diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28841ed3..6779f8c1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,7 +76,7 @@ jobs:
go vet ./...
- name: Test
- run: go test ./...
+ run: go test -timeout 15m ./...
- name: WASM prover builds
run: |
diff --git a/.gitignore b/.gitignore
index 7ffcccf7..c156cff8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,19 +7,22 @@ deployments/reclaim/preprod/*.local.json
*.vk
.playwright-cli/
output/
-# Repo-root tooling (Biome, lefthook)
-/node_modules/
-packages/client-ts/node_modules/
+# Repo-root tooling (Biome, lefthook). No trailing slash: a shared install is
+# often a symlink into a sibling worktree, and a `dir/` pattern matches only
+# real directories, so the slash form leaves the symlink untracked-but-visible
+# and `git add -A` commits a dangling link.
+/node_modules
+packages/client-ts/node_modules
packages/client-ts/.vite/
apps/ownership-proof-web/.next/
-apps/ownership-proof-web/node_modules/
+apps/ownership-proof-web/node_modules
# App uses pnpm-lock.yaml; ignore npm lockfiles from accidental npm install.
apps/ownership-proof-web/package-lock.json
apps/ownership-proof-web/tsconfig.tsbuildinfo
!apps/ownership-proof-web/public/proof-assets/ownership.vk
!apps/ownership-proof-web/public/proof-releases/*/assets/ownership.vk
apps/proof-helper-desktop/dist/
-apps/proof-helper-desktop/node_modules/
+apps/proof-helper-desktop/node_modules
apps/proof-helper-desktop/src-tauri/target/
apps/proof-helper-desktop/src-tauri/*/target/
apps/proof-helper-desktop/src-tauri/binaries/proof-tool-*
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..261eeb9e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..81cda3fa
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,4 @@
+Proof ZK Recovery
+Copyright 2026 Midgard Labs
+
+This product includes software developed by Midgard Labs.
diff --git a/apps/ownership-proof-web/components/ClaimFlow.test.tsx b/apps/ownership-proof-web/components/ClaimFlow.test.tsx
index 792fe400..6867f714 100644
--- a/apps/ownership-proof-web/components/ClaimFlow.test.tsx
+++ b/apps/ownership-proof-web/components/ClaimFlow.test.tsx
@@ -1403,6 +1403,28 @@ describe("ClaimFlow", () => {
expect(screen.getByRole("heading", { name: "Verify this recovery service" })).toBeInTheDocument();
});
+ it("resumes the safe-wallet handoff after refreshing the CIP-30 page bridge", async () => {
+ installWallets({
+ impacted: walletApi({ getChangeAddress: walletAddressHex, getUsedAddresses: [usedWalletAddressHex] }),
+ safe: walletApi({ getChangeAddress: safeWalletAddressHex, getUsedAddresses: [safeWalletAddressHex] }),
+ });
+ vi.stubGlobal("fetch", claimFlowFetch());
+
+ render();
+ await connectImpactedAndContinueToSafeWallet();
+ await waitFor(() => expect(window.localStorage.getItem("proof-tool.claim-flow.resume.v1")).not.toBeNull());
+ const snapshot = JSON.parse(window.localStorage.getItem("proof-tool.claim-flow.resume.v1") ?? "null");
+ expect(snapshot).toMatchObject({ screen: "safe-wallet", safeWallet: null, draft: null });
+ expect(snapshot.claimRows).toHaveLength(1);
+
+ cleanup();
+ render();
+ expect(await screen.findByText("Resume your claim in progress?")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Resume" }));
+
+ expect(await screen.findByRole("heading", { name: "Connect safe wallet" })).toBeInTheDocument();
+ });
+
it("paginates claims beyond page 2 with numbered page buttons", async () => {
const enable = vi.fn().mockResolvedValue({
getNetworkId: vi.fn().mockResolvedValue(0),
@@ -1497,6 +1519,36 @@ describe("ClaimFlow", () => {
expect(await screen.findByRole("heading", { name: "Create proofs" })).toBeInTheDocument();
});
+ it("does not expose safe-wallet confirmation while its claim draft is still pending", async () => {
+ installWallets({
+ impacted: walletApi({ getChangeAddress: walletAddressHex, getUsedAddresses: [usedWalletAddressHex] }),
+ safe: walletApi({ getChangeAddress: safeWalletAddressHex, getUsedAddresses: [safeWalletAddressHex] }),
+ });
+ const draft = claimDraft([`${"a".repeat(64)}#0`]);
+ const base = claimFlowFetch({ draft });
+ let resolveDraft: (response: Response) => void = () => {};
+ const pendingDraft = new Promise((resolve) => {
+ resolveDraft = resolve;
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: RequestInfo | URL, init?: RequestInit) =>
+ String(url) === "/claim-api/draft" ? pendingDraft : base(url, init),
+ ),
+ );
+
+ render();
+
+ await connectImpactedAndContinueToSafeWallet();
+ fireEvent.click(await findSafeWalletOption());
+ fireEvent.click(screen.getByRole("button", { name: "Connect safe wallet" }));
+ await waitFor(() => expect(screen.getByRole("button", { name: "Connect safe wallet" })).toBeInTheDocument());
+ expect(screen.queryByRole("button", { name: "Confirm destination and continue" })).not.toBeInTheDocument();
+
+ await act(async () => resolveDraft(jsonResponse(draft)));
+ expect(await screen.findByRole("button", { name: "Confirm destination and continue" })).toBeInTheDocument();
+ });
+
it("clears the connected safe wallet when choosing a different wallet", async () => {
installWallets({
impacted: walletApi({ getChangeAddress: walletAddressHex, getUsedAddresses: [usedWalletAddressHex] }),
@@ -2283,9 +2335,10 @@ function claimDeployment() {
reclaimGlobalScriptHash: "c".repeat(56),
paramsCurrencySymbol: "d".repeat(56),
paramsTokenName: "",
- verifierVkHash: "e".repeat(64),
+ verifierVkHash: "f".repeat(64),
+ proofVkHash: "e".repeat(64),
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2",
- reclaimGlobalBatchTranscriptVkHash: "e".repeat(64),
+ reclaimGlobalBatchTranscriptVkHash: "f".repeat(64),
contractVersion: "v1",
sourceCommit: "f".repeat(40),
paramsUtxo: {
diff --git a/apps/ownership-proof-web/components/ClaimFlow.tsx b/apps/ownership-proof-web/components/ClaimFlow.tsx
index dee5d182..97e29f66 100644
--- a/apps/ownership-proof-web/components/ClaimFlow.tsx
+++ b/apps/ownership-proof-web/components/ClaimFlow.tsx
@@ -275,11 +275,11 @@ type ClaimFlowResumeSnapshot = {
selectedImpactedWallet: string;
selectedSafeWallet: string;
impactedWallet: ImpactedWalletSummary | null;
- safeWallet: SafeWalletSummary;
+ safeWallet: SafeWalletSummary | null;
claimRows: ClaimRow[];
claimIndexerTotal: number;
pendingOutrefs: string[];
- draft: ClaimDraftResponse;
+ draft: ClaimDraftResponse | null;
proofArtifacts?: Record[];
build?: ClaimBuildResponse | null;
};
@@ -1130,7 +1130,9 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
return;
}
const resumeScreen = resumableClaimScreen(screen);
- if (!resumeScreen || !draft || !safeWallet) {
+ const canResumeSafeWalletHandoff = resumeScreen === "safe-wallet" && impactedWallet && claimRows.length > 0;
+ const canResumeAfterDestination = resumeScreen !== "safe-wallet" && draft && safeWallet;
+ if (!resumeScreen || (!canResumeSafeWalletHandoff && !canResumeAfterDestination)) {
return;
}
writeClaimFlowResumeSnapshot({
@@ -1221,7 +1223,7 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
setHelperError("Proof Helper destination key is not ready.");
return false;
}
- if (deployment?.available && profile.key_hash !== deployment.deployment.verifierVkHash) {
+ if (deployment?.available && profile.key_hash !== deployment.deployment.proofVkHash) {
setHelperState("unavailable");
setHelperError("Proof Helper destination key hash does not match this claim deployment.");
return false;
@@ -1280,7 +1282,7 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
setBrowserProvingStatus("checking");
setBrowserProvingDetail("");
try {
- const check = await checkBrowserProving(browserProvingDescriptor, deployment.deployment.verifierVkHash);
+ const check = await checkBrowserProving(browserProvingDescriptor, deployment.deployment.proofVkHash);
setBrowserProvingStatus(check.status);
setBrowserProvingDetail(
check.status === "ready"
@@ -1679,12 +1681,13 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
safeWalletApiRef.current = api;
setSafeWalletSigningAvailable(true);
setSafeWalletSigningSessionState("ready");
- setSafeWallet(walletSummary);
// Stay on the safe-wallet screen after a successful connect (C17) so the
// user sees the populated destination panel and confirms it explicitly.
- // The draft is still created here; only the auto-advance is removed.
+ // Publish the connected destination only after the draft attempt settles
+ // so an early confirmation cannot race the in-flight draft and then be
+ // overwritten by this connection handler's completion.
await createOrRefreshClaimDraft(walletSummary);
- setScreen((current) => (current === "insufficient-ada" ? current : "safe-wallet"));
+ setSafeWallet(walletSummary);
} catch (error) {
setSafeWallet(null);
safeWalletApiRef.current = null;
@@ -1780,7 +1783,7 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
const runId = ++proofRunIdRef.current;
try {
if (proofMethod === "browser") {
- await generateClaimProofsInBrowser(deployment.deployment.verifierVkHash, runId);
+ await generateClaimProofsInBrowser(deployment.deployment.proofVkHash, runId);
return;
}
@@ -1826,7 +1829,7 @@ export function ClaimFlow({ createWorker = defaultCreateWorker }: ClaimFlowProps
signal: abortController.signal,
onProgress: setProofProgress,
});
- const artifacts = validateDestinationProofResponse(helperResponse, draft, deployment.deployment.verifierVkHash);
+ const artifacts = validateDestinationProofResponse(helperResponse, draft, deployment.deployment.proofVkHash);
applyProofRunSuccess(runId, artifacts);
} catch (error) {
if (error instanceof DesktopHelperCancelledError) {
@@ -6896,11 +6899,11 @@ function readClaimFlowResumeSnapshot(): ClaimFlowResumeSnapshot | null {
!resumableClaimScreen(parsed.screen) ||
typeof parsed.selectedImpactedWallet !== "string" ||
typeof parsed.selectedSafeWallet !== "string" ||
- !parsed.safeWallet ||
- !parsed.draft ||
+ !parsed.impactedWallet ||
!Array.isArray(parsed.claimRows) ||
!Array.isArray(parsed.pendingOutrefs) ||
- typeof parsed.claimIndexerTotal !== "number"
+ typeof parsed.claimIndexerTotal !== "number" ||
+ (parsed.screen !== "safe-wallet" && (!parsed.safeWallet || !parsed.draft))
) {
return null;
}
@@ -6912,6 +6915,8 @@ function readClaimFlowResumeSnapshot(): ClaimFlowResumeSnapshot | null {
function resumableClaimScreen(screen: ClaimScreen): ClaimScreen | null {
switch (screen) {
+ case "safe-wallet":
+ return screen;
case "helper-unavailable":
case "create-proofs-generating":
case "create-proofs-complete":
diff --git a/apps/ownership-proof-web/components/ReclaimFundingFlow.test.tsx b/apps/ownership-proof-web/components/ReclaimFundingFlow.test.tsx
index 13985bab..02593a75 100644
--- a/apps/ownership-proof-web/components/ReclaimFundingFlow.test.tsx
+++ b/apps/ownership-proof-web/components/ReclaimFundingFlow.test.tsx
@@ -401,6 +401,7 @@ function deployment() {
paramsCurrencySymbol: "params-policy",
paramsTokenName: "params-token",
verifierVkHash: "vk-hash",
+ proofVkHash: "native-vk-hash",
contractVersion: "v1",
sourceCommit: "commit",
};
diff --git a/apps/ownership-proof-web/components/ReclaimFundingFlow.tsx b/apps/ownership-proof-web/components/ReclaimFundingFlow.tsx
index 6dc76bd1..f8a852f7 100644
--- a/apps/ownership-proof-web/components/ReclaimFundingFlow.tsx
+++ b/apps/ownership-proof-web/components/ReclaimFundingFlow.tsx
@@ -1015,6 +1015,7 @@ function fixtureDeployment(): DeploymentResponse {
paramsCurrencySymbol: "params-policy",
paramsTokenName: "params-token",
verifierVkHash: "vk-hash",
+ proofVkHash: "native-vk-hash",
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2",
reclaimGlobalBatchTranscriptVkHash: "vk-hash",
contractVersion: "v1",
diff --git a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs
new file mode 100644
index 00000000..19174979
--- /dev/null
+++ b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs
@@ -0,0 +1,1383 @@
+#!/usr/bin/env node
+
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import {
+ chmodSync,
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ realpathSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { promisify } from "node:util";
+import {
+ Constr,
+ Data,
+ credentialToRewardAddress,
+ mintingPolicyToId,
+ scriptHashToCredential,
+ validatorToAddress,
+ validatorToScriptHash,
+} from "@lucid-evolution/lucid";
+import { blake2b } from "@noble/hashes/blake2b";
+import { assertReclaimGlobalProofSlotEncoding, reclaimGlobalExportArgs } from "../preprod/deploy-reclaim-preprod.mjs";
+
+const execFileAsync = promisify(execFile);
+
+export const MAINNET_PREPARATION_SCHEMA = "proof-tool-reclaim-mainnet-deployment-preparation-v1";
+export const UNSIGNED_MANIFEST_FILENAME = "reclaim-deployment.unsigned-template.json";
+export const DEPLOYMENT_PLAN_FILENAME = "deployment-plan.json";
+
+const NETWORK = "Mainnet";
+const NETWORK_ID = 1;
+const KEY_VERSION = "ownership-destination-v2";
+const CIRCUIT_ID = "root-ownership-destination-v2/bls12-381/groth16";
+const CURVE = "BLS12-381";
+const BACKEND = "groth16";
+const CARDANO_VK_FORMAT = "groth16-bls12-381-bsb22";
+const CARDANO_VK_BYTES = 672;
+const CARDANO_PROOF_BYTES = 336;
+const PUBLIC_EVIDENCE_FIXTURE = "repository-golden-destination-v2";
+const PUBLIC_INPUT_DOMAIN = "ROOT-OWNERSHIP-DESTINATION-v1";
+const GOLDEN_PUBLIC_CREDENTIAL = "19e07fbcc7577359d6c51f1e49cf1b0bf4c943b48ba4e4905a8702e4";
+const GOLDEN_PUBLIC_DESTINATION =
+ "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111" +
+ "0000000000000000000000000000000000000000000000000000000000";
+const PARAMS_TOKEN_NAME = "5245434c41494d504152414d53";
+const PROOF_SLOT_ENCODING = "full-proof-plus-public-input-digest-v2";
+const BATCH_TRANSCRIPT = "statement-bound-v2";
+const MAX_RELEASE_METADATA_BYTES = 8 * 1024 * 1024;
+const MAX_NATIVE_VK_BYTES = 64 * 1024 * 1024;
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const REPO_ROOT = path.resolve(__dirname, "../../../..");
+const CONTRACT_DIR = path.join(REPO_ROOT, "contracts", "ownership-verifier");
+
+export class MainnetPreparationError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = "MainnetPreparationError";
+ this.code = code;
+ }
+}
+
+export async function prepareReclaimMainnet(options = {}) {
+ const repoRoot = path.resolve(options.repoRoot ?? REPO_ROOT);
+ const normalized = validatePreparationOptions(options, repoRoot);
+ const assertCleanSignedSourceFn = options.assertCleanSignedSourceFn ?? assertCleanSignedSource;
+ const verifyMPCReleaseFn = options.verifyMPCReleaseFn ?? verifyMPCRelease;
+ const inspectMPCReleaseFn = options.inspectMPCReleaseFn ?? inspectMPCRelease;
+ const exportScriptsFn = options.exportScriptsFn ?? exportDeploymentScripts;
+ const verifyProductionDecisionFn = options.verifyProductionDecisionFn ?? verifyProductionDecision;
+ const publishFn = options.publishFn ?? publishPreparation;
+ const trustedInputs = trustedInputSnapshot(normalized);
+
+ const source = await assertCleanSignedSourceFn(repoRoot, normalized.sourceSignedTag);
+ const releaseVerification = await verifyMPCReleaseFn({
+ ...normalized,
+ repoRoot,
+ });
+ const release = inspectMPCReleaseFn({
+ releaseDir: normalized.releaseDir,
+ ceremonyPath: normalized.ceremonyPath,
+ expectedSourceCommit: source.commit,
+ expectedSignatureKeyID: normalized.releaseSignatureKeyID,
+ expectedCeremonyID: releaseVerification.ceremonyID,
+ });
+ const scripts = await exportScriptsFn({
+ repoRoot,
+ contractDir: path.join(repoRoot, "contracts", "ownership-verifier"),
+ seedOutRef: normalized.seedOutRef,
+ cardanoVKHex: release.cardanoVKHex,
+ cardanoVKBlake2b256: release.cardanoVKBlake2b256,
+ });
+
+ const decision = await verifyProductionDecisionFn({
+ ...normalized,
+ repoRoot,
+ expectedCeremonyID: release.ceremonyID,
+ expectedCandidateID: release.candidateID,
+ expectedReleaseManifestSHA256: release.releaseManifestSHA256,
+ expectedSourceCommit: source.commit,
+ expectedSignedTag: source.signedTag,
+ });
+
+ // Re-run the exact release verifier immediately before publication. This is
+ // deliberately expensive: the locally written plan must not be based on a
+ // release that changed after the first verification and script export.
+ const finalVerification = await verifyMPCReleaseFn({
+ ...normalized,
+ repoRoot,
+ });
+ if (finalVerification.ceremonyID !== release.ceremonyID) {
+ throw new MainnetPreparationError(
+ "mpc_release_changed",
+ "The exact MPC release identity changed during Mainnet preparation.",
+ );
+ }
+ assertReleasePlanningSnapshot(normalized.releaseDir, release.snapshot);
+ const finalSource = await assertCleanSignedSourceFn(repoRoot, normalized.sourceSignedTag);
+ if (finalSource.commit !== source.commit || finalSource.signedTag !== source.signedTag) {
+ throw new MainnetPreparationError(
+ "source_changed",
+ "The clean signed source identity changed during Mainnet preparation.",
+ );
+ }
+ assertTrustedInputSnapshot(normalized, trustedInputs);
+ const finalDecision = await verifyProductionDecisionFn({
+ ...normalized,
+ repoRoot,
+ expectedCeremonyID: release.ceremonyID,
+ expectedCandidateID: release.candidateID,
+ expectedReleaseManifestSHA256: release.releaseManifestSHA256,
+ expectedSourceCommit: source.commit,
+ expectedSignedTag: source.signedTag,
+ });
+ if (JSON.stringify(finalDecision) !== JSON.stringify(decision)) {
+ throw new MainnetPreparationError(
+ "production_decision_changed",
+ "The canonical production GO decision changed during preparation.",
+ );
+ }
+ const artifacts = buildPreparationArtifacts({
+ source,
+ release,
+ scripts,
+ decision: finalDecision,
+ seedOutRef: normalized.seedOutRef,
+ });
+
+ const published = publishFn(normalized.outDir, artifacts);
+ return {
+ ok: true,
+ dryRun: true,
+ network: NETWORK,
+ networkId: NETWORK_ID,
+ submitted: false,
+ unsigned: true,
+ sourceCommit: source.commit,
+ ceremonyId: release.ceremonyID,
+ candidateId: release.candidateID,
+ nativeVkBlake2b256: release.nativeVKBlake2b256,
+ cardanoVkBlake2b256: release.cardanoVKBlake2b256,
+ paramsPolicyId: scripts.paramsPolicyID,
+ reclaimGlobalScriptHash: scripts.reclaimGlobalScriptHash,
+ reclaimBaseScriptHash: scripts.reclaimBaseScriptHash,
+ decisionId: finalDecision.decisionID,
+ releaseId: finalDecision.releaseID,
+ outputs: published,
+ };
+}
+
+export function validatePreparationOptions(options, repoRoot = REPO_ROOT) {
+ if (options.dryRun !== true) {
+ throw new MainnetPreparationError("dry_run_required", "Mainnet preparation requires the explicit --dry-run guard.");
+ }
+ if (options.network !== NETWORK || Number(options.networkId) !== NETWORK_ID) {
+ throw new MainnetPreparationError(
+ "mainnet_identity_required",
+ "Mainnet preparation requires network Mainnet and network_id 1.",
+ );
+ }
+ const required = {
+ sourceSignedTag: "--source-signed-tag",
+ mpcCeremonyBin: "--mpc-ceremony-bin",
+ ceremonyPath: "--ceremony",
+ ceremonySignaturePath: "--ceremony-signature",
+ coordinatorPublicKeyPath: "--coordinator-public-key-file",
+ releaseDir: "--release-dir",
+ releasePublicKeyPath: "--release-public-key-file",
+ releaseSignatureKeyID: "--release-signature-key-id",
+ decisionRecordPath: "--production-decision",
+ decisionEvidenceRoot: "--decision-evidence-root",
+ outDir: "--out-dir",
+ };
+ const normalized = {};
+ for (const [field, flag] of Object.entries(required)) {
+ const value = typeof options[field] === "string" ? options[field].trim() : "";
+ if (!value) {
+ throw new MainnetPreparationError("required_option_missing", `${flag} is required.`);
+ }
+ normalized[field] = field.endsWith("ID") || field === "sourceSignedTag" ? value : resolvePath(repoRoot, value);
+ }
+ normalized.sourceSignedTag = options.sourceSignedTag.trim();
+ normalized.releaseSignatureKeyID = options.releaseSignatureKeyID.trim();
+ if (!Array.isArray(options.decisionSignaturePaths) || options.decisionSignaturePaths.length !== 4) {
+ throw new MainnetPreparationError(
+ "decision_signature_count_invalid",
+ "--decision-signature must be supplied exactly four times.",
+ );
+ }
+ normalized.decisionSignaturePaths = options.decisionSignaturePaths.map((value) => {
+ const candidate = String(value).trim();
+ if (!candidate) {
+ throw new MainnetPreparationError("decision_signature_invalid", "--decision-signature paths must be non-empty.");
+ }
+ return resolvePath(repoRoot, candidate);
+ });
+ if (new Set(normalized.decisionSignaturePaths).size !== 4) {
+ throw new MainnetPreparationError(
+ "decision_signature_duplicate",
+ "The four production decision signature paths must be distinct.",
+ );
+ }
+ normalized.seedOutRef = parseSeedOutRef(options.seedOutRef);
+
+ requireRegularNoSymlink(normalized.mpcCeremonyBin, "MPC ceremony binary");
+ if ((statSync(normalized.mpcCeremonyBin).mode & 0o111) === 0) {
+ throw new MainnetPreparationError("mpc_binary_not_executable", "MPC ceremony binary must be executable.");
+ }
+ requireRealDirectory(normalized.releaseDir, "MPC release directory");
+ requireRealDirectory(normalized.decisionEvidenceRoot, "production decision evidence root");
+ for (const [file, label] of [
+ [normalized.ceremonyPath, "ceremony definition"],
+ [normalized.ceremonySignaturePath, "ceremony definition signature"],
+ [normalized.coordinatorPublicKeyPath, "coordinator public key"],
+ [normalized.releasePublicKeyPath, "release public key"],
+ [normalized.decisionRecordPath, "production decision record"],
+ ...normalized.decisionSignaturePaths.map((file, index) => [file, `production decision signature ${index + 1}`]),
+ ]) {
+ requireRegularNoSymlink(file, label);
+ }
+ requireExternalTrustAnchor(normalized.releasePublicKeyPath, normalized.releaseDir, "release public key");
+ requirePathOutside(normalized.outDir, normalized.releaseDir, "output directory", "MPC release directory");
+ if (existsSync(normalized.outDir) || isSymlink(normalized.outDir)) {
+ throw new MainnetPreparationError("output_exists", "Mainnet preparation output directory must not already exist.");
+ }
+ requireRealDirectory(path.dirname(normalized.outDir), "output parent");
+ return normalized;
+}
+
+export async function assertCleanSignedSource(repoRoot, signedTag) {
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/+@-]{0,159}$/u.test(signedTag)) {
+ throw new MainnetPreparationError("source_tag_invalid", "Source signed tag has an unsafe or invalid name.");
+ }
+ const status = (await execGit(repoRoot, ["status", "--porcelain", "--untracked-files=all"])).trim();
+ if (status) {
+ throw new MainnetPreparationError(
+ "source_not_clean",
+ "The Mainnet preparation source checkout must be completely clean.",
+ );
+ }
+ const commit = (await execGit(repoRoot, ["rev-parse", "--verify", "HEAD"])).trim().toLowerCase();
+ if (!/^[0-9a-f]{40}$/u.test(commit)) {
+ throw new MainnetPreparationError("source_commit_invalid", "HEAD is not an exact 40-character commit.");
+ }
+ const tagType = (await execGit(repoRoot, ["cat-file", "-t", signedTag])).trim();
+ if (tagType !== "tag") {
+ throw new MainnetPreparationError(
+ "source_tag_not_annotated",
+ "The source release tag must be an annotated, signed Git tag.",
+ );
+ }
+ const tagCommit = (await execGit(repoRoot, ["rev-list", "-n", "1", signedTag])).trim().toLowerCase();
+ if (tagCommit !== commit) {
+ throw new MainnetPreparationError(
+ "source_tag_commit_mismatch",
+ "The signed source tag does not resolve to the clean checked-out commit.",
+ );
+ }
+ try {
+ await execGit(repoRoot, ["verify-tag", signedTag]);
+ } catch {
+ throw new MainnetPreparationError(
+ "source_tag_signature_invalid",
+ "Git did not verify the source release tag signature.",
+ );
+ }
+ return { commit, signedTag };
+}
+
+export async function verifyMPCRelease(options) {
+ const args = [
+ "--format",
+ "json",
+ "--quiet",
+ "release",
+ "verify",
+ "--ceremony",
+ options.ceremonyPath,
+ "--ceremony-signature",
+ options.ceremonySignaturePath,
+ "--coordinator-public-key-file",
+ options.coordinatorPublicKeyPath,
+ "--keys-dir",
+ options.releaseDir,
+ "--manifest-public-key-file",
+ options.releasePublicKeyPath,
+ "--signature-key-id",
+ options.releaseSignatureKeyID,
+ ];
+ let stdout;
+ try {
+ ({ stdout } = await execFileAsync(options.mpcCeremonyBin, args, {
+ cwd: options.repoRoot,
+ maxBuffer: 64 * 1024 * 1024,
+ env: minimalChildEnvironment(),
+ }));
+ } catch {
+ throw new MainnetPreparationError(
+ "mpc_release_verification_failed",
+ "Exact signed MPC release verification failed.",
+ );
+ }
+ let result;
+ try {
+ result = JSON.parse(stdout.trim());
+ } catch {
+ throw new MainnetPreparationError(
+ "mpc_release_verification_malformed",
+ "MPC release verification did not emit one valid JSON result.",
+ );
+ }
+ if (
+ result?.schema !== "proof-tool-mpc-command-result-v1" ||
+ result.ok !== true ||
+ result.command !== "release verify" ||
+ !/^sha256:[0-9a-f]{64}$/u.test(result.ceremony_id ?? "")
+ ) {
+ throw new MainnetPreparationError(
+ "mpc_release_verification_rejected",
+ "MPC release verification did not return an exact successful release result.",
+ );
+ }
+ return { ceremonyID: result.ceremony_id };
+}
+
+export async function verifyProductionDecision(options) {
+ const args = [
+ "--format",
+ "json",
+ "--quiet",
+ "decision",
+ "verify",
+ "--ceremony",
+ options.ceremonyPath,
+ "--ceremony-signature",
+ options.ceremonySignaturePath,
+ "--coordinator-public-key-file",
+ options.coordinatorPublicKeyPath,
+ "--decision",
+ options.decisionRecordPath,
+ ];
+ for (const signature of options.decisionSignaturePaths) {
+ args.push("--signature", signature);
+ }
+ args.push("--evidence-root", options.decisionEvidenceRoot);
+ let stdout;
+ try {
+ ({ stdout } = await execFileAsync(options.mpcCeremonyBin, args, {
+ cwd: options.repoRoot,
+ maxBuffer: 64 * 1024 * 1024,
+ env: minimalChildEnvironment(),
+ }));
+ } catch {
+ throw new MainnetPreparationError(
+ "production_decision_verification_failed",
+ "Canonical production GO decision verification failed.",
+ );
+ }
+ let result;
+ try {
+ result = JSON.parse(stdout.trim());
+ } catch {
+ throw new MainnetPreparationError(
+ "production_decision_verification_malformed",
+ "Production decision verification did not emit one valid JSON result.",
+ );
+ }
+ if (
+ result?.schema !== "proof-tool-mpc-command-result-v1" ||
+ result.ok !== true ||
+ result.command !== "decision verify" ||
+ result.decision !== "GO" ||
+ !/^sha256:[0-9a-f]{64}$/u.test(result.decision_id ?? "") ||
+ !/^sha256:[0-9a-f]{64}$/u.test(result.release_id ?? "")
+ ) {
+ throw new MainnetPreparationError(
+ "production_decision_rejected",
+ "Production decision verification did not return an exact successful GO result.",
+ );
+ }
+ exact(result.ceremony_id, options.expectedCeremonyID, "production decision ceremony id");
+ exact(result.candidate_id, options.expectedCandidateID, "production decision candidate id");
+ exact(result.source_commit, options.expectedSourceCommit, "production decision source commit");
+ exact(result.source_signed_tag, options.expectedSignedTag, "production decision signed source tag");
+
+ const decision = assertProductionDecisionRecordBinding({
+ decisionBytes: readRegularAbsolute(
+ options.decisionRecordPath,
+ "production decision record",
+ MAX_RELEASE_METADATA_BYTES,
+ ),
+ result,
+ expectedReleaseManifestSHA256: options.expectedReleaseManifestSHA256,
+ });
+ const tag = await inspectSignedTagProvenance(options.repoRoot, options.expectedSignedTag);
+ exact(
+ result.source_tag_signer_fingerprint,
+ tag.signerFingerprint,
+ "production decision source-tag signer fingerprint",
+ );
+ exact(result.source_tag_object_sha256, tag.objectSHA256, "production decision signed tag object digest");
+ return {
+ decisionID: result.decision_id,
+ releaseID: result.release_id,
+ ceremonyID: result.ceremony_id,
+ candidateID: result.candidate_id,
+ sourceCommit: result.source_commit,
+ sourceSignedTag: result.source_signed_tag,
+ sourceTagSignerFingerprint: result.source_tag_signer_fingerprint,
+ sourceTagObjectSHA256: result.source_tag_object_sha256,
+ };
+}
+
+export function assertProductionDecisionRecordBinding({ decisionBytes, result, expectedReleaseManifestSHA256 }) {
+ const decision = parseJSON(decisionBytes, "production decision record");
+ exact(decision.schema, "proof-tool-mpc-production-decision-v1", "production decision schema");
+ exact(decision.decision_id, result.decision_id, "production decision id");
+ exact(decision.decision, "GO", "production decision outcome");
+ exact(decision.ceremony_id, result.ceremony_id, "production decision record ceremony id");
+ exact(decision.release?.release_id, result.release_id, "production decision record release id");
+ exact(decision.release?.candidate_id, result.candidate_id, "production decision record candidate id");
+ exact(
+ path.posix.basename(decision.release?.manifest?.artifact?.name ?? ""),
+ "manifest.json",
+ "production decision release manifest filename",
+ );
+ exact(
+ decision.release?.manifest?.artifact?.digest?.sha256,
+ expectedReleaseManifestSHA256,
+ "production decision exact release manifest digest",
+ );
+ exact(decision.source_release?.source_commit, result.source_commit, "production decision record source commit");
+ exact(decision.source_release?.signed_tag, result.source_signed_tag, "production decision record signed source tag");
+ exact(
+ decision.source_release?.signer_fingerprint_hex,
+ result.source_tag_signer_fingerprint,
+ "production decision record source-tag signer fingerprint",
+ );
+ exact(
+ decision.source_release?.signed_tag_object?.artifact?.digest?.sha256,
+ result.source_tag_object_sha256,
+ "production decision record signed tag object digest",
+ );
+ return decision;
+}
+
+async function inspectSignedTagProvenance(repoRoot, signedTag) {
+ let fingerprint;
+ try {
+ fingerprint = (await execGit(repoRoot, ["verify-tag", "--format=%GF", signedTag])).trim().toLowerCase();
+ } catch {
+ throw new MainnetPreparationError(
+ "source_tag_signature_invalid",
+ "Git did not verify the pinned source tag signature.",
+ );
+ }
+ if (!/^[0-9a-f]{40}$/u.test(fingerprint)) {
+ throw new MainnetPreparationError(
+ "source_tag_fingerprint_invalid",
+ "The verified source tag did not expose one full OpenPGP primary-key fingerprint.",
+ );
+ }
+ let tagObject;
+ try {
+ ({ stdout: tagObject } = await execFileAsync("git", ["cat-file", "tag", signedTag], {
+ cwd: repoRoot,
+ encoding: "buffer",
+ maxBuffer: 16 * 1024 * 1024,
+ env: minimalChildEnvironment(),
+ }));
+ } catch {
+ throw new MainnetPreparationError(
+ "source_tag_object_invalid",
+ "The exact annotated signed tag object could not be read.",
+ );
+ }
+ return {
+ signerFingerprint: fingerprint,
+ objectSHA256: `sha256:${createHash("sha256").update(tagObject).digest("hex")}`,
+ };
+}
+
+export function inspectMPCRelease({
+ releaseDir,
+ ceremonyPath,
+ expectedSourceCommit,
+ expectedSignatureKeyID,
+ expectedCeremonyID,
+}) {
+ const manifestBytes = readRegular(releaseDir, "manifest.json", MAX_RELEASE_METADATA_BYTES);
+ const candidateBytes = readRegular(releaseDir, "candidate.json", MAX_RELEASE_METADATA_BYTES);
+ const transcriptBytes = readRegular(releaseDir, "setup-transcript.json", MAX_RELEASE_METADATA_BYTES);
+ const reportBytes = readRegular(releaseDir, "verification-report.json", MAX_RELEASE_METADATA_BYTES);
+ const publicEvidenceBytes = readRegular(releaseDir, "public-finalization-evidence.json", MAX_RELEASE_METADATA_BYTES);
+ const nativeVK = readRegular(releaseDir, "ownership.vk", MAX_NATIVE_VK_BYTES);
+ const cardanoVK = readRegular(releaseDir, "cardano-vk.bin", CARDANO_VK_BYTES);
+ const cardanoVKHexBytes = readRegular(releaseDir, "cardano-vk.hex", CARDANO_VK_BYTES * 2 + 2);
+ const cardanoVKFormatBytes = readRegular(releaseDir, "cardano-vk-format.txt", 128);
+ const ceremonyBytes = readRegularAbsolute(ceremonyPath, "ceremony definition", MAX_RELEASE_METADATA_BYTES);
+ const manifest = parseJSON(manifestBytes, "MPC release manifest");
+ const candidate = parseJSON(candidateBytes, "MPC release candidate");
+ const transcript = parseJSON(transcriptBytes, "MPC final transcript");
+ const report = parseJSON(reportBytes, "MPC verification report");
+ const publicEvidence = parseJSON(publicEvidenceBytes, "MPC public finalization evidence");
+ const ceremony = parseJSON(ceremonyBytes, "MPC ceremony definition");
+
+ exact(manifest.schema, "proof-tool-key-manifest-v1", "release manifest schema");
+ exact(manifest.key_version, KEY_VERSION, "release key version");
+ exact(manifest.circuit_id, CIRCUIT_ID, "release circuit id");
+ exact(manifest.curve, CURVE, "release curve");
+ exact(manifest.backend, BACKEND, "release backend");
+ exact(manifest.circuit_source_commit, expectedSourceCommit, "release source commit");
+ exact(manifest.signature_key_id, expectedSignatureKeyID, "release signature key id");
+
+ exact(candidate.schema, "proof-tool-mpc-release-candidate-v2", "candidate schema");
+ if (!/^sha256:[0-9a-f]{64}$/u.test(candidate.candidate_id ?? "")) {
+ throw new MainnetPreparationError("coherence_mismatch", "candidate id is not an exact SHA-256 identity.");
+ }
+ exact(candidate.ceremony_id, expectedCeremonyID, "candidate ceremony id");
+ exact(candidate.circuit?.key_version, KEY_VERSION, "candidate key version");
+ exact(candidate.circuit?.circuit_id, CIRCUIT_ID, "candidate circuit id");
+ exact(candidate.circuit?.curve, CURVE, "candidate curve");
+ exact(candidate.circuit?.backend, BACKEND, "candidate backend");
+ exact(transcript.schema, "proof-tool-mpc-final-transcript-v1", "final transcript schema");
+ exact(transcript.ceremony_id, expectedCeremonyID, "final transcript ceremony id");
+ if (!Array.isArray(transcript.audits) || transcript.audits.length < 2) {
+ throw new MainnetPreparationError(
+ "independent_audits_missing",
+ "MPC final transcript must bind at least two independent audits.",
+ );
+ }
+ exact(ceremony.schema, "proof-tool-mpc-ceremony-definition-v1", "ceremony definition schema");
+ exact(ceremony.ceremony_id, expectedCeremonyID, "ceremony definition id");
+ exact(ceremony.mode, "production", "ceremony mode");
+ exact(ceremony.software?.source_commit, expectedSourceCommit, "ceremony source commit");
+ exact(ceremony.software?.source_dirty, false, "ceremony clean-source flag");
+ exact(ceremony.release_signer?.key_id, expectedSignatureKeyID, "ceremony release signer key id");
+
+ exact(report.schema, "proof-tool-mpc-verification-report-v2", "verification report schema");
+ exact(report.ceremony_id, expectedCeremonyID, "verification report ceremony id");
+ exact(report.fixture, PUBLIC_EVIDENCE_FIXTURE, "verification report public fixture");
+ exact(report.native_proof_verified, true, "native positive proof evidence");
+ exact(report.wrong_credential_rejected, true, "negative credential proof evidence");
+ exact(report.wrong_destination_rejected, true, "negative destination proof evidence");
+ exact(report.wrong_digest_rejected, true, "negative public-input digest evidence");
+ exact(report.wrong_proof_rejected, true, "negative proof evidence");
+ exact(report.wrong_vk_rejected, true, "negative verifying-key evidence");
+ exact(report.proof_truncation_rejected, true, "proof truncation evidence");
+ exact(report.proof_append_rejected, true, "proof append evidence");
+ exact(report.cardano_proof_format, CARDANO_VK_FORMAT, "verification report Cardano proof format");
+ exact(report.cardano_proof_bytes, CARDANO_PROOF_BYTES, "verification report Cardano proof size");
+ exact(report.cardano_vk_format, CARDANO_VK_FORMAT, "verification report Cardano VK format");
+ exact(report.cardano_vk_bytes, CARDANO_VK_BYTES, "verification report Cardano VK size");
+ exact(publicEvidence.schema, "proof-tool-mpc-public-finalization-evidence-v1", "public evidence schema");
+ exact(publicEvidence.ceremony_id, expectedCeremonyID, "public evidence ceremony id");
+ exact(publicEvidence.fixture, PUBLIC_EVIDENCE_FIXTURE, "public evidence fixture");
+ exact(publicEvidence.credential_hex, GOLDEN_PUBLIC_CREDENTIAL, "public evidence credential");
+ exact(publicEvidence.destination_hex, GOLDEN_PUBLIC_DESTINATION, "public evidence destination");
+ exact(publicEvidence.cardano_proof_format, CARDANO_VK_FORMAT, "public evidence Cardano proof format");
+ if (cardanoVK.length !== CARDANO_VK_BYTES) {
+ throw new MainnetPreparationError(
+ "cardano_vk_size_invalid",
+ `Cardano verifier key is ${cardanoVK.length} bytes, want ${CARDANO_VK_BYTES}.`,
+ );
+ }
+ const cardanoVKHex = cardanoVKHexBytes.toString("utf8").trim();
+ if (!/^[0-9a-f]{1344}$/u.test(cardanoVKHex) || !Buffer.from(cardanoVKHex, "hex").equals(cardanoVK)) {
+ throw new MainnetPreparationError(
+ "cardano_vk_hex_mismatch",
+ "Cardano verifier-key hex does not exactly encode cardano-vk.bin.",
+ );
+ }
+ exact(cardanoVKFormatBytes.toString("utf8").trim(), CARDANO_VK_FORMAT, "Cardano VK format file");
+
+ const nativeVKDigest = digest(nativeVK);
+ const cardanoVKDigest = digest(cardanoVK);
+ const reportDigest = digest(reportBytes);
+ const publicEvidenceDigest = digest(publicEvidenceBytes);
+ const cardanoProof = decodeExactHex(
+ publicEvidence.cardano_proof_hex,
+ CARDANO_PROOF_BYTES,
+ "public evidence Cardano proof",
+ );
+ const credential = decodeExactHex(publicEvidence.credential_hex, 28, "public evidence credential");
+ const destination = decodeExactHex(publicEvidence.destination_hex, 58, "public evidence destination");
+ const expectedPublicInputDigest = Buffer.from(
+ blake2b(Uint8Array.from(Buffer.concat([Buffer.from(PUBLIC_INPUT_DOMAIN, "utf8"), credential, destination])), {
+ dkLen: 32,
+ }),
+ ).toString("hex");
+ exact(publicEvidence.public_input_digest_hex, expectedPublicInputDigest, "public evidence public-input digest");
+ assertArtifactDigest(candidate.verifying_key, "ownership.vk", nativeVKDigest, "candidate native VK");
+ assertArtifactDigest(candidate.cardano_verifying_key, "cardano-vk.bin", cardanoVKDigest, "candidate Cardano VK");
+ assertArtifactDigest(
+ candidate.verification_report,
+ "verification-report.json",
+ reportDigest,
+ "candidate verification report",
+ );
+ assertArtifactDigest(
+ candidate.public_finalization_evidence,
+ "public-finalization-evidence.json",
+ publicEvidenceDigest,
+ "candidate public evidence",
+ );
+ assertArtifactDigest(
+ report.public_evidence,
+ "public-finalization-evidence.json",
+ publicEvidenceDigest,
+ "verification report public evidence",
+ );
+ assertArtifactDigest(
+ publicEvidence.cardano_verifying_key,
+ "cardano-vk.bin",
+ cardanoVKDigest,
+ "public evidence Cardano VK",
+ );
+ assertArtifactDigest(transcript.verifying_key, "ownership.vk", nativeVKDigest, "transcript native VK");
+ assertArtifactDigest(transcript.cardano_verifying_key, "cardano-vk.bin", cardanoVKDigest, "transcript Cardano VK");
+ exact(manifest.vk_hash, nativeVKDigest.blake2b256, "manifest native VK hash");
+ assertExactDigest(report.cardano_vk_raw_digest, cardanoVKDigest, "verification report Cardano VK");
+ assertExactDigest(report.cardano_proof_raw_digest, digest(cardanoProof), "verification report Cardano proof");
+ assertExactDigest(publicEvidence.cardano_proof_raw_digest, digest(cardanoProof), "public evidence Cardano proof");
+ if (nativeVKDigest.blake2b256 === cardanoVKDigest.blake2b256) {
+ throw new MainnetPreparationError(
+ "vk_semantics_ambiguous",
+ "Native gnark and Cardano wire-format verifier-key hashes must remain distinct.",
+ );
+ }
+ const snapshot = planningSnapshot(releaseDir);
+ return {
+ ceremonyID: expectedCeremonyID,
+ candidateID: candidate.candidate_id,
+ releaseManifestSHA256: `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`,
+ candidateSchema: candidate.schema,
+ verificationReportSchema: report.schema,
+ verificationReportSHA256: reportDigest.sha256,
+ publicEvidenceSchema: publicEvidence.schema,
+ publicEvidenceSHA256: publicEvidenceDigest.sha256,
+ cardanoProofBlake2b256: digest(cardanoProof).blake2b256,
+ nativeVKBlake2b256: nativeVKDigest.blake2b256,
+ cardanoVKBlake2b256: cardanoVKDigest.blake2b256,
+ cardanoVKHex,
+ manifest,
+ candidate,
+ transcript,
+ snapshot,
+ };
+}
+
+export async function exportDeploymentScripts({
+ contractDir = CONTRACT_DIR,
+ seedOutRef,
+ cardanoVKHex,
+ cardanoVKBlake2b256,
+}) {
+ const exportScript = async (mode, ...args) => {
+ let stdout;
+ try {
+ ({ stdout } = await execFileAsync(
+ "cabal",
+ ["v2-run", "--offline", "reclaim-scripts-export", "--", mode, ...args],
+ {
+ cwd: contractDir,
+ maxBuffer: 256 * 1024 * 1024,
+ env: minimalChildEnvironment(),
+ },
+ ));
+ } catch {
+ throw new MainnetPreparationError("script_export_failed", `Offline ${mode} script export failed.`);
+ }
+ return parseScriptExport(stdout, mode);
+ };
+
+ const oneShot = await exportScript("one-shot", seedOutRef.txHash, String(seedOutRef.outputIndex));
+ const paramsPolicyID = mintingPolicyToId(oneShot).toLowerCase();
+ const global = await exportScript(
+ ...reclaimGlobalExportArgs("global-v2", paramsPolicyID, cardanoVKHex, normalizeBlake2b256(cardanoVKBlake2b256)),
+ );
+ assertReclaimGlobalProofSlotEncoding(
+ global.proofSlotEncoding,
+ global.batchTranscript,
+ global.verifierVKHash,
+ cardanoVKBlake2b256,
+ );
+ const reclaimGlobalScriptHash = validatorToScriptHash(global).toLowerCase();
+ const base = await exportScript("base", reclaimGlobalScriptHash);
+ const reclaimBaseScriptHash = validatorToScriptHash(base).toLowerCase();
+ const holder = await exportScript("params-holder");
+ const paramsHolderScriptHash = validatorToScriptHash(holder).toLowerCase();
+
+ return {
+ paramsPolicyID,
+ paramsTokenName: PARAMS_TOKEN_NAME,
+ paramsUnit: `${paramsPolicyID}${PARAMS_TOKEN_NAME}`,
+ oneShot,
+ global,
+ base,
+ holder,
+ reclaimGlobalScriptHash,
+ reclaimBaseScriptHash,
+ paramsHolderScriptHash,
+ reclaimBaseAddress: validatorToAddress(NETWORK, base),
+ paramsHolderAddress: validatorToAddress(NETWORK, holder),
+ reclaimGlobalRewardAddress: credentialToRewardAddress(NETWORK, scriptHashToCredential(reclaimGlobalScriptHash)),
+ };
+}
+
+export function buildPreparationArtifacts({ source, release, scripts, decision, seedOutRef }) {
+ const paramsDatum = Data.to(new Constr(0, [scripts.reclaimBaseScriptHash]));
+ const voidDatum = Data.void();
+ const manifest = {
+ schema: "proof-tool-reclaim-deployment-v1",
+ deployment_id: `mainnet:${scripts.reclaimBaseScriptHash}:${source.commit}`,
+ network: NETWORK,
+ network_id: NETWORK_ID,
+ source_commit: source.commit,
+ contract_version: "ownership-verifier-0.1.0.0",
+ reclaim_base: {
+ address: scripts.reclaimBaseAddress,
+ script_hash: scripts.reclaimBaseScriptHash,
+ required_global_credential: scripts.reclaimGlobalScriptHash,
+ },
+ reclaim_global: {
+ script_hash: scripts.reclaimGlobalScriptHash,
+ rewarding_credential: scripts.reclaimGlobalScriptHash,
+ params_currency_symbol: scripts.paramsPolicyID,
+ verifier_vk_hash: release.cardanoVKBlake2b256,
+ proof_profile: "single-destination",
+ proof_slot_encoding: PROOF_SLOT_ENCODING,
+ batch_transcript_vk_hash: release.cardanoVKBlake2b256,
+ },
+ params_utxo: {
+ tx_hash: null,
+ output_index: 0,
+ policy_id: scripts.paramsPolicyID,
+ token_name: PARAMS_TOKEN_NAME,
+ holder_address: scripts.paramsHolderAddress,
+ datum_reclaim_base_script_hash: scripts.reclaimBaseScriptHash,
+ },
+ proof: {
+ circuit_id: CIRCUIT_ID,
+ key_version: KEY_VERSION,
+ destination_address_encoding: "destination-address-v1",
+ vk_hash: release.nativeVKBlake2b256,
+ cardano_vk_blake2b256: release.cardanoVKBlake2b256,
+ setup_transcript_hash: release.manifest.setup_transcript_hash,
+ mpc_ceremony_id: release.ceremonyID,
+ mpc_candidate_id: release.candidateID,
+ },
+ batching: {
+ default_utxo_count: 6,
+ optimization_utxo_count: 6,
+ hard_max_utxo_count: 7,
+ max_tx_cpu_percent: 90,
+ max_tx_mem_percent: 80,
+ distinct_7_opt_in: {
+ request_parameter: "maxUtxos",
+ request_value: 7,
+ require_explicit_request: true,
+ require_measured_execution_units: true,
+ },
+ },
+ reference_scripts: {
+ reclaim_base: {
+ tx_hash: null,
+ output_index: 1,
+ script_hash: scripts.reclaimBaseScriptHash,
+ holder_address: scripts.paramsHolderAddress,
+ },
+ reclaim_global: {
+ tx_hash: null,
+ output_index: 2,
+ script_hash: scripts.reclaimGlobalScriptHash,
+ holder_address: scripts.paramsHolderAddress,
+ },
+ },
+ enabled: false,
+ planning: {
+ status: "unsigned-template-only",
+ production_decision_id: decision.decisionID,
+ mpc_release_id: decision.releaseID,
+ source_signed_tag: source.signedTag,
+ release_manifest_sha256: release.releaseManifestSHA256,
+ mpc_candidate_schema: release.candidateSchema,
+ mpc_verification_report_schema: release.verificationReportSchema,
+ mpc_verification_report_sha256: release.verificationReportSHA256,
+ mpc_public_evidence_schema: release.publicEvidenceSchema,
+ mpc_public_evidence_sha256: release.publicEvidenceSHA256,
+ mpc_cardano_proof_blake2b256: release.cardanoProofBlake2b256,
+ unresolved_fields: [
+ "params_utxo.tx_hash",
+ "reference_scripts.reclaim_base.tx_hash",
+ "reference_scripts.reclaim_global.tx_hash",
+ "minimum_lovelace",
+ "reward_account_registration_state",
+ "provider_configuration",
+ ],
+ },
+ provider: {
+ primary: null,
+ fallback: null,
+ },
+ };
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
+ const plan = {
+ schema: MAINNET_PREPARATION_SCHEMA,
+ status: "dry-run-only",
+ network: NETWORK,
+ network_id: NETWORK_ID,
+ submitted: false,
+ ledger_network_access_used: false,
+ signed_transaction_created: false,
+ unsigned_transaction_cbor: null,
+ wallet_secrets_used: false,
+ source: {
+ commit: source.commit,
+ signed_tag: source.signedTag,
+ clean: true,
+ },
+ mpc_release: {
+ ceremony_id: release.ceremonyID,
+ candidate_id: release.candidateID,
+ manifest_sha256: release.releaseManifestSHA256,
+ signature_key_id: release.manifest.signature_key_id,
+ candidate_schema: release.candidateSchema,
+ verification_report_schema: release.verificationReportSchema,
+ verification_report_sha256: release.verificationReportSHA256,
+ public_evidence_schema: release.publicEvidenceSchema,
+ public_evidence_sha256: release.publicEvidenceSHA256,
+ cardano_proof_blake2b256: release.cardanoProofBlake2b256,
+ native_vk_blake2b256: release.nativeVKBlake2b256,
+ cardano_vk_format: CARDANO_VK_FORMAT,
+ cardano_vk_blake2b256: release.cardanoVKBlake2b256,
+ exact_release_verification_passes: 2,
+ },
+ production_decision: {
+ decision: "GO",
+ decision_id: decision.decisionID,
+ release_id: decision.releaseID,
+ four_role_signatures_verified: true,
+ source_tag_signer_fingerprint: decision.sourceTagSignerFingerprint,
+ source_tag_object_sha256: decision.sourceTagObjectSHA256,
+ },
+ parameterization: {
+ seed_out_ref: seedOutRef.canonical,
+ params_policy_id: scripts.paramsPolicyID,
+ params_token_name: PARAMS_TOKEN_NAME,
+ params_unit: scripts.paramsUnit,
+ reclaim_global_script_hash: scripts.reclaimGlobalScriptHash,
+ reclaim_base_script_hash: scripts.reclaimBaseScriptHash,
+ params_holder_script_hash: scripts.paramsHolderScriptHash,
+ reclaim_base_address: scripts.reclaimBaseAddress,
+ params_holder_address: scripts.paramsHolderAddress,
+ reclaim_global_reward_address: scripts.reclaimGlobalRewardAddress,
+ scripts: {
+ one_shot_params_nft: {
+ type: scripts.oneShot.type,
+ script_cbor_hex: scripts.oneShot.script,
+ policy_id: scripts.paramsPolicyID,
+ },
+ reclaim_global_v2: {
+ type: scripts.global.type,
+ script_cbor_hex: scripts.global.script,
+ script_hash: scripts.reclaimGlobalScriptHash,
+ },
+ reclaim_base: {
+ type: scripts.base.type,
+ script_cbor_hex: scripts.base.script,
+ script_hash: scripts.reclaimBaseScriptHash,
+ },
+ params_holder: {
+ type: scripts.holder.type,
+ script_cbor_hex: scripts.holder.script,
+ script_hash: scripts.paramsHolderScriptHash,
+ },
+ },
+ global_v2: {
+ proof_slot_encoding: PROOF_SLOT_ENCODING,
+ batch_transcript: BATCH_TRANSCRIPT,
+ verifier_vk_hash: release.cardanoVKBlake2b256,
+ },
+ },
+ reference_output_plan: {
+ fixed_order_required: true,
+ inputs: [
+ {
+ purpose: "one-shot params NFT seed",
+ out_ref: seedOutRef.canonical,
+ live_unspent_status: "must be rechecked immediately before transaction construction",
+ },
+ ],
+ certificates: [
+ {
+ purpose: "register ReclaimGlobal rewarding credential when not already registered",
+ credential: scripts.reclaimGlobalScriptHash,
+ decision: "must be resolved from a fresh Mainnet ledger snapshot",
+ },
+ ],
+ outputs: [
+ {
+ output_index: 0,
+ purpose: "params NFT and ReclaimBase hash datum",
+ address: scripts.paramsHolderAddress,
+ assets: { [scripts.paramsUnit]: "1" },
+ inline_datum_cbor: paramsDatum,
+ reference_script_hash: null,
+ minimum_lovelace: null,
+ },
+ {
+ output_index: 1,
+ purpose: "ReclaimBase reference script",
+ address: scripts.paramsHolderAddress,
+ assets: {},
+ inline_datum_cbor: voidDatum,
+ reference_script_hash: scripts.reclaimBaseScriptHash,
+ reference_script_cbor_hex: scripts.base.script,
+ minimum_lovelace: null,
+ },
+ {
+ output_index: 2,
+ purpose: "ReclaimGlobal reference script",
+ address: scripts.paramsHolderAddress,
+ assets: {},
+ inline_datum_cbor: voidDatum,
+ reference_script_hash: scripts.reclaimGlobalScriptHash,
+ reference_script_cbor_hex: scripts.global.script,
+ minimum_lovelace: null,
+ },
+ ],
+ build_gate:
+ "Resolve protocol parameters, registration state, seed UTxO status, min lovelace, fees, collateral, change, and output indexes in a separately reviewed transaction-building step.",
+ },
+ unsigned_manifest: {
+ filename: UNSIGNED_MANIFEST_FILENAME,
+ sha256: `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`,
+ enabled: false,
+ unresolved_transaction_fields: true,
+ },
+ prohibited_actions: [
+ "wallet secret loading",
+ "transaction signing",
+ "transaction submission",
+ "provider mutation",
+ "deployment manifest activation",
+ ],
+ };
+ return {
+ [DEPLOYMENT_PLAN_FILENAME]: Buffer.from(`${JSON.stringify(plan, null, 2)}\n`),
+ [UNSIGNED_MANIFEST_FILENAME]: manifestBytes,
+ };
+}
+
+export function publishPreparation(outDir, artifacts) {
+ const parent = path.dirname(outDir);
+ let staging = "";
+ try {
+ staging = mkdtempSync(path.join(parent, ".mainnet-preparation.partial."), { encoding: "utf8" });
+ chmodPrivateDirectory(staging);
+ } catch {
+ if (staging) rmSync(staging, { recursive: true, force: true });
+ throw new MainnetPreparationError(
+ "preparation_publication_failed",
+ "Could not create a private Mainnet preparation staging directory.",
+ );
+ }
+ let destinationCreated = false;
+ try {
+ for (const [name, bytes] of Object.entries(artifacts)) {
+ writeFileSync(path.join(staging, name), bytes, { flag: "wx", mode: 0o600 });
+ }
+ // mkdir is the no-replacement publication boundary. Move each complete
+ // file only after this process exclusively owns the fresh destination.
+ // A crash can leave an incomplete, disabled preparation directory, but it
+ // cannot replace any prior artifact and cannot create an active manifest.
+ mkdirSync(outDir, { mode: 0o700 });
+ destinationCreated = true;
+ for (const name of Object.keys(artifacts)) {
+ renameSync(path.join(staging, name), path.join(outDir, name));
+ }
+ rmSync(staging, { recursive: true });
+ } catch (error) {
+ if (destinationCreated) {
+ rmSync(outDir, { recursive: true, force: true });
+ }
+ throw new MainnetPreparationError(
+ "preparation_publication_failed",
+ `Could not publish the fresh Mainnet preparation: ${error?.code ?? "write_failed"}.`,
+ );
+ } finally {
+ rmSync(staging, { recursive: true, force: true });
+ }
+ return {
+ plan: path.join(outDir, DEPLOYMENT_PLAN_FILENAME),
+ unsignedManifest: path.join(outDir, UNSIGNED_MANIFEST_FILENAME),
+ };
+}
+
+function parseScriptExport(stdout, mode) {
+ let parsed;
+ try {
+ const start = stdout.indexOf("{");
+ if (start < 0) throw new Error("missing JSON");
+ parsed = JSON.parse(stdout.slice(start));
+ } catch {
+ throw new MainnetPreparationError("script_export_malformed", `Offline ${mode} export was not valid JSON.`);
+ }
+ const expectedName = {
+ "one-shot": "one-shot-params-nft",
+ "global-v2": "reclaim-global-v2",
+ base: "reclaim-base",
+ "params-holder": "reclaim-params-holder",
+ }[mode];
+ if (
+ parsed.schema !== "proof-tool-reclaim-script-export-v1" ||
+ parsed.name !== expectedName ||
+ parsed.type !== "PlutusV3" ||
+ !/^[0-9a-f]+$/u.test(parsed.script ?? "") ||
+ parsed.script.length % 2 !== 0
+ ) {
+ throw new MainnetPreparationError(
+ "script_export_identity_invalid",
+ `Offline ${mode} export has unexpected identity or script bytes.`,
+ );
+ }
+ return {
+ type: parsed.type,
+ script: parsed.script,
+ proofSlotEncoding: parsed.proof_slot_encoding,
+ batchTranscript: parsed.batch_transcript,
+ verifierVKHash: parsed.verifier_vk_hash,
+ };
+}
+
+function parseSeedOutRef(value) {
+ const match = /^([0-9a-f]{64})#(0|[1-9][0-9]*)$/u.exec(typeof value === "string" ? value.trim() : "");
+ if (!match) {
+ throw new MainnetPreparationError(
+ "seed_out_ref_invalid",
+ "--seed-out-ref must be a lowercase 32-byte transaction hash and non-negative index joined by #.",
+ );
+ }
+ const outputIndex = Number(match[2]);
+ if (!Number.isSafeInteger(outputIndex)) {
+ throw new MainnetPreparationError("seed_out_ref_invalid", "Seed output index exceeds the safe integer range.");
+ }
+ return { txHash: match[1], outputIndex, canonical: `${match[1]}#${outputIndex}` };
+}
+
+function planningSnapshot(releaseDir) {
+ const names = [
+ "manifest.json",
+ "manifest.sig",
+ "manifest-public-key.hex",
+ "candidate.json",
+ "candidate.sig.json",
+ "candidate-checksums.sha256",
+ "setup-transcript.json",
+ "verification-report.json",
+ "public-finalization-evidence.json",
+ "ownership.vk",
+ "cardano-vk.bin",
+ "cardano-vk.hex",
+ "cardano-vk-format.txt",
+ "checksums.sha256",
+ ];
+ return Object.fromEntries(names.map((name) => [name, fileIdentity(path.join(releaseDir, name))]));
+}
+
+function assertReleasePlanningSnapshot(releaseDir, expected) {
+ const actual = planningSnapshot(releaseDir);
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new MainnetPreparationError(
+ "mpc_release_changed",
+ "MPC release planning artifacts changed during Mainnet preparation.",
+ );
+ }
+}
+
+function trustedInputSnapshot(options) {
+ return Object.fromEntries(
+ [
+ ["ceremony", options.ceremonyPath],
+ ["ceremony_signature", options.ceremonySignaturePath],
+ ["coordinator_public_key", options.coordinatorPublicKeyPath],
+ ["release_public_key", options.releasePublicKeyPath],
+ ["production_decision", options.decisionRecordPath],
+ ...options.decisionSignaturePaths.map((file, index) => [`production_decision_signature_${index + 1}`, file]),
+ ].map(([name, file]) => [name, fileIdentity(file)]),
+ );
+}
+
+function assertTrustedInputSnapshot(options, expected) {
+ if (JSON.stringify(trustedInputSnapshot(options)) !== JSON.stringify(expected)) {
+ throw new MainnetPreparationError(
+ "trusted_input_changed",
+ "A ceremony or production-decision trust input changed during Mainnet preparation.",
+ );
+ }
+}
+
+function fileIdentity(file) {
+ requireRegularNoSymlink(file, "MPC release artifact");
+ const bytes = readFileSync(file);
+ return {
+ size: bytes.length,
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ };
+}
+
+function assertArtifactDigest(ref, expectedName, actual, label) {
+ exact(ref?.name, expectedName, `${label} filename`);
+ exact(ref?.digest?.sha256, actual.sha256, `${label} sha256`);
+ exact(ref?.digest?.blake2b256, actual.blake2b256, `${label} blake2b256`);
+ exact(ref?.digest?.size, actual.size, `${label} size`);
+}
+
+function assertExactDigest(actual, expected, label) {
+ exact(actual?.sha256, expected.sha256, `${label} sha256`);
+ exact(actual?.blake2b256, expected.blake2b256, `${label} blake2b256`);
+ exact(actual?.size, expected.size, `${label} size`);
+}
+
+function decodeExactHex(value, expectedBytes, label) {
+ if (typeof value !== "string" || !new RegExp(`^[0-9a-f]{${expectedBytes * 2}}$`, "u").test(value)) {
+ throw new MainnetPreparationError(
+ "coherence_mismatch",
+ `${label} is not exactly ${expectedBytes} lowercase hexadecimal bytes.`,
+ );
+ }
+ return Buffer.from(value, "hex");
+}
+
+function digest(bytes) {
+ return {
+ sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
+ blake2b256: `blake2b256:${Buffer.from(blake2b(Uint8Array.from(bytes), { dkLen: 32 })).toString("hex")}`,
+ size: bytes.length,
+ };
+}
+
+function parseJSON(bytes, label) {
+ try {
+ return JSON.parse(bytes.toString("utf8"));
+ } catch {
+ throw new MainnetPreparationError("release_json_malformed", `${label} is not valid JSON.`);
+ }
+}
+
+function exact(actual, expected, label) {
+ if (actual !== expected) {
+ throw new MainnetPreparationError("coherence_mismatch", `${label} does not match the required value.`);
+ }
+}
+
+function readRegular(root, name, maximum) {
+ const file = path.join(root, name);
+ requireRegularNoSymlink(file, `MPC release ${name}`);
+ requireBoundedSize(file, maximum, `MPC release ${name}`);
+ return readFileSync(file);
+}
+
+function readRegularAbsolute(file, label, maximum) {
+ requireRegularNoSymlink(file, label);
+ requireBoundedSize(file, maximum, label);
+ return readFileSync(file);
+}
+
+function requireBoundedSize(file, maximum, label) {
+ const size = statSync(file).size;
+ if (!Number.isSafeInteger(size) || size <= 0 || size > maximum) {
+ throw new MainnetPreparationError(
+ "required_file_size_invalid",
+ `${label} size must be within 1..${maximum} bytes.`,
+ );
+ }
+}
+
+function chmodPrivateDirectory(dir) {
+ try {
+ chmodSync(dir, 0o700);
+ } catch {
+ throw new MainnetPreparationError(
+ "preparation_publication_failed",
+ "Could not create preparation staging directory.",
+ );
+ }
+}
+
+function requireRegularNoSymlink(file, label) {
+ let info;
+ try {
+ info = lstatSync(file);
+ } catch {
+ throw new MainnetPreparationError("required_file_invalid", `${label} must be an existing regular file.`);
+ }
+ if (!info.isFile() || info.isSymbolicLink()) {
+ throw new MainnetPreparationError("required_file_invalid", `${label} must be a non-symlink regular file.`);
+ }
+}
+
+function requireRealDirectory(dir, label) {
+ let info;
+ try {
+ info = lstatSync(dir);
+ } catch {
+ throw new MainnetPreparationError("required_directory_invalid", `${label} must be an existing real directory.`);
+ }
+ if (!info.isDirectory() || info.isSymbolicLink()) {
+ throw new MainnetPreparationError("required_directory_invalid", `${label} must be a non-symlink directory.`);
+ }
+}
+
+function requireExternalTrustAnchor(file, releaseDir, label) {
+ const canonicalFile = realpathSync(file);
+ const canonicalRelease = realpathSync(releaseDir);
+ const relative = path.relative(canonicalRelease, canonicalFile);
+ if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
+ throw new MainnetPreparationError(
+ "trust_anchor_not_external",
+ `The out-of-band ${label} must be outside the MPC release directory.`,
+ );
+ }
+}
+
+function requirePathOutside(target, protectedRoot, targetLabel, protectedLabel) {
+ const canonicalProtected = realpathSync(protectedRoot);
+ const canonicalTarget = path.join(realpathSync(path.dirname(target)), path.basename(target));
+ const relative = path.relative(canonicalProtected, canonicalTarget);
+ if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
+ throw new MainnetPreparationError("unsafe_output_path", `${targetLabel} must be outside the ${protectedLabel}.`);
+ }
+}
+
+function isSymlink(file) {
+ try {
+ return lstatSync(file).isSymbolicLink();
+ } catch {
+ return false;
+ }
+}
+
+function resolvePath(root, value) {
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value);
+}
+
+async function execGit(repoRoot, args) {
+ try {
+ const { stdout } = await execFileAsync("git", args, {
+ cwd: repoRoot,
+ maxBuffer: 8 * 1024 * 1024,
+ env: minimalChildEnvironment(),
+ });
+ return stdout;
+ } catch {
+ throw new MainnetPreparationError("git_verification_failed", "Git source verification failed.");
+ }
+}
+
+function minimalChildEnvironment() {
+ const allowed = [
+ "PATH",
+ "HOME",
+ "TMPDIR",
+ "TMP",
+ "TEMP",
+ "LANG",
+ "LC_ALL",
+ "TZ",
+ "GNUPGHOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "GIT_CONFIG_GLOBAL",
+ "GIT_CONFIG_SYSTEM",
+ "CABAL_CONFIG",
+ "CABAL_DIR",
+ "GHC_ENVIRONMENT",
+ "LD_LIBRARY_PATH",
+ ];
+ const env = {};
+ for (const key of allowed) {
+ if (process.env[key] !== undefined) env[key] = process.env[key];
+ }
+ env.LC_ALL = "C";
+ env.TZ = "UTC";
+ return env;
+}
+
+function normalizeBlake2b256(value) {
+ return String(value).replace(/^blake2b256:/u, "");
+}
+
+function parseCLI(argv) {
+ const values = {};
+ const booleanFlags = new Set(["--dry-run"]);
+ const mapping = {
+ "--network": "network",
+ "--network-id": "networkId",
+ "--source-signed-tag": "sourceSignedTag",
+ "--mpc-ceremony-bin": "mpcCeremonyBin",
+ "--ceremony": "ceremonyPath",
+ "--ceremony-signature": "ceremonySignaturePath",
+ "--coordinator-public-key-file": "coordinatorPublicKeyPath",
+ "--release-dir": "releaseDir",
+ "--release-public-key-file": "releasePublicKeyPath",
+ "--release-signature-key-id": "releaseSignatureKeyID",
+ "--seed-out-ref": "seedOutRef",
+ "--production-decision": "decisionRecordPath",
+ "--decision-evidence-root": "decisionEvidenceRoot",
+ "--decision-signature": "decisionSignaturePaths",
+ "--out-dir": "outDir",
+ };
+ for (let index = 0; index < argv.length; index += 1) {
+ const flag = argv[index];
+ if (booleanFlags.has(flag)) {
+ if (values.dryRun) throw new MainnetPreparationError("usage_error", `${flag} was supplied more than once.`);
+ values.dryRun = true;
+ continue;
+ }
+ const field = mapping[flag];
+ if (!field || index + 1 >= argv.length || argv[index + 1].startsWith("--")) {
+ throw new MainnetPreparationError("usage_error", `Unknown or incomplete option: ${flag}`);
+ }
+ if (field === "decisionSignaturePaths") {
+ values[field] ??= [];
+ values[field].push(argv[index + 1]);
+ index += 1;
+ continue;
+ }
+ if (values[field] !== undefined) {
+ throw new MainnetPreparationError("usage_error", `${flag} was supplied more than once.`);
+ }
+ values[field] = argv[index + 1];
+ index += 1;
+ }
+ return values;
+}
+
+async function main() {
+ try {
+ const result = await prepareReclaimMainnet(parseCLI(process.argv.slice(2)));
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+ } catch (error) {
+ const code = error?.code ?? "mainnet_preparation_failed";
+ const message = error?.message ?? String(error);
+ process.stderr.write(`Mainnet deployment preparation failed closed: ${code}: ${message}\n`);
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main();
+}
diff --git a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs
new file mode 100644
index 00000000..73b5f8ec
--- /dev/null
+++ b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs
@@ -0,0 +1,618 @@
+import { createHash } from "node:crypto";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { blake2b } from "@noble/hashes/blake2b";
+import {
+ assertProductionDecisionRecordBinding,
+ buildPreparationArtifacts,
+ inspectMPCRelease,
+ prepareReclaimMainnet,
+ validatePreparationOptions,
+} from "./prepare-reclaim-mainnet.mjs";
+import { validateReclaimManifest } from "../../scripts/verify-reclaim-manifest.mjs";
+
+const tempDirs = [];
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ while (tempDirs.length > 0) {
+ rmSync(tempDirs.pop(), { force: true, recursive: true });
+ }
+});
+
+describe("Mainnet deployment preparation guards", () => {
+ it.each([
+ [{ dryRun: false, network: "Mainnet", networkId: 1 }, "dry_run_required"],
+ [{ dryRun: true, network: "Preprod", networkId: 1 }, "mainnet_identity_required"],
+ [{ dryRun: true, network: "Mainnet", networkId: 0 }, "mainnet_identity_required"],
+ ])("rejects missing dry-run or exact Mainnet identity before touching files", (options, code) => {
+ expect(() => validatePreparationOptions(options, "/does/not/matter")).toThrowError(
+ expect.objectContaining({ code }),
+ );
+ });
+
+ it("rejects an existing output directory", () => {
+ const fixture = optionFixture();
+ mkdirSync(fixture.options.outDir);
+
+ expect(() => validatePreparationOptions(fixture.options, fixture.root)).toThrowError(
+ expect.objectContaining({ code: "output_exists" }),
+ );
+ });
+
+ it("stops on exact MPC release verification failure before export, decision verification, or output", async () => {
+ const fixture = optionFixture();
+ const events = [];
+
+ await expect(
+ prepareReclaimMainnet({
+ ...fixture.options,
+ repoRoot: fixture.root,
+ assertCleanSignedSourceFn: vi.fn(async () => {
+ events.push("source");
+ return { commit: "11".repeat(20), signedTag: fixture.options.sourceSignedTag };
+ }),
+ verifyMPCReleaseFn: vi.fn(async () => {
+ events.push("release");
+ throw new Error("tampered release");
+ }),
+ inspectMPCReleaseFn: vi.fn(() => events.push("inspect")),
+ exportScriptsFn: vi.fn(() => events.push("export")),
+ verifyProductionDecisionFn: vi.fn(() => events.push("decision")),
+ publishFn: vi.fn(() => events.push("publish")),
+ }),
+ ).rejects.toThrow(/tampered release/u);
+
+ expect(events).toEqual(["source", "release"]);
+ expect(existsSync(fixture.options.outDir)).toBe(false);
+ });
+
+ it("publishes only the disabled template and dry-run plan after two exact release verifications", async () => {
+ const options = optionFixture();
+ const release = releaseFixture();
+ const verifyMPCReleaseFn = vi.fn(async () => ({ ceremonyID: release.ceremonyID }));
+ const preparation = await prepareReclaimMainnet({
+ ...options.options,
+ ceremonyPath: release.expectations.ceremonyPath,
+ releaseDir: release.expectations.releaseDir,
+ repoRoot: options.root,
+ assertCleanSignedSourceFn: vi.fn(async () => ({
+ commit: release.sourceCommit,
+ signedTag: options.options.sourceSignedTag,
+ })),
+ verifyMPCReleaseFn,
+ exportScriptsFn: vi.fn(async () => scriptFixture()),
+ verifyProductionDecisionFn: vi.fn(async () => productionDecisionResult()),
+ });
+
+ expect(verifyMPCReleaseFn).toHaveBeenCalledTimes(2);
+ expect(preparation).toMatchObject({
+ ok: true,
+ dryRun: true,
+ network: "Mainnet",
+ networkId: 1,
+ submitted: false,
+ unsigned: true,
+ });
+ expect(
+ [path.basename(preparation.outputs.plan), path.basename(preparation.outputs.unsignedManifest)].sort(),
+ ).toEqual(["deployment-plan.json", "reclaim-deployment.unsigned-template.json"]);
+ const manifest = JSON.parse(readFileSync(preparation.outputs.unsignedManifest, "utf8"));
+ expect(manifest.enabled).toBe(false);
+ expect(manifest.params_utxo.tx_hash).toBeNull();
+ });
+
+ it("rejects public finalization evidence changed after release inspection", async () => {
+ const options = optionFixture();
+ const release = releaseFixture();
+ const publicEvidencePath = path.join(release.expectations.releaseDir, "public-finalization-evidence.json");
+
+ await expect(
+ prepareReclaimMainnet({
+ ...options.options,
+ ceremonyPath: release.expectations.ceremonyPath,
+ releaseDir: release.expectations.releaseDir,
+ repoRoot: options.root,
+ assertCleanSignedSourceFn: vi.fn(async () => ({
+ commit: release.sourceCommit,
+ signedTag: options.options.sourceSignedTag,
+ })),
+ verifyMPCReleaseFn: vi.fn(async () => ({ ceremonyID: release.ceremonyID })),
+ exportScriptsFn: vi.fn(async () => {
+ writeFileSync(publicEvidencePath, `${readFileSync(publicEvidencePath, "utf8")} `);
+ return scriptFixture();
+ }),
+ verifyProductionDecisionFn: vi.fn(async () => productionDecisionResult()),
+ }),
+ ).rejects.toThrowError(expect.objectContaining({ code: "mpc_release_changed" }));
+
+ expect(existsSync(options.options.outDir)).toBe(false);
+ });
+});
+
+describe("MPC release key semantics", () => {
+ it("keeps the native gnark VK hash separate from the Cardano wire-format VK hash", () => {
+ const fixture = releaseFixture();
+ const inspected = inspectMPCRelease(fixture.expectations);
+
+ expect(inspected.nativeVKBlake2b256).toBe(fixture.nativeDigest.blake2b256);
+ expect(inspected.cardanoVKBlake2b256).toBe(fixture.cardanoDigest.blake2b256);
+ expect(inspected.nativeVKBlake2b256).not.toBe(inspected.cardanoVKBlake2b256);
+ });
+
+ it("rejects a release whose signed manifest substitutes the Cardano hash for the native VK hash", () => {
+ const fixture = releaseFixture();
+ const manifestPath = path.join(fixture.expectations.releaseDir, "manifest.json");
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ manifest.vk_hash = fixture.cardanoDigest.blake2b256;
+ writeJSON(manifestPath, manifest);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "coherence_mismatch" }),
+ );
+ });
+
+ it("rejects a Cardano hex export that does not encode the signed 672-byte artifact", () => {
+ const fixture = releaseFixture();
+ writeFileSync(path.join(fixture.expectations.releaseDir, "cardano-vk.hex"), `${"00".repeat(672)}\n`);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "cardano_vk_hex_mismatch" }),
+ );
+ });
+
+ it.each([
+ ["candidate.json", "schema", "proof-tool-mpc-release-candidate-v1"],
+ ["verification-report.json", "schema", "proof-tool-mpc-verification-report-v1"],
+ ])("rejects stale pre-public-evidence schema in %s", (filename, field, value) => {
+ const fixture = releaseFixture();
+ const file = path.join(fixture.expectations.releaseDir, filename);
+ const record = JSON.parse(readFileSync(file, "utf8"));
+ record[field] = value;
+ writeJSON(file, record);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "coherence_mismatch" }),
+ );
+ });
+
+ it("rejects a candidate that does not hash-bind the exact v2 report", () => {
+ const fixture = releaseFixture();
+ const file = path.join(fixture.expectations.releaseDir, "candidate.json");
+ const candidate = JSON.parse(readFileSync(file, "utf8"));
+ candidate.verification_report.digest.sha256 = `sha256:${"00".repeat(32)}`;
+ writeJSON(file, candidate);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "coherence_mismatch" }),
+ );
+ });
+
+ it("rejects public evidence whose exact Cardano proof digest was substituted", () => {
+ const fixture = releaseFixture();
+ const evidencePath = path.join(fixture.expectations.releaseDir, "public-finalization-evidence.json");
+ const candidatePath = path.join(fixture.expectations.releaseDir, "candidate.json");
+ const reportPath = path.join(fixture.expectations.releaseDir, "verification-report.json");
+ const evidence = JSON.parse(readFileSync(evidencePath, "utf8"));
+ evidence.cardano_proof_hex = `${"7c".repeat(336)}`;
+ const changedEvidenceBytes = writeJSON(evidencePath, evidence);
+ const changedEvidenceDigest = artifactDigest(changedEvidenceBytes);
+ const candidate = JSON.parse(readFileSync(candidatePath, "utf8"));
+ candidate.public_finalization_evidence.digest = changedEvidenceDigest;
+ writeJSON(candidatePath, candidate);
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
+ report.public_evidence.digest = changedEvidenceDigest;
+ const changedReportBytes = writeJSON(reportPath, report);
+ candidate.verification_report.digest = artifactDigest(changedReportBytes);
+ writeJSON(candidatePath, candidate);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "coherence_mismatch" }),
+ );
+ });
+
+ it("rejects any missing native negative verification result", () => {
+ const fixture = releaseFixture();
+ const reportPath = path.join(fixture.expectations.releaseDir, "verification-report.json");
+ const candidatePath = path.join(fixture.expectations.releaseDir, "candidate.json");
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
+ report.wrong_vk_rejected = false;
+ const changedReportBytes = writeJSON(reportPath, report);
+ const candidate = JSON.parse(readFileSync(candidatePath, "utf8"));
+ candidate.verification_report.digest = artifactDigest(changedReportBytes);
+ writeJSON(candidatePath, candidate);
+
+ expect(() => inspectMPCRelease(fixture.expectations)).toThrowError(
+ expect.objectContaining({ code: "coherence_mismatch" }),
+ );
+ });
+});
+
+describe("production GO decision release and source provenance", () => {
+ it("binds the verified decision output to the exact local release manifest and full tag provenance", () => {
+ const releaseManifestSHA256 = `sha256:${"44".repeat(32)}`;
+ const result = decisionCommandResult();
+ const decision = decisionRecord(result, releaseManifestSHA256);
+
+ expect(
+ assertProductionDecisionRecordBinding({
+ decisionBytes: jsonBytes(decision),
+ result,
+ expectedReleaseManifestSHA256: releaseManifestSHA256,
+ }),
+ ).toEqual(decision);
+ });
+
+ it.each([
+ [
+ "release manifest",
+ (decision) => (decision.release.manifest.artifact.digest.sha256 = `sha256:${"ff".repeat(32)}`),
+ ],
+ ["tag fingerprint", (decision) => (decision.source_release.signer_fingerprint_hex = "ff".repeat(20))],
+ [
+ "tag object",
+ (decision) => (decision.source_release.signed_tag_object.artifact.digest.sha256 = `sha256:${"ff".repeat(32)}`),
+ ],
+ ])("rejects authenticated decision drift in %s", (_label, mutate) => {
+ const releaseManifestSHA256 = `sha256:${"44".repeat(32)}`;
+ const result = decisionCommandResult();
+ const decision = decisionRecord(result, releaseManifestSHA256);
+ mutate(decision);
+
+ expect(() =>
+ assertProductionDecisionRecordBinding({
+ decisionBytes: jsonBytes(decision),
+ result,
+ expectedReleaseManifestSHA256: releaseManifestSHA256,
+ }),
+ ).toThrowError(expect.objectContaining({ code: "coherence_mismatch" }));
+ });
+});
+
+describe("unsigned plan artifacts", () => {
+ it("emits an inactive manifest template and a reference-output plan without transaction bytes", () => {
+ const nativeHash = `blake2b256:${"11".repeat(32)}`;
+ const cardanoHash = `blake2b256:${"22".repeat(32)}`;
+ const artifacts = buildPreparationArtifacts({
+ source: { commit: "33".repeat(20), signedTag: "v1.0.0-mainnet" },
+ release: {
+ ceremonyID: `sha256:${"44".repeat(32)}`,
+ candidateID: `sha256:${"55".repeat(32)}`,
+ releaseManifestSHA256: `sha256:${"66".repeat(32)}`,
+ candidateSchema: "proof-tool-mpc-release-candidate-v2",
+ verificationReportSchema: "proof-tool-mpc-verification-report-v2",
+ verificationReportSHA256: `sha256:${"67".repeat(32)}`,
+ publicEvidenceSchema: "proof-tool-mpc-public-finalization-evidence-v1",
+ publicEvidenceSHA256: `sha256:${"68".repeat(32)}`,
+ cardanoProofBlake2b256: `blake2b256:${"69".repeat(32)}`,
+ nativeVKBlake2b256: nativeHash,
+ cardanoVKBlake2b256: cardanoHash,
+ manifest: {
+ signature_key_id: "release-2026",
+ setup_transcript_hash: `blake2b256:${"77".repeat(32)}`,
+ },
+ },
+ scripts: scriptFixture(),
+ decision: productionDecisionResult(),
+ seedOutRef: {
+ txHash: "88".repeat(32),
+ outputIndex: 3,
+ canonical: `${"88".repeat(32)}#3`,
+ },
+ });
+ const plan = JSON.parse(artifacts["deployment-plan.json"]);
+ const manifest = JSON.parse(artifacts["reclaim-deployment.unsigned-template.json"]);
+
+ expect(plan.network).toBe("Mainnet");
+ expect(plan.network_id).toBe(1);
+ expect(plan.submitted).toBe(false);
+ expect(plan.signed_transaction_created).toBe(false);
+ expect(plan.unsigned_transaction_cbor).toBeNull();
+ expect(plan.reference_output_plan.outputs.map((output) => output.output_index)).toEqual([0, 1, 2]);
+ expect(manifest.enabled).toBe(false);
+ expect(manifest.params_utxo.tx_hash).toBeNull();
+ expect(manifest.proof.vk_hash).toBe(nativeHash);
+ expect(manifest.proof.cardano_vk_blake2b256).toBe(cardanoHash);
+ expect(manifest.planning.mpc_candidate_schema).toBe("proof-tool-mpc-release-candidate-v2");
+ expect(manifest.planning.mpc_verification_report_schema).toBe("proof-tool-mpc-verification-report-v2");
+ expect(manifest.planning.mpc_public_evidence_sha256).toBe(`sha256:${"68".repeat(32)}`);
+ expect(manifest.reclaim_global.verifier_vk_hash).toBe(cardanoHash);
+ expect(manifest.reclaim_global.verifier_vk_hash).not.toBe(manifest.proof.vk_hash);
+ const activationErrors = validateReclaimManifest(manifest);
+ expect(activationErrors.map((error) => error.field)).toEqual(
+ expect.arrayContaining(["params_utxo.tx_hash", "enabled"]),
+ );
+ expect(JSON.stringify({ plan, manifest })).not.toMatch(/mnemonic|xprv|private_key|signed_tx/iu);
+ });
+});
+
+function optionFixture() {
+ const root = tempDir("proof-tool-mainnet-options-");
+ const releaseDir = path.join(root, "release");
+ const trustDir = path.join(root, "trust");
+ mkdirSync(releaseDir);
+ mkdirSync(trustDir);
+ const decisionEvidenceRoot = path.join(root, "decision-evidence");
+ mkdirSync(decisionEvidenceRoot);
+ const files = {
+ mpcCeremonyBin: path.join(root, "mpc-ceremony"),
+ ceremonyPath: path.join(root, "ceremony.json"),
+ ceremonySignaturePath: path.join(root, "ceremony.sig.json"),
+ coordinatorPublicKeyPath: path.join(trustDir, "coordinator.pub"),
+ releasePublicKeyPath: path.join(trustDir, "release.pub"),
+ decisionRecordPath: path.join(trustDir, "production-decision.json"),
+ };
+ for (const file of Object.values(files)) writeFileSync(file, "fixture\n");
+ const decisionSignaturePaths = Array.from({ length: 4 }, (_, index) => {
+ const file = path.join(trustDir, `production-decision-${index + 1}.sig.json`);
+ writeFileSync(file, "fixture\n");
+ return file;
+ });
+ chmodSync(files.mpcCeremonyBin, 0o700);
+ return {
+ root,
+ options: {
+ dryRun: true,
+ network: "Mainnet",
+ networkId: 1,
+ sourceSignedTag: "v1.0.0-mainnet",
+ ...files,
+ releaseDir,
+ releaseSignatureKeyID: "release-2026",
+ decisionSignaturePaths,
+ decisionEvidenceRoot,
+ seedOutRef: `${"99".repeat(32)}#0`,
+ outDir: path.join(root, "output"),
+ },
+ };
+}
+
+function releaseFixture() {
+ const root = tempDir("proof-tool-mainnet-release-");
+ const releaseDir = path.join(root, "release");
+ mkdirSync(releaseDir);
+ const ceremonyID = `sha256:${"aa".repeat(32)}`;
+ const candidateID = `sha256:${"bb".repeat(32)}`;
+ const sourceCommit = "cc".repeat(20);
+ const signatureKeyID = "release-2026";
+ const nativeVK = Buffer.from("native-gnark-vk-fixture");
+ const cardanoVK = Buffer.alloc(672, 0x5a);
+ const cardanoProof = Buffer.alloc(336, 0x6b);
+ const nativeDigest = artifactDigest(nativeVK);
+ const cardanoDigest = artifactDigest(cardanoVK);
+ const ref = (name, value) => ({ name, digest: artifactDigest(value) });
+ const credentialHex = "19e07fbcc7577359d6c51f1e49cf1b0bf4c943b48ba4e4905a8702e4";
+ const destinationHex =
+ "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111" +
+ "0000000000000000000000000000000000000000000000000000000000";
+ const publicInputDigestHex = Buffer.from(
+ blake2b(
+ Uint8Array.from(
+ Buffer.concat([
+ Buffer.from("ROOT-OWNERSHIP-DESTINATION-v1", "utf8"),
+ Buffer.from(credentialHex, "hex"),
+ Buffer.from(destinationHex, "hex"),
+ ]),
+ ),
+ { dkLen: 32 },
+ ),
+ ).toString("hex");
+ const publicEvidence = {
+ schema: "proof-tool-mpc-public-finalization-evidence-v1",
+ ceremony_id: ceremonyID,
+ fixture: "repository-golden-destination-v2",
+ credential_hex: credentialHex,
+ destination_hex: destinationHex,
+ public_input_digest_hex: publicInputDigestHex,
+ cardano_proof_hex: cardanoProof.toString("hex"),
+ cardano_proof_format: "groth16-bls12-381-bsb22",
+ cardano_proof_raw_digest: artifactDigest(cardanoProof),
+ cardano_verifying_key: ref("cardano-vk.bin", cardanoVK),
+ };
+ const publicEvidenceBytes = jsonBytes(publicEvidence);
+ const report = {
+ schema: "proof-tool-mpc-verification-report-v2",
+ ceremony_id: ceremonyID,
+ fixture: "repository-golden-destination-v2",
+ native_proof_verified: true,
+ wrong_credential_rejected: true,
+ wrong_destination_rejected: true,
+ wrong_digest_rejected: true,
+ wrong_proof_rejected: true,
+ wrong_vk_rejected: true,
+ proof_truncation_rejected: true,
+ proof_append_rejected: true,
+ cardano_proof_format: "groth16-bls12-381-bsb22",
+ cardano_proof_bytes: 336,
+ cardano_proof_raw_digest: artifactDigest(cardanoProof),
+ cardano_vk_format: "groth16-bls12-381-bsb22",
+ cardano_vk_bytes: 672,
+ cardano_vk_raw_digest: cardanoDigest,
+ public_evidence: ref("public-finalization-evidence.json", publicEvidenceBytes),
+ checked_at: "2026-07-23T00:00:00Z",
+ };
+ const reportBytes = jsonBytes(report);
+ const candidate = {
+ schema: "proof-tool-mpc-release-candidate-v2",
+ candidate_id: candidateID,
+ ceremony_id: ceremonyID,
+ circuit: {
+ key_version: "ownership-destination-v2",
+ circuit_id: "root-ownership-destination-v2/bls12-381/groth16",
+ curve: "BLS12-381",
+ backend: "groth16",
+ },
+ verifying_key: ref("ownership.vk", nativeVK),
+ cardano_verifying_key: ref("cardano-vk.bin", cardanoVK),
+ verification_report: ref("verification-report.json", reportBytes),
+ public_finalization_evidence: ref("public-finalization-evidence.json", publicEvidenceBytes),
+ };
+ const transcript = {
+ schema: "proof-tool-mpc-final-transcript-v1",
+ ceremony_id: ceremonyID,
+ audits: [{ name: "audit-1.json" }, { name: "audit-2.json" }],
+ verifying_key: candidate.verifying_key,
+ cardano_verifying_key: candidate.cardano_verifying_key,
+ };
+ const manifest = {
+ schema: "proof-tool-key-manifest-v1",
+ key_version: "ownership-destination-v2",
+ circuit_id: "root-ownership-destination-v2/bls12-381/groth16",
+ curve: "BLS12-381",
+ backend: "groth16",
+ circuit_source_commit: sourceCommit,
+ signature_key_id: signatureKeyID,
+ vk_hash: nativeDigest.blake2b256,
+ };
+ const ceremony = {
+ schema: "proof-tool-mpc-ceremony-definition-v1",
+ ceremony_id: ceremonyID,
+ mode: "production",
+ software: { source_commit: sourceCommit, source_dirty: false },
+ release_signer: { key_id: signatureKeyID },
+ };
+ const ceremonyPath = path.join(root, "ceremony.json");
+ writeJSON(ceremonyPath, ceremony);
+ writeJSON(path.join(releaseDir, "manifest.json"), manifest);
+ writeJSON(path.join(releaseDir, "candidate.json"), candidate);
+ writeJSON(path.join(releaseDir, "setup-transcript.json"), transcript);
+ writeJSON(path.join(releaseDir, "verification-report.json"), report);
+ writeJSON(path.join(releaseDir, "public-finalization-evidence.json"), publicEvidence);
+ writeFileSync(path.join(releaseDir, "ownership.vk"), nativeVK);
+ writeFileSync(path.join(releaseDir, "cardano-vk.bin"), cardanoVK);
+ writeFileSync(path.join(releaseDir, "cardano-vk.hex"), `${cardanoVK.toString("hex")}\n`);
+ writeFileSync(path.join(releaseDir, "cardano-vk-format.txt"), "groth16-bls12-381-bsb22\n");
+ for (const name of [
+ "manifest.sig",
+ "manifest-public-key.hex",
+ "candidate.sig.json",
+ "candidate-checksums.sha256",
+ "checksums.sha256",
+ ]) {
+ writeFileSync(path.join(releaseDir, name), `${name}\n`);
+ }
+ return {
+ expectations: {
+ releaseDir,
+ ceremonyPath,
+ expectedSourceCommit: sourceCommit,
+ expectedSignatureKeyID: signatureKeyID,
+ expectedCeremonyID: ceremonyID,
+ },
+ nativeDigest,
+ cardanoDigest,
+ ceremonyID,
+ sourceCommit,
+ };
+}
+
+function productionDecisionResult() {
+ return {
+ decisionID: `sha256:${"10".repeat(32)}`,
+ releaseID: `sha256:${"20".repeat(32)}`,
+ ceremonyID: `sha256:${"22".repeat(32)}`,
+ candidateID: `sha256:${"33".repeat(32)}`,
+ sourceCommit: "11".repeat(20),
+ sourceSignedTag: "v1.0.0-mainnet",
+ sourceTagSignerFingerprint: "aa".repeat(20),
+ sourceTagObjectSHA256: `sha256:${"bb".repeat(32)}`,
+ };
+}
+
+function decisionCommandResult() {
+ return {
+ decision_id: `sha256:${"10".repeat(32)}`,
+ release_id: `sha256:${"20".repeat(32)}`,
+ ceremony_id: `sha256:${"22".repeat(32)}`,
+ candidate_id: `sha256:${"33".repeat(32)}`,
+ source_commit: "11".repeat(20),
+ source_signed_tag: "v1.0.0-mainnet",
+ source_tag_signer_fingerprint: "aa".repeat(20),
+ source_tag_object_sha256: `sha256:${"bb".repeat(32)}`,
+ };
+}
+
+function decisionRecord(result, releaseManifestSHA256) {
+ return {
+ schema: "proof-tool-mpc-production-decision-v1",
+ decision_id: result.decision_id,
+ ceremony_id: result.ceremony_id,
+ release: {
+ release_id: result.release_id,
+ candidate_id: result.candidate_id,
+ manifest: {
+ uri: "https://example.invalid/release/manifest.json",
+ artifact: {
+ name: "release/manifest.json",
+ digest: {
+ sha256: releaseManifestSHA256,
+ blake2b256: `blake2b256:${"55".repeat(32)}`,
+ size: 123,
+ },
+ },
+ },
+ },
+ source_release: {
+ source_commit: result.source_commit,
+ signed_tag: result.source_signed_tag,
+ signature_format: "openpgp-primary-key-v4",
+ signer_fingerprint_hex: result.source_tag_signer_fingerprint,
+ signed_tag_object: {
+ uri: "https://example.invalid/source/tag.object",
+ artifact: {
+ name: "source/tag.object",
+ digest: {
+ sha256: result.source_tag_object_sha256,
+ blake2b256: `blake2b256:${"66".repeat(32)}`,
+ size: 456,
+ },
+ },
+ },
+ },
+ decision: "GO",
+ };
+}
+
+function scriptFixture() {
+ return {
+ paramsPolicyID: "88".repeat(28),
+ paramsTokenName: "5245434c41494d504152414d53",
+ paramsUnit: `${"88".repeat(28)}5245434c41494d504152414d53`,
+ reclaimGlobalScriptHash: "99".repeat(28),
+ reclaimBaseScriptHash: "aa".repeat(28),
+ paramsHolderScriptHash: "bb".repeat(28),
+ reclaimBaseAddress: "addr1reclaimbase",
+ paramsHolderAddress: "addr1paramsholder",
+ reclaimGlobalRewardAddress: "stake1reclaimglobal",
+ oneShot: { type: "PlutusV3", script: "0102" },
+ global: { type: "PlutusV3", script: "0304" },
+ base: { type: "PlutusV3", script: "0506" },
+ holder: { type: "PlutusV3", script: "0708" },
+ };
+}
+
+function artifactDigest(bytes) {
+ return {
+ sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
+ blake2b256: `blake2b256:${Buffer.from(blake2b(Uint8Array.from(bytes), { dkLen: 32 })).toString("hex")}`,
+ size: bytes.length,
+ };
+}
+
+function writeJSON(file, value) {
+ const bytes = jsonBytes(value);
+ writeFileSync(file, bytes);
+ return bytes;
+}
+
+function jsonBytes(value) {
+ return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
+}
+
+function tempDir(prefix) {
+ const dir = mkdtempSync(path.join(tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
diff --git a/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.mjs b/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.mjs
index 482b2270..da8833a2 100644
--- a/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.mjs
@@ -807,7 +807,7 @@ export function buildManifest({
script_hash: globalScriptHash,
rewarding_credential: globalScriptHash,
params_currency_symbol: paramsPolicyId,
- verifier_vk_hash: destination.vkHash,
+ verifier_vk_hash: destination.cardanoVkBlake2b256,
proof_profile: "single-destination",
proof_slot_encoding: FULL_PROOF_PLUS_PUBLIC_INPUT_DIGEST_V2,
batch_transcript_vk_hash: destination.cardanoVkBlake2b256,
diff --git a/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.test.mjs b/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.test.mjs
index cd690b63..765eaa50 100644
--- a/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/deploy-reclaim-preprod.test.mjs
@@ -101,7 +101,7 @@ describe("reclaim script exporter invocation", () => {
expect(manifest.proof.circuit_id).toBe("root-ownership-destination-v2/bls12-381/groth16");
expect(manifest.proof.key_version).toBe("ownership-destination-v2");
expect(manifest.proof.vk_hash).toBe(`blake2b256:${"11".repeat(32)}`);
- expect(manifest.reclaim_global.verifier_vk_hash).toBe(manifest.proof.vk_hash);
+ expect(manifest.reclaim_global.verifier_vk_hash).toBe(manifest.proof.cardano_vk_blake2b256);
expect(manifest.proof.cardano_vk_blake2b256).toBe(`blake2b256:${"22".repeat(32)}`);
expect(manifest.proof.cardano_vk_blake2b256).not.toBe(manifest.proof.vk_hash);
expect(manifest.batching).toEqual({
diff --git a/apps/ownership-proof-web/e2e/preprod/deployment-stage.mjs b/apps/ownership-proof-web/e2e/preprod/deployment-stage.mjs
index 26777aaf..20237f9c 100644
--- a/apps/ownership-proof-web/e2e/preprod/deployment-stage.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/deployment-stage.mjs
@@ -72,6 +72,7 @@ export function verifyDeploymentPair(reclaim, claim, preflight) {
assertEqual("network_id", reclaimDeployment.networkId, claimDeployment.networkId);
assertEqual("source_commit", reclaimDeployment.sourceCommit, claimDeployment.sourceCommit);
assertEqual("verifier_vk_hash", reclaimDeployment.verifierVkHash, claimDeployment.verifierVkHash);
+ assertEqual("proof_vk_hash", reclaimDeployment.proofVkHash, claimDeployment.proofVkHash);
const expectedSourceCommit = preflight?.context?.manifest?.source_commit;
if (expectedSourceCommit && reclaimDeployment.sourceCommit !== expectedSourceCommit) {
@@ -132,6 +133,7 @@ export function verifyDeploymentPair(reclaim, claim, preflight) {
networkId: reclaimDeployment.networkId,
sourceCommit: reclaimDeployment.sourceCommit,
verifierVkHash: reclaimDeployment.verifierVkHash,
+ proofVkHash: reclaimDeployment.proofVkHash,
contractVersion: reclaimDeployment.contractVersion,
proofProfile: capabilities.proofProfile,
helperKeyVersion: capabilities.helperKeyVersion,
diff --git a/apps/ownership-proof-web/e2e/preprod/deployment-stage.test.mjs b/apps/ownership-proof-web/e2e/preprod/deployment-stage.test.mjs
index 0ad37710..fb7ba089 100644
--- a/apps/ownership-proof-web/e2e/preprod/deployment-stage.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/deployment-stage.test.mjs
@@ -27,6 +27,7 @@ describe("deploy-or-verify preprod manifest stage", () => {
networkId: 0,
sourceCommit: "1234567890abcdef1234567890abcdef12345678",
verifierVkHash: "b".repeat(64),
+ proofVkHash: "c".repeat(64),
proofProfile: "single-destination",
destinationAddressEncoding: "destination-address-v1",
referenceScriptsConfigured: true,
@@ -56,6 +57,15 @@ describe("deploy-or-verify preprod manifest stage", () => {
);
});
+ it("rejects native proof-key endpoint mismatches independently", () => {
+ const claim = validClaimDeploymentResponse();
+ claim.deployment.proofVkHash = "d".repeat(64);
+
+ expect(() => verifyDeploymentPair(validDeploymentResponse(), claim, preflight())).toThrow(
+ /proof_vk_hash mismatch/u,
+ );
+ });
+
it("rejects app endpoints for a stale deployment with the current source commit", () => {
const reclaim = validDeploymentResponse();
const claim = validClaimDeploymentResponse();
@@ -165,6 +175,7 @@ function deployment() {
paramsCurrencySymbol: "e".repeat(56),
paramsTokenName: "00",
verifierVkHash: "b".repeat(64),
+ proofVkHash: "c".repeat(64),
contractVersion: "test-contract",
sourceCommit: "1234567890abcdef1234567890abcdef12345678",
};
diff --git a/apps/ownership-proof-web/e2e/preprod/lace-profile-setup.mjs b/apps/ownership-proof-web/e2e/preprod/lace-profile-setup.mjs
index 6133113d..c7a694b3 100644
--- a/apps/ownership-proof-web/e2e/preprod/lace-profile-setup.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/lace-profile-setup.mjs
@@ -1,21 +1,34 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
+import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
+import {
+ LACE_PROFILE_ENV_FILE_ENV,
+ loadPersistentLaceProfileEnv,
+ persistentLaceProfileEnvFile,
+} from "./persistent-lace-profile.mjs";
import { createRealLaceProfileDriverFromEnv } from "./real-lace-driver.mjs";
const DEFAULT_OUTPUT_DIR = "output/preprod-e2e/lace-profile";
async function main() {
const env = process.env;
- const outputDir = path.resolve(process.cwd(), env.RECLAIM_E2E_OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR);
- mkdirSync(outputDir, { recursive: true });
- const artifactPath = path.join(outputDir, "lace-profile-validation.json");
let context = null;
try {
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
+ const profileEnvFile = path.resolve(
+ env[LACE_PROFILE_ENV_FILE_ENV]?.trim() || persistentLaceProfileEnvFile(repoRoot),
+ );
+ const persistentProfile = loadPersistentLaceProfileEnv({ env, profileEnvFile });
+ console.log(`Validating persistent Lace profile ${persistentProfile.name}; this command never creates a profile.`);
+ const outputDir = path.resolve(process.cwd(), env.RECLAIM_E2E_OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR);
+ mkdirSync(outputDir, { recursive: true });
+ const artifactPath = path.join(outputDir, "lace-profile-validation.json");
const driver = await createRealLaceProfileDriverFromEnv({
env,
cwd: process.cwd(),
+ repoRoot,
});
context = await driver.launchBrowserContext(chromium, { headless: false });
const artifact = await driver.validateProfile();
diff --git a/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.mjs b/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.mjs
index 0b70be4b..16791ca7 100644
--- a/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.mjs
@@ -5,6 +5,11 @@ import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath, pathToFileURL } from "node:url";
import { waitForAppReady } from "./app-server.mjs";
+import {
+ LACE_PROFILE_ENV_FILE_ENV,
+ loadPersistentLaceProfileEnv,
+ persistentLaceProfileEnvFile,
+} from "./persistent-lace-profile.mjs";
import { runWebAppClaimFlowWasmLace } from "./web-app-claim-flow-wasm-lace.mjs";
const DEFAULT_HOST = "127.0.0.1";
@@ -14,7 +19,6 @@ const DEFAULT_PROOF_ASSET_HOSTS = Object.freeze([
"proof-assets-2m.reclaim-proof.com",
]);
const LOCAL_ENV_FILE_ENV = "RECLAIM_E2E_LOCAL_ENV_FILE";
-const PROFILE_ENV_FILE_ENV = "RECLAIM_E2E_LACE_PROFILE_ENV_FILE";
const LOCAL_MANIFEST_ENV = "RECLAIM_E2E_LOCAL_MANIFEST_PATH";
export class LocalPrClaimFlowError extends Error {
@@ -57,21 +61,21 @@ export async function runLocalPrClaimFlow(options = {}) {
LOCAL_ENV_FILE_ENV,
);
const profileEnvFile = resolveInputFile(
- initialEnv[PROFILE_ENV_FILE_ENV],
- [
- path.join(repoRoot, "output", "playwright", "lace-e2e-preprod-profile-v2", "profile.env"),
- path.join(sharedRoot, "output", "playwright", "lace-e2e-preprod-profile-v2", "profile.env"),
- ],
- PROFILE_ENV_FILE_ENV,
+ initialEnv[LACE_PROFILE_ENV_FILE_ENV],
+ [persistentLaceProfileEnvFile(repoRoot), persistentLaceProfileEnvFile(sharedRoot)],
+ LACE_PROFILE_ENV_FILE_ENV,
);
const loadEnvFile = options.loadEnvFile ?? ((file) => process.loadEnvFile(file));
loadEnvFile(localEnvFile);
- loadEnvFile(profileEnvFile);
- const port = parsePort((options.env ?? process.env).RECLAIM_E2E_LOCAL_PORT ?? DEFAULT_PORT);
+ const runtimeEnv = options.env ?? process.env;
+ const persistentProfile = loadPersistentLaceProfileEnv({ env: runtimeEnv, profileEnvFile });
+ console.log(`Reusing persistent Lace profile ${persistentProfile.name}; automatic profile creation is disabled.`);
+
+ const port = parsePort(runtimeEnv.RECLAIM_E2E_LOCAL_PORT ?? DEFAULT_PORT);
const baseUrl = `http://${DEFAULT_HOST}:${port}`;
const flowEnv = createLocalVercelEmulationEnv({
- baseEnv: { ...(options.env ?? process.env) },
+ baseEnv: { ...runtimeEnv },
branch: git.branch,
commitSha: git.commitSha,
port,
diff --git a/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.test.mjs b/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.test.mjs
index 80888294..fdd4062e 100644
--- a/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/local-web-app-claim-flow-wasm-lace.test.mjs
@@ -1,3 +1,4 @@
+import path from "node:path";
import { describe, expect, it } from "vitest";
import {
assertLocalPrContext,
@@ -8,7 +9,9 @@ import {
pinLocalDeploymentManifest,
resolveOpenPullRequest,
} from "./local-web-app-claim-flow-wasm-lace.mjs";
+import { assertPersistentLaceProfileSelection, loadPersistentLaceProfileEnv } from "./persistent-lace-profile.mjs";
import {
+ collectProofStallDiagnostic,
disposePageRoutes,
isolatePreparedClaimResponse,
prepareLaceRoleBeforeNavigation,
@@ -102,6 +105,55 @@ describe("local production PR claim flow", () => {
expect(serverEnv.NODE_ENV).toBe("production");
});
+ it("pins the guarded lane to the persistent profile stored beside profile.env", () => {
+ const profileDir = path.resolve("/repo/output/playwright/lace-e2e-preprod-profile-v2");
+ const profileEnvFile = path.join(profileDir, "profile.env");
+ const env = {
+ PW_USER_DATA_DIR: profileDir,
+ RECLAIM_E2E_LACE_WALLET_PASSWORD: "test-only-password",
+ };
+ const initializedProfileExists = (candidate) =>
+ candidate === profileDir ||
+ candidate === path.join(profileDir, "Local State") ||
+ candidate === path.join(profileDir, "Default", "Preferences") ||
+ candidate === path.join(profileDir, "Default", "Local Extension Settings");
+
+ expect(
+ assertPersistentLaceProfileSelection({ env, profileEnvFile, fileExists: initializedProfileExists }),
+ ).toMatchObject({ name: "lace-e2e-preprod-profile-v2", profileDir });
+ expect(() =>
+ assertPersistentLaceProfileSelection({
+ env: { ...env, PW_USER_DATA_DIR: "/repo/output/playwright/replacement-profile" },
+ profileEnvFile,
+ fileExists: initializedProfileExists,
+ }),
+ ).toThrowError(expect.objectContaining({ code: "persistent_lace_profile_path_mismatch" }));
+ expect(() =>
+ assertPersistentLaceProfileSelection({
+ env: { ...env, RECLAIM_E2E_LACE_WALLET_PASSWORD: "" },
+ profileEnvFile,
+ fileExists: initializedProfileExists,
+ }),
+ ).toThrowError(expect.objectContaining({ code: "persistent_lace_profile_password_missing" }));
+
+ const staleShellEnv = {
+ PW_USER_DATA_DIR: "/repo/output/playwright/stale-profile",
+ RECLAIM_E2E_LACE_WALLET_PASSWORD: "stale-password",
+ };
+ expect(
+ loadPersistentLaceProfileEnv({
+ env: staleShellEnv,
+ profileEnvFile,
+ fileExists: (candidate) => candidate === profileEnvFile || initializedProfileExists(candidate),
+ readTextFile: () => `PW_USER_DATA_DIR=${profileDir}\nRECLAIM_E2E_LACE_WALLET_PASSWORD=persisted-password\n`,
+ }),
+ ).toMatchObject({ profileDir });
+ expect(staleShellEnv).toMatchObject({
+ PW_USER_DATA_DIR: profileDir,
+ RECLAIM_E2E_LACE_WALLET_PASSWORD: "persisted-password",
+ });
+ });
+
it("resets the local origin and initializes the compromised Lace role before page creation", async () => {
const actions = [];
await prepareLaceRoleBeforeNavigation(
@@ -138,6 +190,41 @@ describe("local production PR claim flow", () => {
).resolves.toBeUndefined();
});
+ it("collects only secret-free worker readiness after a proof stall", async () => {
+ const diagnostic = await collectProofStallDiagnostic(
+ {
+ workers: () => [
+ {
+ url: () => "http://127.0.0.1:3917/proof-runtime/prover-worker.js?secret=must-not-survive",
+ evaluate: async () => ({
+ crossOriginIsolated: true,
+ discoverEntrypoint: true,
+ preflightEntrypoint: true,
+ proveEntrypoint: true,
+ resourceCount: 4,
+ wasmProverReady: true,
+ }),
+ },
+ ],
+ evaluate: async () => ({ headings: ["Create proofs"], online: true, progress: [] }),
+ },
+ new Date(Date.now() - 100),
+ );
+
+ expect(diagnostic).toMatchObject({
+ collected: true,
+ page: { headings: ["Create proofs"], online: true, progress: [] },
+ workers: [
+ {
+ url: "http://127.0.0.1:3917/proof-runtime/prover-worker.js",
+ wasmProverReady: true,
+ discoverEntrypoint: true,
+ },
+ ],
+ });
+ expect(JSON.stringify(diagnostic)).not.toContain("must-not-survive");
+ });
+
it("isolates the prepared claim when the Lace wallet has other valid claims", () => {
const payload = {
available: true,
diff --git a/apps/ownership-proof-web/e2e/preprod/persistent-lace-profile.mjs b/apps/ownership-proof-web/e2e/preprod/persistent-lace-profile.mjs
new file mode 100644
index 00000000..02af1159
--- /dev/null
+++ b/apps/ownership-proof-web/e2e/preprod/persistent-lace-profile.mjs
@@ -0,0 +1,101 @@
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { parseEnv } from "node:util";
+
+export const PERSISTENT_LACE_PROFILE_DIR_NAME = "lace-e2e-preprod-profile-v2";
+export const LACE_PROFILE_ENV_FILE_ENV = "RECLAIM_E2E_LACE_PROFILE_ENV_FILE";
+export const LACE_WALLET_PASSWORD_ENV = "RECLAIM_E2E_LACE_WALLET_PASSWORD";
+
+const INITIALIZED_PROFILE_PATHS = Object.freeze([
+ "Local State",
+ path.join("Default", "Preferences"),
+ path.join("Default", "Local Extension Settings"),
+]);
+
+export class PersistentLaceProfileError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = "PersistentLaceProfileError";
+ this.code = code;
+ }
+}
+
+export function persistentLaceProfileEnvFile(repoRoot) {
+ return path.join(repoRoot, "output", "playwright", PERSISTENT_LACE_PROFILE_DIR_NAME, "profile.env");
+}
+
+export function loadPersistentLaceProfileEnv(options) {
+ const env = options.env;
+ const profileEnvFile = path.resolve(options.profileEnvFile);
+ const fileExists = options.fileExists ?? existsSync;
+ const readTextFile = options.readTextFile ?? ((filePath) => readFileSync(filePath, "utf8"));
+ if (!fileExists(profileEnvFile)) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_env_missing",
+ "The persistent Lace profile.env is missing. Restore it with the existing profile; do not bootstrap a replacement.",
+ );
+ }
+
+ let profileEnv;
+ try {
+ profileEnv = parseEnv(readTextFile(profileEnvFile));
+ } catch {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_env_invalid",
+ "The persistent Lace profile.env could not be parsed.",
+ );
+ }
+ const effectiveEnv = { ...env, ...profileEnv };
+ const profile = assertPersistentLaceProfileSelection({
+ env: effectiveEnv,
+ fileExists,
+ profileEnvFile,
+ });
+ Object.assign(env, profileEnv);
+ return profile;
+}
+
+export function assertPersistentLaceProfileSelection({ env, profileEnvFile, fileExists = existsSync }) {
+ const expectedProfileDir = path.dirname(path.resolve(profileEnvFile));
+ const configured = String(env?.PW_USER_DATA_DIR ?? "").trim();
+ if (!configured) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_path_missing",
+ "The persistent Lace profile.env must set PW_USER_DATA_DIR.",
+ );
+ }
+ const configuredProfileDir = path.resolve(configured);
+ if (configuredProfileDir !== expectedProfileDir) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_path_mismatch",
+ "PW_USER_DATA_DIR must select the persistent Lace profile stored beside the chosen profile.env; refusing to use or create another profile.",
+ );
+ }
+ if (!fileExists(configuredProfileDir)) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_missing",
+ "The persistent Lace profile is missing. Restore it together with profile.env; do not bootstrap a replacement profile.",
+ );
+ }
+ if (!hasInitializedLaceProfileState(configuredProfileDir, fileExists)) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_uninitialized",
+ "The persistent Lace profile is uninitialized. Refusing to launch Chromium because that could create a replacement profile; restore the existing profile and profile.env instead.",
+ );
+ }
+ if (!String(env?.[LACE_WALLET_PASSWORD_ENV] ?? "").trim()) {
+ throw new PersistentLaceProfileError(
+ "persistent_lace_profile_password_missing",
+ `The persistent Lace profile.env must set ${LACE_WALLET_PASSWORD_ENV}.`,
+ );
+ }
+ return Object.freeze({
+ envFile: path.resolve(profileEnvFile),
+ name: path.basename(configuredProfileDir),
+ profileDir: configuredProfileDir,
+ });
+}
+
+export function hasInitializedLaceProfileState(profileDir, fileExists = existsSync) {
+ return INITIALIZED_PROFILE_PATHS.every((relativePath) => fileExists(path.join(profileDir, relativePath)));
+}
diff --git a/apps/ownership-proof-web/e2e/preprod/proof-stage.mjs b/apps/ownership-proof-web/e2e/preprod/proof-stage.mjs
index b141e523..8d69009d 100644
--- a/apps/ownership-proof-web/e2e/preprod/proof-stage.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/proof-stage.mjs
@@ -148,7 +148,7 @@ export async function runDestinationProofStage(options = {}) {
const claimDeployment = await fetchAppJson(fetchFn, appTarget.baseUrl, "/claim-api/deployment");
const deployment = assertClaimDeployment(claimDeployment);
const helperStatus = await fetchHelperJson(fetchFn, helperUrl, "/status", appOrigin, token, "GET");
- const helperProfile = assertHelperDestinationProfile(helperStatus, deployment.verifierVkHash);
+ const helperProfile = assertHelperDestinationProfile(helperStatus, deployment.proofVkHash);
const matchingUtxos = await loadMatchingReclaimUtxos(fetchFn, appTarget.baseUrl, impactedCredential);
if (matchingUtxos.length < batchSize) {
throw new PreprodDestinationProofStageError(
@@ -185,7 +185,7 @@ export async function runDestinationProofStage(options = {}) {
},
include_debug_path: false,
});
- const proofSummaries = assertProofArtifacts(helperResponse, draft, deployment.verifierVkHash);
+ const proofSummaries = assertProofArtifacts(helperResponse, draft, deployment.proofVkHash);
const screenshotPath = options.page
? path.join(outputDir, "screenshots", "generate-destination-bound-proofs.png")
@@ -207,7 +207,8 @@ export async function runDestinationProofStage(options = {}) {
network: deployment.network,
networkId: deployment.networkId,
proofProfile: draft.proofProfile,
- verifierVkHash: deployment.verifierVkHash,
+ proofVkHash: deployment.proofVkHash,
+ onChainVerifierVkHash: deployment.verifierVkHash,
helper: {
helperUrl,
tokenRequired: true,
@@ -364,10 +365,14 @@ function assertClaimDeployment(response) {
"Claim deployment must use destination-address-v1.",
);
}
- if (typeof deployment.id !== "string" || typeof deployment.verifierVkHash !== "string") {
+ if (
+ typeof deployment.id !== "string" ||
+ typeof deployment.verifierVkHash !== "string" ||
+ typeof deployment.proofVkHash !== "string"
+ ) {
throw new PreprodDestinationProofStageError(
"claim_deployment_malformed",
- "Claim deployment is missing id or verifier hash.",
+ "Claim deployment is missing id, on-chain verifier hash, or native proof verifier hash.",
);
}
return deployment;
diff --git a/apps/ownership-proof-web/e2e/preprod/proof-stage.test.mjs b/apps/ownership-proof-web/e2e/preprod/proof-stage.test.mjs
index 2822c389..0d92cade 100644
--- a/apps/ownership-proof-web/e2e/preprod/proof-stage.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/proof-stage.test.mjs
@@ -14,6 +14,7 @@ import {
const tempDirs = [];
const verifierVkHash = "b".repeat(64);
+const onChainVerifierVkHash = "c".repeat(64);
const impactedCredential = "19e07fbcc7577359d6c51f1e49cf1b0bf4c943b48ba4e4905a8702e4";
const safeCredential = "2a".repeat(28);
const safeAddress =
@@ -89,6 +90,8 @@ describe("destination-bound proof preprod stage", () => {
provider: "desktop-helper",
deploymentId: deployment().id,
proofProfile: "single-destination",
+ proofVkHash: verifierVkHash,
+ onChainVerifierVkHash,
helper: {
helperUrl: "http://127.0.0.1:49152",
token: "[redacted]",
@@ -411,7 +414,8 @@ function deployment() {
id: "preprod:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1234567890abcdef1234567890abcdef12345678",
network: "Preprod",
networkId: 0,
- verifierVkHash,
+ verifierVkHash: onChainVerifierVkHash,
+ proofVkHash: verifierVkHash,
};
}
diff --git a/apps/ownership-proof-web/e2e/preprod/real-lace-driver.mjs b/apps/ownership-proof-web/e2e/preprod/real-lace-driver.mjs
index 25b4011a..6b9fce9b 100644
--- a/apps/ownership-proof-web/e2e/preprod/real-lace-driver.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/real-lace-driver.mjs
@@ -9,6 +9,7 @@ import {
redactAddress,
validatePreprodWalletFile,
} from "./preflight.mjs";
+import { hasInitializedLaceProfileState } from "./persistent-lace-profile.mjs";
export const LACE_EXTENSION_DIR_ENV = "RECLAIM_E2E_LACE_EXTENSION_DIR";
export const LACE_WALLET_PASSWORD_ENV = "RECLAIM_E2E_LACE_WALLET_PASSWORD";
@@ -56,7 +57,7 @@ export async function createRealLaceProfileDriverFromEnv(options = {}) {
);
}
const manifest = readLaceManifest(manifestPath, readTextFile);
- const userDataDir = requiredString(env.PW_USER_DATA_DIR, "PW_USER_DATA_DIR");
+ const userDataDir = requiredInitializedLaceProfileDirectory(env.PW_USER_DATA_DIR, fileExists);
const walletFile = loadWalletFile(env.PREPROD_TEST_WALLETS_FILE, { cwd, repoRoot, fileExists, readTextFile });
const validation = validatePreprodWalletFile(walletFile);
if (!validation.ok) {
@@ -618,6 +619,18 @@ function requiredExistingDirectory(value, field, fileExists) {
return resolved;
}
+function requiredInitializedLaceProfileDirectory(value, fileExists) {
+ const field = "PW_USER_DATA_DIR";
+ const resolved = requiredExistingDirectory(value, field, fileExists);
+ if (!hasInitializedLaceProfileState(resolved, fileExists)) {
+ throw new PreprodRealLaceDriverError(
+ "pw_user_data_dir_uninitialized",
+ "PW_USER_DATA_DIR is not the initialized persistent Lace test profile. Refusing to launch Chromium because that could create a replacement profile; restore the existing profile and profile.env instead.",
+ );
+ }
+ return resolved;
+}
+
async function resolveExtensionId(context, manifestPath) {
const serviceWorker =
context.serviceWorkers()[0] ??
@@ -738,25 +751,21 @@ async function approveLaceDappConnection(context, extensionId, accountLabel, fal
continue;
}
await accountDropdown.click();
- const accountOptions = page.locator('[data-testid^="dropdown-menu-item-"]');
- const configuredAccount = accountOptions.filter({ hasText: accountLabel }).first();
- let account = configuredAccount;
- if (!(await waitUntilVisible(configuredAccount, 5_000))) {
- const visibleAccounts = [];
- for (let index = 0; index < (await accountOptions.count()); index += 1) {
- const candidate = accountOptions.nth(index);
- if (await safeVisible(candidate)) {
- visibleAccounts.push(candidate);
- }
- }
- if (visibleAccounts.length !== 1) {
- throw new PreprodRealLaceDriverError(
- "lace_connection_account_missing",
- `Lace connection prompt does not expose the configured account ${accountLabel} or one unambiguous source account.`,
- );
+ const visibleAccounts = await waitForVisibleLaceDappAccounts(page, 5_000);
+ const configuredAccounts = [];
+ for (const candidate of visibleAccounts) {
+ const label = (await candidate.getAttribute("aria-label"))?.trim();
+ if (label === accountLabel) {
+ configuredAccounts.push(candidate);
}
- account = visibleAccounts[0];
}
+ if (configuredAccounts.length > 1 || (configuredAccounts.length === 0 && visibleAccounts.length !== 1)) {
+ throw new PreprodRealLaceDriverError(
+ "lace_connection_account_missing",
+ `Lace connection prompt does not expose the configured account ${accountLabel} or one unambiguous source account.`,
+ );
+ }
+ const account = configuredAccounts[0] ?? visibleAccounts[0];
await account.click();
if (onBeforeApprove) {
await onBeforeApprove(page, authorize);
@@ -772,6 +781,26 @@ async function approveLaceDappConnection(context, extensionId, accountLabel, fal
);
}
+async function waitForVisibleLaceDappAccounts(page, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const candidates = page.locator('[data-testid^="dropdown-menu-item-"]');
+ const accounts = [];
+ for (let index = 0; index < (await candidates.count()); index += 1) {
+ const candidate = candidates.nth(index);
+ const testId = await candidate.getAttribute("data-testid");
+ if (/^dropdown-menu-item-\d+$/u.test(testId ?? "") && (await safeVisible(candidate))) {
+ accounts.push(candidate);
+ }
+ }
+ if (accounts.length > 0) {
+ return accounts;
+ }
+ await sleep(EXTENSION_POLL_MS);
+ }
+ return [];
+}
+
async function disconnectLaceDappOrigin(
context,
extensionId,
@@ -848,7 +877,7 @@ function normalizeDappOrigin(value) {
}
}
-async function submitVisibleLaceAuthentication(page, password) {
+async function submitVisibleLaceAuthentication(page, password, options = {}) {
if (!page || page.isClosed()) {
return false;
}
@@ -886,11 +915,11 @@ async function submitVisibleLaceAuthentication(page, password) {
.first()
.waitFor({ state: "hidden", timeout: EXTENSION_TIMEOUT_MS })
.then(() => true)
- .catch(() => false);
+ .catch(() => options.allowPageClose === true && page.isClosed());
if (!dismissed) {
throw new PreprodRealLaceDriverError(
"lace_signing_authentication_failed",
- "Lace rejected the configured wallet password while signing.",
+ "Lace did not dismiss the signing authentication prompt after confirmation.",
);
}
return true;
@@ -903,7 +932,7 @@ async function settleLaceSigningAuthentication(page, password) {
if (!page || page.isClosed()) {
return;
}
- if (await submitVisibleLaceAuthentication(page, password)) {
+ if (await submitVisibleLaceAuthentication(page, password, { allowPageClose: true })) {
return;
}
const signingButton = page.locator(LACE_CARDANO_SIGN_SELECTOR).first();
diff --git a/apps/ownership-proof-web/e2e/preprod/real-lace-driver.test.mjs b/apps/ownership-proof-web/e2e/preprod/real-lace-driver.test.mjs
index 0c2f568b..7c5de5d9 100644
--- a/apps/ownership-proof-web/e2e/preprod/real-lace-driver.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/real-lace-driver.test.mjs
@@ -38,6 +38,7 @@ describe("real Lace profile driver", () => {
const userDataDir = path.join(repo, "lace-profile");
const walletPath = path.join(repo, "wallets.local.json");
mkdirSync(extensionDir, { recursive: true });
+ initializeLaceProfile(userDataDir);
writeFileSync(path.join(extensionDir, "manifest.json"), JSON.stringify({ manifest_version: 3 }), "utf8");
writeFileSync(walletPath, JSON.stringify(validWalletFile()), "utf8");
@@ -80,16 +81,18 @@ describe("real Lace profile driver", () => {
it("keeps mnemonic material out of the public driver summary", async () => {
const repo = tempDir();
const extensionDir = path.join(repo, "lace-extension");
+ const userDataDir = path.join(repo, "lace-profile");
const walletPath = path.join(repo, "wallets.local.json");
const walletFile = validWalletFile();
mkdirSync(extensionDir, { recursive: true });
+ initializeLaceProfile(userDataDir);
writeFileSync(path.join(extensionDir, "manifest.json"), JSON.stringify({ manifest_version: 3 }), "utf8");
writeFileSync(walletPath, JSON.stringify(walletFile), "utf8");
const driver = await createRealLaceProfileDriverFromEnv({
env: {
[LACE_EXTENSION_DIR_ENV]: extensionDir,
- PW_USER_DATA_DIR: path.join(repo, "lace-profile"),
+ PW_USER_DATA_DIR: userDataDir,
PREPROD_TEST_WALLETS_FILE: walletPath,
},
cwd: repo,
@@ -103,6 +106,30 @@ describe("real Lace profile driver", () => {
expect(await driver.recoveryPhraseForBrowserUi("reclaim_funder")).toBe(walletFile.reclaim_funder.mnemonic);
});
+ it("refuses to let Chromium create a replacement profile from an empty directory", async () => {
+ const repo = tempDir();
+ const extensionDir = path.join(repo, "lace-extension");
+ const userDataDir = path.join(repo, "empty-profile");
+ const walletPath = path.join(repo, "wallets.local.json");
+ mkdirSync(extensionDir, { recursive: true });
+ mkdirSync(userDataDir, { recursive: true });
+ writeFileSync(path.join(extensionDir, "manifest.json"), JSON.stringify({ manifest_version: 3 }), "utf8");
+ writeFileSync(walletPath, JSON.stringify(validWalletFile()), "utf8");
+
+ await expect(
+ createRealLaceProfileDriverFromEnv({
+ env: {
+ [LACE_EXTENSION_DIR_ENV]: extensionDir,
+ PW_USER_DATA_DIR: userDataDir,
+ PREPROD_TEST_WALLETS_FILE: walletPath,
+ },
+ cwd: repo,
+ repoRoot: repo,
+ deriveRoleState,
+ }),
+ ).rejects.toMatchObject({ code: "pw_user_data_dir_uninitialized" });
+ });
+
it("refuses any signing request for the compromised role", async () => {
const compromised = deriveRoleState({
role: "compromised_user",
@@ -240,6 +267,62 @@ describe("real Lace profile driver", () => {
expect(driver.roleState("safe_claim_destination").signAttempts).toBe(1);
});
+ it("accepts Lace closing the signing page after successful authentication", async () => {
+ const safe = deriveRoleState({
+ role: "safe_claim_destination",
+ mnemonic: words("delta", 12),
+ label: "safe_claim_dest",
+ });
+ const driver = new RealLaceProfileDriver({
+ browserChannel: "chromium",
+ extensionDir: "/tmp/lace",
+ extensionRoute: "expo/index.html",
+ manifestPath: "/tmp/lace/manifest.json",
+ providerId: "lace",
+ providerName: "Lace",
+ roleLabels: { safe_claim_destination: "safe_claim_dest" },
+ roleStates: new Map([["safe_claim_destination", safe]]),
+ userDataDir: "/tmp/profile",
+ walletPassword: "test-password",
+ });
+ const { context, clicks } = fakeLaceSignContext({ closeOnAuthentication: true });
+ driver.context = context;
+ driver.extensionId = "laceextensionid";
+
+ await driver.approveWalletSigning("safe_claim_destination", "claim");
+
+ expect(clicks).toEqual(["sign", "password:test-password", "authenticate:auto-waited", "page:closed"]);
+ expect(driver.roleState("safe_claim_destination").signAttempts).toBe(1);
+ });
+
+ it("fails closed when the signing authentication prompt remains open", async () => {
+ const safe = deriveRoleState({
+ role: "safe_claim_destination",
+ mnemonic: words("delta", 12),
+ label: "safe_claim_dest",
+ });
+ const driver = new RealLaceProfileDriver({
+ browserChannel: "chromium",
+ extensionDir: "/tmp/lace",
+ extensionRoute: "expo/index.html",
+ manifestPath: "/tmp/lace/manifest.json",
+ providerId: "lace",
+ providerName: "Lace",
+ roleLabels: { safe_claim_destination: "safe_claim_dest" },
+ roleStates: new Map([["safe_claim_destination", safe]]),
+ userDataDir: "/tmp/profile",
+ walletPassword: "wrong-password",
+ });
+ const { context } = fakeLaceSignContext({ rejectAuthentication: true });
+ driver.context = context;
+ driver.extensionId = "laceextensionid";
+
+ await expect(driver.approveWalletSigning("safe_claim_destination", "claim")).rejects.toMatchObject({
+ code: "lace_signing_authentication_failed",
+ });
+ expect(driver.roleState("safe_claim_destination").signAttempts ?? 0).toBe(0);
+ });
+
it("selects the Lace 2.1.1 DApp account by label before authorizing", async () => {
const compromised = deriveRoleState({
role: "compromised_user",
@@ -296,6 +379,34 @@ describe("real Lace profile driver", () => {
expect(clicks).toEqual(["dropdown", "account:Source Account", "authorize"]);
}, 10_000);
+ it("fails closed when two real Lace account rows do not match the configured role", async () => {
+ const compromised = deriveRoleState({
+ role: "compromised_user",
+ mnemonic: words("cable", 12),
+ label: "missing_role",
+ });
+ const driver = new RealLaceProfileDriver({
+ browserChannel: "chromium",
+ extensionDir: "/tmp/lace",
+ extensionRoute: "expo/index.html",
+ manifestPath: "/tmp/lace/manifest.json",
+ providerId: "lace",
+ providerName: "Lace",
+ roleLabels: { compromised_user: "missing_role" },
+ roleStates: new Map([["compromised_user", compromised]]),
+ userDataDir: "/tmp/profile",
+ walletPassword: "test-password",
+ });
+ const { context, clicks } = fakeLaceDappConnectContext("missing_role", ["Source Account", "Safe Account"]);
+ driver.context = context;
+ driver.extensionId = "laceextensionid";
+
+ await expect(driver.approveDappConnection("compromised_user")).rejects.toMatchObject({
+ code: "lace_connection_account_missing",
+ });
+ expect(clicks).toEqual(["dropdown"]);
+ });
+
it("disconnects the exact local origin through Lace Authorized DApps", async () => {
const safe = deriveRoleState({
role: "safe_claim_destination",
@@ -399,6 +510,11 @@ function fakeExtensionContext() {
function fakeLaceDappConnectContext(accountLabel, availableAccountLabels = [accountLabel]) {
const clicks = [];
let dropdownOpen = false;
+ const accountNodes = availableAccountLabels.flatMap((label, index) => [
+ { kind: "account", label, testId: `dropdown-menu-item-${index}` },
+ { kind: "account-child", label: label.slice(0, 2).toUpperCase(), testId: `dropdown-menu-item-${index}-avatar` },
+ { kind: "account-child", label, testId: `dropdown-menu-item-${index}-text` },
+ ]);
const page = {
url() {
return "chrome-extension://laceextensionid/expo/index.html#/cardano-dapp-connect";
@@ -407,22 +523,27 @@ function fakeLaceDappConnectContext(accountLabel, availableAccountLabels = [acco
return false;
},
locator(selector) {
- const makeLocator = (kind, hasText = null) => ({
+ const makeLocator = (kind, accountNode = null) => ({
first() {
- return makeLocator(kind, hasText);
- },
- filter(options) {
- return makeLocator("account", options.hasText);
+ return makeLocator(kind, accountNode);
},
async count() {
- return kind === "account-list" ? availableAccountLabels.length : 1;
+ return kind === "account-list" ? accountNodes.length : 1;
},
nth(index) {
- return makeLocator("account", availableAccountLabels[index]);
+ return makeLocator(accountNodes[index]?.kind ?? "missing", accountNodes[index]);
+ },
+ async getAttribute(name) {
+ if (name === "data-testid") return accountNode?.testId ?? null;
+ if (name === "aria-label" && kind === "account") return accountNode?.label ?? null;
+ return null;
+ },
+ async innerText() {
+ return accountNode?.label ?? "";
},
async isVisible() {
if (kind === "dropdown" || kind === "authorize") return true;
- if (kind === "account") return dropdownOpen && availableAccountLabels.includes(hasText);
+ if (kind === "account" || kind === "account-child") return dropdownOpen;
return false;
},
async click() {
@@ -430,7 +551,7 @@ function fakeLaceDappConnectContext(accountLabel, availableAccountLabels = [acco
dropdownOpen = true;
clicks.push("dropdown");
} else if (kind === "account") {
- clicks.push(`account:${hasText}`);
+ clicks.push(`account:${accountNode.label}`);
} else if (kind === "authorize") {
clicks.push("authorize");
}
@@ -452,10 +573,11 @@ function fakeLaceDappConnectContext(accountLabel, availableAccountLabels = [acco
};
}
-function fakeLaceSignContext() {
+function fakeLaceSignContext(options = {}) {
const clicks = [];
let signClicked = false;
let authVisible = false;
+ let pageClosed = false;
function makeLocator(kind) {
const locator = {
@@ -479,11 +601,20 @@ function fakeLaceSignContext() {
clicks.push("sign");
}
if (kind === "auth-confirm") {
- authVisible = false;
+ if (pageOptions.rejectAuthentication !== true) {
+ authVisible = false;
+ }
clicks.push(options?.force === true ? "authenticate:forced" : "authenticate:auto-waited");
+ if (pageOptions.closeOnAuthentication === true) {
+ pageClosed = true;
+ clicks.push("page:closed");
+ }
}
},
async waitFor(options) {
+ if (pageClosed) {
+ throw new Error("signing page closed");
+ }
if (kind === "auth-body" && options.state === "hidden" && !authVisible) {
return;
}
@@ -493,12 +624,13 @@ function fakeLaceSignContext() {
return locator;
}
+ const pageOptions = options;
const page = {
url() {
return "chrome-extension://laceextensionid/expo/index.html#/cardano-sign-tx";
},
isClosed() {
- return false;
+ return pageClosed;
},
locator(selector) {
if (selector === 'body:has([data-testid="sign-tx-origin"]) [data-testid="dapp-connector-primary-button"]') {
@@ -645,3 +777,10 @@ function tempDir() {
tempDirs.push(dir);
return dir;
}
+
+function initializeLaceProfile(userDataDir) {
+ const defaultDir = path.join(userDataDir, "Default");
+ mkdirSync(path.join(defaultDir, "Local Extension Settings"), { recursive: true });
+ writeFileSync(path.join(userDataDir, "Local State"), "{}", "utf8");
+ writeFileSync(path.join(defaultDir, "Preferences"), "{}", "utf8");
+}
diff --git a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.mjs b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.mjs
index 791420ab..dbb647e0 100644
--- a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.mjs
@@ -343,12 +343,19 @@ export function validateBrowserWasmClaimDeployment(response) {
"The target deployment does not expose a valid pinned verifier-key hash.",
);
}
+ if (!/^(?:blake2b256:)?[0-9a-f]{64}$/u.test(String(deployment.proofVkHash ?? ""))) {
+ throw new WebAppClaimFlowContractError(
+ "preprod_manifest_incoherent",
+ "The target deployment does not expose a valid native proof verifier-key hash.",
+ );
+ }
return Object.freeze({
deploymentId: deployment.id,
network: deployment.network,
networkId: deployment.networkId,
sourceCommit: deployment.sourceCommit,
verifierVkHash: deployment.verifierVkHash,
+ proofVkHash: deployment.proofVkHash,
proofAssetId: deployment.proof.browser_proving.id ?? null,
});
}
diff --git a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.test.mjs b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.test.mjs
index 09dd5166..962d2969 100644
--- a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.test.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-contract.test.mjs
@@ -173,6 +173,7 @@ describe("web-app claim flow contract", () => {
networkId: 0,
sourceCommit: commit,
verifierVkHash: "blake2b256:" + "d".repeat(64),
+ proofVkHash: "blake2b256:" + "e".repeat(64),
proof: { browser_proving: { id: "browser-assets", enabled: true } },
},
}),
@@ -185,6 +186,7 @@ describe("web-app claim flow contract", () => {
network: "Preprod",
networkId: 0,
verifierVkHash: "d".repeat(64),
+ proofVkHash: "e".repeat(64),
proof: { browser_proving: { enabled: false } },
},
}),
diff --git a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-wasm-lace.mjs b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-wasm-lace.mjs
index fd7f3ec1..fc0b5a5b 100644
--- a/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-wasm-lace.mjs
+++ b/apps/ownership-proof-web/e2e/preprod/web-app-claim-flow-wasm-lace.mjs
@@ -30,6 +30,7 @@ const COMPROMISED_ROLE = "compromised_user";
const SAFE_ROLE = "safe_claim_destination";
const DEFAULT_UI_TIMEOUT_MS = 120_000;
const PROOF_TIMEOUT_MS = 10 * 60_000;
+const PROOF_STALL_DIAGNOSTIC_MS = 90_000;
const CONFIRMATION_TIMEOUT_MS = 5 * 60_000;
const CONFIRMATION_POLL_MS = 5_000;
@@ -247,10 +248,18 @@ export async function runWebAppClaimFlowWasmLace(options = {}) {
await expectHeading(page, "Connect safe wallet");
await capture("07-safe-wallet.png", page, "safe-wallet");
+ await page.waitForFunction(
+ (storageKey) => Boolean(globalThis.localStorage.getItem(storageKey)),
+ "proof-tool.claim-flow.resume.v1",
+ );
await walletDriver.disconnectDappOrigin(config.baseUrl, {
beforeDisconnect: (extensionPage) =>
capture("08-lace-impacted-disconnect.png", extensionPage, "lace-impacted-disconnect"),
});
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expectHeading(page, "Verify this recovery service");
+ await page.getByRole("button", { name: "Resume", exact: true }).click();
+ await expectHeading(page, "Connect safe wallet");
await walletDriver.connectRole(page, SAFE_ROLE, "claim-wallet-option");
await page.getByRole("button", { name: "Connect safe wallet", exact: true }).click();
await walletDriver.approveDappConnection(SAFE_ROLE, {
@@ -279,6 +288,12 @@ export async function runWebAppClaimFlowWasmLace(options = {}) {
await page.getByLabel(`Recovery word ${index + 1}`, { exact: true }).fill(word);
}
await page.getByRole("button", { name: "Generate proofs", exact: true }).click();
+ const proofStartedAt = now();
+ run.journey.browserProof = {
+ startedAt: proofStartedAt.toISOString(),
+ historicalBaselineMilliseconds: 56_565,
+ };
+ persistRun(runPath, run);
await page
.getByText("Proof generation is running in this browser", { exact: false })
.waitFor({ timeout: PROOF_TIMEOUT_MS });
@@ -286,7 +301,15 @@ export async function runWebAppClaimFlowWasmLace(options = {}) {
recoveryPhraseEgressGuard.assertClear();
await capture("13-proofs-generating.png", page, "create-proofs-generating");
- await expectHeading(page, "Proofs ready", PROOF_TIMEOUT_MS);
+ const cancelStallDiagnostic = scheduleProofStallDiagnostic({ page, proofStartedAt, run, runPath });
+ try {
+ await expectHeading(page, "Proofs ready", PROOF_TIMEOUT_MS);
+ } finally {
+ cancelStallDiagnostic();
+ }
+ run.journey.browserProof.completedAt = now().toISOString();
+ run.journey.browserProof.durationMilliseconds = Math.max(0, now().getTime() - proofStartedAt.getTime());
+ persistRun(runPath, run);
recoveryPhraseEgressGuard.assertClear();
await capture("14-proofs-ready.png", page, "create-proofs-complete");
await page.getByRole("button", { name: "Continue to current batch", exact: true }).click();
@@ -404,6 +427,86 @@ export async function runWebAppClaimFlowWasmLace(options = {}) {
};
}
+export function scheduleProofStallDiagnostic({
+ page,
+ proofStartedAt,
+ run,
+ runPath,
+ delayMs = PROOF_STALL_DIAGNOSTIC_MS,
+}) {
+ let cancelled = false;
+ const timeout = setTimeout(async () => {
+ if (cancelled) return;
+ const diagnostic = await collectProofStallDiagnostic(page, proofStartedAt).catch(() => ({
+ collected: false,
+ elapsedMilliseconds: Math.max(0, Date.now() - proofStartedAt.getTime()),
+ }));
+ if (cancelled) return;
+ run.journey.browserProof.stallDiagnostic = diagnostic;
+ persistRun(runPath, run);
+ }, delayMs);
+ return () => {
+ cancelled = true;
+ clearTimeout(timeout);
+ };
+}
+
+export async function collectProofStallDiagnostic(page, proofStartedAt) {
+ const workers = await Promise.all(
+ page.workers().map(async (worker) => {
+ const state = await worker
+ .evaluate(() => ({
+ crossOriginIsolated: globalThis.crossOriginIsolated === true,
+ wasmProverReady: globalThis.__wasmProverReady === true,
+ discoverEntrypoint: typeof globalThis.discoverCredentialPaths === "function",
+ preflightEntrypoint: typeof globalThis.preflightProofAssets === "function",
+ proveEntrypoint: typeof globalThis.proveDestination === "function",
+ resourceCount: performance.getEntriesByType("resource").length,
+ }))
+ .catch(() => ({ evaluationUnavailable: true }));
+ return {
+ url: safeDiagnosticUrl(worker.url()),
+ ...state,
+ };
+ }),
+ );
+ const pageState = await page
+ .evaluate(() => ({
+ online: navigator.onLine,
+ headings: [...document.querySelectorAll("h1, h2, h3")]
+ .filter((heading) => {
+ const style = getComputedStyle(heading);
+ return style.display !== "none" && style.visibility !== "hidden";
+ })
+ .map((heading) => String(heading.textContent ?? "").trim())
+ .filter(Boolean)
+ .slice(0, 12),
+ progress: [...document.querySelectorAll('[role="progressbar"]')]
+ .map((element) => ({
+ now: element.getAttribute("aria-valuenow"),
+ min: element.getAttribute("aria-valuemin"),
+ max: element.getAttribute("aria-valuemax"),
+ }))
+ .slice(0, 4),
+ }))
+ .catch(() => ({ evaluationUnavailable: true }));
+ return {
+ collected: true,
+ elapsedMilliseconds: Math.max(0, Date.now() - proofStartedAt.getTime()),
+ page: pageState,
+ workers,
+ };
+}
+
+function safeDiagnosticUrl(value) {
+ try {
+ const parsed = new URL(value);
+ return `${parsed.origin}${parsed.pathname}`;
+ } catch {
+ return "unavailable";
+ }
+}
+
export async function disposePageRoutes(page) {
await page?.unrouteAll({ behavior: "ignoreErrors" }).catch(() => undefined);
}
diff --git a/apps/ownership-proof-web/lib/claim-server/build-submit.ts b/apps/ownership-proof-web/lib/claim-server/build-submit.ts
index 39de672f..511e22de 100644
--- a/apps/ownership-proof-web/lib/claim-server/build-submit.ts
+++ b/apps/ownership-proof-web/lib/claim-server/build-submit.ts
@@ -300,7 +300,7 @@ export async function prepareClaimBuildPreflight(
);
}
- const proofs = assertProofArtifacts(raw.proofArtifacts, draft, deployment.verifierVkHash);
+ const proofs = assertProofArtifacts(raw.proofArtifacts, draft, deployment.proofVkHash);
const proofHexes = proofs.map((proof) => proof.proofHex);
const paramsReferenceInput = await loadParamsReferenceInput(provider, deployment);
const referenceScripts = await loadClaimReferenceScripts(provider, deployment);
@@ -627,7 +627,7 @@ function makeReclaimGlobalRedeemer(
destinationOutputStartIndex: number | bigint,
fullProofs: string[],
publicInputDigests: string[],
- verifierVkHash: string,
+ batchTranscriptVkHash: string,
): string {
if (fullProofs.length !== publicInputDigests.length) {
throw new Error("reclaim v2 proof/digest list lengths differ");
@@ -644,7 +644,7 @@ function makeReclaimGlobalRedeemer(
// guard. The transaction carries only the parallel lists; the validator
// independently recreates this transcript using its embedded hash.
buildBatchTranscriptV2(
- decodeBlake2b256(verifierVkHash, "deployment verifier key hash"),
+ decodeBlake2b256(batchTranscriptVkHash, "deployment batch transcript Cardano verifier-key hash"),
fullProofs.map((proof, index) => decodeHexBytes(proof, `reclaim v2 proof ${index}`)),
publicInputDigests.map((digest, index) => decodeHexBytes(digest, `reclaim v2 public input digest ${index}`)),
);
diff --git a/apps/ownership-proof-web/lib/claim-server/claim-server.test.ts b/apps/ownership-proof-web/lib/claim-server/claim-server.test.ts
index 69635cf2..96bd5e37 100644
--- a/apps/ownership-proof-web/lib/claim-server/claim-server.test.ts
+++ b/apps/ownership-proof-web/lib/claim-server/claim-server.test.ts
@@ -53,6 +53,7 @@ const PARAMS_POLICY = "55".repeat(28);
const PARAMS_TOKEN_NAME = "5245434c41494d";
const PARAMS_HOLDER_ADDRESS = credentialToAddress("Preprod", scriptHashToCredential("66".repeat(28)));
const VK_HASH = "22".repeat(32);
+const CARDANO_VK_HASH = "44".repeat(32);
const SAFE_ADDRESS = credentialToAddress("Preprod", keyHashToCredential(SAFE_CREDENTIAL));
const RECLAIM_ADDRESS = credentialToAddress("Preprod", scriptHashToCredential(RECLAIM_SCRIPT));
const DEPLOYMENT: ReclaimDeployment = {
@@ -65,9 +66,10 @@ const DEPLOYMENT: ReclaimDeployment = {
reclaimGlobalScriptHash: RECLAIM_GLOBAL_SCRIPT,
paramsCurrencySymbol: PARAMS_POLICY,
paramsTokenName: PARAMS_TOKEN_NAME,
- verifierVkHash: VK_HASH,
+ verifierVkHash: CARDANO_VK_HASH,
+ proofVkHash: VK_HASH,
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2",
- reclaimGlobalBatchTranscriptVkHash: VK_HASH,
+ reclaimGlobalBatchTranscriptVkHash: CARDANO_VK_HASH,
contractVersion: "test",
sourceCommit: "source",
paramsUtxo: {
@@ -96,7 +98,7 @@ const DEPLOYMENT: ReclaimDeployment = {
const STATEMENT_BOUND_V2_DEPLOYMENT: ReclaimDeployment = {
...DEPLOYMENT,
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2",
- reclaimGlobalBatchTranscriptVkHash: VK_HASH,
+ reclaimGlobalBatchTranscriptVkHash: CARDANO_VK_HASH,
batching: {
default_utxo_count: CLAIM_DEFAULT_BATCH_CAP,
optimization_utxo_count: CLAIM_OPTIMIZATION_BATCH_CAP,
@@ -1212,7 +1214,13 @@ function referenceScriptUtxos(
}
function isDeployment(value: unknown): value is ReclaimDeployment {
- return Boolean(value && typeof value === "object" && "reclaimBaseAddress" in value && "verifierVkHash" in value);
+ return Boolean(
+ value &&
+ typeof value === "object" &&
+ "reclaimBaseAddress" in value &&
+ "verifierVkHash" in value &&
+ "proofVkHash" in value,
+ );
}
function claimBuildReview(selectedOutrefs: string[]) {
diff --git a/apps/ownership-proof-web/lib/claim-server/claim.test.ts b/apps/ownership-proof-web/lib/claim-server/claim.test.ts
index 7afca63d..a5e5f481 100644
--- a/apps/ownership-proof-web/lib/claim-server/claim.test.ts
+++ b/apps/ownership-proof-web/lib/claim-server/claim.test.ts
@@ -98,7 +98,7 @@ describe("claim build and submit guardrails", () => {
out_ref: `${"a".repeat(64)}#0`,
artifact: {
circuit_id: "root-ownership-destination-v1/bls12-381/groth16",
- vk_hash: deployment().verifierVkHash,
+ vk_hash: deployment().proofVkHash,
cardano: {
proof_hex: "aa",
public_input_digest_hex: "bb",
@@ -153,6 +153,7 @@ function deployment(): ReclaimDeployment {
paramsCurrencySymbol: "d".repeat(56),
paramsTokenName: "5245434c41494d",
verifierVkHash: "blake2b256:" + "e".repeat(64),
+ proofVkHash: "blake2b256:" + "f".repeat(64),
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2",
reclaimGlobalBatchTranscriptVkHash: "blake2b256:" + "e".repeat(64),
contractVersion: "v1",
diff --git a/apps/ownership-proof-web/lib/reclaim-server/manifest.test.ts b/apps/ownership-proof-web/lib/reclaim-server/manifest.test.ts
index 188e4f7f..abc48a06 100644
--- a/apps/ownership-proof-web/lib/reclaim-server/manifest.test.ts
+++ b/apps/ownership-proof-web/lib/reclaim-server/manifest.test.ts
@@ -44,6 +44,8 @@ describe("reclaim deployment manifest validation", () => {
}
expect(result.deployment.reclaimGlobalProofSlotEncoding).toBe(FULL_PROOF_PLUS_PUBLIC_INPUT_DIGEST_V2);
expect(result.deployment.reclaimGlobalBatchTranscriptVkHash).toBe(manifest.proof.cardano_vk_blake2b256);
+ expect(result.deployment.verifierVkHash).toBe(manifest.proof.cardano_vk_blake2b256);
+ expect(result.deployment.proofVkHash).toBe(manifest.proof.vk_hash);
});
it("fails closed for incomplete or mismatched statement-bound V2 metadata", () => {
@@ -146,9 +148,9 @@ describe("reclaim deployment manifest validation", () => {
expect(errorCodes(validateReclaimDeploymentManifest(manifest))).toContain("global_credential_mismatch");
});
- it("disables readiness for a verifier hash/proof mismatch", () => {
+ it("disables readiness when the on-chain verifier hash is not the Cardano VK hash", () => {
const manifest = validManifest();
- manifest.proof.vk_hash = prefixedHash("9");
+ manifest.reclaim_global.verifier_vk_hash = prefixedHash("9");
expect(errorCodes(validateReclaimDeploymentManifest(manifest))).toContain("verifier_hash_mismatch");
});
@@ -377,7 +379,8 @@ function validManifest(): ReclaimDeploymentManifest {
const sourceCommit = "abcdef1234567890";
const globalCredential = hash56("b");
const paramsPolicy = hash56("d");
- const verifierHash = prefixedHash("e");
+ const nativeVerifierHash = prefixedHash("e");
+ const cardanoVerifierHash = prefixedHash("1");
return {
schema: RECLAIM_DEPLOYMENT_SCHEMA,
@@ -395,10 +398,10 @@ function validManifest(): ReclaimDeploymentManifest {
script_hash: hash56("c"),
rewarding_credential: globalCredential,
params_currency_symbol: paramsPolicy,
- verifier_vk_hash: verifierHash,
+ verifier_vk_hash: cardanoVerifierHash,
proof_profile: "single-destination",
proof_slot_encoding: FULL_PROOF_PLUS_PUBLIC_INPUT_DIGEST_V2,
- batch_transcript_vk_hash: prefixedHash("1"),
+ batch_transcript_vk_hash: cardanoVerifierHash,
},
params_utxo: {
tx_hash: hash64("f"),
@@ -412,8 +415,8 @@ function validManifest(): ReclaimDeploymentManifest {
circuit_id: DESTINATION_CIRCUIT_ID,
key_version: DESTINATION_KEY_VERSION,
destination_address_encoding: DESTINATION_ADDRESS_ENCODING,
- vk_hash: verifierHash,
- cardano_vk_blake2b256: prefixedHash("1"),
+ vk_hash: nativeVerifierHash,
+ cardano_vk_blake2b256: cardanoVerifierHash,
},
batching: {
default_utxo_count: 6,
diff --git a/apps/ownership-proof-web/lib/reclaim-server/manifest.ts b/apps/ownership-proof-web/lib/reclaim-server/manifest.ts
index 40639c50..704795e5 100644
--- a/apps/ownership-proof-web/lib/reclaim-server/manifest.ts
+++ b/apps/ownership-proof-web/lib/reclaim-server/manifest.ts
@@ -647,13 +647,13 @@ export function validateReclaimDeploymentManifest(
}
if (
manifest.reclaim_global.verifier_vk_hash &&
- manifest.proof.vk_hash &&
- manifest.reclaim_global.verifier_vk_hash !== manifest.proof.vk_hash
+ manifest.proof.cardano_vk_blake2b256 &&
+ normalizedHash(manifest.reclaim_global.verifier_vk_hash) !== normalizedHash(manifest.proof.cardano_vk_blake2b256)
) {
errors.push({
code: "verifier_hash_mismatch",
- field: "proof.vk_hash",
- message: "proof.vk_hash must equal reclaim_global.verifier_vk_hash.",
+ field: "reclaim_global.verifier_vk_hash",
+ message: "ReclaimGlobal verifier hash must equal proof.cardano_vk_blake2b256.",
});
}
if (
@@ -799,6 +799,7 @@ export function deploymentFromManifest(manifest: ReclaimDeploymentManifest): Rec
paramsCurrencySymbol: manifest.reclaim_global.params_currency_symbol,
paramsTokenName: manifest.params_utxo.token_name,
verifierVkHash: manifest.reclaim_global.verifier_vk_hash,
+ proofVkHash: manifest.proof.vk_hash,
contractVersion: manifest.contract_version,
sourceCommit: manifest.source_commit,
reclaimGlobalRewardingCredential: manifest.reclaim_global.rewarding_credential,
@@ -886,7 +887,9 @@ function manifestFromEnv(env: EnvMap): Record {
envValue(env, FLAT_ENV_FIELDS.reclaimGlobalRewardingCredential) ||
envValue(env, FLAT_ENV_FIELDS.reclaimBaseRequiredGlobalCredential);
const paramsCurrencySymbol = envValue(env, FLAT_ENV_FIELDS.paramsCurrencySymbol);
- const verifierVkHash = envValue(env, FLAT_ENV_FIELDS.verifierVkHash) || envValue(env, FLAT_ENV_FIELDS.proofVkHash);
+ const proofVkHash = envValue(env, FLAT_ENV_FIELDS.proofVkHash);
+ const cardanoVkHash = envValue(env, FLAT_ENV_FIELDS.proofCardanoVkHash);
+ const verifierVkHash = envValue(env, FLAT_ENV_FIELDS.verifierVkHash) || cardanoVkHash;
const proofSlotEncoding = envValue(env, FLAT_ENV_FIELDS.reclaimGlobalProofSlotEncoding);
const batchTranscriptVkHash = envValue(env, FLAT_ENV_FIELDS.reclaimGlobalBatchTranscriptVkHash);
const distinctSevenOptIn = distinctSevenOptInFromEnv(env);
@@ -930,8 +933,8 @@ function manifestFromEnv(env: EnvMap): Record {
key_version: envValue(env, FLAT_ENV_FIELDS.proofKeyVersion) || DESTINATION_KEY_VERSION,
destination_address_encoding:
envValue(env, FLAT_ENV_FIELDS.destinationAddressEncoding) || DESTINATION_ADDRESS_ENCODING,
- vk_hash: envValue(env, FLAT_ENV_FIELDS.proofVkHash) || verifierVkHash,
- cardano_vk_blake2b256: envValue(env, FLAT_ENV_FIELDS.proofCardanoVkHash),
+ vk_hash: proofVkHash,
+ cardano_vk_blake2b256: cardanoVkHash,
},
batching: {
default_utxo_count: parseEnvInteger(env, FLAT_ENV_FIELDS.defaultUtxoCount) ?? 4,
diff --git a/apps/ownership-proof-web/lib/reclaim-server/transactions.test.ts b/apps/ownership-proof-web/lib/reclaim-server/transactions.test.ts
index 8cdbdd66..4b9acccf 100644
--- a/apps/ownership-proof-web/lib/reclaim-server/transactions.test.ts
+++ b/apps/ownership-proof-web/lib/reclaim-server/transactions.test.ts
@@ -36,6 +36,7 @@ function deployment() {
paramsCurrencySymbol: "params-policy",
paramsTokenName: "params-token",
verifierVkHash: "vk-hash",
+ proofVkHash: "native-vk-hash",
reclaimGlobalProofSlotEncoding: "full-proof-plus-public-input-digest-v2" as const,
reclaimGlobalBatchTranscriptVkHash: "vk-hash",
contractVersion: "v1",
diff --git a/apps/ownership-proof-web/lib/reclaim/types.ts b/apps/ownership-proof-web/lib/reclaim/types.ts
index 0e86e345..985cb336 100644
--- a/apps/ownership-proof-web/lib/reclaim/types.ts
+++ b/apps/ownership-proof-web/lib/reclaim/types.ts
@@ -66,7 +66,10 @@ export type ReclaimDeployment = {
reclaimGlobalBatchTranscriptVkHash: string;
paramsCurrencySymbol: string;
paramsTokenName: string;
+ /** BLAKE2b-256 of the Cardano wire-format VK embedded in ReclaimGlobal. */
verifierVkHash: string;
+ /** BLAKE2b-256 of the native gnark VK used by local/browser provers. */
+ proofVkHash: string;
contractVersion: string;
sourceCommit: string;
reclaimGlobalRewardingCredential?: string;
diff --git a/apps/ownership-proof-web/package.json b/apps/ownership-proof-web/package.json
index 0372cda8..3519dfd6 100644
--- a/apps/ownership-proof-web/package.json
+++ b/apps/ownership-proof-web/package.json
@@ -10,8 +10,10 @@
"start": "next start",
"test": "vitest run",
"deploy:reclaim:preprod": "node e2e/preprod/deploy-reclaim-preprod.mjs",
+ "deploy:reclaim:mainnet:prepare": "node e2e/mainnet/prepare-reclaim-mainnet.mjs",
"e2e:preprod:stage2g:v2:material": "node e2e/preprod/stage2g-v2-material.mjs",
"e2e:preprod:stage2g:v2:evaluate": "node e2e/preprod/stage2g-v2-evaluate.mjs",
+ "e2e:preprod:lace:validate-profile": "node e2e/preprod/lace-profile-setup.mjs",
"e2e:preprod:lace:setup": "node e2e/preprod/lace-profile-setup.mjs",
"test:e2e:preprod": "node e2e/preprod/run.mjs",
"test:e2e:preprod:lace-smoke": "RECLAIM_E2E_WALLET_MODE=lace node e2e/preprod/run.mjs",
diff --git a/apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json
index f93327e6..b6edb218 100644
--- a/apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json
+++ b/apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json
@@ -14,7 +14,7 @@
"script_hash": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
"rewarding_credential": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
"params_currency_symbol": "d6777b8c3be1c6c0c9baba52a880c1980a662c16ffc0885ecaa03119",
- "verifier_vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"proof_profile": "single-destination",
"proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
"batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
@@ -35,25 +35,25 @@
"cardano_vk_blake2b256": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"browser_proving": {
"enabled": true,
- "runtime_base_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime",
- "runtime_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/runtime-manifest.json",
- "prover_worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/prover-worker.js",
- "wasm_exec_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/wasm_exec.js",
- "manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/manifest.json",
- "manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/manifest.sig",
+ "runtime_base_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime",
+ "runtime_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/runtime-manifest.json",
+ "prover_worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/prover-worker.js",
+ "wasm_exec_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/wasm_exec.js",
+ "manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.json",
+ "manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.sig",
"manifest_public_key_hex": "2af3b300b9e641ede236d4b7d48b43eccfb843ffa9aca74abb38f98e7211eccb",
- "chunk_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/chunk-manifest.json",
- "chunk_manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/chunk-manifest.sig",
+ "chunk_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.json",
+ "chunk_manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.sig",
"chunk_manifest_public_key_hex": "e20b0fb38fb6dc0a66284a8f3a6e8d05bf55b8e966d86f53b77d284b524463d6",
- "deployment_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/reclaim-deployment.json",
- "vk_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/ownership.vk",
+ "deployment_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/reclaim-deployment.json",
+ "vk_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.vk",
"pk_url": "https://proof-assets.reclaim-proof.com/proof-assets/preprod-9fac96b-g3a/ownership.pk",
- "pk_index_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/ownership.pk.idx.json",
+ "pk_index_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.pk.idx.json",
"ccs_url": "https://proof-assets-2m.reclaim-proof.com/proof-assets/preprod-9fac96b-g3a-pk2m-r1/ownership-destination.ccs",
"ccs_blake2b256": "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352",
- "proof_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/proof-destination.wasm",
- "worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/msm-worker.js",
- "msm_worker_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/runtime/msmworker.wasm",
+ "proof_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/proof-destination.wasm",
+ "worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msm-worker.js",
+ "msm_worker_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msmworker.wasm",
"tuning": {
"shard_count": 8,
"range_fetch_concurrency": 2,
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-explicit-path-r1/assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-explicit-path-r1/assets/reclaim-deployment.json
index 921f9fd4..05c7e790 100644
--- a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-explicit-path-r1/assets/reclaim-deployment.json
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-explicit-path-r1/assets/reclaim-deployment.json
@@ -14,7 +14,7 @@
"script_hash": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"rewarding_credential": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"params_currency_symbol": "82c806809e8e2a65c153041db187ca96f2feeb87a3fe135bf3803174",
- "verifier_vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"proof_profile": "single-destination",
"proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
"batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-key-discovery-r1/assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-key-discovery-r1/assets/reclaim-deployment.json
index 31f1dada..58ab060c 100644
--- a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-key-discovery-r1/assets/reclaim-deployment.json
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-key-discovery-r1/assets/reclaim-deployment.json
@@ -14,7 +14,7 @@
"script_hash": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"rewarding_credential": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"params_currency_symbol": "82c806809e8e2a65c153041db187ca96f2feeb87a3fe135bf3803174",
- "verifier_vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"proof_profile": "single-destination",
"proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
"batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest-public-key.hex b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest-public-key.hex
new file mode 100644
index 00000000..0ee92bf9
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest-public-key.hex
@@ -0,0 +1 @@
+e20b0fb38fb6dc0a66284a8f3a6e8d05bf55b8e966d86f53b77d284b524463d6
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.json
new file mode 100644
index 00000000..11c67c15
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.json
@@ -0,0 +1,5043 @@
+{
+ "schema": "proof-tool-proof-assets-chunk-manifest-v1",
+ "release": "proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1",
+ "profile": "preprod-single-destination",
+ "generated_at": "2026-07-31T16:52:41Z",
+ "signature_key_id": "preprod-local-destination-d2c944dd753c-r3",
+ "coherence": {
+ "key_manifest_sha256": "sha256:ee4610efa42482bbd78ce019582dc98ae9a91a806c2ab63436157f42a166c8ab",
+ "key_manifest_blake2b256": "blake2b256:826aac9b8cc747892d53f3a0364bd86dcee1e10cd2a3e19ed695504d13316629",
+ "key_version": "ownership-destination-v2",
+ "circuit_id": "root-ownership-destination-v2/bls12-381/groth16",
+ "vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "proving_key_size": 1288707133,
+ "proving_key_sha256": "sha256:3e8a88b48ce1604697f705480196e54e1b87728637ccc6806e1888b7c8a114d6",
+ "proving_key_blake2b256": "blake2b256:24c153e5f8ee606b87ffb7cd752c37953d7fe9e69e996dac80c2a804aa2724ac",
+ "verifying_key_sha256": "sha256:6484b03a5aafa96859be256484d84bebab0d6051f04373e416d2d2189e7fdec4",
+ "verifying_key_size": 784,
+ "constraint_system_hash": "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352",
+ "setup_transcript_hash": "blake2b256:5c23a38efe7f8662cfd120d11dddcdbba2c4ad4598526d6a9ec9279e13e29ecf",
+ "circuit_source_commit": "9fac96bc0669285433ca51e62873b1ab1fa274de",
+ "gnark_version": "v0.15.0",
+ "proof_tool_version": "0.1.0",
+ "cardano_vk_format": "groth16-bls12-381-bsb22",
+ "cardano_vk_blake2b256": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
+ "deployment_id": "preprod:744cc4718e8149201c7e9cb3d3a550f34cb18dfc8076a33172d9354d:fccccbc8ab525c9da8d8ae334398f590459c3a3c",
+ "deployment_source_commit": "fccccbc8ab525c9da8d8ae334398f590459c3a3c"
+ },
+ "transport": {
+ "base_url": "https://proof-assets-2m.reclaim-proof.com/proof-assets/preprod-9fac96b-g3a-optremote-r1-2m-hit/",
+ "content_encoding": "identity",
+ "requires_https": true,
+ "supports_range": true
+ },
+ "proving_key": {
+ "path": "ownership.pk",
+ "chunk_size": 2097152,
+ "chunks_root_blake2b256": "blake2b256:9cdb46890704344c48c2faf17905bf8fecc7f44c4c7164b765ec66aa0e837ac9",
+ "chunks": [
+ {
+ "index": 0,
+ "offset": 0,
+ "size": 2097152,
+ "path": "ownership.pk.part0000",
+ "sha256": "sha256:e18cab71e95e11d29872b370e6ef3f6542878d8ea750fd0dc28533b1eca9e7a6",
+ "blake2b256": "blake2b256:860de634b2f2b753eb1a9c4446df05d221f02aafa2603e12f9b7325f3d7ed0c9"
+ },
+ {
+ "index": 1,
+ "offset": 2097152,
+ "size": 2097152,
+ "path": "ownership.pk.part0001",
+ "sha256": "sha256:7281e7c8c48c3e80a5f7ec9ff346b0572a722bf56829ccc86b4ec069b93464e0",
+ "blake2b256": "blake2b256:9b54f196c92eae83c0ee3039f17cb9ac8833c4bc997dffca0e8de6150eb259c4"
+ },
+ {
+ "index": 2,
+ "offset": 4194304,
+ "size": 2097152,
+ "path": "ownership.pk.part0002",
+ "sha256": "sha256:0ff0227b2bc971e1b86132b02ffcfd3ab056f2b92bfed9ee7f3d4da114ac949c",
+ "blake2b256": "blake2b256:97c3908a9014c8d54c495f8678a42edbe2172b86dd558d675be870be85d370eb"
+ },
+ {
+ "index": 3,
+ "offset": 6291456,
+ "size": 2097152,
+ "path": "ownership.pk.part0003",
+ "sha256": "sha256:467570fa5f7fe25e9454fd8d76f5e9f459ba344dbe67c22beb11fd6a7e95f5b9",
+ "blake2b256": "blake2b256:9dcef300defeb9e6b2229de74267bde7632434163180fe8c96463cb640c23c7f"
+ },
+ {
+ "index": 4,
+ "offset": 8388608,
+ "size": 2097152,
+ "path": "ownership.pk.part0004",
+ "sha256": "sha256:9f73cc3e7fbc9040258c2982cbc7c64d7de533dcde75ca366e91fa86f7a20e9f",
+ "blake2b256": "blake2b256:e5e5f86b4f32ac1f8c74bb5190987a4ffacaf25e70c4aa1a23f97d58e5800c53"
+ },
+ {
+ "index": 5,
+ "offset": 10485760,
+ "size": 2097152,
+ "path": "ownership.pk.part0005",
+ "sha256": "sha256:50b9ba66a852084055d511bc0bb4cbdd805880f9db3e86acbbf062a1c2440dd0",
+ "blake2b256": "blake2b256:74fd030f17177acc78ce411da9c7009e2d07ab3d4c151cd514fcc3e446917c83"
+ },
+ {
+ "index": 6,
+ "offset": 12582912,
+ "size": 2097152,
+ "path": "ownership.pk.part0006",
+ "sha256": "sha256:3e8e1a5b1ecabfceec703d9bd010c6707cbe5833f8cd7ff8831b06c07544476d",
+ "blake2b256": "blake2b256:c0356c9c1a64e84f6989a003b14442543e4e720a38e99675011247210debbfbc"
+ },
+ {
+ "index": 7,
+ "offset": 14680064,
+ "size": 2097152,
+ "path": "ownership.pk.part0007",
+ "sha256": "sha256:f02b53bad80523cf3dee15030d2b352550358ab23bead5bf848800c6fd2fe2d7",
+ "blake2b256": "blake2b256:889fe5eaf3d9ab4fd6c62a8891c7332997dd8dcd2294bb89b7dc310fc820597c"
+ },
+ {
+ "index": 8,
+ "offset": 16777216,
+ "size": 2097152,
+ "path": "ownership.pk.part0008",
+ "sha256": "sha256:33bd5ef2500a0dc0fe494042c82fa758e5943171766f384ec30dbadd319516d9",
+ "blake2b256": "blake2b256:f962770b91a7edef7c785e4c3899fcbe6db915b761ed8df38f2c1f4536662b15"
+ },
+ {
+ "index": 9,
+ "offset": 18874368,
+ "size": 2097152,
+ "path": "ownership.pk.part0009",
+ "sha256": "sha256:734ece72ee20d4c57c69e3f51afbce593b03d3836e2a5d9aaeb933ca37977047",
+ "blake2b256": "blake2b256:3181b3b4f2302259f47982376bc02a308ae1fc01ea2dc21bee5f7b905ca93f3b"
+ },
+ {
+ "index": 10,
+ "offset": 20971520,
+ "size": 2097152,
+ "path": "ownership.pk.part0010",
+ "sha256": "sha256:daa6ac02cbed87256e6f5c6d0d27ae88db7d15f52993e3824c76d9c7d4a55446",
+ "blake2b256": "blake2b256:5d8f056e87b6668f5abb5633a30adcaae4da04e647d689e5d91af19a5fea02f9"
+ },
+ {
+ "index": 11,
+ "offset": 23068672,
+ "size": 2097152,
+ "path": "ownership.pk.part0011",
+ "sha256": "sha256:64ef6816c98fdb7f67f6fdef5d0ad410a2ef0ae6daf78bd72a654d2ff4af99df",
+ "blake2b256": "blake2b256:6366e6b71cd14d0bee834a00ef2c7b994f22671672e0158e9be94020c370090b"
+ },
+ {
+ "index": 12,
+ "offset": 25165824,
+ "size": 2097152,
+ "path": "ownership.pk.part0012",
+ "sha256": "sha256:50cb51ac5657d9f870807bfeecea1ccf82787d71b0ced6ba670fd084927a450d",
+ "blake2b256": "blake2b256:7afe9dff0124a2f9bb37d027ddbac3e651e484ec6ba1c6548114970d3ac7f228"
+ },
+ {
+ "index": 13,
+ "offset": 27262976,
+ "size": 2097152,
+ "path": "ownership.pk.part0013",
+ "sha256": "sha256:b2b91710c97e86afb698a9f88cc2abcd11cd5ecc27edd8b9f960e3ba292d3c3e",
+ "blake2b256": "blake2b256:e420580ceb1219ae71fcf5420a2908a9166990e77b1841a577e382f04a8af47c"
+ },
+ {
+ "index": 14,
+ "offset": 29360128,
+ "size": 2097152,
+ "path": "ownership.pk.part0014",
+ "sha256": "sha256:2023337bb37ae015715ad8b0fe0ce82748cbe6f76deb696e11800e1c5961d948",
+ "blake2b256": "blake2b256:dbc2209d9d7f7531288114311ac3c59c55092053b52407b63ab8fcfec1ef09a2"
+ },
+ {
+ "index": 15,
+ "offset": 31457280,
+ "size": 2097152,
+ "path": "ownership.pk.part0015",
+ "sha256": "sha256:aaf01e4b05aa935a430af6ec7a47ceb00ae2d8265ba192f395406b255695d143",
+ "blake2b256": "blake2b256:505516065aaf2a5a1d2710d4e494869e37272f77a57327300e605d1314f59f4d"
+ },
+ {
+ "index": 16,
+ "offset": 33554432,
+ "size": 2097152,
+ "path": "ownership.pk.part0016",
+ "sha256": "sha256:9cf249fa9991faef4e406d0cda844a82c40c6f76758d900d05029bf3e2550794",
+ "blake2b256": "blake2b256:938ec0dcbd703791add44b41e5276c0709cca587cec78fb918f2f5cb67a65a86"
+ },
+ {
+ "index": 17,
+ "offset": 35651584,
+ "size": 2097152,
+ "path": "ownership.pk.part0017",
+ "sha256": "sha256:b96563ce6d68eae8f3a2a23910755a19939851ed3642557b1da52435236b5a23",
+ "blake2b256": "blake2b256:bdce66b645783ff8f415905721a910669ba238c0df9dafbbfbbbcef8a31620df"
+ },
+ {
+ "index": 18,
+ "offset": 37748736,
+ "size": 2097152,
+ "path": "ownership.pk.part0018",
+ "sha256": "sha256:a7b58c89462e3295413eb91fefead606390b6a2cd9ab53552a18c8cfc975028a",
+ "blake2b256": "blake2b256:f04719457f7ef6d0d725caeb4cc13e224d84c92c6ba9a3104a9b6081a1717047"
+ },
+ {
+ "index": 19,
+ "offset": 39845888,
+ "size": 2097152,
+ "path": "ownership.pk.part0019",
+ "sha256": "sha256:7e8de657cd3cbe0432898335ead7bf95f5ab8f8417983f0cd6a9e090d096b326",
+ "blake2b256": "blake2b256:9abffb9b7e40e79df4529a94f84c76304a0a072852bc3389b873b25fe93de9d3"
+ },
+ {
+ "index": 20,
+ "offset": 41943040,
+ "size": 2097152,
+ "path": "ownership.pk.part0020",
+ "sha256": "sha256:f5aef3cb3b68ba71f4f10fbe6ef71aa2177d591b72e0abb0f0fe57fbb3793ed2",
+ "blake2b256": "blake2b256:920c7503373c570e26e0ab74844c094bb97f8ff5270ffff18f1cbfd67d9d9466"
+ },
+ {
+ "index": 21,
+ "offset": 44040192,
+ "size": 2097152,
+ "path": "ownership.pk.part0021",
+ "sha256": "sha256:2c3613617929778ab6390d6e80977a2cb8b7949482a7421d2a604ac70e49c172",
+ "blake2b256": "blake2b256:65466e10db9cba0655429a51d02558221aaa62af205b0d8b86af6c78689def1d"
+ },
+ {
+ "index": 22,
+ "offset": 46137344,
+ "size": 2097152,
+ "path": "ownership.pk.part0022",
+ "sha256": "sha256:ceaf6f1717781816945cc22cfa9837352b72fa34007cde49be97652f29398fe0",
+ "blake2b256": "blake2b256:c480b9883e4cb90fc9146c24b91c1096197b348a00067007d5ef918617ec8c15"
+ },
+ {
+ "index": 23,
+ "offset": 48234496,
+ "size": 2097152,
+ "path": "ownership.pk.part0023",
+ "sha256": "sha256:a8b92e0d7e3a4e322913e2b1011edf5ad4b4f973ef0ac736bf012240de729e44",
+ "blake2b256": "blake2b256:0e75de84bff004a7ff538c44f1d1852e6a08fa5614e4314a017357b54133b901"
+ },
+ {
+ "index": 24,
+ "offset": 50331648,
+ "size": 2097152,
+ "path": "ownership.pk.part0024",
+ "sha256": "sha256:61e8747cbb5631dd16e6b1bc5e738e4df0626fcfd812a66a9633d8714008230b",
+ "blake2b256": "blake2b256:2859f0c3611e8021ad94421f6bf765de2f6727776e66e999bb19dd77a4ec115b"
+ },
+ {
+ "index": 25,
+ "offset": 52428800,
+ "size": 2097152,
+ "path": "ownership.pk.part0025",
+ "sha256": "sha256:1803242c31aee36b423d2f0270e9562a599a3995d9ba023f8d685fea984395cc",
+ "blake2b256": "blake2b256:9490b703376d2c692985aae482704bfff973cf5d955639b8f45ed1d775f09f60"
+ },
+ {
+ "index": 26,
+ "offset": 54525952,
+ "size": 2097152,
+ "path": "ownership.pk.part0026",
+ "sha256": "sha256:29c6f55a827ceaf8973be1552e2233ceda7f9945f8c64b158f5725ed17d41821",
+ "blake2b256": "blake2b256:0a63c313b44675344ae85355f5dcfb464cb8ef12fa6bae53e2de423cc6fc2bfc"
+ },
+ {
+ "index": 27,
+ "offset": 56623104,
+ "size": 2097152,
+ "path": "ownership.pk.part0027",
+ "sha256": "sha256:8d3591a407a90c8f88922a79c625d6b8caaf7103c444e85e6077a37946237d29",
+ "blake2b256": "blake2b256:cafb4e6a696378213288c147ba220765ffb00a30f281cd95a25dcde5b870129d"
+ },
+ {
+ "index": 28,
+ "offset": 58720256,
+ "size": 2097152,
+ "path": "ownership.pk.part0028",
+ "sha256": "sha256:6690b07303b49331d30eed416f0a30fbae1b32014ac16ec036638fbb1e309015",
+ "blake2b256": "blake2b256:08d3798f910595d66d02a2238886a271ce9feb403835c984806ec8f0c3922585"
+ },
+ {
+ "index": 29,
+ "offset": 60817408,
+ "size": 2097152,
+ "path": "ownership.pk.part0029",
+ "sha256": "sha256:098dd6c455854fc533e36be9e2313deae8d610ec34607c48b0b95db7c4e18ac5",
+ "blake2b256": "blake2b256:22c8320be9bc9e3fb748358dcd06c4e2ae5e7fad37fd3086810359c7e39a6773"
+ },
+ {
+ "index": 30,
+ "offset": 62914560,
+ "size": 2097152,
+ "path": "ownership.pk.part0030",
+ "sha256": "sha256:bd0948dce76f93b27e2dfb057a3918a51294a52d599b73221ddfcfdd5ac0eb52",
+ "blake2b256": "blake2b256:4fa6ac27ffbc7bd3416b1dde6cc239a0cf0e4cc862c1abd828dd84358f6f8c98"
+ },
+ {
+ "index": 31,
+ "offset": 65011712,
+ "size": 2097152,
+ "path": "ownership.pk.part0031",
+ "sha256": "sha256:aa938f4539cb52f1dc3f31dbac82854721e91430ebc4c77498c9fbeeb360ceec",
+ "blake2b256": "blake2b256:aad1c4e0b5f787340f283828070085e918860f249d30f5090ef533b639119df5"
+ },
+ {
+ "index": 32,
+ "offset": 67108864,
+ "size": 2097152,
+ "path": "ownership.pk.part0032",
+ "sha256": "sha256:87442ede75f4b161478ef00d32f1d5d70edad323b6d5efecdc71a368c6f57f98",
+ "blake2b256": "blake2b256:1bccb7925e322d5558aa30ac20fde7f175b62113be91984eabd27505a36501a8"
+ },
+ {
+ "index": 33,
+ "offset": 69206016,
+ "size": 2097152,
+ "path": "ownership.pk.part0033",
+ "sha256": "sha256:d7572dfb9cbb8fe4a16b8d4bbeef8d5fe270ffdcb7d3bef28e8d2bf503d6403a",
+ "blake2b256": "blake2b256:362a5215f387d4f1b7a65e358d94cc3e605ac8d85410be3f971d76676e55fb8f"
+ },
+ {
+ "index": 34,
+ "offset": 71303168,
+ "size": 2097152,
+ "path": "ownership.pk.part0034",
+ "sha256": "sha256:4d8000d3bd684c78c1f8770299dbcb44776d87b64b4d53639090e42db0e7522c",
+ "blake2b256": "blake2b256:6966162dd75a7be0e1a1c429a12e189358d98d8e22917e2460e9058239aea4fd"
+ },
+ {
+ "index": 35,
+ "offset": 73400320,
+ "size": 2097152,
+ "path": "ownership.pk.part0035",
+ "sha256": "sha256:7f1d08af7a1a9b7bb2213815dd67f2c85798b6e56d366f0c76ea90b7960757cb",
+ "blake2b256": "blake2b256:8b526fae4ce5a8576e060f198df0459446cb3e5b39c9b98be3ba07f0f95238ac"
+ },
+ {
+ "index": 36,
+ "offset": 75497472,
+ "size": 2097152,
+ "path": "ownership.pk.part0036",
+ "sha256": "sha256:dc3122f1d32af1496636eb533f736819b7876e6727d4090d42b618ca0d916108",
+ "blake2b256": "blake2b256:27237ed4d95913977b2b5d5acbb53d44bcbd1d0273f9c658308d326ad718857a"
+ },
+ {
+ "index": 37,
+ "offset": 77594624,
+ "size": 2097152,
+ "path": "ownership.pk.part0037",
+ "sha256": "sha256:9eb892122eef7a5d2efe3f8b8b85d5ba47de0dcbab053b0a6f6a9c2c4f7ec1ef",
+ "blake2b256": "blake2b256:e5572750a60e4cf82834ca58d4c24c92ee4328e8dedffe4074ceadf81cc4af62"
+ },
+ {
+ "index": 38,
+ "offset": 79691776,
+ "size": 2097152,
+ "path": "ownership.pk.part0038",
+ "sha256": "sha256:f459b74906960301031a74b5e5b94dbb4159580b0a37d022b9bf55447c59e07b",
+ "blake2b256": "blake2b256:70566dbfc41615d8d0e766c896333a39637ef9494c0e4aea1ba271a245ea1bb0"
+ },
+ {
+ "index": 39,
+ "offset": 81788928,
+ "size": 2097152,
+ "path": "ownership.pk.part0039",
+ "sha256": "sha256:cb3f7f53395660d0003506deb05691a6a666db027d409fc898ad6a766a3a3791",
+ "blake2b256": "blake2b256:d4490de12eb44f1d7a756f54e1d42ed296f87999d0b0d47788049f7cdd96910d"
+ },
+ {
+ "index": 40,
+ "offset": 83886080,
+ "size": 2097152,
+ "path": "ownership.pk.part0040",
+ "sha256": "sha256:d95ec7d878268ae76fcb9727a6cf8e8d838d4f4ba16c169a3bdaf2458d4b4ae1",
+ "blake2b256": "blake2b256:b12868142722643ad8f87ae7fe90301a71129570605bb3e605ff45ea1accee25"
+ },
+ {
+ "index": 41,
+ "offset": 85983232,
+ "size": 2097152,
+ "path": "ownership.pk.part0041",
+ "sha256": "sha256:7d7e72e95689fc76c3cbedca74b0c8d89d5d8cd5ac8f8f1434c1b6a47b61d614",
+ "blake2b256": "blake2b256:1efb08afffa990ae294002fd1463907ea68d4d766520e31b3ffce034d6208531"
+ },
+ {
+ "index": 42,
+ "offset": 88080384,
+ "size": 2097152,
+ "path": "ownership.pk.part0042",
+ "sha256": "sha256:9c1ffa70ff108a8a3200fb2b2155d2de6c96781d786784ae5910a34335e9e0a7",
+ "blake2b256": "blake2b256:c58a38baf2e1a428080851f817536206142bd1c63902037b8ed794c407679c55"
+ },
+ {
+ "index": 43,
+ "offset": 90177536,
+ "size": 2097152,
+ "path": "ownership.pk.part0043",
+ "sha256": "sha256:ce133ba8f202f075043f47461179c5ea816fb82d519ede7b1402afcbd00efc67",
+ "blake2b256": "blake2b256:0f17a0ef25366a54861168c6166a9a8cab295f98e72b7462d1965b7c057b9b15"
+ },
+ {
+ "index": 44,
+ "offset": 92274688,
+ "size": 2097152,
+ "path": "ownership.pk.part0044",
+ "sha256": "sha256:a7ae4c4d161b2b80f5ba707eed3eb7fadb6233585a033572916b345f8cd78a20",
+ "blake2b256": "blake2b256:127a7395894c566205dfda3edfd2b82ae2af648c1f7453f9a565c42636e3b1a7"
+ },
+ {
+ "index": 45,
+ "offset": 94371840,
+ "size": 2097152,
+ "path": "ownership.pk.part0045",
+ "sha256": "sha256:57dc54640475b97beadbba755d5356d5ee91892b5ef7eb5d3fa64b22e1be8706",
+ "blake2b256": "blake2b256:60e28bf18a8a264605dd4445127770b264c9138cb7cf4603dc5187e37b30b4c2"
+ },
+ {
+ "index": 46,
+ "offset": 96468992,
+ "size": 2097152,
+ "path": "ownership.pk.part0046",
+ "sha256": "sha256:b01f4f90ca7958e8dad4dfff4dd8a71445a66fd6d80c2bf692510da1f53df9ba",
+ "blake2b256": "blake2b256:b36f6bb6c99252e6c0c3378c2b39c51f918c6507f9f51dd1c5e8f4d3b5b3fe56"
+ },
+ {
+ "index": 47,
+ "offset": 98566144,
+ "size": 2097152,
+ "path": "ownership.pk.part0047",
+ "sha256": "sha256:43f3f98e07a929659b3123e895202edac8e84da79d294510604728ce20542145",
+ "blake2b256": "blake2b256:e9aa71d9059edf316a963dc40856a1f855806491d769996c58d5894c3c9c0313"
+ },
+ {
+ "index": 48,
+ "offset": 100663296,
+ "size": 2097152,
+ "path": "ownership.pk.part0048",
+ "sha256": "sha256:2a5b49b1b5eb56b6669d80a9467baf712dde398e334d133811fd1d2e8222d38a",
+ "blake2b256": "blake2b256:6f143348f66833a6418ae3e1e57819831efa8e60711c09987691618183719b72"
+ },
+ {
+ "index": 49,
+ "offset": 102760448,
+ "size": 2097152,
+ "path": "ownership.pk.part0049",
+ "sha256": "sha256:5170f84867ba2bc0424b387b24d64b1a3fd9b9d63610232ff9f0c0ce11d70ccf",
+ "blake2b256": "blake2b256:ba329413cdbc3cbbf0241f887aefb65820f8f90de7c87ff6d546227b3b410ca6"
+ },
+ {
+ "index": 50,
+ "offset": 104857600,
+ "size": 2097152,
+ "path": "ownership.pk.part0050",
+ "sha256": "sha256:e92a17b20d44735b98f4ae9ac9c141a25851ba43b2dd5b5a89730c759c1afd1c",
+ "blake2b256": "blake2b256:daaa8a3e7eaf708189c172339307b6da516e86553920be3d4fb7cef23f9dac88"
+ },
+ {
+ "index": 51,
+ "offset": 106954752,
+ "size": 2097152,
+ "path": "ownership.pk.part0051",
+ "sha256": "sha256:500a002009ed3bf202c1d1c0935d1a8d2d4638f823c2f57e84b3d1e245f0eaeb",
+ "blake2b256": "blake2b256:ed628bd0966a614fee479327ec4e4862c2cd8a1b769797719abacd4f9f11d788"
+ },
+ {
+ "index": 52,
+ "offset": 109051904,
+ "size": 2097152,
+ "path": "ownership.pk.part0052",
+ "sha256": "sha256:0e6ab8c2413a432e65697eef5f251fcd66d5f335fa1294ae0cc2d9077a135c54",
+ "blake2b256": "blake2b256:9e6b2c1a2bf80122ec0b5c3f1750e1482f669f8abaf4a2dd3512c73f3282141d"
+ },
+ {
+ "index": 53,
+ "offset": 111149056,
+ "size": 2097152,
+ "path": "ownership.pk.part0053",
+ "sha256": "sha256:47ee3f4c7c6370fbfc7a6dc777f80ef1ac53d3212007b6abd3eafa0ffb16a286",
+ "blake2b256": "blake2b256:7970a010ec95e5ab0475256f2b12f7a643321cbcb416196c2919d93c935cf6b7"
+ },
+ {
+ "index": 54,
+ "offset": 113246208,
+ "size": 2097152,
+ "path": "ownership.pk.part0054",
+ "sha256": "sha256:db7b67e689ac5316ee213a0c0f409f933df9ab19fe52cac9d9fde80ec1c59327",
+ "blake2b256": "blake2b256:9880b299baa9f07fe28eb3d29d238780448f4f2f7dae42b52c2921c20ca148dd"
+ },
+ {
+ "index": 55,
+ "offset": 115343360,
+ "size": 2097152,
+ "path": "ownership.pk.part0055",
+ "sha256": "sha256:7573302661f218ec705e3e94477a39199e24cd4e4e2ad4916c2c6030eb17c3ce",
+ "blake2b256": "blake2b256:2fbeb5a90368811d823666ff8c00614f599af11748d45156f60c3b7d8f6e0e3b"
+ },
+ {
+ "index": 56,
+ "offset": 117440512,
+ "size": 2097152,
+ "path": "ownership.pk.part0056",
+ "sha256": "sha256:d9fb3edbfd58427452c4cdc4bfd46f879d7894a3af77db2d20b6f82ae1dacb46",
+ "blake2b256": "blake2b256:56b0702ee23db5748b2c37e87c2ccf94818a83c642d539a9abad8fef78cc9f55"
+ },
+ {
+ "index": 57,
+ "offset": 119537664,
+ "size": 2097152,
+ "path": "ownership.pk.part0057",
+ "sha256": "sha256:75564efd5f56f7dac874b1f7f67fce4d802e7a766dea1570d397802955c7a27a",
+ "blake2b256": "blake2b256:35d05fca091002da8db403f005b3419efbcab09296cedbfffe92bc883851deec"
+ },
+ {
+ "index": 58,
+ "offset": 121634816,
+ "size": 2097152,
+ "path": "ownership.pk.part0058",
+ "sha256": "sha256:b762733778d22671607e069e33aa8178d3d2b4dfe7dca15926a6ede9d4cfb3dd",
+ "blake2b256": "blake2b256:fb9e04960332df913137c2f14d6b37e2662564a0ce181b743b4aec37554d8817"
+ },
+ {
+ "index": 59,
+ "offset": 123731968,
+ "size": 2097152,
+ "path": "ownership.pk.part0059",
+ "sha256": "sha256:41511c175e158b3a35b164f87c7e5261df32c7144f1f3cbdcedbad91d2dbebd4",
+ "blake2b256": "blake2b256:3d259000ae1cef659065dabcfbcd5f6ec5d11532807821f50cc27f577899053c"
+ },
+ {
+ "index": 60,
+ "offset": 125829120,
+ "size": 2097152,
+ "path": "ownership.pk.part0060",
+ "sha256": "sha256:fa8ef0d62f402ec136d0a767f254cebd931f9a3614043ea7216479f17af1e926",
+ "blake2b256": "blake2b256:c132a3e63f5d0ab57acf62d5ac3e1371ebb7cc6d2b54a5d6cb860f3db65630be"
+ },
+ {
+ "index": 61,
+ "offset": 127926272,
+ "size": 2097152,
+ "path": "ownership.pk.part0061",
+ "sha256": "sha256:c954af9fd8f9c8d8cea4ad4725d14b347e2efcf16e4495e3a04d956316a1401b",
+ "blake2b256": "blake2b256:c1d22da4109095c6117c352d029da2b70117d0b2fd9487c7eb8a173c3cb6b353"
+ },
+ {
+ "index": 62,
+ "offset": 130023424,
+ "size": 2097152,
+ "path": "ownership.pk.part0062",
+ "sha256": "sha256:37f1bd81c94ffcf5f3203cdf8a1e9051051ad66755fc55f8dab766e5952d752c",
+ "blake2b256": "blake2b256:d26564b6c227f97dc0bd90c2afda6ede9c518bae78530dc3e5f665e7b41e3a1c"
+ },
+ {
+ "index": 63,
+ "offset": 132120576,
+ "size": 2097152,
+ "path": "ownership.pk.part0063",
+ "sha256": "sha256:c63910b8b96756c557d71e12daf2a43b248004f274bc19b472889064555d15b5",
+ "blake2b256": "blake2b256:674689b10dcc32223920d8d776ff27b1452119c19cbd726ea93c697592b53b1c"
+ },
+ {
+ "index": 64,
+ "offset": 134217728,
+ "size": 2097152,
+ "path": "ownership.pk.part0064",
+ "sha256": "sha256:4cdf8615ff2287140d2b2a30ce2d51b089659430de6fe4f166ee448261665b61",
+ "blake2b256": "blake2b256:dfddc8c7727c38e260f42bcd959cafe91168c95c739569631d0f54a2e2c1efde"
+ },
+ {
+ "index": 65,
+ "offset": 136314880,
+ "size": 2097152,
+ "path": "ownership.pk.part0065",
+ "sha256": "sha256:830d6f5d51bfc396bbcc4983d94478cef1f2466227f3ab132ed1aba2b8a219ec",
+ "blake2b256": "blake2b256:0cd257e60871ea575ac9db38181e3b65dd9a745d4ccf0e77d327a94b4a3443bb"
+ },
+ {
+ "index": 66,
+ "offset": 138412032,
+ "size": 2097152,
+ "path": "ownership.pk.part0066",
+ "sha256": "sha256:6a6a0216d8008b9e3ec6567e3febbf298f88aec4261a997de9c51e933e3e1225",
+ "blake2b256": "blake2b256:13d0e3bd02c21aa776191a5030b3d3259b0063796c059a25e49457d0ba642632"
+ },
+ {
+ "index": 67,
+ "offset": 140509184,
+ "size": 2097152,
+ "path": "ownership.pk.part0067",
+ "sha256": "sha256:140c2649f7740c93f953e1dc00c4f1c4e7c19ff575066af7bc6f10a280924183",
+ "blake2b256": "blake2b256:0ab6d5ca19bb9b74661dd90246d38558bfabbc8d1999b010ffffab84b99c27b4"
+ },
+ {
+ "index": 68,
+ "offset": 142606336,
+ "size": 2097152,
+ "path": "ownership.pk.part0068",
+ "sha256": "sha256:4cf19b215e000a909ed61bdb532f4e5f8c79ae09ae8a01faf1fa2019d7c940f1",
+ "blake2b256": "blake2b256:2e3df90f3b56e8f9930368ef7306d79c667b60e715391e1044090931c521506a"
+ },
+ {
+ "index": 69,
+ "offset": 144703488,
+ "size": 2097152,
+ "path": "ownership.pk.part0069",
+ "sha256": "sha256:77e7fe3ac6e43218abe169d101c3d138d2c0e31a71af77d2e68ad065fccd921b",
+ "blake2b256": "blake2b256:2c72810f3ba53de1998e18be4cfebbab6a2789f4cb473da39e6ed4c5dbb1bf27"
+ },
+ {
+ "index": 70,
+ "offset": 146800640,
+ "size": 2097152,
+ "path": "ownership.pk.part0070",
+ "sha256": "sha256:1725e50f6a390c29420dc24342f17c866a7bee0c8fb3789f46866d629dcad8e4",
+ "blake2b256": "blake2b256:9df103bba3bfb5e9422cc4d66eb95cccffa70651cc31eb1bfd6f48947a71f7ed"
+ },
+ {
+ "index": 71,
+ "offset": 148897792,
+ "size": 2097152,
+ "path": "ownership.pk.part0071",
+ "sha256": "sha256:80aa2a6a3fb8fcae3bfd76b9f01a17ab42a3e2f9f353b9f1190a79f7f709b0dd",
+ "blake2b256": "blake2b256:1af9c0252d21e1b238de468ec31e3236cfd12265021a9ef456bc02f2da8a035f"
+ },
+ {
+ "index": 72,
+ "offset": 150994944,
+ "size": 2097152,
+ "path": "ownership.pk.part0072",
+ "sha256": "sha256:507a1f9dff333331884eb0919cdf41502039df97a9c3aff112841df179936e72",
+ "blake2b256": "blake2b256:c8c41e61d8af6c49a38298fd23b3c899eb3de5737fa187faef6450c6c7a99d77"
+ },
+ {
+ "index": 73,
+ "offset": 153092096,
+ "size": 2097152,
+ "path": "ownership.pk.part0073",
+ "sha256": "sha256:b97da987a0e5d491ce8ae3590e7a46c51acb65dabd5822437d9774dbf98b9320",
+ "blake2b256": "blake2b256:0a20ece7eb0e0a336c348f99fb3f1ee57cf255f5e5c6ebfae3b2485f5a90073f"
+ },
+ {
+ "index": 74,
+ "offset": 155189248,
+ "size": 2097152,
+ "path": "ownership.pk.part0074",
+ "sha256": "sha256:37dd6acf4516104193760267b6c63bb496f5f221a358640c2fe40de4e2a233a4",
+ "blake2b256": "blake2b256:6984d591d8c7617d7b31c8847baa6b2d6647b6bd00eb34687772afee2fa70057"
+ },
+ {
+ "index": 75,
+ "offset": 157286400,
+ "size": 2097152,
+ "path": "ownership.pk.part0075",
+ "sha256": "sha256:9592cfce12edc0f707eb2984b60906cdd5c887e18d50f7bcdbc0a2df3be26599",
+ "blake2b256": "blake2b256:f6107ac1f7a0d64b6afabdafb98a28c09c2e47a7374486ff1fd00a0f9ac002b8"
+ },
+ {
+ "index": 76,
+ "offset": 159383552,
+ "size": 2097152,
+ "path": "ownership.pk.part0076",
+ "sha256": "sha256:0d1fea2a834cc48cff523f5ddb237411b911613a15c59223a3c2be00ca629ce1",
+ "blake2b256": "blake2b256:1d7fb4e7e1d127992d801990598c8cfa62cdc09ff962b233e4bf09c47c258a63"
+ },
+ {
+ "index": 77,
+ "offset": 161480704,
+ "size": 2097152,
+ "path": "ownership.pk.part0077",
+ "sha256": "sha256:2ce4721c2e5843bc18fc044fbf97e3e43ee1603767b43a177d64a8b367ee0a11",
+ "blake2b256": "blake2b256:518e4608ef7fac6c46ab50c3c5c9d6f0c3e6b506235e882fa40993030a336d65"
+ },
+ {
+ "index": 78,
+ "offset": 163577856,
+ "size": 2097152,
+ "path": "ownership.pk.part0078",
+ "sha256": "sha256:9f0d6d4e3af3009e7da9d622b3054f2c0c9cd77e8b7166b521b91ef0d2691ccc",
+ "blake2b256": "blake2b256:08367472376cb3713d811c2a35107c8fcaec4d4e78fa5c017dc14135cfb10b6b"
+ },
+ {
+ "index": 79,
+ "offset": 165675008,
+ "size": 2097152,
+ "path": "ownership.pk.part0079",
+ "sha256": "sha256:aeb3f491aa9fcba185572e598ac637c767a1c2788c942e899b1c00730f780593",
+ "blake2b256": "blake2b256:e48222b6c69ad973c748f81898e0944417c7944a30c9491f0301015a2b7d236c"
+ },
+ {
+ "index": 80,
+ "offset": 167772160,
+ "size": 2097152,
+ "path": "ownership.pk.part0080",
+ "sha256": "sha256:eeac5f69f383d6282771bd6f8f779e6f26c17b0577097155994f8ca2c5511427",
+ "blake2b256": "blake2b256:cd83590eb00fca6a2a727f0544acd2e81559b7b1697fba83b2c27f3cbc522f72"
+ },
+ {
+ "index": 81,
+ "offset": 169869312,
+ "size": 2097152,
+ "path": "ownership.pk.part0081",
+ "sha256": "sha256:405703dec52ef675c4a41a608070e794f719693f8320d88b94596c716d9e200b",
+ "blake2b256": "blake2b256:b39e8077cb44d695c128ed4f65466c9e64b86752ce16da54b8b70938605ec42f"
+ },
+ {
+ "index": 82,
+ "offset": 171966464,
+ "size": 2097152,
+ "path": "ownership.pk.part0082",
+ "sha256": "sha256:4da6eb1580e3d9e000e3569eab7714bece669bf1d327dfb1875c6a5f67ea6576",
+ "blake2b256": "blake2b256:3d7253168d83e94b0e384e430dd0d9ebb9d9e9a9fc1713f4228be45d72f3b8ab"
+ },
+ {
+ "index": 83,
+ "offset": 174063616,
+ "size": 2097152,
+ "path": "ownership.pk.part0083",
+ "sha256": "sha256:7041d1ab980b1feca340af9fdab25c19a8ee503761d30c2b62902e0f7fcc7bd9",
+ "blake2b256": "blake2b256:f824e0d6062a74411c4eb1940c1ad9eaca8ce5dea86a926c7765763ff51b334e"
+ },
+ {
+ "index": 84,
+ "offset": 176160768,
+ "size": 2097152,
+ "path": "ownership.pk.part0084",
+ "sha256": "sha256:ab1ce1c567e10e3393f50228a34741df28e897064164830c3db99f51212b7a86",
+ "blake2b256": "blake2b256:f83e79518b0192fa0964570c0c07a42fbe065c1c08f988b02f0086ac558d65bd"
+ },
+ {
+ "index": 85,
+ "offset": 178257920,
+ "size": 2097152,
+ "path": "ownership.pk.part0085",
+ "sha256": "sha256:70ffe7ca8b15bce598ed4b597517ee2577c7d59fa3512af2c4ea604c9af95bee",
+ "blake2b256": "blake2b256:6b808af8f056dd95b5f1e1f13c6612dcf64ec1a7e7381f621a26c7ea572c90c8"
+ },
+ {
+ "index": 86,
+ "offset": 180355072,
+ "size": 2097152,
+ "path": "ownership.pk.part0086",
+ "sha256": "sha256:bc38fe916d719d5ad11cad0c8bab8288ce92820044324f848c647cfd0584a531",
+ "blake2b256": "blake2b256:6982d3c856baf415c7ba24c11038eb33c0f889577622aa0b959c49813c97a670"
+ },
+ {
+ "index": 87,
+ "offset": 182452224,
+ "size": 2097152,
+ "path": "ownership.pk.part0087",
+ "sha256": "sha256:3e30a1989492aaec11fb3cbd95f6c00b9a8cf08ee085ff59c2ba4deb78bb1529",
+ "blake2b256": "blake2b256:f4e3376e982f989c769c3fb07c3f9e3f66a1eca1314af9339823f60f2e043482"
+ },
+ {
+ "index": 88,
+ "offset": 184549376,
+ "size": 2097152,
+ "path": "ownership.pk.part0088",
+ "sha256": "sha256:ac9ff6ca13dad1f3760859fdd6ed10743a72a275f775a7ab810a80ee6b9f5306",
+ "blake2b256": "blake2b256:d392924e7137aa205560bb209e75db2c84a55949a292a2e74bd8f3ea8f16d50a"
+ },
+ {
+ "index": 89,
+ "offset": 186646528,
+ "size": 2097152,
+ "path": "ownership.pk.part0089",
+ "sha256": "sha256:9360444a20cf299d98cbacf6d5aedf196504b4e72cf435c87f73282afc4d1e39",
+ "blake2b256": "blake2b256:bb32dae8256a1cefd693dc7a9ed721f225556cb00e66f8cc90607a5b79ef268c"
+ },
+ {
+ "index": 90,
+ "offset": 188743680,
+ "size": 2097152,
+ "path": "ownership.pk.part0090",
+ "sha256": "sha256:9f1823539749a6e592596b746dd1e965952d4862b2376aa04ecf058e1284e693",
+ "blake2b256": "blake2b256:a783303774f4bbf5f58f960063fbb07aa98bbafc67bdaa4aaed64a43e6bf916b"
+ },
+ {
+ "index": 91,
+ "offset": 190840832,
+ "size": 2097152,
+ "path": "ownership.pk.part0091",
+ "sha256": "sha256:5a649706b9f67505ea9a4e7b1fc50567f62ca4115afafecf97d29ee99042f7ca",
+ "blake2b256": "blake2b256:ac05e465668b4b1c5ac15370857c69c942cf5e4a0ce0bc1b4356f659ef239870"
+ },
+ {
+ "index": 92,
+ "offset": 192937984,
+ "size": 2097152,
+ "path": "ownership.pk.part0092",
+ "sha256": "sha256:64ad9d308da1310598ce2a161416e712b974264aeb13fa244aa8e8d92e9335e8",
+ "blake2b256": "blake2b256:134b3e3069b585164d28020b5dd8bbf3f37bc16aa4a24f23cf37d44d9ed0572e"
+ },
+ {
+ "index": 93,
+ "offset": 195035136,
+ "size": 2097152,
+ "path": "ownership.pk.part0093",
+ "sha256": "sha256:ba48de28b2a2be067b3c3c88c0194abdb78694dd1880150c4909051be16f3a35",
+ "blake2b256": "blake2b256:72f013654b8707e51be6183d405bb372eb4faccd4f72faa869a5717ebd46dcd7"
+ },
+ {
+ "index": 94,
+ "offset": 197132288,
+ "size": 2097152,
+ "path": "ownership.pk.part0094",
+ "sha256": "sha256:aad09f7eda72abeae42d76f1f7cf64c06d40cdfd6f74a4d5acf2b455cdf0e467",
+ "blake2b256": "blake2b256:f92dc336d9fcdc18e1b22b8cd0c57e42c0e4e80e059a914b4020727671c881b3"
+ },
+ {
+ "index": 95,
+ "offset": 199229440,
+ "size": 2097152,
+ "path": "ownership.pk.part0095",
+ "sha256": "sha256:ad0e7ca4bbbc2743726a24d8320309266b8619637e09df8ea52efbbb77389022",
+ "blake2b256": "blake2b256:f211b81f85608b3f7f9482d58636eadb37c810a4500b819df4b9e518825975db"
+ },
+ {
+ "index": 96,
+ "offset": 201326592,
+ "size": 2097152,
+ "path": "ownership.pk.part0096",
+ "sha256": "sha256:00c57b9e1cdce8c54daa96a831e1fb38b1f474542a6ddb4e6940399d325ef8a3",
+ "blake2b256": "blake2b256:4aac365a7f0cfdb6d9831ba3376bc52fd1c9ca85381026197edc5642441322f2"
+ },
+ {
+ "index": 97,
+ "offset": 203423744,
+ "size": 2097152,
+ "path": "ownership.pk.part0097",
+ "sha256": "sha256:9f0491a7559b571adef319af6f9a91ea798c698976fc469332370ea36cfe6258",
+ "blake2b256": "blake2b256:7754bd9cee690e46ff438fe5d86d2d3dc0465708664de64a83596b6f79599919"
+ },
+ {
+ "index": 98,
+ "offset": 205520896,
+ "size": 2097152,
+ "path": "ownership.pk.part0098",
+ "sha256": "sha256:6da531f37ac885945e1da3f55626c478c4787fcd3fbec1bbee067dcedfda77c5",
+ "blake2b256": "blake2b256:19dbdd9e1c270c48499f229dccbe55237245ffc20f0352518b9c0f099c53221e"
+ },
+ {
+ "index": 99,
+ "offset": 207618048,
+ "size": 2097152,
+ "path": "ownership.pk.part0099",
+ "sha256": "sha256:0434676ba2db3ab80f41e1179aecccdd87cf172d6242a0a2f4e9954a1317dcf1",
+ "blake2b256": "blake2b256:0551e02240339c9c53398fdd454ed787578fcdcd733296cb2193d6f7fc658216"
+ },
+ {
+ "index": 100,
+ "offset": 209715200,
+ "size": 2097152,
+ "path": "ownership.pk.part0100",
+ "sha256": "sha256:d9c1bf7949a2e272783a12a194c83a5f70c5b6cf2bb1df6926896564995e6a71",
+ "blake2b256": "blake2b256:9c5ea4aeb22baee5b16cba6a9adfbbc06320733f6068ae1a01424cee2d35bcea"
+ },
+ {
+ "index": 101,
+ "offset": 211812352,
+ "size": 2097152,
+ "path": "ownership.pk.part0101",
+ "sha256": "sha256:dcab6968a3a2428b9cf33c7be27204516a6582629d33f46712a766a47757f91d",
+ "blake2b256": "blake2b256:62081e3447f204e9d2e2d7b956da11476ff907351feabf436d5dcb17cebe2cbf"
+ },
+ {
+ "index": 102,
+ "offset": 213909504,
+ "size": 2097152,
+ "path": "ownership.pk.part0102",
+ "sha256": "sha256:53e8a8f797924c73f9cc289a4c3d84c1bc6432f5ee47f2d5f74dfa4e1ceada0e",
+ "blake2b256": "blake2b256:803dc5845f77f1cc25fe998412cd712ea81f4bf15b2892572dc20b12f13a3655"
+ },
+ {
+ "index": 103,
+ "offset": 216006656,
+ "size": 2097152,
+ "path": "ownership.pk.part0103",
+ "sha256": "sha256:38ffb1da9699177cb1c46b84843b68634088fe2a174a0533613acdb4c1b106c3",
+ "blake2b256": "blake2b256:9ccc3911ff4ee854da2b4e9706273e3471d3c0c01d993f07f5d8f37f4cbd009d"
+ },
+ {
+ "index": 104,
+ "offset": 218103808,
+ "size": 2097152,
+ "path": "ownership.pk.part0104",
+ "sha256": "sha256:1f0cad0ac8e8e650ebb52e41c2136d74bb72caf95e83d00ed58e2af6f3a0e93a",
+ "blake2b256": "blake2b256:63afec24a3f1dc84ca366853bb615cec8f530a9cd10d2148c85c357d2bec5002"
+ },
+ {
+ "index": 105,
+ "offset": 220200960,
+ "size": 2097152,
+ "path": "ownership.pk.part0105",
+ "sha256": "sha256:309b8a1875ee02df96e5e38a02b5ef55f03a5e5ec84b11040e663f2fdde7e8f6",
+ "blake2b256": "blake2b256:8e03258e72fec6c2f8071e4b7ab1061e4ece78d4a6fd318d6b9cab9f35ef7637"
+ },
+ {
+ "index": 106,
+ "offset": 222298112,
+ "size": 2097152,
+ "path": "ownership.pk.part0106",
+ "sha256": "sha256:c1feeeefa7ae4fd162b7b0b8a7b46401e2636c94960a24955bd7b899184ad4f3",
+ "blake2b256": "blake2b256:a9e32bec864592aa72bcd1881a8259d8a483a1be17667b7812164ea487777264"
+ },
+ {
+ "index": 107,
+ "offset": 224395264,
+ "size": 2097152,
+ "path": "ownership.pk.part0107",
+ "sha256": "sha256:bd6f1d7bc2ea96a0e34275b488dff02e11264e606853ccfae00d253bd2b89b4c",
+ "blake2b256": "blake2b256:944ff259b23d12b5bb4df1350fa812433855ff11f5c21826f4e806aee905eabf"
+ },
+ {
+ "index": 108,
+ "offset": 226492416,
+ "size": 2097152,
+ "path": "ownership.pk.part0108",
+ "sha256": "sha256:be9a97b53229c097e82745a213fd196d8f151346d3ee2c2050f5b2e04ec5a4a1",
+ "blake2b256": "blake2b256:bde376621e7f91bdaeb10ce6a3328c3af34bde1eea2cffe1c96f46c44403d911"
+ },
+ {
+ "index": 109,
+ "offset": 228589568,
+ "size": 2097152,
+ "path": "ownership.pk.part0109",
+ "sha256": "sha256:9fc33e0b3b6bc35523a16d4f7c85f8d3f654361e932c4029d3c93cb935aee8db",
+ "blake2b256": "blake2b256:fe1d45b068dd58e00fff070623d4007055047e994ce4d582e977ab4599b6aed5"
+ },
+ {
+ "index": 110,
+ "offset": 230686720,
+ "size": 2097152,
+ "path": "ownership.pk.part0110",
+ "sha256": "sha256:f29f796fc656a336fcd1996136a4bf7694e8b1fd5c8b99f9390c1df56d6356ef",
+ "blake2b256": "blake2b256:57af371c4f915b27c63548f49a2967d1e89953fec70d5516826083110206f4af"
+ },
+ {
+ "index": 111,
+ "offset": 232783872,
+ "size": 2097152,
+ "path": "ownership.pk.part0111",
+ "sha256": "sha256:01de31fa867f12862e31911f0e9293cd762dde07f1dbb13d70872be9ef7484e6",
+ "blake2b256": "blake2b256:7f3ef00201c6b9b4617a2628de30796aef40d1f8b862515a321a797df4444b20"
+ },
+ {
+ "index": 112,
+ "offset": 234881024,
+ "size": 2097152,
+ "path": "ownership.pk.part0112",
+ "sha256": "sha256:fdbf9509c9e2ab7dd92599fc6bfd193fac6ef4f67ae56d410d61eebee0e217aa",
+ "blake2b256": "blake2b256:efb9d74947ddf107baf97fdb892da9d5eed36eecdf4cc95e06b9931a360c7494"
+ },
+ {
+ "index": 113,
+ "offset": 236978176,
+ "size": 2097152,
+ "path": "ownership.pk.part0113",
+ "sha256": "sha256:6e6759bd8b53f89353825bb436039c60e42c392f4f7c3bd887f2eff9b8faaadd",
+ "blake2b256": "blake2b256:5f4e72480554f6b093d253c48f8fe0f99091b2b0d90304c5663309fa2a13e4d0"
+ },
+ {
+ "index": 114,
+ "offset": 239075328,
+ "size": 2097152,
+ "path": "ownership.pk.part0114",
+ "sha256": "sha256:2dcb60f9b8dfcfb0817d63a7687c5506defd0b8f80f68c19815e019241597105",
+ "blake2b256": "blake2b256:7fc545d3bcd15e76b16cd4b201351392b5633fca1cb31a1bb092c991e57be5f5"
+ },
+ {
+ "index": 115,
+ "offset": 241172480,
+ "size": 2097152,
+ "path": "ownership.pk.part0115",
+ "sha256": "sha256:2ee9058acc3f942d2dd73896bb0bd4336f0b0b92e4d23ad19c5488bac9fd7987",
+ "blake2b256": "blake2b256:2d292ccbee9913aba050a5cbea1838ecca367a727df6c330b45dd5ef309ca6f5"
+ },
+ {
+ "index": 116,
+ "offset": 243269632,
+ "size": 2097152,
+ "path": "ownership.pk.part0116",
+ "sha256": "sha256:5e3d0605313ba2d3c385726a47aa7badc74b611a4796a939f77777c9dfc5935c",
+ "blake2b256": "blake2b256:ef8288dcc58874dcd51a70478796d645d22f5838f96ea4775b507ec5cfc88b1b"
+ },
+ {
+ "index": 117,
+ "offset": 245366784,
+ "size": 2097152,
+ "path": "ownership.pk.part0117",
+ "sha256": "sha256:585dc688e16e230a4c5f1d307de74ea9d94d301f49741c2eac6c33ed28018388",
+ "blake2b256": "blake2b256:c2c1cfcf0ce16fff8a6e08dc3c6c3a0d1c51981897ff872b9d0c702c4dc442ea"
+ },
+ {
+ "index": 118,
+ "offset": 247463936,
+ "size": 2097152,
+ "path": "ownership.pk.part0118",
+ "sha256": "sha256:5a0d1e221cab52899b4e61465224283e6a1ffab75891010678c42b0a25be6881",
+ "blake2b256": "blake2b256:a329d5b5780b0472a43de3eb3b58420347340dd18dfdbb110604dcc886229b24"
+ },
+ {
+ "index": 119,
+ "offset": 249561088,
+ "size": 2097152,
+ "path": "ownership.pk.part0119",
+ "sha256": "sha256:0f0007a9c97ae6950e7aa3843b57fe85f3df366368566f491678e19cf91494a2",
+ "blake2b256": "blake2b256:4394f2217ee20003e47132d19904732d3266d375026aa9a1b3c2e95456a18a02"
+ },
+ {
+ "index": 120,
+ "offset": 251658240,
+ "size": 2097152,
+ "path": "ownership.pk.part0120",
+ "sha256": "sha256:c578df7d2389c1cc61e514f8c28bc2e560f187c1f8c4d7b024cfe761033aba10",
+ "blake2b256": "blake2b256:5fe3b1a70f21af0c89955e5966abe3711219e71846e73af671a4bcb26ba8ecf9"
+ },
+ {
+ "index": 121,
+ "offset": 253755392,
+ "size": 2097152,
+ "path": "ownership.pk.part0121",
+ "sha256": "sha256:af4e5332005bdc4e28b80ae3924fe27a107b3820ff23b1a95b49c12748cf3f73",
+ "blake2b256": "blake2b256:595de05e34e382bc9dc30113e780093cc88cf182227adccc4fa798d423f72948"
+ },
+ {
+ "index": 122,
+ "offset": 255852544,
+ "size": 2097152,
+ "path": "ownership.pk.part0122",
+ "sha256": "sha256:409ce271875e4b739356fdd82fa87a6219998028b0adff01ca6887a7faa6612f",
+ "blake2b256": "blake2b256:77e3bdfa5cb5ba177b64764dc2cfb4e1c259618f19dbcb89516213f1543522a2"
+ },
+ {
+ "index": 123,
+ "offset": 257949696,
+ "size": 2097152,
+ "path": "ownership.pk.part0123",
+ "sha256": "sha256:a72f2fd0f6f1bb1b9da0e916bb95f05369b512fb4a9e826ae62493c14072b5bf",
+ "blake2b256": "blake2b256:5a0be73d7bbb829d051bfcbd6b8c6839e0cf105e7a4ec8eb7efde63f101d09be"
+ },
+ {
+ "index": 124,
+ "offset": 260046848,
+ "size": 2097152,
+ "path": "ownership.pk.part0124",
+ "sha256": "sha256:ff2decf27afb99f858f885810f3506e43d4810d3e7f2974964a9c34fef8c9eae",
+ "blake2b256": "blake2b256:13caf8933f3e3ff66e468db93da74a84aa9cfc6b7f9e24056a9ac149a2c86673"
+ },
+ {
+ "index": 125,
+ "offset": 262144000,
+ "size": 2097152,
+ "path": "ownership.pk.part0125",
+ "sha256": "sha256:537764035dbd3ea18e5c5ff908447fdd06c757f6a88f674b51460bc4f6921608",
+ "blake2b256": "blake2b256:453b4f7cd290aa797c3966daa8c2aa036d3e70743bcccde41d2f37a18082f3d6"
+ },
+ {
+ "index": 126,
+ "offset": 264241152,
+ "size": 2097152,
+ "path": "ownership.pk.part0126",
+ "sha256": "sha256:135a8e2227d421ce13fec9ead88c0c4cf7e28f809e1f34ee22293eeae8b1eaca",
+ "blake2b256": "blake2b256:1cc31f8808eb215feaad26bb1f706e3e4cd6e66a9aebfd7086a6ab347261095d"
+ },
+ {
+ "index": 127,
+ "offset": 266338304,
+ "size": 2097152,
+ "path": "ownership.pk.part0127",
+ "sha256": "sha256:50571eda2367840928bd751c29b9c6c5ab3b7ec5ea43b0adc1178530b0e38600",
+ "blake2b256": "blake2b256:6ea0d55d9c07e533347a4206c359229f4449c38f04f2b4b38bdd4e7755c64598"
+ },
+ {
+ "index": 128,
+ "offset": 268435456,
+ "size": 2097152,
+ "path": "ownership.pk.part0128",
+ "sha256": "sha256:32ee594aba22e940a5d6572276689a423e192c4d81c94a559626bbe4e6f5f197",
+ "blake2b256": "blake2b256:0089f745895e6ecc9b5485ed3eee1444c7b5ab4a4a9f70358d2b6805aaec07d2"
+ },
+ {
+ "index": 129,
+ "offset": 270532608,
+ "size": 2097152,
+ "path": "ownership.pk.part0129",
+ "sha256": "sha256:5aba8b8ca0bd671700cfe8de74282d194febc0902a6ba188f977b8edfe1fa12b",
+ "blake2b256": "blake2b256:d41ca1c3d01eb5cf9524827a62541204164bca2cb4e7c62400d55e277cf521bb"
+ },
+ {
+ "index": 130,
+ "offset": 272629760,
+ "size": 2097152,
+ "path": "ownership.pk.part0130",
+ "sha256": "sha256:f4d2b5add4e1761f0eae6a80a0f140e69868dd32f4ef26a957ab7e189a27f1bf",
+ "blake2b256": "blake2b256:9cf9f4c4238df805be4097fc5bb9e757e1bdfae618fdf396485dc2e8280ab6ce"
+ },
+ {
+ "index": 131,
+ "offset": 274726912,
+ "size": 2097152,
+ "path": "ownership.pk.part0131",
+ "sha256": "sha256:5df410f97307edd5d153d96b4f2440351c351b222ce569f04e6289822bd9e727",
+ "blake2b256": "blake2b256:663f2b7b17cd3e6af48295b86657bfd8f4e6e38d5ad70b76ca3d0dfd7062fdcf"
+ },
+ {
+ "index": 132,
+ "offset": 276824064,
+ "size": 2097152,
+ "path": "ownership.pk.part0132",
+ "sha256": "sha256:cbf828f4a43240005fd1c0975126278b091a69b19e2d1d68dd7bc977bac5a763",
+ "blake2b256": "blake2b256:9c1cda279f2e91c7e4ef681bf69dc77b60bf61d46690645467d83efca3548b59"
+ },
+ {
+ "index": 133,
+ "offset": 278921216,
+ "size": 2097152,
+ "path": "ownership.pk.part0133",
+ "sha256": "sha256:3af2e3338bca1ca240d4a3488049f79b001c4936140b8973fbb886e6e560532c",
+ "blake2b256": "blake2b256:e75c3516e7c0499e1eadb993648b24029a82e9a3086b945b52da6fd32bff6bb6"
+ },
+ {
+ "index": 134,
+ "offset": 281018368,
+ "size": 2097152,
+ "path": "ownership.pk.part0134",
+ "sha256": "sha256:39fdd11d7a3a75d96be446cf0f3af14f8c91044dc1e8241a455ec4757d3ee656",
+ "blake2b256": "blake2b256:ffed1d6eca819b5806ab8c7c1375f2d78f5a129df7514c0ca2334261edfd6006"
+ },
+ {
+ "index": 135,
+ "offset": 283115520,
+ "size": 2097152,
+ "path": "ownership.pk.part0135",
+ "sha256": "sha256:84b892bd53a0174904b059114c850c8bdb829087c9ee11fab5d695d758f8b46c",
+ "blake2b256": "blake2b256:24b13cba405ccbc4618a3347145b658c99ac3e285c2ac4f57a4c3dce11b1ddc2"
+ },
+ {
+ "index": 136,
+ "offset": 285212672,
+ "size": 2097152,
+ "path": "ownership.pk.part0136",
+ "sha256": "sha256:be5b9196f4636fd97395d0fa68e7473563a46f7b5f24be8880df998d518abb2c",
+ "blake2b256": "blake2b256:870a4f906e4f0e26cef795e4ec7eb170c977a8fca0a35333de69784297c03a2e"
+ },
+ {
+ "index": 137,
+ "offset": 287309824,
+ "size": 2097152,
+ "path": "ownership.pk.part0137",
+ "sha256": "sha256:4ffb0bd479d9eb4d01d7de01815240592f598c2a592fe2370658e0d3522dd92c",
+ "blake2b256": "blake2b256:2c01f71f6709e724eea678142b4d4018c69e5c622075f898213fe1a42091f27b"
+ },
+ {
+ "index": 138,
+ "offset": 289406976,
+ "size": 2097152,
+ "path": "ownership.pk.part0138",
+ "sha256": "sha256:8d12b478cf1e3f354cfc4023dfc1682b05b896ddca7e791e2f7a192fa3c2bbd0",
+ "blake2b256": "blake2b256:7b0956f1254d09db6e33cc8523979370276974a341eae65759fdc2d19e86b453"
+ },
+ {
+ "index": 139,
+ "offset": 291504128,
+ "size": 2097152,
+ "path": "ownership.pk.part0139",
+ "sha256": "sha256:71d713b016a19e31ba4f919fb761ff5efa137561ea2c3effed6b5b35edb24744",
+ "blake2b256": "blake2b256:2c476874ee53227e07fc3b640398392e69fb0f802b4d347378afd427b795da70"
+ },
+ {
+ "index": 140,
+ "offset": 293601280,
+ "size": 2097152,
+ "path": "ownership.pk.part0140",
+ "sha256": "sha256:3d8c17cabeb2663ebcd94636202792258183edb56e170aa9965e14ddc42d1d8c",
+ "blake2b256": "blake2b256:1ac65663c6975ed5ba12741732f3e910b8c4176cd285714a2cca136fcf1263a5"
+ },
+ {
+ "index": 141,
+ "offset": 295698432,
+ "size": 2097152,
+ "path": "ownership.pk.part0141",
+ "sha256": "sha256:6161ef938d90f7098f906fb0a288376dc7f4d578db04c5931391dd05454794db",
+ "blake2b256": "blake2b256:37be5177eef478cb69dbf3f2cf0c9890312d14f6e01b1715acde4c2004b3f49d"
+ },
+ {
+ "index": 142,
+ "offset": 297795584,
+ "size": 2097152,
+ "path": "ownership.pk.part0142",
+ "sha256": "sha256:cddc64a5d29d254b0d571f68b58ef370c32e1f7f5b99312e07626795bf46a9ae",
+ "blake2b256": "blake2b256:0d7d2e48882921631f9a1d9d7e26b85e04aa892ba015261311962c5d7b729024"
+ },
+ {
+ "index": 143,
+ "offset": 299892736,
+ "size": 2097152,
+ "path": "ownership.pk.part0143",
+ "sha256": "sha256:a0d8c0531d987eaeab5c28fd8a4401726ff850ef4840efdaac25d235572b0d82",
+ "blake2b256": "blake2b256:de1f6a9a0751b08818b16a74756df939501cc43672d9695395a3fc52e86d2052"
+ },
+ {
+ "index": 144,
+ "offset": 301989888,
+ "size": 2097152,
+ "path": "ownership.pk.part0144",
+ "sha256": "sha256:691cfbfcc81c682f7316dbce02cb7c3f9284b6cb19c6a777718e3ee9088dfa90",
+ "blake2b256": "blake2b256:4e95596797cc5518fea1ac979f8ab18a947619a6f9c5c02cd406063894032aee"
+ },
+ {
+ "index": 145,
+ "offset": 304087040,
+ "size": 2097152,
+ "path": "ownership.pk.part0145",
+ "sha256": "sha256:6af57a3307972278b7ac42416c3dbdf2e45da2a40937440f7dbff8713ca36ae0",
+ "blake2b256": "blake2b256:916e46f166937c87cd134b492019099d4d4caf043445c404059b2f209e52809e"
+ },
+ {
+ "index": 146,
+ "offset": 306184192,
+ "size": 2097152,
+ "path": "ownership.pk.part0146",
+ "sha256": "sha256:9f346111db9caf678549db7edb1d8004410c4e019a90d23247293c780ec0a08e",
+ "blake2b256": "blake2b256:93e78d9c7d7763690f5f9cb6e02ebb1093bc06d6b78f5bdb7c178d394a28c93d"
+ },
+ {
+ "index": 147,
+ "offset": 308281344,
+ "size": 2097152,
+ "path": "ownership.pk.part0147",
+ "sha256": "sha256:ce1d20edc14661a437d36fb07cd941545854ad2d41beb16336148c98e4873745",
+ "blake2b256": "blake2b256:c51a7b6e0630cfbe6fbe8bd372b1c83e1f1622f490b2348a13b7e0875f95714e"
+ },
+ {
+ "index": 148,
+ "offset": 310378496,
+ "size": 2097152,
+ "path": "ownership.pk.part0148",
+ "sha256": "sha256:d5c3a3de37e2db4823e08bac9f760760f6200d70b94dd2346cfc729a0b210424",
+ "blake2b256": "blake2b256:960cff968a121edaef7de2b728ffbbde347f44b17e59d9b0c0bda3a00ea7615f"
+ },
+ {
+ "index": 149,
+ "offset": 312475648,
+ "size": 2097152,
+ "path": "ownership.pk.part0149",
+ "sha256": "sha256:de908e47bd54cf2c6b118594bbfb881f47909a1a760da305b3d00351b6ec7bba",
+ "blake2b256": "blake2b256:c95d6f9c13def39be88ebcb6ffcf83fc2dc1a53c2f303e3355d92eb5fbe84903"
+ },
+ {
+ "index": 150,
+ "offset": 314572800,
+ "size": 2097152,
+ "path": "ownership.pk.part0150",
+ "sha256": "sha256:b03c0638009138f0c58c3543f6f05221f4572c7d9ab6c98d50e07e1b73971320",
+ "blake2b256": "blake2b256:764acf0c79b9b8aa80e34c7ad28170eac61abc654b44a85963cb0e44349a5c21"
+ },
+ {
+ "index": 151,
+ "offset": 316669952,
+ "size": 2097152,
+ "path": "ownership.pk.part0151",
+ "sha256": "sha256:9e48a308d2df5e06081f658f0229cb20bd7531be6c7ecca2de3621fbe6e81515",
+ "blake2b256": "blake2b256:9a285fb590e06d249c9644c99b97c1ed14581a3fb95394bf6402c91439f185bd"
+ },
+ {
+ "index": 152,
+ "offset": 318767104,
+ "size": 2097152,
+ "path": "ownership.pk.part0152",
+ "sha256": "sha256:204d7ae0ee450fd8c9cb75072c02b0af6ff6fd20373199cb58345b04ce9e23a5",
+ "blake2b256": "blake2b256:78a5d14a65e0effbbd46fd8210dd1ff153c397c1d63f8bde9d2e152e51b29d65"
+ },
+ {
+ "index": 153,
+ "offset": 320864256,
+ "size": 2097152,
+ "path": "ownership.pk.part0153",
+ "sha256": "sha256:28ecaf20cbe7bd3e559d2a27a9d2885b144eb213c1872b7fabb296edc6e3f79f",
+ "blake2b256": "blake2b256:66314b4759b6a39d64cba0bc0e9acd6f929b4a223d413d0c3c03b3bf1bc6aa4a"
+ },
+ {
+ "index": 154,
+ "offset": 322961408,
+ "size": 2097152,
+ "path": "ownership.pk.part0154",
+ "sha256": "sha256:e78d3fa1c5748446108967c40830efee12abd31f74e568f6a1457f5aa910cfbf",
+ "blake2b256": "blake2b256:bf13451eb034534086f9348cff4074d64c8cbc1d7b5b040452b4ffbfe65236e0"
+ },
+ {
+ "index": 155,
+ "offset": 325058560,
+ "size": 2097152,
+ "path": "ownership.pk.part0155",
+ "sha256": "sha256:c7d2e647f200f44a76b0e3920714f7301ae68bc8a0b7fa20adcd103c886d65e2",
+ "blake2b256": "blake2b256:69f3ff0e7cbc4ef8d0a44fe301921488a088100beff35fb44ac71dadabbe8b52"
+ },
+ {
+ "index": 156,
+ "offset": 327155712,
+ "size": 2097152,
+ "path": "ownership.pk.part0156",
+ "sha256": "sha256:41b2a0a2d17d899579364743a6c98219ed750d55a64d9abe5781c8e1a3feef7f",
+ "blake2b256": "blake2b256:2158a1e3780921d8d0e451b5f10aa3d2090c28975ca33839028060dd754446b2"
+ },
+ {
+ "index": 157,
+ "offset": 329252864,
+ "size": 2097152,
+ "path": "ownership.pk.part0157",
+ "sha256": "sha256:49c4fa48380bc66e3b0907fe7377391a642f31049187cfe8415d5121df6eddcd",
+ "blake2b256": "blake2b256:c6259fdf772be189ce99111e6bca7328c187946e1ba5a262296a0a48e48ad482"
+ },
+ {
+ "index": 158,
+ "offset": 331350016,
+ "size": 2097152,
+ "path": "ownership.pk.part0158",
+ "sha256": "sha256:1f4cba4f12c12efa1f5b5b2bcacfbdc3412b1d832132ffa75d6229b634ff464e",
+ "blake2b256": "blake2b256:9af1f7fd32e12c8cce44a0f14f2fbe56f814144b7f38d53bcaa0bb0b2f691895"
+ },
+ {
+ "index": 159,
+ "offset": 333447168,
+ "size": 2097152,
+ "path": "ownership.pk.part0159",
+ "sha256": "sha256:92086debfb572be2aa41094ebfd642322fbc36c4edf79298921822b2c6a17a60",
+ "blake2b256": "blake2b256:219b10af76fe05f67883385b25843dccde370417864b0ab1a541e759fe8b8c44"
+ },
+ {
+ "index": 160,
+ "offset": 335544320,
+ "size": 2097152,
+ "path": "ownership.pk.part0160",
+ "sha256": "sha256:f3d3c1dd8830ba13c5b1ad6ffd9bcf4474c0570b66fdb920001909ab149d295d",
+ "blake2b256": "blake2b256:120e223007b8c134153ccaf81893321cd0ce360a46fa5cd460288ce85b460a65"
+ },
+ {
+ "index": 161,
+ "offset": 337641472,
+ "size": 2097152,
+ "path": "ownership.pk.part0161",
+ "sha256": "sha256:fc42acd764d2820447101b3f1f725f2f3c107774723e464837b0a4d09cfd3762",
+ "blake2b256": "blake2b256:059cbee1364e09f2033802e546dcc70c5a3ea5c6de121d469686959534854b24"
+ },
+ {
+ "index": 162,
+ "offset": 339738624,
+ "size": 2097152,
+ "path": "ownership.pk.part0162",
+ "sha256": "sha256:a608978aa6b270038612bbfcd7704623c620cf997dd864708eb7e302135222fc",
+ "blake2b256": "blake2b256:b4d3e80b4863ce512f77d1f28a7977c85446bbf53e54a4b2a42033bc1cf9ff7e"
+ },
+ {
+ "index": 163,
+ "offset": 341835776,
+ "size": 2097152,
+ "path": "ownership.pk.part0163",
+ "sha256": "sha256:e6f04250bde2bb070b426a423bac4af9a618d6484c977871a8e0f8cb0723e866",
+ "blake2b256": "blake2b256:e151e7f7409fbcfcdaae88d7a3ba574004c4f71196ff1da356726955d47bb2a4"
+ },
+ {
+ "index": 164,
+ "offset": 343932928,
+ "size": 2097152,
+ "path": "ownership.pk.part0164",
+ "sha256": "sha256:056860d5dc3f8ec47c8d8994781c90f14a9af126d1205c88fa21de303e311147",
+ "blake2b256": "blake2b256:bc427d00800f7cc483f13eee9c3e22eaa1632a66d32958c21c38d163b1e4ca26"
+ },
+ {
+ "index": 165,
+ "offset": 346030080,
+ "size": 2097152,
+ "path": "ownership.pk.part0165",
+ "sha256": "sha256:beb86266aac1b2c598ff0d9cc265f90bbaabdafdceb2632635f3a1065d288925",
+ "blake2b256": "blake2b256:4202fe0f6ebc69665393cd2ba5ff97a26e8cb53c6cdb27e163ea1da9b16150ad"
+ },
+ {
+ "index": 166,
+ "offset": 348127232,
+ "size": 2097152,
+ "path": "ownership.pk.part0166",
+ "sha256": "sha256:3325c18b662df69bc6d3c1eab99a35e99aeba7f9e8d51a87dcff8dfa5caf5e15",
+ "blake2b256": "blake2b256:4f20c628904bf6166f53d9a234432cb695499fd0235c60cf13430f89cec4e5ec"
+ },
+ {
+ "index": 167,
+ "offset": 350224384,
+ "size": 2097152,
+ "path": "ownership.pk.part0167",
+ "sha256": "sha256:001fd455f2e396a63284d5d3bcf743d223094c3726fa237a08321f266abf55be",
+ "blake2b256": "blake2b256:2c02f95531cadc0c797c4dd3516bb69cb0baa8567539e708f1dedfad38111438"
+ },
+ {
+ "index": 168,
+ "offset": 352321536,
+ "size": 2097152,
+ "path": "ownership.pk.part0168",
+ "sha256": "sha256:d5c6a22f7064ede3363c33fb6c0173fb7fff59475e536efb8750a5495652f631",
+ "blake2b256": "blake2b256:dca98e0c9a313b6babae84062473054b2e565146114e76a92edbc44761a279fb"
+ },
+ {
+ "index": 169,
+ "offset": 354418688,
+ "size": 2097152,
+ "path": "ownership.pk.part0169",
+ "sha256": "sha256:b72a8a65b5e6e14273a52d5623ea13b33223fef5f6e38382fde29302b786004e",
+ "blake2b256": "blake2b256:20a8c378e6bb974ad9c08525058b282dad5ea8d1ef2b3ac28c027a023217dd6e"
+ },
+ {
+ "index": 170,
+ "offset": 356515840,
+ "size": 2097152,
+ "path": "ownership.pk.part0170",
+ "sha256": "sha256:4e070e95c9d0770100cf884a261bca247a82b9eef13d19f57dbbdaf91456587b",
+ "blake2b256": "blake2b256:9f01fdbc1fab7a20494139a2ce153ec74733c7c8ac67c08827dac3939673c35a"
+ },
+ {
+ "index": 171,
+ "offset": 358612992,
+ "size": 2097152,
+ "path": "ownership.pk.part0171",
+ "sha256": "sha256:8b75518b759d5d523e2e585f0f7855f2c97504bd5e19db51d7df8cc676bbf00a",
+ "blake2b256": "blake2b256:47a49369391f4c5d42950b0fb87242bacc3d51ca94a41cb5971552f2b347c3e3"
+ },
+ {
+ "index": 172,
+ "offset": 360710144,
+ "size": 2097152,
+ "path": "ownership.pk.part0172",
+ "sha256": "sha256:9520fda258820b986fe1a6ed4106d7d1e58bfe3e9b0f70ef2558a53aaa58d4ce",
+ "blake2b256": "blake2b256:0b5012c58ec506c8df777004a5d7623c014525b6e765304979aa00b0f48bb825"
+ },
+ {
+ "index": 173,
+ "offset": 362807296,
+ "size": 2097152,
+ "path": "ownership.pk.part0173",
+ "sha256": "sha256:1a114bf24c09daab9beb3c4f877e37126832f5734eb92ef858d9f00ac4e9d6ae",
+ "blake2b256": "blake2b256:c3dce52994b8fb5653b44bb190234c424e73bb8b000cadbf639dfc9cad8cb0e3"
+ },
+ {
+ "index": 174,
+ "offset": 364904448,
+ "size": 2097152,
+ "path": "ownership.pk.part0174",
+ "sha256": "sha256:89d836ae6a0bb7b0b344bbd970de8fd12a57a65c321ed74b533625f7be9bec50",
+ "blake2b256": "blake2b256:35028057dd76a893a13b6cebd854dfd0b795bca7a9f6b0f6345d3e9aae291587"
+ },
+ {
+ "index": 175,
+ "offset": 367001600,
+ "size": 2097152,
+ "path": "ownership.pk.part0175",
+ "sha256": "sha256:164d448d09e04d8c21d6db906acb22b83624cf2a981f143aa68378d5114c55b9",
+ "blake2b256": "blake2b256:e724fe0b0fc5ce6880947bf4d4042387ebd7c753bd3ffea7e44cad175eb8d339"
+ },
+ {
+ "index": 176,
+ "offset": 369098752,
+ "size": 2097152,
+ "path": "ownership.pk.part0176",
+ "sha256": "sha256:f597f98eb5742d70e0234a7610896fae441faa0af1e855d7f8b49853e05f553b",
+ "blake2b256": "blake2b256:c265027310bfcaa790365e313e52904258653908f591973bd652fe1ebd52ef18"
+ },
+ {
+ "index": 177,
+ "offset": 371195904,
+ "size": 2097152,
+ "path": "ownership.pk.part0177",
+ "sha256": "sha256:88a691e85d447e42e5cdc18f1a3f432ea6c5632039330736bc7fd32cb9fb9b34",
+ "blake2b256": "blake2b256:2bdc41c43196b3d1f7a665bde2fe86354942149cec28ec9765d3df370a3fbe67"
+ },
+ {
+ "index": 178,
+ "offset": 373293056,
+ "size": 2097152,
+ "path": "ownership.pk.part0178",
+ "sha256": "sha256:daaf5248f7a382d4470a1d9879202fb18407159b8563f9b7bae69f88b2ec7683",
+ "blake2b256": "blake2b256:700741e9bba8293d192d33af52dba0e1ac90aa1e8488db2aa4a807938fb10644"
+ },
+ {
+ "index": 179,
+ "offset": 375390208,
+ "size": 2097152,
+ "path": "ownership.pk.part0179",
+ "sha256": "sha256:9890a80d88992882eebb5437a8c446be8e9aeba8cf2027480fb0929b33be4cf3",
+ "blake2b256": "blake2b256:8784c1f2a3b5424d9673e1a632012601b2ded64a7b895fed005e0e924f792659"
+ },
+ {
+ "index": 180,
+ "offset": 377487360,
+ "size": 2097152,
+ "path": "ownership.pk.part0180",
+ "sha256": "sha256:37894e9d4778ae2d9ec6f688430eb57b6395efae23ac4e3069909eafffa28222",
+ "blake2b256": "blake2b256:a3f5d41de2b9a0acc24a0e33389bff675348870ce97690ddad9e7a1d55c6b039"
+ },
+ {
+ "index": 181,
+ "offset": 379584512,
+ "size": 2097152,
+ "path": "ownership.pk.part0181",
+ "sha256": "sha256:55894af3b94fbd1a464a2747f543fc4656fa9b144049ce321b473bc57cdc22c2",
+ "blake2b256": "blake2b256:62a2837365a682675eca81205f523e30fe03a070e1a950d37c5f5c2fa5206ba4"
+ },
+ {
+ "index": 182,
+ "offset": 381681664,
+ "size": 2097152,
+ "path": "ownership.pk.part0182",
+ "sha256": "sha256:e736d6e0b6fb80d38f99cf3da1adb68bc0008c4a253456edc9d9c2961779e782",
+ "blake2b256": "blake2b256:d4dcad62aae74ccc10c7ed5da063d16628dc3908141de011258cf9c844429315"
+ },
+ {
+ "index": 183,
+ "offset": 383778816,
+ "size": 2097152,
+ "path": "ownership.pk.part0183",
+ "sha256": "sha256:386a4a74e31aa0972b843e22f5e697f049ff3af32f14788083d2176d7fa38fd8",
+ "blake2b256": "blake2b256:a52b9d5c2f724975bfa19edfda0827a35cced2b12381b7819a15f928774f6d7c"
+ },
+ {
+ "index": 184,
+ "offset": 385875968,
+ "size": 2097152,
+ "path": "ownership.pk.part0184",
+ "sha256": "sha256:8ce3d8d8c23e3d68a177d151f61e5d2940708cf39b2f72999d8b49389de5247a",
+ "blake2b256": "blake2b256:b041758e99aae027a61f0ae1b82ea16d77f55a67b3380f6edc7ec84b11b4ddd0"
+ },
+ {
+ "index": 185,
+ "offset": 387973120,
+ "size": 2097152,
+ "path": "ownership.pk.part0185",
+ "sha256": "sha256:56d074bae908930c40e5ab58a35deaec3028b5fe8463783b52fd258acb2ed368",
+ "blake2b256": "blake2b256:2e020f6edca5b53b53861b844ea9765bb53d569eefa763908e4b8399faa85167"
+ },
+ {
+ "index": 186,
+ "offset": 390070272,
+ "size": 2097152,
+ "path": "ownership.pk.part0186",
+ "sha256": "sha256:7460e40c90be4ee0b6cdb4c3395cfad8985d52987c593cd24ad1530e2f72c588",
+ "blake2b256": "blake2b256:c3ecf28452dd989b84e4595b8bf7d8fd22d0190cc61d5109f5ae96360675422d"
+ },
+ {
+ "index": 187,
+ "offset": 392167424,
+ "size": 2097152,
+ "path": "ownership.pk.part0187",
+ "sha256": "sha256:ad4a193042b68978d849dffc01079f7e6cb7363edc23da424e01bf019c75e19e",
+ "blake2b256": "blake2b256:5de644c83efd4321c91fbd7a22ce61b2d62652ec63f30658b16f39ae3c2f4342"
+ },
+ {
+ "index": 188,
+ "offset": 394264576,
+ "size": 2097152,
+ "path": "ownership.pk.part0188",
+ "sha256": "sha256:810cfddbad78859eef4c284d85baa5aa160f4decc2dfef54df315a1fd6c34e3a",
+ "blake2b256": "blake2b256:fbaeb5d65d3ce1b38156013a457974d99ae1361d9b8134d9f025fca1a804db5b"
+ },
+ {
+ "index": 189,
+ "offset": 396361728,
+ "size": 2097152,
+ "path": "ownership.pk.part0189",
+ "sha256": "sha256:402a4025bb2851d49972c4b58016963fe1b45cbc133f65d75b69cc133cfcfd1d",
+ "blake2b256": "blake2b256:71409000210fdb39553de6a12b6c64b64a6619264eb534beede8597ff9e3a8a9"
+ },
+ {
+ "index": 190,
+ "offset": 398458880,
+ "size": 2097152,
+ "path": "ownership.pk.part0190",
+ "sha256": "sha256:481624b08fa14e881f44775e02748886485ef38c2dd801a0e8bd9b556be13c83",
+ "blake2b256": "blake2b256:c0e82409c317e17808369bcb30aeb1582d6f8bf406b9e2df0d842a2926cde3e1"
+ },
+ {
+ "index": 191,
+ "offset": 400556032,
+ "size": 2097152,
+ "path": "ownership.pk.part0191",
+ "sha256": "sha256:fd2b322add52f1609e824309cbce3246e300e20816ad12e3abe88c3ae1424817",
+ "blake2b256": "blake2b256:85a1d0b55e275161a8c7e38d616afa2bb33ac2cee1d4f32c37ac57de6994eb45"
+ },
+ {
+ "index": 192,
+ "offset": 402653184,
+ "size": 2097152,
+ "path": "ownership.pk.part0192",
+ "sha256": "sha256:f00660f35d4873822670bbc3b9412bd2f49396c59315364cfe941669ef609720",
+ "blake2b256": "blake2b256:fe207f14a0b87df99288d3c9768fee6068f04e2840902c7e4739845718dac77a"
+ },
+ {
+ "index": 193,
+ "offset": 404750336,
+ "size": 2097152,
+ "path": "ownership.pk.part0193",
+ "sha256": "sha256:dc78fceb2a44aa21d428025d78e76aaa1f48492dfacac677fab11e27b2c23f22",
+ "blake2b256": "blake2b256:ad44473a4c0ac5d3b5b034f30cc81606fdbc5c6c2319a2c73df15365bb07d507"
+ },
+ {
+ "index": 194,
+ "offset": 406847488,
+ "size": 2097152,
+ "path": "ownership.pk.part0194",
+ "sha256": "sha256:25ed950bbcfdfc5739dd924493b45f29977fac1c3539fc581be177eee2648942",
+ "blake2b256": "blake2b256:1977dd29d0aa49a3c76d047a952715464e0357ffb4eac6083c41e0c036783a65"
+ },
+ {
+ "index": 195,
+ "offset": 408944640,
+ "size": 2097152,
+ "path": "ownership.pk.part0195",
+ "sha256": "sha256:882807207a24e206704c20172272003ae9b5a8b5be84f946620dbc7b8198f05d",
+ "blake2b256": "blake2b256:8dbc6fe94b495419c95415ca94c08aa0c0f53c80a7a5d4a2cbb5f47a7ebab558"
+ },
+ {
+ "index": 196,
+ "offset": 411041792,
+ "size": 2097152,
+ "path": "ownership.pk.part0196",
+ "sha256": "sha256:5279d1000844bad10628817aca1fb9553de946d974dfaa66cf9c74331aba16d3",
+ "blake2b256": "blake2b256:8ffe6ed417db2e4e99b4b9f310e3d5799e3d8cb96a252e0aafb784a5b8d14236"
+ },
+ {
+ "index": 197,
+ "offset": 413138944,
+ "size": 2097152,
+ "path": "ownership.pk.part0197",
+ "sha256": "sha256:710c46876969f88726869aaa7dc46bdd25fc9833ef25ebb53d471d547f788438",
+ "blake2b256": "blake2b256:f4f1a16072e0c3f5abcc4e678bd6f5e0d8ac866b45052f476fc98fbb2d34c1c2"
+ },
+ {
+ "index": 198,
+ "offset": 415236096,
+ "size": 2097152,
+ "path": "ownership.pk.part0198",
+ "sha256": "sha256:948151cf29d9dc32bc1559169c028976f076dba67d08f68b026b5a009f8efd58",
+ "blake2b256": "blake2b256:40a1ed7b1cbabe1ce4ecb35860cd5bbab8ef729ce86e20e6759398c30f353953"
+ },
+ {
+ "index": 199,
+ "offset": 417333248,
+ "size": 2097152,
+ "path": "ownership.pk.part0199",
+ "sha256": "sha256:11627177ee92b3c58b34c066c16a60955aca877df3dc63295ed137d3e8dc0bbc",
+ "blake2b256": "blake2b256:0b12b2b5ed82ddc621e2be7720f64a133af965580d462c042c9b2fa8f4a1912b"
+ },
+ {
+ "index": 200,
+ "offset": 419430400,
+ "size": 2097152,
+ "path": "ownership.pk.part0200",
+ "sha256": "sha256:a220ed7b82008ef1f84f44218ff9c4c284243b9badc667868c0e9f769dd0b2ef",
+ "blake2b256": "blake2b256:d25ff586f873148aac1290080de103890319dc696fa66a48d242062333e48a83"
+ },
+ {
+ "index": 201,
+ "offset": 421527552,
+ "size": 2097152,
+ "path": "ownership.pk.part0201",
+ "sha256": "sha256:b9e278df1656da95be2225971db252e60541d19debb79e8eec214829ef79d9cf",
+ "blake2b256": "blake2b256:0afce66e492e2d8067dbdb3914e415aa4443b5d5cc058bff0a92500318a1f633"
+ },
+ {
+ "index": 202,
+ "offset": 423624704,
+ "size": 2097152,
+ "path": "ownership.pk.part0202",
+ "sha256": "sha256:ec3f21953c9901b1fe5f18fcfe1fdf2d7e797d2bf2bd1294afacdbd9f58f1491",
+ "blake2b256": "blake2b256:f15bb58c7b96ccd2414752715c263a5aacac581e0030366cab98f11539b1c530"
+ },
+ {
+ "index": 203,
+ "offset": 425721856,
+ "size": 2097152,
+ "path": "ownership.pk.part0203",
+ "sha256": "sha256:3ab4c01f98ee471756ae8399e567c8424d55f50efb85dfabf67f39393e4cb62c",
+ "blake2b256": "blake2b256:0b76c9961bd564e6e681e833f58d5f9e14d90191b60ae115314b2c1d1628eebd"
+ },
+ {
+ "index": 204,
+ "offset": 427819008,
+ "size": 2097152,
+ "path": "ownership.pk.part0204",
+ "sha256": "sha256:8059c1f8c4200b19439cc1aab354dbed70350c087511b013865586255a504459",
+ "blake2b256": "blake2b256:7ef42b24caa64e70b1bb6358629d6b217a7e7086159ee71e10c5aa2a3d511e5f"
+ },
+ {
+ "index": 205,
+ "offset": 429916160,
+ "size": 2097152,
+ "path": "ownership.pk.part0205",
+ "sha256": "sha256:e72db9a51fc4792abdc6bf10156c1219c3c2844bc32f393cbc69125635f738f6",
+ "blake2b256": "blake2b256:009b8d1b4d1964490b65df704f62680caea3ded3aedb93118788de6d3c83c30f"
+ },
+ {
+ "index": 206,
+ "offset": 432013312,
+ "size": 2097152,
+ "path": "ownership.pk.part0206",
+ "sha256": "sha256:6f28ee02b886dca3db66b474ce5b9932a75b6f63dcfce70788e30359633adfde",
+ "blake2b256": "blake2b256:66057ff352cb9025a4fd18a7768cd02d020203db804497d6aca39c76f9548134"
+ },
+ {
+ "index": 207,
+ "offset": 434110464,
+ "size": 2097152,
+ "path": "ownership.pk.part0207",
+ "sha256": "sha256:425207902d2da38ba618c5a3f85ba073a66960182778ba270316893d30a774e6",
+ "blake2b256": "blake2b256:2129017dc295b3de2148bbfffb81d6f32ff03735e2944534f3aca8ea1412e0bf"
+ },
+ {
+ "index": 208,
+ "offset": 436207616,
+ "size": 2097152,
+ "path": "ownership.pk.part0208",
+ "sha256": "sha256:ce2d837497240b88548eb34054d9f3d3fc3d3bf4a7c0b069704b23c37506ba23",
+ "blake2b256": "blake2b256:126609876c9b9a093bd6fc5904b066f0dda5a487bd9bad6c2e1dea3f7e050c2b"
+ },
+ {
+ "index": 209,
+ "offset": 438304768,
+ "size": 2097152,
+ "path": "ownership.pk.part0209",
+ "sha256": "sha256:8dcf887c1ac92f83c5a5316a5fb69798db35b7f3e3c5f9c1159f1b9b31717c34",
+ "blake2b256": "blake2b256:3f960e82cd72c125aa1a7ff9b733fd1a86a6e87014bf63d2c40a3bc921a36c60"
+ },
+ {
+ "index": 210,
+ "offset": 440401920,
+ "size": 2097152,
+ "path": "ownership.pk.part0210",
+ "sha256": "sha256:6dc4b62357a4eda55c9f0888be0adebc48afc30d704523dd6fcb5504929fd4a6",
+ "blake2b256": "blake2b256:1911d810c16f94a3ccf2f0b98568ee4309b75976c979d0b5297cf779c5484b23"
+ },
+ {
+ "index": 211,
+ "offset": 442499072,
+ "size": 2097152,
+ "path": "ownership.pk.part0211",
+ "sha256": "sha256:d3b32ade39b36bc9b044b23d89865ff438af3c423391c2edb979ada719f3c38a",
+ "blake2b256": "blake2b256:418668eb48100ccaab6a335b987f3bbfd5fef4dbe49a6cafff55c9c05652ea4f"
+ },
+ {
+ "index": 212,
+ "offset": 444596224,
+ "size": 2097152,
+ "path": "ownership.pk.part0212",
+ "sha256": "sha256:42b4a44b1764a99b422f3900bc4cc0ac65d282974658e6c626ff0add2b4fa15a",
+ "blake2b256": "blake2b256:5a08353ef2c570fb9acea283b9905d74319895dee7f48ea6aff9f9b13d7a7b9a"
+ },
+ {
+ "index": 213,
+ "offset": 446693376,
+ "size": 2097152,
+ "path": "ownership.pk.part0213",
+ "sha256": "sha256:644f8f5264b95022d121348e987918e1509d3b205117b68d266f2d45883747cd",
+ "blake2b256": "blake2b256:62376a5e450f88b6499dc0ef4165c014b46da3298dc53c447a2e60b525f4ceb0"
+ },
+ {
+ "index": 214,
+ "offset": 448790528,
+ "size": 2097152,
+ "path": "ownership.pk.part0214",
+ "sha256": "sha256:b2447e7eacf477636bead030d864731d95a571877fb933c4651287963a0e42b5",
+ "blake2b256": "blake2b256:fd5e42134081ee0ebea30238284560eb6e22795243b5a3b16ff83aa2df0c6b14"
+ },
+ {
+ "index": 215,
+ "offset": 450887680,
+ "size": 2097152,
+ "path": "ownership.pk.part0215",
+ "sha256": "sha256:80611bba5b392691a387e719251eb646948c0b96167988ba3711757163ea18e6",
+ "blake2b256": "blake2b256:72ad466d7b418ba68e0616c118953b10209fa88913809dfc32fea04aedb63511"
+ },
+ {
+ "index": 216,
+ "offset": 452984832,
+ "size": 2097152,
+ "path": "ownership.pk.part0216",
+ "sha256": "sha256:7d5279c0debcacc2340af96994e5e6cbdb57aa85d5c928eab649d7c15dbfb5d6",
+ "blake2b256": "blake2b256:b7c5048f1af659826956cf05be7263321f79d1583f3aa40dc84e785a7e909863"
+ },
+ {
+ "index": 217,
+ "offset": 455081984,
+ "size": 2097152,
+ "path": "ownership.pk.part0217",
+ "sha256": "sha256:1930a35683927c9b53b338ae2fc455f23005c6f01562f56aae247cbb1c2427fd",
+ "blake2b256": "blake2b256:9d5354a8248a076228a7401028a47d82d13cfc70dc7e7ce5f962aefc28e7dcd6"
+ },
+ {
+ "index": 218,
+ "offset": 457179136,
+ "size": 2097152,
+ "path": "ownership.pk.part0218",
+ "sha256": "sha256:39747a17340e4364bd579d6aaaf1f2a6e7d0246a565d983366ab0af80cc4b3e5",
+ "blake2b256": "blake2b256:3ba5311e2f76d59b2633a71e7dd5201ca03b9cc0480dc5ef03cde85e33362c05"
+ },
+ {
+ "index": 219,
+ "offset": 459276288,
+ "size": 2097152,
+ "path": "ownership.pk.part0219",
+ "sha256": "sha256:f6b31c334112cd515df1460e286f05fe09fdab76151838618a0b1f75584dd129",
+ "blake2b256": "blake2b256:9aa2cd0e2c19412310367a5eab65320923457d23ca03c8d5c69a7f29cb2b0118"
+ },
+ {
+ "index": 220,
+ "offset": 461373440,
+ "size": 2097152,
+ "path": "ownership.pk.part0220",
+ "sha256": "sha256:3ed19945f56d8226f36affcd1b450add60cef103cbdd4f8fc2fd65a9de3d9649",
+ "blake2b256": "blake2b256:84512c991e16b4c2b59ee985cf66894b0677bac908ce88263279dee6bd30dbd9"
+ },
+ {
+ "index": 221,
+ "offset": 463470592,
+ "size": 2097152,
+ "path": "ownership.pk.part0221",
+ "sha256": "sha256:fd35a963912a3a81ae3a280fce7793f6be6c63c8d138067837f335fea1a9b8be",
+ "blake2b256": "blake2b256:78622fe323820ae5d9f2a5f3fc655ef85a0fc022a7847884ae0aa9cce6b72555"
+ },
+ {
+ "index": 222,
+ "offset": 465567744,
+ "size": 2097152,
+ "path": "ownership.pk.part0222",
+ "sha256": "sha256:0446335940ba3147d1e95d0d889dcf694c21c7944501da240eb63b5e4e57ad51",
+ "blake2b256": "blake2b256:f9b559e26658833deb1536b477dffa89710ce0d3c2a2d161802170e2ce64684d"
+ },
+ {
+ "index": 223,
+ "offset": 467664896,
+ "size": 2097152,
+ "path": "ownership.pk.part0223",
+ "sha256": "sha256:af7ac87a9f358cc1e4cfaf0919cff99565a7fc95b4cbf585e1af0b4857ca0ca3",
+ "blake2b256": "blake2b256:14075730f0fea1ba28ff751d7073464c9c18193e936770e34b2cc368a11fe191"
+ },
+ {
+ "index": 224,
+ "offset": 469762048,
+ "size": 2097152,
+ "path": "ownership.pk.part0224",
+ "sha256": "sha256:1ba117e1edaef21988b5228a33e5aeb5be662330842c5a71a2ad42562388902b",
+ "blake2b256": "blake2b256:c01e8f220cc3129bd6edc292f59b044bab60fe100d66953d934ab3e90b5abfeb"
+ },
+ {
+ "index": 225,
+ "offset": 471859200,
+ "size": 2097152,
+ "path": "ownership.pk.part0225",
+ "sha256": "sha256:5e93ce92118f6f84842cbe28a10b1077010f9c34e70f78e975c0b1f6c5dd2a0d",
+ "blake2b256": "blake2b256:8125697728a72491f30dd2f691bc45ec5412737371b728e1d379451ee490b590"
+ },
+ {
+ "index": 226,
+ "offset": 473956352,
+ "size": 2097152,
+ "path": "ownership.pk.part0226",
+ "sha256": "sha256:6cc31b3af9bc213a41e8a84e7ed9666305962312925ee99198ab9a7afba1fa87",
+ "blake2b256": "blake2b256:b86a74f4278fdfe561ed95edbff3f46913778d44a6f8e3c10cd44d67d7f35aaf"
+ },
+ {
+ "index": 227,
+ "offset": 476053504,
+ "size": 2097152,
+ "path": "ownership.pk.part0227",
+ "sha256": "sha256:107169c4bb1fd4da1b96c8a0da9bb5c97d9a4e13f83ed947c47a8f4ecead38db",
+ "blake2b256": "blake2b256:da15d77405c8d61860b0ce8e0c98638aac2c71b6bb2d40fbe360f856a83a0e3b"
+ },
+ {
+ "index": 228,
+ "offset": 478150656,
+ "size": 2097152,
+ "path": "ownership.pk.part0228",
+ "sha256": "sha256:7075f7f3dc344320c5ec62253f348426e28f4a977f654e89a16e48cca5a9d66a",
+ "blake2b256": "blake2b256:db49074067767d4b3c4ee6b2ce21f373109bf3b6ebe0e5082de2b7046b61f865"
+ },
+ {
+ "index": 229,
+ "offset": 480247808,
+ "size": 2097152,
+ "path": "ownership.pk.part0229",
+ "sha256": "sha256:953d8aeb64dd3ec5ea78220d46a956f246d95202cc6f222d06dcbd62821bafd3",
+ "blake2b256": "blake2b256:42ce9ac5cd8087b3336dba24edbf746869feae936f0fa27e00de06d3018a460d"
+ },
+ {
+ "index": 230,
+ "offset": 482344960,
+ "size": 2097152,
+ "path": "ownership.pk.part0230",
+ "sha256": "sha256:5ec3d6448985e44d39e8e3de91c717ef2218ab5c5136b253dce2c95489e2c3a3",
+ "blake2b256": "blake2b256:5748eacae0e8bdc2d129a4016ab651607de4fafa758c46ed6fc21cf4f5f6f5e3"
+ },
+ {
+ "index": 231,
+ "offset": 484442112,
+ "size": 2097152,
+ "path": "ownership.pk.part0231",
+ "sha256": "sha256:ed8bdddedd21555b2ff9d1366192652dd42c6a1dd129d38fd00e0328c62be25c",
+ "blake2b256": "blake2b256:157373b1c229d318fbb6e946466eeebe6b5df7a8ae39053603b1aca267f0e914"
+ },
+ {
+ "index": 232,
+ "offset": 486539264,
+ "size": 2097152,
+ "path": "ownership.pk.part0232",
+ "sha256": "sha256:58f219d3569a4fb439d8453dd52c3c185ab32d557b86064a7e0058035ad4bfc2",
+ "blake2b256": "blake2b256:b0b6908c90857684731a4a17b81bdcebabb11fc7e4bfc2c09774459f8a6d571f"
+ },
+ {
+ "index": 233,
+ "offset": 488636416,
+ "size": 2097152,
+ "path": "ownership.pk.part0233",
+ "sha256": "sha256:b34450d636b3ffacbf13de5e16a05f993e4e37c11f76db3d2e4b838fa0ee4c52",
+ "blake2b256": "blake2b256:ba709be33971b463727d6ecca48dd490f8e28dd634e0bbb74130d55d45ec770c"
+ },
+ {
+ "index": 234,
+ "offset": 490733568,
+ "size": 2097152,
+ "path": "ownership.pk.part0234",
+ "sha256": "sha256:d748de041c494ab7f0858b710b0e853a029366708cbad89833a0fa078b2120e4",
+ "blake2b256": "blake2b256:657008b036f35cb58bed2f82fbc39ef97320f313b03751118f1555dc8299f6f5"
+ },
+ {
+ "index": 235,
+ "offset": 492830720,
+ "size": 2097152,
+ "path": "ownership.pk.part0235",
+ "sha256": "sha256:2fa82c40801e3d4427f706f8ebac3b36c430343321095d5d1ca5a6087aab3717",
+ "blake2b256": "blake2b256:134513fa057840d28251557e4f6701d4974c4c65957a299b76571240a0835d4b"
+ },
+ {
+ "index": 236,
+ "offset": 494927872,
+ "size": 2097152,
+ "path": "ownership.pk.part0236",
+ "sha256": "sha256:8302270209262dfdaf8383c6d6b8fb8225f1da1a256b8fb9442e1559ed859275",
+ "blake2b256": "blake2b256:e62560667181f7c9a9d0601c594d4dc27bad0bec71aaf5f8f00669cd3ef20b22"
+ },
+ {
+ "index": 237,
+ "offset": 497025024,
+ "size": 2097152,
+ "path": "ownership.pk.part0237",
+ "sha256": "sha256:ea3aa09a9368d8fffc2fd06f099b36269de9d329cde0f47e9c76360f47e90f7c",
+ "blake2b256": "blake2b256:fffb04d40640d574ed33cc4f8b0c44b23af0c30fbda119da1bdef7a95916eab2"
+ },
+ {
+ "index": 238,
+ "offset": 499122176,
+ "size": 2097152,
+ "path": "ownership.pk.part0238",
+ "sha256": "sha256:cf82f6a3895359ed163fde16548aad3424dcbc007cf68d269425678ce2af7eb0",
+ "blake2b256": "blake2b256:5007d9b66432ffc3a067d7baf35f3dd48ddaae82a0181f9c4ad083ba946242c3"
+ },
+ {
+ "index": 239,
+ "offset": 501219328,
+ "size": 2097152,
+ "path": "ownership.pk.part0239",
+ "sha256": "sha256:f85f07291898221febbfb783e39303cace5f64e904782feaca7985276938a35b",
+ "blake2b256": "blake2b256:3c4f7c5c7fac3f6d659cb72437aae6e39a45f5440fb871464560539437906dd9"
+ },
+ {
+ "index": 240,
+ "offset": 503316480,
+ "size": 2097152,
+ "path": "ownership.pk.part0240",
+ "sha256": "sha256:8cd003fa5c7f6100fd02450faa2713ffa1235baff860bb9fe969f92c822b9495",
+ "blake2b256": "blake2b256:43a2eaae0c348af0f8c5853d3e22a3df01413a97d32d326333fd59b09f126b9c"
+ },
+ {
+ "index": 241,
+ "offset": 505413632,
+ "size": 2097152,
+ "path": "ownership.pk.part0241",
+ "sha256": "sha256:6999960301e8d903c6bfc97d99a35e088a9f62eda71b7c8a204141b8d34b83b6",
+ "blake2b256": "blake2b256:b0f72d054ddcb04d5f12d150391690b08751ff74ebbb6c00d8e3a9db1f7c1a67"
+ },
+ {
+ "index": 242,
+ "offset": 507510784,
+ "size": 2097152,
+ "path": "ownership.pk.part0242",
+ "sha256": "sha256:b01ebc2d608dbce12ea8aa52ec58d63d8d524ccc6102fcd2070cd474427883ea",
+ "blake2b256": "blake2b256:b4c54fa5c248d253c9fff5b0b793eb975cb322e60a98b2727fbea7ec35b6c900"
+ },
+ {
+ "index": 243,
+ "offset": 509607936,
+ "size": 2097152,
+ "path": "ownership.pk.part0243",
+ "sha256": "sha256:b58620e4635bd8011af6a44c7d2b480dd01f82b68b633a0280b2ef02e31a00fc",
+ "blake2b256": "blake2b256:73d3e4d911cc2ecf310d1ed3b70d1094f103b3cf48cfbbdbcd92f135b6db5178"
+ },
+ {
+ "index": 244,
+ "offset": 511705088,
+ "size": 2097152,
+ "path": "ownership.pk.part0244",
+ "sha256": "sha256:48ce2206ff6bfe242dc1aaafd30859555a1929481dd6ed88c804440a938b0f33",
+ "blake2b256": "blake2b256:7e70edbe4e5f8fd90df83f165656311d6c2a113198784aac0d30f7ba3bc0ba4c"
+ },
+ {
+ "index": 245,
+ "offset": 513802240,
+ "size": 2097152,
+ "path": "ownership.pk.part0245",
+ "sha256": "sha256:1169a514e6e653586eb83bec8a7df35f797d119b30c634017864d788c4c622c7",
+ "blake2b256": "blake2b256:ac8711f707fdbc4bf73fde453e7c44a0d1f7fc3b1c8eccc77b7fe657134e5b86"
+ },
+ {
+ "index": 246,
+ "offset": 515899392,
+ "size": 2097152,
+ "path": "ownership.pk.part0246",
+ "sha256": "sha256:759c2d7f9b7682dd8aacde94183fc832a9385e9a8e7c1445df66f66f057eb8c7",
+ "blake2b256": "blake2b256:0394a7d3df1bbb51a0b5cb88cda0f6302748f6bbeffdbc6cf46d5681591f3118"
+ },
+ {
+ "index": 247,
+ "offset": 517996544,
+ "size": 2097152,
+ "path": "ownership.pk.part0247",
+ "sha256": "sha256:ef6bad871230f8d7e263300f856158e867154525b6fc2aff3980d7cb86ce151d",
+ "blake2b256": "blake2b256:48a52609575261b66cf1fe0c15ae74d99813d11bb76d554288b1626da19f0838"
+ },
+ {
+ "index": 248,
+ "offset": 520093696,
+ "size": 2097152,
+ "path": "ownership.pk.part0248",
+ "sha256": "sha256:72634e0033af650cb08a8a8dbf6544c56166a7e425cf9e8e58bc4cd542bf4097",
+ "blake2b256": "blake2b256:96c9cfd5d45b5d69dbe182e8bbc5a19d6f09329cdb5b9e86ab01441c0ee844d8"
+ },
+ {
+ "index": 249,
+ "offset": 522190848,
+ "size": 2097152,
+ "path": "ownership.pk.part0249",
+ "sha256": "sha256:c55b431913c70f3f7b7f7c0fb45f2d7d551973c335c52f18ce9f668f15c387f3",
+ "blake2b256": "blake2b256:9477bbc2fad9341e90db6d57eccb88ddfc9ff997094dea055d83a9cdecf30a4a"
+ },
+ {
+ "index": 250,
+ "offset": 524288000,
+ "size": 2097152,
+ "path": "ownership.pk.part0250",
+ "sha256": "sha256:f0a9ea0e7c346d94d4e299b6da051ae27aa005200e9c5b46cb6435f879633545",
+ "blake2b256": "blake2b256:98dac3c81d17b91ec7c8a777811855ceddba5dedc26392db082bf67cc034ca99"
+ },
+ {
+ "index": 251,
+ "offset": 526385152,
+ "size": 2097152,
+ "path": "ownership.pk.part0251",
+ "sha256": "sha256:197f5135c82d068b0da9aa5231db9404708512387c93e533c77972c4194c9986",
+ "blake2b256": "blake2b256:bd331103226a2f64abedcb3e2566ee3cd00ed572479a10cac5a8a541911c5037"
+ },
+ {
+ "index": 252,
+ "offset": 528482304,
+ "size": 2097152,
+ "path": "ownership.pk.part0252",
+ "sha256": "sha256:1333d987113f905186041899b9eeb28a056e77cc32283eea78b8a9aa5be4fc4e",
+ "blake2b256": "blake2b256:0cb1d715e3ffb584d54c150a07289fd7709d7eed29e3491c7197576fa952159e"
+ },
+ {
+ "index": 253,
+ "offset": 530579456,
+ "size": 2097152,
+ "path": "ownership.pk.part0253",
+ "sha256": "sha256:17ebb854fa2a1f853b6028eda87e280ec4fb5c12ce6e454a1606d2f4888113d5",
+ "blake2b256": "blake2b256:38f045f038158e9d46263fae9fa8d9543631c6f6c0dc92659aeba660e6d7238c"
+ },
+ {
+ "index": 254,
+ "offset": 532676608,
+ "size": 2097152,
+ "path": "ownership.pk.part0254",
+ "sha256": "sha256:0484d81d40be6d2815969bf372eb687f6b1c73ba862fc1a7e7e7ddeb4ae6dabc",
+ "blake2b256": "blake2b256:cc7efc77fcbcbd9613d7450b4ee646dc60d27dbe5d223e81020f5a3261e58bfc"
+ },
+ {
+ "index": 255,
+ "offset": 534773760,
+ "size": 2097152,
+ "path": "ownership.pk.part0255",
+ "sha256": "sha256:911fa972123a6c82895a23d354fe47b405aa559d990c4e1b050eaf2cdeeb759b",
+ "blake2b256": "blake2b256:c146ef702290d2f605f06db28a05ee0ca9062bd5818dedafe8d302dc7aa73400"
+ },
+ {
+ "index": 256,
+ "offset": 536870912,
+ "size": 2097152,
+ "path": "ownership.pk.part0256",
+ "sha256": "sha256:00e546213f2c68f33203e38d6e3f3e27ac0561fadf44cf78d0e7ac0622b434f7",
+ "blake2b256": "blake2b256:edffac35b0e6a60bef3855225946ae8370a3ee5b72c08d68d855eb0014a98208"
+ },
+ {
+ "index": 257,
+ "offset": 538968064,
+ "size": 2097152,
+ "path": "ownership.pk.part0257",
+ "sha256": "sha256:22407630eab1af668ad0e25558b7d8ee19aebefd42a7e18b2e8a171719637f2a",
+ "blake2b256": "blake2b256:bdd3a51965961c5c9e5ab134b768afc1e0ea1b806822d4b405f734e7fff60949"
+ },
+ {
+ "index": 258,
+ "offset": 541065216,
+ "size": 2097152,
+ "path": "ownership.pk.part0258",
+ "sha256": "sha256:17d33aa00913ffa8055486e18196c49a8e15ead07c54d9540a5ccd9cfbfdda13",
+ "blake2b256": "blake2b256:8b29a4a8d737c5e15a31e213f05e3005e818690d598b1ab9f855d05a53205fc5"
+ },
+ {
+ "index": 259,
+ "offset": 543162368,
+ "size": 2097152,
+ "path": "ownership.pk.part0259",
+ "sha256": "sha256:3696252249c5a5c827a8bed921277adf2ba32709d3d5f2a51311e4c7d4390838",
+ "blake2b256": "blake2b256:444377d7ffb1d4fa067fe354eb942767cc2e6ba123425992250794b604f15cfc"
+ },
+ {
+ "index": 260,
+ "offset": 545259520,
+ "size": 2097152,
+ "path": "ownership.pk.part0260",
+ "sha256": "sha256:b936647ac68292ccad08613c35ace699d6f9106bd289b2c7459444dad3e405e4",
+ "blake2b256": "blake2b256:ce92bcd40ff653d58abe4605ded7c68ad7dfeeff6f588488d50fb98225b74f3e"
+ },
+ {
+ "index": 261,
+ "offset": 547356672,
+ "size": 2097152,
+ "path": "ownership.pk.part0261",
+ "sha256": "sha256:50252bcb8dba8243b63f18616c56a8c6f3614048eba5ed039b72caeecad25f4a",
+ "blake2b256": "blake2b256:e04af74aa842e1ef75bc10b5ec62026d5b0f83bec217577538b41b88edd85fde"
+ },
+ {
+ "index": 262,
+ "offset": 549453824,
+ "size": 2097152,
+ "path": "ownership.pk.part0262",
+ "sha256": "sha256:65a4cc6b3b48c56ba78a0569f2c5c4153a9bd6d0ee68137445aa8c5a91fb2c0b",
+ "blake2b256": "blake2b256:4880ef602f496326f6c56cc216736f73e98411b1fe474867971f5e89756bafdd"
+ },
+ {
+ "index": 263,
+ "offset": 551550976,
+ "size": 2097152,
+ "path": "ownership.pk.part0263",
+ "sha256": "sha256:8e30bf407b5bbebf500f2408110eb9fbe4f60c64128522d8dbf90060713ce434",
+ "blake2b256": "blake2b256:3c58f2a01915ea07d22248e640e273551463209388aecec78b723760153816db"
+ },
+ {
+ "index": 264,
+ "offset": 553648128,
+ "size": 2097152,
+ "path": "ownership.pk.part0264",
+ "sha256": "sha256:05f960eea3271084453cf85ba9f55bca1080e1211699941a1b957f6a6a0eaf08",
+ "blake2b256": "blake2b256:367113ccb1b632ada71ddf2fa6faa7195ee28a2617446cdd143cb56d938b912b"
+ },
+ {
+ "index": 265,
+ "offset": 555745280,
+ "size": 2097152,
+ "path": "ownership.pk.part0265",
+ "sha256": "sha256:3df329284564e7b9e3692e6199c63cf8c34bd2a089cfbdef24156f009b023b38",
+ "blake2b256": "blake2b256:a3af4ff77a4c6b69a21cce4775d18fe93d8e2d0cb42209242f1ef14dbd729e09"
+ },
+ {
+ "index": 266,
+ "offset": 557842432,
+ "size": 2097152,
+ "path": "ownership.pk.part0266",
+ "sha256": "sha256:19acc6b7b5233ef7c2a447535b37e6cc5e1632953a09fc79175c85a110ecad57",
+ "blake2b256": "blake2b256:1d1fcf9babc41db2d4c072641b976817cb2864b12e6565ca7726aceee5829570"
+ },
+ {
+ "index": 267,
+ "offset": 559939584,
+ "size": 2097152,
+ "path": "ownership.pk.part0267",
+ "sha256": "sha256:97e1a908dbd4e5ffdcf3204a8cb58ca329800d4d9a1f2e3ae0fd33f54bbec8f9",
+ "blake2b256": "blake2b256:7c1692973c37a88056637a3843993bb46939f8ab2082334f3b569512b1313399"
+ },
+ {
+ "index": 268,
+ "offset": 562036736,
+ "size": 2097152,
+ "path": "ownership.pk.part0268",
+ "sha256": "sha256:4885fe52aa6e03a1434da7ff6adcf8cbf4c63583ce6b164ec0772b74fa05efaa",
+ "blake2b256": "blake2b256:ad6b14348154d37291ea7a4b9d3a7a401f6ff6794333ad87242348f58845e5df"
+ },
+ {
+ "index": 269,
+ "offset": 564133888,
+ "size": 2097152,
+ "path": "ownership.pk.part0269",
+ "sha256": "sha256:00b100d8546d63c40cb937c3ea44541dcf956706e5680989c3bf776c462337b7",
+ "blake2b256": "blake2b256:e5b8b8836e71f8aab5aaa1126c1c922a5f00567e3d1e61c47d9765540df421bc"
+ },
+ {
+ "index": 270,
+ "offset": 566231040,
+ "size": 2097152,
+ "path": "ownership.pk.part0270",
+ "sha256": "sha256:c2a67bc5dc990480d4fd661834374726ed3aba51cfc23c5599878441af0eea51",
+ "blake2b256": "blake2b256:e88373bb533018587da917c45896122f6f60f0640837abda7a3c792d6dbcc35a"
+ },
+ {
+ "index": 271,
+ "offset": 568328192,
+ "size": 2097152,
+ "path": "ownership.pk.part0271",
+ "sha256": "sha256:bae0b7fcd0211557ad2f74b8693da31a074d4c16e4ec3fd0e0d2041953895f50",
+ "blake2b256": "blake2b256:820696e04373d12c55ba46de0518d55a5761a9ab28837310dfede108c0b7261a"
+ },
+ {
+ "index": 272,
+ "offset": 570425344,
+ "size": 2097152,
+ "path": "ownership.pk.part0272",
+ "sha256": "sha256:605f6312612b89ebffa57abf18553a4e4ef5330c3837c389049f503f0219eaf7",
+ "blake2b256": "blake2b256:20543d25c0f0ec0be6e3ebfe616cc41c0032b9dd3ab72dbaf68b51995f3c1975"
+ },
+ {
+ "index": 273,
+ "offset": 572522496,
+ "size": 2097152,
+ "path": "ownership.pk.part0273",
+ "sha256": "sha256:bd168ece304f0a9ea9421feb9c6be32d6a6f9569314933c4ad8ec07b384d2374",
+ "blake2b256": "blake2b256:23bc85777d773a0434be9fdd79118a3d2526f0cf1612c1b125495c7fd5a92862"
+ },
+ {
+ "index": 274,
+ "offset": 574619648,
+ "size": 2097152,
+ "path": "ownership.pk.part0274",
+ "sha256": "sha256:7bc2edab9e6362b1393130b6957ba505bd1ca102eb54000a8a6b228184bd4f74",
+ "blake2b256": "blake2b256:9ab710ff99ed881495201ea159af2b796a87a7eb47154279d1061a9db446dc00"
+ },
+ {
+ "index": 275,
+ "offset": 576716800,
+ "size": 2097152,
+ "path": "ownership.pk.part0275",
+ "sha256": "sha256:0ed2a103e7a3ad0aba2f3aff6e00332eaed93ced883724b154fd058d7ad3be65",
+ "blake2b256": "blake2b256:3424affa5ca787e9baef0076bad26b09f23cc142090b4eafb0f16392eb9f2a04"
+ },
+ {
+ "index": 276,
+ "offset": 578813952,
+ "size": 2097152,
+ "path": "ownership.pk.part0276",
+ "sha256": "sha256:94cbc87d4c417c4bb1ded0e11a13b4b2df6f937b56a190f39c0bd52aebd15ccf",
+ "blake2b256": "blake2b256:e42643247c535bdecef018bfb63143fc061429a48e8fe388971dcbbb1373f5f0"
+ },
+ {
+ "index": 277,
+ "offset": 580911104,
+ "size": 2097152,
+ "path": "ownership.pk.part0277",
+ "sha256": "sha256:80e678b00ed26f77013c7296a71247a7af39cc2e528a885f1212d1d52f015ba0",
+ "blake2b256": "blake2b256:ebed73b7d47c0511b3a31104e1b60a67924d777b153eb76c462aee3e6382d7e9"
+ },
+ {
+ "index": 278,
+ "offset": 583008256,
+ "size": 2097152,
+ "path": "ownership.pk.part0278",
+ "sha256": "sha256:1d3f9fc58de467db96a3597f405b5df260b1951d36845809a6646f7226c93a62",
+ "blake2b256": "blake2b256:447a7dbf85910702e4b8933c2868ecbad9bf827747403064cd18a74429f82088"
+ },
+ {
+ "index": 279,
+ "offset": 585105408,
+ "size": 2097152,
+ "path": "ownership.pk.part0279",
+ "sha256": "sha256:26c5482d61ddc2c7fefcc28ba3b32cf8ca0d447f38f9fc3174111c5e9daee24a",
+ "blake2b256": "blake2b256:3ab0ebffdf96df91f45e6c02839f67bc5f575edc6b0f48e60440a7d5f585c593"
+ },
+ {
+ "index": 280,
+ "offset": 587202560,
+ "size": 2097152,
+ "path": "ownership.pk.part0280",
+ "sha256": "sha256:f52c428762fe80d076d5d20e9229582bfcff751382aa3b27eb366fc1a9033fd9",
+ "blake2b256": "blake2b256:f9627f58207c35f9a28bf52633067adb38097a25da50a4f74679e1b5edb1334b"
+ },
+ {
+ "index": 281,
+ "offset": 589299712,
+ "size": 2097152,
+ "path": "ownership.pk.part0281",
+ "sha256": "sha256:972ca5acaf6d9c0bc87e8e953888307ba2f11bf243c2f172961108ad3f5391f4",
+ "blake2b256": "blake2b256:2e25ccc626b857e4473e089e9b6097b84247ebd06400691b35cc48b56343197d"
+ },
+ {
+ "index": 282,
+ "offset": 591396864,
+ "size": 2097152,
+ "path": "ownership.pk.part0282",
+ "sha256": "sha256:650ca83181eb374c3f4353ab026a43b6ed96dadcba17d6494d86b035e90b967f",
+ "blake2b256": "blake2b256:e43f3334efd23d6a91fe9f4eab14362c869396e93a94eb3bf1c293a3392de686"
+ },
+ {
+ "index": 283,
+ "offset": 593494016,
+ "size": 2097152,
+ "path": "ownership.pk.part0283",
+ "sha256": "sha256:3ebf47270137389351570134ee019842b64c271f1f8f4aab0cfc425b8b66f2a0",
+ "blake2b256": "blake2b256:79d2820c8a75a24a30872e1317b3b5247dc0047969729cc12bb97d0f1226e08a"
+ },
+ {
+ "index": 284,
+ "offset": 595591168,
+ "size": 2097152,
+ "path": "ownership.pk.part0284",
+ "sha256": "sha256:67bc498501d73baad868649d489abb4e0d0f149081548cbebd2327a9d44ca5b1",
+ "blake2b256": "blake2b256:298a3a8610d1c98dee4d57b0fb0cb419467f5d83648097f668ed34ddf804f495"
+ },
+ {
+ "index": 285,
+ "offset": 597688320,
+ "size": 2097152,
+ "path": "ownership.pk.part0285",
+ "sha256": "sha256:e838b6f0b18276af20387eb591d31467cf1dccddcfbbb52539707983e3ca866f",
+ "blake2b256": "blake2b256:9433204cc2171ad03fc7b6012b329ecb9e64dc83f69ea0558c3cdbc776a9258c"
+ },
+ {
+ "index": 286,
+ "offset": 599785472,
+ "size": 2097152,
+ "path": "ownership.pk.part0286",
+ "sha256": "sha256:39cf73ae10253c932e92baf729c215729eb912c044fe435f1de80cac68129b97",
+ "blake2b256": "blake2b256:e1016dfbf6fe4dba329e3dc1566fe3643755bddc3b3a0328840efe6508860e97"
+ },
+ {
+ "index": 287,
+ "offset": 601882624,
+ "size": 2097152,
+ "path": "ownership.pk.part0287",
+ "sha256": "sha256:d11fb2a2cf01511b34dde3605e311c0b06dafd9d4f889a42e9e463c5a263ff18",
+ "blake2b256": "blake2b256:3175e086177b1b8f84b0c77ab87ded74e98d01fb38c024697c2f057d11c6ea72"
+ },
+ {
+ "index": 288,
+ "offset": 603979776,
+ "size": 2097152,
+ "path": "ownership.pk.part0288",
+ "sha256": "sha256:1787dbf4fbed5d1642f0be2701c98385274f769c5efe2b9f939911878d41e6f5",
+ "blake2b256": "blake2b256:eb7ce0843d5de73d60052349a12e9d03171c8fe7e9d7242552c7890c5a6f73a6"
+ },
+ {
+ "index": 289,
+ "offset": 606076928,
+ "size": 2097152,
+ "path": "ownership.pk.part0289",
+ "sha256": "sha256:e151f7536cf9c957ec9f80a210cca455db689edc8cc60cd6b65db19769eaf3dd",
+ "blake2b256": "blake2b256:c958c694183012cdab863fa977c002b2259484b218a32302484c9bd33b3064ec"
+ },
+ {
+ "index": 290,
+ "offset": 608174080,
+ "size": 2097152,
+ "path": "ownership.pk.part0290",
+ "sha256": "sha256:6a005ebebc3a74786b0c6dd9774a893525219a0b330d66420cac09230c1441b6",
+ "blake2b256": "blake2b256:8518008e48ddb723c5f9f7512e5c559a54c749bc16f7c825643af48faff24630"
+ },
+ {
+ "index": 291,
+ "offset": 610271232,
+ "size": 2097152,
+ "path": "ownership.pk.part0291",
+ "sha256": "sha256:31f04ed8a4fa58b54d28ebf9179211e17287ddeeb06d37405467f981bcb01f06",
+ "blake2b256": "blake2b256:ec222ee29691def2bd3e12bd2a8797d2126cb6806f5d4ced3d3406e55bd8b27c"
+ },
+ {
+ "index": 292,
+ "offset": 612368384,
+ "size": 2097152,
+ "path": "ownership.pk.part0292",
+ "sha256": "sha256:bfcbb3b6cb80f21ce5be9527fd82f7bee58456adc9d4b3bf421d72c324400e03",
+ "blake2b256": "blake2b256:f347763ca9e9d5fada65b75f81e354592a6706103a9ec186d65540e36429188a"
+ },
+ {
+ "index": 293,
+ "offset": 614465536,
+ "size": 2097152,
+ "path": "ownership.pk.part0293",
+ "sha256": "sha256:125cf04a3f3dfc8d0501c639f98d2b90c3fca58da0b22d63a83d93ba1e98337d",
+ "blake2b256": "blake2b256:172f37a72f1aa1a51b7e09522a26639e9155030e2029a4d33740a82efaeba158"
+ },
+ {
+ "index": 294,
+ "offset": 616562688,
+ "size": 2097152,
+ "path": "ownership.pk.part0294",
+ "sha256": "sha256:7661e09be2a87b0ceb61bada733ee1513f07c54bf0d33241e9dad1f7f6c6cc75",
+ "blake2b256": "blake2b256:850848342e8413ef702f38f461fbc6fb3fb6e71c3ebdafe7fa2b74775d6dde66"
+ },
+ {
+ "index": 295,
+ "offset": 618659840,
+ "size": 2097152,
+ "path": "ownership.pk.part0295",
+ "sha256": "sha256:e7d6006fd180e89e4588839e9b3f3b8c35616582867e32733c4f3f76ddc8959c",
+ "blake2b256": "blake2b256:44bdd56386cdeb85c7358bd7641e5f3d16c77e0eaa3932ce666bc4aebc19ae1f"
+ },
+ {
+ "index": 296,
+ "offset": 620756992,
+ "size": 2097152,
+ "path": "ownership.pk.part0296",
+ "sha256": "sha256:1e01b4c34c2eb23b85dd01138a6366e2608d4db156c1a03abd5290351e55549b",
+ "blake2b256": "blake2b256:4a708bb509fb1f56ea5a475110c1f9b2876971202be148a368296bcb8d82e487"
+ },
+ {
+ "index": 297,
+ "offset": 622854144,
+ "size": 2097152,
+ "path": "ownership.pk.part0297",
+ "sha256": "sha256:1da9d45aa39efb330fd53e110bc46bcffadefbf69eedeb13754089cd6ace77ef",
+ "blake2b256": "blake2b256:6806fb96ce9060b51d83cc6ac78265222514d322b872c0abf1195b181ffda5f8"
+ },
+ {
+ "index": 298,
+ "offset": 624951296,
+ "size": 2097152,
+ "path": "ownership.pk.part0298",
+ "sha256": "sha256:3e165c9227e63508ce6117e0e5f093c165d10c94f846b62839deec7c9dd5eac9",
+ "blake2b256": "blake2b256:a961f43d949dd7dec597c395e61cb612d47c7817607813b85ddeb9eb5b309fe0"
+ },
+ {
+ "index": 299,
+ "offset": 627048448,
+ "size": 2097152,
+ "path": "ownership.pk.part0299",
+ "sha256": "sha256:740f258c5de2555c91a168769a7d867465840f41e5f6a63529d4e20661f95367",
+ "blake2b256": "blake2b256:2a2a17b6726f430902f5d5b3d2c97d16705cae44035d2aba65d2d57ebdf0536e"
+ },
+ {
+ "index": 300,
+ "offset": 629145600,
+ "size": 2097152,
+ "path": "ownership.pk.part0300",
+ "sha256": "sha256:033dfc80257eee64afcf329b57d5b9c5dcc9bd1ab0ccc4ab92c5f65cfb6e3c06",
+ "blake2b256": "blake2b256:8d6e2b16dd7988ab06820d487b8801867b7b58d17d074bc1bbd5911458fc4c75"
+ },
+ {
+ "index": 301,
+ "offset": 631242752,
+ "size": 2097152,
+ "path": "ownership.pk.part0301",
+ "sha256": "sha256:c1ca716807a4eb729c03215df32cb33484f761f126b5cef3d02ee690b9c7f692",
+ "blake2b256": "blake2b256:e061d5eb2993d69b08af69229fe09b06be1eed9830f9a2e4f467555648744151"
+ },
+ {
+ "index": 302,
+ "offset": 633339904,
+ "size": 2097152,
+ "path": "ownership.pk.part0302",
+ "sha256": "sha256:f916910fdf3bd399f21472b1db04f12065b89f4762d4462376af0ca46838c69b",
+ "blake2b256": "blake2b256:3237c0514c1800ece867fae3ff37f63a84f0983726c9b92644c68b721b609ac0"
+ },
+ {
+ "index": 303,
+ "offset": 635437056,
+ "size": 2097152,
+ "path": "ownership.pk.part0303",
+ "sha256": "sha256:ac8da650f390c946a452dd03b104db7ea34330e78a840db55be531befe9c9bad",
+ "blake2b256": "blake2b256:431bef9ae90a2b315d18cf87d49ad8cef5742597f292a301b0601d936974fd93"
+ },
+ {
+ "index": 304,
+ "offset": 637534208,
+ "size": 2097152,
+ "path": "ownership.pk.part0304",
+ "sha256": "sha256:7a9c3a3e0c908ac0c67386d15aba56cbed460006a4716c5e4c7cfa058dd9842b",
+ "blake2b256": "blake2b256:9d494702bfaa3bd70056fc70a78d17a159768f0db79d46809b3716bba818aa24"
+ },
+ {
+ "index": 305,
+ "offset": 639631360,
+ "size": 2097152,
+ "path": "ownership.pk.part0305",
+ "sha256": "sha256:3a104693f898107091baabe2ec48df99bfbd3abe883b550026ae1f80b2e8fdbe",
+ "blake2b256": "blake2b256:4c87478470a0fd1587a642ebacdb84e836bfaff8fb7f45d48c799cabcbdaeff7"
+ },
+ {
+ "index": 306,
+ "offset": 641728512,
+ "size": 2097152,
+ "path": "ownership.pk.part0306",
+ "sha256": "sha256:9a7498c18b23e8d0e158de7023d3bccb4c71ad96ec0851c610c376b39c67711b",
+ "blake2b256": "blake2b256:30412af7cfc31a206044b21e5fb9e912e49bc018b0931973ed775cdd55a16e07"
+ },
+ {
+ "index": 307,
+ "offset": 643825664,
+ "size": 2097152,
+ "path": "ownership.pk.part0307",
+ "sha256": "sha256:8bcb966dd6d1e0daff5412777857ede5fd794ebd957222d9935ccd0df7cc55ec",
+ "blake2b256": "blake2b256:d2a733971b7fb224786cffc9f675fb93c78ef733ecb88fddeaba368e9a3ec88a"
+ },
+ {
+ "index": 308,
+ "offset": 645922816,
+ "size": 2097152,
+ "path": "ownership.pk.part0308",
+ "sha256": "sha256:bfb5b812fa12429750f0fd94021f3fad0d96bb476a6dde66d155ed10dcaa81a5",
+ "blake2b256": "blake2b256:5049162cc6869612c1a8a39c2325f9f14dedb54cdee9e147a90c7945f927f663"
+ },
+ {
+ "index": 309,
+ "offset": 648019968,
+ "size": 2097152,
+ "path": "ownership.pk.part0309",
+ "sha256": "sha256:defdb6959ec53f47ef286b8489e7bb4ca13865a57609b87ab92f9433d7d6f446",
+ "blake2b256": "blake2b256:6654a5b087961c166736700a64562e5a68677d7d440c8e16399c362859020380"
+ },
+ {
+ "index": 310,
+ "offset": 650117120,
+ "size": 2097152,
+ "path": "ownership.pk.part0310",
+ "sha256": "sha256:15859305de0caf15e9515c5290dd984d3f9343ed11b128b945733f1921c8a217",
+ "blake2b256": "blake2b256:ba997b1aff1050b461f280a97f6197ccaf1b07b06a99936a810af1e567b9cb37"
+ },
+ {
+ "index": 311,
+ "offset": 652214272,
+ "size": 2097152,
+ "path": "ownership.pk.part0311",
+ "sha256": "sha256:6f4daefec26e9b4a4b17619fa25830bb0568b5809032b7b667e104f8f3ee27f5",
+ "blake2b256": "blake2b256:3bfe0f07ae8927a186e2a5a025ab7f77d01600f0a6bfc15f847921628d2f3526"
+ },
+ {
+ "index": 312,
+ "offset": 654311424,
+ "size": 2097152,
+ "path": "ownership.pk.part0312",
+ "sha256": "sha256:89dab15266f98172d2c32131ebd8e8c96853fdfe0f85a26cb202ae0513b62fbc",
+ "blake2b256": "blake2b256:4a9c4da905d496cbdac389da1399013bfea84ed0e5a307b5e41c6459153d4c1d"
+ },
+ {
+ "index": 313,
+ "offset": 656408576,
+ "size": 2097152,
+ "path": "ownership.pk.part0313",
+ "sha256": "sha256:dd614acce85ecf8ac61041064e10f4cb15db437cd4ea83bbebef0fbb3f7f7a33",
+ "blake2b256": "blake2b256:0a1bef14765b6583da567e122f4fe4f48917c495c9c37655da3cc4dae2041737"
+ },
+ {
+ "index": 314,
+ "offset": 658505728,
+ "size": 2097152,
+ "path": "ownership.pk.part0314",
+ "sha256": "sha256:1b2be6a427fcc651876d2b8ad0171c11b3da35f46308e7ff6d7684e6f2ebb5b6",
+ "blake2b256": "blake2b256:541c10ea8897ec2ba3fdd788c9f267699b43d6347e1a484b94fc12b901a718e2"
+ },
+ {
+ "index": 315,
+ "offset": 660602880,
+ "size": 2097152,
+ "path": "ownership.pk.part0315",
+ "sha256": "sha256:88f6cd26a2a0643fd37d2727c7bb2ad8ee63055b51d6e64ddd024d6955269846",
+ "blake2b256": "blake2b256:35cf642ef5ce8604eb61ec9370102ab6b1f2ec257c300d0fce2dea38bdc6f0c3"
+ },
+ {
+ "index": 316,
+ "offset": 662700032,
+ "size": 2097152,
+ "path": "ownership.pk.part0316",
+ "sha256": "sha256:da2b1b189896211923955d3114bcb7696ba21e187e44f4c272abd03919b52d4e",
+ "blake2b256": "blake2b256:16e3126c27e64cbdc6929efc9af1f34144675bf1e37865afcfdb3ae1d01ea720"
+ },
+ {
+ "index": 317,
+ "offset": 664797184,
+ "size": 2097152,
+ "path": "ownership.pk.part0317",
+ "sha256": "sha256:82f02ce5f8cec4d968a2e867e4e5cd0f057d8bf93f536c746a5c448d8efb42d6",
+ "blake2b256": "blake2b256:f307e7c3aee4761dd0c9785c279f0a30a7149176303ff5e2d3a017793da4d8b4"
+ },
+ {
+ "index": 318,
+ "offset": 666894336,
+ "size": 2097152,
+ "path": "ownership.pk.part0318",
+ "sha256": "sha256:4d72e5c2a0289505101d60334bf23e130885ec9c1b5b1d8a75bcb124e763255d",
+ "blake2b256": "blake2b256:eaffb9fe92c485307883fcd9c76bcdc930e57c2a69094200c007ab49d718e7cc"
+ },
+ {
+ "index": 319,
+ "offset": 668991488,
+ "size": 2097152,
+ "path": "ownership.pk.part0319",
+ "sha256": "sha256:b147d244dfbc01a914282b8e6164e016582e4d3d962f304a6ce53bf17c734620",
+ "blake2b256": "blake2b256:7f372923d5d30e9d3e0dc9ec89c5c76aedc3d4ad6e286b1a81f190e1c993a33d"
+ },
+ {
+ "index": 320,
+ "offset": 671088640,
+ "size": 2097152,
+ "path": "ownership.pk.part0320",
+ "sha256": "sha256:486e862fc9f4ebc74b418b16b35e18f6bbd132877a7fbe900cbbb4a7c6a80070",
+ "blake2b256": "blake2b256:b4e8671b453c050ec13861ec7fe4a2e0c1ea5ad57472839b599d4bfcf8642402"
+ },
+ {
+ "index": 321,
+ "offset": 673185792,
+ "size": 2097152,
+ "path": "ownership.pk.part0321",
+ "sha256": "sha256:cdc09cdb4774b9ab1fe5a3dddb6c965dd8826d08e3c95e563349980b3b8a755c",
+ "blake2b256": "blake2b256:9b46af33410271721bb381a69c17fa898ec65a3c9111fbc0d6486c97a4e87e51"
+ },
+ {
+ "index": 322,
+ "offset": 675282944,
+ "size": 2097152,
+ "path": "ownership.pk.part0322",
+ "sha256": "sha256:fc2d0f05071f4290d92fd0942a414e23bd60230a19ccf1b99408839ab296e002",
+ "blake2b256": "blake2b256:16025141615be7bc00f9e5f8e1499d1b3386dcefc84cd3c8fd3c3b32eb15a59c"
+ },
+ {
+ "index": 323,
+ "offset": 677380096,
+ "size": 2097152,
+ "path": "ownership.pk.part0323",
+ "sha256": "sha256:b55a9cd28f98acb0a8a4f31f38deb4662d80299d3f795572827e0151a5785282",
+ "blake2b256": "blake2b256:4679590f983f10cf411d729a4bb01a5237bcdb2d2a3b3232bd0d86bfa01efd56"
+ },
+ {
+ "index": 324,
+ "offset": 679477248,
+ "size": 2097152,
+ "path": "ownership.pk.part0324",
+ "sha256": "sha256:64d5bbe203cb79d50468ce066ec8f981e1ffadacbe36aad0c7dc61e6bf8133b2",
+ "blake2b256": "blake2b256:a3055795262bf2fd361e2d00280e64dc85ba1fd1ad91684030ef040c70e3306f"
+ },
+ {
+ "index": 325,
+ "offset": 681574400,
+ "size": 2097152,
+ "path": "ownership.pk.part0325",
+ "sha256": "sha256:18f62895a008eb7b0127e0ab8be8411439fe88ee55056197efaa5069e327ef88",
+ "blake2b256": "blake2b256:6a57a7f430a9bf5e91f84d17ae08f39ef07a7c7feebc696e4896b3af241ea268"
+ },
+ {
+ "index": 326,
+ "offset": 683671552,
+ "size": 2097152,
+ "path": "ownership.pk.part0326",
+ "sha256": "sha256:2dac0e24e90f2c8f61924d7975d7627a5ace28047922185e8331e2f308413c9a",
+ "blake2b256": "blake2b256:47b1e1e008b3267db705397b1df5b66c925ab3c3d91de5cab2e0924f68b0e748"
+ },
+ {
+ "index": 327,
+ "offset": 685768704,
+ "size": 2097152,
+ "path": "ownership.pk.part0327",
+ "sha256": "sha256:5a2bd7b5b9f10f1e964aff348d0f99422194169ef2c7af1d25fa86015fef43ae",
+ "blake2b256": "blake2b256:a0597f1d9236b094b89fe2ea681f5b7f76b9a08e38775e774fe10adab3cc3c84"
+ },
+ {
+ "index": 328,
+ "offset": 687865856,
+ "size": 2097152,
+ "path": "ownership.pk.part0328",
+ "sha256": "sha256:48c40553816cd6e5e9be1e0e7df2ea0932ec06c26cbf066396ee709f1b398af8",
+ "blake2b256": "blake2b256:a95ed11365a8636179a14532bd125aea2e137446393d80cfa9b51c35ee1fc3a3"
+ },
+ {
+ "index": 329,
+ "offset": 689963008,
+ "size": 2097152,
+ "path": "ownership.pk.part0329",
+ "sha256": "sha256:6959de4113dbb2f665c05f71348a6bcd30bf8eaf1aa2c77c7d7317f558e8e3e6",
+ "blake2b256": "blake2b256:c2a6639691b75841745ec74d6bba66c2314b5e3fe7ee105899bacab99270e094"
+ },
+ {
+ "index": 330,
+ "offset": 692060160,
+ "size": 2097152,
+ "path": "ownership.pk.part0330",
+ "sha256": "sha256:d48d4ac74c3232a97dc66143629c86b2fa0fcef89b7fa2782f6db1532268db99",
+ "blake2b256": "blake2b256:e50143687eb58e8873122960c126a7b2cdbf2e26ab3e97efe3f7c10fe143689d"
+ },
+ {
+ "index": 331,
+ "offset": 694157312,
+ "size": 2097152,
+ "path": "ownership.pk.part0331",
+ "sha256": "sha256:27df74c014831927bab988c4ad5d7e23cf6d2220c32f25d219fd1d035ffeb3c3",
+ "blake2b256": "blake2b256:81ba17de940c10757e8188b80b9e9067541e53e2351eea44f103e9f8a099e070"
+ },
+ {
+ "index": 332,
+ "offset": 696254464,
+ "size": 2097152,
+ "path": "ownership.pk.part0332",
+ "sha256": "sha256:5adc26ca5973121fe40eab03d22ab816f20d7f9848592a383efd08fe13673d01",
+ "blake2b256": "blake2b256:f1792a7c1c0d258e69a747b61e156b08d5f05ec1fc3a33c6e5b98c2c92ac48d4"
+ },
+ {
+ "index": 333,
+ "offset": 698351616,
+ "size": 2097152,
+ "path": "ownership.pk.part0333",
+ "sha256": "sha256:295a496d82116dc72e20b782e1b0ed5920695e7e9fbceda6fac737675f375268",
+ "blake2b256": "blake2b256:3c477225a149b98abac11f1f6418c2ed2c6601a47ce8aa364e25210377316eba"
+ },
+ {
+ "index": 334,
+ "offset": 700448768,
+ "size": 2097152,
+ "path": "ownership.pk.part0334",
+ "sha256": "sha256:93e345d685c1377661dec32ba4eba31905813fc721b7bfd09ca8f97b07b1262a",
+ "blake2b256": "blake2b256:a5c9aaa8ffa9eb7a2c0677ed8e17131df7d8000ae485924c11be170802998a16"
+ },
+ {
+ "index": 335,
+ "offset": 702545920,
+ "size": 2097152,
+ "path": "ownership.pk.part0335",
+ "sha256": "sha256:82e9d19b9bb83590080f48cb4ffaa0bc2fec4605dac6423ad9230d8d332afbd8",
+ "blake2b256": "blake2b256:3f8a61caa0ba5712a11958c0c01a96edb7eaba3ba0add1101da51d8691b62521"
+ },
+ {
+ "index": 336,
+ "offset": 704643072,
+ "size": 2097152,
+ "path": "ownership.pk.part0336",
+ "sha256": "sha256:e4cc771de68bb44290062099c656058ada3730a0bab34e1b80c53d24aa3812b0",
+ "blake2b256": "blake2b256:4aa2ed362082a8f4f45f489a6f4f9c53f278d98d07a5b5c25b75db29abfabedd"
+ },
+ {
+ "index": 337,
+ "offset": 706740224,
+ "size": 2097152,
+ "path": "ownership.pk.part0337",
+ "sha256": "sha256:ef092b37f0e7a9b7aabae6667f6674b90cc1100719845eef34dc6d8640398925",
+ "blake2b256": "blake2b256:eb4f6c6e3189b937f303e83f0055a5ea386a638225b400916a81ac298abca218"
+ },
+ {
+ "index": 338,
+ "offset": 708837376,
+ "size": 2097152,
+ "path": "ownership.pk.part0338",
+ "sha256": "sha256:299190551a1de011f393e567177fcf1f525e4ddef8c5940245ce1648fc85cf7c",
+ "blake2b256": "blake2b256:8243d0dfe1bd51282c0ff4999df0d597e9fa46dfca96fa047ad820dc84d71467"
+ },
+ {
+ "index": 339,
+ "offset": 710934528,
+ "size": 2097152,
+ "path": "ownership.pk.part0339",
+ "sha256": "sha256:d346167967a1a1cb1f899abd84b6b2356188225b001f780263ba45712ed56628",
+ "blake2b256": "blake2b256:20d8f75dcb13fd617c092490f2ff12ecb464d35dd342f8b40eaf9b630236bce6"
+ },
+ {
+ "index": 340,
+ "offset": 713031680,
+ "size": 2097152,
+ "path": "ownership.pk.part0340",
+ "sha256": "sha256:1e8a5f210d0c12b4f37a41ed02d46cc6eb40c9aa32104a7e30b473bd4e88708e",
+ "blake2b256": "blake2b256:9727052ed3f5fcb5f10db34e106aabbefe3bfa1f2f2c3015b0e3e1fa451d4d7b"
+ },
+ {
+ "index": 341,
+ "offset": 715128832,
+ "size": 2097152,
+ "path": "ownership.pk.part0341",
+ "sha256": "sha256:a69cf551c46024c1fc2ce302a9ab6ad9862f31073a8cb713e0722fadba3e74cf",
+ "blake2b256": "blake2b256:26cf32ed4b966a48d8832df78dccc65020f375b895bc395ace8fcb41e7197f9e"
+ },
+ {
+ "index": 342,
+ "offset": 717225984,
+ "size": 2097152,
+ "path": "ownership.pk.part0342",
+ "sha256": "sha256:b6335296336a7deaadef4b10114f41c99f9270e7610eeedc758422bc84f17a02",
+ "blake2b256": "blake2b256:3ebc6219499f229b85eb04487daddc7bb9f13cad2ddf1f3dde2b5772f2a0b3ec"
+ },
+ {
+ "index": 343,
+ "offset": 719323136,
+ "size": 2097152,
+ "path": "ownership.pk.part0343",
+ "sha256": "sha256:3e644a0ab30bd3e9e680dbc9e9aa75cd92567e590f74083b6c12834afee4451f",
+ "blake2b256": "blake2b256:795f42fb11580c4281a7b6ed302ee4be1705f55006114ca5474e0cb8aea95fc4"
+ },
+ {
+ "index": 344,
+ "offset": 721420288,
+ "size": 2097152,
+ "path": "ownership.pk.part0344",
+ "sha256": "sha256:63cb28dbe68feb22d7077b0d04a425060ed927afea4eeb7f51c2c13889cb888c",
+ "blake2b256": "blake2b256:6f5ca26589b351305fa6bcb75a334f0ec527fd01c8434b1af070b1a53fffd4a2"
+ },
+ {
+ "index": 345,
+ "offset": 723517440,
+ "size": 2097152,
+ "path": "ownership.pk.part0345",
+ "sha256": "sha256:9ecf02ae373e66d85ad36a8751268fa1e8ae7a88065db28cd4a235520b82d616",
+ "blake2b256": "blake2b256:4819a62581e5b5c25f03e6be80bb73215e7598ab47f4d3df2fc023d2817fa4c2"
+ },
+ {
+ "index": 346,
+ "offset": 725614592,
+ "size": 2097152,
+ "path": "ownership.pk.part0346",
+ "sha256": "sha256:544b333eb62ec36835a0942ddcf6d73ebfff167f2373614a7aa260c164e766ab",
+ "blake2b256": "blake2b256:669bd85b16160219f562181026f3cf22a01a3b987e8952052b7048fcb6071510"
+ },
+ {
+ "index": 347,
+ "offset": 727711744,
+ "size": 2097152,
+ "path": "ownership.pk.part0347",
+ "sha256": "sha256:c8421942a9f5e180b3bd9b1410b14d13318afd57f283f9d2a58528a5e590779a",
+ "blake2b256": "blake2b256:4423ac89cd0d42c709fe06b733c21a2f14ab38198d986990dc9fdbd5c45731db"
+ },
+ {
+ "index": 348,
+ "offset": 729808896,
+ "size": 2097152,
+ "path": "ownership.pk.part0348",
+ "sha256": "sha256:d0a6b0954cb2496f74d0afe58409131d6f84b14af6e7e905f541e2124891e13e",
+ "blake2b256": "blake2b256:d7306557dbc7ef00319d61310f3cc079d0c15f1bbc3f80acefce479d57d34394"
+ },
+ {
+ "index": 349,
+ "offset": 731906048,
+ "size": 2097152,
+ "path": "ownership.pk.part0349",
+ "sha256": "sha256:10ee052082bdb43a36701373658f2a6c12c7c10c704c01e87681c47a17968cd3",
+ "blake2b256": "blake2b256:c6178c1c355f520f35341d42164372c58819439b751c2a58933eb612c55d349a"
+ },
+ {
+ "index": 350,
+ "offset": 734003200,
+ "size": 2097152,
+ "path": "ownership.pk.part0350",
+ "sha256": "sha256:2b078e66c33b21a47deb4b85503a65b8860608e5845862ce718576b951f12579",
+ "blake2b256": "blake2b256:71fbec05a2870e7949d55853dee213569bdd07b6318b0903aace5b1354b2b876"
+ },
+ {
+ "index": 351,
+ "offset": 736100352,
+ "size": 2097152,
+ "path": "ownership.pk.part0351",
+ "sha256": "sha256:a065162b60a073d69aee5d380434880e69f06d4d28c57a7725f1f52d32584180",
+ "blake2b256": "blake2b256:da8553a04465c5a51a3d744cc14239fd4ecba40b286671299c60d0c41789b463"
+ },
+ {
+ "index": 352,
+ "offset": 738197504,
+ "size": 2097152,
+ "path": "ownership.pk.part0352",
+ "sha256": "sha256:d17d5193e8d67989a0be8f11a470b7c7f01eafc537032da6ee140912e3899338",
+ "blake2b256": "blake2b256:a27a42c16acc8ef80e5817b89db5b5cc43ceaea9027fcfb4de35db2534462d2f"
+ },
+ {
+ "index": 353,
+ "offset": 740294656,
+ "size": 2097152,
+ "path": "ownership.pk.part0353",
+ "sha256": "sha256:0a6740835149ac2392ec5735f279e8ab98ee5e7d805ef2273dc6beef3e9194ee",
+ "blake2b256": "blake2b256:5eb166970380e827780c8d98564f012a4498dc41b35401e829add5c5a737d234"
+ },
+ {
+ "index": 354,
+ "offset": 742391808,
+ "size": 2097152,
+ "path": "ownership.pk.part0354",
+ "sha256": "sha256:4aa62000cb21753cedeb643ecf6b988940c5ef60b917c6b414de9adba255cc0d",
+ "blake2b256": "blake2b256:73f7c1f098dbaca30d55a61530165084d8698e42fa27b2186e4b7140c97aad22"
+ },
+ {
+ "index": 355,
+ "offset": 744488960,
+ "size": 2097152,
+ "path": "ownership.pk.part0355",
+ "sha256": "sha256:de4019713aa0c28fe3611827b11c17b9aa625897225d9ef7629b124f58bd86a3",
+ "blake2b256": "blake2b256:b11b1309ec8ea8158e8101c516ad4c186b0f5a1b6b37533ce68f0d9a0786ad39"
+ },
+ {
+ "index": 356,
+ "offset": 746586112,
+ "size": 2097152,
+ "path": "ownership.pk.part0356",
+ "sha256": "sha256:6554528dd04f9900f75961802558480321fe45e61b658e64868b11821f606fc8",
+ "blake2b256": "blake2b256:e98c4d3a80eede4995f5d246accc6adb03db0cbc6c961eb11cf70283e23d920e"
+ },
+ {
+ "index": 357,
+ "offset": 748683264,
+ "size": 2097152,
+ "path": "ownership.pk.part0357",
+ "sha256": "sha256:11a25e61c777c31795c002d260e19d64a1ede29f7f7604c78c0460f7d1abbc65",
+ "blake2b256": "blake2b256:fd893d1e04c5666a40926d2e5669339fe5b587d411964b8ca2236fbbeab6890a"
+ },
+ {
+ "index": 358,
+ "offset": 750780416,
+ "size": 2097152,
+ "path": "ownership.pk.part0358",
+ "sha256": "sha256:d1ded6031bc1583a5c9abe4fcf2ad21f104005076dadc60a689c89036c713cd2",
+ "blake2b256": "blake2b256:41536968e39fb052c62f68cee1db23767d2dd79315776e09529da675568d0058"
+ },
+ {
+ "index": 359,
+ "offset": 752877568,
+ "size": 2097152,
+ "path": "ownership.pk.part0359",
+ "sha256": "sha256:aeaeaba19931810ae8e2c6da9f28301b834cb9502c047c4c91b0d1946851f1f3",
+ "blake2b256": "blake2b256:77ba7f4c09a07f8949d4e78c11525803b875c36b4cca58026442ce7ec50c5e9b"
+ },
+ {
+ "index": 360,
+ "offset": 754974720,
+ "size": 2097152,
+ "path": "ownership.pk.part0360",
+ "sha256": "sha256:2e58508592227a9f2a598a9e23781e3c2db4f4df6855d76d43e04fc55ab2075f",
+ "blake2b256": "blake2b256:06c6a3eb081189d9414d6d16d8ba409aad2ca706662573f3d9dd179388921af7"
+ },
+ {
+ "index": 361,
+ "offset": 757071872,
+ "size": 2097152,
+ "path": "ownership.pk.part0361",
+ "sha256": "sha256:3a2e04e988e0c1f1ff3f67180ea1d22c5025e2d3c5c2b4e6d5befcbea460ce87",
+ "blake2b256": "blake2b256:21c2c634f20b0e5228c677593fef6cbb0b1dff599beff6709e548e873bc8ebe8"
+ },
+ {
+ "index": 362,
+ "offset": 759169024,
+ "size": 2097152,
+ "path": "ownership.pk.part0362",
+ "sha256": "sha256:1dff12f3cd5b568bb2a59fb83e7a698dbc9fa7428151609ede399f1b0826f048",
+ "blake2b256": "blake2b256:e7861ea398860be4e0d05dc018acefd84f56c10693359a208a64d506a0c5edc7"
+ },
+ {
+ "index": 363,
+ "offset": 761266176,
+ "size": 2097152,
+ "path": "ownership.pk.part0363",
+ "sha256": "sha256:4ba6b274fc26655c0463e05d2efaa92ee178151367e9893752117b7e87befd3a",
+ "blake2b256": "blake2b256:0f2b2e9abdb6430d58e193ea2c213fa31be029347ac29f4f7a0838325cc0c928"
+ },
+ {
+ "index": 364,
+ "offset": 763363328,
+ "size": 2097152,
+ "path": "ownership.pk.part0364",
+ "sha256": "sha256:62c818463c9281759c9e0f8abe7eb385335d9bae3e1e4ebde246244e16b0954a",
+ "blake2b256": "blake2b256:434bb453912caecee7049fb299ce238e0e432bd3bbb7826b4b76d811277e6b7b"
+ },
+ {
+ "index": 365,
+ "offset": 765460480,
+ "size": 2097152,
+ "path": "ownership.pk.part0365",
+ "sha256": "sha256:eccc2f2aabfd37c47d2b0df406cbf87b539093bad704d6347653127380a81eec",
+ "blake2b256": "blake2b256:7be625bbd61d33d2fe1cbec70c1d0355ae5df53cb69549088644e66e064159b0"
+ },
+ {
+ "index": 366,
+ "offset": 767557632,
+ "size": 2097152,
+ "path": "ownership.pk.part0366",
+ "sha256": "sha256:dbc0b9bc9b9130325f2b13916a2183e20d7bf10d0cd728b8e0b66d83d83e924d",
+ "blake2b256": "blake2b256:7676034c3fdd7b14596fc7261b746c6a43b24dbe8083b7f740bdba80a32dfc72"
+ },
+ {
+ "index": 367,
+ "offset": 769654784,
+ "size": 2097152,
+ "path": "ownership.pk.part0367",
+ "sha256": "sha256:995be05b9762e0e7911e4d5fa6fcba1b334011d7f0d09a6728498f5912ebe54b",
+ "blake2b256": "blake2b256:0d7084847ff2d0e11b2a2a4d27f331e28d5cfb9ad81a1a32484995f731e97778"
+ },
+ {
+ "index": 368,
+ "offset": 771751936,
+ "size": 2097152,
+ "path": "ownership.pk.part0368",
+ "sha256": "sha256:367bb0e22967327dc24938ebc3edaca09f5f52cf657a532c1b221c1013cfa3ec",
+ "blake2b256": "blake2b256:4892e5ccdc5efe532908eca50cb1d0215750feb9e559858cd31921f8c981ccdf"
+ },
+ {
+ "index": 369,
+ "offset": 773849088,
+ "size": 2097152,
+ "path": "ownership.pk.part0369",
+ "sha256": "sha256:da8b7a651dda6d0f6f23a24a9c900914b514ef9aa603eeb4347e3f763dbcc983",
+ "blake2b256": "blake2b256:e8fd77f792da53c65e1118e30dceb7220138614aec2de489181e7efe3df545b6"
+ },
+ {
+ "index": 370,
+ "offset": 775946240,
+ "size": 2097152,
+ "path": "ownership.pk.part0370",
+ "sha256": "sha256:d76a593a9eeb0a4031e8f3cd1051c2548c8fc762c11e2624c495dac6ccdd7e34",
+ "blake2b256": "blake2b256:e67f1934ec746f23f10f302b453540892e4cc08240a7239b27246bda31a713c1"
+ },
+ {
+ "index": 371,
+ "offset": 778043392,
+ "size": 2097152,
+ "path": "ownership.pk.part0371",
+ "sha256": "sha256:30b19ec2cb06dfd6597fe920307d8b0dc1cef34d2fed58939671b1ccf3b93f91",
+ "blake2b256": "blake2b256:22a66c4735d5beb14ce4311bd37045c1899b96d0cb387a29da98a921491d907b"
+ },
+ {
+ "index": 372,
+ "offset": 780140544,
+ "size": 2097152,
+ "path": "ownership.pk.part0372",
+ "sha256": "sha256:b156f84b577e91e49aa76c43a7e0889ef656a4feba180b1bcfc552cf485be8c5",
+ "blake2b256": "blake2b256:bb374cca7aa9b6843ad91ca50fd130295cee0b62f5b5d7575387d31f158581d0"
+ },
+ {
+ "index": 373,
+ "offset": 782237696,
+ "size": 2097152,
+ "path": "ownership.pk.part0373",
+ "sha256": "sha256:496fd840b1a4c4b111e0a415b9dd36e369a6ce8af58ab7745c4c952d0e5698a1",
+ "blake2b256": "blake2b256:5053a9d966bf61ce6b3ebbfaf118541e2bf99c6340823b97f9acee0c4c9d3d2c"
+ },
+ {
+ "index": 374,
+ "offset": 784334848,
+ "size": 2097152,
+ "path": "ownership.pk.part0374",
+ "sha256": "sha256:bc3f3775cf13a4ca2a873b311fc8acd1d2c9e881a23296353881c4985e61356b",
+ "blake2b256": "blake2b256:c833be957e2a86a27d1e42db4920af321354b2967e7a8aeb54be29f53f2eb0f9"
+ },
+ {
+ "index": 375,
+ "offset": 786432000,
+ "size": 2097152,
+ "path": "ownership.pk.part0375",
+ "sha256": "sha256:68084a2331c363b5170592bbe55c7871b288369dae3bb76fef52733ddc9db2d7",
+ "blake2b256": "blake2b256:057375d1273183bfd111d163da216e89dc9b5b7bc8c2926f11383b29f8f6364e"
+ },
+ {
+ "index": 376,
+ "offset": 788529152,
+ "size": 2097152,
+ "path": "ownership.pk.part0376",
+ "sha256": "sha256:dd2b68b40aa6e7c9c6551d504992115eaf4df221430224f3bb39927a08a5862a",
+ "blake2b256": "blake2b256:6234cd1aa281b0fa5270c3f7856e3905457f3368f27a940e7232caca822d31d1"
+ },
+ {
+ "index": 377,
+ "offset": 790626304,
+ "size": 2097152,
+ "path": "ownership.pk.part0377",
+ "sha256": "sha256:039325472f292a5a3b8d94d6d56d26927d15ca5c1982c8f0bbce315e678897f8",
+ "blake2b256": "blake2b256:7c8f5ccc497cb81c0dfb82fe220554c7db6c1d1a0141420268d8d552e4257feb"
+ },
+ {
+ "index": 378,
+ "offset": 792723456,
+ "size": 2097152,
+ "path": "ownership.pk.part0378",
+ "sha256": "sha256:c70c9aa85a847f198e69dc9a25add1a4deb5dec4f003ddf23fb92b3bf07ec3e1",
+ "blake2b256": "blake2b256:d161b9f27287c99bc9f0df85bf36dae3dfde2ad97e6afbc385ef2d9644e944eb"
+ },
+ {
+ "index": 379,
+ "offset": 794820608,
+ "size": 2097152,
+ "path": "ownership.pk.part0379",
+ "sha256": "sha256:d0d36583bdf2cfe1797da54e3c28b2874e029cd3a12e5514ee35facbe9cfe281",
+ "blake2b256": "blake2b256:9c0d51245023e6048eb8592f0b23fa57e3bf48eb3820bbf641cdc3596c70d770"
+ },
+ {
+ "index": 380,
+ "offset": 796917760,
+ "size": 2097152,
+ "path": "ownership.pk.part0380",
+ "sha256": "sha256:cfe31f8db02596d4d4d12a43c4496ec05cbc9388034d8b5ac6f7af6f44d08ebe",
+ "blake2b256": "blake2b256:30b0ee831de0acd9af6ae418730aaf94d309b38cfc26a1215ef177206344d57c"
+ },
+ {
+ "index": 381,
+ "offset": 799014912,
+ "size": 2097152,
+ "path": "ownership.pk.part0381",
+ "sha256": "sha256:0ed7617419ee5a3ad6ca1490f7dc6a268a695d5e1eb31c72674ca08dbc243d59",
+ "blake2b256": "blake2b256:379d782f8a1f4cac164451706a6755fb1a03fb7b07807a60d58e31e23a5954a5"
+ },
+ {
+ "index": 382,
+ "offset": 801112064,
+ "size": 2097152,
+ "path": "ownership.pk.part0382",
+ "sha256": "sha256:6bea012622fc8f2d5ee9dd08abb350651fc6f1860bfd3ea36bc4b3377278bf26",
+ "blake2b256": "blake2b256:26a57aa590e42983eee96788d33c343a5a9fd8dd3118d8bf6b2d9e466e168e85"
+ },
+ {
+ "index": 383,
+ "offset": 803209216,
+ "size": 2097152,
+ "path": "ownership.pk.part0383",
+ "sha256": "sha256:f3f673e91f716a16d3a397e84d0a7138c40ec1e8bb2db88109a2ff42558b20c4",
+ "blake2b256": "blake2b256:747038e92f7098ad8a2803eada1c7a026703ff1f89820631b8e3d99f25cca197"
+ },
+ {
+ "index": 384,
+ "offset": 805306368,
+ "size": 2097152,
+ "path": "ownership.pk.part0384",
+ "sha256": "sha256:18f4178b2187e91aa7335ea198e719cb151bff8f693bdf23551e919f452891e6",
+ "blake2b256": "blake2b256:e9d3f54a07db894a16b3ebbaaf29a309da788c0956b8b8d771c6442c732fbcb6"
+ },
+ {
+ "index": 385,
+ "offset": 807403520,
+ "size": 2097152,
+ "path": "ownership.pk.part0385",
+ "sha256": "sha256:ab69af407d05c0dd10017675e3474388dddb8578211faadb9e99a9c087177375",
+ "blake2b256": "blake2b256:b99399d44c248f23f3f599f003d9bb45b1909f13c7411fbd4c1ec1c412014017"
+ },
+ {
+ "index": 386,
+ "offset": 809500672,
+ "size": 2097152,
+ "path": "ownership.pk.part0386",
+ "sha256": "sha256:27798840244f38e043694c1c0f676889c485a7f379f19d309d81d7cb2738646b",
+ "blake2b256": "blake2b256:377752b710c5b861520059ce09292c4ff3329e6a17988e6da133fe4ebd283ac1"
+ },
+ {
+ "index": 387,
+ "offset": 811597824,
+ "size": 2097152,
+ "path": "ownership.pk.part0387",
+ "sha256": "sha256:932c4bce27036e9553ccfe3f39b0ba0eeed1ccf01b3365698fd9d1afd8e54a19",
+ "blake2b256": "blake2b256:383f5cb8a1c6cf8073cabe7935ed7ea171aea8c08415a0671846172fd0489172"
+ },
+ {
+ "index": 388,
+ "offset": 813694976,
+ "size": 2097152,
+ "path": "ownership.pk.part0388",
+ "sha256": "sha256:513d2be878e7987bd8387df176a71aa2898b1ed9f1a4aa039831b4f0791dea54",
+ "blake2b256": "blake2b256:87aa93033db014789f52733ad1b4d95e08070eba30aab44ad2ec504fb8e76483"
+ },
+ {
+ "index": 389,
+ "offset": 815792128,
+ "size": 2097152,
+ "path": "ownership.pk.part0389",
+ "sha256": "sha256:5c62c29873ee4092b1fc1c5b0d35f67efc88fe73731099c4b708cd7d2a64c22f",
+ "blake2b256": "blake2b256:3d9a578eb7498de3e829216001ae76450ddef840d5a74b58a0eba8cb74d507fa"
+ },
+ {
+ "index": 390,
+ "offset": 817889280,
+ "size": 2097152,
+ "path": "ownership.pk.part0390",
+ "sha256": "sha256:beb39815c30e945b97cd9da5920a1b7807f9f9f0f0773aefb619ec09e68932a8",
+ "blake2b256": "blake2b256:648795177c67f3f10045733176aded4c53c49396b2ee57a192495d3dad665f0e"
+ },
+ {
+ "index": 391,
+ "offset": 819986432,
+ "size": 2097152,
+ "path": "ownership.pk.part0391",
+ "sha256": "sha256:81a7cc00c4e24f4074900667a029a54dddf5291a0bd3882a9c5c3bb86685adb7",
+ "blake2b256": "blake2b256:1e1d8e987aec86ce45480d66974f3beb4c0a3271b14a0735cb4892dc444b95b4"
+ },
+ {
+ "index": 392,
+ "offset": 822083584,
+ "size": 2097152,
+ "path": "ownership.pk.part0392",
+ "sha256": "sha256:a2338748a2842b8e07887b5ee06cbcabea9994f5a1c3b7833e96a8a2acb6370b",
+ "blake2b256": "blake2b256:48fb7cc6cf40cc3bc2b9acf499ac961aacb3477c6d59a9a99b01d6efaaf327ec"
+ },
+ {
+ "index": 393,
+ "offset": 824180736,
+ "size": 2097152,
+ "path": "ownership.pk.part0393",
+ "sha256": "sha256:a369a8f272e75987a640262fd8c43b8570df96a1c32a7c22b40295456caaccbe",
+ "blake2b256": "blake2b256:a3725360853d6bc645db2e6ea114a6bdde24a30f6ee446c49258ac4db62ca994"
+ },
+ {
+ "index": 394,
+ "offset": 826277888,
+ "size": 2097152,
+ "path": "ownership.pk.part0394",
+ "sha256": "sha256:46f9ac82851f4e3afa119233637b6783554a585b16999e8f3a2d7d2a652d56cb",
+ "blake2b256": "blake2b256:d29ac065d90cf4ed2ccf592a9758c651c7ab499cb1d1d8f8295173d77c2622e2"
+ },
+ {
+ "index": 395,
+ "offset": 828375040,
+ "size": 2097152,
+ "path": "ownership.pk.part0395",
+ "sha256": "sha256:c9fde983813e41b13a6a2744da5ae37b3c69dc0935a46e5b9009645d7f98b0ca",
+ "blake2b256": "blake2b256:7391a5c149e90e075e9df3a118dd812c33d04f76299dc20bad917e4f1ec98715"
+ },
+ {
+ "index": 396,
+ "offset": 830472192,
+ "size": 2097152,
+ "path": "ownership.pk.part0396",
+ "sha256": "sha256:298c6541e80026c08e1982efb0d77ebf4f351f3e371208cdf25ad9407aa56d62",
+ "blake2b256": "blake2b256:e2c60dad57cb7a15418f70c4746288b7a57d01adb6a532d99b4416ea1d2f528b"
+ },
+ {
+ "index": 397,
+ "offset": 832569344,
+ "size": 2097152,
+ "path": "ownership.pk.part0397",
+ "sha256": "sha256:0346ba5e987d88c2caf90e9e22dc0fe6212a3dea0f1635edd76064d65501d4a6",
+ "blake2b256": "blake2b256:bda0d4846e541d2927d152456a9eeb47bc93db010cec18f52b2beac17909abc8"
+ },
+ {
+ "index": 398,
+ "offset": 834666496,
+ "size": 2097152,
+ "path": "ownership.pk.part0398",
+ "sha256": "sha256:47058321306162fa64a9a0db24f8b2de52611e0e44b6f50e2daba1e499efc026",
+ "blake2b256": "blake2b256:e85dddb34c4a6cc3d78cba3addf79dcc2fc82ee6eb389f6d8f9499b678e3df75"
+ },
+ {
+ "index": 399,
+ "offset": 836763648,
+ "size": 2097152,
+ "path": "ownership.pk.part0399",
+ "sha256": "sha256:297aff1359c13b5b56cb7444ec7093422f179408ddfe9c7c26fbc538c7eeab75",
+ "blake2b256": "blake2b256:ed99f037cadcf682ed3b2c2f07a82657fbd1640639dbf1a4cc24dbc7cb1bf7be"
+ },
+ {
+ "index": 400,
+ "offset": 838860800,
+ "size": 2097152,
+ "path": "ownership.pk.part0400",
+ "sha256": "sha256:363b130086b682dddce3c9d68a073c8a6d1018a9160454f7011aa7f742b4d2fc",
+ "blake2b256": "blake2b256:001ddc12b616a3e7339317f4838e0c0b28fbf5733ac27b3890e95df59989a4fb"
+ },
+ {
+ "index": 401,
+ "offset": 840957952,
+ "size": 2097152,
+ "path": "ownership.pk.part0401",
+ "sha256": "sha256:9d189dedf9a345ab8ce53ffb9df1c759b72dfe7bd3d3194fc8581030b1216e53",
+ "blake2b256": "blake2b256:eea84baa5fefda3bf75df471cc0b957df0215b50a954434cae4a33658c74cacf"
+ },
+ {
+ "index": 402,
+ "offset": 843055104,
+ "size": 2097152,
+ "path": "ownership.pk.part0402",
+ "sha256": "sha256:eb6327c876ebb80c321ee099264dfe5a2d3d436a976765cedd9fc59bdd25f3bf",
+ "blake2b256": "blake2b256:760450e0befe5f34146d6c67ccdf8dfbd0b6f428a4f41fdd3c28e9a667c1b54d"
+ },
+ {
+ "index": 403,
+ "offset": 845152256,
+ "size": 2097152,
+ "path": "ownership.pk.part0403",
+ "sha256": "sha256:182369774bddea3771d1f3131de04c39ba304be6d97792304c1434437c65e5bd",
+ "blake2b256": "blake2b256:757cc9fd7caeb96348d99f6587bd29b37b6a8719a50beab2a3bfaf98f5146f2d"
+ },
+ {
+ "index": 404,
+ "offset": 847249408,
+ "size": 2097152,
+ "path": "ownership.pk.part0404",
+ "sha256": "sha256:1bfa9a59f1223cc54c21c248f3a0bbd709d664446c4a1f260f28a1df523062ef",
+ "blake2b256": "blake2b256:55956d22d688a600af1f5e859d13c749baaa3a6e2a2757cf2d80acd0af450e1e"
+ },
+ {
+ "index": 405,
+ "offset": 849346560,
+ "size": 2097152,
+ "path": "ownership.pk.part0405",
+ "sha256": "sha256:4f540a822ff37fd7a97bfa266ef3d8d162cf532733bc4c18208598c2fd016e8a",
+ "blake2b256": "blake2b256:c27db6f4737fc71be55a17ca722bcfea294248422bb21eae0cb14f238eb63b7d"
+ },
+ {
+ "index": 406,
+ "offset": 851443712,
+ "size": 2097152,
+ "path": "ownership.pk.part0406",
+ "sha256": "sha256:f9ab40fd023897f436c977a93db26037f1cdc599774a0b900e4e21fbb2b3d5cd",
+ "blake2b256": "blake2b256:9d965feba7fee01f2bb8746272ecf267a6cf23f2d7cba60b81d498c5ebc5e8c4"
+ },
+ {
+ "index": 407,
+ "offset": 853540864,
+ "size": 2097152,
+ "path": "ownership.pk.part0407",
+ "sha256": "sha256:01b73696ebd81272438ddff295bfa6a88e46ad3145c61b8bd4d603baf5334cdd",
+ "blake2b256": "blake2b256:c1bbb1cfb51937e9e225db16a8413548d84bcf22e19c0500c49b1a536f2646c7"
+ },
+ {
+ "index": 408,
+ "offset": 855638016,
+ "size": 2097152,
+ "path": "ownership.pk.part0408",
+ "sha256": "sha256:07a3217f68d0263dc04ca34c37e29142c0dc919d18bc5aabfc9e4403ba565680",
+ "blake2b256": "blake2b256:b3421afd86f84f45d20b97ed9e9a15e7c02c994977b9b8ce9bf502fd3bb56ffa"
+ },
+ {
+ "index": 409,
+ "offset": 857735168,
+ "size": 2097152,
+ "path": "ownership.pk.part0409",
+ "sha256": "sha256:eb56e06bea34062722e5044be98a1bf6de2f23a208cb428c35f9f5f029ce454f",
+ "blake2b256": "blake2b256:2334c73cff1980a0900aa76fbad20058e5554fadf90f785ce04dbf77850e4380"
+ },
+ {
+ "index": 410,
+ "offset": 859832320,
+ "size": 2097152,
+ "path": "ownership.pk.part0410",
+ "sha256": "sha256:b955ad21fe408ec0dd211fe98603518dea85a3cd3a53847e8e2cf14ad970e5de",
+ "blake2b256": "blake2b256:e8cdc59a03c0047ff42cdfc70b269ff2d93406e96f06e8273c550acc11c81bb5"
+ },
+ {
+ "index": 411,
+ "offset": 861929472,
+ "size": 2097152,
+ "path": "ownership.pk.part0411",
+ "sha256": "sha256:276478a6a0cb394b441067d6634e0e7c9e62e6e641c964a98a5168ecc2e29db1",
+ "blake2b256": "blake2b256:5c299e0b5d7dd8e39bdc3ee7da1186c1e1b8622eedbec60395cab1db9e8030e3"
+ },
+ {
+ "index": 412,
+ "offset": 864026624,
+ "size": 2097152,
+ "path": "ownership.pk.part0412",
+ "sha256": "sha256:eb579fc1efc07fa604ef014a3d55aaf9be1bfffb831005eb57a8b42a497a8733",
+ "blake2b256": "blake2b256:53924c2a5152a96eeaab07ff6e43ba92c5263949e25082fd603c0033b15cfa92"
+ },
+ {
+ "index": 413,
+ "offset": 866123776,
+ "size": 2097152,
+ "path": "ownership.pk.part0413",
+ "sha256": "sha256:60d21a7c9b3559e95a296303a3ffcce1926f209473039aea73cd1d2ed117e72c",
+ "blake2b256": "blake2b256:b1fece54ae0f07b53b1fd29e4a66da565d4c9334aa80986cf4c42489d2d0f920"
+ },
+ {
+ "index": 414,
+ "offset": 868220928,
+ "size": 2097152,
+ "path": "ownership.pk.part0414",
+ "sha256": "sha256:b1d51db712a81ce43c989c56f1eb911d2120cd1d8f0ac7cb1beb875527a9d068",
+ "blake2b256": "blake2b256:86119ca6c3e99a31f771446198dae66e0921f726aa02fc2fe6825628a4400bdf"
+ },
+ {
+ "index": 415,
+ "offset": 870318080,
+ "size": 2097152,
+ "path": "ownership.pk.part0415",
+ "sha256": "sha256:cf914f04af52dbf0c1b1be21fc667805f34738d6d6d0c28fce2221b420382dc7",
+ "blake2b256": "blake2b256:2a8c270633683bc528143ae813b32908ee25942854c893857779c41aa721e0b6"
+ },
+ {
+ "index": 416,
+ "offset": 872415232,
+ "size": 2097152,
+ "path": "ownership.pk.part0416",
+ "sha256": "sha256:e3c353fa69ef6d0c369745237d8d096c32791d851efeaea9c7f10831df380846",
+ "blake2b256": "blake2b256:e64a5d3daf8ceadb278e9e7d5a34385818bc48b1a08519d1b6e227eb1b0dbbc6"
+ },
+ {
+ "index": 417,
+ "offset": 874512384,
+ "size": 2097152,
+ "path": "ownership.pk.part0417",
+ "sha256": "sha256:5d39ba65f8b7d9051e77094a17e7405e30097448bb255570ab239f75e39a9213",
+ "blake2b256": "blake2b256:04fc902ecdadd59541f9b5f6768d4b33c27c9757dd0e7a356390e341164e7122"
+ },
+ {
+ "index": 418,
+ "offset": 876609536,
+ "size": 2097152,
+ "path": "ownership.pk.part0418",
+ "sha256": "sha256:f666289533b59466e432ad5f92f1664d95ec29b5a751ffee3b094f65e6833424",
+ "blake2b256": "blake2b256:8029741ba10a350131e042c1b0233c84a09eda89948b5f4676c42e804eac3a51"
+ },
+ {
+ "index": 419,
+ "offset": 878706688,
+ "size": 2097152,
+ "path": "ownership.pk.part0419",
+ "sha256": "sha256:e10b807fc83ce27acca267376ed4175729003fd9eab184150ec45add858530fb",
+ "blake2b256": "blake2b256:5fb16b0da756004759f7abeeea02f252c59358da0aa3546fe618b2816be25d03"
+ },
+ {
+ "index": 420,
+ "offset": 880803840,
+ "size": 2097152,
+ "path": "ownership.pk.part0420",
+ "sha256": "sha256:04e2aa661b63ae827345a98aca3da802dde6221348a42f56b2b16f6ad0a95c48",
+ "blake2b256": "blake2b256:6e46aeed5eb0cae37d2a1806a27a424d60179f31def2827921405bd5a385eb16"
+ },
+ {
+ "index": 421,
+ "offset": 882900992,
+ "size": 2097152,
+ "path": "ownership.pk.part0421",
+ "sha256": "sha256:6105b03b6b902f62ddcd342228b1b1ca4a6bb748d7e2e7d94e8e64e39b316ec5",
+ "blake2b256": "blake2b256:f1b3b1e631c5638399dd6163b8efca1fa17aa180e41f1be20bc9754e2ad3047d"
+ },
+ {
+ "index": 422,
+ "offset": 884998144,
+ "size": 2097152,
+ "path": "ownership.pk.part0422",
+ "sha256": "sha256:c2af859381485eb3405e80571c7664d3cb616345f7e48c72117d3918861baa5e",
+ "blake2b256": "blake2b256:2946c0d848ae992e6c62eaaa792f6fb339694180a0cea71190272f09b58f7f11"
+ },
+ {
+ "index": 423,
+ "offset": 887095296,
+ "size": 2097152,
+ "path": "ownership.pk.part0423",
+ "sha256": "sha256:fa1211b1f76ffb0b88f207e53c3da8712b09299590692e52c2c1348af3f213b5",
+ "blake2b256": "blake2b256:4d2fea58119a2df4d21de15692dc1df0ffd32673e2aca49460531270735048cc"
+ },
+ {
+ "index": 424,
+ "offset": 889192448,
+ "size": 2097152,
+ "path": "ownership.pk.part0424",
+ "sha256": "sha256:ca52ac7261b399afcda5a36bdaf773f34d58c649cfa3a1926ddf2f86d6c84ce0",
+ "blake2b256": "blake2b256:f6ff5159668c43c136b04eaa873bb4985982f32f79c328b09289a5e523965b79"
+ },
+ {
+ "index": 425,
+ "offset": 891289600,
+ "size": 2097152,
+ "path": "ownership.pk.part0425",
+ "sha256": "sha256:b763049d37413b4b0b4af949c5f23349f4786bd97c445e875c3b1df1c3a47cab",
+ "blake2b256": "blake2b256:b19c79ff8cd8c97e40e919e45392d39ebf17ced3ad1adbfdda811061944959db"
+ },
+ {
+ "index": 426,
+ "offset": 893386752,
+ "size": 2097152,
+ "path": "ownership.pk.part0426",
+ "sha256": "sha256:c0ebb19e602c3669ad1242cf24f4a03d1e35c605999bda16e606af3949b4fab8",
+ "blake2b256": "blake2b256:f91fc70a5cef1bab1d7277e47e12f656c62be24e363d135f307ee29c147bd3c0"
+ },
+ {
+ "index": 427,
+ "offset": 895483904,
+ "size": 2097152,
+ "path": "ownership.pk.part0427",
+ "sha256": "sha256:08a3c421e76e250d092cbbd02e5df7f1bfbd81c959b969f36e78803c49ef1d3f",
+ "blake2b256": "blake2b256:cc6e9f5d4bd6be31afebed0003f851ad6f1754fe173b03adfcc30fc9ec45aaee"
+ },
+ {
+ "index": 428,
+ "offset": 897581056,
+ "size": 2097152,
+ "path": "ownership.pk.part0428",
+ "sha256": "sha256:dfc26a1e0de1b347f7ebfc7e95d53c73c1cd4816ca52dd7f77dab69d496b1156",
+ "blake2b256": "blake2b256:7021a69acb528714a5a2c6557761dfec9db271327d76d4baeccf68f00305c096"
+ },
+ {
+ "index": 429,
+ "offset": 899678208,
+ "size": 2097152,
+ "path": "ownership.pk.part0429",
+ "sha256": "sha256:0d4dd40e8525f441e6adf83d9b32bac836d0c74f5ef4b0197ad5f25db22402f7",
+ "blake2b256": "blake2b256:1ec86ba107d7b0ea4c4d8d3c76a3ec3eb9ab96d5bd0c1dfafdfbe5c5cfe83037"
+ },
+ {
+ "index": 430,
+ "offset": 901775360,
+ "size": 2097152,
+ "path": "ownership.pk.part0430",
+ "sha256": "sha256:be5368b7cb04775f4cadbaae771edf26c448aa727133e34fdd885701f0c8c7ab",
+ "blake2b256": "blake2b256:37b8cc0386a52c27e78593cad185d320c4f92b9b13db82293fdca24a0b97cfcb"
+ },
+ {
+ "index": 431,
+ "offset": 903872512,
+ "size": 2097152,
+ "path": "ownership.pk.part0431",
+ "sha256": "sha256:1870d566a77f0ca831df4c1e00f47c122f9b5d78bde08e0d4ce83d85ddee6433",
+ "blake2b256": "blake2b256:11fe88fc7767d3e9b4e652372ae04ebe66b2d77a1650c97ee6fad641d034f954"
+ },
+ {
+ "index": 432,
+ "offset": 905969664,
+ "size": 2097152,
+ "path": "ownership.pk.part0432",
+ "sha256": "sha256:92a745e1c66730474dc1bc11acbf048aefb6249ff0ebe7f2869e35c0c2fdc6ed",
+ "blake2b256": "blake2b256:72ff37c8b8851100c381aacb5641f1e5bbb196047505d491179228db67572e7f"
+ },
+ {
+ "index": 433,
+ "offset": 908066816,
+ "size": 2097152,
+ "path": "ownership.pk.part0433",
+ "sha256": "sha256:96f32c6ff65c2b52e6a214960bdcf81c84ef1833badbecdb5badd6c8cdb3dae3",
+ "blake2b256": "blake2b256:c78132d78aa8b37fcc4637fd4f68100e9a556f4b3b3ec7ba8f53254bda1be5d6"
+ },
+ {
+ "index": 434,
+ "offset": 910163968,
+ "size": 2097152,
+ "path": "ownership.pk.part0434",
+ "sha256": "sha256:a855805f28aea1da5aaaabb73dea2599228f1e557d1f30ab628093a351446098",
+ "blake2b256": "blake2b256:589e079709cf5066f57c51e7290316966747d6cd02c064e37862edc3dd6aa566"
+ },
+ {
+ "index": 435,
+ "offset": 912261120,
+ "size": 2097152,
+ "path": "ownership.pk.part0435",
+ "sha256": "sha256:3fa38e7f95c683139113fd7937a82454111c312fa9954b824ffe59aa43b4a4ce",
+ "blake2b256": "blake2b256:696942224ec0c251cf89a75ae9602f58589be4225a09bb529e2318333083e28e"
+ },
+ {
+ "index": 436,
+ "offset": 914358272,
+ "size": 2097152,
+ "path": "ownership.pk.part0436",
+ "sha256": "sha256:d17f1b122f837eb51caab67d35207e40821aebca755d5eb43259f7aac42ca7b8",
+ "blake2b256": "blake2b256:69163e572555863abf257d090d83c4187d5f74a7da0ca985a78643af5371c19c"
+ },
+ {
+ "index": 437,
+ "offset": 916455424,
+ "size": 2097152,
+ "path": "ownership.pk.part0437",
+ "sha256": "sha256:377e15159d863211d96cc974591c05b693b72a89da8e87f66935e62c85caeb31",
+ "blake2b256": "blake2b256:45b3d5ff0d6758299f4992821eae12fb6ccb3bd15550ac1b140d65a26bcebe93"
+ },
+ {
+ "index": 438,
+ "offset": 918552576,
+ "size": 2097152,
+ "path": "ownership.pk.part0438",
+ "sha256": "sha256:a8f16e98c1dac428fd778c509ed627b4a33edc71a7a4ad8b5df6aea353c2f945",
+ "blake2b256": "blake2b256:b26068befc18f2fef8a533e9b3a8ea4a0cdfc2defb78003397fa1ab2595a56e1"
+ },
+ {
+ "index": 439,
+ "offset": 920649728,
+ "size": 2097152,
+ "path": "ownership.pk.part0439",
+ "sha256": "sha256:81c17794cddfff2caba61c2fbe21df7ae97cefd826a60177f914d3af9a1b3ce3",
+ "blake2b256": "blake2b256:24d100501c0fabda8d8ad72181a30e56619a39c9be4f658f37e573d1be5bd901"
+ },
+ {
+ "index": 440,
+ "offset": 922746880,
+ "size": 2097152,
+ "path": "ownership.pk.part0440",
+ "sha256": "sha256:827f61b43c65cfded341fd9319b16c4a3b931a45db943f739fa42e587ca8135b",
+ "blake2b256": "blake2b256:9913411ecff917fe90c386ca9139fa6dcc9c499d6849e3e0460cd5e1181ba337"
+ },
+ {
+ "index": 441,
+ "offset": 924844032,
+ "size": 2097152,
+ "path": "ownership.pk.part0441",
+ "sha256": "sha256:75119b94c6a86e53ae0a74b4b8c28412afb798b30f946ffd16e88fdb222bed39",
+ "blake2b256": "blake2b256:452bdb9e3637fb1ad25c6cf0c95d2169d2adc1a33b6f359ccde31b3cf1e466ae"
+ },
+ {
+ "index": 442,
+ "offset": 926941184,
+ "size": 2097152,
+ "path": "ownership.pk.part0442",
+ "sha256": "sha256:0cb420425a7ce1251a47050397e70e42cca76a4f686a4abb60c11484403ca42e",
+ "blake2b256": "blake2b256:79b0863c19b1751b39bbf8b7edc953b278e7c0e12fa43ae6e798cd0192dd54c5"
+ },
+ {
+ "index": 443,
+ "offset": 929038336,
+ "size": 2097152,
+ "path": "ownership.pk.part0443",
+ "sha256": "sha256:23159b7cc31be6dcc130994d8e4cfe57b6f9a22317f9db6efc1cd6e5365aed45",
+ "blake2b256": "blake2b256:d3268811dd461f50ec93bc75d9587eef150f739088c5e07e757a05a67d18f067"
+ },
+ {
+ "index": 444,
+ "offset": 931135488,
+ "size": 2097152,
+ "path": "ownership.pk.part0444",
+ "sha256": "sha256:3913b9516f301abcb3759828612165c7761a10ec939fdd44c1c7b8b7fef3da2e",
+ "blake2b256": "blake2b256:09269a842b05fabc49f91d0016480de36ae4c4fe281e22c261588a8dc6e5dfd5"
+ },
+ {
+ "index": 445,
+ "offset": 933232640,
+ "size": 2097152,
+ "path": "ownership.pk.part0445",
+ "sha256": "sha256:d2d5159250884a35a7656e9eb49b8d2637f81873d09080e28208ee8a76b148a3",
+ "blake2b256": "blake2b256:847f04c428126b6226c4b121a8e91402a9c8f1fa61120a21194b3f45c0e04395"
+ },
+ {
+ "index": 446,
+ "offset": 935329792,
+ "size": 2097152,
+ "path": "ownership.pk.part0446",
+ "sha256": "sha256:cc156e3f79e1a1b4f3928dc86c206f6a634ada62b49147978f57d45025a22687",
+ "blake2b256": "blake2b256:f479b8d418be6b2c68d1366f0ced6cc7e623d8e63adaccb0396b2bff10e90bfc"
+ },
+ {
+ "index": 447,
+ "offset": 937426944,
+ "size": 2097152,
+ "path": "ownership.pk.part0447",
+ "sha256": "sha256:5eb27cf4265be94bb1d91c318c9a4f39536283ef296a732282a708a11fd4a6c8",
+ "blake2b256": "blake2b256:4e3b76473fd355eb40afd7d19d001af0316d970148122cea6359a51016b8785c"
+ },
+ {
+ "index": 448,
+ "offset": 939524096,
+ "size": 2097152,
+ "path": "ownership.pk.part0448",
+ "sha256": "sha256:bb16f94c0502ef73fea45d45bc890ac9567ac9f4e70d90e0af9ab02db49bf177",
+ "blake2b256": "blake2b256:2ed28fab033a62002a4fbe33ae463590ee3de90800be8a4d11fc702ea87680fc"
+ },
+ {
+ "index": 449,
+ "offset": 941621248,
+ "size": 2097152,
+ "path": "ownership.pk.part0449",
+ "sha256": "sha256:bbf4f6846d8010785a9b5dee83747ac14142c481c9386e3167ffe0449d07a3aa",
+ "blake2b256": "blake2b256:d97b67028e572d167fbeac1399a278acfa42fd3605dd0d9f44e279a03b7391d9"
+ },
+ {
+ "index": 450,
+ "offset": 943718400,
+ "size": 2097152,
+ "path": "ownership.pk.part0450",
+ "sha256": "sha256:ace01a14c2518ecbf5f1e9453c9c3101db05cf62ade829e49b7b4bcb53682ddd",
+ "blake2b256": "blake2b256:0454551165d9865f9e6cc2a986aafd44f5b866a58a6863f0fbb0d3214985625e"
+ },
+ {
+ "index": 451,
+ "offset": 945815552,
+ "size": 2097152,
+ "path": "ownership.pk.part0451",
+ "sha256": "sha256:13dc5c148eb6c5bb89b18e5a931470cc591c424865ff7d8932c28b42886a7160",
+ "blake2b256": "blake2b256:4b3a5368b7642c54357a38b6923f337035421c6e63b4eb84c81b445df1b5fb00"
+ },
+ {
+ "index": 452,
+ "offset": 947912704,
+ "size": 2097152,
+ "path": "ownership.pk.part0452",
+ "sha256": "sha256:7e2e685478ba31dac57850038ff7ce7b4c0772b220ec9fd2de547f477502d2c6",
+ "blake2b256": "blake2b256:399526a4a067f74f0b7fab213185936d60caa8ec839cbcb8e29616e4adf61e3e"
+ },
+ {
+ "index": 453,
+ "offset": 950009856,
+ "size": 2097152,
+ "path": "ownership.pk.part0453",
+ "sha256": "sha256:72331fd8c2ca422de74fde865953817d688a9bdfdbb838d0c32b622dac2b4f14",
+ "blake2b256": "blake2b256:10028210041a0dc4a962e0f695ea8a0ede621b3ea259d7112a1588c138c44cb9"
+ },
+ {
+ "index": 454,
+ "offset": 952107008,
+ "size": 2097152,
+ "path": "ownership.pk.part0454",
+ "sha256": "sha256:c9bd1fdc7c4f7867879ae731dcf9121bd9069bce33d47864bce3e550da40df9a",
+ "blake2b256": "blake2b256:4f85903f7d97db1e390cd9d269bc4adb846936cdca03f6fd337765834eb79af0"
+ },
+ {
+ "index": 455,
+ "offset": 954204160,
+ "size": 2097152,
+ "path": "ownership.pk.part0455",
+ "sha256": "sha256:54ddc5c7169ef8e45b70bce9208b52e724c823ffb6bdb95bc9bc3da748e10bed",
+ "blake2b256": "blake2b256:f7d1fd48ca3bee097d34654262b3e275591ede135e00469155e1bd3be4623a5b"
+ },
+ {
+ "index": 456,
+ "offset": 956301312,
+ "size": 2097152,
+ "path": "ownership.pk.part0456",
+ "sha256": "sha256:3e73e654d184151a2a350bc61e2bb1a5b9e90fa51d4e6c29e022c948adad5c3e",
+ "blake2b256": "blake2b256:49a18a913bb917c98a3759a025cfff19713a9c87ae999701c00ea8956db87ac4"
+ },
+ {
+ "index": 457,
+ "offset": 958398464,
+ "size": 2097152,
+ "path": "ownership.pk.part0457",
+ "sha256": "sha256:4c931b691f606d77db09a566130291789ff23248fbc68a21f43bfd0de59be2e0",
+ "blake2b256": "blake2b256:591821fc148ce271645278b473803e264b833b41d4774637629819f934a0edc2"
+ },
+ {
+ "index": 458,
+ "offset": 960495616,
+ "size": 2097152,
+ "path": "ownership.pk.part0458",
+ "sha256": "sha256:e21bb0f0a8370f64e4fa35a7c68e440476dc5274be3d7481b156f4aa0a947c4a",
+ "blake2b256": "blake2b256:4f92c80653add33fd8ae64921b4502ced326070354d2e4e13ee93aecf6704863"
+ },
+ {
+ "index": 459,
+ "offset": 962592768,
+ "size": 2097152,
+ "path": "ownership.pk.part0459",
+ "sha256": "sha256:6c367ed44d45663ae184b4c7ca3c6bc419fecbbb24edcac55ed9922b3758dd24",
+ "blake2b256": "blake2b256:9e3c1b562737b071cca60414e0beea4ad912a3dedc99ac6756d28d906c63706a"
+ },
+ {
+ "index": 460,
+ "offset": 964689920,
+ "size": 2097152,
+ "path": "ownership.pk.part0460",
+ "sha256": "sha256:0259b1cdf84f9797727b09988b256ebb3cba685e9e1746824b293bb6a692cc5c",
+ "blake2b256": "blake2b256:2fff89412d5b75a3481adc94b9e167f20fd9a644af5ac3fba0d30b3f4371d562"
+ },
+ {
+ "index": 461,
+ "offset": 966787072,
+ "size": 2097152,
+ "path": "ownership.pk.part0461",
+ "sha256": "sha256:14356234ae472d855f8d3e031dfc2cfbb544c312c21b3dc2111c3a2c1b926968",
+ "blake2b256": "blake2b256:b621957953a817dd5ef88ab5fcb0ad515d3a13dc701872758e6fc9bf847c856b"
+ },
+ {
+ "index": 462,
+ "offset": 968884224,
+ "size": 2097152,
+ "path": "ownership.pk.part0462",
+ "sha256": "sha256:334e311a669af1c0a4e3af07c6531c09188267847e49b9e3e1f5339fdb127e42",
+ "blake2b256": "blake2b256:bc7a2870f2d1a8bc8fd5f0b7ace8f3b6099029940dc578abc78624ae47ce20c4"
+ },
+ {
+ "index": 463,
+ "offset": 970981376,
+ "size": 2097152,
+ "path": "ownership.pk.part0463",
+ "sha256": "sha256:bfd5f782b7672fea3b0cb758ac97af3e37ffeae8c832b840d4e7b8100c0bd32e",
+ "blake2b256": "blake2b256:6fc3f763a31a630864e5a4bc7333d2d0169b71326b4801bb2240dd0d0b71185b"
+ },
+ {
+ "index": 464,
+ "offset": 973078528,
+ "size": 2097152,
+ "path": "ownership.pk.part0464",
+ "sha256": "sha256:64a292cc1cd91cd314e0a79912aac4967e7c88eacbb6960d35e5e83572abe227",
+ "blake2b256": "blake2b256:b3b74879e2d5b5d5ec5f45ffbec4619d8f53fad917bafb88432e33ed05cae925"
+ },
+ {
+ "index": 465,
+ "offset": 975175680,
+ "size": 2097152,
+ "path": "ownership.pk.part0465",
+ "sha256": "sha256:bbeecab69609ab7e2ec5c003d9096902ced893569153898f3782e0a552450aaa",
+ "blake2b256": "blake2b256:7eb81e225474fc714588cb071a53fef6560c9f49529e4df128f2979edcb84a87"
+ },
+ {
+ "index": 466,
+ "offset": 977272832,
+ "size": 2097152,
+ "path": "ownership.pk.part0466",
+ "sha256": "sha256:d3ce15833a96c9f2041b316c36db66fb50b03735fc43b0347fca4904432e4d9e",
+ "blake2b256": "blake2b256:3f4a8b8c90e69ad86d157a5e623b74a030e243387ee39b86566730181fd15e07"
+ },
+ {
+ "index": 467,
+ "offset": 979369984,
+ "size": 2097152,
+ "path": "ownership.pk.part0467",
+ "sha256": "sha256:c33e89ff6d203fc8eb9c186769a4bfef3ad69d426ce964528f8dd7925a8485ad",
+ "blake2b256": "blake2b256:a447f470aa1b83a82fc10fca8bd92880f0715bf1b53f1a0f9a330373824a9f85"
+ },
+ {
+ "index": 468,
+ "offset": 981467136,
+ "size": 2097152,
+ "path": "ownership.pk.part0468",
+ "sha256": "sha256:bec2ce07996de78e2583734ec9541bca1df63dc5d16c466a788e38a49e398641",
+ "blake2b256": "blake2b256:a38312c0dd9b276df131c7c67509eeb64a9e50c35f18718fd0dd66102980b76a"
+ },
+ {
+ "index": 469,
+ "offset": 983564288,
+ "size": 2097152,
+ "path": "ownership.pk.part0469",
+ "sha256": "sha256:01c6b19b283302c2cb23efe05e47248d0dd1a8b2d7fac7ce3a9bf2e75306c53c",
+ "blake2b256": "blake2b256:61e34575e96f8178300f438ead4da0bfe86cec6c579fd03f22eef2ba47a1d129"
+ },
+ {
+ "index": 470,
+ "offset": 985661440,
+ "size": 2097152,
+ "path": "ownership.pk.part0470",
+ "sha256": "sha256:d794196c02606afe7743ebf7b0fb1e68faaab1c2d4f521721c81cc670fc9a7cb",
+ "blake2b256": "blake2b256:9dd1fc81a250566b7c67f06ea4fd0142bb2b5698ac40936a89045987c4e6fbdc"
+ },
+ {
+ "index": 471,
+ "offset": 987758592,
+ "size": 2097152,
+ "path": "ownership.pk.part0471",
+ "sha256": "sha256:ac8d7c27541fce556bdb89f88106886b0401b2137a897d1869885282cbf08a6e",
+ "blake2b256": "blake2b256:e98a615be7defff5ec9d6aed41ef586fabc908fbc409c833f532d67cb7840d93"
+ },
+ {
+ "index": 472,
+ "offset": 989855744,
+ "size": 2097152,
+ "path": "ownership.pk.part0472",
+ "sha256": "sha256:a97fce5d7b9ba4429f10c6e963bd32486f9aedeadf3e76f30958b660004f8106",
+ "blake2b256": "blake2b256:8c04591afbaf1674ddaa35f153b9e920bb729fa51892f516691cb84d87a26d50"
+ },
+ {
+ "index": 473,
+ "offset": 991952896,
+ "size": 2097152,
+ "path": "ownership.pk.part0473",
+ "sha256": "sha256:ad920813d02e19b2b3770ff9284806ffe95b72fa73f2f58f92cb57940c27d1fc",
+ "blake2b256": "blake2b256:6cfeb4edd7a56fd94cfa7d7a3cdd611381ec7dd4193b526ec88fc98b595bd516"
+ },
+ {
+ "index": 474,
+ "offset": 994050048,
+ "size": 2097152,
+ "path": "ownership.pk.part0474",
+ "sha256": "sha256:fb0b7227efea6ff88c0ea243bfa45f5f4a3e499e1fe1a08dc47a37b64409ab6a",
+ "blake2b256": "blake2b256:5d0801efe056bf094c7a5d80d57ff4f1db1e47126f05d47dc991d79bfad29c7c"
+ },
+ {
+ "index": 475,
+ "offset": 996147200,
+ "size": 2097152,
+ "path": "ownership.pk.part0475",
+ "sha256": "sha256:73636c10e5cbe89c75f236fc1186800c64a4fe8a4862f8cad4c1cd13fdc47286",
+ "blake2b256": "blake2b256:0b7d0e38afc736f0a140f579ac1a5f0ab4372577cce6fe9025fdec3e891539d8"
+ },
+ {
+ "index": 476,
+ "offset": 998244352,
+ "size": 2097152,
+ "path": "ownership.pk.part0476",
+ "sha256": "sha256:11e1d8a0c49a86f0d0b02e9f900a771d3888b8392086d24ccb337bdef56856bd",
+ "blake2b256": "blake2b256:70e93a553c7de95ee45207f263ebe8eebac1a83d20753c66ddb68b9a4598e683"
+ },
+ {
+ "index": 477,
+ "offset": 1000341504,
+ "size": 2097152,
+ "path": "ownership.pk.part0477",
+ "sha256": "sha256:19114a6a91c1a2fcb42d15c8ab2f7171bd8e9371077656dd62c37d2e0f7ed3de",
+ "blake2b256": "blake2b256:1ac2695fd9f1c61fbc6d3e9d394057a6860a72f3d628b8d9d3a5ae69583296f8"
+ },
+ {
+ "index": 478,
+ "offset": 1002438656,
+ "size": 2097152,
+ "path": "ownership.pk.part0478",
+ "sha256": "sha256:f7beb8c35a67026a5e5cbdcb4860cf08aae7516b60e8593eb27754060b85451f",
+ "blake2b256": "blake2b256:2338ee94bb2028fd3d47e40f43a86636d7aaded2837c7ad33a5881007bbef1a7"
+ },
+ {
+ "index": 479,
+ "offset": 1004535808,
+ "size": 2097152,
+ "path": "ownership.pk.part0479",
+ "sha256": "sha256:f8f9e3c6797a1532da99804935c6a783d908550f9d85df37470188f4bc744d0a",
+ "blake2b256": "blake2b256:73fd6543cd4bb9a5cd13c7cd6dd918cdc2c445ae768b37985c506f0c95d6d0d4"
+ },
+ {
+ "index": 480,
+ "offset": 1006632960,
+ "size": 2097152,
+ "path": "ownership.pk.part0480",
+ "sha256": "sha256:9b9003594e69a106d1aae0d9f9776da6be7875c0f391b62042bc7542d9f90308",
+ "blake2b256": "blake2b256:444cd09bd8597c8cb6521d0acfb74dae59346b4485b700b5a576ace8b6e78e62"
+ },
+ {
+ "index": 481,
+ "offset": 1008730112,
+ "size": 2097152,
+ "path": "ownership.pk.part0481",
+ "sha256": "sha256:b4976893ae5bf294d903302e826bc3467ce8ec351e02f552f0d48ed421a40295",
+ "blake2b256": "blake2b256:57b2b8fc1fc5553698f346e07be873da7d8e33606ca6aabe261d43d9d924a816"
+ },
+ {
+ "index": 482,
+ "offset": 1010827264,
+ "size": 2097152,
+ "path": "ownership.pk.part0482",
+ "sha256": "sha256:fa41d653b78e97b4b05959af10d9dfe5894fcff2042e0a7101d3bf5165cb096e",
+ "blake2b256": "blake2b256:20c2ab9f6876924f9f19caf5f1b6b8dbfcbd8e820a10d99d01061b70a8e7362a"
+ },
+ {
+ "index": 483,
+ "offset": 1012924416,
+ "size": 2097152,
+ "path": "ownership.pk.part0483",
+ "sha256": "sha256:75763825ac216ea043ebaea1eec82225a852c7b1f302e06152bf578771e8135a",
+ "blake2b256": "blake2b256:1dae67cc45a16f9e32ef9280cc4a733628e4f15505c5bdca0d63e3a469bd1792"
+ },
+ {
+ "index": 484,
+ "offset": 1015021568,
+ "size": 2097152,
+ "path": "ownership.pk.part0484",
+ "sha256": "sha256:d505adab9b347f837c51a8ed8ddf6780d29e9a0825a188062d67d879ce8924d5",
+ "blake2b256": "blake2b256:dad145b5b1186d28978c8e4efe30840095dc792c2e6853f9981c39f25b7a16fb"
+ },
+ {
+ "index": 485,
+ "offset": 1017118720,
+ "size": 2097152,
+ "path": "ownership.pk.part0485",
+ "sha256": "sha256:458402b42420b539f4fada1bb652eb433ee9512cfa3e35a9e4ee08265c0aee32",
+ "blake2b256": "blake2b256:ed65ee9aa3eb2767d6ba15866c5dcd9a5c12f2e207e2456abc231d6e504b228b"
+ },
+ {
+ "index": 486,
+ "offset": 1019215872,
+ "size": 2097152,
+ "path": "ownership.pk.part0486",
+ "sha256": "sha256:7ffdf394ffac5b49df93baa2e31a8c9557e7673a34a76a151fa2e98801e026c8",
+ "blake2b256": "blake2b256:a633ed989a5c9fd83419305a873d9f68c55d6c335eb247fc35ccb1d2f286532e"
+ },
+ {
+ "index": 487,
+ "offset": 1021313024,
+ "size": 2097152,
+ "path": "ownership.pk.part0487",
+ "sha256": "sha256:6ab020428f2a0b73c72191195d1849d8ffa9e21c1d35809ab92b7a1b13abe881",
+ "blake2b256": "blake2b256:cfd214f2ff80dae476e36771006c8b04b844c3993ca0645d5196b8e7eeab5245"
+ },
+ {
+ "index": 488,
+ "offset": 1023410176,
+ "size": 2097152,
+ "path": "ownership.pk.part0488",
+ "sha256": "sha256:95da4ecbcccd84d6cb139fd7bd1d22db97d3d2a357df7395e8fbdd359c5477c8",
+ "blake2b256": "blake2b256:d5ee1d6a620d4169d3561c44bbc34c02b2bef8b8f52d6c2a5f47b2b7c0c325bf"
+ },
+ {
+ "index": 489,
+ "offset": 1025507328,
+ "size": 2097152,
+ "path": "ownership.pk.part0489",
+ "sha256": "sha256:09fdc277ad97948ef9e74f1bc698472ce7369807520d81f03c0ae0026bf24d7a",
+ "blake2b256": "blake2b256:dc3cc2386007f4fb17b8e08c9fe6a0114bf05dc68aa69fd24fb556eef4094f57"
+ },
+ {
+ "index": 490,
+ "offset": 1027604480,
+ "size": 2097152,
+ "path": "ownership.pk.part0490",
+ "sha256": "sha256:fd4a597c2ed4c4609aa62def8b2d314c0b4920a0536c89681487bd207686547c",
+ "blake2b256": "blake2b256:94b30dffd9c22095465d3cae78690f581ac910d60bcf0b376df5fd61761289ff"
+ },
+ {
+ "index": 491,
+ "offset": 1029701632,
+ "size": 2097152,
+ "path": "ownership.pk.part0491",
+ "sha256": "sha256:4cf85e0c4f30770f94574f6c8cbbd0cbd2035b392654d378fabad468689efebf",
+ "blake2b256": "blake2b256:32e7aea30e48a89ad678692a8c540793f958681f8f6de28fdd54d1a36983d373"
+ },
+ {
+ "index": 492,
+ "offset": 1031798784,
+ "size": 2097152,
+ "path": "ownership.pk.part0492",
+ "sha256": "sha256:b37d995535eedfe3a866d8ff3120dc11a4531677e47128cd14cd4da20e7a7d14",
+ "blake2b256": "blake2b256:882cc5d8ebdeb65c6d74d4d5508b107be82677392396564ad464b25d19534199"
+ },
+ {
+ "index": 493,
+ "offset": 1033895936,
+ "size": 2097152,
+ "path": "ownership.pk.part0493",
+ "sha256": "sha256:46b39c93b1264f88f2a9ec4aeb6e63fd2a2667451c1c4d3320697e828dceab4a",
+ "blake2b256": "blake2b256:85338e31c0f5a0a532cef6897d2a43851a65aba28eaac7a980b224c05d2b9470"
+ },
+ {
+ "index": 494,
+ "offset": 1035993088,
+ "size": 2097152,
+ "path": "ownership.pk.part0494",
+ "sha256": "sha256:c4260a245b7ecd16cec8574264785d2186d5c3f7563857568f57fed7bfe322b1",
+ "blake2b256": "blake2b256:34325f4c98f6021f3f5b2b258d5954af0a1f72f96a6194ecbfee0e7d987ec745"
+ },
+ {
+ "index": 495,
+ "offset": 1038090240,
+ "size": 2097152,
+ "path": "ownership.pk.part0495",
+ "sha256": "sha256:6a6f8ea5ce10fcb8e68dacf9e2162e6eeaaad44937eb31bee220fe2684fc7e65",
+ "blake2b256": "blake2b256:7f452c5abec4f388507bbb7a257815d2bd97ceac6bbd8c5c9e05655c18d22be7"
+ },
+ {
+ "index": 496,
+ "offset": 1040187392,
+ "size": 2097152,
+ "path": "ownership.pk.part0496",
+ "sha256": "sha256:f1b4eeddf1a1f1333783728ae280d6b83520dce523a1f390557b4eaebf41c668",
+ "blake2b256": "blake2b256:73f2bb7fd03b48202d73475faaf919aa1f2733eef42cf4b6d03b5edb9df167c0"
+ },
+ {
+ "index": 497,
+ "offset": 1042284544,
+ "size": 2097152,
+ "path": "ownership.pk.part0497",
+ "sha256": "sha256:94d815b9ab8dac183c6161d43514604604caf696d29dfe64c8841c0961b23f37",
+ "blake2b256": "blake2b256:ac01de666cd3d1c59c5d70513434076e0a288b1bc7ea238268541c02788a5892"
+ },
+ {
+ "index": 498,
+ "offset": 1044381696,
+ "size": 2097152,
+ "path": "ownership.pk.part0498",
+ "sha256": "sha256:8e32f6efbec09781c727fdd8b6ba26be33d8ac9531c812c9e259de809bb287de",
+ "blake2b256": "blake2b256:e61af1223fa2e3c98c48b47440efec12d7d080a3fb001f978ecc239198d97ad6"
+ },
+ {
+ "index": 499,
+ "offset": 1046478848,
+ "size": 2097152,
+ "path": "ownership.pk.part0499",
+ "sha256": "sha256:e560d0bd4e8258b2d4e5316284a07a217a8bb208614a6f7da2bc5cbba2e73633",
+ "blake2b256": "blake2b256:b0d1280082c776141873608b9098108e3cdfc98c6e98f49de69893b44d8f18d6"
+ },
+ {
+ "index": 500,
+ "offset": 1048576000,
+ "size": 2097152,
+ "path": "ownership.pk.part0500",
+ "sha256": "sha256:c55cf7927f6a8d9f3224951ee7f5b45c5509e30fccc658d529a0104bc46679f1",
+ "blake2b256": "blake2b256:f87cd2dc55854443c1fb5ca73681681049980494edbe4e2bf8f4dccd9602d9a8"
+ },
+ {
+ "index": 501,
+ "offset": 1050673152,
+ "size": 2097152,
+ "path": "ownership.pk.part0501",
+ "sha256": "sha256:47873eb68a5d4fd846ef86e65ad957c7ae86a58d9ca0027bc19b8c857fb2029d",
+ "blake2b256": "blake2b256:9f328b11bb63b04754d4c01c1354dde69c9d2455afb718c8e043857e480dabd3"
+ },
+ {
+ "index": 502,
+ "offset": 1052770304,
+ "size": 2097152,
+ "path": "ownership.pk.part0502",
+ "sha256": "sha256:ffda0e555c2a82ac8f94102a05646d9de6c7e6eabd93d37ede7f83d4166d911f",
+ "blake2b256": "blake2b256:c2a6d4698508d4d88579e2660cd057fe9778f60757a7322fea04c119552004fe"
+ },
+ {
+ "index": 503,
+ "offset": 1054867456,
+ "size": 2097152,
+ "path": "ownership.pk.part0503",
+ "sha256": "sha256:f0c8629f07f236837e9e59d368a63300e53f5b2376d20a3ce6622131786c6811",
+ "blake2b256": "blake2b256:672befb142dc2386fd2bf6f5ba64198547bb0bc54a501f0795cfa75a7a45cb5e"
+ },
+ {
+ "index": 504,
+ "offset": 1056964608,
+ "size": 2097152,
+ "path": "ownership.pk.part0504",
+ "sha256": "sha256:f34340382bf951ef43121fc117e4e173f8411d4e0d4595f2ed2ee01f8eea6f5b",
+ "blake2b256": "blake2b256:d16a3d1b4073c817bdca269b4c6de82b14390d34a8fedf7580b4b0ab3d55de74"
+ },
+ {
+ "index": 505,
+ "offset": 1059061760,
+ "size": 2097152,
+ "path": "ownership.pk.part0505",
+ "sha256": "sha256:5d4eaebc7a75bb6ff9b71ab7c742ba4300e293deaf63a7b69c3eb2826aee1c59",
+ "blake2b256": "blake2b256:343b95c448fa89393510c2a9013a69ad570feee791d78449e7b02d6c45c60a1a"
+ },
+ {
+ "index": 506,
+ "offset": 1061158912,
+ "size": 2097152,
+ "path": "ownership.pk.part0506",
+ "sha256": "sha256:cbbd4ea8b851214764217abf92314f0092369d3dd52993dee70146461ed2879f",
+ "blake2b256": "blake2b256:87d7a7671edbb291f7e4a04dd5267c0675ad29ae23a35d5400f7709601d8d1a1"
+ },
+ {
+ "index": 507,
+ "offset": 1063256064,
+ "size": 2097152,
+ "path": "ownership.pk.part0507",
+ "sha256": "sha256:e7974e37280f9acf80ee53f1da28d7a7249c8b0564031320d830ce54fc722731",
+ "blake2b256": "blake2b256:3ae992cd3291070c2775808d3539a14fb1f869a02e941d5dcfc6e5201ca5818f"
+ },
+ {
+ "index": 508,
+ "offset": 1065353216,
+ "size": 2097152,
+ "path": "ownership.pk.part0508",
+ "sha256": "sha256:bd9a5d1e6126d020cff0ef105fc4df08cc61d6e731ead0459fe86785a4ee19cd",
+ "blake2b256": "blake2b256:f9f90533097146648061822a15d5a90bd474d9dc59c1dbb662a598122be40c89"
+ },
+ {
+ "index": 509,
+ "offset": 1067450368,
+ "size": 2097152,
+ "path": "ownership.pk.part0509",
+ "sha256": "sha256:e38866d959e2c56979c34dacf580754e9c1314c81c511d6f0c7238df19e7c0fd",
+ "blake2b256": "blake2b256:8951abb7aebe8016b48cc518de6c7a73023bbb21e1c7180ddf77bfc635501e2e"
+ },
+ {
+ "index": 510,
+ "offset": 1069547520,
+ "size": 2097152,
+ "path": "ownership.pk.part0510",
+ "sha256": "sha256:49e7c4f9e10ec1c6b9c4d3fa45f7dfa6fee0634ec2b7019050dd672e257337cf",
+ "blake2b256": "blake2b256:189f95b75cd52ca7eddf5ec9f02d67f2a156703533069f6e8de66b0bca5eb5f8"
+ },
+ {
+ "index": 511,
+ "offset": 1071644672,
+ "size": 2097152,
+ "path": "ownership.pk.part0511",
+ "sha256": "sha256:b44af2ddb5cc63aa090326613d7be4a16ccde3e376e8c8e90dc0ead437023e09",
+ "blake2b256": "blake2b256:7df5ef6e588d9109c47ae52de70e650348b83b29a107024b4dae8f2d0791432b"
+ },
+ {
+ "index": 512,
+ "offset": 1073741824,
+ "size": 2097152,
+ "path": "ownership.pk.part0512",
+ "sha256": "sha256:42c0fcdb7701de4549470dd7b3acf7b70c00ef51dcb6bf7927856b7a05e092c7",
+ "blake2b256": "blake2b256:aa8a6d6e0c3631097a4cae899e1a387b464fc8c5277e83a64e9b5ccf428fdfb5"
+ },
+ {
+ "index": 513,
+ "offset": 1075838976,
+ "size": 2097152,
+ "path": "ownership.pk.part0513",
+ "sha256": "sha256:01e83defebcb6c5fdcdba4d2a04926dbc69c8a4658e2fdd5341eda1067ef4fce",
+ "blake2b256": "blake2b256:e03231ef2f11047c98a194f5766bfd82c0e49dfbf65dd0f01df328b2383034b6"
+ },
+ {
+ "index": 514,
+ "offset": 1077936128,
+ "size": 2097152,
+ "path": "ownership.pk.part0514",
+ "sha256": "sha256:37ce42f4d29d98f005342c3ba66304ac9d75d37b5a763c1e60010af6c2c1f6c8",
+ "blake2b256": "blake2b256:e3fed53c1450eb0ef085744b0e516c03f1de82113a738f690a465486dead2d2a"
+ },
+ {
+ "index": 515,
+ "offset": 1080033280,
+ "size": 2097152,
+ "path": "ownership.pk.part0515",
+ "sha256": "sha256:62ebe08ac3405300e6874a439250ab6120f98f163e0b82b549ff068e1e7c6ec2",
+ "blake2b256": "blake2b256:99f92d9c508107b4981a75767e4d1a00ff02cf91fd76ca118b4819311eb49c9b"
+ },
+ {
+ "index": 516,
+ "offset": 1082130432,
+ "size": 2097152,
+ "path": "ownership.pk.part0516",
+ "sha256": "sha256:469de0d6af089e9ca504bb3aa1de1ee14ea1409e2406454fc1def748b3595ac7",
+ "blake2b256": "blake2b256:9d925332c43633da07c5f6ade050cc6a5cfdf319e2ed6cb2629add0da66e4bc4"
+ },
+ {
+ "index": 517,
+ "offset": 1084227584,
+ "size": 2097152,
+ "path": "ownership.pk.part0517",
+ "sha256": "sha256:d09187a77280f82172986c4ccad9fc84239199b6e0d355e6790382e83ccfbb2d",
+ "blake2b256": "blake2b256:d19989475b3131479fa6f461a5b9f7f0c76eda661c63f733302da3131f8bcd11"
+ },
+ {
+ "index": 518,
+ "offset": 1086324736,
+ "size": 2097152,
+ "path": "ownership.pk.part0518",
+ "sha256": "sha256:57461c79fb0b3acc757b2d30af130ead3322d39599ba85375662145ea1862d67",
+ "blake2b256": "blake2b256:e242ea54ee5fc53eb881ec8970905b65f525a64ae6a3311663801d956090e318"
+ },
+ {
+ "index": 519,
+ "offset": 1088421888,
+ "size": 2097152,
+ "path": "ownership.pk.part0519",
+ "sha256": "sha256:fe171d5632f71827e452112f5eb30565574254bdcfff2850430ea5a120fe4f41",
+ "blake2b256": "blake2b256:eacf9742abfcc404118b115d9d222ad1231cf037b11cb3d38433c1ba7d440889"
+ },
+ {
+ "index": 520,
+ "offset": 1090519040,
+ "size": 2097152,
+ "path": "ownership.pk.part0520",
+ "sha256": "sha256:f30268c031bb8a670ba4c333ed6c2d871b2d6a97e1cca1eb36f63cc2287072af",
+ "blake2b256": "blake2b256:b3aa015d1d773e7e6acf1eda6c54f169073543f2e9cc2484350640c5623a4980"
+ },
+ {
+ "index": 521,
+ "offset": 1092616192,
+ "size": 2097152,
+ "path": "ownership.pk.part0521",
+ "sha256": "sha256:4239b41d864e74036be1654e3e0f1be4ed9332c1b19943912564271496f777f2",
+ "blake2b256": "blake2b256:f662132a84d8b4f05758df4fcc1651f8c5a60ae0d8fa385092f8c9c183232fbd"
+ },
+ {
+ "index": 522,
+ "offset": 1094713344,
+ "size": 2097152,
+ "path": "ownership.pk.part0522",
+ "sha256": "sha256:278cbc96d19ceed9b10b8d495a1172c5680e8959d8991810ed43da01909af3f6",
+ "blake2b256": "blake2b256:d720c6a11ee6c9edbda56c7baf3dfbe6b864f36e5d6704b6b6d56dc55e5b47b9"
+ },
+ {
+ "index": 523,
+ "offset": 1096810496,
+ "size": 2097152,
+ "path": "ownership.pk.part0523",
+ "sha256": "sha256:6feb0ae1619e0f569820197ad9585b227981d6779391e4fa074f43b33a8fc5d0",
+ "blake2b256": "blake2b256:eb54dd078dacbcd5fb1401b866ea705ad2c159abc73aafc5b77f7b2e8bd08bad"
+ },
+ {
+ "index": 524,
+ "offset": 1098907648,
+ "size": 2097152,
+ "path": "ownership.pk.part0524",
+ "sha256": "sha256:22f101535fbeed586c20849f553734c406e0c7ec9accbb2aaa36877a31aa6439",
+ "blake2b256": "blake2b256:ac12921dd57f695459fff48cfd1b6bd7389f6ca72c24bf227755601770987579"
+ },
+ {
+ "index": 525,
+ "offset": 1101004800,
+ "size": 2097152,
+ "path": "ownership.pk.part0525",
+ "sha256": "sha256:633a63ffa3df213fbe3b28dc251fa92ecb87c34400f890682dc8f44c1090cf57",
+ "blake2b256": "blake2b256:c6d5ee9b88a267006cade4de545a99fe2f6c87254599d12376edf9652f0da767"
+ },
+ {
+ "index": 526,
+ "offset": 1103101952,
+ "size": 2097152,
+ "path": "ownership.pk.part0526",
+ "sha256": "sha256:5c91a2e0678c2e7afc78333e5bef7b449446e2e024fd4928d02dd4d4df39046b",
+ "blake2b256": "blake2b256:f7a2bc6c0d889bec921dc38d2832e321c7c118c4460f6fcd1b6eee48483bf38c"
+ },
+ {
+ "index": 527,
+ "offset": 1105199104,
+ "size": 2097152,
+ "path": "ownership.pk.part0527",
+ "sha256": "sha256:bfc8c582585f7f4b29cbb95c129512e12d51f7b107e6b7b2f42b0b9617dc9937",
+ "blake2b256": "blake2b256:99daf31bbe6a0e929750a685fd3871a620183ab4bd5d1696e77e87e905ba772a"
+ },
+ {
+ "index": 528,
+ "offset": 1107296256,
+ "size": 2097152,
+ "path": "ownership.pk.part0528",
+ "sha256": "sha256:569dc7d7f13e283b09cc0c50477f5a57c9e31e08aa352bd5ce3d9314d83fb9b6",
+ "blake2b256": "blake2b256:ec139387bd0574e5e4520fc4e42bd0accf335e3a14b3a815ab55d34e04ada3ac"
+ },
+ {
+ "index": 529,
+ "offset": 1109393408,
+ "size": 2097152,
+ "path": "ownership.pk.part0529",
+ "sha256": "sha256:5241d76228e30c9032461f1ac4ae5b7caf443f16268377e8fdac0f5fc591ebf6",
+ "blake2b256": "blake2b256:8bac30b307dcb3fd904815df5f7bb5102c579b0720f209e4c3dcd377f8db4486"
+ },
+ {
+ "index": 530,
+ "offset": 1111490560,
+ "size": 2097152,
+ "path": "ownership.pk.part0530",
+ "sha256": "sha256:90e2770071bfb314582d54b810a9f81579a0833c0d3c0e64c012fa565bd59ac7",
+ "blake2b256": "blake2b256:65a3594a1365bb54adf1d0c7612497da277b2fc0711600e126537eeb0ec57e34"
+ },
+ {
+ "index": 531,
+ "offset": 1113587712,
+ "size": 2097152,
+ "path": "ownership.pk.part0531",
+ "sha256": "sha256:70717a7cab3f84951e4691749136bf3675baa1343497e9acd9db5cbfab4c1eb7",
+ "blake2b256": "blake2b256:72a5e9bbf694eb15734b76f8b956da96212109fec7555b282c94eaa70a1917d5"
+ },
+ {
+ "index": 532,
+ "offset": 1115684864,
+ "size": 2097152,
+ "path": "ownership.pk.part0532",
+ "sha256": "sha256:5ec9e8f076137bdaf9795336d97d515aa0dada9de5c318f8f4761fad04590678",
+ "blake2b256": "blake2b256:414680ce23595e85a55a41be00f59924931fb00ecfe66c6efe7080de31ce8adf"
+ },
+ {
+ "index": 533,
+ "offset": 1117782016,
+ "size": 2097152,
+ "path": "ownership.pk.part0533",
+ "sha256": "sha256:fadf724cd958585ef1fee9450b9340eb66442b23c860c1a09cdf53e18a3d9dfc",
+ "blake2b256": "blake2b256:9744f6807bfb0d4694f82fd01e8d526c30dc82a7d4f4e08d8518ba9c5d48b9a3"
+ },
+ {
+ "index": 534,
+ "offset": 1119879168,
+ "size": 2097152,
+ "path": "ownership.pk.part0534",
+ "sha256": "sha256:b1db04338f1f37aa4b02ae7274f689e224b884f58bae2be5fa8d787f40a17048",
+ "blake2b256": "blake2b256:dfd553e0e1039a5ecf168edd0faef7d125ce221f680653127572d1d11f9fc03f"
+ },
+ {
+ "index": 535,
+ "offset": 1121976320,
+ "size": 2097152,
+ "path": "ownership.pk.part0535",
+ "sha256": "sha256:77db8973740ea2908730b8c8172fc43c641fc08bbd2ba115783107f062f1939f",
+ "blake2b256": "blake2b256:4196018fc7f5a02cfb4f727dc185a9b7bb1d6f850046463a8920c29be5a3b68d"
+ },
+ {
+ "index": 536,
+ "offset": 1124073472,
+ "size": 2097152,
+ "path": "ownership.pk.part0536",
+ "sha256": "sha256:e701352e813ab40df1607e7cec8f42b057957fd83cbb6af5ac7fa2ebca95dba1",
+ "blake2b256": "blake2b256:05487acc9c23ae8444bcc64e96c783ccac57c7091b71e43ea2e7572f1e41252a"
+ },
+ {
+ "index": 537,
+ "offset": 1126170624,
+ "size": 2097152,
+ "path": "ownership.pk.part0537",
+ "sha256": "sha256:8e68c5d2fe91a066f53ac5aa697bd4dcac7379ee8d472602d35d8856f80b5990",
+ "blake2b256": "blake2b256:4c5e240fe1befed24e27ae662ca6f3685547111a585f7b95e768595879aea359"
+ },
+ {
+ "index": 538,
+ "offset": 1128267776,
+ "size": 2097152,
+ "path": "ownership.pk.part0538",
+ "sha256": "sha256:d9b7a9d2e56c5c20724ca1414864027045a57fde01e374148110d3d3ea04ca3b",
+ "blake2b256": "blake2b256:af46aa59ede9dc33249b9b76fbf5da5f9dcb66614427c0b0a9482362f93e18dc"
+ },
+ {
+ "index": 539,
+ "offset": 1130364928,
+ "size": 2097152,
+ "path": "ownership.pk.part0539",
+ "sha256": "sha256:1ced8bb7f909f0b77828a5b68d37b7bd91deed2c8d69734cdef656d0de172687",
+ "blake2b256": "blake2b256:677a3f29609c7965c3260d9c36bf5e63d0400997231660699780f2632a0240b5"
+ },
+ {
+ "index": 540,
+ "offset": 1132462080,
+ "size": 2097152,
+ "path": "ownership.pk.part0540",
+ "sha256": "sha256:fee5e4039907c7ea20f6bc79d5607877cca021ff508456e3d79cffe08eec03d0",
+ "blake2b256": "blake2b256:675312211b9148268f53f84b3a67dddcddbd0bf0da180bd9fe6496702ba1a880"
+ },
+ {
+ "index": 541,
+ "offset": 1134559232,
+ "size": 2097152,
+ "path": "ownership.pk.part0541",
+ "sha256": "sha256:2269da67c2ca572387f514d93ab92854d80a1274a3c8fe7490c27edc2455203a",
+ "blake2b256": "blake2b256:b6939f6b7f68aee70f2631045e58cebb2c2ed8a64b97fb6e595bc75e53433424"
+ },
+ {
+ "index": 542,
+ "offset": 1136656384,
+ "size": 2097152,
+ "path": "ownership.pk.part0542",
+ "sha256": "sha256:dccaa7614d5eb350dce51ed8e48f36b7fbd1f3565f9dba4c4231c5b31f6624b5",
+ "blake2b256": "blake2b256:64c97216c9e1c8a504cd0aed8d6ed3d03b032511203500779725fb06121b943c"
+ },
+ {
+ "index": 543,
+ "offset": 1138753536,
+ "size": 2097152,
+ "path": "ownership.pk.part0543",
+ "sha256": "sha256:c47fa5063d41383dbb6f27feab41b5d4968e0a7963dc780e0d42ef1932e0a058",
+ "blake2b256": "blake2b256:d69cc4556ffb0e87f705ddd605bdefcae4264b8df8c3e0fc14b9d2c333a76f9a"
+ },
+ {
+ "index": 544,
+ "offset": 1140850688,
+ "size": 2097152,
+ "path": "ownership.pk.part0544",
+ "sha256": "sha256:731ce940467ca3f91d6283d96bf391c1384d9dedca748b85e6890ddb37812a00",
+ "blake2b256": "blake2b256:cacd0ac3b46d4483d85f6f08960ef2256725b3afeb1e377a3a4026266870b6d1"
+ },
+ {
+ "index": 545,
+ "offset": 1142947840,
+ "size": 2097152,
+ "path": "ownership.pk.part0545",
+ "sha256": "sha256:36fd0eefd6fcaaac286d22ab16f155809800be37134abc36076d3f564b8734cf",
+ "blake2b256": "blake2b256:9e53632ac62fd16c6ab5c1d1e682994c799af763edf53fb844ab2e2a0a759bf3"
+ },
+ {
+ "index": 546,
+ "offset": 1145044992,
+ "size": 2097152,
+ "path": "ownership.pk.part0546",
+ "sha256": "sha256:66de586b9710045830c03bb4a646c701f512d5a6b97607e3aa4feeffd28d2169",
+ "blake2b256": "blake2b256:9fdcdffb261c9eabf7da1ad661b2a8f92f97bf3885a7656d8e343ab6e380d1b6"
+ },
+ {
+ "index": 547,
+ "offset": 1147142144,
+ "size": 2097152,
+ "path": "ownership.pk.part0547",
+ "sha256": "sha256:34601762d800d0d997643230c4fa89fbcf25b40eec81b4258c147b2e4cbe8c31",
+ "blake2b256": "blake2b256:6059a270ba0df890c9d821e051ff6539f9ce432ab712c41eb9e5bf072ce3dfe1"
+ },
+ {
+ "index": 548,
+ "offset": 1149239296,
+ "size": 2097152,
+ "path": "ownership.pk.part0548",
+ "sha256": "sha256:f14f6324c172c0a9a701dce567755d649c98c7730d5c409c05e663bc98fc424d",
+ "blake2b256": "blake2b256:7a125c8338fe1a93e9a3f2b2219e386c5d1bc147825cba9482ec6e1adf733dd0"
+ },
+ {
+ "index": 549,
+ "offset": 1151336448,
+ "size": 2097152,
+ "path": "ownership.pk.part0549",
+ "sha256": "sha256:0eb2cd2e7d7d3a5bec769ccb44723d4b461afb9cb34aec803941e78a7303744d",
+ "blake2b256": "blake2b256:0eb02ae6b7656f64fe8ae036683c58de6f575c7ad7675145a229c22751fa76bb"
+ },
+ {
+ "index": 550,
+ "offset": 1153433600,
+ "size": 2097152,
+ "path": "ownership.pk.part0550",
+ "sha256": "sha256:7b311363ed1a9ba3476241e91a17f96d3920897758e0ef25e479047a9201ca22",
+ "blake2b256": "blake2b256:5429e5db6fc9704777be0ec02c7d60f100177203ddbd8b7c26e4ac4a675545cb"
+ },
+ {
+ "index": 551,
+ "offset": 1155530752,
+ "size": 2097152,
+ "path": "ownership.pk.part0551",
+ "sha256": "sha256:63cdc8040b8c74989761c9e779911099968c8a4e6af30cd9a7c860ba2477bdcf",
+ "blake2b256": "blake2b256:9563866acec868c13b19f46904246334a872af1ce1a82256e77958a9cae97dd5"
+ },
+ {
+ "index": 552,
+ "offset": 1157627904,
+ "size": 2097152,
+ "path": "ownership.pk.part0552",
+ "sha256": "sha256:ed3a70c0637bbdbfb2c8bc6c2eae8db34249dc1029a9eb638fe75b6324545491",
+ "blake2b256": "blake2b256:37db785da6e7267394572fe043c655428f3bf0e3999b1739f14046139363d282"
+ },
+ {
+ "index": 553,
+ "offset": 1159725056,
+ "size": 2097152,
+ "path": "ownership.pk.part0553",
+ "sha256": "sha256:19135f4ff4171e6bcc87c5457fdf4d9fc47851b485b5a5c35c36979d0067e097",
+ "blake2b256": "blake2b256:76a05955a17d92f3a65e68da7a5f2957844fd22f09a1141e96a0bc7e320db19f"
+ },
+ {
+ "index": 554,
+ "offset": 1161822208,
+ "size": 2097152,
+ "path": "ownership.pk.part0554",
+ "sha256": "sha256:f4ab5df0cd50cf1d7d8e4dcba86998832e03ab094a7434b7681d637ffee3dc48",
+ "blake2b256": "blake2b256:27b91d5651d9884f9140ac56adbd021e5cf100e5cf086acd59530e71a75f23b8"
+ },
+ {
+ "index": 555,
+ "offset": 1163919360,
+ "size": 2097152,
+ "path": "ownership.pk.part0555",
+ "sha256": "sha256:f61b89514f239e515d861f2ce02850b504214d8c13649749e133cfb16083fcd9",
+ "blake2b256": "blake2b256:a7d440ce21298894bac39bcced54b05601634ed799987a9bd24866780d5fea19"
+ },
+ {
+ "index": 556,
+ "offset": 1166016512,
+ "size": 2097152,
+ "path": "ownership.pk.part0556",
+ "sha256": "sha256:ba9fcc8c480700ecea87afc8488b48d224810f51bff2b7127215170a68e3dc6b",
+ "blake2b256": "blake2b256:75ddc98bf8e92bc24bb14d7db2a583a73794855173c177dae3f6042fb3af2493"
+ },
+ {
+ "index": 557,
+ "offset": 1168113664,
+ "size": 2097152,
+ "path": "ownership.pk.part0557",
+ "sha256": "sha256:312dc901c29bb962350b3ca742ab2e2ed07f9d72dbd9bd2621a823a7702dd40a",
+ "blake2b256": "blake2b256:da48084a45491db6c049d68fec9e8a83ccc3126ec6fe871df6694175164c8dea"
+ },
+ {
+ "index": 558,
+ "offset": 1170210816,
+ "size": 2097152,
+ "path": "ownership.pk.part0558",
+ "sha256": "sha256:babf6affdc9c5ef8b2d96c61a9f0e8300ce8d0ba83abea735ad6ea3ecff12232",
+ "blake2b256": "blake2b256:2a0bd8f2ea3393ebabc667593eef1e2ce640e9ad253b52482e368cacaea22469"
+ },
+ {
+ "index": 559,
+ "offset": 1172307968,
+ "size": 2097152,
+ "path": "ownership.pk.part0559",
+ "sha256": "sha256:9c6ada69b33d7d36f7713a0ddbf1843e8ef2ee6287ac02b75ee18f5279d8cad1",
+ "blake2b256": "blake2b256:71eeef0ed06b5eb747194db8ab65d808669959fe8f42075586fbe4e5c4f14ffc"
+ },
+ {
+ "index": 560,
+ "offset": 1174405120,
+ "size": 2097152,
+ "path": "ownership.pk.part0560",
+ "sha256": "sha256:f5cccf35c60a068560ae0c6334cd71a6e56054812568435efc15720716ca9ea6",
+ "blake2b256": "blake2b256:5ea7a1127dc6149f8165d94297d7661c46a0a2d779aa867e9bd53cfb2eec4a40"
+ },
+ {
+ "index": 561,
+ "offset": 1176502272,
+ "size": 2097152,
+ "path": "ownership.pk.part0561",
+ "sha256": "sha256:eade51b1c22ec7b224bb22ae3347246b9651a584c6fe22fbf4f9f7227458e250",
+ "blake2b256": "blake2b256:84927292897aef79ca3832b12806f76fa5b0367b5b303c040d190309585e3f8a"
+ },
+ {
+ "index": 562,
+ "offset": 1178599424,
+ "size": 2097152,
+ "path": "ownership.pk.part0562",
+ "sha256": "sha256:ca17af22066f0d6691f3013b56e82820323207febec27822d64648915a7e66ce",
+ "blake2b256": "blake2b256:24d30b20adac8bd0c5965eac2c5619c531329f402a02f519926430f0f792ea2d"
+ },
+ {
+ "index": 563,
+ "offset": 1180696576,
+ "size": 2097152,
+ "path": "ownership.pk.part0563",
+ "sha256": "sha256:bdd9e21e66527e61df76b2f1fa65a7c823d63c7e5d8623b08f9828132bab235d",
+ "blake2b256": "blake2b256:e70de067c87204e2b05666ff89fa85d18a61b455dbbcb55406def37c6627f29e"
+ },
+ {
+ "index": 564,
+ "offset": 1182793728,
+ "size": 2097152,
+ "path": "ownership.pk.part0564",
+ "sha256": "sha256:cc4d9a99ff38b2676f05a1220b3cce3c3a07d31554e1d4648d3cd343d47c38af",
+ "blake2b256": "blake2b256:64997bd643a5d7498db281cdd803453512816c6fb9dff47fd48540204db3f15c"
+ },
+ {
+ "index": 565,
+ "offset": 1184890880,
+ "size": 2097152,
+ "path": "ownership.pk.part0565",
+ "sha256": "sha256:51450346111cb4162a202d712bb039ae388798bca982eefd7981e78033d08371",
+ "blake2b256": "blake2b256:c2ee970ccf0bb2802f314902122aef80ad9faea20a14e83b92cfec3f69b942dd"
+ },
+ {
+ "index": 566,
+ "offset": 1186988032,
+ "size": 2097152,
+ "path": "ownership.pk.part0566",
+ "sha256": "sha256:46d5d23f780cd891e74099d20a5b985785ec1ade6a0e410f1e8261b247a6476c",
+ "blake2b256": "blake2b256:6a52bcdcfedf8a798cff1a848a8b5a8a5b13fdf5d97e5fd50290ff6e2ebece60"
+ },
+ {
+ "index": 567,
+ "offset": 1189085184,
+ "size": 2097152,
+ "path": "ownership.pk.part0567",
+ "sha256": "sha256:5e604842b880fc8046a014bf80b931382c003f3fd2209613455d02936bc53407",
+ "blake2b256": "blake2b256:b24c7553ad101384a85bd09981b13398717fffc8d2c6847e2bdf81d3453cf613"
+ },
+ {
+ "index": 568,
+ "offset": 1191182336,
+ "size": 2097152,
+ "path": "ownership.pk.part0568",
+ "sha256": "sha256:4310c19033615eb555ca994c452d6b20b55e3d13da20e2e31860b8f18ebac059",
+ "blake2b256": "blake2b256:07e333dd63a9246498457cf57596d38168c4d62e84c416544a78fa6e4dc3982d"
+ },
+ {
+ "index": 569,
+ "offset": 1193279488,
+ "size": 2097152,
+ "path": "ownership.pk.part0569",
+ "sha256": "sha256:32507e5a734fb3e67f8f9d736209a6117eb2cae6618aace82c0dad3785696dfc",
+ "blake2b256": "blake2b256:b2fc8a9afac54aa1b90fa94442b4e94cd674039d6861f63f360549b49eb67c92"
+ },
+ {
+ "index": 570,
+ "offset": 1195376640,
+ "size": 2097152,
+ "path": "ownership.pk.part0570",
+ "sha256": "sha256:4c06e8f06f76a2679c6ac73f3c6a1ba766d695b1b3656920c6f2d6c1bcc9f819",
+ "blake2b256": "blake2b256:da12f8ec4d4f9af90e7241b37e5dcda23e92acf828b21b2941d8879107656165"
+ },
+ {
+ "index": 571,
+ "offset": 1197473792,
+ "size": 2097152,
+ "path": "ownership.pk.part0571",
+ "sha256": "sha256:606b42f2480656a758dace38fd5cf04169bd71c8f97bcf404bafc07b30b44ff5",
+ "blake2b256": "blake2b256:e75256ae828efb69343feb939377956f4ba9d0f5352ca15d6b41dafcd8ab40dc"
+ },
+ {
+ "index": 572,
+ "offset": 1199570944,
+ "size": 2097152,
+ "path": "ownership.pk.part0572",
+ "sha256": "sha256:c997f9b97c839f549c4a8637b5fa8181b735bf8a555847b9d5fc54695f15c825",
+ "blake2b256": "blake2b256:d465a1055efdd1f7fa1be2b0c2fd4102a210bbd29443fd957f5edf0d88785b72"
+ },
+ {
+ "index": 573,
+ "offset": 1201668096,
+ "size": 2097152,
+ "path": "ownership.pk.part0573",
+ "sha256": "sha256:0c1a5d1c06f8c5ed605da547f6c6eeca3883be931b94282f817a2eff34335cc8",
+ "blake2b256": "blake2b256:9ef6847cbe3ca922343799d6c88ff6e09e50cf0319b656250c1b00e134ec8df2"
+ },
+ {
+ "index": 574,
+ "offset": 1203765248,
+ "size": 2097152,
+ "path": "ownership.pk.part0574",
+ "sha256": "sha256:e6193ba831ded0ecb4cc7f87ebb78a94b0cb46b5a9257697ad900f9a92ba7def",
+ "blake2b256": "blake2b256:9a3145f3aba475df9aa8ae2a2103e6eefa5358a09f2551be17afc349f6e6b4b2"
+ },
+ {
+ "index": 575,
+ "offset": 1205862400,
+ "size": 2097152,
+ "path": "ownership.pk.part0575",
+ "sha256": "sha256:7900d918d8bd6cad2918b3656eb3a092c7d3f296409bd48614ba22ad12b2e95b",
+ "blake2b256": "blake2b256:e11d1bacb2e665ba7ad02821ff121f800a03d1239e88f0a03e122429fbabc74e"
+ },
+ {
+ "index": 576,
+ "offset": 1207959552,
+ "size": 2097152,
+ "path": "ownership.pk.part0576",
+ "sha256": "sha256:0d565446f8f3e0a7034c076e1e3988800ca0426946ba83bc00530bbd9afee332",
+ "blake2b256": "blake2b256:b15d91c7e4d97dd9790a9602db088a5298cee6b862fd4ef14aa6d85c3314dd49"
+ },
+ {
+ "index": 577,
+ "offset": 1210056704,
+ "size": 2097152,
+ "path": "ownership.pk.part0577",
+ "sha256": "sha256:e48d69d159db2ed35ddc532be2d06fe77ee34786f8a609b9564eed0252b0efc7",
+ "blake2b256": "blake2b256:da2b8970214f2becea5b786ed93203fafe1d3f81c764005d3b1e8961d40644aa"
+ },
+ {
+ "index": 578,
+ "offset": 1212153856,
+ "size": 2097152,
+ "path": "ownership.pk.part0578",
+ "sha256": "sha256:8fa1c3a7be0ae9357833f7d02cd328203994c9cb6fbbb6d5c361e29782d94751",
+ "blake2b256": "blake2b256:ad8594deb2910002230f2243eb6b08fb66381aeaf31af5c01a39ba1b600ad210"
+ },
+ {
+ "index": 579,
+ "offset": 1214251008,
+ "size": 2097152,
+ "path": "ownership.pk.part0579",
+ "sha256": "sha256:5277c303bc46b41be79e86fae0ee58b420f109f525b2072cec64fd42cbcdd907",
+ "blake2b256": "blake2b256:b72d7da0ad4da175b2d3af1d70063fe2f2507e1d6bc57f165c0775096d865ccb"
+ },
+ {
+ "index": 580,
+ "offset": 1216348160,
+ "size": 2097152,
+ "path": "ownership.pk.part0580",
+ "sha256": "sha256:0010fbddc787d51ddf64635a93ffa07fb76d00abb20a3128f8702f25107b5aff",
+ "blake2b256": "blake2b256:d8f4e0dcb6842e3b562ebbd23e157af4dc2158333625cba6809dec69e7f1bae7"
+ },
+ {
+ "index": 581,
+ "offset": 1218445312,
+ "size": 2097152,
+ "path": "ownership.pk.part0581",
+ "sha256": "sha256:ce20b336f61014920ab72787d41983c9e505ee3de4e4608e6ad20cdf7c49ae30",
+ "blake2b256": "blake2b256:328b5e459ce47233e938251957786248b2cdded93626544c894f90c5b413d8bd"
+ },
+ {
+ "index": 582,
+ "offset": 1220542464,
+ "size": 2097152,
+ "path": "ownership.pk.part0582",
+ "sha256": "sha256:6a2cd2d3dc4e14c255cf338dcda31f15c7ec9bc74a11c8832573fc253c965da8",
+ "blake2b256": "blake2b256:635c7a569ac43840dd585f56273cf69b093fb614b255b99b6aeb95276c9843c1"
+ },
+ {
+ "index": 583,
+ "offset": 1222639616,
+ "size": 2097152,
+ "path": "ownership.pk.part0583",
+ "sha256": "sha256:322803a4b4495d86af38574f56492fe79d5347f25f63976139dade68c98d2fd3",
+ "blake2b256": "blake2b256:1282def9e2b55ea4b98310deee2342cf2eeb1f11698a9377a4918a5a716e9acd"
+ },
+ {
+ "index": 584,
+ "offset": 1224736768,
+ "size": 2097152,
+ "path": "ownership.pk.part0584",
+ "sha256": "sha256:1ad939b489bdc08d156ceb97a0a080d3b6ccdff7f41bab48e977f9746e82daa9",
+ "blake2b256": "blake2b256:64eeb6ca89ac290097c869b3f2eab045d8438b9bc56cf3af44c2268c117b8d5b"
+ },
+ {
+ "index": 585,
+ "offset": 1226833920,
+ "size": 2097152,
+ "path": "ownership.pk.part0585",
+ "sha256": "sha256:155c540411626fdae070e4da17a37e5e9b3ae7df692dbeaf976f7bd63352dfee",
+ "blake2b256": "blake2b256:d8f623e809201c2176a297a356b19db80572b208d6956327889d725489633be5"
+ },
+ {
+ "index": 586,
+ "offset": 1228931072,
+ "size": 2097152,
+ "path": "ownership.pk.part0586",
+ "sha256": "sha256:72805b9b95948fd795a330f2dd5bd193781ab09ecd01afe741e13855faac906d",
+ "blake2b256": "blake2b256:1a2c2c4a3c459148eb6fcdde59d479ec9c2d7330f90ce04d0d43871199f3b19b"
+ },
+ {
+ "index": 587,
+ "offset": 1231028224,
+ "size": 2097152,
+ "path": "ownership.pk.part0587",
+ "sha256": "sha256:3e302b27cc9883e125175c151bcf98496ff55f1c7ff8ddc89866791ed7e137df",
+ "blake2b256": "blake2b256:ac9388c7b6b9b61b499aa183f80fd10932f515f3eb4b04bfb1b057a708a6d198"
+ },
+ {
+ "index": 588,
+ "offset": 1233125376,
+ "size": 2097152,
+ "path": "ownership.pk.part0588",
+ "sha256": "sha256:9c5faf2d2ccb530cd3030d1535308af5b81fea09e6292af89963f5d5851e509f",
+ "blake2b256": "blake2b256:697643ce721c95d75971703dc9bd693ad143bb4314396d6c09542705199e744f"
+ },
+ {
+ "index": 589,
+ "offset": 1235222528,
+ "size": 2097152,
+ "path": "ownership.pk.part0589",
+ "sha256": "sha256:ba8c4f4c17f73ccca05997c236010ab45ad2ae7729494e8e38753a8d13feafd0",
+ "blake2b256": "blake2b256:038d8a5213e8f900d2cb9463da67c6b3aa949fba090cfaed2d84796ef8cc409b"
+ },
+ {
+ "index": 590,
+ "offset": 1237319680,
+ "size": 2097152,
+ "path": "ownership.pk.part0590",
+ "sha256": "sha256:065a21eb1ca80bc518d6458f5842dd303b5a90980e22b4a96375921067e825c1",
+ "blake2b256": "blake2b256:75126875e8e9683f51241b538aefeab3a5eeef56d98ec3473e5112345b1dfb5c"
+ },
+ {
+ "index": 591,
+ "offset": 1239416832,
+ "size": 2097152,
+ "path": "ownership.pk.part0591",
+ "sha256": "sha256:fb1020ca42fa7e16b9f8a71d41036f76df72f465b453c8ae8d23ce8cc709cde0",
+ "blake2b256": "blake2b256:625a8c68bab2c68250e3d864b999cf6c7bc38a25f3fb4487d1bd9f463496e897"
+ },
+ {
+ "index": 592,
+ "offset": 1241513984,
+ "size": 2097152,
+ "path": "ownership.pk.part0592",
+ "sha256": "sha256:8a9555f094e6dd0acfedfcab0a90674caf5b8aebd25e9ec2c3bfdbc3175d6ce2",
+ "blake2b256": "blake2b256:64542e2c9a7a0393dbc9c5a244c15ea0b85d8152319b473e0e9b92672e85eff9"
+ },
+ {
+ "index": 593,
+ "offset": 1243611136,
+ "size": 2097152,
+ "path": "ownership.pk.part0593",
+ "sha256": "sha256:2c1806406a42a43c7901d8a0bf6312adeb625d6cc8f4503bb556ce69b2845c94",
+ "blake2b256": "blake2b256:d272d1c91240765ff754564c927d47395d42c37658d5f0bbfd271e3b9495ab49"
+ },
+ {
+ "index": 594,
+ "offset": 1245708288,
+ "size": 2097152,
+ "path": "ownership.pk.part0594",
+ "sha256": "sha256:ed17483c22a568deb1fb560bc52bdbf9cad31996435b57647afd9a2b6705e855",
+ "blake2b256": "blake2b256:81e2816618032e7b6b8564ce72ba2a1e01c7b353d02ab725f891b9d328e03072"
+ },
+ {
+ "index": 595,
+ "offset": 1247805440,
+ "size": 2097152,
+ "path": "ownership.pk.part0595",
+ "sha256": "sha256:00518d5115002a84a7b7d2ab017150ca7bb42dc4e5503449238f1a2634ab7843",
+ "blake2b256": "blake2b256:f53b5ef12624824bd62321dbff93cc46af4c643ec127d283683ec54ef5bf3708"
+ },
+ {
+ "index": 596,
+ "offset": 1249902592,
+ "size": 2097152,
+ "path": "ownership.pk.part0596",
+ "sha256": "sha256:37ec0dea5ed5855df1edb79b1c2fa40d3731a927ea31cff954597af44b4a019f",
+ "blake2b256": "blake2b256:8354fd2ee8741c2df07b5ffcdff1dd810a6cc3a6d15ddd5bc710eb8618b0fe2f"
+ },
+ {
+ "index": 597,
+ "offset": 1251999744,
+ "size": 2097152,
+ "path": "ownership.pk.part0597",
+ "sha256": "sha256:71f8de9b132d155269e90cd833abacc929582f4a72650dd417b178807754c908",
+ "blake2b256": "blake2b256:7220412c1965660e845ec440c90c9b996f4f3968501962da612e176db3479b76"
+ },
+ {
+ "index": 598,
+ "offset": 1254096896,
+ "size": 2097152,
+ "path": "ownership.pk.part0598",
+ "sha256": "sha256:4c9135e07f63ab3cb9c21a1ee0e307b5d0622b86319559f79892a7a5f43b78fb",
+ "blake2b256": "blake2b256:5ecd527f8e9285b7e1e8ae9d5482871cc26191062f1f771acbbac9f5884440b7"
+ },
+ {
+ "index": 599,
+ "offset": 1256194048,
+ "size": 2097152,
+ "path": "ownership.pk.part0599",
+ "sha256": "sha256:ea6e2f5f67e843c4fba509361062ddd6196dd8dd8915177cbe52a59a23329b26",
+ "blake2b256": "blake2b256:399b7ae9ada3f6aa65fb9f1bb77e56ace01678628b410e97ec83c407d1c17df8"
+ },
+ {
+ "index": 600,
+ "offset": 1258291200,
+ "size": 2097152,
+ "path": "ownership.pk.part0600",
+ "sha256": "sha256:c26cf5e6e3f82d853078b606a59d19a513cec9e02114461ef891d17b068d93af",
+ "blake2b256": "blake2b256:fe53488a05b8c2892d453ffd29804a0f0da659a327c0d91ce9ace30d27b3d733"
+ },
+ {
+ "index": 601,
+ "offset": 1260388352,
+ "size": 2097152,
+ "path": "ownership.pk.part0601",
+ "sha256": "sha256:0b53dcf681d32154f86851e792d26b5e6c5c1fbf5b6fb4ab50937f3e90110150",
+ "blake2b256": "blake2b256:60d4c2c9109e830686906c3429520416f1b9bdb1f421c7f9d29f00cc53027ff6"
+ },
+ {
+ "index": 602,
+ "offset": 1262485504,
+ "size": 2097152,
+ "path": "ownership.pk.part0602",
+ "sha256": "sha256:a08f776bb4c1c4287f25d5d5bc1fdf023924e8e89fa1b475e662c5ddc3518a56",
+ "blake2b256": "blake2b256:bc3c22297527a3ce51fd71ac34c72d4c7fc16431ccb710d2c14f2419ff076bfc"
+ },
+ {
+ "index": 603,
+ "offset": 1264582656,
+ "size": 2097152,
+ "path": "ownership.pk.part0603",
+ "sha256": "sha256:55aadf654444707e592629ff0960692e4f8b452c143527be593157c1f0f8bfd9",
+ "blake2b256": "blake2b256:06ea2d7f8052b85b947bf2d400dcb00f0232c8502b10b8a6171c7fe9f59cd99a"
+ },
+ {
+ "index": 604,
+ "offset": 1266679808,
+ "size": 2097152,
+ "path": "ownership.pk.part0604",
+ "sha256": "sha256:725e9b3b9d595caa4de4f55bfa3fb328569bdbe9354a3e531c59cc5306f53084",
+ "blake2b256": "blake2b256:fed4960faebda71eedbc0064f64ce2025dad318c523fcefc97a1a21da4887f48"
+ },
+ {
+ "index": 605,
+ "offset": 1268776960,
+ "size": 2097152,
+ "path": "ownership.pk.part0605",
+ "sha256": "sha256:594fe330fb19fae0d77a9e9b10f25a6192919a9c90bdc61012b9eb2baf0e5479",
+ "blake2b256": "blake2b256:f56db035fac7a981ca3cab5f932641f6f0d9d8a803c5e5ee72e348f5b1855fb8"
+ },
+ {
+ "index": 606,
+ "offset": 1270874112,
+ "size": 2097152,
+ "path": "ownership.pk.part0606",
+ "sha256": "sha256:d4e81e0bd9e3d11cc6de33dc3488b3630072cf2a2069dd1706786ec8762a7f20",
+ "blake2b256": "blake2b256:682a7b8c2c8261643ccc8156e24716ffb4bd3086d736ebaae210a0853cc93c8d"
+ },
+ {
+ "index": 607,
+ "offset": 1272971264,
+ "size": 2097152,
+ "path": "ownership.pk.part0607",
+ "sha256": "sha256:66f07c53297e56ee7c9078cb249aa6f58bb8e58994fc8498b07947e11c9c26a5",
+ "blake2b256": "blake2b256:65902056cf20532e54223758dfc921521a31de17e1c7b1c5a35d2d3c18bb21c9"
+ },
+ {
+ "index": 608,
+ "offset": 1275068416,
+ "size": 2097152,
+ "path": "ownership.pk.part0608",
+ "sha256": "sha256:6ebc3a28b146d15b206b22b755a58a4f50d43806f15581d0169609e22401b4cf",
+ "blake2b256": "blake2b256:0ea3b20a3fdfa3607af8f338d97a0071239c0065b32a0429c9e0a4739821b207"
+ },
+ {
+ "index": 609,
+ "offset": 1277165568,
+ "size": 2097152,
+ "path": "ownership.pk.part0609",
+ "sha256": "sha256:79c66d3648d4b31bbf282718b90e674e956d40552df4a123c397207672c7fb0a",
+ "blake2b256": "blake2b256:25a719116462ad2e052a6a885f686442629977364f3d1a727ec67b718acac7b6"
+ },
+ {
+ "index": 610,
+ "offset": 1279262720,
+ "size": 2097152,
+ "path": "ownership.pk.part0610",
+ "sha256": "sha256:732cd0899492c3759647e8086a50c2164e618e82807dc5a19c131bf053771112",
+ "blake2b256": "blake2b256:aaa18af8bc78b751645d861e9b21689dc711b3d9feb77d6f65c31dd15fe5702d"
+ },
+ {
+ "index": 611,
+ "offset": 1281359872,
+ "size": 2097152,
+ "path": "ownership.pk.part0611",
+ "sha256": "sha256:0a595c721887261feb145815f6a34f6bbf0daa566e5f37e19d00c4dc449dbdd7",
+ "blake2b256": "blake2b256:300970edfceb7a5943547f4b94d49426f689731265d1ce07c240def22b133915"
+ },
+ {
+ "index": 612,
+ "offset": 1283457024,
+ "size": 2097152,
+ "path": "ownership.pk.part0612",
+ "sha256": "sha256:8c9c0e53605048a66d67381aaa01c3c89d6f30cd93e5db70ba0a9614e195001b",
+ "blake2b256": "blake2b256:d3213f8cc740b33387cef088e3f3dc6566a9468c057f5f0ef4a52a74a19291d4"
+ },
+ {
+ "index": 613,
+ "offset": 1285554176,
+ "size": 2097152,
+ "path": "ownership.pk.part0613",
+ "sha256": "sha256:8bf851a4fea6b724cf4a08745a58ffade14c84a5c9f49c998ba3f53f12aef32b",
+ "blake2b256": "blake2b256:5eaffa637730cee8949f832038dfe85a5290db8c847bde3280fca728c84e5cbb"
+ },
+ {
+ "index": 614,
+ "offset": 1287651328,
+ "size": 1055805,
+ "path": "ownership.pk.part0614",
+ "sha256": "sha256:143a13aca4f283322edb9ff2a1dc415756ed4a069bf5b7f6476896c40b8e96ba",
+ "blake2b256": "blake2b256:1dd945da35d4b8e889df92e43bcebee3fc0f22c613ede3dcfc173aafb7b9502e"
+ }
+ ]
+ },
+ "proving_key_index": {
+ "schema": "proof-tool-proving-key-index-v1",
+ "file_size": 1288707133,
+ "sha256": "sha256:c549d8f5033ffe1834567b6ecc3712874c7136bc721fee7da9d52bf1624f5dd2",
+ "blake2b256": "blake2b256:ce3774af5d7228440a9bb48c86ad4606f41e462a74322bfeb8f6e878319b0e07",
+ "sections": [
+ {
+ "name": "A",
+ "offset": 461,
+ "len": 225909504,
+ "elem_size": 96
+ },
+ {
+ "name": "B",
+ "offset": 225909969,
+ "len": 163189056,
+ "elem_size": 96
+ },
+ {
+ "name": "Basis",
+ "offset": 1037543673,
+ "len": 125581728,
+ "elem_size": 96
+ },
+ {
+ "name": "BasisExpSigma",
+ "offset": 1163125405,
+ "len": 125581728,
+ "elem_size": 96
+ },
+ {
+ "name": "G2B",
+ "offset": 706138557,
+ "len": 326378112,
+ "elem_size": 192
+ },
+ {
+ "name": "K",
+ "offset": 590425529,
+ "len": 115712640,
+ "elem_size": 96
+ },
+ {
+ "name": "Z",
+ "offset": 389099029,
+ "len": 201326496,
+ "elem_size": 96
+ }
+ ]
+ },
+ "assets": {
+ "msmworker.wasm": {
+ "path": "msmworker.wasm",
+ "size": 11141058,
+ "sha256": "sha256:f94900e5e684854f4db1a44c2b0dfb09572b74d80f3031d49e9c778ee1190225",
+ "blake2b256": "blake2b256:084f31fa51c61dc25a208645ce22e5880c8d6ea2e9d0cb65ceb0174cb3a67a25"
+ },
+ "ownership-destination.ccs": {
+ "path": "ownership-destination.ccs",
+ "size": 129221468,
+ "sha256": "sha256:b5e629f47321048a6e2f85b3a839c1cf898454b69eef582f54e07d6d647074dc",
+ "blake2b256": "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352"
+ },
+ "ownership.vk": {
+ "path": "ownership.vk",
+ "size": 784,
+ "sha256": "sha256:6484b03a5aafa96859be256484d84bebab0d6051f04373e416d2d2189e7fdec4",
+ "blake2b256": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80"
+ },
+ "proof-destination.wasm": {
+ "path": "proof-destination.wasm",
+ "size": 22882453,
+ "sha256": "sha256:3ad5f0ab4b9bbef21c3530a5eb09278823b4e348bc2a2e3e31f8d71c2ba56d4e",
+ "blake2b256": "blake2b256:107406db64de119b9547f100979d9b2c493d43edae45f438a76f5e32c89f7491"
+ },
+ "worker.js": {
+ "path": "worker.js",
+ "size": 22008,
+ "sha256": "sha256:743e73bfd3a2fa48301c5cd63a7e9e1c3258143041aad00a4d70e1787b78cee0",
+ "blake2b256": "blake2b256:4cc299e2e1abd240390c17e703da77d31c251a0a121203425abf872ecad015f6"
+ }
+ }
+}
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.sig b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.sig
new file mode 100644
index 00000000..c1178724
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.sig
@@ -0,0 +1 @@
+8fdc39de1dd9302a9035cd644f86d2a9d198a955998b79a29ee7fb1f343fb58d2c8d69b54b9c9965be985ade844995d0114508bee1c9fdd53aa82b70f800d701
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest-public-key.hex b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest-public-key.hex
new file mode 100644
index 00000000..99633a54
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest-public-key.hex
@@ -0,0 +1 @@
+2af3b300b9e641ede236d4b7d48b43eccfb843ffa9aca74abb38f98e7211eccb
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.json
new file mode 100644
index 00000000..dd692d32
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.json
@@ -0,0 +1,20 @@
+{
+ "schema": "proof-tool-key-manifest-v1",
+ "key_version": "ownership-destination-v2",
+ "circuit_id": "root-ownership-destination-v2/bls12-381/groth16",
+ "curve": "BLS12-381",
+ "backend": "groth16",
+ "vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "proving_key_sha256": "sha256:3e8a88b48ce1604697f705480196e54e1b87728637ccc6806e1888b7c8a114d6",
+ "proving_key_blake2b256": "blake2b256:24c153e5f8ee606b87ffb7cd752c37953d7fe9e69e996dac80c2a804aa2724ac",
+ "proving_key_size": 1288707133,
+ "verifying_key_sha256": "sha256:6484b03a5aafa96859be256484d84bebab0d6051f04373e416d2d2189e7fdec4",
+ "verifying_key_size": 784,
+ "constraint_system_hash": "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352",
+ "circuit_source_commit": "9fac96bc0669285433ca51e62873b1ab1fa274de",
+ "proof_tool_version": "0.1.0",
+ "gnark_version": "v0.15.0",
+ "setup_transcript_hash": "blake2b256:5c23a38efe7f8662cfd120d11dddcdbba2c4ad4598526d6a9ec9279e13e29ecf",
+ "published_at": "2026-07-12T13:24:53Z",
+ "signature_key_id": "preprod-local-destination-v2-9fac96b-g3a"
+}
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.sig b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.sig
new file mode 100644
index 00000000..2d3a1abf
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.sig
@@ -0,0 +1 @@
+a5287d07a090c89f74e4f5e8eda917351ce7559d622e46f11cf877a5a83f2e06f2e6c94e0dcd182aa21e048dc0a94b908e1e14477a8843b0851aa5ab587bbf06
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.pk.idx.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.pk.idx.json
new file mode 100644
index 00000000..0e970981
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.pk.idx.json
@@ -0,0 +1,52 @@
+{
+ "sections": {
+ "A": {
+ "name": "A",
+ "offset": 461,
+ "len": 225909504,
+ "elem_size": 96
+ },
+ "B": {
+ "name": "B",
+ "offset": 225909969,
+ "len": 163189056,
+ "elem_size": 96
+ },
+ "Basis": {
+ "name": "Basis",
+ "offset": 1037543673,
+ "len": 125581728,
+ "elem_size": 96
+ },
+ "BasisExpSigma": {
+ "name": "BasisExpSigma",
+ "offset": 1163125405,
+ "len": 125581728,
+ "elem_size": 96
+ },
+ "G2B": {
+ "name": "G2B",
+ "offset": 706138557,
+ "len": 326378112,
+ "elem_size": 192
+ },
+ "K": {
+ "name": "K",
+ "offset": 590425529,
+ "len": 115712640,
+ "elem_size": 96
+ },
+ "Z": {
+ "name": "Z",
+ "offset": 389099029,
+ "len": 201326496,
+ "elem_size": 96
+ }
+ },
+ "domain_cardinality": 2097152,
+ "nb_wires": 2513486,
+ "nb_infinity_a": 160262,
+ "nb_infinity_b": 813600,
+ "nb_commitment_keys": 1,
+ "file_size": 1288707133
+}
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.vk b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.vk
new file mode 100644
index 00000000..15292403
Binary files /dev/null and b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.vk differ
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/reclaim-deployment.json
new file mode 100644
index 00000000..b6edb218
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/reclaim-deployment.json
@@ -0,0 +1,112 @@
+{
+ "schema": "proof-tool-reclaim-deployment-v1",
+ "deployment_id": "preprod:744cc4718e8149201c7e9cb3d3a550f34cb18dfc8076a33172d9354d:fccccbc8ab525c9da8d8ae334398f590459c3a3c",
+ "network": "Preprod",
+ "network_id": 0,
+ "source_commit": "fccccbc8ab525c9da8d8ae334398f590459c3a3c",
+ "contract_version": "ownership-verifier-0.1.0.0",
+ "reclaim_base": {
+ "address": "addr_test1wp6ye3r336q5jgqu06wt85a92re5evvdljq8dge3wtvn2ngfs6j3w",
+ "script_hash": "744cc4718e8149201c7e9cb3d3a550f34cb18dfc8076a33172d9354d",
+ "required_global_credential": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8"
+ },
+ "reclaim_global": {
+ "script_hash": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
+ "rewarding_credential": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
+ "params_currency_symbol": "d6777b8c3be1c6c0c9baba52a880c1980a662c16ffc0885ecaa03119",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
+ "proof_profile": "single-destination",
+ "proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
+ "batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
+ },
+ "params_utxo": {
+ "tx_hash": "c8d6d3b6ddd1a8aa43ee039acb54a79a4bb427f4bbacd95085754b09ecfada2f",
+ "output_index": 0,
+ "policy_id": "d6777b8c3be1c6c0c9baba52a880c1980a662c16ffc0885ecaa03119",
+ "token_name": "5245434c41494d504152414d53",
+ "holder_address": "addr_test1wr4mrzsjwa6pquu0m6480mq06kpxsht80d4nfh56dcak6lsejdm28",
+ "datum_reclaim_base_script_hash": "744cc4718e8149201c7e9cb3d3a550f34cb18dfc8076a33172d9354d"
+ },
+ "proof": {
+ "circuit_id": "root-ownership-destination-v2/bls12-381/groth16",
+ "key_version": "ownership-destination-v2",
+ "destination_address_encoding": "destination-address-v1",
+ "vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "cardano_vk_blake2b256": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
+ "browser_proving": {
+ "enabled": true,
+ "runtime_base_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime",
+ "runtime_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/runtime-manifest.json",
+ "prover_worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/prover-worker.js",
+ "wasm_exec_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/wasm_exec.js",
+ "manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.json",
+ "manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/manifest.sig",
+ "manifest_public_key_hex": "2af3b300b9e641ede236d4b7d48b43eccfb843ffa9aca74abb38f98e7211eccb",
+ "chunk_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.json",
+ "chunk_manifest_sig_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/chunk-manifest.sig",
+ "chunk_manifest_public_key_hex": "e20b0fb38fb6dc0a66284a8f3a6e8d05bf55b8e966d86f53b77d284b524463d6",
+ "deployment_manifest_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/reclaim-deployment.json",
+ "vk_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.vk",
+ "pk_url": "https://proof-assets.reclaim-proof.com/proof-assets/preprod-9fac96b-g3a/ownership.pk",
+ "pk_index_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/assets/ownership.pk.idx.json",
+ "ccs_url": "https://proof-assets-2m.reclaim-proof.com/proof-assets/preprod-9fac96b-g3a-pk2m-r1/ownership-destination.ccs",
+ "ccs_blake2b256": "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352",
+ "proof_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/proof-destination.wasm",
+ "worker_js_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msm-worker.js",
+ "msm_worker_wasm_url": "/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msmworker.wasm",
+ "tuning": {
+ "shard_count": 8,
+ "range_fetch_concurrency": 2,
+ "chunk_prefetch_window": 2,
+ "pinned_decode": true,
+ "opt_w1": true,
+ "opt_w2": true,
+ "opt_w3": true,
+ "opt_w5": true,
+ "opt_w6": true,
+ "opt_w7": true,
+ "gogc": 15,
+ "gomemlimit": "3200MiB"
+ }
+ }
+ },
+ "batching": {
+ "default_utxo_count": 6,
+ "optimization_utxo_count": 6,
+ "hard_max_utxo_count": 7,
+ "max_tx_cpu_percent": 90,
+ "max_tx_mem_percent": 80,
+ "distinct_7_opt_in": {
+ "request_parameter": "maxUtxos",
+ "request_value": 7,
+ "require_explicit_request": true,
+ "require_measured_execution_units": true
+ }
+ },
+ "provider": {
+ "primary": "blockfrost",
+ "fallback": "koios"
+ },
+ "reference_scripts": {
+ "reclaim_base": {
+ "tx_hash": "c8d6d3b6ddd1a8aa43ee039acb54a79a4bb427f4bbacd95085754b09ecfada2f",
+ "output_index": 1,
+ "script_hash": "744cc4718e8149201c7e9cb3d3a550f34cb18dfc8076a33172d9354d",
+ "holder_address": "addr_test1wr4mrzsjwa6pquu0m6480mq06kpxsht80d4nfh56dcak6lsejdm28"
+ },
+ "reclaim_global": {
+ "tx_hash": "c8d6d3b6ddd1a8aa43ee039acb54a79a4bb427f4bbacd95085754b09ecfada2f",
+ "output_index": 2,
+ "script_hash": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
+ "holder_address": "addr_test1wr4mrzsjwa6pquu0m6480mq06kpxsht80d4nfh56dcak6lsejdm28"
+ }
+ },
+ "enabled": true,
+ "preprod_notes": {
+ "holder_model": "local-preprod-unspendable-params-holder",
+ "holder_script_hash": "ebb18a12777410738fdeaa77ec0fd582685d677b6b34de9a6e3b6d7e",
+ "destination_key_provenance": "single-actor local Preprod setup; not an MPC ceremony",
+ "global_reward_address": "stake_test17zjd5a88edh2fa8xq3t2pfh2huxvlq6xf6l92ejrjrhnn7qfqsud2",
+ "global_reward_account_registered": true
+ }
+}
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msm-worker.js b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msm-worker.js
new file mode 100644
index 00000000..c8597fd2
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msm-worker.js
@@ -0,0 +1,550 @@
+// worker.js — the candidate per-shard MSM kernel bootstrap for the browser.
+//
+// Spawned by msmengine's shardedMSM (internal/msmengine/sharded_js.go) as
+// `new Worker(worker_js_url)`. This candidate mirrors the signed production
+// Worker while runtime findings are still behind default-false flags.
+//
+// Message contract (must match sharded_js.go):
+// in : { id, g2:bool, pts:SharedArrayBuffer, scs:SharedArrayBuffer, pinnedDecode:bool }
+// out: { id, partial:Uint8Array, compute_ms, timings } on success
+// { id, error:string } on failure
+//
+// Worker-owned proving-key fetch tasks use:
+// in : { type:'msm-section-range', id, g2, pkPlan, section, lo, hi, scs, pinnedDecode, optW7 }
+// out: { id, partial:Uint8Array, compute_ms, timings, bytes }
+//
+// An optional first message { type:'init', wasmURL, gogc, gomemlimit } overrides
+// the kernel wasm URL and per-worker Go runtime tuning. Query-string tuning is
+// used otherwise, matching the signed production Worker.
+
+let wasmURL = 'msmworker.wasm';
+let readyPromise = null;
+
+const TUNING_VALUE = /^[A-Za-z0-9.]+$/;
+
+function workerTaskError(code, message, retryable = false, retryAfterMS = 0) {
+ const error = new Error(message);
+ error.workerCode = code;
+ error.retryable = retryable === true;
+ error.retryAfterMS = Number.isSafeInteger(retryAfterMS) && retryAfterMS > 0 ? retryAfterMS : 0;
+ return error;
+}
+
+function workerErrorPayload(error) {
+ const message = String(error && error.message ? error.message : error);
+ const code = typeof error?.workerCode === 'string' ? error.workerCode : 'worker-compute';
+ return {
+ message,
+ code,
+ retryable: error?.retryable === true,
+ retryAfterMS: Number.isSafeInteger(error?.retryAfterMS) ? error.retryAfterMS : 0,
+ };
+}
+
+function retryAfterMilliseconds(response) {
+ const value = (response.headers.get('retry-after') || '').trim();
+ if (!/^\d+$/.test(value)) return 0;
+ const milliseconds = Number(value) * 1000;
+ return Number.isSafeInteger(milliseconds) && milliseconds <= 30_000 ? milliseconds : 0;
+}
+
+// A transient transport failure should not discard a whole shard's verified
+// chunks and MSM work. Keep this retry strictly inside the chunk fetch path so
+// the successful path performs no timer allocation and no extra request. The
+// outer worker/shard retry remains the last resort for worker termination or a
+// transport that remains unavailable after this one recovery attempt.
+const CHUNK_FETCH_MAX_ATTEMPTS = 2;
+const CHUNK_RETRY_BASE_MS = 250;
+const CHUNK_RETRY_MAX_MS = 30_000;
+
+function chunkRetryDelayMilliseconds(chunk, attempt, retryAfterMS = 0) {
+ const shift = Math.min(Math.max(attempt - 1, 0), 3);
+ const base = Math.min(CHUNK_RETRY_MAX_MS, CHUNK_RETRY_BASE_MS * (2 ** shift));
+ const index = Number.isSafeInteger(chunk?.index) && chunk.index >= 0 ? chunk.index : 0;
+ // Deterministic jitter avoids synchronized retries without adding a random
+ // source or any work to successful requests.
+ const jitter = (index * 37 + attempt * 17) % 101;
+ return Math.min(CHUNK_RETRY_MAX_MS, Math.max(retryAfterMS, base + jitter));
+}
+
+async function retryChunkFetchOrThrow(error, chunk, attempt) {
+ if (error?.retryable !== true || attempt >= CHUNK_FETCH_MAX_ATTEMPTS) throw error;
+ const delay = chunkRetryDelayMilliseconds(chunk, attempt, error.retryAfterMS || 0);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+}
+
+async function fetchChunkAttempt(chunkURL, chunk) {
+ let response;
+ try {
+ response = await fetch(chunkURL, { cache: 'force-cache' });
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `fetch chunk ${chunk.index}: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+ if (response.status !== 200) {
+ const retryable = response.status === 408 || response.status === 425 ||
+ response.status === 429 || response.status >= 500;
+ const failure = workerTaskError(
+ 'chunk-fetch-http',
+ `fetch chunk ${chunk.index} returned status ${response.status}`,
+ retryable,
+ retryable ? retryAfterMilliseconds(response) : 0,
+ );
+ if (response.body && typeof response.body.cancel === 'function') {
+ try { await response.body.cancel(); } catch { /* best effort */ }
+ }
+ throw failure;
+ }
+ try {
+ return { response, raw: new Uint8Array(await response.arrayBuffer()) };
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `read chunk ${chunk.index} body: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+}
+
+function workerProgressState(message) {
+ if (
+ !(message?.progress instanceof SharedArrayBuffer) ||
+ !Number.isSafeInteger(message.id)
+ ) {
+ return null;
+ }
+ const state = new Int32Array(message.progress);
+ return state.length >= 2 ? { state, requestID: message.id } : null;
+}
+
+function markWorkerProgress(progress, completedWindows) {
+ if (
+ !progress ||
+ !Number.isSafeInteger(completedWindows) ||
+ completedWindows <= 0 ||
+ Atomics.load(progress.state, 0) !== progress.requestID
+ ) {
+ return;
+ }
+ Atomics.store(progress.state, 1, completedWindows);
+}
+
+function tuningFromLocation(name, fallback) {
+ try {
+ const raw = new URL(self.location.href).searchParams.get(name);
+ if (raw && TUNING_VALUE.test(raw)) return raw;
+ } catch (err) {
+ // fall through to the default
+ }
+ return fallback;
+}
+
+let gogc = tuningFromLocation('gogc', '50');
+let gomemlimit = tuningFromLocation('gomemlimit', '512MiB');
+
+// opt-W7: each Web Worker is an isolated realm, so this two-entry LRU is
+// naturally per-worker. Entries are inserted only after both pinned digests
+// verify. The key binds the URL and every chunk identity/pin field so a later
+// proof or asset version cannot reuse bytes under different authentication.
+const VERIFIED_CHUNK_CACHE_LIMIT = 2;
+const verifiedChunkCache = new Map();
+
+function verifiedChunkCacheKey(baseURL, chunk) {
+ return JSON.stringify([
+ resolveChunkURL(baseURL, chunk.path),
+ chunk.index,
+ chunk.offset,
+ chunk.size,
+ chunk.sha256,
+ chunk.blake2b256,
+ ]);
+}
+
+function cachedVerifiedChunk(key, chunk) {
+ const entry = verifiedChunkCache.get(key);
+ if (!entry || entry.verified !== true || entry.raw.byteLength !== chunk.size) return null;
+ verifiedChunkCache.delete(key);
+ verifiedChunkCache.set(key, entry);
+ return entry.raw;
+}
+
+function insertVerifiedChunk(key, raw) {
+ verifiedChunkCache.delete(key);
+ verifiedChunkCache.set(key, { raw, verified: true });
+ while (verifiedChunkCache.size > VERIFIED_CHUNK_CACHE_LIMIT) {
+ verifiedChunkCache.delete(verifiedChunkCache.keys().next().value);
+ }
+}
+
+function verifiedChunkCacheBytes() {
+ let bytes = 0;
+ for (const entry of verifiedChunkCache.values()) {
+ if (entry && entry.verified === true && entry.raw instanceof Uint8Array) {
+ bytes += entry.raw.byteLength;
+ }
+ }
+ return bytes;
+}
+
+// Collect telemetry only after a successful kernel call. Go heap fields come
+// from this Worker's msmworker.wasm instance, not the main prover runtime. The
+// browser JS heap metric is optional and therefore omitted when unavailable;
+// absence must never be encoded as a misleading zero. W7 cache residency is
+// reported only when W7 is active and counts verified entries exclusively.
+function collectCandidateWorkerTelemetry(optW7 = false) {
+ const telemetry = {};
+ if (typeof self.__msmengineWorkerMemStats === 'function') {
+ copyTimingFields(telemetry, self.__msmengineWorkerMemStats());
+ }
+ const jsHeapUsed = globalThis.performance?.memory?.usedJSHeapSize;
+ if (typeof jsHeapUsed === 'number' && Number.isFinite(jsHeapUsed) && jsHeapUsed >= 0) {
+ telemetry.worker_js_heap_used_bytes = jsHeapUsed;
+ }
+ if (optW7) {
+ telemetry.worker_w7_verified_cache_bytes = verifiedChunkCacheBytes();
+ }
+ return telemetry;
+}
+
+function startKernel(compiledModule = null) {
+ readyPromise = (async () => {
+ importScripts('wasm_exec.js');
+ const go = new Go();
+ go.env.GOGC = gogc;
+ go.env.GOMEMLIMIT = gomemlimit;
+ let instance;
+ if (compiledModule) {
+ instance = await WebAssembly.instantiate(compiledModule, go.importObject);
+ } else if (typeof WebAssembly.instantiateStreaming === 'function') {
+ const result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject);
+ instance = result.instance;
+ } else {
+ const bytes = await (await fetch(wasmURL)).arrayBuffer();
+ const result = await WebAssembly.instantiate(bytes, go.importObject);
+ instance = result.instance;
+ }
+ // go.run resolves only when the kernel's main returns (it blocks forever),
+ // so we do NOT await it; the registered functions are installed
+ // synchronously during main before it parks. Wait for the ready flag.
+ go.run(instance);
+ while (!self.__msmengineReady) {
+ await new Promise((r) => setTimeout(r, 0));
+ }
+ })();
+ return readyPromise;
+}
+
+function resolveChunkURL(baseURL, relPath) {
+ if (!baseURL) throw new Error('pk section plan base_url is required');
+ if (!relPath || relPath.includes('\\') || relPath.includes('://') || /[?#]/.test(relPath)) {
+ throw new Error(`unsafe chunk path ${relPath}`);
+ }
+ const parts = relPath.split('/');
+ if (parts.some((part) => part === '' || part === '.' || part === '..')) {
+ throw new Error(`unsafe chunk path ${relPath}`);
+ }
+ const base = new URL(baseURL);
+ if (base.protocol !== 'http:' && base.protocol !== 'https:') {
+ throw new Error('pk section plan base_url must use http or https');
+ }
+ return new URL(relPath, base).href;
+}
+
+// classifyChunkTransfer splits a completed chunk fetch into wire bytes vs
+// HTTP-cache bytes using Resource Timing. transferSize is only exposed for
+// same-origin responses or cross-origin ones sending Timing-Allow-Origin, so
+// the split degrades to "opaque" against a CDN without that header — the
+// summed byte totals stay exact either way, only the attribution coarsens.
+// The default resource-timing buffer (250 entries) overflows silently during
+// a proof's hundreds of chunk fetches; grow it once and reset it only when
+// full. Clearing per-classification would race sibling fetches in the same
+// Promise.all window and misattribute their bytes as opaque.
+try {
+ performance.setResourceTimingBufferSize(4096);
+ performance.onresourcetimingbufferfull = () => performance.clearResourceTimings();
+} catch {
+ // Older engines without the API keep the default buffer; classification
+ // degrades to opaque once it fills, never miscounts.
+}
+
+function classifyChunkTransfer(url, byteLength) {
+ try {
+ const entries = performance.getEntriesByName(url, 'resource');
+ if (!entries.length) return { network: 0, diskCache: 0, opaque: byteLength };
+ // Newest entry wins: a chunk refetched after LRU eviction must not read
+ // the timing of its earlier fetch.
+ const entry = entries[entries.length - 1];
+ if (entry.transferSize > 0) return { network: byteLength, diskCache: 0, opaque: 0 };
+ if (entry.decodedBodySize > 0) return { network: 0, diskCache: byteLength, opaque: 0 };
+ return { network: 0, diskCache: 0, opaque: byteLength };
+ } catch {
+ return { network: 0, diskCache: 0, opaque: byteLength };
+ }
+}
+
+async function fetchVerifiedChunk(baseURL, chunk, optW7 = false) {
+ const cacheKey = optW7 ? verifiedChunkCacheKey(baseURL, chunk) : '';
+ if (optW7) {
+ const cached = cachedVerifiedChunk(cacheKey, chunk);
+ if (cached) {
+ return {
+ raw: cached, fetchMS: 0, hashMS: 0, fetchedBytes: 0, cacheHit: true, cacheMiss: false,
+ attempts: 0,
+ transfer: { network: 0, diskCache: 0, opaque: 0 },
+ };
+ }
+ }
+ const fetchStarted = performance.now();
+ const chunkURL = resolveChunkURL(baseURL, chunk.path);
+ let attempt = 1;
+ let fetched;
+ try {
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
+ } catch (error) {
+ await retryChunkFetchOrThrow(error, chunk, attempt);
+ attempt = 2;
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
+ }
+ const { response, raw } = fetched;
+ const fetchMS = performance.now() - fetchStarted;
+ const encoding = (response.headers.get('content-encoding') || '').trim();
+ if (encoding && encoding !== 'identity') {
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} content-encoding ${encoding}, want identity`);
+ }
+ if (raw.byteLength !== chunk.size) {
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} size ${raw.byteLength}, want ${chunk.size}`);
+ }
+ const hashStarted = performance.now();
+ const digestError = self.__msmengineVerifyChunkBytes(raw, chunk.sha256, chunk.blake2b256);
+ if (digestError) throw workerTaskError('chunk-integrity', digestError);
+ const hashMS = performance.now() - hashStarted;
+ // Verify-before-cache is the W7 security boundary. No error path above can
+ // populate the LRU, so corrupt bytes are fetched and rejected again.
+ if (optW7) insertVerifiedChunk(cacheKey, raw);
+ return {
+ raw, fetchMS, hashMS, fetchedBytes: raw.byteLength, cacheHit: false, cacheMiss: optW7,
+ attempts: attempt,
+ transfer: classifyChunkTransfer(chunkURL, raw.byteLength),
+ };
+}
+
+async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = false, prefetchWindow = 2, onProgress) {
+ if (!plan || typeof plan !== 'object') throw new Error('pk section plan is required');
+ const section = plan.sections && plan.sections[sectionName];
+ if (!section) throw new Error(`section ${sectionName} not found in pk section plan`);
+ const wantElemSize = g2 ? 192 : 96;
+ if (section.elem_size !== wantElemSize) {
+ throw new Error(`section ${sectionName} elem_size ${section.elem_size}, want ${wantElemSize}`);
+ }
+ const totalPoints = Math.floor(section.len / section.elem_size);
+ if (lo < 0 || hi < lo || hi > totalPoints) {
+ throw new Error(`section range ${sectionName} [${lo},${hi}) out of bounds (len=${totalPoints})`);
+ }
+ const start = section.offset + lo * section.elem_size;
+ const end = section.offset + hi * section.elem_size;
+ if (start < 0 || end < start || end > plan.file_size) {
+ throw new Error(`section range ${sectionName} bytes [${start},${end}) out of bounds (file_size=${plan.file_size})`);
+ }
+ const pointsRaw = new Uint8Array(end - start);
+ // This Worker-originated field is the runtime capability acknowledgement.
+ // Legacy/signed production Workers omit it and are rejected by the main
+ // runtime before their partial can be used when optW7 was requested.
+ const timings = {
+ fetch_ms: 0,
+ hash_ms: 0,
+ slice_ms: 0,
+ cache_hits: 0,
+ cache_misses: 0,
+ fetch_requests: 0,
+ fetch_attempts: 0,
+ w7_applied: optW7 ? 1 : 0,
+ };
+ const bytes = {
+ fetched: 0, hashed: 0, cache_hit: 0, used: pointsRaw.byteLength,
+ // Wire-vs-HTTP-cache attribution of the fetched bytes (Resource Timing).
+ // "opaque" collects bytes the browser will not attribute (cross-origin
+ // responses without Timing-Allow-Origin).
+ network: 0, disk_cache: 0, opaque: 0,
+ };
+ const chunks = (plan.chunks || []).filter((chunk) => {
+ const chunkStart = chunk.offset;
+ const chunkEnd = chunk.offset + chunk.size;
+ return chunkEnd > start && chunkStart < end;
+ });
+ const windowSize = Math.max(1, Math.min(4, Number.isSafeInteger(prefetchWindow) ? prefetchWindow : 2));
+ for (let offset = 0; offset < chunks.length; offset += windowSize) {
+ const window = chunks.slice(offset, offset + windowSize);
+ timings.fetch_requests += window.length;
+ // Promise resolution happens only after each object passes both pinned
+ // digests. No byte is copied into the point buffer before the whole window
+ // has passed verification, and corrupt bytes never enter the W7 cache.
+ const verified = await Promise.all(
+ window.map((chunk) => fetchVerifiedChunk(plan.base_url, chunk, optW7)),
+ );
+ for (let index = 0; index < window.length; index += 1) {
+ const chunk = window[index];
+ const { raw, fetchMS, hashMS, fetchedBytes, cacheHit, cacheMiss, attempts, transfer } = verified[index];
+ timings.fetch_ms += fetchMS;
+ timings.hash_ms += hashMS;
+ timings.cache_hits += cacheHit ? 1 : 0;
+ timings.cache_misses += cacheMiss ? 1 : 0;
+ timings.fetch_attempts += attempts || 0;
+ bytes.fetched += fetchedBytes;
+ bytes.hashed += cacheHit ? 0 : raw.byteLength;
+ bytes.cache_hit += cacheHit ? raw.byteLength : 0;
+ if (transfer) {
+ bytes.network += transfer.network;
+ bytes.disk_cache += transfer.diskCache;
+ bytes.opaque += transfer.opaque;
+ }
+ const chunkStart = chunk.offset;
+ const useStart = Math.max(start, chunkStart);
+ const useEnd = Math.min(end, chunkStart + chunk.size);
+ const sliceStarted = performance.now();
+ pointsRaw.set(raw.subarray(useStart - chunkStart, useEnd - chunkStart), useStart - start);
+ timings.slice_ms += performance.now() - sliceStarted;
+ }
+ if (typeof onProgress === 'function') onProgress(Math.floor(offset / windowSize) + 1);
+ }
+ return { pointsRaw, timings, bytes };
+}
+
+function runKernel(g2, pointsRaw, scsU8, pinnedDecode) {
+ const timed = g2 ? self.__msmengineShardG2Timed : self.__msmengineShardG1Timed;
+ if (typeof timed === 'function') {
+ const result = timed(pointsRaw, scsU8, !!pinnedDecode);
+ return {
+ partial: result.partial,
+ timings: result.timings || {},
+ };
+ }
+ const legacy = g2 ? self.__msmengineShardG2 : self.__msmengineShardG1;
+ return {
+ partial: legacy(pointsRaw, scsU8),
+ timings: {},
+ };
+}
+
+function copyTimingFields(dst, src) {
+ for (const [key, value] of Object.entries(src || {})) {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ dst[key] = value;
+ }
+ }
+}
+
+async function runSectionRange(msg) {
+ const plan = typeof msg.pkPlan === 'string' ? JSON.parse(msg.pkPlan) : msg.pkPlan;
+ const progress = workerProgressState(msg);
+ let completedWindows = 0;
+ const { pointsRaw, timings, bytes } = await fetchSectionPointBytes(
+ plan,
+ msg.section,
+ msg.lo,
+ msg.hi,
+ msg.g2,
+ msg.optW7 === true,
+ msg.chunkPrefetchWindow,
+ (value) => {
+ completedWindows = value;
+ markWorkerProgress(progress, value);
+ },
+ );
+ markWorkerProgress(progress, completedWindows + 1);
+ const scsU8 = new Uint8Array(msg.scs);
+ const computeStarted = performance.now();
+ let partial;
+ try {
+ const result = runKernel(msg.g2, pointsRaw, scsU8, msg.pinnedDecode);
+ partial = result.partial;
+ copyTimingFields(timings, result.timings);
+ } finally {
+ scsU8.fill(0);
+ }
+ timings.compute_ms = performance.now() - computeStarted;
+ if (typeof timings.kernel_ms === 'number') {
+ timings.compute_ms = timings.kernel_ms;
+ }
+ copyTimingFields(timings, collectCandidateWorkerTelemetry(msg.optW7 === true));
+ timings.total_ms = timings.fetch_ms + timings.hash_ms + timings.slice_ms + timings.compute_ms;
+ return { partial, timings, bytes };
+}
+
+self.onmessage = async (e) => {
+ const msg = e.data;
+ if (msg && msg.type === 'init') {
+ if (msg.wasmURL) wasmURL = msg.wasmURL;
+ if (msg.gogc && TUNING_VALUE.test(String(msg.gogc))) gogc = String(msg.gogc);
+ if (msg.gomemlimit && TUNING_VALUE.test(String(msg.gomemlimit))) gomemlimit = String(msg.gomemlimit);
+ try {
+ await startKernel(msg.compiledModule || null);
+ self.postMessage({ type: 'ready' });
+ } catch (err) {
+ self.postMessage({ type: 'init-error', error: String(err && err.message ? err.message : err) });
+ }
+ return;
+ }
+ try {
+ if (!readyPromise) startKernel();
+ await readyPromise;
+ if (msg && msg.type === 'msm-section-range') {
+ const { partial, timings, bytes } = await runSectionRange(msg);
+ self.postMessage({ id: msg.id, partial, compute_ms: timings.compute_ms || 0, timings, bytes }, [partial.buffer]);
+ return;
+ }
+ if (msg && msg.type === 'fft-transform') {
+ // opt-W8 whole-vector computeH transform. The vector arrives and leaves
+ // through the same SharedArrayBuffer (canonical scalar bytes); the reply
+ // carries only an empty partial to satisfy the shared reply shape. The
+ // vector is witness-derived, so the local copy is zeroed like scalars.
+ const vecView = new Uint8Array(msg.vec);
+ const computeStarted = performance.now();
+ const out = self.__msmengineFFTTransform(vecView, !!msg.inverse, !!msg.coset, msg.cardinality);
+ vecView.set(out);
+ out.fill(0);
+ const computeMS = performance.now() - computeStarted;
+ self.postMessage({
+ id: msg.id,
+ partial: new Uint8Array(0),
+ compute_ms: computeMS,
+ timings: { compute_ms: computeMS },
+ });
+ return;
+ }
+ const { id, g2, pts, scs } = msg;
+ const ptsU8 = new Uint8Array(pts);
+ const scsU8 = new Uint8Array(scs);
+ const computeStarted = performance.now();
+ let partial;
+ let timings = {};
+ try {
+ const result = runKernel(g2, ptsU8, scsU8, msg.pinnedDecode);
+ partial = result.partial; // Uint8Array (96 G1 / 192 G2 bytes)
+ timings = result.timings || {};
+ } finally {
+ scsU8.fill(0);
+ }
+ const computeMS = performance.now() - computeStarted;
+ if (typeof timings.kernel_ms === 'number') {
+ timings.compute_ms = timings.kernel_ms;
+ } else {
+ timings.compute_ms = computeMS;
+ }
+ copyTimingFields(timings, collectCandidateWorkerTelemetry(false));
+ // partial is backed by a plain ArrayBuffer (not the shared input), so it is
+ // transferable — hand ownership to the main thread to avoid a copy.
+ self.postMessage({ id, partial, compute_ms: timings.compute_ms || computeMS, timings }, [partial.buffer]);
+ } catch (err) {
+ const failure = workerErrorPayload(err);
+ self.postMessage({
+ id: msg && msg.id,
+ error: failure.message,
+ error_code: failure.code,
+ retryable: failure.retryable,
+ retry_after_ms: failure.retryAfterMS,
+ });
+ }
+};
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msmworker.wasm b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msmworker.wasm
new file mode 100644
index 00000000..a00831cb
Binary files /dev/null and b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/msmworker.wasm differ
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/proof-destination.wasm b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/proof-destination.wasm
new file mode 100644
index 00000000..9eb579cd
Binary files /dev/null and b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/proof-destination.wasm differ
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/prover-worker.js b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/prover-worker.js
new file mode 100644
index 00000000..2a25e173
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/prover-worker.js
@@ -0,0 +1,442 @@
+// prover-worker.js — dedicated classic worker hosting the Go proof orchestrator
+// (proof-destination.wasm) for browser proving.
+//
+// The page (lib/proving/browser-wasm.ts) speaks this protocol:
+// in : { id, type:'init', wasmUrl, wasmExecUrl, msmWorkerWasmUrl, gogc, gomemlimit }
+// out: { id, type:'ready' } | { id, type:'error', message }
+//
+// in : { id, type:'preflight', requestJson }
+// out: { id, type:'preflight-result', result } | { id, type:'error', message }
+//
+// in : { id, type:'discover', requestJson }
+// out: { id, type:'progress', stage, frac, aggregate discovery measurements }
+// { id, type:'discover-result', result } | { id, type:'error', message }
+//
+// in : { id, type:'prove', requestJson }
+// out: { id, type:'progress', stage, frac } (repeated)
+// { id, type:'prove-result', result } | { id, type:'error', message }
+//
+// The Go orchestrator spawns the MSM shard workers (msm-worker.js) itself via
+// `new Worker(artifacts.worker_js_url)`; relative worker/asset URLs resolve
+// against this script's own URL, so all runtime files live in this directory.
+//
+// SECRETS: requestJson contains the master extended private key. It must never
+// be logged or echoed back; error replies carry only a plain message string,
+// progress replies carry only aggregate numeric measurements. No console
+// logging in this file.
+//
+// Termination is handled by the page via worker.terminate(); there is no
+// shutdown message.
+
+'use strict';
+
+let initPromise = null;
+let compiledMSMWorkerModule = null;
+let activeRangeFallback = null;
+const nativeFetch = self.fetch.bind(self);
+
+// The Go range reader accepts HTTP 200 by discarding bytes up to the requested
+// offset. That compatibility path is catastrophic for a multi-GB proving key
+// when a CDN silently ignores Range. Keep the accepted prover WASM byte-exact
+// and adapt only the broken transport here: a healthy 206 is returned untouched
+// with no probe, timer, retry, or extra request. After an observed 200, cancel
+// it and serve subsequent ranges from the already-pinned signed chunk set.
+self.fetch = rangeFallbackFetch;
+
+function rangeFallbackContext(requestJson) {
+ const request = JSON.parse(requestJson);
+ const artifacts = request && typeof request === 'object' ? request.artifacts : null;
+ if (!artifacts || typeof artifacts !== 'object') return null;
+ const required = [
+ 'pk_url',
+ 'chunk_manifest_url',
+ 'chunk_manifest_sig_url',
+ 'chunk_manifest_public_key_hex',
+ ];
+ if (required.some((name) => typeof artifacts[name] !== 'string' || !artifacts[name])) return null;
+ return {
+ pkURL: new URL(artifacts.pk_url, self.location.href).href,
+ chunkManifestURL: new URL(artifacts.chunk_manifest_url, self.location.href).href,
+ chunkManifestSignatureURL: new URL(artifacts.chunk_manifest_sig_url, self.location.href).href,
+ chunkManifestPublicKeyHex: artifacts.chunk_manifest_public_key_hex,
+ verifiedManifest: null,
+ verifiedChunks: new Map(),
+ useChunks: false,
+ };
+}
+
+async function withRangeFallback(requestJson, operation) {
+ if (activeRangeFallback) throw new Error('prover worker request already active');
+ const context = rangeFallbackContext(requestJson);
+ activeRangeFallback = context;
+ try {
+ return await operation();
+ } finally {
+ if (context) context.verifiedChunks.clear();
+ activeRangeFallback = null;
+ }
+}
+
+function requestURL(input) {
+ if (typeof input === 'string' || input instanceof URL) {
+ return new URL(String(input), self.location.href).href;
+ }
+ return new URL(input.url, self.location.href).href;
+}
+
+function requestRange(input, init) {
+ const headers = new Headers(input && typeof input === 'object' && input.headers ? input.headers : undefined);
+ if (init && init.headers) {
+ for (const [name, value] of new Headers(init.headers)) headers.set(name, value);
+ }
+ return headers.get('range') || '';
+}
+
+async function rangeFallbackFetch(input, init) {
+ const context = activeRangeFallback;
+ const url = requestURL(input);
+ const range = requestRange(input, init);
+ const isPKRange = !!context && url === context.pkURL && range !== '';
+ if (isPKRange && context.useChunks) {
+ return signedChunkRangeResponse(context, range);
+ }
+ const response = await nativeFetch(input, init);
+ if (!isPKRange || response.status !== 200) return response;
+ if (response.body && typeof response.body.cancel === 'function') {
+ await response.body.cancel();
+ }
+ context.useChunks = true;
+ return signedChunkRangeResponse(context, range);
+}
+
+function parseByteRange(raw, fileSize) {
+ const match = /^bytes=(\d+)-(\d+)$/.exec(raw);
+ if (!match) throw new Error('proving key fallback requires one bounded byte range');
+ const start = Number(match[1]);
+ const end = Number(match[2]);
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= fileSize) {
+ throw new Error('proving key fallback range is out of bounds');
+ }
+ return { start, end };
+}
+
+function decodeHex(raw, expectedBytes, label) {
+ if (typeof raw !== 'string' || !new RegExp(`^[0-9a-f]{${expectedBytes * 2}}$`, 'i').test(raw)) {
+ throw new Error(`${label} must be ${expectedBytes}-byte hex`);
+ }
+ const out = new Uint8Array(expectedBytes);
+ for (let index = 0; index < expectedBytes; index += 1) {
+ out[index] = Number.parseInt(raw.slice(index * 2, index * 2 + 2), 16);
+ }
+ return out;
+}
+
+function safeChunkPath(raw) {
+ if (
+ typeof raw !== 'string' || raw === '' || raw.startsWith('/') ||
+ raw.includes('\\') || raw.includes('://') || /[?#]/.test(raw) ||
+ raw.split('/').some((part) => part === '' || part === '.' || part === '..')
+ ) {
+ throw new Error('signed proving key chunk path is unsafe');
+ }
+ return raw;
+}
+
+function validateSignedChunkManifest(manifest) {
+ const provingKey = manifest?.proving_key;
+ const transport = manifest?.transport;
+ const fileSize = Number(manifest?.coherence?.proving_key_size);
+ const indexFileSize = Number(manifest?.proving_key_index?.file_size);
+ const chunkSize = Number(provingKey?.chunk_size);
+ const chunks = provingKey?.chunks;
+ if (
+ manifest?.schema !== 'proof-tool-proof-assets-chunk-manifest-v1' ||
+ !Number.isSafeInteger(fileSize) || fileSize <= 0 || indexFileSize !== fileSize ||
+ !Number.isSafeInteger(chunkSize) || chunkSize <= 0 || !Array.isArray(chunks) || chunks.length === 0
+ ) {
+ throw new Error('signed proving key chunk manifest is incomplete');
+ }
+ let baseURL;
+ try {
+ baseURL = new URL(transport?.base_url);
+ } catch {
+ throw new Error('signed proving key chunk base URL is invalid');
+ }
+ if (!['http:', 'https:'].includes(baseURL.protocol)) {
+ throw new Error('signed proving key chunk base URL must use HTTP(S)');
+ }
+ if (baseURL.username || baseURL.password || baseURL.search || baseURL.hash || !baseURL.pathname.endsWith('/')) {
+ throw new Error('signed proving key chunk base URL must be a plain directory URL');
+ }
+ if (transport?.requires_https === true && baseURL.protocol !== 'https:') {
+ throw new Error('signed proving key chunk transport requires HTTPS');
+ }
+ if (transport?.content_encoding !== 'identity') {
+ throw new Error('signed proving key chunks require identity encoding');
+ }
+ let expectedOffset = 0;
+ for (let index = 0; index < chunks.length; index += 1) {
+ const chunk = chunks[index];
+ const size = Number(chunk?.size);
+ if (
+ chunk?.index !== index || chunk?.offset !== expectedOffset ||
+ !Number.isSafeInteger(size) || size <= 0 || size > chunkSize ||
+ (index < chunks.length - 1 && size !== chunkSize) ||
+ !/^sha256:[0-9a-f]{64}$/i.test(chunk?.sha256 || '') ||
+ !/^blake2b256:[0-9a-f]{64}$/i.test(chunk?.blake2b256 || '')
+ ) {
+ throw new Error(`signed proving key chunk ${index} is not canonical`);
+ }
+ safeChunkPath(chunk.path);
+ expectedOffset += size;
+ }
+ if (expectedOffset !== fileSize) throw new Error('signed proving key chunks do not cover the proving key');
+ return { baseURL, chunks, fileSize };
+}
+
+function signedChunkURL(baseURL, rawPath) {
+ const url = new URL(safeChunkPath(rawPath), baseURL);
+ if (url.origin !== baseURL.origin || !url.pathname.startsWith(baseURL.pathname)) {
+ throw new Error('signed proving key chunk path escapes its base URL');
+ }
+ return url.href;
+}
+
+async function verifiedChunkManifest(context) {
+ if (context.verifiedManifest) return context.verifiedManifest;
+ const [manifestResponse, signatureResponse] = await Promise.all([
+ nativeFetch(context.chunkManifestURL, { cache: 'force-cache' }),
+ nativeFetch(context.chunkManifestSignatureURL, { cache: 'force-cache' }),
+ ]);
+ if (manifestResponse.status !== 200 || signatureResponse.status !== 200) {
+ throw new Error('fetch signed proving key chunk manifest failed');
+ }
+ const manifestRaw = new Uint8Array(await manifestResponse.arrayBuffer());
+ const signatureRaw = (await signatureResponse.text()).trim();
+ if (manifestRaw.byteLength === 0 || manifestRaw.byteLength > 8 * 1024 * 1024 || signatureRaw.length > 256) {
+ throw new Error('signed proving key chunk manifest response is not bounded');
+ }
+ const publicKey = await crypto.subtle.importKey(
+ 'raw',
+ decodeHex(context.chunkManifestPublicKeyHex, 32, 'chunk manifest public key'),
+ { name: 'Ed25519' },
+ false,
+ ['verify'],
+ );
+ const signature = decodeHex(signatureRaw, 64, 'chunk manifest signature');
+ if (!(await crypto.subtle.verify({ name: 'Ed25519' }, publicKey, signature, manifestRaw))) {
+ throw new Error('signed proving key chunk manifest signature verification failed');
+ }
+ const manifest = JSON.parse(new TextDecoder().decode(manifestRaw));
+ context.verifiedManifest = validateSignedChunkManifest(manifest);
+ return context.verifiedManifest;
+}
+
+function hexBytes(raw) {
+ return Array.from(raw, (value) => value.toString(16).padStart(2, '0')).join('');
+}
+
+async function fetchVerifiedChunk(context, manifest, chunk) {
+ if (context.verifiedChunks.has(chunk.index)) return context.verifiedChunks.get(chunk.index);
+ const pending = (async () => {
+ const chunkURL = signedChunkURL(manifest.baseURL, chunk.path);
+ const response = await nativeFetch(chunkURL, { cache: 'force-cache' });
+ if (response.status !== 200) throw new Error(`fetch proving key chunk ${chunk.index} returned ${response.status}`);
+ const encoding = (response.headers.get('content-encoding') || '').trim();
+ if (encoding && encoding !== 'identity') throw new Error(`proving key chunk ${chunk.index} was transformed`);
+ const raw = new Uint8Array(await response.arrayBuffer());
+ if (raw.byteLength !== chunk.size) throw new Error(`proving key chunk ${chunk.index} size mismatch`);
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', raw));
+ if (`sha256:${hexBytes(digest)}` !== chunk.sha256) {
+ throw new Error(`proving key chunk ${chunk.index} sha256 mismatch`);
+ }
+ return raw;
+ })();
+ context.verifiedChunks.set(chunk.index, pending);
+ // Small-field reads reuse chunk zero once; a one-entry cache avoids that
+ // duplicate without retaining the multi-chunk infinity bitmap afterward.
+ while (context.verifiedChunks.size > 1) {
+ context.verifiedChunks.delete(context.verifiedChunks.keys().next().value);
+ }
+ try {
+ return await pending;
+ } catch (error) {
+ if (context.verifiedChunks.get(chunk.index) === pending) context.verifiedChunks.delete(chunk.index);
+ throw error;
+ }
+}
+
+async function signedChunkRangeResponse(context, rawRange) {
+ const manifest = await verifiedChunkManifest(context);
+ const { start, end } = parseByteRange(rawRange, manifest.fileSize);
+ const selected = manifest.chunks.filter((chunk) => chunk.offset <= end && chunk.offset + chunk.size > start);
+ const chunkBytes = await Promise.all(selected.map((chunk) => fetchVerifiedChunk(context, manifest, chunk)));
+ const output = new Uint8Array(end - start + 1);
+ for (let index = 0; index < selected.length; index += 1) {
+ const chunk = selected[index];
+ const raw = chunkBytes[index];
+ const useStart = Math.max(start, chunk.offset);
+ const useEnd = Math.min(end + 1, chunk.offset + chunk.size);
+ output.set(raw.subarray(useStart - chunk.offset, useEnd - chunk.offset), useStart - start);
+ }
+ return new Response(output, {
+ status: 206,
+ headers: {
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': String(output.byteLength),
+ 'Content-Range': `bytes ${start}-${end}/${manifest.fileSize}`,
+ 'Content-Type': 'application/octet-stream',
+ },
+ });
+}
+
+function errorMessage(err) {
+ return String(err && err.message ? err.message : err);
+}
+
+// The entrypoints resolve with already-parsed JS objects (main_js.go builds
+// them via JSON.parse), but tolerate a JSON string in case that changes.
+function normalizeResult(result) {
+ return typeof result === 'string' ? JSON.parse(result) : result;
+}
+
+function finiteNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : 0;
+}
+
+function postProgress(id, progress) {
+ const p = progress && typeof progress === 'object' ? progress : {};
+ self.postMessage({
+ id,
+ type: 'progress',
+ stage: String(p.stage || ''),
+ frac: finiteNumber(p.frac),
+ candidates_scanned: finiteNumber(p.candidates_scanned),
+ candidates_total: finiteNumber(p.candidates_total),
+ candidates_per_second: finiteNumber(p.candidates_per_second),
+ eta_seconds: finiteNumber(p.eta_seconds),
+ matched: finiteNumber(p.matched),
+ targets: finiteNumber(p.targets),
+ });
+}
+
+async function compileMSMWorkerModule(url) {
+ if (!url) return null;
+ if (typeof WebAssembly.compileStreaming === 'function') {
+ return await WebAssembly.compileStreaming(fetch(url));
+ }
+ return await WebAssembly.compile(await (await fetch(url)).arrayBuffer());
+}
+
+// __proofChunkReadahead(urls, concurrency) — called by the Go orchestrator
+// after the signed chunk manifest is verified. Warms the HTTP cache with the
+// proving-key chunks in dispatch order so the MSM workers' later
+// cache:'force-cache' fetches skip the network. Bodies are read (a response
+// must complete to be committed to the cache) and discarded; integrity is
+// enforced by the workers' digest checks at consumption time. Fetches are
+// low-priority so an in-flight readahead never starves a worker's needed-now
+// chunk on the shared connection.
+self.__proofChunkReadahead = (urls, concurrency) => {
+ let cancelled = false;
+ let next = 0;
+ const runner = async () => {
+ while (!cancelled && next < urls.length) {
+ const url = urls[next];
+ next += 1;
+ try {
+ const resp = await fetch(url, { cache: 'force-cache', priority: 'low' });
+ if (resp.ok) await resp.arrayBuffer();
+ } catch {
+ // Readahead is best-effort: a failed warm-up fetch just means the
+ // worker pays the network cost later, exactly as without readahead.
+ }
+ }
+ };
+ const lanes = Math.max(1, Math.min(4, concurrency | 0));
+ for (let i = 0; i < lanes; i += 1) runner();
+ return { cancel: () => { cancelled = true; } };
+};
+
+function installMSMWorkerInitializer(wasmURL) {
+ self.__initializeMSMWorker = (worker) => {
+ const init = { type: 'init', wasmURL };
+ if (compiledMSMWorkerModule) init.compiledModule = compiledMSMWorkerModule;
+ try {
+ worker.postMessage(init);
+ } catch {
+ // Older engines may not clone WebAssembly.Module. They compile once per
+ // nested worker but preserve the same pinned URL and verification path.
+ worker.postMessage({ type: 'init', wasmURL });
+ }
+ };
+}
+
+async function initRuntime(msg) {
+ const msmCompile = compileMSMWorkerModule(msg.msmWorkerWasmUrl).catch(() => null);
+ importScripts(msg.wasmExecUrl);
+ const go = new self.Go();
+ go.env.GOGC = msg.gogc ? String(msg.gogc) : '50';
+ go.env.GOMEMLIMIT = msg.gomemlimit ? String(msg.gomemlimit) : '3000MiB';
+ let instance;
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
+ const result = await WebAssembly.instantiateStreaming(fetch(msg.wasmUrl), go.importObject);
+ instance = result.instance;
+ } else {
+ const bytes = await (await fetch(msg.wasmUrl)).arrayBuffer();
+ const result = await WebAssembly.instantiate(bytes, go.importObject);
+ instance = result.instance;
+ }
+ // go.run resolves only when the Go program exits; the prover parks forever,
+ // so do NOT await it. proveDestination/preflightProofAssets are registered
+ // during main; wait for the readiness flag it sets last.
+ go.run(instance);
+ while (!self.__wasmProverReady) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ compiledMSMWorkerModule = await msmCompile;
+ installMSMWorkerInitializer(msg.msmWorkerWasmUrl);
+}
+
+self.onmessage = async (event) => {
+ const msg = event.data || {};
+ const id = msg.id;
+ try {
+ if (msg.type === 'init') {
+ if (initPromise) throw new Error('prover worker is already initialized');
+ initPromise = initRuntime(msg);
+ await initPromise;
+ self.postMessage({ id, type: 'ready' });
+ return;
+ }
+ if (!initPromise) throw new Error('prover worker is not initialized (send init first)');
+ await initPromise;
+ if (msg.type === 'preflight') {
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.preflightProofAssets(msg.requestJson),
+ );
+ self.postMessage({ id, type: 'preflight-result', result: normalizeResult(result) });
+ return;
+ }
+ if (msg.type === 'discover') {
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.discoverCredentialPaths(msg.requestJson, (progress) => postProgress(id, progress)),
+ );
+ self.postMessage({ id, type: 'discover-result', result: normalizeResult(result) });
+ return;
+ }
+ if (msg.type === 'prove') {
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.proveDestination(msg.requestJson, (progress) => postProgress(id, progress)),
+ );
+ self.postMessage({ id, type: 'prove-result', result: normalizeResult(result) });
+ return;
+ }
+ throw new Error(`unknown message type ${String(msg.type)}`);
+ } catch (err) {
+ self.postMessage({ id, type: 'error', message: errorMessage(err) });
+ }
+};
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/runtime-manifest.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/runtime-manifest.json
new file mode 100644
index 00000000..e964b290
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/runtime-manifest.json
@@ -0,0 +1,43 @@
+{
+ "go_version": "go version go1.26.5 linux/amd64",
+ "build_flags": [
+ "-trimpath",
+ "-buildvcs=false",
+ "-mod=vendor",
+ "-ldflags",
+ "-buildid="
+ ],
+ "wasm_opt": "wasm-opt version 130 (version_130) -O3 -all",
+ "files": [
+ {
+ "filename": "proof-destination.wasm",
+ "size_bytes": 22882453,
+ "sha256": "3ad5f0ab4b9bbef21c3530a5eb09278823b4e348bc2a2e3e31f8d71c2ba56d4e",
+ "blake2b256": "107406db64de119b9547f100979d9b2c493d43edae45f438a76f5e32c89f7491"
+ },
+ {
+ "filename": "msmworker.wasm",
+ "size_bytes": 11141058,
+ "sha256": "f94900e5e684854f4db1a44c2b0dfb09572b74d80f3031d49e9c778ee1190225",
+ "blake2b256": "084f31fa51c61dc25a208645ce22e5880c8d6ea2e9d0cb65ceb0174cb3a67a25"
+ },
+ {
+ "filename": "wasm_exec.js",
+ "size_bytes": 16992,
+ "sha256": "0c949f4996f9a89698e4b5c586de32249c3b69b7baadb64d220073cc04acba14",
+ "blake2b256": "9be0dbf2b47f0cf1e2f7525312c1a03b241eebd4002af8486ed352f981af9246"
+ },
+ {
+ "filename": "msm-worker.js",
+ "size_bytes": 22008,
+ "sha256": "743e73bfd3a2fa48301c5cd63a7e9e1c3258143041aad00a4d70e1787b78cee0",
+ "blake2b256": "4cc299e2e1abd240390c17e703da77d31c251a0a121203425abf872ecad015f6"
+ },
+ {
+ "filename": "prover-worker.js",
+ "size_bytes": 18306,
+ "sha256": "c0dc56625cd93191afa3aa18f5205be061d88da1a5100b0f11364e37ff133027",
+ "blake2b256": "0530e1369a193ed17b20e6d77b2cf05dd0b795ee5ba4cb1fa9cff017fe5e272f"
+ }
+ ]
+}
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/wasm_exec.js b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/wasm_exec.js
new file mode 100644
index 00000000..d71af9e9
--- /dev/null
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1/runtime/wasm_exec.js
@@ -0,0 +1,575 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+"use strict";
+
+(() => {
+ const enosys = () => {
+ const err = new Error("not implemented");
+ err.code = "ENOSYS";
+ return err;
+ };
+
+ if (!globalThis.fs) {
+ let outputBuf = "";
+ globalThis.fs = {
+ constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
+ writeSync(fd, buf) {
+ outputBuf += decoder.decode(buf);
+ const nl = outputBuf.lastIndexOf("\n");
+ if (nl != -1) {
+ console.log(outputBuf.substring(0, nl));
+ outputBuf = outputBuf.substring(nl + 1);
+ }
+ return buf.length;
+ },
+ write(fd, buf, offset, length, position, callback) {
+ if (offset !== 0 || length !== buf.length || position !== null) {
+ callback(enosys());
+ return;
+ }
+ const n = this.writeSync(fd, buf);
+ callback(null, n);
+ },
+ chmod(path, mode, callback) { callback(enosys()); },
+ chown(path, uid, gid, callback) { callback(enosys()); },
+ close(fd, callback) { callback(enosys()); },
+ fchmod(fd, mode, callback) { callback(enosys()); },
+ fchown(fd, uid, gid, callback) { callback(enosys()); },
+ fstat(fd, callback) { callback(enosys()); },
+ fsync(fd, callback) { callback(null); },
+ ftruncate(fd, length, callback) { callback(enosys()); },
+ lchown(path, uid, gid, callback) { callback(enosys()); },
+ link(path, link, callback) { callback(enosys()); },
+ lstat(path, callback) { callback(enosys()); },
+ mkdir(path, perm, callback) { callback(enosys()); },
+ open(path, flags, mode, callback) { callback(enosys()); },
+ read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
+ readdir(path, callback) { callback(enosys()); },
+ readlink(path, callback) { callback(enosys()); },
+ rename(from, to, callback) { callback(enosys()); },
+ rmdir(path, callback) { callback(enosys()); },
+ stat(path, callback) { callback(enosys()); },
+ symlink(path, link, callback) { callback(enosys()); },
+ truncate(path, length, callback) { callback(enosys()); },
+ unlink(path, callback) { callback(enosys()); },
+ utimes(path, atime, mtime, callback) { callback(enosys()); },
+ };
+ }
+
+ if (!globalThis.process) {
+ globalThis.process = {
+ getuid() { return -1; },
+ getgid() { return -1; },
+ geteuid() { return -1; },
+ getegid() { return -1; },
+ getgroups() { throw enosys(); },
+ pid: -1,
+ ppid: -1,
+ umask() { throw enosys(); },
+ cwd() { throw enosys(); },
+ chdir() { throw enosys(); },
+ }
+ }
+
+ if (!globalThis.path) {
+ globalThis.path = {
+ resolve(...pathSegments) {
+ return pathSegments.join("/");
+ }
+ }
+ }
+
+ if (!globalThis.crypto) {
+ throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
+ }
+
+ if (!globalThis.performance) {
+ throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
+ }
+
+ if (!globalThis.TextEncoder) {
+ throw new Error("globalThis.TextEncoder is not available, polyfill required");
+ }
+
+ if (!globalThis.TextDecoder) {
+ throw new Error("globalThis.TextDecoder is not available, polyfill required");
+ }
+
+ const encoder = new TextEncoder("utf-8");
+ const decoder = new TextDecoder("utf-8");
+
+ globalThis.Go = class {
+ constructor() {
+ this.argv = ["js"];
+ this.env = {};
+ this.exit = (code) => {
+ if (code !== 0) {
+ console.warn("exit code:", code);
+ }
+ };
+ this._exitPromise = new Promise((resolve) => {
+ this._resolveExitPromise = resolve;
+ });
+ this._pendingEvent = null;
+ this._scheduledTimeouts = new Map();
+ this._nextCallbackTimeoutID = 1;
+
+ const setInt64 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
+ }
+
+ const setInt32 = (addr, v) => {
+ this.mem.setUint32(addr + 0, v, true);
+ }
+
+ const getInt64 = (addr) => {
+ const low = this.mem.getUint32(addr + 0, true);
+ const high = this.mem.getInt32(addr + 4, true);
+ return low + high * 4294967296;
+ }
+
+ const loadValue = (addr) => {
+ const f = this.mem.getFloat64(addr, true);
+ if (f === 0) {
+ return undefined;
+ }
+ if (!isNaN(f)) {
+ return f;
+ }
+
+ const id = this.mem.getUint32(addr, true);
+ return this._values[id];
+ }
+
+ const storeValue = (addr, v) => {
+ const nanHead = 0x7FF80000;
+
+ if (typeof v === "number" && v !== 0) {
+ if (isNaN(v)) {
+ this.mem.setUint32(addr + 4, nanHead, true);
+ this.mem.setUint32(addr, 0, true);
+ return;
+ }
+ this.mem.setFloat64(addr, v, true);
+ return;
+ }
+
+ if (v === undefined) {
+ this.mem.setFloat64(addr, 0, true);
+ return;
+ }
+
+ let id = this._ids.get(v);
+ if (id === undefined) {
+ id = this._idPool.pop();
+ if (id === undefined) {
+ id = this._values.length;
+ }
+ this._values[id] = v;
+ this._goRefCounts[id] = 0;
+ this._ids.set(v, id);
+ }
+ this._goRefCounts[id]++;
+ let typeFlag = 0;
+ switch (typeof v) {
+ case "object":
+ if (v !== null) {
+ typeFlag = 1;
+ }
+ break;
+ case "string":
+ typeFlag = 2;
+ break;
+ case "symbol":
+ typeFlag = 3;
+ break;
+ case "function":
+ typeFlag = 4;
+ break;
+ }
+ this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
+ this.mem.setUint32(addr, id, true);
+ }
+
+ const loadSlice = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return new Uint8Array(this._inst.exports.mem.buffer, array, len);
+ }
+
+ const loadSliceOfValues = (addr) => {
+ const array = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ const a = new Array(len);
+ for (let i = 0; i < len; i++) {
+ a[i] = loadValue(array + i * 8);
+ }
+ return a;
+ }
+
+ const loadString = (addr) => {
+ const saddr = getInt64(addr + 0);
+ const len = getInt64(addr + 8);
+ return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
+ }
+
+ const testCallExport = (a, b) => {
+ this._inst.exports.testExport0();
+ return this._inst.exports.testExport(a, b);
+ }
+
+ const timeOrigin = Date.now() - performance.now();
+ this.importObject = {
+ _gotest: {
+ add: (a, b) => a + b,
+ callExport: testCallExport,
+ },
+ gojs: {
+ // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
+ // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
+ // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
+ // This changes the SP, thus we have to update the SP used by the imported function.
+
+ // func wasmExit(code int32)
+ "runtime.wasmExit": (sp) => {
+ sp >>>= 0;
+ const code = this.mem.getInt32(sp + 8, true);
+ this.exited = true;
+ delete this._inst;
+ delete this._values;
+ delete this._goRefCounts;
+ delete this._ids;
+ delete this._idPool;
+ this.exit(code);
+ },
+
+ // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
+ "runtime.wasmWrite": (sp) => {
+ sp >>>= 0;
+ const fd = getInt64(sp + 8);
+ const p = getInt64(sp + 16);
+ const n = this.mem.getInt32(sp + 24, true);
+ fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
+ },
+
+ // func resetMemoryDataView()
+ "runtime.resetMemoryDataView": (sp) => {
+ sp >>>= 0;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ },
+
+ // func nanotime1() int64
+ "runtime.nanotime1": (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
+ },
+
+ // func walltime() (sec int64, nsec int32)
+ "runtime.walltime": (sp) => {
+ sp >>>= 0;
+ const msec = (new Date).getTime();
+ setInt64(sp + 8, msec / 1000);
+ this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
+ },
+
+ // func scheduleTimeoutEvent(delay int64) int32
+ "runtime.scheduleTimeoutEvent": (sp) => {
+ sp >>>= 0;
+ const id = this._nextCallbackTimeoutID;
+ this._nextCallbackTimeoutID++;
+ this._scheduledTimeouts.set(id, setTimeout(
+ () => {
+ this._resume();
+ while (this._scheduledTimeouts.has(id)) {
+ // for some reason Go failed to register the timeout event, log and try again
+ // (temporary workaround for https://github.com/golang/go/issues/28975)
+ console.warn("scheduleTimeoutEvent: missed timeout event");
+ this._resume();
+ }
+ },
+ getInt64(sp + 8),
+ ));
+ this.mem.setInt32(sp + 16, id, true);
+ },
+
+ // func clearTimeoutEvent(id int32)
+ "runtime.clearTimeoutEvent": (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getInt32(sp + 8, true);
+ clearTimeout(this._scheduledTimeouts.get(id));
+ this._scheduledTimeouts.delete(id);
+ },
+
+ // func getRandomData(r []byte)
+ "runtime.getRandomData": (sp) => {
+ sp >>>= 0;
+ crypto.getRandomValues(loadSlice(sp + 8));
+ },
+
+ // func finalizeRef(v ref)
+ "syscall/js.finalizeRef": (sp) => {
+ sp >>>= 0;
+ const id = this.mem.getUint32(sp + 8, true);
+ this._goRefCounts[id]--;
+ if (this._goRefCounts[id] === 0) {
+ const v = this._values[id];
+ this._values[id] = null;
+ this._ids.delete(v);
+ this._idPool.push(id);
+ }
+ },
+
+ // func stringVal(value string) ref
+ "syscall/js.stringVal": (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, loadString(sp + 8));
+ },
+
+ // func valueGet(v ref, p string) ref
+ "syscall/js.valueGet": (sp) => {
+ sp >>>= 0;
+ const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 32, result);
+ },
+
+ // func valueSet(v ref, p string, x ref)
+ "syscall/js.valueSet": (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
+ },
+
+ // func valueDelete(v ref, p string)
+ "syscall/js.valueDelete": (sp) => {
+ sp >>>= 0;
+ Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
+ },
+
+ // func valueIndex(v ref, i int) ref
+ "syscall/js.valueIndex": (sp) => {
+ sp >>>= 0;
+ storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
+ },
+
+ // valueSetIndex(v ref, i int, x ref)
+ "syscall/js.valueSetIndex": (sp) => {
+ sp >>>= 0;
+ Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
+ },
+
+ // func valueCall(v ref, m string, args []ref) (ref, bool)
+ "syscall/js.valueCall": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const m = Reflect.get(v, loadString(sp + 16));
+ const args = loadSliceOfValues(sp + 32);
+ const result = Reflect.apply(m, v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, result);
+ this.mem.setUint8(sp + 64, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 56, err);
+ this.mem.setUint8(sp + 64, 0);
+ }
+ },
+
+ // func valueInvoke(v ref, args []ref) (ref, bool)
+ "syscall/js.valueInvoke": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.apply(v, undefined, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, err);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueNew(v ref, args []ref) (ref, bool)
+ "syscall/js.valueNew": (sp) => {
+ sp >>>= 0;
+ try {
+ const v = loadValue(sp + 8);
+ const args = loadSliceOfValues(sp + 16);
+ const result = Reflect.construct(v, args);
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, result);
+ this.mem.setUint8(sp + 48, 1);
+ } catch (err) {
+ sp = this._inst.exports.getsp() >>> 0; // see comment above
+ storeValue(sp + 40, err);
+ this.mem.setUint8(sp + 48, 0);
+ }
+ },
+
+ // func valueLength(v ref) int
+ "syscall/js.valueLength": (sp) => {
+ sp >>>= 0;
+ setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
+ },
+
+ // valuePrepareString(v ref) (ref, int)
+ "syscall/js.valuePrepareString": (sp) => {
+ sp >>>= 0;
+ const str = encoder.encode(String(loadValue(sp + 8)));
+ storeValue(sp + 16, str);
+ setInt64(sp + 24, str.length);
+ },
+
+ // valueLoadString(v ref, b []byte)
+ "syscall/js.valueLoadString": (sp) => {
+ sp >>>= 0;
+ const str = loadValue(sp + 8);
+ loadSlice(sp + 16).set(str);
+ },
+
+ // func valueInstanceOf(v ref, t ref) bool
+ "syscall/js.valueInstanceOf": (sp) => {
+ sp >>>= 0;
+ this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
+ },
+
+ // func copyBytesToGo(dst []byte, src ref) (int, bool)
+ "syscall/js.copyBytesToGo": (sp) => {
+ sp >>>= 0;
+ const dst = loadSlice(sp + 8);
+ const src = loadValue(sp + 32);
+ if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ // func copyBytesToJS(dst ref, src []byte) (int, bool)
+ "syscall/js.copyBytesToJS": (sp) => {
+ sp >>>= 0;
+ const dst = loadValue(sp + 8);
+ const src = loadSlice(sp + 16);
+ if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
+ this.mem.setUint8(sp + 48, 0);
+ return;
+ }
+ const toCopy = src.subarray(0, dst.length);
+ dst.set(toCopy);
+ setInt64(sp + 40, toCopy.length);
+ this.mem.setUint8(sp + 48, 1);
+ },
+
+ "debug": (value) => {
+ console.log(value);
+ },
+ }
+ };
+ }
+
+ async run(instance) {
+ if (!(instance instanceof WebAssembly.Instance)) {
+ throw new Error("Go.run: WebAssembly.Instance expected");
+ }
+ this._inst = instance;
+ this.mem = new DataView(this._inst.exports.mem.buffer);
+ this._values = [ // JS values that Go currently has references to, indexed by reference id
+ NaN,
+ 0,
+ null,
+ true,
+ false,
+ globalThis,
+ this,
+ ];
+ this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
+ this._ids = new Map([ // mapping from JS values to reference ids
+ [0, 1],
+ [null, 2],
+ [true, 3],
+ [false, 4],
+ [globalThis, 5],
+ [this, 6],
+ ]);
+ this._idPool = []; // unused ids that have been garbage collected
+ this.exited = false; // whether the Go program has exited
+
+ // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
+ let offset = 4096;
+
+ const strPtr = (str) => {
+ const ptr = offset;
+ const bytes = encoder.encode(str + "\0");
+ new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
+ offset += bytes.length;
+ if (offset % 8 !== 0) {
+ offset += 8 - (offset % 8);
+ }
+ return ptr;
+ };
+
+ const argc = this.argv.length;
+
+ const argvPtrs = [];
+ this.argv.forEach((arg) => {
+ argvPtrs.push(strPtr(arg));
+ });
+ argvPtrs.push(0);
+
+ const keys = Object.keys(this.env).sort();
+ keys.forEach((key) => {
+ argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
+ });
+ argvPtrs.push(0);
+
+ const argv = offset;
+ argvPtrs.forEach((ptr) => {
+ this.mem.setUint32(offset, ptr, true);
+ this.mem.setUint32(offset + 4, 0, true);
+ offset += 8;
+ });
+
+ // The linker guarantees global data starts from at least wasmMinDataAddr.
+ // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
+ const wasmMinDataAddr = 4096 + 8192;
+ if (offset >= wasmMinDataAddr) {
+ throw new Error("total length of command line and environment variables exceeds limit");
+ }
+
+ this._inst.exports.run(argc, argv);
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ await this._exitPromise;
+ }
+
+ _resume() {
+ if (this.exited) {
+ throw new Error("Go program has already exited");
+ }
+ this._inst.exports.resume();
+ if (this.exited) {
+ this._resolveExitPromise();
+ }
+ }
+
+ _makeFuncWrapper(id) {
+ const go = this;
+ return function () {
+ const event = { id: id, this: this, args: arguments };
+ go._pendingEvent = event;
+ go._resume();
+ return event.result;
+ };
+ }
+ }
+})();
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/reclaim-deployment.json
index f93327e6..cc1cdcf7 100644
--- a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/reclaim-deployment.json
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1/assets/reclaim-deployment.json
@@ -14,7 +14,7 @@
"script_hash": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
"rewarding_credential": "a4da74e7cb6ea4f4e60456a0a6eabf0ccf83464ebe55664390ef39f8",
"params_currency_symbol": "d6777b8c3be1c6c0c9baba52a880c1980a662c16ffc0885ecaa03119",
- "verifier_vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"proof_profile": "single-destination",
"proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
"batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
diff --git a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-runtime-opt-r2/assets/reclaim-deployment.json b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-runtime-opt-r2/assets/reclaim-deployment.json
index 369f70d2..16baaa77 100644
--- a/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-runtime-opt-r2/assets/reclaim-deployment.json
+++ b/apps/ownership-proof-web/public/proof-releases/proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-runtime-opt-r2/assets/reclaim-deployment.json
@@ -14,7 +14,7 @@
"script_hash": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"rewarding_credential": "1556d4b8968fc1bc2beb692634a8e1c7e4d476cce48a5969c007b2c5",
"params_currency_symbol": "82c806809e8e2a65c153041db187ca96f2feeb87a3fe135bf3803174",
- "verifier_vk_hash": "blake2b256:b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80",
+ "verifier_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4",
"proof_profile": "single-destination",
"proof_slot_encoding": "full-proof-plus-public-input-digest-v2",
"batch_transcript_vk_hash": "blake2b256:06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4"
diff --git a/apps/ownership-proof-web/public/proof-runtime/msm-worker.js b/apps/ownership-proof-web/public/proof-runtime/msm-worker.js
index d85ec56f..c8597fd2 100644
--- a/apps/ownership-proof-web/public/proof-runtime/msm-worker.js
+++ b/apps/ownership-proof-web/public/proof-runtime/msm-worker.js
@@ -22,6 +22,116 @@ let readyPromise = null;
const TUNING_VALUE = /^[A-Za-z0-9.]+$/;
+function workerTaskError(code, message, retryable = false, retryAfterMS = 0) {
+ const error = new Error(message);
+ error.workerCode = code;
+ error.retryable = retryable === true;
+ error.retryAfterMS = Number.isSafeInteger(retryAfterMS) && retryAfterMS > 0 ? retryAfterMS : 0;
+ return error;
+}
+
+function workerErrorPayload(error) {
+ const message = String(error && error.message ? error.message : error);
+ const code = typeof error?.workerCode === 'string' ? error.workerCode : 'worker-compute';
+ return {
+ message,
+ code,
+ retryable: error?.retryable === true,
+ retryAfterMS: Number.isSafeInteger(error?.retryAfterMS) ? error.retryAfterMS : 0,
+ };
+}
+
+function retryAfterMilliseconds(response) {
+ const value = (response.headers.get('retry-after') || '').trim();
+ if (!/^\d+$/.test(value)) return 0;
+ const milliseconds = Number(value) * 1000;
+ return Number.isSafeInteger(milliseconds) && milliseconds <= 30_000 ? milliseconds : 0;
+}
+
+// A transient transport failure should not discard a whole shard's verified
+// chunks and MSM work. Keep this retry strictly inside the chunk fetch path so
+// the successful path performs no timer allocation and no extra request. The
+// outer worker/shard retry remains the last resort for worker termination or a
+// transport that remains unavailable after this one recovery attempt.
+const CHUNK_FETCH_MAX_ATTEMPTS = 2;
+const CHUNK_RETRY_BASE_MS = 250;
+const CHUNK_RETRY_MAX_MS = 30_000;
+
+function chunkRetryDelayMilliseconds(chunk, attempt, retryAfterMS = 0) {
+ const shift = Math.min(Math.max(attempt - 1, 0), 3);
+ const base = Math.min(CHUNK_RETRY_MAX_MS, CHUNK_RETRY_BASE_MS * (2 ** shift));
+ const index = Number.isSafeInteger(chunk?.index) && chunk.index >= 0 ? chunk.index : 0;
+ // Deterministic jitter avoids synchronized retries without adding a random
+ // source or any work to successful requests.
+ const jitter = (index * 37 + attempt * 17) % 101;
+ return Math.min(CHUNK_RETRY_MAX_MS, Math.max(retryAfterMS, base + jitter));
+}
+
+async function retryChunkFetchOrThrow(error, chunk, attempt) {
+ if (error?.retryable !== true || attempt >= CHUNK_FETCH_MAX_ATTEMPTS) throw error;
+ const delay = chunkRetryDelayMilliseconds(chunk, attempt, error.retryAfterMS || 0);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+}
+
+async function fetchChunkAttempt(chunkURL, chunk) {
+ let response;
+ try {
+ response = await fetch(chunkURL, { cache: 'force-cache' });
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `fetch chunk ${chunk.index}: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+ if (response.status !== 200) {
+ const retryable = response.status === 408 || response.status === 425 ||
+ response.status === 429 || response.status >= 500;
+ const failure = workerTaskError(
+ 'chunk-fetch-http',
+ `fetch chunk ${chunk.index} returned status ${response.status}`,
+ retryable,
+ retryable ? retryAfterMilliseconds(response) : 0,
+ );
+ if (response.body && typeof response.body.cancel === 'function') {
+ try { await response.body.cancel(); } catch { /* best effort */ }
+ }
+ throw failure;
+ }
+ try {
+ return { response, raw: new Uint8Array(await response.arrayBuffer()) };
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `read chunk ${chunk.index} body: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+}
+
+function workerProgressState(message) {
+ if (
+ !(message?.progress instanceof SharedArrayBuffer) ||
+ !Number.isSafeInteger(message.id)
+ ) {
+ return null;
+ }
+ const state = new Int32Array(message.progress);
+ return state.length >= 2 ? { state, requestID: message.id } : null;
+}
+
+function markWorkerProgress(progress, completedWindows) {
+ if (
+ !progress ||
+ !Number.isSafeInteger(completedWindows) ||
+ completedWindows <= 0 ||
+ Atomics.load(progress.state, 0) !== progress.requestID
+ ) {
+ return;
+ }
+ Atomics.store(progress.state, 1, completedWindows);
+}
+
function tuningFromLocation(name, fallback) {
try {
const raw = new URL(self.location.href).searchParams.get(name);
@@ -182,39 +292,46 @@ async function fetchVerifiedChunk(baseURL, chunk, optW7 = false) {
if (cached) {
return {
raw: cached, fetchMS: 0, hashMS: 0, fetchedBytes: 0, cacheHit: true, cacheMiss: false,
+ attempts: 0,
transfer: { network: 0, diskCache: 0, opaque: 0 },
};
}
}
const fetchStarted = performance.now();
const chunkURL = resolveChunkURL(baseURL, chunk.path);
- const response = await fetch(chunkURL, { cache: 'force-cache' });
- const raw = new Uint8Array(await response.arrayBuffer());
- const fetchMS = performance.now() - fetchStarted;
- if (response.status !== 200) {
- throw new Error(`fetch chunk ${chunk.index} returned status ${response.status}`);
+ let attempt = 1;
+ let fetched;
+ try {
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
+ } catch (error) {
+ await retryChunkFetchOrThrow(error, chunk, attempt);
+ attempt = 2;
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
}
+ const { response, raw } = fetched;
+ const fetchMS = performance.now() - fetchStarted;
const encoding = (response.headers.get('content-encoding') || '').trim();
if (encoding && encoding !== 'identity') {
- throw new Error(`chunk ${chunk.index} content-encoding ${encoding}, want identity`);
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} content-encoding ${encoding}, want identity`);
}
if (raw.byteLength !== chunk.size) {
- throw new Error(`chunk ${chunk.index} size ${raw.byteLength}, want ${chunk.size}`);
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} size ${raw.byteLength}, want ${chunk.size}`);
}
const hashStarted = performance.now();
const digestError = self.__msmengineVerifyChunkBytes(raw, chunk.sha256, chunk.blake2b256);
- if (digestError) throw new Error(digestError);
+ if (digestError) throw workerTaskError('chunk-integrity', digestError);
const hashMS = performance.now() - hashStarted;
// Verify-before-cache is the W7 security boundary. No error path above can
// populate the LRU, so corrupt bytes are fetched and rejected again.
if (optW7) insertVerifiedChunk(cacheKey, raw);
return {
raw, fetchMS, hashMS, fetchedBytes: raw.byteLength, cacheHit: false, cacheMiss: optW7,
+ attempts: attempt,
transfer: classifyChunkTransfer(chunkURL, raw.byteLength),
};
}
-async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = false, prefetchWindow = 2) {
+async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = false, prefetchWindow = 2, onProgress) {
if (!plan || typeof plan !== 'object') throw new Error('pk section plan is required');
const section = plan.sections && plan.sections[sectionName];
if (!section) throw new Error(`section ${sectionName} not found in pk section plan`);
@@ -242,6 +359,7 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
cache_hits: 0,
cache_misses: 0,
fetch_requests: 0,
+ fetch_attempts: 0,
w7_applied: optW7 ? 1 : 0,
};
const bytes = {
@@ -268,11 +386,12 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
);
for (let index = 0; index < window.length; index += 1) {
const chunk = window[index];
- const { raw, fetchMS, hashMS, fetchedBytes, cacheHit, cacheMiss, transfer } = verified[index];
+ const { raw, fetchMS, hashMS, fetchedBytes, cacheHit, cacheMiss, attempts, transfer } = verified[index];
timings.fetch_ms += fetchMS;
timings.hash_ms += hashMS;
timings.cache_hits += cacheHit ? 1 : 0;
timings.cache_misses += cacheMiss ? 1 : 0;
+ timings.fetch_attempts += attempts || 0;
bytes.fetched += fetchedBytes;
bytes.hashed += cacheHit ? 0 : raw.byteLength;
bytes.cache_hit += cacheHit ? raw.byteLength : 0;
@@ -288,6 +407,7 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
pointsRaw.set(raw.subarray(useStart - chunkStart, useEnd - chunkStart), useStart - start);
timings.slice_ms += performance.now() - sliceStarted;
}
+ if (typeof onProgress === 'function') onProgress(Math.floor(offset / windowSize) + 1);
}
return { pointsRaw, timings, bytes };
}
@@ -318,6 +438,8 @@ function copyTimingFields(dst, src) {
async function runSectionRange(msg) {
const plan = typeof msg.pkPlan === 'string' ? JSON.parse(msg.pkPlan) : msg.pkPlan;
+ const progress = workerProgressState(msg);
+ let completedWindows = 0;
const { pointsRaw, timings, bytes } = await fetchSectionPointBytes(
plan,
msg.section,
@@ -326,7 +448,12 @@ async function runSectionRange(msg) {
msg.g2,
msg.optW7 === true,
msg.chunkPrefetchWindow,
+ (value) => {
+ completedWindows = value;
+ markWorkerProgress(progress, value);
+ },
);
+ markWorkerProgress(progress, completedWindows + 1);
const scsU8 = new Uint8Array(msg.scs);
const computeStarted = performance.now();
let partial;
@@ -411,6 +538,13 @@ self.onmessage = async (e) => {
// transferable — hand ownership to the main thread to avoid a copy.
self.postMessage({ id, partial, compute_ms: timings.compute_ms || computeMS, timings }, [partial.buffer]);
} catch (err) {
- self.postMessage({ id: msg && msg.id, error: String(err && err.message ? err.message : err) });
+ const failure = workerErrorPayload(err);
+ self.postMessage({
+ id: msg && msg.id,
+ error: failure.message,
+ error_code: failure.code,
+ retryable: failure.retryable,
+ retry_after_ms: failure.retryAfterMS,
+ });
}
};
diff --git a/apps/ownership-proof-web/public/proof-runtime/prover-worker.js b/apps/ownership-proof-web/public/proof-runtime/prover-worker.js
index 5151b8de..2a25e173 100644
--- a/apps/ownership-proof-web/public/proof-runtime/prover-worker.js
+++ b/apps/ownership-proof-web/public/proof-runtime/prover-worker.js
@@ -32,6 +32,263 @@
let initPromise = null;
let compiledMSMWorkerModule = null;
+let activeRangeFallback = null;
+const nativeFetch = self.fetch.bind(self);
+
+// The Go range reader accepts HTTP 200 by discarding bytes up to the requested
+// offset. That compatibility path is catastrophic for a multi-GB proving key
+// when a CDN silently ignores Range. Keep the accepted prover WASM byte-exact
+// and adapt only the broken transport here: a healthy 206 is returned untouched
+// with no probe, timer, retry, or extra request. After an observed 200, cancel
+// it and serve subsequent ranges from the already-pinned signed chunk set.
+self.fetch = rangeFallbackFetch;
+
+function rangeFallbackContext(requestJson) {
+ const request = JSON.parse(requestJson);
+ const artifacts = request && typeof request === 'object' ? request.artifacts : null;
+ if (!artifacts || typeof artifacts !== 'object') return null;
+ const required = [
+ 'pk_url',
+ 'chunk_manifest_url',
+ 'chunk_manifest_sig_url',
+ 'chunk_manifest_public_key_hex',
+ ];
+ if (required.some((name) => typeof artifacts[name] !== 'string' || !artifacts[name])) return null;
+ return {
+ pkURL: new URL(artifacts.pk_url, self.location.href).href,
+ chunkManifestURL: new URL(artifacts.chunk_manifest_url, self.location.href).href,
+ chunkManifestSignatureURL: new URL(artifacts.chunk_manifest_sig_url, self.location.href).href,
+ chunkManifestPublicKeyHex: artifacts.chunk_manifest_public_key_hex,
+ verifiedManifest: null,
+ verifiedChunks: new Map(),
+ useChunks: false,
+ };
+}
+
+async function withRangeFallback(requestJson, operation) {
+ if (activeRangeFallback) throw new Error('prover worker request already active');
+ const context = rangeFallbackContext(requestJson);
+ activeRangeFallback = context;
+ try {
+ return await operation();
+ } finally {
+ if (context) context.verifiedChunks.clear();
+ activeRangeFallback = null;
+ }
+}
+
+function requestURL(input) {
+ if (typeof input === 'string' || input instanceof URL) {
+ return new URL(String(input), self.location.href).href;
+ }
+ return new URL(input.url, self.location.href).href;
+}
+
+function requestRange(input, init) {
+ const headers = new Headers(input && typeof input === 'object' && input.headers ? input.headers : undefined);
+ if (init && init.headers) {
+ for (const [name, value] of new Headers(init.headers)) headers.set(name, value);
+ }
+ return headers.get('range') || '';
+}
+
+async function rangeFallbackFetch(input, init) {
+ const context = activeRangeFallback;
+ const url = requestURL(input);
+ const range = requestRange(input, init);
+ const isPKRange = !!context && url === context.pkURL && range !== '';
+ if (isPKRange && context.useChunks) {
+ return signedChunkRangeResponse(context, range);
+ }
+ const response = await nativeFetch(input, init);
+ if (!isPKRange || response.status !== 200) return response;
+ if (response.body && typeof response.body.cancel === 'function') {
+ await response.body.cancel();
+ }
+ context.useChunks = true;
+ return signedChunkRangeResponse(context, range);
+}
+
+function parseByteRange(raw, fileSize) {
+ const match = /^bytes=(\d+)-(\d+)$/.exec(raw);
+ if (!match) throw new Error('proving key fallback requires one bounded byte range');
+ const start = Number(match[1]);
+ const end = Number(match[2]);
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= fileSize) {
+ throw new Error('proving key fallback range is out of bounds');
+ }
+ return { start, end };
+}
+
+function decodeHex(raw, expectedBytes, label) {
+ if (typeof raw !== 'string' || !new RegExp(`^[0-9a-f]{${expectedBytes * 2}}$`, 'i').test(raw)) {
+ throw new Error(`${label} must be ${expectedBytes}-byte hex`);
+ }
+ const out = new Uint8Array(expectedBytes);
+ for (let index = 0; index < expectedBytes; index += 1) {
+ out[index] = Number.parseInt(raw.slice(index * 2, index * 2 + 2), 16);
+ }
+ return out;
+}
+
+function safeChunkPath(raw) {
+ if (
+ typeof raw !== 'string' || raw === '' || raw.startsWith('/') ||
+ raw.includes('\\') || raw.includes('://') || /[?#]/.test(raw) ||
+ raw.split('/').some((part) => part === '' || part === '.' || part === '..')
+ ) {
+ throw new Error('signed proving key chunk path is unsafe');
+ }
+ return raw;
+}
+
+function validateSignedChunkManifest(manifest) {
+ const provingKey = manifest?.proving_key;
+ const transport = manifest?.transport;
+ const fileSize = Number(manifest?.coherence?.proving_key_size);
+ const indexFileSize = Number(manifest?.proving_key_index?.file_size);
+ const chunkSize = Number(provingKey?.chunk_size);
+ const chunks = provingKey?.chunks;
+ if (
+ manifest?.schema !== 'proof-tool-proof-assets-chunk-manifest-v1' ||
+ !Number.isSafeInteger(fileSize) || fileSize <= 0 || indexFileSize !== fileSize ||
+ !Number.isSafeInteger(chunkSize) || chunkSize <= 0 || !Array.isArray(chunks) || chunks.length === 0
+ ) {
+ throw new Error('signed proving key chunk manifest is incomplete');
+ }
+ let baseURL;
+ try {
+ baseURL = new URL(transport?.base_url);
+ } catch {
+ throw new Error('signed proving key chunk base URL is invalid');
+ }
+ if (!['http:', 'https:'].includes(baseURL.protocol)) {
+ throw new Error('signed proving key chunk base URL must use HTTP(S)');
+ }
+ if (baseURL.username || baseURL.password || baseURL.search || baseURL.hash || !baseURL.pathname.endsWith('/')) {
+ throw new Error('signed proving key chunk base URL must be a plain directory URL');
+ }
+ if (transport?.requires_https === true && baseURL.protocol !== 'https:') {
+ throw new Error('signed proving key chunk transport requires HTTPS');
+ }
+ if (transport?.content_encoding !== 'identity') {
+ throw new Error('signed proving key chunks require identity encoding');
+ }
+ let expectedOffset = 0;
+ for (let index = 0; index < chunks.length; index += 1) {
+ const chunk = chunks[index];
+ const size = Number(chunk?.size);
+ if (
+ chunk?.index !== index || chunk?.offset !== expectedOffset ||
+ !Number.isSafeInteger(size) || size <= 0 || size > chunkSize ||
+ (index < chunks.length - 1 && size !== chunkSize) ||
+ !/^sha256:[0-9a-f]{64}$/i.test(chunk?.sha256 || '') ||
+ !/^blake2b256:[0-9a-f]{64}$/i.test(chunk?.blake2b256 || '')
+ ) {
+ throw new Error(`signed proving key chunk ${index} is not canonical`);
+ }
+ safeChunkPath(chunk.path);
+ expectedOffset += size;
+ }
+ if (expectedOffset !== fileSize) throw new Error('signed proving key chunks do not cover the proving key');
+ return { baseURL, chunks, fileSize };
+}
+
+function signedChunkURL(baseURL, rawPath) {
+ const url = new URL(safeChunkPath(rawPath), baseURL);
+ if (url.origin !== baseURL.origin || !url.pathname.startsWith(baseURL.pathname)) {
+ throw new Error('signed proving key chunk path escapes its base URL');
+ }
+ return url.href;
+}
+
+async function verifiedChunkManifest(context) {
+ if (context.verifiedManifest) return context.verifiedManifest;
+ const [manifestResponse, signatureResponse] = await Promise.all([
+ nativeFetch(context.chunkManifestURL, { cache: 'force-cache' }),
+ nativeFetch(context.chunkManifestSignatureURL, { cache: 'force-cache' }),
+ ]);
+ if (manifestResponse.status !== 200 || signatureResponse.status !== 200) {
+ throw new Error('fetch signed proving key chunk manifest failed');
+ }
+ const manifestRaw = new Uint8Array(await manifestResponse.arrayBuffer());
+ const signatureRaw = (await signatureResponse.text()).trim();
+ if (manifestRaw.byteLength === 0 || manifestRaw.byteLength > 8 * 1024 * 1024 || signatureRaw.length > 256) {
+ throw new Error('signed proving key chunk manifest response is not bounded');
+ }
+ const publicKey = await crypto.subtle.importKey(
+ 'raw',
+ decodeHex(context.chunkManifestPublicKeyHex, 32, 'chunk manifest public key'),
+ { name: 'Ed25519' },
+ false,
+ ['verify'],
+ );
+ const signature = decodeHex(signatureRaw, 64, 'chunk manifest signature');
+ if (!(await crypto.subtle.verify({ name: 'Ed25519' }, publicKey, signature, manifestRaw))) {
+ throw new Error('signed proving key chunk manifest signature verification failed');
+ }
+ const manifest = JSON.parse(new TextDecoder().decode(manifestRaw));
+ context.verifiedManifest = validateSignedChunkManifest(manifest);
+ return context.verifiedManifest;
+}
+
+function hexBytes(raw) {
+ return Array.from(raw, (value) => value.toString(16).padStart(2, '0')).join('');
+}
+
+async function fetchVerifiedChunk(context, manifest, chunk) {
+ if (context.verifiedChunks.has(chunk.index)) return context.verifiedChunks.get(chunk.index);
+ const pending = (async () => {
+ const chunkURL = signedChunkURL(manifest.baseURL, chunk.path);
+ const response = await nativeFetch(chunkURL, { cache: 'force-cache' });
+ if (response.status !== 200) throw new Error(`fetch proving key chunk ${chunk.index} returned ${response.status}`);
+ const encoding = (response.headers.get('content-encoding') || '').trim();
+ if (encoding && encoding !== 'identity') throw new Error(`proving key chunk ${chunk.index} was transformed`);
+ const raw = new Uint8Array(await response.arrayBuffer());
+ if (raw.byteLength !== chunk.size) throw new Error(`proving key chunk ${chunk.index} size mismatch`);
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', raw));
+ if (`sha256:${hexBytes(digest)}` !== chunk.sha256) {
+ throw new Error(`proving key chunk ${chunk.index} sha256 mismatch`);
+ }
+ return raw;
+ })();
+ context.verifiedChunks.set(chunk.index, pending);
+ // Small-field reads reuse chunk zero once; a one-entry cache avoids that
+ // duplicate without retaining the multi-chunk infinity bitmap afterward.
+ while (context.verifiedChunks.size > 1) {
+ context.verifiedChunks.delete(context.verifiedChunks.keys().next().value);
+ }
+ try {
+ return await pending;
+ } catch (error) {
+ if (context.verifiedChunks.get(chunk.index) === pending) context.verifiedChunks.delete(chunk.index);
+ throw error;
+ }
+}
+
+async function signedChunkRangeResponse(context, rawRange) {
+ const manifest = await verifiedChunkManifest(context);
+ const { start, end } = parseByteRange(rawRange, manifest.fileSize);
+ const selected = manifest.chunks.filter((chunk) => chunk.offset <= end && chunk.offset + chunk.size > start);
+ const chunkBytes = await Promise.all(selected.map((chunk) => fetchVerifiedChunk(context, manifest, chunk)));
+ const output = new Uint8Array(end - start + 1);
+ for (let index = 0; index < selected.length; index += 1) {
+ const chunk = selected[index];
+ const raw = chunkBytes[index];
+ const useStart = Math.max(start, chunk.offset);
+ const useEnd = Math.min(end + 1, chunk.offset + chunk.size);
+ output.set(raw.subarray(useStart - chunk.offset, useEnd - chunk.offset), useStart - start);
+ }
+ return new Response(output, {
+ status: 206,
+ headers: {
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': String(output.byteLength),
+ 'Content-Range': `bytes ${start}-${end}/${manifest.fileSize}`,
+ 'Content-Type': 'application/octet-stream',
+ },
+ });
+}
function errorMessage(err) {
return String(err && err.message ? err.message : err);
@@ -155,17 +412,26 @@ self.onmessage = async (event) => {
if (!initPromise) throw new Error('prover worker is not initialized (send init first)');
await initPromise;
if (msg.type === 'preflight') {
- const result = await self.preflightProofAssets(msg.requestJson);
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.preflightProofAssets(msg.requestJson),
+ );
self.postMessage({ id, type: 'preflight-result', result: normalizeResult(result) });
return;
}
if (msg.type === 'discover') {
- const result = await self.discoverCredentialPaths(msg.requestJson, (progress) => postProgress(id, progress));
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.discoverCredentialPaths(msg.requestJson, (progress) => postProgress(id, progress)),
+ );
self.postMessage({ id, type: 'discover-result', result: normalizeResult(result) });
return;
}
if (msg.type === 'prove') {
- const result = await self.proveDestination(msg.requestJson, (progress) => postProgress(id, progress));
+ const result = await withRangeFallback(
+ msg.requestJson,
+ () => self.proveDestination(msg.requestJson, (progress) => postProgress(id, progress)),
+ );
self.postMessage({ id, type: 'prove-result', result: normalizeResult(result) });
return;
}
diff --git a/apps/ownership-proof-web/scripts/verify-proof-release.mjs b/apps/ownership-proof-web/scripts/verify-proof-release.mjs
index 0960ef41..a0ff6595 100644
--- a/apps/ownership-proof-web/scripts/verify-proof-release.mjs
+++ b/apps/ownership-proof-web/scripts/verify-proof-release.mjs
@@ -325,14 +325,43 @@ function verifyKeyCoherence(keyManifest, coherence, deployment) {
for (const [field, expected] of Object.entries(checks)) equal(coherence?.[field], expected, `coherence ${field}`);
equal(deployment.proof?.key_version, coherence.key_version, "deployment key version");
equal(deployment.proof?.circuit_id, coherence.circuit_id, "deployment circuit id");
- equal(deployment.proof?.vk_hash, coherence.vk_hash, "deployment VK hash");
- equal(deployment.reclaim_global?.verifier_vk_hash, coherence.vk_hash, "on-chain VK hash");
+ equal(deployment.proof?.vk_hash, coherence.vk_hash, "native gnark VK hash");
equal(deployment.proof?.cardano_vk_blake2b256, coherence.cardano_vk_blake2b256, "Cardano VK hash");
+ equal(deployment.reclaim_global?.verifier_vk_hash, coherence.cardano_vk_blake2b256, "on-chain Cardano VK hash");
equal(
deployment.reclaim_global?.batch_transcript_vk_hash,
coherence.cardano_vk_blake2b256,
"batch transcript VK hash",
);
+ if (deployment.network === "Mainnet") {
+ equal(deployment.proof?.setup_transcript_hash, keyManifest.setup_transcript_hash, "Mainnet setup transcript hash");
+ equal(deployment.proof?.mpc_ceremony_id, coherence.mpc_ceremony_id, "Mainnet MPC ceremony id");
+ equal(deployment.proof?.mpc_candidate_id, coherence.mpc_candidate_id, "Mainnet MPC candidate id");
+ equal(
+ deployment.planning?.production_decision_id,
+ coherence.production_decision_id,
+ "Mainnet production decision id",
+ );
+ equal(deployment.planning?.mpc_release_id, coherence.mpc_release_id, "Mainnet MPC release id");
+ equal(
+ deployment.planning?.release_manifest_sha256,
+ coherence.release_manifest_sha256,
+ "Mainnet release manifest SHA-256",
+ );
+ equal(
+ deployment.planning?.release_manifest_sha256,
+ coherence.key_manifest_sha256,
+ "Mainnet exact signed release manifest",
+ );
+ for (const [label, value] of [
+ ["MPC ceremony id", coherence.mpc_ceremony_id],
+ ["MPC candidate id", coherence.mpc_candidate_id],
+ ["production decision id", coherence.production_decision_id],
+ ["MPC release id", coherence.mpc_release_id],
+ ]) {
+ checkDigestString(value, "sha256", `Mainnet ${label}`);
+ }
+ }
equal(deployment.deployment_id, coherence.deployment_id, "deployment id");
equal(deployment.source_commit, coherence.deployment_source_commit, "deployment source commit");
}
diff --git a/apps/ownership-proof-web/scripts/verify-proof-release.test.mjs b/apps/ownership-proof-web/scripts/verify-proof-release.test.mjs
index 8ff85684..48dfd7cd 100644
--- a/apps/ownership-proof-web/scripts/verify-proof-release.test.mjs
+++ b/apps/ownership-proof-web/scripts/verify-proof-release.test.mjs
@@ -1,3 +1,4 @@
+import { generateKeyPairSync, sign } from "node:crypto";
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -22,7 +23,7 @@ describe("proof release coherence verifier", () => {
).resolves.toMatchObject({
ok: true,
mode: "local",
- release: "proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-reclaim-744cc471-r1",
+ release: "proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-range-fallback-r1",
});
});
@@ -44,6 +45,82 @@ describe("proof release coherence verifier", () => {
).rejects.toThrow(/signature verification failed/u);
});
+ it("rejects a deployment that uses the native gnark VK hash as its on-chain hash", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "proof-release-test-"));
+ temporaryRoots.push(root);
+ await cp(publicRoot, root, { recursive: true });
+ const deploymentPath = path.join(root, "proof-assets/reclaim-deployment.json");
+ const deployment = JSON.parse(await readFile(deploymentPath, "utf8"));
+ deployment.reclaim_global.verifier_vk_hash = deployment.proof.vk_hash;
+ const versionedDeploymentPath = path.join(root, deployment.proof.browser_proving.deployment_manifest_url.slice(1));
+ await Promise.all([
+ writeFile(deploymentPath, `${JSON.stringify(deployment, null, 2)}\n`),
+ writeFile(versionedDeploymentPath, `${JSON.stringify(deployment, null, 2)}\n`),
+ ]);
+
+ await expect(
+ verifyProofRelease({
+ webRoot: root,
+ deployment: deploymentPath,
+ }),
+ ).rejects.toThrow(/on-chain Cardano VK hash/u);
+ });
+
+ it("rejects Mainnet proof assets whose GO-approved release manifest digest drifted", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "proof-release-test-"));
+ temporaryRoots.push(root);
+ await cp(publicRoot, root, { recursive: true });
+ const deploymentPath = path.join(root, "proof-assets/reclaim-deployment.json");
+ const deployment = JSON.parse(await readFile(deploymentPath, "utf8"));
+ const chunkPath = path.join(root, deployment.proof.browser_proving.chunk_manifest_url.slice(1));
+ const chunkSignaturePath = path.join(root, deployment.proof.browser_proving.chunk_manifest_sig_url.slice(1));
+ const chunk = JSON.parse(await readFile(chunkPath, "utf8"));
+ const keyManifestPath = path.join(root, deployment.proof.browser_proving.manifest_url.slice(1));
+ const keyManifest = JSON.parse(await readFile(keyManifestPath, "utf8"));
+ const ceremonyID = `sha256:${"11".repeat(32)}`;
+ const candidateID = `sha256:${"22".repeat(32)}`;
+ const decisionID = `sha256:${"33".repeat(32)}`;
+ const releaseID = `sha256:${"44".repeat(32)}`;
+
+ deployment.network = "Mainnet";
+ deployment.proof.setup_transcript_hash = keyManifest.setup_transcript_hash;
+ deployment.proof.mpc_ceremony_id = ceremonyID;
+ deployment.proof.mpc_candidate_id = candidateID;
+ deployment.planning = {
+ production_decision_id: decisionID,
+ mpc_release_id: releaseID,
+ release_manifest_sha256: `sha256:${"ff".repeat(32)}`,
+ };
+ Object.assign(chunk.coherence, {
+ mpc_ceremony_id: ceremonyID,
+ mpc_candidate_id: candidateID,
+ production_decision_id: decisionID,
+ mpc_release_id: releaseID,
+ release_manifest_sha256: chunk.coherence.key_manifest_sha256,
+ });
+
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
+ const publicKeyHex = publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("hex");
+ deployment.proof.browser_proving.chunk_manifest_public_key_hex = publicKeyHex;
+ const chunkBytes = Buffer.from(`${JSON.stringify(chunk, null, 2)}\n`);
+ const versionedDeploymentPath = path.join(root, deployment.proof.browser_proving.deployment_manifest_url.slice(1));
+ const chunkPublicKeyPath = path.join(path.dirname(chunkPath), "chunk-manifest-public-key.hex");
+ await Promise.all([
+ writeFile(chunkPath, chunkBytes),
+ writeFile(chunkSignaturePath, `${sign(null, chunkBytes, privateKey).toString("hex")}\n`),
+ writeFile(chunkPublicKeyPath, `${publicKeyHex}\n`),
+ writeFile(deploymentPath, `${JSON.stringify(deployment, null, 2)}\n`),
+ writeFile(versionedDeploymentPath, `${JSON.stringify(deployment, null, 2)}\n`),
+ ]);
+
+ await expect(
+ verifyProofRelease({
+ webRoot: root,
+ deployment: deploymentPath,
+ }),
+ ).rejects.toThrow(/Mainnet release manifest SHA-256/u);
+ });
+
it("waits for the production alias to serve the expected deployment commit", async () => {
const expectedCommitSha = "a".repeat(40);
const responses = ["b".repeat(40), expectedCommitSha];
diff --git a/apps/ownership-proof-web/scripts/verify-reclaim-manifest.mjs b/apps/ownership-proof-web/scripts/verify-reclaim-manifest.mjs
index 4e1bf161..39fcb7fc 100644
--- a/apps/ownership-proof-web/scripts/verify-reclaim-manifest.mjs
+++ b/apps/ownership-proof-web/scripts/verify-reclaim-manifest.mjs
@@ -126,7 +126,7 @@ export function validateReclaimManifest(raw) {
"reclaim_base.required_global_credential",
errors,
);
- exact(global.verifier_vk_hash, proof.vk_hash, "proof.vk_hash", errors);
+ exact(global.verifier_vk_hash, proof.cardano_vk_blake2b256, "reclaim_global.verifier_vk_hash", errors);
exact(params.datum_reclaim_base_script_hash, base.script_hash, "params_utxo.datum_reclaim_base_script_hash", errors);
exact(params.policy_id, global.params_currency_symbol, "params_utxo.policy_id", errors);
if (
diff --git a/apps/ownership-proof-web/scripts/verify-reclaim-manifest.test.mjs b/apps/ownership-proof-web/scripts/verify-reclaim-manifest.test.mjs
index 43ec3bc3..340518bc 100644
--- a/apps/ownership-proof-web/scripts/verify-reclaim-manifest.test.mjs
+++ b/apps/ownership-proof-web/scripts/verify-reclaim-manifest.test.mjs
@@ -32,6 +32,13 @@ describe("verify-reclaim-manifest V2 coherence", () => {
expect(errorFields(manifest)).toContain("reclaim_global.batch_transcript_vk_hash");
});
+ it("rejects the native gnark VK hash in the on-chain verifier field", () => {
+ const manifest = statementBoundV2Manifest();
+ manifest.reclaim_global.verifier_vk_hash = manifest.proof.vk_hash;
+
+ expect(errorFields(manifest)).toContain("reclaim_global.verifier_vk_hash");
+ });
+
it("accepts the explicit seven-slot V2 capacity policy", () => {
const manifest = statementBoundV2Manifest();
manifest.batching = {
diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go
new file mode 100644
index 00000000..8952b509
--- /dev/null
+++ b/cmd/mpc-ceremony/cli_test.go
@@ -0,0 +1,857 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) {
+ t.Parallel()
+
+ ceremonyTrust := []string{
+ "--ceremony", "ceremony/ceremony.json",
+ "--ceremony-signature", "ceremony/ceremony.sig",
+ "--coordinator-public-key-file", "trust/coordinator.pub",
+ }
+ contribute := []string{
+ "--transcript-dir", "transcript/phase",
+ "--chain", "transcript/phase/chain-0000.json",
+ "--chain-signature", "transcript/phase/chain-0000.sig",
+ "--participant-id", "participant-01",
+ "--participant-signing-key", "private/participant.key",
+ "--environment", "participant/environment.json",
+ "--contributed-at", "2026-07-23T12:00:00Z",
+ "--out-dir", "candidate/participant-01",
+ }
+ verify := []string{
+ "--transcript-dir", "transcript/phase",
+ "--chain", "transcript/phase/chain-0000.json",
+ "--chain-signature", "transcript/phase/chain-0000.sig",
+ "--candidate-dir", "candidate/participant-01",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--accepted-at", "2026-07-23T12:05:00Z",
+ }
+ closeFlags := []string{
+ "--transcript-dir", "transcript/phase",
+ "--chain", "transcript/phase/chain-0001.json",
+ "--chain-signature", "transcript/phase/chain-0001.sig",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--beacon-round", "12345",
+ }
+ replayFlags := []string{
+ "--transcript-root", "transcript",
+ "--phase1-chain", "transcript/phase1/chain.json",
+ "--phase1-chain-signature", "transcript/phase1/chain.sig",
+ "--phase1-close", "transcript/phase1/close.json",
+ "--phase1-close-signature", "transcript/phase1/close.sig",
+ "--phase1-beacon", "transcript/phase1/beacon.json",
+ "--phase1-beacon-signature", "transcript/phase1/beacon.sig",
+ "--phase1-seal", "transcript/phase1/seal.json",
+ "--phase1-seal-signature", "transcript/phase1/seal.sig",
+ "--phase2-chain", "transcript/phase2/chain.json",
+ "--phase2-chain-signature", "transcript/phase2/chain.sig",
+ "--phase2-close", "transcript/phase2/close.json",
+ "--phase2-close-signature", "transcript/phase2/close.sig",
+ "--phase2-beacon", "transcript/phase2/beacon.json",
+ "--phase2-beacon-signature", "transcript/phase2/beacon.sig",
+ }
+
+ tests := []struct {
+ name string
+ args []string
+ command Command
+ }{
+ {
+ name: "init",
+ args: []string{
+ "init",
+ "--created-at", "2026-07-23T11:00:00Z",
+ "--key-version", "ownership-destination-v2",
+ "--participants", "policy/participants.json",
+ "--policy", "policy/ceremony.json",
+ "--coordinator-key-id", "coordinator-2026",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--out-dir", "transcript",
+ "--mode", "production",
+ },
+ command: CommandInit,
+ },
+ {
+ name: "phase1 contribute",
+ args: joinArgs([]string{"phase1", "contribute"}, ceremonyTrust, contribute),
+ command: CommandPhase1Contribute,
+ },
+ {
+ name: "phase1 attest erasure",
+ args: joinArgs(
+ []string{"phase1", "attest-erasure"},
+ ceremonyTrust,
+ []string{
+ "--participant-id", "participant-01",
+ "--participant-signing-key", "private/participant.key",
+ "--candidate-dir", "candidate/participant-01",
+ "--destroyed-at", "2026-07-23T12:04:00Z",
+ },
+ ),
+ command: CommandPhase1Erasure,
+ },
+ {
+ name: "phase1 verify",
+ args: joinArgs([]string{"phase1", "verify"}, ceremonyTrust, verify),
+ command: CommandPhase1Verify,
+ },
+ {
+ name: "phase1 close",
+ args: joinArgs([]string{"phase1", "close"}, ceremonyTrust, closeFlags),
+ command: CommandPhase1Close,
+ },
+ {
+ name: "phase1 beacon",
+ args: joinArgs(
+ []string{"phase1", "beacon"},
+ ceremonyTrust,
+ []string{
+ "--closure", "transcript/phase1/closure/record.json",
+ "--closure-signature", "transcript/phase1/closure/record.sig",
+ "--raw-response", "beacons/phase1-raw.json",
+ "--published-at", "2026-07-24T12:00:00Z",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--transcript-dir", "transcript",
+ },
+ ),
+ command: CommandPhase1Beacon,
+ },
+ {
+ name: "phase1 seal",
+ args: joinArgs(
+ []string{"phase1", "seal"},
+ ceremonyTrust,
+ []string{
+ "--transcript-dir", "transcript/phase1",
+ "--closure", "transcript/phase1/closure/record.json",
+ "--closure-signature", "transcript/phase1/closure/record.sig",
+ "--beacon", "beacons/phase1.json",
+ "--beacon-signature", "beacons/phase1.sig",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--out-dir", "transcript/phase1-seal",
+ },
+ ),
+ command: CommandPhase1Seal,
+ },
+ {
+ name: "phase2 init",
+ args: joinArgs(
+ []string{"phase2", "init"},
+ ceremonyTrust,
+ []string{
+ "--phase1-transcript-dir", "transcript/phase1",
+ "--phase1-seal", "transcript/phase1-seal/seal.json",
+ "--phase1-seal-signature", "transcript/phase1-seal/seal.sig",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--out-dir", "transcript/phase2",
+ },
+ ),
+ command: CommandPhase2Init,
+ },
+ {
+ name: "phase2 contribute",
+ args: joinArgs(
+ []string{"phase2", "contribute"},
+ ceremonyTrust,
+ []string{"--phase1-seal", "transcript/phase1-seal/seal.json"},
+ []string{"--phase1-seal-signature", "transcript/phase1-seal/seal.sig"},
+ contribute,
+ ),
+ command: CommandPhase2Contribute,
+ },
+ {
+ name: "phase2 attest erasure",
+ args: joinArgs(
+ []string{"phase2", "attest-erasure"},
+ ceremonyTrust,
+ []string{
+ "--participant-id", "participant-01",
+ "--participant-signing-key", "private/participant.key",
+ "--candidate-dir", "candidate/participant-01",
+ "--destroyed-at", "2026-07-23T12:04:00Z",
+ },
+ ),
+ command: CommandPhase2Erasure,
+ },
+ {
+ name: "phase2 verify",
+ args: joinArgs(
+ []string{"phase2", "verify"},
+ ceremonyTrust,
+ []string{"--phase1-seal", "transcript/phase1-seal/seal.json"},
+ []string{"--phase1-seal-signature", "transcript/phase1-seal/seal.sig"},
+ verify,
+ ),
+ command: CommandPhase2Verify,
+ },
+ {
+ name: "phase2 close",
+ args: joinArgs(
+ []string{"phase2", "close"},
+ ceremonyTrust,
+ []string{"--phase1-seal", "transcript/phase1-seal/seal.json"},
+ []string{"--phase1-seal-signature", "transcript/phase1-seal/seal.sig"},
+ closeFlags,
+ ),
+ command: CommandPhase2Close,
+ },
+ {
+ name: "phase2 beacon",
+ args: joinArgs(
+ []string{"phase2", "beacon"},
+ ceremonyTrust,
+ []string{
+ "--closure", "transcript/phase2/closure/record.json",
+ "--closure-signature", "transcript/phase2/closure/record.sig",
+ "--raw-response", "beacons/phase2-raw.json",
+ "--published-at", "2026-07-25T12:00:00Z",
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--transcript-dir", "transcript",
+ },
+ ),
+ command: CommandPhase2Beacon,
+ },
+ {
+ name: "finalize complete",
+ args: joinArgs(
+ []string{"finalize", "complete"},
+ ceremonyTrust,
+ replayFlags,
+ []string{
+ "--coordinator-signing-key", "private/coordinator.key",
+ "--public-evidence", "candidate/public-evidence.json",
+ "--finalized-at", "2026-07-26T12:00:00Z",
+ "--out-dir", "candidate/release",
+ },
+ ),
+ command: CommandFinalizeComplete,
+ },
+ {
+ name: "audit",
+ args: joinArgs(
+ []string{"audit"},
+ ceremonyTrust,
+ replayFlags,
+ []string{
+ "--candidate-bundle", "candidate/release",
+ "--auditor-id", "auditor-01",
+ "--auditor-signing-key", "private/auditor.key",
+ "--audited-at", "2026-07-27T12:00:00Z",
+ "--out", "audits/auditor-01.json",
+ "--audit-signature", "audits/auditor-01.sig",
+ },
+ ),
+ command: CommandAudit,
+ },
+ {
+ name: "release sign",
+ args: joinArgs(
+ []string{"release", "sign"},
+ ceremonyTrust,
+ []string{
+ "--candidate-bundle", "candidate/release",
+ "--audit-report", "audits/auditor-01.json",
+ "--audit-signature", "audits/auditor-01.sig",
+ "--audit-report", "audits/auditor-02.json",
+ "--audit-signature", "audits/auditor-02.sig",
+ "--operational-evidence-root", "operational-input",
+ "--operational-bundle", "operational-input/operational/evidence-bundle.json",
+ "--operational-bundle-signature", "operational-input/operational/evidence-bundle.sig",
+ "--release-signing-key", "private/release.key",
+ "--signature-key-id", "release-2026",
+ "--released-at", "2026-07-28T12:00:00Z",
+ "--release-dir", "release",
+ },
+ ),
+ command: CommandReleaseSign,
+ },
+ {
+ name: "release verify",
+ args: joinArgs(
+ []string{"release", "verify"},
+ ceremonyTrust,
+ []string{
+ "--keys-dir", "release",
+ "--manifest-public-key-file", "trust/release.pub",
+ "--signature-key-id", "release-2026",
+ },
+ ),
+ command: CommandReleaseVerify,
+ },
+ {
+ name: "decision prepare",
+ args: joinArgs(
+ []string{"decision", "prepare"},
+ ceremonyTrust,
+ []string{
+ "--draft", "governance/decision.draft.json",
+ "--out", "governance/decision.json",
+ },
+ ),
+ command: CommandDecisionPrepare,
+ },
+ {
+ name: "decision sign",
+ args: joinArgs(
+ []string{"decision", "sign"},
+ ceremonyTrust,
+ []string{
+ "--decision", "governance/decision.json",
+ "--role", "auditor",
+ "--signer-id", "auditor-01",
+ "--signing-key", "private/auditor-01.key",
+ "--out", "governance/auditor-01.decision.sig.json",
+ },
+ ),
+ command: CommandDecisionSign,
+ },
+ {
+ name: "decision verify",
+ args: joinArgs(
+ []string{"decision", "verify"},
+ ceremonyTrust,
+ []string{
+ "--decision", "governance/decision.json",
+ "--signature", "governance/coordinator.sig.json",
+ "--signature", "governance/auditor-01.sig.json",
+ "--signature", "governance/auditor-02.sig.json",
+ "--signature", "governance/release-signer.sig.json",
+ "--evidence-root", "governance/evidence",
+ },
+ ),
+ command: CommandDecisionVerify,
+ },
+ {
+ name: "ops export signing",
+ args: joinArgs(
+ []string{"ops", "export-signing"},
+ ceremonyTrust,
+ []string{
+ "--record-type", "enrollment",
+ "--record", "ops/enrollment.json",
+ "--out-dir", "ops/export",
+ },
+ ),
+ command: CommandOpsExportSigning,
+ },
+ {
+ name: "ops import signature",
+ args: joinArgs(
+ []string{"ops", "import-signature"},
+ ceremonyTrust,
+ []string{
+ "--record-type", "enrollment",
+ "--canonical", "ops/export/canonical.json",
+ "--signer-public-key-file", "trust/participant.pub",
+ "--raw-signature", "offline/enrollment.sig",
+ "--out", "ops/enrollment.sig",
+ },
+ ),
+ command: CommandOpsImportSig,
+ },
+ {
+ name: "ops verify",
+ args: joinArgs(
+ []string{"ops", "verify"},
+ ceremonyTrust,
+ []string{
+ "--record-type", "receipt",
+ "--record", "ops/receipt.json",
+ "--signature", "ops/receipt.sig",
+ "--signer-public-key-file", "trust/participant.pub",
+ "--related-record", "ops/handoff.json",
+ },
+ ),
+ command: CommandOpsVerify,
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+ invocation, err := parseInvocation(test.args)
+ if err != nil {
+ t.Fatalf("parseInvocation() error = %v", err)
+ }
+ if invocation.Command != test.command {
+ t.Fatalf("command = %q, want %q", invocation.Command, test.command)
+ }
+ if invocation.Options == nil {
+ t.Fatal("options are nil")
+ }
+ })
+ }
+}
+
+func TestParseInvocationRejectsMissingExplicitPaths(t *testing.T) {
+ t.Parallel()
+
+ _, err := parseInvocation([]string{"phase1", "contribute", "--participant-id", "p1"})
+ if err == nil {
+ t.Fatal("parseInvocation() accepted missing paths")
+ }
+ for _, expected := range []string{"--ceremony", "--transcript-dir", "--chain", "--out-dir"} {
+ if !strings.Contains(err.Error(), expected) {
+ t.Errorf("error %q does not mention %s", err, expected)
+ }
+ }
+}
+
+func TestParseInvocationRejectsStreamsURLsAndForce(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {
+ name: "standard stream",
+ args: []string{
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "-", "--policy", "policy.json",
+ "--coordinator-key-id", "key-id",
+ "--coordinator-signing-key", "key", "--out-dir", "out",
+ },
+ want: "standard input/output is not supported",
+ },
+ {
+ name: "URL",
+ args: []string{
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "https://example.invalid/roster.json",
+ "--policy", "policy.json", "--coordinator-key-id", "key-id",
+ "--coordinator-signing-key", "key", "--out-dir", "out",
+ },
+ want: "URLs are not supported",
+ },
+ {
+ name: "force",
+ args: []string{
+ "init", "--force", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ },
+ want: "flag provided but not defined: -force",
+ },
+ {
+ name: "operator beacon randomness",
+ args: []string{
+ "phase1", "beacon", "--randomness-hex", strings.Repeat("ab", 32),
+ },
+ want: "flag provided but not defined: -randomness-hex",
+ },
+ {
+ name: "operator beacon challenge",
+ args: []string{
+ "phase2", "beacon", "--challenge", "chosen-by-operator",
+ },
+ want: "flag provided but not defined: -challenge",
+ },
+ {
+ name: "unauthenticated replay override",
+ args: []string{
+ "finalize", "complete", "--phase1-contribution", "substituted.bin",
+ },
+ want: "flag provided but not defined: -phase1-contribution",
+ },
+ }
+
+ for _, test := range tests {
+ test := test
+ 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 substring %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestReleaseSignRequiresPairedIndependentAudits(t *testing.T) {
+ t.Parallel()
+
+ base := []string{
+ "release", "sign",
+ "--ceremony", "ceremony.json",
+ "--ceremony-signature", "ceremony.sig",
+ "--coordinator-public-key-file", "coordinator.pub",
+ "--candidate-bundle", "candidate",
+ "--operational-evidence-root", "operational-input",
+ "--operational-bundle", "operational-input/operational/evidence-bundle.json",
+ "--operational-bundle-signature", "operational-input/operational/evidence-bundle.sig",
+ "--release-signing-key", "release.key",
+ "--signature-key-id", "release-2026",
+ "--released-at", "2026-07-28T12:00:00Z",
+ "--release-dir", "release",
+ }
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {
+ name: "one audit",
+ args: append(append([]string(nil), base...),
+ "--audit-report", "audit-1.json",
+ "--audit-signature", "audit-1.sig",
+ ),
+ want: "at least twice",
+ },
+ {
+ name: "mismatched signatures",
+ args: append(append([]string(nil), base...),
+ "--audit-report", "audit-1.json",
+ "--audit-report", "audit-2.json",
+ "--audit-signature", "audit-1.sig",
+ ),
+ want: "counts must match",
+ },
+ }
+ for _, test := range tests {
+ test := test
+ 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 substring %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestInitUsesContentAddressedIdentityInputs(t *testing.T) {
+ t.Parallel()
+
+ base := []string{
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "participants.json", "--policy", "policy.json",
+ "--coordinator-key-id", "coordinator",
+ "--coordinator-signing-key", "coordinator.key", "--out-dir", "out",
+ }
+ invocation, err := parseInvocation(base)
+ if err != nil {
+ t.Fatalf("parseInvocation() error = %v", err)
+ }
+ options := invocation.Options.(InitOptions)
+ if options.SessionNonceHex != "" {
+ t.Fatalf("session nonce = %q, want executor-generated empty input", options.SessionNonceHex)
+ }
+
+ withNonce := append(append([]string(nil), base...), "--session-nonce-hex", strings.Repeat("ab", 32))
+ invocation, err = parseInvocation(withNonce)
+ if err != nil {
+ t.Fatalf("parseInvocation() with nonce error = %v", err)
+ }
+ options = invocation.Options.(InitOptions)
+ if options.SessionNonceHex != strings.Repeat("ab", 32) {
+ t.Fatalf("session nonce = %q", options.SessionNonceHex)
+ }
+
+ for name, args := range map[string][]string{
+ "user ceremony id": append(append([]string(nil), base...), "--ceremony-id", "operator-label"),
+ "short nonce": append(append([]string(nil), base...), "--session-nonce-hex", "abcd"),
+ "wrong key version": {
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", "ownership-v1",
+ "--participants", "participants.json", "--policy", "policy.json",
+ "--coordinator-key-id", "coordinator",
+ "--coordinator-signing-key", "coordinator.key", "--out-dir", "out",
+ },
+ } {
+ name, args := name, args
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ if _, err := parseInvocation(args); err == nil {
+ t.Fatal("parseInvocation() accepted invalid identity input")
+ }
+ })
+ }
+}
+
+func TestRunCLIHelpDoesNotExecute(t *testing.T) {
+ t.Parallel()
+
+ var stdout, stderr bytes.Buffer
+ executor := executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ t.Fatal("executor called for help")
+ return CommandResult{}, nil
+ })
+ exitCode := runCLI(context.Background(), []string{"help", "phase1", "contribute"}, &stdout, &stderr, executor)
+ if exitCode != 0 {
+ t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "phase1 contribute") {
+ t.Fatalf("help output = %q", stdout.String())
+ }
+}
+
+func TestRunCLIJSONSuccessIsOneMachineReadableObject(t *testing.T) {
+ t.Parallel()
+
+ args := []string{
+ "--format", "json",
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "participants.json", "--policy", "policy.json",
+ "--coordinator-key-id", "coordinator",
+ "--coordinator-signing-key", "coordinator.key", "--out-dir", "out",
+ }
+ executor := executorFunc(func(_ context.Context, invocation Invocation) (CommandResult, error) {
+ return CommandResult{
+ CeremonyID: "id",
+ Outputs: map[string]string{"ceremony": "out/ceremony.json"},
+ Summary: "initialized",
+ }, nil
+ })
+ var stdout, stderr bytes.Buffer
+ exitCode := runCLI(context.Background(), args, &stdout, &stderr, executor)
+ if exitCode != 0 {
+ t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String())
+ }
+ var result CommandResult
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("stdout is not one JSON object: %v; stdout = %q", err, stdout.String())
+ }
+ if result.Schema != commandResultSchema || !result.OK || result.Command != CommandInit {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want empty", stderr.String())
+ }
+}
+
+func TestRunCLIReportsUnwiredEngineExplicitly(t *testing.T) {
+ t.Parallel()
+
+ args := []string{
+ "--format", "json",
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "participants.json", "--policy", "policy.json",
+ "--coordinator-key-id", "coordinator",
+ "--coordinator-signing-key", "coordinator.key", "--out-dir", "out",
+ }
+ var stdout, stderr bytes.Buffer
+ exitCode := runCLI(context.Background(), args, &stdout, &stderr, nil)
+ if exitCode != 6 {
+ t.Fatalf("exit code = %d, want 6", exitCode)
+ }
+ if !strings.Contains(stdout.String(), `"code":"engine_not_wired"`) {
+ t.Fatalf("stdout = %q", stdout.String())
+ }
+ if !errors.Is(errExecutorNotWired, errExecutorNotWired) {
+ t.Fatal("sentinel error is not stable")
+ }
+}
+
+func TestRunCLIJSONUsageErrorIsMachineReadable(t *testing.T) {
+ t.Parallel()
+
+ var stdout, stderr bytes.Buffer
+ exitCode := runCLI(
+ context.Background(),
+ []string{"--format=json", "phase1", "contribute"},
+ &stdout,
+ &stderr,
+ executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ t.Fatal("executor called for invalid invocation")
+ return CommandResult{}, nil
+ }),
+ )
+ if exitCode != 2 {
+ t.Fatalf("exit code = %d, want 2", exitCode)
+ }
+ var result struct {
+ Schema string `json:"schema"`
+ OK bool `json:"ok"`
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatalf("stdout is not JSON: %v; stdout = %q", err, stdout.String())
+ }
+ if result.Schema != commandResultSchema || result.OK || result.Error.Code != "usage_error" {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want empty", stderr.String())
+ }
+}
+
+func TestRunCLIErrorOutputRedactsCallerControlledValues(t *testing.T) {
+ t.Parallel()
+
+ validErasureArgs := func(participantID, signingKey string) []string {
+ return []string{
+ "phase1", "attest-erasure",
+ "--ceremony", "ceremony.json",
+ "--ceremony-signature", "ceremony.sig",
+ "--coordinator-public-key-file", "coordinator.pub",
+ "--participant-id", participantID,
+ "--participant-signing-key", signingKey,
+ "--candidate-dir", "candidate",
+ "--destroyed-at", "2026-07-23T12:00:00Z",
+ }
+ }
+ tests := []struct {
+ name string
+ sentinel string
+ args []string
+ executor Executor
+ }{
+ {
+ name: "unexpected positional",
+ sentinel: "position-SENSITIVE-SENTINEL",
+ args: []string{"phase1", "attest-erasure", "position-SENSITIVE-SENTINEL"},
+ executor: executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ t.Fatal("executor called for invalid positionals")
+ return CommandResult{}, nil
+ }),
+ },
+ {
+ name: "unknown command",
+ sentinel: "unknown-SENSITIVE-SENTINEL",
+ args: []string{"unknown-SENSITIVE-SENTINEL"},
+ executor: executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ t.Fatal("executor called for unknown command")
+ return CommandResult{}, nil
+ }),
+ },
+ {
+ name: "participant lookup",
+ sentinel: "participant-SENSITIVE-SENTINEL",
+ args: validErasureArgs("participant-SENSITIVE-SENTINEL", "participant.key"),
+ executor: executorFunc(func(_ context.Context, invocation Invocation) (CommandResult, error) {
+ options := invocation.Options.(ErasureOptions)
+ return CommandResult{}, errors.New("participant lookup failed: " + options.ParticipantID)
+ }),
+ },
+ {
+ name: "participant value matching command word",
+ sentinel: "close",
+ args: validErasureArgs("close", "participant.key"),
+ executor: executorFunc(func(_ context.Context, invocation Invocation) (CommandResult, error) {
+ options := invocation.Options.(ErasureOptions)
+ return CommandResult{}, errors.New("participant lookup failed: " + options.ParticipantID)
+ }),
+ },
+ {
+ name: "participant signing key",
+ sentinel: "key-SENSITIVE-SENTINEL",
+ args: validErasureArgs("participant-01", "key-SENSITIVE-SENTINEL"),
+ executor: executorFunc(func(_ context.Context, invocation Invocation) (CommandResult, error) {
+ options := invocation.Options.(ErasureOptions)
+ return CommandResult{}, errors.New("participant signing key failed: " + options.ParticipantSigningKey)
+ }),
+ },
+ }
+ for _, format := range []string{"human", "json"} {
+ format := format
+ for _, tc := range tests {
+ tc := tc
+ t.Run(format+"/"+tc.name, func(t *testing.T) {
+ t.Parallel()
+ args := append([]string(nil), tc.args...)
+ if format == "json" {
+ args = append([]string{"--format=json"}, args...)
+ }
+ var stdout, stderr bytes.Buffer
+ exitCode := runCLI(context.Background(), args, &stdout, &stderr, tc.executor)
+ if exitCode != 2 && exitCode != 6 {
+ t.Fatalf("exit code = %d, stdout = %q, stderr = %q", exitCode, stdout.String(), stderr.String())
+ }
+ combined := stdout.String() + stderr.String()
+ if strings.Contains(combined, tc.sentinel) {
+ t.Fatalf("caller-controlled value leaked in output: %q", combined)
+ }
+ if !strings.Contains(combined, "redacted") {
+ t.Fatalf("output did not mark the redaction: %q", combined)
+ }
+ if format == "json" {
+ var payload any
+ if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
+ t.Fatalf("stdout is not JSON: %v; stdout = %q", err, stdout.String())
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want empty", stderr.String())
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestRunCLIRejectsHelpOutputFailure(t *testing.T) {
+ t.Parallel()
+
+ var stderr bytes.Buffer
+ exitCode := runCLI(
+ context.Background(),
+ []string{"help"},
+ failingWriter{},
+ &stderr,
+ executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ t.Fatal("executor called for help")
+ return CommandResult{}, nil
+ }),
+ )
+ if exitCode != 6 {
+ t.Fatalf("exit code = %d, want 6", exitCode)
+ }
+ if !strings.Contains(stderr.String(), "write help") {
+ t.Fatalf("stderr = %q, want write failure", stderr.String())
+ }
+}
+
+func TestRunCLIRejectsResultOutputFailure(t *testing.T) {
+ t.Parallel()
+
+ args := []string{
+ "init", "--created-at", "2026-07-23T11:00:00Z", "--key-version", supportedKeyVersion,
+ "--participants", "participants.json", "--policy", "policy.json",
+ "--coordinator-key-id", "coordinator",
+ "--coordinator-signing-key", "coordinator.key", "--out-dir", "out",
+ }
+ var stderr bytes.Buffer
+ exitCode := runCLI(
+ context.Background(),
+ args,
+ failingWriter{},
+ &stderr,
+ executorFunc(func(context.Context, Invocation) (CommandResult, error) {
+ return CommandResult{Summary: "initialized"}, nil
+ }),
+ )
+ if exitCode != 6 {
+ t.Fatalf("exit code = %d, want 6", exitCode)
+ }
+ if !strings.Contains(stderr.String(), "write command result") {
+ t.Fatalf("stderr = %q, want write failure", stderr.String())
+ }
+}
+
+type failingWriter struct{}
+
+func (failingWriter) Write([]byte) (int, error) {
+ return 0, io.ErrClosedPipe
+}
+
+func joinArgs(parts ...[]string) []string {
+ var result []string
+ for _, part := range parts {
+ result = append(result, part...)
+ }
+ return result
+}
diff --git a/cmd/mpc-ceremony/decision.go b/cmd/mpc-ceremony/decision.go
new file mode 100644
index 00000000..4a34fd9c
--- /dev/null
+++ b/cmd/mpc-ceremony/decision.go
@@ -0,0 +1,173 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/mpcceremony"
+)
+
+func executeDecisionPrepare(options DecisionPrepareOptions) (CommandResult, error) {
+ trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{
+ DefinitionPath: options.CeremonyPath,
+ DefinitionSignaturePath: options.CeremonySignaturePath,
+ CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ draftBytes, err := readRegularOperationalFile(options.DraftPath, maxOperationalRecordBytes)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ decision, decisionBytes, err := mpcceremony.PrepareProductionDecision(
+ trusted.Definition,
+ draftBytes,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if err := writeFreshOperationalFile(options.OutPath, decisionBytes, 0o600); err != nil {
+ return CommandResult{}, err
+ }
+ return decisionCommandResult(
+ decision,
+ fmt.Sprintf(
+ "prepared exact canonical %s production decision for independent signing",
+ decision.Decision,
+ ),
+ map[string]string{
+ "draft": options.DraftPath,
+ "decision": options.OutPath,
+ },
+ ), nil
+}
+
+func executeDecisionSign(options DecisionSignOptions) (CommandResult, error) {
+ trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{
+ DefinitionPath: options.CeremonyPath,
+ DefinitionSignaturePath: options.CeremonySignaturePath,
+ CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ decisionBytes, err := readRegularOperationalFile(options.DecisionPath, maxOperationalRecordBytes)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ var decision mpcceremony.ProductionDecision
+ if err := mpcceremony.UnmarshalCanonical(decisionBytes, &decision); err != nil {
+ return CommandResult{}, err
+ }
+ if decision.Decision == mpcceremony.DecisionGO && options.EvidenceRoot == "" {
+ return CommandResult{}, fmt.Errorf("--evidence-root is required before signing a GO decision")
+ }
+ if options.EvidenceRoot != "" {
+ if _, err := mpcceremony.VerifyProductionDecisionEvidence(
+ mpcceremony.VerifyProductionDecisionEvidenceOptions{
+ Definition: trusted.Definition,
+ DecisionBytes: decisionBytes,
+ EvidenceRoot: options.EvidenceRoot,
+ },
+ ); err != nil {
+ return CommandResult{}, fmt.Errorf("refuse to sign unverified decision evidence: %w", err)
+ }
+ }
+ privateKey, _, err := keybundle.LoadExistingPrivateKey(options.SigningKey)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ signatureBytes, err := mpcceremony.SignProductionDecision(
+ trusted.Definition,
+ decisionBytes,
+ mpcceremony.DecisionSignerRole(options.Role),
+ options.SignerID,
+ privateKey,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if err := writeFreshOperationalFile(options.OutPath, signatureBytes, 0o600); err != nil {
+ return CommandResult{}, err
+ }
+ return decisionCommandResult(
+ decision,
+ fmt.Sprintf(
+ "signed exact canonical %s production decision as %s",
+ decision.Decision,
+ options.Role,
+ ),
+ map[string]string{
+ "decision": options.DecisionPath,
+ "signature": options.OutPath,
+ },
+ ), nil
+}
+
+func decisionCommandResult(
+ decision mpcceremony.ProductionDecision,
+ summary string,
+ outputs map[string]string,
+) CommandResult {
+ return CommandResult{
+ CeremonyID: decision.CeremonyID,
+ Decision: string(decision.Decision),
+ DecisionID: decision.DecisionID,
+ ReleaseID: decision.Release.ReleaseID,
+ CandidateID: decision.Release.CandidateID,
+ SourceCommit: decision.SourceRelease.SourceCommit,
+ SourceSignedTag: decision.SourceRelease.SignedTag,
+ SourceTagSignerFingerprint: decision.SourceRelease.SignerFingerprintHex,
+ SourceTagObjectSHA256: decision.SourceRelease.SignedTagObject.Artifact.Digest.SHA256,
+ Summary: summary,
+ Outputs: outputs,
+ }
+}
+
+func executeDecisionVerify(options DecisionVerifyOptions) (CommandResult, error) {
+ trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{
+ DefinitionPath: options.CeremonyPath,
+ DefinitionSignaturePath: options.CeremonySignaturePath,
+ CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ decisionBytes, err := readRegularOperationalFile(options.DecisionPath, maxOperationalRecordBytes)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ signatures := make([][]byte, len(options.SignaturePaths))
+ for index, path := range options.SignaturePaths {
+ signatures[index], err = readRegularOperationalFile(path, maxOperationalRecordBytes)
+ if err != nil {
+ return CommandResult{}, fmt.Errorf("decision signature %d: %w", index, err)
+ }
+ }
+ verified, err := mpcceremony.VerifyProductionDecision(mpcceremony.VerifyProductionDecisionOptions{
+ Definition: trusted.Definition,
+ DecisionBytes: decisionBytes,
+ SignatureBytes: signatures,
+ EvidenceRoot: options.EvidenceRoot,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return decisionCommandResult(
+ verified.Decision,
+ fmt.Sprintf(
+ "verified %s production decision, %d exact role signatures, and %d pinned evidence artifacts",
+ verified.Decision.Decision,
+ len(verified.VerifiedSigners),
+ len(verified.VerifiedArtifacts),
+ ),
+ map[string]string{
+ "decision": options.DecisionPath,
+ "evidence_root": options.EvidenceRoot,
+ },
+ ), nil
+}
diff --git a/cmd/mpc-ceremony/decision_test.go b/cmd/mpc-ceremony/decision_test.go
new file mode 100644
index 00000000..5494212b
--- /dev/null
+++ b/cmd/mpc-ceremony/decision_test.go
@@ -0,0 +1,422 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "proof-tool/internal/mpcceremony"
+)
+
+func TestDecisionSignCLIAuthenticatesDefinitionRoleAndExactBytes(t *testing.T) {
+ root := t.TempDir()
+ definition, decisionBytes, coordinatorKey := decisionSignFixture(t)
+ definitionBytes, definitionSignatureBytes, err := mpcceremony.SignRecord(
+ definition,
+ definition.Coordinator.KeyID,
+ coordinatorKey,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ ceremonyPath := filepath.Join(root, "ceremony.json")
+ ceremonySignaturePath := filepath.Join(root, "ceremony.sig.json")
+ coordinatorPublicKeyPath := filepath.Join(root, "coordinator-public-key.hex")
+ decisionDraftPath := filepath.Join(root, "decision.draft.json")
+ decisionPath := filepath.Join(root, "decision.json")
+ signingKeyPath := filepath.Join(root, "coordinator-private-key.hex")
+ outputPath := filepath.Join(root, "coordinator-decision.sig.json")
+ writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600)
+ writeDecisionTestFile(t, ceremonySignaturePath, definitionSignatureBytes, 0o600)
+ writeDecisionTestFile(
+ t,
+ coordinatorPublicKeyPath,
+ []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"),
+ 0o600,
+ )
+ var expectedDecision mpcceremony.ProductionDecision
+ if err := mpcceremony.UnmarshalCanonical(decisionBytes, &expectedDecision); err != nil {
+ t.Fatal(err)
+ }
+ draftBytes, err := mpcceremony.MarshalCanonical(decisionDraft(expectedDecision))
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeDecisionTestFile(t, decisionDraftPath, draftBytes, 0o600)
+ writeDecisionTestFile(t, signingKeyPath, []byte(hex.EncodeToString(coordinatorKey)+"\n"), 0o600)
+
+ trustArgs := []string{
+ "--ceremony", ceremonyPath,
+ "--ceremony-signature", ceremonySignaturePath,
+ "--coordinator-public-key-file", coordinatorPublicKeyPath,
+ }
+ prepareArgs := append(
+ []string{"--format", "json", "decision", "prepare"},
+ trustArgs...,
+ )
+ prepareArgs = append(
+ prepareArgs,
+ "--draft", decisionDraftPath,
+ "--out", decisionPath,
+ )
+ var prepareStdout, prepareStderr bytes.Buffer
+ if code := runCLI(
+ context.Background(),
+ prepareArgs,
+ &prepareStdout,
+ &prepareStderr,
+ workflowExecutor{},
+ ); code != 0 {
+ t.Fatalf(
+ "decision prepare exit = %d, stdout = %q, stderr = %q",
+ code,
+ prepareStdout.String(),
+ prepareStderr.String(),
+ )
+ }
+ preparedBytes, err := os.ReadFile(decisionPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(preparedBytes, decisionBytes) {
+ t.Fatal("decision prepare did not derive the exact expected canonical record")
+ }
+
+ args := []string{
+ "--format", "json",
+ "decision", "sign",
+ "--ceremony", ceremonyPath,
+ "--ceremony-signature", ceremonySignaturePath,
+ "--coordinator-public-key-file", coordinatorPublicKeyPath,
+ "--decision", decisionPath,
+ "--role", "coordinator",
+ "--signer-id", definition.Coordinator.ID,
+ "--signing-key", signingKeyPath,
+ "--out", outputPath,
+ }
+ var stdout, stderr bytes.Buffer
+ if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 {
+ t.Fatalf("decision sign 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 != CommandDecisionSign ||
+ result.Decision != string(mpcceremony.DecisionNOGO) ||
+ result.CeremonyID != definition.CeremonyID {
+ t.Fatalf("decision sign result = %+v", result)
+ }
+ signatureBytes, err := os.ReadFile(outputPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var signature mpcceremony.ProductionDecisionSignature
+ if err := mpcceremony.UnmarshalCanonical(signatureBytes, &signature); err != nil {
+ t.Fatal(err)
+ }
+ publicKey := coordinatorKey.Public().(ed25519.PublicKey)
+ if err := mpcceremony.VerifyExact(
+ decisionBytes,
+ signature.Signature,
+ definition.Coordinator.KeyID,
+ publicKey,
+ ); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDecisionVerifyCLIRequiresAndRoutesExplicitSignatures(t *testing.T) {
+ args := []string{
+ "--format", "json",
+ "decision", "verify",
+ "--ceremony", "ceremony.json",
+ "--ceremony-signature", "ceremony.sig.json",
+ "--coordinator-public-key-file", "coordinator.pub",
+ "--decision", "decision.json",
+ "--signature", "coordinator.sig.json",
+ "--signature", "auditor-01.sig.json",
+ "--signature", "auditor-02.sig.json",
+ "--signature", "release.sig.json",
+ "--evidence-root", "evidence",
+ }
+ executor := executorFunc(func(_ context.Context, invocation Invocation) (CommandResult, error) {
+ if invocation.Command != CommandDecisionVerify {
+ t.Fatalf("command = %q", invocation.Command)
+ }
+ options := invocation.Options.(DecisionVerifyOptions)
+ if len(options.SignaturePaths) != 4 || options.EvidenceRoot != "evidence" {
+ t.Fatalf("decision verify options = %+v", options)
+ }
+ return CommandResult{
+ Decision: string(mpcceremony.DecisionGO),
+ DecisionID: "sha256:" + strings.Repeat("11", 32),
+ ReleaseID: "sha256:" + strings.Repeat("22", 32),
+ }, nil
+ })
+ var stdout, stderr bytes.Buffer
+ if code := runCLI(context.Background(), args, &stdout, &stderr, executor); code != 0 {
+ t.Fatalf("decision verify exit = %d, stderr = %q", code, stderr.String())
+ }
+ var result CommandResult
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
+ t.Fatal(err)
+ }
+ if !result.OK || result.Command != CommandDecisionVerify ||
+ result.Decision != string(mpcceremony.DecisionGO) {
+ t.Fatalf("decision verify result = %+v", result)
+ }
+}
+
+func decisionSignFixture(t *testing.T) (mpcceremony.CeremonyDefinition, []byte, ed25519.PrivateKey) {
+ t.Helper()
+ private := func(fill byte) ed25519.PrivateKey {
+ return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{fill}, ed25519.SeedSize))
+ }
+ identity := func(id string, fill byte) mpcceremony.Identity {
+ value, err := mpcceremony.NewIdentity(
+ id,
+ "Test "+id,
+ id+"-key",
+ private(fill).Public().(ed25519.PublicKey),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ return value
+ }
+ coordinator := identity("coordinator", 0x01)
+ releaseSigner := identity("release-signer", 0x02)
+ auditors := []mpcceremony.Identity{identity("auditor-01", 0x03), identity("auditor-02", 0x04)}
+ participants := []mpcceremony.Participant{
+ {Identity: identity("participant-01", 0x11)},
+ {Identity: identity("participant-02", 0x12)},
+ {Identity: identity("participant-03", 0x13)},
+ }
+ definition, err := mpcceremony.NewCeremonyDefinition(mpcceremony.DefinitionOptions{
+ Mode: mpcceremony.ModeProduction,
+ 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,
+ InternalVariables: 3,
+ SecretVariables: 2,
+ PublicVariables: 1,
+ DomainSize: 1 << 21,
+ Phase2Shape: mpcceremony.Phase2Shape{
+ Commitments: 1, PKK: 1, Z: 7, SigmaCKK: []uint32{1},
+ },
+ },
+ Software: mpcceremony.SoftwareBinding{
+ ProofToolVersion: "0.1.0", GnarkVersion: mpcceremony.GnarkVersion,
+ GnarkCryptoVersion: mpcceremony.GnarkCryptoVersion, DrandVersion: mpcceremony.DrandVersion,
+ GoVersion: mpcceremony.ProductionGoVersion, GoOS: mpcceremony.ProductionGOOS,
+ GoArch: mpcceremony.ProductionGOARCH, GoAMD64: mpcceremony.ProductionGOAMD64,
+ Compiler: mpcceremony.ProductionCompiler, BuildMode: mpcceremony.ProductionBuildMode,
+ TrimPath: true, SourceCommit: strings.Repeat("6b", 20),
+ ToolBinary: mpcceremony.NewDigest([]byte("binary")),
+ },
+ Coordinator: coordinator, ReleaseSigner: releaseSigner, Auditors: auditors, Roster: participants,
+ Phase1Policy: mpcceremony.PhasePolicy{
+ Participants: []string{"participant-01", "participant-02", "participant-03"}, Minimum: 3,
+ },
+ Phase2Policy: mpcceremony.PhasePolicy{
+ Participants: []string{"participant-01", "participant-02", "participant-03"}, Minimum: 3,
+ },
+ 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: mpcceremony.ProductionMinimumWitnessLeadSeconds,
+ FutureRoundRequired: true,
+ },
+ Phase1Genesis: decisionArtifact("phase1/genesis.bin", "genesis").Artifact,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ release, err := mpcceremony.NewSignedReleaseEvidence(mpcceremony.SignedReleaseEvidence{
+ CandidateID: "sha256:" + strings.Repeat("71", 32),
+ Manifest: decisionArtifact("release/manifest.json", "manifest"),
+ ManifestSignature: decisionArtifact("release/manifest.sig", "manifest sig"),
+ ManifestPublicKey: decisionArtifact("release/manifest-public-key.hex", "manifest pub"),
+ Candidate: mpcceremony.SignedLocatedArtifact{
+ Record: decisionArtifact("release/candidate.json", "candidate"),
+ Signature: decisionArtifact("release/candidate.sig.json", "candidate sig"),
+ },
+ FinalTranscript: decisionArtifact("release/final-transcript.json", "transcript"),
+ Artifacts: decisionReleaseArtifacts(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ gates := make([]mpcceremony.ProductionGateResult, 14)
+ gateNames := []mpcceremony.ProductionGate{
+ mpcceremony.GateSignedRelease, mpcceremony.GateOperationalEvidence,
+ mpcceremony.GateIndependentAudits, mpcceremony.GateExternalAudit,
+ mpcceremony.GateK21Rehearsal, mpcceremony.GateMainnetDeploymentPlan,
+ mpcceremony.GateFormalChecklist, mpcceremony.GateParticipantIndependent,
+ mpcceremony.GateParticipantHost, mpcceremony.GateParticipantEntropy,
+ mpcceremony.GateParticipantErasure, mpcceremony.GatePublicWitnessing,
+ mpcceremony.GateImmutableMirrors, mpcceremony.GateLiveTwentyParty,
+ }
+ for index, gate := range gateNames {
+ gates[index] = mpcceremony.ProductionGateResult{
+ Gate: gate, Status: mpcceremony.GatePENDING, Rationale: "External evidence remains pending.",
+ }
+ }
+ decision, err := mpcceremony.NewProductionDecision(mpcceremony.ProductionDecision{
+ CeremonyID: definition.CeremonyID,
+ Release: release,
+ SourceRelease: mpcceremony.SourceReleaseEvidence{
+ SourceCommit: definition.Software.SourceCommit, SignedTag: "v1.0.0-mainnet",
+ SignatureFormat: "openpgp-primary-key-v4", SignerFingerprintHex: strings.Repeat("ab", 20),
+ SignedTagObject: decisionArtifact("source/v1.0.0-mainnet.tag", "tag object"),
+ },
+ OperationalEvidence: mpcceremony.SignedLocatedArtifact{
+ Record: decisionArtifact("operational/bundle.json", "bundle"),
+ Signature: decisionArtifact("operational/bundle.sig.json", "bundle sig"),
+ },
+ Audits: []mpcceremony.ProductionAuditEvidence{
+ {AuditorID: auditors[0].ID, AuditorKeyID: auditors[0].KeyID, Audit: mpcceremony.SignedLocatedArtifact{
+ Record: decisionArtifact("audits/auditor-01.json", "audit1"),
+ Signature: decisionArtifact("audits/auditor-01.sig.json", "audit1 sig"),
+ }},
+ {AuditorID: auditors[1].ID, AuditorKeyID: auditors[1].KeyID, Audit: mpcceremony.SignedLocatedArtifact{
+ Record: decisionArtifact("audits/auditor-02.json", "audit2"),
+ Signature: decisionArtifact("audits/auditor-02.sig.json", "audit2 sig"),
+ }},
+ },
+ ExternalAudits: []mpcceremony.ExternalAuditEvidence{
+ {
+ Auditor: identity("external-auditor-a", 0x05),
+ Report: decisionArtifact("external/audit-a.pdf", "external audit a"),
+ Signoff: decisionArtifact("external/audit-a.sig.json", "external signoff a"),
+ },
+ {
+ Auditor: identity("external-auditor-b", 0x06),
+ Report: decisionArtifact("external/audit-b.pdf", "external audit b"),
+ Signoff: decisionArtifact("external/audit-b.sig.json", "external signoff b"),
+ },
+ },
+ K21Rehearsal: mpcceremony.K21RehearsalEvidence{
+ KeyVersion: definition.Circuit.KeyVersion, CircuitID: definition.Circuit.CircuitID,
+ Curve: definition.Circuit.Curve, Backend: definition.Circuit.Backend,
+ Constraints: definition.Circuit.Constraints, DomainSize: definition.Circuit.DomainSize,
+ Evidence: decisionArtifact("rehearsal/k21.tar.zst", "rehearsal"),
+ },
+ MainnetDeploymentPlan: decisionArtifact("governance/deployment-plan.md", "plan"),
+ FormalChecklist: decisionArtifact("governance/checklist.md", "checklist"),
+ Gates: gates,
+ Decision: mpcceremony.DecisionNOGO,
+ DecidedAt: "2026-07-23T16:00:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ record, err := mpcceremony.MarshalCanonical(decision)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return definition, record, private(0x01)
+}
+
+func decisionArtifact(name, content string) mpcceremony.LocatedArtifactRef {
+ return mpcceremony.LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest([]byte(content))},
+ }
+}
+
+func decisionReleaseArtifacts() []mpcceremony.LocatedArtifactRef {
+ names := []string{
+ "release/audits/auditor-01.json",
+ "release/audits/auditor-01.sig.json",
+ "release/audits/auditor-02.json",
+ "release/audits/auditor-02.sig.json",
+ "release/candidate-checksums.sha256",
+ "release/candidate.json",
+ "release/candidate.sig.json",
+ "release/cardano-vk-format.txt",
+ "release/cardano-vk.bin",
+ "release/cardano-vk.hex",
+ "release/checksums.sha256",
+ "release/final-transcript.json",
+ "release/manifest-public-key.hex",
+ "release/manifest.json",
+ "release/manifest.sig",
+ "release/operational/bundle.json",
+ "release/operational/bundle.sig.json",
+ "release/ownership.ccs",
+ "release/ownership.pk",
+ "release/ownership.vk",
+ "release/phase2-seal.json",
+ "release/public-evidence.json",
+ "release/verification-report.json",
+ }
+ result := make([]mpcceremony.LocatedArtifactRef, len(names))
+ exactContent := map[string]string{
+ "release/candidate.json": "candidate",
+ "release/candidate.sig.json": "candidate sig",
+ "release/final-transcript.json": "transcript",
+ "release/manifest-public-key.hex": "manifest pub",
+ "release/manifest.json": "manifest",
+ "release/manifest.sig": "manifest sig",
+ }
+ for index, name := range names {
+ content := exactContent[name]
+ if content == "" {
+ content = "release tree " + name
+ }
+ result[index] = decisionArtifact(name, content)
+ }
+ return result
+}
+
+func decisionDraft(decision mpcceremony.ProductionDecision) mpcceremony.ProductionDecisionDraft {
+ return mpcceremony.ProductionDecisionDraft{
+ Schema: mpcceremony.ProductionDecisionDraftSchema,
+ CeremonyID: decision.CeremonyID,
+ Release: mpcceremony.SignedReleaseEvidenceDraft{
+ CandidateID: decision.Release.CandidateID,
+ Manifest: decision.Release.Manifest,
+ ManifestSignature: decision.Release.ManifestSignature,
+ ManifestPublicKey: decision.Release.ManifestPublicKey,
+ Candidate: decision.Release.Candidate,
+ FinalTranscript: decision.Release.FinalTranscript,
+ Artifacts: decision.Release.Artifacts,
+ },
+ SourceRelease: decision.SourceRelease,
+ OperationalEvidence: decision.OperationalEvidence,
+ Audits: decision.Audits,
+ ExternalAudits: decision.ExternalAudits,
+ K21Rehearsal: decision.K21Rehearsal,
+ MainnetDeploymentPlan: decision.MainnetDeploymentPlan,
+ FormalChecklist: decision.FormalChecklist,
+ Gates: decision.Gates,
+ Decision: decision.Decision,
+ DecidedAt: decision.DecidedAt,
+ }
+}
+
+func writeDecisionTestFile(t *testing.T, path string, data []byte, mode os.FileMode) {
+ t.Helper()
+ if err := os.WriteFile(path, data, mode); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go
new file mode 100644
index 00000000..f1901942
--- /dev/null
+++ b/cmd/mpc-ceremony/executor.go
@@ -0,0 +1,792 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "proof-tool/internal/mpcceremony"
+)
+
+const proofToolVersion = "0.1.0"
+
+type workflowExecutor struct{}
+
+func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (CommandResult, error) {
+ if err := ctx.Err(); err != nil {
+ return CommandResult{}, err
+ }
+ switch invocation.Command {
+ case CommandInit:
+ return executeInit(invocation.Options.(InitOptions))
+ case CommandPhase1Contribute:
+ return executeContribution(mpcceremony.Phase1, invocation.Options.(ContributeOptions))
+ case CommandPhase1Erasure:
+ return executeErasure(mpcceremony.Phase1, invocation.Options.(ErasureOptions))
+ case CommandPhase1Verify:
+ return executeAccept(mpcceremony.Phase1, invocation.Options.(VerifyContributionOptions))
+ case CommandPhase1Close:
+ return executeClose(mpcceremony.Phase1, invocation.Options.(CloseOptions))
+ case CommandPhase1Beacon:
+ return executeBeacon(mpcceremony.Phase1, invocation.Options.(BeaconOptions))
+ case CommandPhase1Seal:
+ return executePhase1Seal(invocation.Options.(Phase1SealOptions))
+ case CommandPhase2Init:
+ return executePhase2Init(invocation.Options.(Phase2InitOptions))
+ case CommandPhase2Contribute:
+ return executeContribution(mpcceremony.Phase2, invocation.Options.(ContributeOptions))
+ case CommandPhase2Erasure:
+ return executeErasure(mpcceremony.Phase2, invocation.Options.(ErasureOptions))
+ case CommandPhase2Verify:
+ return executeAccept(mpcceremony.Phase2, invocation.Options.(VerifyContributionOptions))
+ case CommandPhase2Close:
+ return executeClose(mpcceremony.Phase2, invocation.Options.(CloseOptions))
+ case CommandPhase2Beacon:
+ return executeBeacon(mpcceremony.Phase2, invocation.Options.(BeaconOptions))
+ case CommandFinalizePrepare:
+ return executePrepareFinalization(invocation.Options.(PrepareFinalizationOptions))
+ case CommandFinalizeComplete:
+ return executeFinalize(invocation.Options.(FinalizeOptions))
+ case CommandAudit:
+ return executeAudit(invocation.Options.(AuditOptions))
+ case CommandReleaseSign:
+ return executeReleaseSign(invocation.Options.(ReleaseSignOptions))
+ case CommandReleaseVerify:
+ return executeReleaseVerify(invocation.Options.(ReleaseVerifyOptions))
+ case CommandOpsExportSigning:
+ return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions))
+ case CommandOpsImportSig:
+ return executeOpsImportSignature(invocation.Options.(OpsImportSignatureOptions))
+ case CommandOpsVerify:
+ return executeOpsVerify(invocation.Options.(OpsVerifyOptions))
+ case CommandDecisionPrepare:
+ return executeDecisionPrepare(invocation.Options.(DecisionPrepareOptions))
+ case CommandDecisionSign:
+ return executeDecisionSign(invocation.Options.(DecisionSignOptions))
+ case CommandDecisionVerify:
+ return executeDecisionVerify(invocation.Options.(DecisionVerifyOptions))
+ default:
+ return CommandResult{}, fmt.Errorf("%w: %s", errExecutorNotWired, invocation.Command)
+ }
+}
+
+func executeInit(options InitOptions) (CommandResult, error) {
+ participants, err := mpcceremony.LoadInitParticipants(options.ParticipantsPath)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ policy, err := mpcceremony.LoadInitPolicy(options.PolicyPath)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if participants.Coordinator.KeyID != options.CoordinatorKeyID {
+ return CommandResult{}, fmt.Errorf(
+ "--coordinator-key-id %q does not match participants coordinator key id %q",
+ options.CoordinatorKeyID,
+ participants.Coordinator.KeyID,
+ )
+ }
+ runningSoftware, err := mpcceremony.RunningSoftwareBindingForMode(proofToolVersion, options.Mode)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ nonce, err := sessionNonce(options.SessionNonceHex)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := mpcceremony.CompileDestinationV2()
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{
+ RootDir: options.OutDir,
+ Circuit: circuit,
+ Definition: mpcceremony.DefinitionOptions{
+ Mode: options.Mode,
+ CreatedAt: options.CreatedAt,
+ SessionNonceHex: nonce,
+ Software: runningSoftware,
+ Coordinator: participants.Coordinator,
+ ReleaseSigner: participants.ReleaseSigner,
+ Auditors: participants.Auditors,
+ Roster: participants.Roster,
+ Phase1Policy: policy.Phase1Policy,
+ Phase2Policy: policy.Phase2Policy,
+ BeaconPolicy: policy.BeaconPolicy,
+ },
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Definition.CeremonyID,
+ Summary: "initialized signed MPC ceremony",
+ Outputs: map[string]string{
+ "ceremony": result.DefinitionPath,
+ "ceremony_signature": result.DefinitionSignaturePath,
+ "coordinator_public_key": result.CoordinatorPublicKeyPath,
+ "r1cs": result.R1CSPath,
+ "phase1_genesis": result.Phase1GenesisPath,
+ "phase1_chain": result.Phase1ChainPath,
+ "phase1_chain_signature": result.Phase1ChainSignaturePath,
+ },
+ }, nil
+}
+
+func executeContribution(phase mpcceremony.Phase, options ContributeOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := loadOperationalCircuit(trust, options.TranscriptDir)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ environment, err := mpcceremony.LoadContributionEnvironment(options.EnvironmentPath)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.CreateContributionCandidate(mpcceremony.ContributionFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: phase,
+ Transcript: transcriptPaths(options.TranscriptDir, options.ChainPath, options.ChainSignaturePath),
+ Phase1SealPath: options.Phase1SealPath,
+ Phase1SealSignaturePath: options.Phase1SealSignaturePath,
+ ParticipantID: options.ParticipantID,
+ ParticipantPrivateKeyPath: options.ParticipantSigningKey,
+ Environment: environment,
+ ContributedAt: options.ContributedAt,
+ CandidateDir: options.OutDir,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Attestation.CeremonyID,
+ Phase: string(phase),
+ Sequence: int(result.Attestation.Index),
+ Summary: fmt.Sprintf("created %s contribution candidate", phase),
+ Outputs: map[string]string{
+ "contribution": result.OutputPayloadPath,
+ "attestation": result.AttestationPath,
+ "attestation_signature": result.AttestationSignaturePath,
+ },
+ }, nil
+}
+
+func executeAccept(phase mpcceremony.Phase, options VerifyContributionOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := loadOperationalCircuit(trust, options.TranscriptDir)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.VerifyAndAcceptContribution(mpcceremony.AcceptContributionFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: phase,
+ Transcript: transcriptPaths(options.TranscriptDir, options.ChainPath, options.ChainSignaturePath),
+ Phase1SealPath: options.Phase1SealPath,
+ Phase1SealSignaturePath: options.Phase1SealSignaturePath,
+ CandidateDir: options.CandidateDir,
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ AcceptedAt: options.AcceptedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Record.CeremonyID,
+ Phase: string(phase),
+ Sequence: int(result.Record.Index),
+ Summary: fmt.Sprintf("verified and accepted %s contribution", phase),
+ Outputs: map[string]string{
+ "accepted_contribution": result.AcceptedPayloadPath,
+ "attestation": result.AcceptedAttestationPath,
+ "attestation_signature": result.AcceptedAttestationSignaturePath,
+ "erasure": result.AcceptedErasurePath,
+ "erasure_signature": result.AcceptedErasureSignaturePath,
+ "verification": result.VerificationPath,
+ "chain": result.ChainPath,
+ "chain_signature": result.ChainSignaturePath,
+ },
+ }, nil
+}
+
+func executeErasure(phase mpcceremony.Phase, options ErasureOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.CreateErasureAttestationFiles(mpcceremony.CreateErasureAttestationFilesOptions{
+ Trust: trust,
+ ParticipantID: options.ParticipantID,
+ ParticipantPrivateKeyPath: options.ParticipantSigningKey,
+ CandidateDir: options.CandidateDir,
+ DestroyedAt: options.DestroyedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if result.Erasure.Phase != phase {
+ return CommandResult{}, fmt.Errorf(
+ "candidate phase is %q, but command is scoped to %q",
+ result.Erasure.Phase,
+ phase,
+ )
+ }
+ return CommandResult{
+ CeremonyID: result.Erasure.CeremonyID,
+ Phase: string(phase),
+ Sequence: int(result.Erasure.Index),
+ Summary: fmt.Sprintf("signed participant %s erasure attestation (not proof of erasure)", phase),
+ Outputs: map[string]string{
+ "erasure": result.ErasurePath,
+ "erasure_signature": result.SignaturePath,
+ },
+ }, nil
+}
+
+func executeClose(phase mpcceremony.Phase, options CloseOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := loadOperationalCircuit(trust, options.TranscriptDir)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.ClosePhaseFiles(mpcceremony.ClosePhaseFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: phase,
+ Transcript: transcriptPaths(options.TranscriptDir, options.ChainPath, options.ChainSignaturePath),
+ Phase1SealPath: options.Phase1SealPath,
+ Phase1SealSignaturePath: options.Phase1SealSignaturePath,
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ BeaconRound: options.BeaconRound,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Close.CeremonyID,
+ Phase: string(phase),
+ Sequence: int(result.Close.FinalIndex),
+ ClosedAt: result.Close.ClosedAt,
+ Summary: fmt.Sprintf("closed %s transcript", phase),
+ Outputs: map[string]string{
+ "closure": result.ClosePath,
+ "closure_signature": result.SignaturePath,
+ },
+ }, nil
+}
+
+func executeBeacon(phase mpcceremony.Phase, options BeaconOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.RecordBeaconFiles(mpcceremony.RecordBeaconFilesOptions{
+ Trust: trust,
+ TranscriptRoot: options.TranscriptDir,
+ Phase: phase,
+ ClosePath: options.ClosurePath,
+ CloseSignaturePath: options.ClosureSignaturePath,
+ RawResponsePath: options.RawResponsePath,
+ PublishedAt: options.PublishedAt,
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Beacon.CeremonyID,
+ Phase: string(phase),
+ Summary: fmt.Sprintf("recorded signed %s beacon evidence", phase),
+ Outputs: map[string]string{
+ "raw_response": result.RawResponsePath,
+ "beacon": result.BeaconPath,
+ "beacon_signature": result.SignaturePath,
+ },
+ }, nil
+}
+
+func executePhase1Seal(options Phase1SealOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := loadOperationalCircuit(trust, options.TranscriptDir)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.SealPhase1Files(mpcceremony.SealPhase1FilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ TranscriptRoot: options.TranscriptDir,
+ ClosePath: options.ClosurePath,
+ CloseSignaturePath: options.ClosureSignaturePath,
+ BeaconPath: options.BeaconPath,
+ BeaconSignaturePath: options.BeaconSignaturePath,
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ OutputDir: options.OutDir,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Seal.CeremonyID,
+ Phase: string(mpcceremony.Phase1),
+ Summary: "sealed Phase 1 with signed beacon evidence",
+ Outputs: map[string]string{
+ "commons": result.CommonsPath,
+ "seal": result.SealPath,
+ "seal_signature": result.SignaturePath,
+ },
+ }, nil
+}
+
+func executePhase2Init(options Phase2InitOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := loadOperationalCircuit(trust, options.Phase1TranscriptDir)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ TranscriptRoot: options.Phase1TranscriptDir,
+ Phase1SealPath: options.Phase1SealPath,
+ Phase1SealSignaturePath: options.Phase1SealSignaturePath,
+ CoordinatorPrivateKeyPath: options.CoordinatorSigningKey,
+ OutputDir: options.OutDir,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Chain.CeremonyID,
+ Phase: string(mpcceremony.Phase2),
+ Summary: "initialized circuit-specific Phase 2",
+ Outputs: map[string]string{
+ "phase2_genesis": result.GenesisPath,
+ "phase2_chain": result.ChainPath,
+ "phase2_chain_signature": result.ChainSignaturePath,
+ },
+ }, nil
+}
+
+func executeFinalize(options FinalizeOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ replay, err := replayPaths(trust, options.Replay)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := mpcceremony.CompileDestinationV2()
+ if err != nil {
+ return CommandResult{}, err
+ }
+ finalizedAt, err := parseUTCTime("--finalized-at", options.FinalizedAt)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.Finalize(mpcceremony.FinalizeOptions{
+ Replay: replay,
+ Circuit: circuit,
+ OutDir: options.OutDir,
+ CoordinatorSigningKey: options.CoordinatorSigningKey,
+ PublicEvidencePath: options.PublicEvidencePath,
+ FinalizedAt: finalizedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.CeremonyID,
+ Summary: "independently replayed both phases and created an unsigned release candidate",
+ Outputs: map[string]string{
+ "candidate_dir": result.OutDir,
+ "candidate": result.CandidatePath,
+ "candidate_signature": result.CandidateSigPath,
+ "verification_report": result.VerificationPath,
+ "proving_key": result.ProvingKeyPath,
+ "verifying_key": result.VerifyingKeyPath,
+ "constraint_system": result.ConstraintSystem,
+ "cardano_vk": result.CardanoVKPath,
+ "checksums": result.CandidateChecksum,
+ },
+ }, nil
+}
+
+func executePrepareFinalization(options PrepareFinalizationOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ replay, err := replayPaths(trust, options.Replay)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := mpcceremony.CompileDestinationV2()
+ if err != nil {
+ return CommandResult{}, err
+ }
+ preparedAt, err := parseUTCTime("--prepared-at", options.PreparedAt)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.PrepareFinalization(mpcceremony.PrepareFinalizationOptions{
+ Replay: replay,
+ Circuit: circuit,
+ OutDir: options.OutDir,
+ CoordinatorSigningKey: options.CoordinatorSigningKey,
+ PreparedAt: preparedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ Summary: "independently replayed both phases and published preliminary final keys for external public-proof generation",
+ Outputs: map[string]string{
+ "preliminary_dir": result.OutDir,
+ "preliminary_metadata": result.MetadataPath,
+ "preliminary_signature": result.SignaturePath,
+ "proving_key": result.ProvingKeyPath,
+ "verifying_key": result.VerifyingKeyPath,
+ "cardano_vk": result.CardanoVKPath,
+ "checksums": result.ChecksumsPath,
+ },
+ }, nil
+}
+
+func executeAudit(options AuditOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ replay, err := replayPaths(trust, options.Replay)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ circuit, err := mpcceremony.CompileDestinationV2()
+ if err != nil {
+ return CommandResult{}, err
+ }
+ auditedAt, err := parseUTCTime("--audited-at", options.AuditedAt)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.Audit(mpcceremony.AuditOptions{
+ Replay: replay,
+ Circuit: circuit,
+ CandidateDir: options.CandidateBundleDir,
+ AuditorID: options.AuditorID,
+ AuditorSigningKey: options.AuditorSigningKey,
+ OutPath: options.OutPath,
+ SignatureOutPath: options.SignatureOutPath,
+ AuditedAt: auditedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Record.CeremonyID,
+ Summary: "independently replayed the ceremony and wrote a signed passing audit",
+ Outputs: map[string]string{
+ "audit": result.RecordPath,
+ "audit_signature": result.SignaturePath,
+ },
+ }, nil
+}
+
+func executeReleaseSign(options ReleaseSignOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ coordinatorPublicKey, err := readPublicKeyHex(options.CoordinatorPublicKeyFile)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ releasedAt, err := parseUTCTime("--released-at", options.ReleasedAt)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.SignRelease(mpcceremony.SignReleaseOptions{
+ DefinitionPath: options.CeremonyPath,
+ DefinitionSignaturePath: options.CeremonySignaturePath,
+ CoordinatorPublicKeyHex: coordinatorPublicKey,
+ CandidateDir: options.CandidateBundleDir,
+ ReleaseDir: options.ReleaseDir,
+ Audits: auditArtifacts(options.AuditReportPaths, options.AuditSignaturePaths),
+ OperationalEvidenceRoot: options.OperationalEvidenceRoot,
+ OperationalBundlePath: options.OperationalBundlePath,
+ OperationalSignaturePath: options.OperationalSignaturePath,
+ ReleaseSigningKey: options.ReleaseSigningKey,
+ SignatureKeyID: options.SignatureKeyID,
+ ReleasedAt: releasedAt,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ Summary: "published a fresh release bundle after verifying audits, public-witness quorums, and multi-relay beacon evidence",
+ Outputs: map[string]string{
+ "release_dir": options.ReleaseDir,
+ "manifest": result.ManifestPath,
+ "manifest_signature": result.ManifestSignature,
+ "manifest_public_key": result.ManifestPublicKey,
+ "setup_transcript": result.FinalTranscript,
+ "operational_evidence": result.OperationalEvidence,
+ "checksums": result.ChecksumsPath,
+ },
+ }, nil
+}
+
+func executeReleaseVerify(options ReleaseVerifyOptions) (CommandResult, error) {
+ trust := trustPaths(
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err := verifyRunningTrust(trust); err != nil {
+ return CommandResult{}, err
+ }
+ coordinatorPublicKey, err := readPublicKeyHex(options.CoordinatorPublicKeyFile)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ releasePublicKey, err := readPublicKeyHex(options.ManifestPublicKeyFile)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ result, err := mpcceremony.VerifyRelease(mpcceremony.VerifyReleaseOptions{
+ DefinitionPath: options.CeremonyPath,
+ DefinitionSignaturePath: options.CeremonySignaturePath,
+ CoordinatorPublicKeyHex: coordinatorPublicKey,
+ KeysDir: options.KeysDir,
+ TrustedPublicKeyHex: releasePublicKey,
+ ExpectedSignatureKeyID: options.SignatureKeyID,
+ RequireProvingKey: true,
+ })
+ if err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: result.Transcript.CeremonyID,
+ Summary: "verified the release signature, bundled audits, native keys, Cardano export, and ceremony coherence",
+ Outputs: map[string]string{
+ "keys_dir": options.KeysDir,
+ },
+ }, nil
+}
+
+func sessionNonce(value string) (string, error) {
+ if value != "" {
+ return value, nil
+ }
+ nonce := make([]byte, 32)
+ if _, err := rand.Read(nonce); err != nil {
+ return "", fmt.Errorf("generate session nonce: %w", err)
+ }
+ return hex.EncodeToString(nonce), nil
+}
+
+func trustPaths(definition, signature, publicKey string) mpcceremony.TrustPaths {
+ return mpcceremony.TrustPaths{
+ DefinitionPath: definition,
+ DefinitionSignaturePath: signature,
+ CoordinatorPublicKeyPath: publicKey,
+ }
+}
+
+func transcriptPaths(root, chain, signature string) mpcceremony.PhaseTranscriptPaths {
+ return mpcceremony.PhaseTranscriptPaths{
+ RootDir: root,
+ ChainPath: chain,
+ ChainSignaturePath: signature,
+ }
+}
+
+func replayPaths(trust mpcceremony.TrustPaths, replay ReplayOptions) (mpcceremony.ReplayPaths, error) {
+ coordinatorPublicKey, err := readPublicKeyHex(trust.CoordinatorPublicKeyPath)
+ if err != nil {
+ return mpcceremony.ReplayPaths{}, err
+ }
+ return mpcceremony.ReplayPaths{
+ TranscriptRoot: replay.TranscriptRoot,
+ CoordinatorPublicKeyHex: coordinatorPublicKey,
+ DefinitionPath: trust.DefinitionPath,
+ DefinitionSignaturePath: trust.DefinitionSignaturePath,
+ Phase1ChainPath: replay.Phase1ChainPath,
+ Phase1ChainSignaturePath: replay.Phase1ChainSignaturePath,
+ Phase1ClosePath: replay.Phase1ClosePath,
+ Phase1CloseSignaturePath: replay.Phase1CloseSignaturePath,
+ Phase1BeaconPath: replay.Phase1BeaconPath,
+ Phase1BeaconSignaturePath: replay.Phase1BeaconSignaturePath,
+ Phase1SealPath: replay.Phase1SealPath,
+ Phase1SealSignaturePath: replay.Phase1SealSignaturePath,
+ Phase2ChainPath: replay.Phase2ChainPath,
+ Phase2ChainSignaturePath: replay.Phase2ChainSignaturePath,
+ Phase2ClosePath: replay.Phase2ClosePath,
+ Phase2CloseSignaturePath: replay.Phase2CloseSignaturePath,
+ Phase2BeaconPath: replay.Phase2BeaconPath,
+ Phase2BeaconSignaturePath: replay.Phase2BeaconSignaturePath,
+ }, nil
+}
+
+func auditArtifacts(records, signatures []string) []mpcceremony.AuditArtifact {
+ result := make([]mpcceremony.AuditArtifact, len(records))
+ for index := range records {
+ result[index] = mpcceremony.AuditArtifact{
+ RecordPath: records[index],
+ SignaturePath: signatures[index],
+ }
+ }
+ return result
+}
+
+func parseUTCTime(flagName, value string) (time.Time, error) {
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("%s must be an RFC3339 timestamp: %w", flagName, err)
+ }
+ if parsed.IsZero() || parsed.Location() != time.UTC {
+ return time.Time{}, fmt.Errorf("%s must use UTC with a Z suffix", flagName)
+ }
+ return parsed, nil
+}
+
+func readPublicKeyHex(path string) (string, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return "", err
+ }
+ if linkInfo.Mode()&fs.ModeSymlink != 0 || !linkInfo.Mode().IsRegular() {
+ return "", fmt.Errorf("trusted public key %q must be a regular file, not a symlink", path)
+ }
+ if linkInfo.Size() <= 0 || linkInfo.Size() > 4096 {
+ return "", fmt.Errorf("trusted public key %q size %d is outside [1,4096]", path, linkInfo.Size())
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return "", err
+ }
+ if !info.Mode().IsRegular() ||
+ !os.SameFile(linkInfo, info) ||
+ info.Size() != linkInfo.Size() {
+ return "", fmt.Errorf("trusted public key %q changed while being opened", path)
+ }
+ raw := make([]byte, info.Size())
+ if _, err := io.ReadFull(file, raw); err != nil {
+ return "", err
+ }
+ var extra [1]byte
+ if n, err := file.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
+ return "", fmt.Errorf("trusted public key %q changed while being read", path)
+ }
+ value := strings.TrimSpace(string(raw))
+ decoded, err := hex.DecodeString(value)
+ if err != nil || len(decoded) != ed25519.PublicKeySize {
+ return "", fmt.Errorf("trusted public key %q must contain exactly %d bytes of hex", path, ed25519.PublicKeySize)
+ }
+ return strings.ToLower(value), nil
+}
+
+func verifyRunningTrust(paths mpcceremony.TrustPaths) error {
+ trusted, err := mpcceremony.LoadSignedDefinition(paths)
+ if err != nil {
+ return err
+ }
+ return mpcceremony.VerifyRunningSoftwareForMode(
+ trusted.Definition.Software,
+ trusted.Definition.Mode,
+ )
+}
+
+func loadOperationalCircuit(paths mpcceremony.TrustPaths, transcriptRoot string) (*mpcceremony.CompiledCircuit, error) {
+ trusted, err := mpcceremony.LoadSignedDefinition(paths)
+ if err != nil {
+ return nil, err
+ }
+ if err := mpcceremony.VerifyRunningSoftwareForMode(
+ trusted.Definition.Software,
+ trusted.Definition.Mode,
+ ); err != nil {
+ return nil, err
+ }
+ r1csPath := filepath.Join(transcriptRoot, filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name))
+ return mpcceremony.ReadR1CSFile(r1csPath, trusted.Definition.Circuit)
+}
diff --git a/cmd/mpc-ceremony/executor_test.go b/cmd/mpc-ceremony/executor_test.go
new file mode 100644
index 00000000..ad82f4a9
--- /dev/null
+++ b/cmd/mpc-ceremony/executor_test.go
@@ -0,0 +1,48 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "crypto/ed25519"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestReadPublicKeyHexRequiresBoundedRegularStableFile(t *testing.T) {
+ dir := t.TempDir()
+ publicKey := make([]byte, ed25519.PublicKeySize)
+ for index := range publicKey {
+ publicKey[index] = byte(index + 1)
+ }
+ path := filepath.Join(dir, "coordinator.hex")
+ if err := os.WriteFile(path, []byte(hex.EncodeToString(publicKey)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ value, err := readPublicKeyHex(path)
+ if err != nil {
+ t.Fatalf("read regular public key: %v", err)
+ }
+ if value != hex.EncodeToString(publicKey) {
+ t.Fatal("public key changed during bounded read")
+ }
+
+ symlink := filepath.Join(dir, "coordinator-link.hex")
+ if err := os.Symlink(path, symlink); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readPublicKeyHex(symlink); err == nil {
+ t.Fatal("accepted a symbolic-link trust anchor")
+ }
+
+ oversized := filepath.Join(dir, "oversized.hex")
+ if err := os.WriteFile(oversized, []byte(strings.Repeat("0", 4097)), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readPublicKeyHex(oversized); err == nil {
+ t.Fatal("accepted an oversized trust anchor")
+ }
+}
diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go
new file mode 100644
index 00000000..75f83ce8
--- /dev/null
+++ b/cmd/mpc-ceremony/integration_test.go
@@ -0,0 +1,254 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) {
+ topics := [][]string{
+ nil,
+ {"init"},
+ {"phase1"},
+ {"phase1", "contribute"},
+ {"phase1", "attest-erasure"},
+ {"phase1", "verify"},
+ {"phase1", "close"},
+ {"phase1", "beacon"},
+ {"phase1", "seal"},
+ {"phase2"},
+ {"phase2", "init"},
+ {"phase2", "contribute"},
+ {"phase2", "attest-erasure"},
+ {"phase2", "verify"},
+ {"phase2", "close"},
+ {"phase2", "beacon"},
+ {"finalize"},
+ {"finalize", "prepare"},
+ {"finalize", "complete"},
+ {"audit"},
+ {"release"},
+ {"release", "sign"},
+ {"release", "verify"},
+ {"decision"},
+ {"decision", "prepare"},
+ {"decision", "sign"},
+ {"decision", "verify"},
+ {"ops"},
+ {"ops", "export-signing"},
+ {"ops", "import-signature"},
+ {"ops", "verify"},
+ }
+ var allHelp bytes.Buffer
+ for _, topic := range topics {
+ if err := writeUsage(&allHelp, topic); err != nil {
+ t.Fatalf("write help for %q: %v", topic, err)
+ }
+ allHelp.WriteByte('\n')
+ }
+
+ lowerHelp := strings.ToLower(allHelp.String())
+ for _, forbiddenSecret := range []string{
+ "mnemonic",
+ "seed phrase",
+ "seed-phrase",
+ "master-xprv",
+ "master xprv",
+ "wallet secret",
+ "wallet-secret",
+ "proving input",
+ "private witness",
+ } {
+ if strings.Contains(lowerHelp, forbiddenSecret) {
+ t.Errorf("participant CLI help exposes forbidden secret input %q", forbiddenSecret)
+ }
+ }
+ for _, forbiddenFlag := range []string{
+ "--url",
+ "--network",
+ "--latest",
+ "--force",
+ "--overwrite",
+ "--skip-verification",
+ "--skip-verify",
+ "--insecure",
+ "--deterministic-randomness",
+ "--challenge",
+ "--randomness-hex",
+ } {
+ if strings.Contains(lowerHelp, forbiddenFlag) {
+ t.Errorf("participant CLI help exposes forbidden flag %q", forbiddenFlag)
+ }
+ }
+
+ allowed := []string{
+ "--audit-report",
+ "--audit-signature",
+ "--audited-at",
+ "--auditor-id",
+ "--auditor-signing-key",
+ "--beacon",
+ "--beacon-signature",
+ "--beacon-round",
+ "--candidate-bundle",
+ "--candidate-dir",
+ "--ceremony",
+ "--ceremony-signature",
+ "--chain",
+ "--chain-signature",
+ "--closure-signature",
+ "--closure",
+ "--created-at",
+ "--destroyed-at",
+ "--decision",
+ "--draft",
+ "--evidence-root",
+ "--coordinator-key-id",
+ "--coordinator-public-key-file",
+ "--coordinator-signing-key",
+ "--environment",
+ "--finalized-at",
+ "--format",
+ "--key-version",
+ "--keys-dir",
+ "--manifest-public-key-file",
+ "--mode",
+ "--out",
+ "--out-dir",
+ "--published-at",
+ "--participant-id",
+ "--participant-signing-key",
+ "--prepared-at",
+ "--public-evidence",
+ "--participants",
+ "--phase1-beacon",
+ "--phase1-beacon-signature",
+ "--phase1-chain",
+ "--phase1-chain-signature",
+ "--phase1-close",
+ "--phase1-close-signature",
+ "--phase1-seal",
+ "--phase1-seal-signature",
+ "--phase1-transcript-dir",
+ "--phase2-beacon",
+ "--phase2-beacon-signature",
+ "--phase2-chain",
+ "--phase2-chain-signature",
+ "--phase2-close",
+ "--phase2-close-signature",
+ "--policy",
+ "--quiet",
+ "--raw-response",
+ "--release-dir",
+ "--release-signing-key",
+ "--released-at",
+ "--record",
+ "--record-type",
+ "--canonical",
+ "--signature",
+ "--signer-public-key-file",
+ "--raw-signature",
+ "--role",
+ "--related-record",
+ "--operational-evidence-root",
+ "--operational-bundle",
+ "--operational-bundle-signature",
+ "--session-nonce-hex",
+ "--signer-id",
+ "--signing-key",
+ "--signature-key-id",
+ "--transcript-dir",
+ "--transcript-root",
+ "--accepted-at",
+ "--contributed-at",
+ }
+ flagPattern := regexp.MustCompile(`--[a-z0-9-]+`)
+ seenSet := make(map[string]struct{})
+ for _, flag := range flagPattern.FindAllString(lowerHelp, -1) {
+ seenSet[flag] = struct{}{}
+ }
+ seen := make([]string, 0, len(seenSet))
+ for flag := range seenSet {
+ seen = append(seen, flag)
+ }
+ slices.Sort(seen)
+ slices.Sort(allowed)
+ if !slices.Equal(seen, allowed) {
+ t.Fatalf("participant CLI flags changed without allowlist review:\n got %v\nwant %v", seen, allowed)
+ }
+}
+
+func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) {
+ tests := []Invocation{
+ {Command: CommandFinalizePrepare, Options: PrepareFinalizationOptions{}},
+ {Command: CommandFinalizeComplete, Options: FinalizeOptions{}},
+ {Command: CommandAudit, Options: AuditOptions{}},
+ {Command: CommandReleaseSign, Options: ReleaseSignOptions{}},
+ {Command: CommandReleaseVerify, Options: ReleaseVerifyOptions{}},
+ {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}},
+ {Command: CommandDecisionSign, Options: DecisionSignOptions{}},
+ {Command: CommandDecisionVerify, Options: DecisionVerifyOptions{}},
+ }
+ for _, invocation := range tests {
+ t.Run(string(invocation.Command), func(t *testing.T) {
+ _, err := (workflowExecutor{}).Execute(context.Background(), invocation)
+ if errors.Is(err, errExecutorNotWired) {
+ t.Fatalf("%s is exposed by the production CLI but not wired to the ceremony engine", invocation.Command)
+ }
+ })
+ }
+}
+
+func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) {
+ commands := [][]string{
+ {"init"},
+ {"phase1", "contribute"},
+ {"phase1", "attest-erasure"},
+ {"phase1", "verify"},
+ {"phase1", "close"},
+ {"phase1", "beacon"},
+ {"phase1", "seal"},
+ {"phase2", "init"},
+ {"phase2", "contribute"},
+ {"phase2", "attest-erasure"},
+ {"phase2", "verify"},
+ {"phase2", "close"},
+ {"phase2", "beacon"},
+ {"finalize"},
+ {"audit"},
+ {"release", "sign"},
+ {"release", "verify"},
+ {"decision", "sign"},
+ {"decision", "verify"},
+ {"ops", "export-signing"},
+ {"ops", "import-signature"},
+ {"ops", "verify"},
+ }
+ forbidden := []string{
+ "--mnemonic",
+ "--seed-phrase",
+ "--master-xprv",
+ "--wallet-secret",
+ "--private-witness",
+ "--proving-input",
+ }
+ for _, command := range commands {
+ for _, flag := range forbidden {
+ name := strings.Join(command, "_") + "_" + strings.TrimPrefix(flag, "--")
+ t.Run(name, func(t *testing.T) {
+ args := append(append([]string(nil), command...), flag, "must-not-be-read")
+ if _, err := parseInvocation(args); err == nil {
+ t.Fatalf("%q unexpectedly accepted secret-bearing flag %q", command, flag)
+ }
+ })
+ }
+ }
+}
diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go
new file mode 100644
index 00000000..0ea52ee1
--- /dev/null
+++ b/cmd/mpc-ceremony/main.go
@@ -0,0 +1,264 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/consensys/gnark/logger"
+)
+
+func main() {
+ // gnark defaults its global logger to stdout. Keep stdout exclusively for
+ // the CLI result contract, especially the single JSON object promised by
+ // --format json.
+ logger.SetOutput(os.Stderr)
+ os.Exit(runCLI(context.Background(), os.Args[1:], os.Stdout, os.Stderr, workflowExecutor{}))
+}
+
+func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, executor Executor) int {
+ invocation, err := parseInvocation(args)
+ if err != nil {
+ var help *helpRequest
+ if errors.As(err, &help) {
+ if err := writeUsage(stdout, help.topic); err != nil {
+ writeDiagnostic(stderr, "error: write help: %v\n", err)
+ return 6
+ }
+ return 0
+ }
+ var usage *usageError
+ 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
+ }
+ if err := writeUsage(stderr, usage.topic); err != nil {
+ return 6
+ }
+ return 2
+ }
+ writeDiagnostic(stderr, "error: %s\n", redactCLIError(err.Error(), args))
+ return 6
+ }
+
+ if executor == nil {
+ executor = unwiredExecutor{}
+ }
+ result, err := executor.Execute(ctx, invocation)
+ if err != nil {
+ return writeExecutionError(invocation, err, args, stdout, stderr)
+ }
+ result.Schema = commandResultSchema
+ result.OK = true
+ 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)
+ 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)
+ return 6
+ }
+ } else {
+ if _, err := fmt.Fprintf(stdout, "%s completed\n", invocation.Command); err != nil {
+ writeDiagnostic(stderr, "error: write command result: %v\n", err)
+ return 6
+ }
+ }
+ names := make([]string, 0, len(result.Outputs))
+ for name := range result.Outputs {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ 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)
+ return 6
+ }
+ }
+ return 0
+}
+
+func writeExecutionError(invocation Invocation, err error, args []string, stdout, stderr io.Writer) int {
+ exitCode := 6
+ code := "internal_error"
+ if errors.Is(err, errExecutorNotWired) {
+ code = "engine_not_wired"
+ }
+ message := redactCLIError(err.Error(), args)
+ if invocation.Global.Format == "json" {
+ payload := struct {
+ Schema string `json:"schema"`
+ OK bool `json:"ok"`
+ Command Command `json:"command"`
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }{
+ Schema: commandResultSchema,
+ OK: false,
+ Command: invocation.Command,
+ }
+ 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)
+ }
+ return exitCode
+ }
+ writeDiagnostic(stderr, "error: %s\n", message)
+ return exitCode
+}
+
+const redactedCLIValue = ""
+
+// redactCLIError keeps command-line values out of diagnostics. In particular,
+// unexpected positionals can be seed phrases, while participant identifiers
+// and private-key paths can expose operator-specific ceremony details. Error
+// messages remain useful, but values supplied by the caller are never echoed.
+func redactCLIError(message string, args []string) string {
+ safeCommandArguments := identifyCLICommandArguments(args)
+ candidates := make(map[string]struct{})
+ for index, arg := range args {
+ if _, safe := safeCommandArguments[index]; safe {
+ continue
+ }
+ if name, value, found := strings.Cut(arg, "="); found && strings.HasPrefix(name, "-") {
+ addCLIErrorCandidate(candidates, value)
+ addCLIErrorCandidate(candidates, name)
+ addCLIErrorCandidate(candidates, "-"+strings.TrimLeft(name, "-"))
+ continue
+ }
+ addCLIErrorCandidate(candidates, arg)
+ if strings.HasPrefix(arg, "-") {
+ addCLIErrorCandidate(candidates, "-"+strings.TrimLeft(arg, "-"))
+ }
+ }
+
+ ordered := make([]string, 0, len(candidates))
+ for candidate := range candidates {
+ ordered = append(ordered, candidate)
+ }
+ sort.Slice(ordered, func(i, j int) bool {
+ return len(ordered[i]) > len(ordered[j])
+ })
+ for _, candidate := range ordered {
+ message = strings.ReplaceAll(message, candidate, redactedCLIValue)
+ }
+ return message
+}
+
+func identifyCLICommandArguments(args []string) map[int]struct{} {
+ safe := make(map[int]struct{})
+ index := 0
+ for index < len(args) {
+ switch {
+ case args[index] == "--format":
+ index += 2
+ case strings.HasPrefix(args[index], "--format="):
+ index++
+ case args[index] == "--quiet" || args[index] == "--help":
+ index++
+ case strings.HasPrefix(args[index], "-"):
+ index++
+ default:
+ goto command
+ }
+ }
+ return safe
+
+command:
+ topLevel := map[string]struct{}{
+ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {},
+ "ops": {}, "phase1": {}, "phase2": {}, "release": {},
+ }
+ if _, ok := topLevel[args[index]]; !ok {
+ return safe
+ }
+ safe[index] = struct{}{}
+
+ subcommands := map[string]map[string]struct{}{
+ "phase1": {
+ "attest-erasure": {}, "beacon": {}, "close": {},
+ "contribute": {}, "help": {}, "seal": {}, "verify": {},
+ },
+ "phase2": {
+ "attest-erasure": {}, "beacon": {}, "close": {},
+ "contribute": {}, "help": {}, "init": {}, "verify": {},
+ },
+ "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}},
+ "ops": {"export-signing": {}, "help": {}, "import-signature": {}, "verify": {}},
+ "release": {"help": {}, "sign": {}, "verify": {}},
+ }
+ allowed, hasSubcommands := subcommands[args[index]]
+ if hasSubcommands && index+1 < len(args) {
+ if _, ok := allowed[args[index+1]]; ok {
+ safe[index+1] = struct{}{}
+ }
+ }
+ return safe
+}
+
+func addCLIErrorCandidate(candidates map[string]struct{}, value string) {
+ if value == "" || value == "-" || value == "--" {
+ return
+ }
+ candidates[value] = struct{}{}
+}
+
+func writeDiagnostic(w io.Writer, format string, args ...any) {
+ _, _ = fmt.Fprintf(w, format, args...)
+}
+
+func requestsJSON(args []string) bool {
+ for i := 0; i < len(args); i++ {
+ switch {
+ case args[i] == "--format" && i+1 < len(args):
+ return args[i+1] == "json"
+ case args[i] == "--format=json":
+ return true
+ case len(args[i]) == 0 || args[i][0] != '-':
+ return false
+ }
+ }
+ return false
+}
+
+func writeParseError(message string, stdout, stderr io.Writer) int {
+ payload := struct {
+ Schema string `json:"schema"`
+ OK bool `json:"ok"`
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }{
+ Schema: commandResultSchema,
+ OK: false,
+ }
+ 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)
+ return 6
+ }
+ return 2
+}
diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go
new file mode 100644
index 00000000..7d3ecae9
--- /dev/null
+++ b/cmd/mpc-ceremony/ops.go
@@ -0,0 +1,377 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "crypto/ed25519"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "proof-tool/internal/mpcceremony"
+)
+
+const maxOperationalRecordBytes = 16 << 20
+
+func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandResult, err error) {
+ recordType := mpcceremony.OperationalRecordType(options.RecordType)
+ canonical, record, trusted, err := loadBoundOperationalRecord(
+ recordType,
+ options.RecordPath,
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ request, err := mpcceremony.NewOperationalSigningRequest(recordType, canonical)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ requestBytes, err := mpcceremony.MarshalCanonical(request)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ definitionBytes, err := mpcceremony.MarshalCanonical(trusted.Definition)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if _, err := mpcceremony.VerifyOperationalRecordBinding(trusted.Definition, definitionBytes, record); err != nil {
+ return CommandResult{}, err
+ }
+
+ if err := os.Mkdir(options.OutDir, 0o700); err != nil {
+ return CommandResult{}, 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)
+ }()
+ canonicalPath := filepath.Join(options.OutDir, "canonical.json")
+ requestPath := filepath.Join(options.OutDir, "signing-request.json")
+ if err := writeFreshOperationalFile(canonicalPath, canonical, 0o600); err != nil {
+ return CommandResult{}, err
+ }
+ if err := writeFreshOperationalFile(requestPath, requestBytes, 0o600); err != nil {
+ return CommandResult{}, err
+ }
+ if err := syncDirectory(options.OutDir); err != nil {
+ return CommandResult{}, 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
+}
+
+func executeOpsImportSignature(options OpsImportSignatureOptions) (CommandResult, error) {
+ recordType := mpcceremony.OperationalRecordType(options.RecordType)
+ canonical, record, trusted, err := loadBoundOperationalRecord(
+ recordType,
+ options.CanonicalPath,
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ definitionBytes, err := canonicalDefinition(trusted)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ expectedSigner, err := mpcceremony.VerifyOperationalRecordBinding(
+ trusted.Definition,
+ definitionBytes,
+ record,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ publicKey, err := loadExpectedOperationalPublicKey(options.SignerPublicKeyFile, expectedSigner)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ rawTransport, err := readRegularOperationalFile(options.RawSignaturePath, 4096)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ rawSignature, err := mpcceremony.DecodeOfflineSignature(rawTransport)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ signature, err := mpcceremony.ImportOperationalSignature(
+ canonical,
+ expectedSigner.KeyID,
+ publicKey,
+ rawSignature,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ signatureBytes, err := mpcceremony.MarshalCanonical(signature)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ if err := writeFreshOperationalFile(options.OutPath, signatureBytes, 0o644); err != nil {
+ return CommandResult{}, err
+ }
+ return CommandResult{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Summary: "verified and imported an offline Ed25519 signature over exact canonical bytes",
+ Outputs: map[string]string{
+ "signature": options.OutPath,
+ },
+ }, nil
+}
+
+func executeOpsVerify(options OpsVerifyOptions) (CommandResult, error) {
+ recordType := mpcceremony.OperationalRecordType(options.RecordType)
+ canonical, record, trusted, err := loadBoundOperationalRecord(
+ recordType,
+ options.RecordPath,
+ options.CeremonyPath,
+ options.CeremonySignaturePath,
+ options.CoordinatorPublicKeyFile,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ definitionBytes, err := canonicalDefinition(trusted)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ expectedSigner, err := mpcceremony.VerifyOperationalRecordBinding(
+ trusted.Definition,
+ definitionBytes,
+ record,
+ )
+ if err != nil {
+ return CommandResult{}, err
+ }
+ publicKey, err := loadExpectedOperationalPublicKey(options.SignerPublicKeyFile, expectedSigner)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ signatureBytes, err := readRegularOperationalFile(options.SignaturePath, 4096)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ var signature mpcceremony.DetachedSignature
+ if err := mpcceremony.UnmarshalCanonical(signatureBytes, &signature); err != nil {
+ return CommandResult{}, fmt.Errorf("signature: %w", err)
+ }
+ if err := mpcceremony.VerifyExact(canonical, signature, expectedSigner.KeyID, publicKey); err != nil {
+ return CommandResult{}, err
+ }
+
+ if recordType == mpcceremony.RecordReceipt {
+ if options.RelatedRecordPath == "" {
+ return CommandResult{}, errors.New("receipt verification requires --related-record with the exact canonical handoff")
+ }
+ handoffBytes, err := readRegularOperationalFile(options.RelatedRecordPath, maxOperationalRecordBytes)
+ if err != nil {
+ return CommandResult{}, err
+ }
+ parsed, err := mpcceremony.ParseOperationalRecord(mpcceremony.RecordHandoff, handoffBytes)
+ if err != nil {
+ return CommandResult{}, fmt.Errorf("related handoff: %w", err)
+ }
+ handoff := parsed.(*mpcceremony.TransferHandoff)
+ receipt := record.(*mpcceremony.TransferReceipt)
+ if err := mpcceremony.VerifyTransferReceipt(handoffBytes, *handoff, *receipt); err != nil {
+ return CommandResult{}, err
+ }
+ }
+ if recordType == mpcceremony.RecordEvidenceBundle {
+ if options.EvidenceRoot == "" {
+ return CommandResult{}, errors.New("evidence-bundle verification requires --evidence-root")
+ }
+ bundle := record.(*mpcceremony.OperationalEvidenceBundle)
+ phase1Close, err := mpcceremony.LoadAuthenticatedCloseEvidence(
+ options.EvidenceRoot,
+ bundle.Phase1.Close,
+ )
+ if err != nil {
+ return CommandResult{}, fmt.Errorf("phase1 close evidence: %w", err)
+ }
+ phase2Close, err := mpcceremony.LoadAuthenticatedCloseEvidence(
+ options.EvidenceRoot,
+ bundle.Phase2.Close,
+ )
+ if err != nil {
+ return CommandResult{}, fmt.Errorf("phase2 close evidence: %w", err)
+ }
+ if _, err := mpcceremony.VerifyOperationalEvidenceBundle(
+ mpcceremony.VerifyOperationalEvidenceOptions{
+ Definition: trusted.Definition,
+ CoordinatorPublicKey: trusted.CoordinatorPublicKey,
+ EvidenceRoot: options.EvidenceRoot,
+ BundleBytes: canonical,
+ BundleSignatureBytes: signatureBytes,
+ Phase1Close: phase1Close,
+ Phase2Close: phase2Close,
+ },
+ ); err != nil {
+ return CommandResult{}, err
+ }
+ }
+ return CommandResult{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Summary: "verified canonical operational record, ceremony binding, signer identity, and detached signature",
+ Outputs: map[string]string{
+ "record": options.RecordPath,
+ "signature": options.SignaturePath,
+ },
+ }, nil
+}
+
+func loadBoundOperationalRecord(
+ recordType mpcceremony.OperationalRecordType,
+ recordPath, ceremonyPath, ceremonySignaturePath, coordinatorPublicKeyPath string,
+) ([]byte, any, *mpcceremony.TrustedCeremony, error) {
+ if err := recordType.Validate(); err != nil {
+ return nil, nil, nil, err
+ }
+ trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{
+ DefinitionPath: ceremonyPath,
+ DefinitionSignaturePath: ceremonySignaturePath,
+ CoordinatorPublicKeyPath: coordinatorPublicKeyPath,
+ })
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ canonical, err := readRegularOperationalFile(recordPath, maxOperationalRecordBytes)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ record, err := mpcceremony.ParseOperationalRecord(recordType, canonical)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("operational record: %w", err)
+ }
+ definitionBytes, err := canonicalDefinition(trusted)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if _, err := mpcceremony.VerifyOperationalRecordBinding(
+ trusted.Definition,
+ definitionBytes,
+ record,
+ ); err != nil {
+ return nil, nil, nil, err
+ }
+ return canonical, record, trusted, nil
+}
+
+func canonicalDefinition(trusted *mpcceremony.TrustedCeremony) ([]byte, error) {
+ // LoadSignedDefinition already accepted this exact type using the canonical
+ // parser, so remarshal cannot fail. Keeping this helper local avoids adding
+ // mutable raw bytes to the trusted ceremony API.
+ data, err := mpcceremony.MarshalCanonical(trusted.Definition)
+ if err != nil {
+ return nil, fmt.Errorf("remarshal authenticated canonical definition: %w", err)
+ }
+ return data, nil
+}
+
+func loadExpectedOperationalPublicKey(path string, expected mpcceremony.Identity) (ed25519.PublicKey, error) {
+ value, err := readPublicKeyHex(path)
+ if err != nil {
+ return nil, err
+ }
+ if value != expected.Ed25519PublicKeyHex {
+ return nil, errors.New("out-of-band signer public key does not match operational record identity")
+ }
+ raw, err := hex.DecodeString(value)
+ if err != nil || len(raw) != ed25519.PublicKeySize {
+ return nil, errors.New("decode expected operational signer public key")
+ }
+ return ed25519.PublicKey(raw), nil
+}
+
+func readRegularOperationalFile(path string, maximum int64) ([]byte, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, fmt.Errorf("inspect %q: %w", path, err)
+ }
+ if linkInfo.Mode()&fs.ModeSymlink != 0 || !linkInfo.Mode().IsRegular() {
+ return nil, fmt.Errorf("%q must be a regular file, not a symlink", path)
+ }
+ if linkInfo.Size() <= 0 || linkInfo.Size() > maximum {
+ return nil, fmt.Errorf("%q size %d is outside [1,%d]", path, linkInfo.Size(), maximum)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || !os.SameFile(linkInfo, info) || info.Size() != linkInfo.Size() {
+ return nil, fmt.Errorf("%q changed while being opened", path)
+ }
+ data := make([]byte, info.Size())
+ if _, err := io.ReadFull(file, data); err != nil {
+ return nil, err
+ }
+ var extra [1]byte
+ if n, err := file.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
+ return nil, fmt.Errorf("%q changed while being read", path)
+ }
+ return data, nil
+}
+
+func writeFreshOperationalFile(path string, data []byte, mode fs.FileMode) (err error) {
+ if len(data) == 0 {
+ return errors.New("refuse to write empty operational artifact")
+ }
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+ if err != nil {
+ return fmt.Errorf("create fresh operational artifact %q: %w", path, err)
+ }
+ complete := false
+ defer func() {
+ closeErr := file.Close()
+ if err == nil && closeErr != nil {
+ err = closeErr
+ }
+ if !complete {
+ _ = os.Remove(path)
+ }
+ }()
+ if _, err = file.Write(data); err != nil {
+ return err
+ }
+ if err = file.Sync(); err != nil {
+ return err
+ }
+ complete = true
+ return nil
+}
+
+func syncDirectory(path string) error {
+ directory, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer directory.Close()
+ return directory.Sync()
+}
diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go
new file mode 100644
index 00000000..9127a577
--- /dev/null
+++ b/cmd/mpc-ceremony/parse.go
@@ -0,0 +1,975 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "strings"
+
+ "proof-tool/internal/mpcceremony"
+)
+
+const supportedKeyVersion = "ownership-destination-v2"
+
+type helpRequest struct {
+ topic []string
+}
+
+func (h *helpRequest) Error() string { return "help requested" }
+
+type usageError struct {
+ message string
+ topic []string
+}
+
+func (e *usageError) Error() string { return e.message }
+
+func parseInvocation(args []string) (Invocation, error) {
+ global := flag.NewFlagSet("mpc-ceremony", flag.ContinueOnError)
+ global.SetOutput(io.Discard)
+ format := global.String("format", "human", "output format: human or json")
+ quiet := global.Bool("quiet", false, "suppress progress output")
+ showHelp := global.Bool("help", false, "show help")
+ if err := global.Parse(args); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return Invocation{}, &helpRequest{}
+ }
+ return Invocation{}, &usageError{message: err.Error()}
+ }
+ if *format != "human" && *format != "json" {
+ return Invocation{}, &usageError{message: "--format must be human or json"}
+ }
+ rest := global.Args()
+ if *showHelp {
+ return Invocation{}, &helpRequest{topic: rest}
+ }
+ if len(rest) == 0 {
+ return Invocation{}, &usageError{message: "missing command"}
+ }
+ if rest[0] == "help" {
+ return Invocation{}, &helpRequest{topic: rest[1:]}
+ }
+
+ invocation := Invocation{Global: GlobalOptions{Format: *format, Quiet: *quiet}}
+ switch rest[0] {
+ case "init":
+ options, err := parseInit(rest[1:])
+ invocation.Command, invocation.Options = CommandInit, options
+ return invocation, wrapCommandError(err, "init")
+ case "phase1":
+ return parsePhase1(invocation, rest[1:])
+ case "phase2":
+ return parsePhase2(invocation, rest[1:])
+ case "finalize":
+ return parseFinalize(invocation, rest[1:])
+ case "audit":
+ options, err := parseAudit(rest[1:])
+ invocation.Command, invocation.Options = CommandAudit, options
+ return invocation, wrapCommandError(err, "audit")
+ case "release":
+ return parseRelease(invocation, rest[1:])
+ case "ops":
+ return parseOps(invocation, rest[1:])
+ case "decision":
+ return parseDecision(invocation, rest[1:])
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown command %q", rest[0]),
+ }
+ }
+}
+
+func parseDecision(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing decision command", topic: []string{"decision"}}
+ }
+ if args[0] == "help" {
+ return Invocation{}, &helpRequest{topic: append([]string{"decision"}, args[1:]...)}
+ }
+ switch args[0] {
+ case "prepare":
+ options, err := parseDecisionPrepare(args[1:])
+ invocation.Command, invocation.Options = CommandDecisionPrepare, options
+ return invocation, wrapCommandError(err, "decision", "prepare")
+ case "sign":
+ options, err := parseDecisionSign(args[1:])
+ invocation.Command, invocation.Options = CommandDecisionSign, options
+ return invocation, wrapCommandError(err, "decision", "sign")
+ case "verify":
+ options, err := parseDecisionVerify(args[1:])
+ invocation.Command, invocation.Options = CommandDecisionVerify, options
+ return invocation, wrapCommandError(err, "decision", "verify")
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown decision command %q", args[0]),
+ topic: []string{"decision"},
+ }
+ }
+}
+
+func parseDecisionPrepare(args []string) (DecisionPrepareOptions, error) {
+ var options DecisionPrepareOptions
+ fs := commandFlagSet("decision prepare")
+ addCeremonyTrustFlags(
+ fs,
+ &options.CeremonyPath,
+ &options.CeremonySignaturePath,
+ &options.CoordinatorPublicKeyFile,
+ )
+ fs.StringVar(&options.DraftPath, "draft", "", "canonical production-decision draft JSON")
+ fs.StringVar(&options.OutPath, "out", "", "fresh canonical content-addressed decision output")
+ 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("--draft", options.DraftPath),
+ pathValue("--out", options.OutPath),
+ )
+}
+
+func parseDecisionSign(args []string) (DecisionSignOptions, error) {
+ var options DecisionSignOptions
+ fs := commandFlagSet("decision sign")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.DecisionPath, "decision", "", "canonical production GO/NO-GO decision JSON")
+ fs.StringVar(&options.EvidenceRoot, "evidence-root", "", "local root containing every artifact pinned by a GO decision")
+ fs.StringVar(&options.Role, "role", "", "signer role: coordinator, auditor, or release_signer")
+ fs.StringVar(&options.SignerID, "signer-id", "", "exact signer identity from the ceremony decision")
+ fs.StringVar(&options.SigningKey, "signing-key", "", "existing Ed25519 decision signer private key path")
+ fs.StringVar(&options.OutPath, "out", "", "fresh canonical detached decision-signature output")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ switch options.Role {
+ case string(mpcceremony.DecisionSignerCoordinator),
+ string(mpcceremony.DecisionSignerAuditor),
+ string(mpcceremony.DecisionSignerRelease):
+ default:
+ return options, errors.New("--role must be coordinator, auditor, or release_signer")
+ }
+ if options.EvidenceRoot != "" {
+ if err := validatePathValue("--evidence-root", options.EvidenceRoot); 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("--decision", options.DecisionPath),
+ value("--role", options.Role),
+ value("--signer-id", options.SignerID),
+ pathValue("--signing-key", options.SigningKey),
+ pathValue("--out", options.OutPath),
+ )
+}
+
+func parseDecisionVerify(args []string) (DecisionVerifyOptions, error) {
+ var options DecisionVerifyOptions
+ var signatures stringList
+ fs := commandFlagSet("decision verify")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.DecisionPath, "decision", "", "canonical production GO/NO-GO decision JSON")
+ fs.Var(&signatures, "signature", "canonical decision-signature path; repeat once per signer")
+ fs.StringVar(&options.EvidenceRoot, "evidence-root", "", "local root containing every artifact pinned by the decision")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ options.SignaturePaths = append([]string(nil), signatures...)
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--decision", options.DecisionPath),
+ pathValue("--evidence-root", options.EvidenceRoot),
+ ); err != nil {
+ return options, err
+ }
+ if len(options.SignaturePaths) == 0 {
+ return options, errors.New("--signature must be supplied at least once")
+ }
+ for _, signature := range options.SignaturePaths {
+ if err := validatePathValue("--signature", signature); err != nil {
+ return options, err
+ }
+ }
+ return options, nil
+}
+
+func parseOps(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing ops command", topic: []string{"ops"}}
+ }
+ if args[0] == "help" {
+ return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)}
+ }
+ switch args[0] {
+ case "export-signing":
+ options, err := parseOpsExportSigning(args[1:])
+ invocation.Command, invocation.Options = CommandOpsExportSigning, options
+ return invocation, wrapCommandError(err, "ops", "export-signing")
+ case "import-signature":
+ options, err := parseOpsImportSignature(args[1:])
+ invocation.Command, invocation.Options = CommandOpsImportSig, options
+ return invocation, wrapCommandError(err, "ops", "import-signature")
+ case "verify":
+ options, err := parseOpsVerify(args[1:])
+ invocation.Command, invocation.Options = CommandOpsVerify, options
+ return invocation, wrapCommandError(err, "ops", "verify")
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown ops command %q", args[0]),
+ topic: []string{"ops"},
+ }
+ }
+}
+
+func parseOpsExportSigning(args []string) (OpsExportSigningOptions, error) {
+ var options OpsExportSigningOptions
+ fs := commandFlagSet("ops export-signing")
+ addOpsRecordFlags(fs, &options.RecordType, &options.RecordPath)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical record and signing request")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ return options, requireValues(
+ value("--record-type", options.RecordType),
+ pathValue("--record", options.RecordPath),
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--out-dir", options.OutDir),
+ )
+}
+
+func parseOpsImportSignature(args []string) (OpsImportSignatureOptions, error) {
+ var options OpsImportSignatureOptions
+ fs := commandFlagSet("ops import-signature")
+ fs.StringVar(&options.RecordType, "record-type", "", "operational record type")
+ fs.StringVar(&options.CanonicalPath, "canonical", "", "exact canonical record bytes exported for offline signing")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.SignerPublicKeyFile, "signer-public-key-file", "", "out-of-band trusted Ed25519 signer public key")
+ fs.StringVar(&options.RawSignaturePath, "raw-signature", "", "64 raw bytes or 128 lowercase hex characters from offline signer")
+ fs.StringVar(&options.OutPath, "out", "", "fresh detached signature output")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ return options, requireValues(
+ value("--record-type", options.RecordType),
+ pathValue("--canonical", options.CanonicalPath),
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--signer-public-key-file", options.SignerPublicKeyFile),
+ pathValue("--raw-signature", options.RawSignaturePath),
+ pathValue("--out", options.OutPath),
+ )
+}
+
+func parseOpsVerify(args []string) (OpsVerifyOptions, error) {
+ var options OpsVerifyOptions
+ fs := commandFlagSet("ops verify")
+ addOpsRecordFlags(fs, &options.RecordType, &options.RecordPath)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.SignaturePath, "signature", "", "detached operational record signature")
+ fs.StringVar(&options.SignerPublicKeyFile, "signer-public-key-file", "", "out-of-band trusted Ed25519 signer public key")
+ fs.StringVar(&options.RelatedRecordPath, "related-record", "", "exact related handoff for receipt cross-checking")
+ fs.StringVar(&options.EvidenceRoot, "evidence-root", "", "operational evidence root required for evidence-bundle verification")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ return options, requireValues(
+ value("--record-type", options.RecordType),
+ pathValue("--record", options.RecordPath),
+ pathValue("--signature", options.SignaturePath),
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--signer-public-key-file", options.SignerPublicKeyFile),
+ )
+}
+
+func addOpsRecordFlags(fs *flag.FlagSet, recordType, recordPath *string) {
+ fs.StringVar(recordType, "record-type", "", "enrollment, handoff, receipt, mirror-receipt, public-witness, beacon-evidence, evidence-bundle, or governance")
+ fs.StringVar(recordPath, "record", "", "canonical operational record JSON")
+}
+
+func parsePhase1(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing phase1 command", topic: []string{"phase1"}}
+ }
+ if args[0] == "help" {
+ return Invocation{}, &helpRequest{topic: append([]string{"phase1"}, args[1:]...)}
+ }
+ switch args[0] {
+ case "contribute":
+ options, err := parseContribute("phase1 contribute", args[1:], false)
+ invocation.Command, invocation.Options = CommandPhase1Contribute, options
+ return invocation, wrapCommandError(err, "phase1", "contribute")
+ case "attest-erasure":
+ options, err := parseErasure("phase1 attest-erasure", args[1:])
+ invocation.Command, invocation.Options = CommandPhase1Erasure, options
+ return invocation, wrapCommandError(err, "phase1", "attest-erasure")
+ case "verify":
+ options, err := parseVerifyContribution("phase1 verify", args[1:], false)
+ invocation.Command, invocation.Options = CommandPhase1Verify, options
+ return invocation, wrapCommandError(err, "phase1", "verify")
+ case "close":
+ options, err := parseClose("phase1 close", args[1:], false)
+ invocation.Command, invocation.Options = CommandPhase1Close, options
+ return invocation, wrapCommandError(err, "phase1", "close")
+ case "beacon":
+ options, err := parseBeacon("phase1 beacon", args[1:])
+ invocation.Command, invocation.Options = CommandPhase1Beacon, options
+ return invocation, wrapCommandError(err, "phase1", "beacon")
+ case "seal":
+ options, err := parsePhase1Seal(args[1:])
+ invocation.Command, invocation.Options = CommandPhase1Seal, options
+ return invocation, wrapCommandError(err, "phase1", "seal")
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown phase1 command %q", args[0]),
+ topic: []string{"phase1"},
+ }
+ }
+}
+
+func parsePhase2(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing phase2 command", topic: []string{"phase2"}}
+ }
+ if args[0] == "help" {
+ return Invocation{}, &helpRequest{topic: append([]string{"phase2"}, args[1:]...)}
+ }
+ switch args[0] {
+ case "init":
+ options, err := parsePhase2Init(args[1:])
+ invocation.Command, invocation.Options = CommandPhase2Init, options
+ return invocation, wrapCommandError(err, "phase2", "init")
+ case "contribute":
+ options, err := parseContribute("phase2 contribute", args[1:], true)
+ invocation.Command, invocation.Options = CommandPhase2Contribute, options
+ return invocation, wrapCommandError(err, "phase2", "contribute")
+ case "attest-erasure":
+ options, err := parseErasure("phase2 attest-erasure", args[1:])
+ invocation.Command, invocation.Options = CommandPhase2Erasure, options
+ return invocation, wrapCommandError(err, "phase2", "attest-erasure")
+ case "verify":
+ options, err := parseVerifyContribution("phase2 verify", args[1:], true)
+ invocation.Command, invocation.Options = CommandPhase2Verify, options
+ return invocation, wrapCommandError(err, "phase2", "verify")
+ case "close":
+ options, err := parseClose("phase2 close", args[1:], true)
+ invocation.Command, invocation.Options = CommandPhase2Close, options
+ return invocation, wrapCommandError(err, "phase2", "close")
+ case "beacon":
+ options, err := parseBeacon("phase2 beacon", args[1:])
+ invocation.Command, invocation.Options = CommandPhase2Beacon, options
+ return invocation, wrapCommandError(err, "phase2", "beacon")
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown phase2 command %q", args[0]),
+ topic: []string{"phase2"},
+ }
+ }
+}
+
+func parseRelease(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing release command", topic: []string{"release"}}
+ }
+ if args[0] == "help" {
+ return Invocation{}, &helpRequest{topic: append([]string{"release"}, args[1:]...)}
+ }
+ switch args[0] {
+ case "sign":
+ options, err := parseReleaseSign(args[1:])
+ invocation.Command, invocation.Options = CommandReleaseSign, options
+ return invocation, wrapCommandError(err, "release", "sign")
+ case "verify":
+ options, err := parseReleaseVerify(args[1:])
+ invocation.Command, invocation.Options = CommandReleaseVerify, options
+ return invocation, wrapCommandError(err, "release", "verify")
+ default:
+ return Invocation{}, &usageError{
+ message: fmt.Sprintf("unknown release command %q", args[0]),
+ topic: []string{"release"},
+ }
+ }
+}
+
+func parseInit(args []string) (InitOptions, error) {
+ var options InitOptions
+ 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.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")
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh ceremony directory")
+ fs.StringVar(&options.Mode, "mode", "rehearsal", "ceremony mode: rehearsal or production")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ 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)
+ }
+ if options.SessionNonceHex != "" {
+ raw, err := hex.DecodeString(options.SessionNonceHex)
+ if err != nil || len(raw) != 32 {
+ return options, errors.New("--session-nonce-hex must encode exactly 32 bytes")
+ }
+ }
+ return options, requireValues(
+ value("--created-at", options.CreatedAt),
+ value("--key-version", options.KeyVersion),
+ pathValue("--participants", options.ParticipantsPath),
+ pathValue("--policy", options.PolicyPath),
+ value("--coordinator-key-id", options.CoordinatorKeyID),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ pathValue("--out-dir", options.OutDir),
+ )
+}
+
+func parseContribute(name string, args []string, phase2 bool) (ContributeOptions, error) {
+ var options ContributeOptions
+ fs := commandFlagSet(name)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ if phase2 {
+ fs.StringVar(&options.Phase1SealPath, "phase1-seal", "", "verified phase 1 seal JSON path")
+ fs.StringVar(&options.Phase1SealSignaturePath, "phase1-seal-signature", "", "detached phase 1 seal signature path")
+ }
+ fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "complete ceremony transcript root")
+ fs.StringVar(&options.ChainPath, "chain", "", "explicit accepted chain JSON path")
+ fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached accepted chain signature path")
+ fs.StringVar(&options.ParticipantID, "participant-id", "", "participant identifier from the signed roster")
+ fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "existing Ed25519 participant private key path")
+ fs.StringVar(&options.EnvironmentPath, "environment", "", "canonical contribution environment attestation JSON path")
+ fs.StringVar(&options.ContributedAt, "contributed-at", "", "contribution timestamp in RFC3339")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh candidate contribution directory")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ required := []requiredValue{
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--transcript-dir", options.TranscriptDir),
+ pathValue("--chain", options.ChainPath),
+ pathValue("--chain-signature", options.ChainSignaturePath),
+ value("--participant-id", options.ParticipantID),
+ pathValue("--participant-signing-key", options.ParticipantSigningKey),
+ pathValue("--environment", options.EnvironmentPath),
+ value("--contributed-at", options.ContributedAt),
+ pathValue("--out-dir", options.OutDir),
+ }
+ if phase2 {
+ required = append(
+ required,
+ pathValue("--phase1-seal", options.Phase1SealPath),
+ pathValue("--phase1-seal-signature", options.Phase1SealSignaturePath),
+ )
+ }
+ return options, requireValues(required...)
+}
+
+func parseVerifyContribution(name string, args []string, phase2 bool) (VerifyContributionOptions, error) {
+ var options VerifyContributionOptions
+ fs := commandFlagSet(name)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ if phase2 {
+ fs.StringVar(&options.Phase1SealPath, "phase1-seal", "", "verified phase 1 seal JSON path")
+ fs.StringVar(&options.Phase1SealSignaturePath, "phase1-seal-signature", "", "detached phase 1 seal signature path")
+ }
+ fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "complete ceremony transcript root")
+ fs.StringVar(&options.ChainPath, "chain", "", "explicit accepted chain JSON path")
+ fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached accepted chain signature path")
+ fs.StringVar(&options.CandidateDir, "candidate-dir", "", "candidate contribution directory")
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.AcceptedAt, "accepted-at", "", "coordinator acceptance timestamp in RFC3339")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ required := []requiredValue{
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--transcript-dir", options.TranscriptDir),
+ pathValue("--chain", options.ChainPath),
+ pathValue("--chain-signature", options.ChainSignaturePath),
+ pathValue("--candidate-dir", options.CandidateDir),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ value("--accepted-at", options.AcceptedAt),
+ }
+ if phase2 {
+ required = append(
+ required,
+ pathValue("--phase1-seal", options.Phase1SealPath),
+ pathValue("--phase1-seal-signature", options.Phase1SealSignaturePath),
+ )
+ }
+ return options, requireValues(required...)
+}
+
+func parseErasure(name string, args []string) (ErasureOptions, error) {
+ var options ErasureOptions
+ fs := commandFlagSet(name)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.ParticipantID, "participant-id", "", "participant identifier from the signed roster")
+ fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "existing Ed25519 participant private key path")
+ fs.StringVar(&options.CandidateDir, "candidate-dir", "", "candidate contribution directory")
+ fs.StringVar(&options.DestroyedAt, "destroyed-at", "", "environment destruction timestamp in RFC3339")
+ 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),
+ value("--participant-id", options.ParticipantID),
+ pathValue("--participant-signing-key", options.ParticipantSigningKey),
+ pathValue("--candidate-dir", options.CandidateDir),
+ value("--destroyed-at", options.DestroyedAt),
+ )
+}
+
+func parseClose(name string, args []string, phase2 bool) (CloseOptions, error) {
+ var options CloseOptions
+ fs := commandFlagSet(name)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ if phase2 {
+ fs.StringVar(&options.Phase1SealPath, "phase1-seal", "", "verified phase 1 seal JSON path")
+ fs.StringVar(&options.Phase1SealSignaturePath, "phase1-seal-signature", "", "detached phase 1 seal signature path")
+ }
+ fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "complete ceremony transcript root")
+ fs.StringVar(&options.ChainPath, "chain", "", "explicit final accepted chain JSON path")
+ 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")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ required := []requiredValue{
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--transcript-dir", options.TranscriptDir),
+ pathValue("--chain", options.ChainPath),
+ pathValue("--chain-signature", options.ChainSignaturePath),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ }
+ if options.BeaconRound == 0 {
+ required = append(required, requiredValue{name: "--beacon-round"})
+ }
+ if phase2 {
+ required = append(
+ required,
+ pathValue("--phase1-seal", options.Phase1SealPath),
+ pathValue("--phase1-seal-signature", options.Phase1SealSignaturePath),
+ )
+ }
+ return options, requireValues(required...)
+}
+
+func parsePhase1Seal(args []string) (Phase1SealOptions, error) {
+ var options Phase1SealOptions
+ fs := commandFlagSet("phase1 seal")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "complete ceremony transcript root containing closed phase 1")
+ fs.StringVar(&options.ClosurePath, "closure", "", "signed phase 1 closure JSON path")
+ fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached phase 1 closure signature path")
+ fs.StringVar(&options.BeaconPath, "beacon", "", "offline public beacon evidence JSON path")
+ fs.StringVar(&options.BeaconSignaturePath, "beacon-signature", "", "detached public beacon signature path")
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh phase 1 seal directory")
+ 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),
+ pathValue("--closure", options.ClosurePath),
+ pathValue("--closure-signature", options.ClosureSignaturePath),
+ pathValue("--beacon", options.BeaconPath),
+ pathValue("--beacon-signature", options.BeaconSignaturePath),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ pathValue("--out-dir", options.OutDir),
+ )
+}
+
+func parseBeacon(name string, args []string) (BeaconOptions, error) {
+ var options BeaconOptions
+ fs := commandFlagSet(name)
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.ClosurePath, "closure", "", "signed phase closure JSON path")
+ fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached phase closure signature path")
+ fs.StringVar(&options.RawResponsePath, "raw-response", "", "local raw beacon-provider response path")
+ fs.StringVar(&options.PublishedAt, "published-at", "", "beacon publication timestamp in RFC3339")
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "ceremony transcript root with a fresh phase beacon directory")
+ 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("--closure", options.ClosurePath),
+ pathValue("--closure-signature", options.ClosureSignaturePath),
+ pathValue("--raw-response", options.RawResponsePath),
+ value("--published-at", options.PublishedAt),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ pathValue("--transcript-dir", options.TranscriptDir),
+ )
+}
+
+func parsePhase2Init(args []string) (Phase2InitOptions, error) {
+ var options Phase2InitOptions
+ fs := commandFlagSet("phase2 init")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.Phase1TranscriptDir, "phase1-transcript-dir", "", "complete ceremony transcript root containing sealed phase 1")
+ fs.StringVar(&options.Phase1SealPath, "phase1-seal", "", "verified phase 1 seal JSON path")
+ fs.StringVar(&options.Phase1SealSignaturePath, "phase1-seal-signature", "", "detached phase 1 seal signature path")
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh phase 2 transcript directory")
+ 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("--phase1-transcript-dir", options.Phase1TranscriptDir),
+ pathValue("--phase1-seal", options.Phase1SealPath),
+ pathValue("--phase1-seal-signature", options.Phase1SealSignaturePath),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ pathValue("--out-dir", options.OutDir),
+ )
+}
+
+func parseFinalize(invocation Invocation, args []string) (Invocation, error) {
+ if len(args) == 0 {
+ return Invocation{}, &usageError{message: "missing finalize command", topic: []string{"finalize"}}
+ }
+ switch args[0] {
+ case "prepare":
+ options, err := parsePrepareFinalization(args[1:])
+ invocation.Command, invocation.Options = CommandFinalizePrepare, options
+ return invocation, wrapCommandError(err, "finalize", "prepare")
+ case "complete":
+ options, err := parseCompleteFinalization(args[1:])
+ invocation.Command, invocation.Options = CommandFinalizeComplete, options
+ return invocation, wrapCommandError(err, "finalize", "complete")
+ default:
+ return Invocation{}, &usageError{message: fmt.Sprintf("unknown finalize command %q", args[0]), topic: []string{"finalize"}}
+ }
+}
+
+func parsePrepareFinalization(args []string) (PrepareFinalizationOptions, error) {
+ var options PrepareFinalizationOptions
+ fs := commandFlagSet("finalize prepare")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ addReplayFlags(fs, &options.Replay)
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.PreparedAt, "prepared-at", "", "preliminary key timestamp in RFC3339 UTC")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh preliminary final-key directory")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ value("--prepared-at", options.PreparedAt),
+ pathValue("--out-dir", options.OutDir),
+ ); err != nil {
+ return options, err
+ }
+ return options, validateReplayOptions(options.Replay)
+}
+
+func parseCompleteFinalization(args []string) (FinalizeOptions, error) {
+ var options FinalizeOptions
+ fs := commandFlagSet("finalize complete")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ addReplayFlags(fs, &options.Replay)
+ fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path")
+ fs.StringVar(&options.PublicEvidencePath, "public-evidence", "", "canonical public finalization evidence JSON from a separate local proof tool")
+ fs.StringVar(&options.FinalizedAt, "finalized-at", "", "candidate finalization timestamp in RFC3339 UTC")
+ fs.StringVar(&options.OutDir, "out-dir", "", "fresh unsigned release candidate directory")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--coordinator-signing-key", options.CoordinatorSigningKey),
+ pathValue("--public-evidence", options.PublicEvidencePath),
+ value("--finalized-at", options.FinalizedAt),
+ pathValue("--out-dir", options.OutDir),
+ ); err != nil {
+ return options, err
+ }
+ return options, validateReplayOptions(options.Replay)
+}
+
+func parseAudit(args []string) (AuditOptions, error) {
+ var options AuditOptions
+ fs := commandFlagSet("audit")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ addReplayFlags(fs, &options.Replay)
+ fs.StringVar(&options.CandidateBundleDir, "candidate-bundle", "", "finalized candidate key bundle directory")
+ fs.StringVar(&options.AuditorID, "auditor-id", "", "auditor identifier from ceremony policy")
+ fs.StringVar(&options.AuditorSigningKey, "auditor-signing-key", "", "existing Ed25519 auditor private key path")
+ fs.StringVar(&options.AuditedAt, "audited-at", "", "audit timestamp in RFC3339 UTC")
+ fs.StringVar(&options.OutPath, "out", "", "fresh audit report JSON path")
+ fs.StringVar(&options.SignatureOutPath, "audit-signature", "", "fresh detached audit signature path")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--candidate-bundle", options.CandidateBundleDir),
+ value("--auditor-id", options.AuditorID),
+ pathValue("--auditor-signing-key", options.AuditorSigningKey),
+ value("--audited-at", options.AuditedAt),
+ pathValue("--out", options.OutPath),
+ pathValue("--audit-signature", options.SignatureOutPath),
+ ); err != nil {
+ return options, err
+ }
+ return options, validateReplayOptions(options.Replay)
+}
+
+func parseReleaseSign(args []string) (ReleaseSignOptions, error) {
+ var options ReleaseSignOptions
+ var auditReports, auditSignatures stringList
+ fs := commandFlagSet("release sign")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.CandidateBundleDir, "candidate-bundle", "", "audited candidate key bundle directory")
+ fs.Var(&auditReports, "audit-report", "independent audit report path; repeat in auditor order")
+ fs.Var(&auditSignatures, "audit-signature", "detached audit signature path; repeat in matching order")
+ fs.StringVar(&options.OperationalEvidenceRoot, "operational-evidence-root", "", "local root containing the complete operational evidence tree")
+ fs.StringVar(&options.OperationalBundlePath, "operational-bundle", "", "coordinator-signed operational evidence bundle JSON path")
+ fs.StringVar(&options.OperationalSignaturePath, "operational-bundle-signature", "", "detached operational evidence bundle signature path")
+ fs.StringVar(&options.ReleaseSigningKey, "release-signing-key", "", "existing Ed25519 release private key path")
+ fs.StringVar(&options.SignatureKeyID, "signature-key-id", "", "release signing key identifier")
+ fs.StringVar(&options.ReleasedAt, "released-at", "", "release publication timestamp in RFC3339 UTC")
+ fs.StringVar(&options.ReleaseDir, "release-dir", "", "fresh release bundle directory distinct from the candidate")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ options.AuditReportPaths = append([]string(nil), auditReports...)
+ options.AuditSignaturePaths = append([]string(nil), auditSignatures...)
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--candidate-bundle", options.CandidateBundleDir),
+ pathValue("--operational-evidence-root", options.OperationalEvidenceRoot),
+ pathValue("--operational-bundle", options.OperationalBundlePath),
+ pathValue("--operational-bundle-signature", options.OperationalSignaturePath),
+ pathValue("--release-signing-key", options.ReleaseSigningKey),
+ value("--signature-key-id", options.SignatureKeyID),
+ value("--released-at", options.ReleasedAt),
+ pathValue("--release-dir", options.ReleaseDir),
+ ); err != nil {
+ return options, err
+ }
+ if err := validateAuditArtifacts(options.AuditReportPaths, options.AuditSignaturePaths); err != nil {
+ return options, err
+ }
+ return options, nil
+}
+
+func parseReleaseVerify(args []string) (ReleaseVerifyOptions, error) {
+ var options ReleaseVerifyOptions
+ fs := commandFlagSet("release verify")
+ addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile)
+ fs.StringVar(&options.KeysDir, "keys-dir", "", "signed key bundle directory")
+ fs.StringVar(&options.ManifestPublicKeyFile, "manifest-public-key-file", "", "out-of-band trusted release public key path")
+ fs.StringVar(&options.SignatureKeyID, "signature-key-id", "", "expected release signature key identifier")
+ if err := parseFlags(fs, args); err != nil {
+ return options, err
+ }
+ if err := requireValues(
+ pathValue("--ceremony", options.CeremonyPath),
+ pathValue("--ceremony-signature", options.CeremonySignaturePath),
+ pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile),
+ pathValue("--keys-dir", options.KeysDir),
+ pathValue("--manifest-public-key-file", options.ManifestPublicKeyFile),
+ value("--signature-key-id", options.SignatureKeyID),
+ ); err != nil {
+ return options, err
+ }
+ return options, nil
+}
+
+func addReplayFlags(fs *flag.FlagSet, replay *ReplayOptions) {
+ fs.StringVar(&replay.TranscriptRoot, "transcript-root", "", "complete immutable ceremony transcript root")
+ fs.StringVar(&replay.Phase1ChainPath, "phase1-chain", "", "final signed phase 1 chain JSON path")
+ fs.StringVar(&replay.Phase1ChainSignaturePath, "phase1-chain-signature", "", "detached final phase 1 chain signature path")
+ fs.StringVar(&replay.Phase1ClosePath, "phase1-close", "", "signed phase 1 closure JSON path")
+ fs.StringVar(&replay.Phase1CloseSignaturePath, "phase1-close-signature", "", "detached phase 1 closure signature path")
+ fs.StringVar(&replay.Phase1BeaconPath, "phase1-beacon", "", "signed phase 1 beacon record path")
+ fs.StringVar(&replay.Phase1BeaconSignaturePath, "phase1-beacon-signature", "", "detached phase 1 beacon signature path")
+ fs.StringVar(&replay.Phase1SealPath, "phase1-seal", "", "signed phase 1 seal JSON path")
+ fs.StringVar(&replay.Phase1SealSignaturePath, "phase1-seal-signature", "", "detached phase 1 seal signature path")
+ fs.StringVar(&replay.Phase2ChainPath, "phase2-chain", "", "final signed phase 2 chain JSON path")
+ fs.StringVar(&replay.Phase2ChainSignaturePath, "phase2-chain-signature", "", "detached final phase 2 chain signature path")
+ fs.StringVar(&replay.Phase2ClosePath, "phase2-close", "", "signed phase 2 closure JSON path")
+ fs.StringVar(&replay.Phase2CloseSignaturePath, "phase2-close-signature", "", "detached phase 2 closure signature path")
+ fs.StringVar(&replay.Phase2BeaconPath, "phase2-beacon", "", "signed phase 2 beacon record path")
+ fs.StringVar(&replay.Phase2BeaconSignaturePath, "phase2-beacon-signature", "", "detached phase 2 beacon signature path")
+}
+
+func validateReplayOptions(replay ReplayOptions) error {
+ return requireValues(
+ pathValue("--transcript-root", replay.TranscriptRoot),
+ pathValue("--phase1-chain", replay.Phase1ChainPath),
+ pathValue("--phase1-chain-signature", replay.Phase1ChainSignaturePath),
+ pathValue("--phase1-close", replay.Phase1ClosePath),
+ pathValue("--phase1-close-signature", replay.Phase1CloseSignaturePath),
+ pathValue("--phase1-beacon", replay.Phase1BeaconPath),
+ pathValue("--phase1-beacon-signature", replay.Phase1BeaconSignaturePath),
+ pathValue("--phase1-seal", replay.Phase1SealPath),
+ pathValue("--phase1-seal-signature", replay.Phase1SealSignaturePath),
+ pathValue("--phase2-chain", replay.Phase2ChainPath),
+ pathValue("--phase2-chain-signature", replay.Phase2ChainSignaturePath),
+ pathValue("--phase2-close", replay.Phase2ClosePath),
+ pathValue("--phase2-close-signature", replay.Phase2CloseSignaturePath),
+ pathValue("--phase2-beacon", replay.Phase2BeaconPath),
+ pathValue("--phase2-beacon-signature", replay.Phase2BeaconSignaturePath),
+ )
+}
+
+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) != len(signatures) {
+ return errors.New("--audit-report and --audit-signature counts must match")
+ }
+ for _, path := range reports {
+ if err := validatePathValue("--audit-report", path); err != nil {
+ return err
+ }
+ }
+ for _, path := range signatures {
+ if err := validatePathValue("--audit-signature", path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func commandFlagSet(name string) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ return fs
+}
+
+func addCeremonyTrustFlags(fs *flag.FlagSet, ceremonyPath, ceremonySignaturePath, coordinatorPublicKeyFile *string) {
+ fs.StringVar(ceremonyPath, "ceremony", "", "signed ceremony definition JSON path")
+ fs.StringVar(ceremonySignaturePath, "ceremony-signature", "", "detached ceremony definition signature path")
+ fs.StringVar(coordinatorPublicKeyFile, "coordinator-public-key-file", "", "out-of-band trusted coordinator public key path")
+}
+
+func parseFlags(fs *flag.FlagSet, args []string) error {
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() != 0 {
+ return fmt.Errorf("unexpected positional arguments: %s", strings.Join(fs.Args(), " "))
+ }
+ return nil
+}
+
+func wrapCommandError(err error, topic ...string) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, flag.ErrHelp) {
+ return &helpRequest{topic: topic}
+ }
+ var h *helpRequest
+ if errors.As(err, &h) {
+ return err
+ }
+ return &usageError{message: err.Error(), topic: topic}
+}
+
+type requiredValue struct {
+ name string
+ value string
+ path bool
+}
+
+func value(name, content string) requiredValue {
+ return requiredValue{name: name, value: strings.TrimSpace(content)}
+}
+
+func pathValue(name, content string) requiredValue {
+ return requiredValue{name: name, value: strings.TrimSpace(content), path: true}
+}
+
+func requireValues(values ...requiredValue) error {
+ var missing []string
+ for _, item := range values {
+ if item.value == "" {
+ missing = append(missing, item.name)
+ continue
+ }
+ if item.path {
+ if err := validatePathValue(item.name, item.value); err != nil {
+ return err
+ }
+ }
+ }
+ if len(missing) != 0 {
+ return fmt.Errorf("required flag(s) missing: %s", strings.Join(missing, ", "))
+ }
+ return nil
+}
+
+func validatePathValue(name, content string) error {
+ if content == "-" {
+ return fmt.Errorf("%s must name a filesystem path; standard input/output is not supported", name)
+ }
+ if strings.Contains(content, "://") {
+ return fmt.Errorf("%s must name a local filesystem path; URLs are not supported", name)
+ }
+ return nil
+}
+
+type stringList []string
+
+func (s *stringList) String() string { return strings.Join(*s, ",") }
+
+func (s *stringList) Set(value string) error {
+ if strings.TrimSpace(value) == "" {
+ return errors.New("value must not be empty")
+ }
+ *s = append(*s, value)
+ return nil
+}
diff --git a/cmd/mpc-ceremony/secret_boundary_test.go b/cmd/mpc-ceremony/secret_boundary_test.go
new file mode 100644
index 00000000..78dee78e
--- /dev/null
+++ b/cmd/mpc-ceremony/secret_boundary_test.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestProductionCeremonySourceAndBinaryExcludeWalletSecretAPIs(t *testing.T) {
+ root := filepath.Clean(filepath.Join("..", ".."))
+ forbiddenSource := []string{
+ "DecodeMasterXPrvHex",
+ "DiscoverCredentialPath",
+ "ownershipdest.Assignment",
+ "prover.Prove(",
+ "--master-xprv",
+ "--seed-phrase",
+ "c065afd2832cd8b087c4d9ab7011f481",
+ }
+ for _, relativeRoot := range []string{"cmd/mpc-ceremony", "internal/mpcceremony"} {
+ err := filepath.WalkDir(filepath.Join(root, relativeRoot), func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ for _, forbidden := range forbiddenSource {
+ if bytes.Contains(data, []byte(forbidden)) {
+ t.Errorf("production ceremony source %s contains forbidden wallet-secret API/literal %q", path, forbidden)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ binary := filepath.Join(t.TempDir(), "mpc-ceremony")
+ build := exec.Command("go", "build", "-mod=vendor", "-trimpath", "-o", binary, "./cmd/mpc-ceremony")
+ build.Dir = root
+ build.Env = append(os.Environ(), "GOWORK=off")
+ if output, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build production ceremony binary: %v\n%s", err, output)
+ }
+ nm := exec.Command("go", "tool", "nm", binary)
+ symbols, err := nm.CombinedOutput()
+ if err != nil {
+ t.Fatalf("inspect production ceremony symbols: %v\n%s", err, symbols)
+ }
+ for _, forbidden := range []string{
+ "ownership.DecodeMasterXPrvHex",
+ "ownership.DiscoverCredentialPath",
+ "ownershipdest.Assignment",
+ "prover.Prove",
+ } {
+ if bytes.Contains(symbols, []byte(forbidden)) {
+ t.Errorf("production ceremony binary retains forbidden wallet-secret symbol %q", forbidden)
+ }
+ }
+ binaryBytes, err := os.ReadFile(binary)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, forbidden := range []string{
+ "--master-xprv",
+ "--seed-phrase",
+ "c065afd2832cd8b087c4d9ab7011f481",
+ } {
+ if bytes.Contains(binaryBytes, []byte(forbidden)) {
+ t.Errorf("production ceremony binary contains forbidden wallet-secret literal %q", forbidden)
+ }
+ }
+}
diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go
new file mode 100644
index 00000000..edfa89d9
--- /dev/null
+++ b/cmd/mpc-ceremony/types.go
@@ -0,0 +1,328 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "errors"
+)
+
+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"
+)
+
+type GlobalOptions struct {
+ Format string
+ Quiet bool
+}
+
+type Invocation struct {
+ Global GlobalOptions
+ Command Command
+ Options any
+}
+
+type InitOptions struct {
+ SessionNonceHex string
+ CreatedAt string
+ KeyVersion string
+ ParticipantsPath string
+ PolicyPath string
+ CoordinatorKeyID string
+ CoordinatorSigningKey string
+ OutDir string
+ Mode string
+}
+
+type ContributeOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ TranscriptDir string
+ ChainPath string
+ ChainSignaturePath string
+ ParticipantID string
+ ParticipantSigningKey string
+ EnvironmentPath string
+ ContributedAt string
+ OutDir string
+}
+
+type VerifyContributionOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ TranscriptDir string
+ ChainPath string
+ ChainSignaturePath string
+ CandidateDir string
+ CoordinatorSigningKey string
+ AcceptedAt string
+}
+
+type ErasureOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ ParticipantID string
+ ParticipantSigningKey string
+ CandidateDir string
+ DestroyedAt string
+}
+
+type CloseOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ TranscriptDir string
+ ChainPath string
+ ChainSignaturePath string
+ CoordinatorSigningKey string
+ BeaconRound uint64
+}
+
+type Phase1SealOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ TranscriptDir string
+ ClosurePath string
+ ClosureSignaturePath string
+ BeaconPath string
+ BeaconSignaturePath string
+ CoordinatorSigningKey string
+ OutDir string
+}
+
+type BeaconOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ ClosurePath string
+ ClosureSignaturePath string
+ RawResponsePath string
+ PublishedAt string
+ CoordinatorSigningKey string
+ TranscriptDir string
+}
+
+type Phase2InitOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Phase1TranscriptDir string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ CoordinatorSigningKey string
+ OutDir string
+}
+
+type FinalizeOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Replay ReplayOptions
+ CoordinatorSigningKey string
+ PublicEvidencePath string
+ FinalizedAt string
+ OutDir string
+}
+
+type PrepareFinalizationOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Replay ReplayOptions
+ CoordinatorSigningKey string
+ PreparedAt string
+ OutDir string
+}
+
+type AuditOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ Replay ReplayOptions
+ CandidateBundleDir string
+ AuditorID string
+ AuditorSigningKey string
+ AuditedAt string
+ OutPath string
+ SignatureOutPath string
+}
+
+type ReleaseSignOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ CandidateBundleDir string
+ AuditReportPaths []string
+ AuditSignaturePaths []string
+ OperationalEvidenceRoot string
+ OperationalBundlePath string
+ OperationalSignaturePath string
+ ReleaseSigningKey string
+ SignatureKeyID string
+ ReleasedAt string
+ ReleaseDir string
+}
+
+type ReleaseVerifyOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ KeysDir string
+ ManifestPublicKeyFile string
+ SignatureKeyID string
+}
+
+type OpsExportSigningOptions struct {
+ RecordType string
+ RecordPath string
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ OutDir string
+}
+
+type OpsImportSignatureOptions struct {
+ RecordType string
+ CanonicalPath string
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ SignerPublicKeyFile string
+ RawSignaturePath string
+ OutPath string
+}
+
+type OpsVerifyOptions struct {
+ RecordType string
+ RecordPath string
+ SignaturePath string
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ SignerPublicKeyFile string
+ RelatedRecordPath string
+ EvidenceRoot string
+}
+
+type DecisionSignOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ DecisionPath string
+ EvidenceRoot string
+ Role string
+ SignerID string
+ SigningKey string
+ OutPath string
+}
+
+type DecisionPrepareOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ DraftPath string
+ OutPath string
+}
+
+type DecisionVerifyOptions struct {
+ CeremonyPath string
+ CeremonySignaturePath string
+ CoordinatorPublicKeyFile string
+ DecisionPath string
+ SignaturePaths []string
+ EvidenceRoot string
+}
+
+type ReplayOptions struct {
+ TranscriptRoot string
+ Phase1ChainPath string
+ Phase1ChainSignaturePath string
+ Phase1ClosePath string
+ Phase1CloseSignaturePath string
+ Phase1BeaconPath string
+ Phase1BeaconSignaturePath string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ Phase2ChainPath string
+ Phase2ChainSignaturePath string
+ Phase2ClosePath string
+ Phase2CloseSignaturePath string
+ Phase2BeaconPath string
+ Phase2BeaconSignaturePath string
+}
+
+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"`
+}
+
+type Executor interface {
+ Execute(context.Context, Invocation) (CommandResult, error)
+}
+
+type executorFunc func(context.Context, Invocation) (CommandResult, error)
+
+func (f executorFunc) Execute(ctx context.Context, invocation Invocation) (CommandResult, error) {
+ return f(ctx, invocation)
+}
+
+var errExecutorNotWired = errors.New("MPC ceremony operation engine is not wired")
+
+type unwiredExecutor struct{}
+
+func (unwiredExecutor) Execute(context.Context, Invocation) (CommandResult, error) {
+ return CommandResult{}, errExecutorNotWired
+}
diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go
new file mode 100644
index 00000000..56d88220
--- /dev/null
+++ b/cmd/mpc-ceremony/usage.go
@@ -0,0 +1,367 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "strings"
+)
+
+func writeUsage(w io.Writer, topic []string) error {
+ key := strings.Join(topic, " ")
+ if text, ok := commandHelp[key]; ok {
+ _, err := fmt.Fprint(w, text)
+ return err
+ }
+ _, err := fmt.Fprint(w, rootHelp)
+ return err
+}
+
+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.
+
+Commands:
+ init Bind a ceremony to the compiled repository circuit
+ 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
+ phase1 close Close the accepted phase 1 chain
+ phase1 beacon Record signed post-closure beacon evidence
+ phase1 seal Apply an offline post-closure beacon
+ phase2 init Initialize circuit-specific phase 2
+ phase2 contribute Verify the full phase 2 chain and contribute
+ phase2 attest-erasure Sign a participant destruction attestation
+ phase2 verify Verify and append one candidate contribution
+ phase2 close Close the accepted phase 2 chain
+ phase2 beacon Record signed post-closure beacon evidence
+ finalize prepare Replay both phases and publish preliminary final keys
+ finalize complete Verify external public evidence and create candidate
+ audit Independently replay and audit ceremony artifacts
+ release sign Sign an audited release manifest
+ release verify Verify release and ceremony coherence
+ 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
+ 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
+
+All input and output paths are explicit. Outputs must not already exist. There
+are no network, automatic-discovery, overwrite, deterministic-randomness, or
+verification-bypass flags.
+
+Run "mpc-ceremony help " for command-specific help.
+`
+
+const replayFlagsHelp = `
+Required immutable replay evidence:
+ --transcript-root DIR
+ --phase1-chain FILE --phase1-chain-signature FILE
+ --phase1-close FILE --phase1-close-signature FILE
+ --phase1-beacon FILE --phase1-beacon-signature FILE
+ --phase1-seal FILE --phase1-seal-signature FILE
+ --phase2-chain FILE --phase2-chain-signature FILE
+ --phase2-close FILE --phase2-close-signature FILE
+ --phase2-beacon FILE --phase2-beacon-signature FILE
+
+The signed chain, closure, beacon, and seal records bind the genesis,
+contributions, attestations, erasure evidence, verification records, raw drand
+responses, and commons by safe relative artifact name. Those artifacts are
+strictly resolved beneath --transcript-root; the operator cannot substitute a
+second path list.
+`
+
+var commandHelp = map[string]string{
+ "init": `Usage:
+ mpc-ceremony init --key-version ownership-destination-v2 \
+ --participants ROSTER.json --policy POLICY.json \
+ --coordinator-key-id ID --coordinator-signing-key KEY \
+ --created-at RFC3339 --out-dir DIR [--mode rehearsal|production] \
+ [--session-nonce-hex HEX]
+
+Compiles a registered repository circuit and writes a fresh signed ceremony
+definition. The authoritative ceremony ID is derived from canonical content,
+including a 32-byte session nonce securely generated when omitted. Production
+mode requires an exact clean source build.
+`,
+ "phase1": `Usage:
+ mpc-ceremony phase1 [flags]
+
+Phase 1 is sequential and append-only. Contributors and coordinators must name
+the exact accepted chain; the command never discovers a "latest" state.
+`,
+ "phase1 contribute": `Usage:
+ mpc-ceremony phase1 contribute --ceremony FILE \
+ --ceremony-signature FILE --coordinator-public-key-file KEY \
+ --transcript-dir DIR --chain FILE --chain-signature FILE \
+ --participant-id ID --participant-signing-key KEY \
+ --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR
+
+Replays the complete accepted phase 1 chain before adding OS-generated
+randomness. The input chain is never modified.
+`,
+ "phase1 attest-erasure": `Usage:
+ mpc-ceremony phase1 attest-erasure --ceremony FILE \
+ --ceremony-signature FILE --coordinator-public-key-file KEY \
+ --participant-id ID --participant-signing-key KEY \
+ --candidate-dir DIR --destroyed-at RFC3339
+
+Writes erasure.json and its participant signature into the candidate directory
+without replacing existing files. This is an operational attestation, not
+technical or cryptographic proof that contribution randomness was erased.
+`,
+ "phase1 verify": `Usage:
+ mpc-ceremony phase1 verify --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --transcript-dir DIR --chain FILE \
+ --chain-signature FILE --candidate-dir DIR \
+ --coordinator-signing-key KEY --accepted-at RFC3339
+
+Authenticates the signed chain and candidate evidence, verifies the candidate
+transition directly from the accepted native head, then appends immutable
+numbered artifacts and a new signed chain record. Participant contribution and
+phase close perform the independent full-prefix replays.
+`,
+ "phase1 close": `Usage:
+ 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
+
+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.
+`,
+ "phase1 beacon": `Usage:
+ mpc-ceremony phase1 beacon --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --closure FILE \
+ --closure-signature FILE --raw-response FILE --published-at RFC3339 \
+ --coordinator-signing-key KEY --transcript-dir DIR
+
+Cryptographically verifies an archived pinned drand quicknet response and
+derives the protocol challenge from its signature. The command performs no
+network fetch and accepts neither randomness nor a challenge from the operator.
+`,
+ "phase1 seal": `Usage:
+ mpc-ceremony phase1 seal --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --transcript-dir DIR --closure FILE \
+ --closure-signature FILE --beacon FILE --beacon-signature FILE \
+ --coordinator-signing-key KEY --out-dir FRESH_DIR
+
+The beacon is supplied as offline evidence and must satisfy the signed policy
+and postdate the signed closure.
+`,
+ "phase2": `Usage:
+ mpc-ceremony phase2 [flags]
+
+Phase 2 is bound to the exact compiled R1CS and verified phase 1 seal.
+`,
+ "phase2 init": `Usage:
+ mpc-ceremony phase2 init --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --phase1-transcript-dir DIR \
+ --phase1-seal FILE --phase1-seal-signature FILE \
+ --coordinator-signing-key KEY --out-dir FRESH_DIR
+`,
+ "phase2 contribute": `Usage:
+ mpc-ceremony phase2 contribute --ceremony FILE \
+ --ceremony-signature FILE --coordinator-public-key-file KEY \
+ --phase1-seal FILE --phase1-seal-signature FILE \
+ --transcript-dir DIR --chain FILE --participant-id ID \
+ --chain-signature FILE --participant-signing-key KEY \
+ --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR
+`,
+ "phase2 attest-erasure": `Usage:
+ mpc-ceremony phase2 attest-erasure --ceremony FILE \
+ --ceremony-signature FILE --coordinator-public-key-file KEY \
+ --participant-id ID --participant-signing-key KEY \
+ --candidate-dir DIR --destroyed-at RFC3339
+
+Signs the participant's Phase 2 environment-destruction attestation. The
+statement is auditable evidence, not proof that secret randomness was erased.
+`,
+ "phase2 verify": `Usage:
+ mpc-ceremony phase2 verify --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --phase1-seal FILE \
+ --phase1-seal-signature FILE --transcript-dir DIR --chain FILE \
+ --chain-signature FILE --candidate-dir DIR \
+ --coordinator-signing-key KEY --accepted-at RFC3339
+
+Authenticates the signed chain, Phase 1 seal, and candidate evidence; verifies
+the candidate transition directly from the accepted native Phase 2 head; then
+appends immutable numbered artifacts and a new signed chain record.
+Participant contribution and phase close retain independent full replays.
+`,
+ "phase2 close": `Usage:
+ mpc-ceremony phase2 close --ceremony FILE --ceremony-signature FILE \
+ --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
+
+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.
+`,
+ "phase2 beacon": `Usage:
+ mpc-ceremony phase2 beacon --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --closure FILE \
+ --closure-signature FILE --raw-response FILE --published-at RFC3339 \
+ --coordinator-signing-key KEY --transcript-dir DIR
+
+Records the distinct Phase 2 post-closure beacon evidence used by finalize.
+`,
+ "finalize": `Usage:
+ mpc-ceremony finalize prepare [FLAGS]
+ mpc-ceremony finalize complete [FLAGS]
+`,
+ "finalize prepare": `Usage:
+ mpc-ceremony finalize prepare --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY [REPLAY EVIDENCE FLAGS] \
+ --coordinator-signing-key KEY --prepared-at RFC3339_UTC \
+ --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.
+`,
+ "finalize complete": `Usage:
+ mpc-ceremony finalize complete --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY [REPLAY EVIDENCE FLAGS] \
+ --coordinator-signing-key KEY --public-evidence FILE \
+ --finalized-at RFC3339_UTC --out-dir FRESH_DIR
+` + replayFlagsHelp + `
+
+Replays both phases again, verifies the canonical external public proof
+against the replayed final VK, and creates the coordinator-signed but
+unsigned-for-release candidate. It accepts only the public evidence artifact.
+Release signing remains a separate post-audit step.
+`,
+ "audit": `Usage:
+ mpc-ceremony audit --ceremony FILE \
+ --ceremony-signature FILE --coordinator-public-key-file KEY \
+ [REPLAY EVIDENCE FLAGS] --candidate-bundle DIR \
+ --auditor-id ID --auditor-signing-key KEY --audited-at RFC3339_UTC \
+ --out FRESH_FILE --audit-signature FRESH_FILE
+` + replayFlagsHelp + `
+
+Audit always independently compiles the circuit and performs the full
+two-phase replay. It emits a signed passing record only after reproducing the
+candidate's native keys, Cardano export, and coherence evidence.
+`,
+ "release": `Usage:
+ mpc-ceremony release [flags]
+
+Release authenticity is separate from MPC contribution identity.
+`,
+ "release sign": `Usage:
+ mpc-ceremony release sign --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --candidate-bundle DIR \
+ --audit-report FILE --audit-signature FILE \
+ --audit-report FILE --audit-signature FILE \
+ --operational-evidence-root DIR \
+ --operational-bundle DIR/operational/evidence-bundle.json \
+ --operational-bundle-signature DIR/operational/evidence-bundle.sig \
+ --release-signing-key KEY --signature-key-id ID \
+ --released-at RFC3339_UTC --release-dir FRESH_DIR
+
+ Requires at least two distinct enrolled auditors plus the coordinator-signed
+ Phase 1 and Phase 2 operational bundle. Each phase must contain a valid public
+ witness quorum and matching multi-relay beacon responses. The candidate is
+ never mutated; all verified evidence is atomically published into a fresh
+ release directory.
+`,
+ "release verify": `Usage:
+ mpc-ceremony release verify --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --keys-dir DIR \
+ --manifest-public-key-file KEY --signature-key-id ID
+
+Authenticates the release using the out-of-band release public key, then
+strictly verifies the bundled audit evidence, transcript, native keys, Cardano
+export, candidate signature, and checksums.
+`,
+ "decision": `Usage:
+ mpc-ceremony decision [flags]
+
+Production decisions are canonical content-addressed records. The command
+never fetches evidence URIs and never infers independence, host integrity,
+entropy quality, erasure, public witnessing, mirrors, or attendance.
+`,
+ "decision prepare": `Usage:
+ mpc-ceremony decision prepare --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --draft FILE --out FRESH_FILE
+
+Strictly parses a proof-tool-mpc-production-decision-draft-v1 record, derives
+the release_id and decision_id, and checks ceremony, source, exact K=21
+circuit, and signer-role bindings. The fresh output is the only byte string
+the accountable roles should sign.
+`,
+ "decision sign": `Usage:
+ mpc-ceremony decision sign --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --decision FILE \
+ --evidence-root DIR \
+ --role coordinator|auditor|release_signer --signer-id ID \
+ --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
+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
+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 \
+ --evidence-root DIR
+
+Strictly parses the record and detached role signatures, hashes every local
+evidence artifact, checks release/candidate/transcript/operational/audit
+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]
+
+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 export-signing": `Usage:
+ mpc-ceremony ops export-signing --record-type TYPE --record FILE \
+ --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --out-dir FRESH_DIR
+
+Strictly verifies the canonical record and ceremony binding, then exports
+canonical.json and signing-request.json. No private signing key is read.
+`,
+ "ops import-signature": `Usage:
+ mpc-ceremony ops import-signature --record-type TYPE --canonical FILE \
+ --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --signer-public-key-file KEY \
+ --raw-signature FILE --out FRESH_FILE
+
+Accepts 64 raw signature bytes or 128 lowercase hex characters, verifies the
+offline Ed25519 signature over exact canonical bytes and signer identity, then
+writes the repository detached-signature format without replacement.
+`,
+ "ops verify": `Usage:
+ mpc-ceremony ops verify --record-type TYPE --record FILE --signature FILE \
+ --ceremony FILE --ceremony-signature FILE \
+ --coordinator-public-key-file KEY --signer-public-key-file KEY \
+ [--related-record HANDOFF] [--evidence-root DIR]
+
+Authenticates canonical bytes, immutable ceremony fields, enrolled signer, and
+detached signature. Receipt verification requires the exact related handoff.
+Evidence-bundle verification requires the complete local evidence root and
+validates both authenticated chains, every custody transfer, independent
+mirrors and public witnesses, and three distinct beacon relay operators.
+`,
+}
diff --git a/cmd/proof-tool/ceremony.go b/cmd/proof-tool/ceremony.go
index f2d0172d..37e5c611 100644
--- a/cmd/proof-tool/ceremony.go
+++ b/cmd/proof-tool/ceremony.go
@@ -21,15 +21,15 @@ import (
"golang.org/x/crypto/blake2b"
"proof-tool/internal/artifact"
- "proof-tool/internal/circuit/ownership"
- "proof-tool/internal/circuit/ownershipdest"
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/keyprofile"
"proof-tool/internal/prover"
)
const (
ceremonyTranscriptSchema = "proof-tool-setup-transcript-v1"
- manifestSignatureFile = "manifest.sig"
- manifestPublicKeyFile = "manifest-public-key.hex"
+ manifestSignatureFile = keybundle.ManifestSignatureFile
+ manifestPublicKeyFile = keybundle.ManifestPublicKeyFile
setupTranscriptFile = "setup-transcript.json"
toxicWasteNotesFile = "TOXIC-WASTE-HANDLING.md"
bundleReadmeFile = "README.md"
@@ -61,15 +61,7 @@ type setupCeremonyResult struct {
Manifest *artifact.KeyManifest
}
-type ceremonyCircuitProfile struct {
- KeyVersion string
- CircuitID string
- Label string
- DefaultKeysDir string
- Compile func() (constraint.ConstraintSystem, error)
- Inspect func(string, bool) prover.BundleStatus
- LoadVerifier func(string) (*prover.OwnershipBundle, error)
-}
+type ceremonyCircuitProfile = keyprofile.Profile
type ceremonyDigest struct {
SHA256 string `json:"sha256"`
@@ -438,105 +430,25 @@ func runSetupCeremony(opts setupCeremonyOptions) (*setupCeremonyResult, error) {
}
func verifyKeyBundle(keysDir, keyVersion, publicKeyHex, expectedSignatureKeyID string, requireProvingKey bool) (*artifact.KeyManifest, error) {
- profile, err := ceremonyProfileForBundle(keysDir, keyVersion)
- if err != nil {
- return nil, err
- }
- status := profile.Inspect(keysDir, requireProvingKey)
- if !status.Ready {
- return nil, fmt.Errorf("key bundle is not ready: %s", status.Error)
- }
- manifest := status.Manifest
- if expectedSignatureKeyID != "" && manifest.SignatureKeyID != expectedSignatureKeyID {
- return nil, fmt.Errorf("manifest signature_key_id %q, want %q", manifest.SignatureKeyID, expectedSignatureKeyID)
- }
- if err := verifyManifestSignature(
- filepath.Join(keysDir, "manifest.json"),
- filepath.Join(keysDir, manifestSignatureFile),
- publicKeyHex,
- ); err != nil {
- return nil, err
- }
- return manifest, nil
-}
-
-func ceremonyProfileForBundle(keysDir, keyVersion string) (ceremonyCircuitProfile, error) {
- if strings.TrimSpace(keyVersion) != "" {
- return ceremonyProfileForKeyVersion(keyVersion)
- }
- manifest, err := artifact.ReadKeyManifest(filepath.Join(keysDir, "manifest.json"))
- if err != nil {
- return ceremonyCircuitProfile{}, err
- }
- return ceremonyProfileForKeyVersion(manifest.KeyVersion)
+ return keybundle.Verify(keybundle.VerifyOptions{
+ KeysDir: keysDir,
+ KeyVersion: keyVersion,
+ PublicKeyHex: publicKeyHex,
+ ExpectedSignatureKeyID: expectedSignatureKeyID,
+ RequireProvingKey: requireProvingKey,
+ })
}
func ceremonyProfileForKeyVersion(keyVersion string) (ceremonyCircuitProfile, error) {
- switch strings.TrimSpace(keyVersion) {
- case prover.DefaultKeyVersion:
- return ceremonyCircuitProfile{
- KeyVersion: prover.DefaultKeyVersion,
- CircuitID: ownership.CircuitID,
- Label: "ownership",
- DefaultKeysDir: prover.DefaultKeyDir(),
- Compile: prover.CompileOwnership,
- Inspect: prover.InspectOwnershipBundle,
- LoadVerifier: prover.LoadOwnershipVerifier,
- }, nil
- case prover.DefaultDestinationKeyVersion:
- return ceremonyCircuitProfile{
- KeyVersion: prover.DefaultDestinationKeyVersion,
- CircuitID: ownershipdest.CircuitID,
- Label: "ownership destination",
- DefaultKeysDir: prover.DefaultDestinationKeyDir(),
- Compile: prover.CompileOwnershipDestination,
- Inspect: prover.InspectOwnershipDestinationBundle,
- LoadVerifier: prover.LoadOwnershipDestinationVerifier,
- }, nil
- default:
- return ceremonyCircuitProfile{}, fmt.Errorf("unsupported key version %q; expected %q or %q", keyVersion, prover.DefaultKeyVersion, prover.DefaultDestinationKeyVersion)
- }
+ return keyprofile.ForKeyVersion(keyVersion)
}
func verifyManifestSignature(manifestPath, signaturePath, publicKeyHex string) error {
- manifestBytes, err := os.ReadFile(manifestPath)
- if err != nil {
- return fmt.Errorf("read manifest: %w", err)
- }
- signatureHex, err := os.ReadFile(signaturePath)
- if err != nil {
- return fmt.Errorf("read manifest signature: %w", err)
- }
- signature, err := hex.DecodeString(strings.TrimSpace(string(signatureHex)))
- if err != nil {
- return fmt.Errorf("decode manifest signature hex: %w", err)
- }
- if len(signature) != ed25519.SignatureSize {
- return fmt.Errorf("manifest signature is %d bytes, want %d", len(signature), ed25519.SignatureSize)
- }
- publicKey, err := decodeEd25519PublicKeyHex(publicKeyHex)
- if err != nil {
- return err
- }
- if !ed25519.Verify(publicKey, manifestBytes, signature) {
- return errors.New("manifest signature verification failed")
- }
- return nil
+ return keybundle.VerifyManifestSignature(manifestPath, signaturePath, publicKeyHex)
}
func manifestPublicKeyForVerification(keysDir, publicKeyHex, publicKeyFile string) (string, bool, error) {
- if publicKeyHex != "" && publicKeyFile != "" {
- return "", false, errors.New("use only one of --manifest-public-key or --manifest-public-key-file")
- }
- if publicKeyHex != "" {
- return strings.TrimSpace(publicKeyHex), true, nil
- }
- if publicKeyFile != "" {
- value, err := readTrimmedFile(publicKeyFile)
- return value, true, err
- }
- value, err := readTrimmedFile(filepath.Join(keysDir, manifestPublicKeyFile))
- return value, false, err
+ return keybundle.ManifestPublicKeyForVerification(keysDir, publicKeyHex, publicKeyFile)
}
func ensureFreshDirectory(dir string) error {
@@ -584,25 +496,20 @@ func readOrCreateEd25519SigningKey(path string) (ed25519.PrivateKey, ed25519.Pub
if strings.TrimSpace(path) == "" {
return nil, nil, false, errors.New("signing key path is required")
}
- rawHex, err := os.ReadFile(path)
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(path)
if err == nil {
- privateKey, err := decodeEd25519PrivateKeyHex(strings.TrimSpace(string(rawHex)))
- if err != nil {
- return nil, nil, false, fmt.Errorf("read signing key %s: %w", path, err)
- }
- publicKey := privateKey.Public().(ed25519.PublicKey)
if err := writePublicSigningKey(path, publicKey); err != nil {
return nil, nil, false, err
}
return privateKey, publicKey, false, nil
}
if !errors.Is(err, os.ErrNotExist) {
- return nil, nil, false, fmt.Errorf("read signing key %s: %w", path, err)
+ return nil, nil, false, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, nil, false, fmt.Errorf("create signing key directory: %w", err)
}
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, false, fmt.Errorf("generate signing key: %w", err)
}
@@ -619,29 +526,7 @@ func readOrCreateEd25519SigningKey(path string) (ed25519.PrivateKey, ed25519.Pub
}
func decodeEd25519PrivateKeyHex(value string) (ed25519.PrivateKey, error) {
- raw, err := hex.DecodeString(strings.TrimSpace(value))
- if err != nil {
- return nil, err
- }
- switch len(raw) {
- case ed25519.SeedSize:
- return ed25519.NewKeyFromSeed(raw), nil
- case ed25519.PrivateKeySize:
- return ed25519.PrivateKey(raw), nil
- default:
- return nil, fmt.Errorf("Ed25519 private key is %d bytes, want %d-byte seed or %d-byte private key", len(raw), ed25519.SeedSize, ed25519.PrivateKeySize)
- }
-}
-
-func decodeEd25519PublicKeyHex(value string) (ed25519.PublicKey, error) {
- raw, err := hex.DecodeString(strings.TrimSpace(value))
- if err != nil {
- return nil, fmt.Errorf("decode manifest public key hex: %w", err)
- }
- if len(raw) != ed25519.PublicKeySize {
- return nil, fmt.Errorf("manifest public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize)
- }
- return ed25519.PublicKey(raw), nil
+ return keybundle.DecodePrivateKeyHex(value)
}
func writePublicSigningKey(privateKeyPath string, publicKey ed25519.PublicKey) error {
@@ -728,14 +613,6 @@ func hostname() string {
return name
}
-func readTrimmedFile(path string) (string, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("read %s: %w", path, err)
- }
- return strings.TrimSpace(string(value)), nil
-}
-
func toxicWasteNotes(generatedAt time.Time, source ceremonySource) string {
sourceLine := "git commit: unavailable"
if source.GitCommit != "" {
diff --git a/cmd/proof-tool/chunk_manifest.go b/cmd/proof-tool/chunk_manifest.go
index c93a706e..1ee19dc1 100644
--- a/cmd/proof-tool/chunk_manifest.go
+++ b/cmd/proof-tool/chunk_manifest.go
@@ -102,6 +102,17 @@ func cmdGenerateChunkManifest(args []string) error {
}
vkPath := filepath.Join(*keysDir, "ownership.vk")
+ vkSourceDigest, err := proofassets.DigestFile(vkPath)
+ if err != nil {
+ return err
+ }
+ ccsSourceDigest, err := proofassets.DigestFile(*ccsPath)
+ if err != nil {
+ return err
+ }
+ if err := proofassets.ValidateKeyManifestAssetDigests(keyManifest, vkSourceDigest, ccsSourceDigest); err != nil {
+ return fmt.Errorf("release asset coherence: %w", err)
+ }
vk, err := prover.LoadVK(vkPath)
if err != nil {
return err
diff --git a/contracts/ownership-verifier/bench/Bench.hs b/contracts/ownership-verifier/bench/Bench.hs
index 657e1718..d5a9a356 100644
--- a/contracts/ownership-verifier/bench/Bench.hs
+++ b/contracts/ownership-verifier/bench/Bench.hs
@@ -50,7 +50,6 @@ import Ownership.ReclaimGlobalMulti
( reclaimGlobalMultiRedeemerData
, reclaimGlobalMultiValidatorCode
)
-import qualified Ownership.ReclaimGlobalMultiV2Bench as V2Multi
import Ownership.Verify (ownershipDestinationPublicInputDigest)
import Protocol11Snapshot
( Protocol11Snapshot (..)
@@ -172,8 +171,6 @@ main = do
statementV2GlobalScript = compiledToProgram (statementV2GlobalValidatorCode paramCurrencySymbol destinationVk (B.blake2b_256 destinationVk))
multiCases =
fmap (multiBenchmarkCase baseScript) multiFixtures
- historicalV2MultiCases =
- fmap (multiBenchmarkCaseWith "historical V2 multi distinct same-master" v2MultiGlobalValidatorCode baseScript) multiFixtures
statementV2DistinctCases =
[ statementV2BenchmarkCase "ZK-02 statement-bound distinct" baseScript statementV2GlobalScript (take inputCount distinctFixtures)
| inputCount <- [1 .. 9]
@@ -331,7 +328,7 @@ main = do
(headerLabels !! 12)
(headerLabels !! 13)
putStrLn (replicate 177 '-')
- mapM_ printCase (multiCases <> historicalV2MultiCases <> statementV2DistinctCases <> statementV2RepeatedCases <> reconciliationCases <> ledgerPreprodCapacityCases <> releaseCases)
+ mapM_ printCase (multiCases <> statementV2DistinctCases <> statementV2RepeatedCases <> reconciliationCases <> ledgerPreprodCapacityCases <> releaseCases)
putStrLn ""
putStrLn "ZK-02 all-distinct redeemer sizes (exact Plutus Data CBOR; not transaction CBOR)"
forM_ [1 .. 9] $ \inputCount ->
@@ -341,10 +338,9 @@ main = do
(compiledCodeSize (baseValidatorCode globalCredential))
(compiledCodeSize (statementV2GlobalValidatorCode paramCurrencySymbol destinationVk (B.blake2b_256 destinationVk)))
forM_ multiFixtures $ \fixture ->
- printf " Multi count-%d: production=%d bytes; historical V2=%d bytes\n"
+ printf " Reference Multi count-%d: %d bytes\n"
(multiFixtureCredentialCount fixture)
(compiledCodeSize (multiGlobalValidatorCode paramCurrencySymbol (multiFixtureVerifierKey fixture)))
- (compiledCodeSize (v2MultiGlobalValidatorCode paramCurrencySymbol (multiFixtureVerifierKey fixture)))
let productionParamCurrencySymbol = V3.CurrencySymbol (bytesToBuiltin (replicate 28 0))
productionStatementV2GlobalCode = statementV2GlobalValidatorCode productionParamCurrencySymbol destinationVk (B.blake2b_256 destinationVk)
productionStatementV2GlobalCredential =
@@ -391,8 +387,7 @@ statementV2BenchmarkCase name baseScript globalScript fixtures =
baseTotal = sumBudgets baseRuns
globalBudget = evaluateBudget globalScript claimContext
--- | Production reconciliation path. The V2 branch uses
--- 'Ownership.ReclaimGlobalV2', never the historical benchmark-only module.
+-- | Production reconciliation path using 'Ownership.ReclaimGlobalV2'.
reconciliationBenchmarkCase ::
Evaluator ->
ClaimProfile ->
@@ -493,7 +488,7 @@ multiBenchmarkCase ::
MultiOwnershipFixture ->
BenchmarkCase
multiBenchmarkCase =
- multiBenchmarkCaseWith "multi distinct same-master" multiGlobalValidatorCode
+ multiBenchmarkCaseWith "reference multi distinct same-master" multiGlobalValidatorCode
multiBenchmarkCaseWith ::
String ->
@@ -635,13 +630,6 @@ multiGlobalValidatorCode currencySymbol verifierKey =
`PlutusTx.unsafeApplyCode` PlutusTx.liftCodeDef paramTokenName
`PlutusTx.unsafeApplyCode` PlutusTx.liftCodeDef verifierKey
-v2MultiGlobalValidatorCode :: V3.CurrencySymbol -> BuiltinByteString -> CompiledCode (BuiltinData -> BuiltinUnit)
-v2MultiGlobalValidatorCode currencySymbol verifierKey =
- V2Multi.reclaimGlobalMultiValidatorCode
- `PlutusTx.unsafeApplyCode` PlutusTx.liftCodeDef currencySymbol
- `PlutusTx.unsafeApplyCode` PlutusTx.liftCodeDef paramTokenName
- `PlutusTx.unsafeApplyCode` PlutusTx.liftCodeDef verifierKey
-
compiledToProgram :: CompiledCode a -> Script
compiledToProgram code =
let script =
diff --git a/contracts/ownership-verifier/export/VerifyDestinationProof.hs b/contracts/ownership-verifier/export/VerifyDestinationProof.hs
index 3fca1e3a..e9fc09d8 100644
--- a/contracts/ownership-verifier/export/VerifyDestinationProof.hs
+++ b/contracts/ownership-verifier/export/VerifyDestinationProof.hs
@@ -10,29 +10,38 @@ import System.Exit (die)
import qualified PlutusTx.Builtins as B
import PlutusTx.Builtins (BuiltinByteString)
-import Ownership.Verify (verifyOwnershipDestinationWithVK)
+import Ownership.Verify
+ ( ownershipDestinationPublicInputDigest
+ , verifyOwnershipDestinationWithVK
+ )
main :: IO ()
main = do
args <- getArgs
case args of
- [vkPath, proofHex, credentialHex, destinationHex] -> do
+ [vkPath, proofHex, credentialHex, destinationHex, publicInputDigestHex] -> do
vkBytes <- decodeHex <$> readFile vkPath
let proofBytes = decodeHex proofHex
credentialBytes = decodeHex credentialHex
destinationBytes = decodeHex destinationHex
+ publicInputDigestBytes = decodeHex publicInputDigestHex
requireLength "verifier key" 672 vkBytes
requireLength "proof" 336 proofBytes
requireLength "credential" 28 credentialBytes
requireLength "destination" 58 destinationBytes
+ requireLength "public input digest" 32 publicInputDigestBytes
let vk = bytesToBuiltin vkBytes
proof = bytesToBuiltin proofBytes
credential = bytesToBuiltin credentialBytes
destination = bytesToBuiltin destinationBytes
+ publicInputDigest = bytesToBuiltin publicInputDigestBytes
+ when
+ (publicInputDigest /= ownershipDestinationPublicInputDigest credential destination) $
+ die "public input digest does not bind credential and destination"
if verifyOwnershipDestinationWithVK vk proof credential destination
then putStrLn "ok"
else die "contract destination-proof verifier rejected artifact"
- _ -> die "usage: verify-destination-proof VK_PATH PROOF_HEX CREDENTIAL_HEX DESTINATION_HEX"
+ _ -> die "usage: verify-destination-proof VK_PATH PROOF_HEX CREDENTIAL_HEX DESTINATION_HEX PUBLIC_INPUT_DIGEST_HEX"
decodeHex :: String -> [Integer]
decodeHex input
diff --git a/contracts/ownership-verifier/ownership-verifier.cabal b/contracts/ownership-verifier/ownership-verifier.cabal
index c686bc35..c3182f5d 100644
--- a/contracts/ownership-verifier/ownership-verifier.cabal
+++ b/contracts/ownership-verifier/ownership-verifier.cabal
@@ -17,7 +17,6 @@ library
Ownership.ReclaimBase
Ownership.ReclaimGlobalV2
Ownership.ReclaimGlobalMulti
- Ownership.ReclaimGlobalMultiV2Bench
Ownership.Verify
build-depends:
, base >= 4.17 && < 5
diff --git a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMulti.hs b/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMulti.hs
index fa561cbe..b98893e5 100644
--- a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMulti.hs
+++ b/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMulti.hs
@@ -4,6 +4,13 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
+-- | Reference implementation of aggregate ownership-proof verification.
+--
+-- This validator is currently unused by the ownership-proof web app and
+-- production deployments. 'Ownership.ReclaimGlobalV2' was selected instead
+-- because its smaller proving key enables faster browser proving. This module
+-- is retained so developers can reference a batched proof-verification
+-- contract.
module Ownership.ReclaimGlobalMulti
( MultiReclaimScan
, ReclaimGlobalMultiParams (..)
diff --git a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMultiV2Bench.hs b/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMultiV2Bench.hs
deleted file mode 100644
index 0dba5efa..00000000
--- a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalMultiV2Bench.hs
+++ /dev/null
@@ -1,559 +0,0 @@
-{-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TemplateHaskell #-}
-
-module Ownership.ReclaimGlobalMultiV2Bench
- ( MultiReclaimScan
- , ReclaimGlobalMultiParams (..)
- , ReclaimGlobalMultiRedeemer (..)
- , destinationAddressV1FromTxOutData
- , mkMultiReclaimGlobal
- , mkMultiReclaimGlobalUntyped
- , multiCredentialCountU16BE
- , multiCredentialPublicInputDigest
- , multiOwnershipDomain
- , reclaimGlobalMultiParamsData
- , reclaimGlobalMultiRedeemerData
- , reclaimGlobalMultiValidator
- , reclaimGlobalMultiValidatorCode
- , scanMultiReclaimInputs
- , validateMultiReclaimInputs
- , validateMultiReclaimInputsWithProofCheck
- ) where
-
-import PlutusLedgerApi.V3
- ( CurrencySymbol (CurrencySymbol)
- , ScriptHash (ScriptHash)
- , TokenName (TokenName)
- , Value
- )
-import PlutusTx (CompiledCode)
-import qualified PlutusTx
-import PlutusTx.Builtins (ByteOrder (BigEndian))
-import PlutusTx.Prelude
-import qualified PlutusLedgerApi.V1.Value as Value
-import qualified PlutusTx.Builtins as B
-import qualified PlutusTx.Builtins.Internal as BI
-
-import Ownership.Verify
- ( CommittedProofCheck (..)
- , ParsedVerifyingKey
- , Proof (Proof)
- , Scalar (Scalar)
- , groth16VerifyCommittedParsedNoPok
- , ownershipProofBatchMergeChallenge
- , parseVerifyingKey
- , verifyCommittedProofMergedWithVK
- )
-
-data ReclaimGlobalMultiParams = ReclaimGlobalMultiParams
- { reclaimBaseScriptHash :: ScriptHash
- }
-
-data ReclaimGlobalMultiRedeemer = ReclaimGlobalMultiRedeemer
- { reclaimParamsIdx :: Integer
- , reclaimDestinationOutIdx :: Integer
- , reclaimProof :: BuiltinByteString
- }
-
-type MultiReclaimScan = (Integer, BuiltinByteString, Value)
-
-{-# INLINABLE reclaimGlobalMultiParamsData #-}
-reclaimGlobalMultiParamsData :: ScriptHash -> BuiltinData
-reclaimGlobalMultiParamsData (ScriptHash baseScriptHash) =
- BI.mkConstr
- 0
- ( BI.mkCons
- (BI.mkB baseScriptHash)
- (BI.mkNilData BI.unitval)
- )
-
-{-# INLINABLE reclaimGlobalMultiRedeemerData #-}
-reclaimGlobalMultiRedeemerData :: Integer -> Integer -> BuiltinByteString -> BuiltinData
-reclaimGlobalMultiRedeemerData paramsIdx destinationOutIdx proof =
- BI.mkConstr
- 0
- ( BI.mkCons
- (BI.mkI paramsIdx)
- ( BI.mkCons
- (BI.mkI destinationOutIdx)
- ( BI.mkCons
- (BI.mkB proof)
- (BI.mkNilData BI.unitval)
- )
- )
- )
-
-{-# INLINABLE builtinIf #-}
-builtinIf :: BI.BuiltinBool -> a -> a -> a
-builtinIf condition trueBranch falseBranch =
- BI.ifThenElse
- condition
- (\_ -> trueBranch)
- (\_ -> falseBranch)
- BI.unitval
-
-{-# INLINABLE builtinAnd #-}
-builtinAnd :: BI.BuiltinBool -> BI.BuiltinBool -> BI.BuiltinBool
-builtinAnd left right =
- builtinIf left right BI.false
-
-{-# INLINABLE boolToBuiltin #-}
-boolToBuiltin :: Bool -> BI.BuiltinBool
-boolToBuiltin condition =
- if condition then BI.true else BI.false
-
-{-# INLINABLE builtinToBool #-}
-builtinToBool :: BI.BuiltinBool -> Bool
-builtinToBool condition =
- builtinIf condition True False
-
-{-# INLINABLE constrTag #-}
-constrTag :: BuiltinData -> Integer
-constrTag datum =
- BI.fst (BI.unsafeDataAsConstr datum)
-
-{-# INLINABLE constrFields #-}
-constrFields :: BuiltinData -> BI.BuiltinList BuiltinData
-constrFields datum =
- BI.snd (BI.unsafeDataAsConstr datum)
-
-{-# INLINABLE field0 #-}
-field0 :: BI.BuiltinList BuiltinData -> BuiltinData
-field0 =
- BI.head
-
-{-# INLINABLE field1 #-}
-field1 :: BI.BuiltinList BuiltinData -> BuiltinData
-field1 fields =
- BI.head (BI.tail fields)
-
-{-# INLINABLE field2 #-}
-field2 :: BI.BuiltinList BuiltinData -> BuiltinData
-field2 fields =
- BI.head (BI.tail (BI.tail fields))
-
-{-# INLINABLE findDataAt #-}
-findDataAt :: BuiltinString -> Integer -> BI.BuiltinList BuiltinData -> BuiltinData
-findDataAt errorMessage idx values =
- if idx < 0
- then traceError errorMessage
- else go idx values
- where
- go !n !remaining =
- B.caseList
- (\() -> traceError errorMessage)
- ( \value rest ->
- builtinIf
- (BI.equalsInteger n 0)
- value
- (go (n - 1) rest)
- )
- remaining
-
-{-# INLINABLE findReferenceInputAtData #-}
-findReferenceInputAtData :: Integer -> BI.BuiltinList BuiltinData -> BuiltinData
-findReferenceInputAtData =
- findDataAt "invalid parameter ref index"
-
-{-# INLINABLE dropDataAt #-}
-dropDataAt :: BuiltinString -> Integer -> BI.BuiltinList BuiltinData -> BI.BuiltinList BuiltinData
-dropDataAt errorMessage idx values =
- if idx < 0
- then traceError errorMessage
- else go idx values
- where
- go !n !remaining =
- B.caseList
- (\() -> traceError errorMessage)
- ( \_ rest ->
- builtinIf
- (BI.equalsInteger n 0)
- remaining
- (go (n - 1) rest)
- )
- remaining
-
-{-# INLINABLE hasExactlyOneParamToken #-}
-hasExactlyOneParamToken :: BuiltinByteString -> BuiltinByteString -> BuiltinData -> BI.BuiltinBool
-hasExactlyOneParamToken paramsCurrencySymbol paramsTokenName txOut =
- let !valueEntries = BI.unsafeDataAsMap txOutValueData
- !nonAdaEntries = BI.tail valueEntries
- in B.caseList
- (\() -> BI.false)
- ( \paramEntry morePolicies ->
- B.caseList
- (\() -> exactParamEntry paramEntry)
- (\_ _ -> BI.false)
- morePolicies
- )
- nonAdaEntries
- where
- txOutFields = constrFields txOut
- txOutValueData = field1 txOutFields
-
- exactParamEntry !paramEntry =
- BI.equalsByteString (BI.unsafeDataAsB (BI.fst paramEntry)) paramsCurrencySymbol
- `builtinAnd` hasExactToken (BI.unsafeDataAsMap (BI.snd paramEntry))
-
- hasExactToken !tokens =
- B.caseList
- (\() -> BI.false)
- ( \token moreTokens ->
- B.caseList
- ( \() ->
- BI.equalsByteString (BI.unsafeDataAsB (BI.fst token)) paramsTokenName
- `builtinAnd` BI.equalsInteger (BI.unsafeDataAsI (BI.snd token)) 1
- )
- (\_ _ -> BI.false)
- moreTokens
- )
- tokens
-
-{-# INLINABLE txInResolved #-}
-txInResolved :: BuiltinData -> BuiltinData
-txInResolved txIn =
- field1 (constrFields txIn)
-
-{-# INLINABLE txOutValueFromData #-}
-txOutValueFromData :: BuiltinData -> Value
-txOutValueFromData txOut =
- PlutusTx.unsafeFromBuiltinData (field1 (constrFields txOut))
-
-{-# INLINABLE txOutAddressFromData #-}
-txOutAddressFromData :: BuiltinData -> BuiltinData
-txOutAddressFromData txOut =
- field0 (constrFields txOut)
-
-{-# INLINABLE inlineDatum #-}
-inlineDatum :: BuiltinData -> BuiltinData
-inlineDatum txOut =
- let !txOutFields = constrFields txOut
- !outputDatum = field2 txOutFields
- !datumConstr = BI.unsafeDataAsConstr outputDatum
- in BI.head (BI.snd datumConstr)
-
-{-# INLINABLE decodeParamsScriptHash #-}
-decodeParamsScriptHash :: BuiltinData -> BuiltinByteString
-decodeParamsScriptHash paramsOut =
- let !paramsDatum = inlineDatum paramsOut
- !paramsConstr = BI.unsafeDataAsConstr paramsDatum
- in BI.unsafeDataAsB (BI.head (BI.snd paramsConstr))
-
-{-# INLINABLE isReclaimBaseInput #-}
-isReclaimBaseInput :: BuiltinByteString -> BuiltinData -> BI.BuiltinBool
-isReclaimBaseInput baseScriptHash txIn =
- let !resolved = txInResolved txIn
- !txOutFields = constrFields resolved
- !address = field0 txOutFields
- !addressFields = constrFields address
- !credential = field0 addressFields
- !credentialConstr = BI.unsafeDataAsConstr credential
- in builtinIf
- (BI.equalsInteger (BI.fst credentialConstr) 1)
- (BI.equalsByteString (BI.unsafeDataAsB (BI.head (BI.snd credentialConstr))) baseScriptHash)
- BI.false
-
-{-# INLINABLE decodeBasePaymentKeyHash #-}
-decodeBasePaymentKeyHash :: BuiltinData -> BuiltinByteString
-decodeBasePaymentKeyHash txOut =
- let !baseDatum = inlineDatum txOut
- !baseDatumConstr = BI.unsafeDataAsConstr baseDatum
- in BI.unsafeDataAsB (BI.head (BI.snd baseDatumConstr))
-
-{-# INLINABLE scanMultiReclaimInputs #-}
-scanMultiReclaimInputs :: BuiltinByteString -> BI.BuiltinList BuiltinData -> MultiReclaimScan
-scanMultiReclaimInputs baseScriptHash inputs =
- go inputs 0 emptyByteString mempty BI.false
- where
- go !remainingInputs !credentialCount !credentialBytes !requiredValue !sawBase =
- B.caseList
- ( \() ->
- builtinIf
- sawBase
- (credentialCount, credentialBytes, requiredValue)
- (traceError "no reclaim base inputs")
- )
- ( \txIn rest ->
- builtinIf
- (isReclaimBaseInput baseScriptHash txIn)
- ( let !resolved = txInResolved txIn
- !paymentKeyHash = decodeBasePaymentKeyHash resolved
- in if lengthOfByteString paymentKeyHash == 28
- then
- go
- rest
- (credentialCount + 1)
- (credentialBytes <> paymentKeyHash)
- (requiredValue <> txOutValueFromData resolved)
- BI.true
- else traceError "reclaim payment key hash must be 28 bytes"
- )
- (go rest credentialCount credentialBytes requiredValue sawBase)
- )
- remainingInputs
-
-{-# INLINABLE credentialHashBytes #-}
-credentialHashBytes :: BuiltinData -> BuiltinByteString
-credentialHashBytes credential =
- let !credentialConstr = BI.unsafeDataAsConstr credential
- !credentialHash = BI.unsafeDataAsB (BI.head (BI.snd credentialConstr))
- in if lengthOfByteString credentialHash == 28
- then credentialHash
- else traceError "credential hash must be 28 bytes"
-
-{-# INLINABLE credentialWireTag #-}
-credentialWireTag :: BuiltinData -> BuiltinByteString
-credentialWireTag credential =
- let !credentialTag = constrTag credential
- in if credentialTag == 0
- then consByteString 1 emptyByteString
- else
- if credentialTag == 1
- then consByteString 2 emptyByteString
- else traceError "unsupported credential constructor"
-
-{-# INLINABLE credentialAddressBytes #-}
-credentialAddressBytes :: BuiltinData -> BuiltinByteString
-credentialAddressBytes credential =
- credentialWireTag credential <> credentialHashBytes credential
-
-{-# INLINABLE zeroCredentialHash #-}
-zeroCredentialHash :: BuiltinByteString
-zeroCredentialHash =
- go (28 :: Integer) emptyByteString
- where
- go :: Integer -> BuiltinByteString -> BuiltinByteString
- go !remaining !acc =
- if remaining == 0
- then acc
- else go (remaining - 1) (consByteString 0 acc)
-
-{-# INLINABLE stakeAddressBytes #-}
-stakeAddressBytes :: BuiltinData -> BuiltinByteString
-stakeAddressBytes stakingCredentialMaybe =
- let !maybeTag = constrTag stakingCredentialMaybe
- in if maybeTag == 1
- then consByteString 0 zeroCredentialHash
- else
- if maybeTag == 0
- then
- let !stakingCredential = BI.head (constrFields stakingCredentialMaybe)
- !stakingCredentialTag = constrTag stakingCredential
- in if stakingCredentialTag == 0
- then credentialAddressBytes (BI.head (constrFields stakingCredential))
- else
- if stakingCredentialTag == 1
- then traceError "staking pointers are unsupported"
- else traceError "unsupported staking credential constructor"
- else traceError "unsupported maybe staking credential constructor"
-
-{-# INLINABLE destinationAddressV1FromTxOutData #-}
-destinationAddressV1FromTxOutData :: BuiltinData -> BuiltinByteString
-destinationAddressV1FromTxOutData txOut =
- let !txOutFields = constrFields txOut
- !address = field0 txOutFields
- !addressFields = constrFields address
- !encoded =
- credentialAddressBytes (field0 addressFields)
- <> stakeAddressBytes (field1 addressFields)
- in if lengthOfByteString encoded == 58
- then encoded
- else traceError "destination address v1 must be 58 bytes"
-
-{-# INLINABLE multiOwnershipDomain #-}
-multiOwnershipDomain :: BuiltinByteString
-multiOwnershipDomain = "ROOT-OWNERSHIP-MULTI-v1"
-
-{-# INLINABLE multiCredentialCountU16BE #-}
-multiCredentialCountU16BE :: Integer -> BuiltinByteString
-multiCredentialCountU16BE credentialCount =
- if credentialCount >= 1 && credentialCount <= 65535
- then integerToByteString BigEndian 2 credentialCount
- else traceError "multi credential count out of range"
-
-{-# INLINABLE multiCredentialPublicInputDigest #-}
-multiCredentialPublicInputDigest :: Integer -> BuiltinByteString -> BuiltinByteString -> BuiltinByteString
-multiCredentialPublicInputDigest credentialCount credentialBytes destinationBytes =
- if lengthOfByteString credentialBytes == credentialCount * 28
- && lengthOfByteString destinationBytes == 58
- then
- blake2b_256
- ( multiOwnershipDomain
- <> multiCredentialCountU16BE credentialCount
- <> credentialBytes
- <> destinationBytes
- )
- else traceError "malformed multi credential public input"
-
-{-# INLINABLE verifyMultiOwnershipWithParsedVK #-}
-verifyMultiOwnershipWithParsedVK ::
- ParsedVerifyingKey ->
- BuiltinByteString ->
- Integer ->
- BuiltinByteString ->
- BuiltinByteString ->
- Bool
-verifyMultiOwnershipWithParsedVK parsedVerifierKey proof credentialCount credentialBytes destinationBytes =
- case
- groth16VerifyCommittedParsedNoPok
- parsedVerifierKey
- (Proof proof)
- (Scalar (multiCredentialPublicInputDigest credentialCount credentialBytes destinationBytes))
- of
- CommittedProofCheck commitment pok a b c vkX ->
- verifyCommittedProofMergedWithVK
- parsedVerifierKey
- (bls12_381_millerLoop a b)
- vkX
- c
- commitment
- pok
- (ownershipProofBatchMergeChallenge proof)
-
-{-# INLINABLE scanDestinationOutputs #-}
-scanDestinationOutputs :: BI.BuiltinList BuiltinData -> (BuiltinByteString, Value)
-scanDestinationOutputs outputs =
- B.caseList
- (\() -> traceError "invalid destination output index")
- ( \firstOutput rest ->
- let !destinationAddress = txOutAddressFromData firstOutput
- !destinationBytes = destinationAddressV1FromTxOutData firstOutput
- !destinationValue =
- accumulateDestinationValue
- destinationAddress
- (txOutValueFromData firstOutput)
- rest
- in (destinationBytes, destinationValue)
- )
- outputs
-
-{-# INLINABLE accumulateDestinationValue #-}
-accumulateDestinationValue :: BuiltinData -> Value -> BI.BuiltinList BuiltinData -> Value
-accumulateDestinationValue destinationAddress initialValue outputs =
- go initialValue outputs
- where
- go !acc !remaining =
- B.caseList
- (\() -> acc)
- ( \txOut rest ->
- builtinIf
- (BI.equalsData (txOutAddressFromData txOut) destinationAddress)
- (go (acc <> txOutValueFromData txOut) rest)
- acc
- )
- remaining
-
-{-# INLINABLE validateMultiReclaimInputs #-}
-validateMultiReclaimInputs ::
- BuiltinByteString ->
- ParsedVerifyingKey ->
- BuiltinByteString ->
- BI.BuiltinList BuiltinData ->
- BI.BuiltinList BuiltinData ->
- BI.BuiltinBool
-validateMultiReclaimInputs baseScriptHash parsedVerifierKey proof destinationOutputs inputs =
- let !(!credentialCount, !credentialBytes, !requiredValue) =
- scanMultiReclaimInputs baseScriptHash inputs
- !(!destinationBytes, !destinationValue) =
- scanDestinationOutputs destinationOutputs
- in builtinIf
- ( boolToBuiltin $
- verifyMultiOwnershipWithParsedVK
- parsedVerifierKey
- proof
- credentialCount
- credentialBytes
- destinationBytes
- )
- ( builtinIf
- (boolToBuiltin (requiredValue `Value.leq` destinationValue))
- BI.true
- (traceError "destination output underpays reclaim inputs")
- )
- (traceError "multi reclaim proof validation failed")
-
-validateMultiReclaimInputsWithProofCheck ::
- (Integer -> BuiltinByteString -> BuiltinByteString -> Bool) ->
- BuiltinByteString ->
- BI.BuiltinList BuiltinData ->
- BI.BuiltinList BuiltinData ->
- Bool
-validateMultiReclaimInputsWithProofCheck proofCheck baseScriptHash destinationOutputs inputs =
- builtinToBool $
- let !(!credentialCount, !credentialBytes, !requiredValue) =
- scanMultiReclaimInputs baseScriptHash inputs
- !(!destinationBytes, !destinationValue) =
- scanDestinationOutputs destinationOutputs
- in builtinIf
- (boolToBuiltin (proofCheck credentialCount credentialBytes destinationBytes))
- ( builtinIf
- (boolToBuiltin (requiredValue `Value.leq` destinationValue))
- BI.true
- (traceError "destination output underpays reclaim inputs")
- )
- (traceError "multi reclaim proof validation failed")
-
-{-# INLINABLE validateParams #-}
-validateParams :: BuiltinByteString -> BuiltinByteString -> BuiltinData -> BI.BuiltinBool
-validateParams paramsCurrencySymbol paramsTokenName paramsOut =
- hasExactlyOneParamToken paramsCurrencySymbol paramsTokenName paramsOut
-
-{-# INLINABLE mkMultiReclaimGlobal #-}
-mkMultiReclaimGlobal :: CurrencySymbol -> TokenName -> BuiltinByteString -> BuiltinData -> Bool
-mkMultiReclaimGlobal (CurrencySymbol paramsCurrencySymbol) (TokenName paramsTokenName) verifierKey ctx =
- builtinToBool $
- isRewarding `builtinAnd` validateGlobal
- where
- !ctxFields = constrFields ctx
- !txInfo = field0 ctxFields
- !redeemer = field1 ctxFields
- !scriptInfo = field2 ctxFields
- !txInfoFields = constrFields txInfo
- !txInfoInputs = field0 txInfoFields
- !txInfoReferenceInputs = field1 txInfoFields
- !txInfoOutputs = field2 txInfoFields
- !redeemerConstr = BI.unsafeDataAsConstr redeemer
- !redeemerFields = BI.snd redeemerConstr
- !paramsRefIdx = BI.unsafeDataAsI (field0 redeemerFields)
- !destinationOutIdx = BI.unsafeDataAsI (field1 redeemerFields)
- !proof = BI.unsafeDataAsB (field2 redeemerFields)
- !parsedVerifierKey = parseVerifyingKey verifierKey
-
- isRewarding =
- BI.equalsInteger (constrTag scriptInfo) 2
-
- validateGlobal =
- let !paramsInput = findReferenceInputAtData paramsRefIdx (BI.unsafeDataAsList txInfoReferenceInputs)
- !paramsOut = txInResolved paramsInput
- !baseScriptHash = decodeParamsScriptHash paramsOut
- !destinationOutputs =
- dropDataAt "invalid destination output index" destinationOutIdx (BI.unsafeDataAsList txInfoOutputs)
- in validateParams paramsCurrencySymbol paramsTokenName paramsOut
- `builtinAnd` validateMultiReclaimInputs
- baseScriptHash
- parsedVerifierKey
- proof
- destinationOutputs
- (BI.unsafeDataAsList txInfoInputs)
-
-{-# INLINABLE reclaimGlobalMultiValidator #-}
-reclaimGlobalMultiValidator :: CurrencySymbol -> TokenName -> BuiltinByteString -> BuiltinData -> Bool
-reclaimGlobalMultiValidator =
- mkMultiReclaimGlobal
-
-{-# INLINABLE mkMultiReclaimGlobalUntyped #-}
-mkMultiReclaimGlobalUntyped :: CurrencySymbol -> TokenName -> BuiltinByteString -> BuiltinData -> BuiltinUnit
-mkMultiReclaimGlobalUntyped paramsCurrencySymbol paramsTokenName verifierKey ctx =
- check $
- mkMultiReclaimGlobal
- paramsCurrencySymbol
- paramsTokenName
- verifierKey
- ctx
-
-reclaimGlobalMultiValidatorCode :: CompiledCode (CurrencySymbol -> TokenName -> BuiltinByteString -> BuiltinData -> BuiltinUnit)
-reclaimGlobalMultiValidatorCode =
- $$(PlutusTx.compile [||mkMultiReclaimGlobalUntyped||])
diff --git a/contracts/ownership-verifier/test/VerifySpec.hs b/contracts/ownership-verifier/test/VerifySpec.hs
index e74f595f..033177d8 100644
--- a/contracts/ownership-verifier/test/VerifySpec.hs
+++ b/contracts/ownership-verifier/test/VerifySpec.hs
@@ -51,37 +51,28 @@ import Ownership.ReclaimGlobalMulti
, reclaimGlobalMultiValidator
, validateMultiReclaimInputsWithProofCheck
)
-import qualified Ownership.ReclaimGlobalMultiV2Bench as V2Multi
import Ownership.Verify
( BatchCommittedProofCheck (..)
, CommittedProofCheck (..)
, ParsedBatchVerifyingKey (..)
- , ParsedVerifyingKey (..)
- , Proof (Proof)
- , Scalar (Scalar)
, batchCoefficientUsesUnscaledAlpha
, blsBaseFieldOrder
, blsScalarFieldOrder
, coefficientFirstVkX
, commitmentYIsCanonical
- , committedProofMergedSidesWithVK
, expandMsgXmd48
- , groth16VerifyCommittedParsedNoPok
, ownershipDestinationDomain
, ownershipDestinationPublicInputDigest
, ownershipDomain
, ownershipProofBatchChallenge
, ownershipProofBatchChallengeV2
, ownershipProofBatchDomainV2
- , ownershipProofBatchMergeChallenge
, ownershipProofBatchMergeChallengeV2
, ownershipPublicInputDigest
, parseVerifyingKey
, parseVerifyingKeyBatch
, verifyOwnershipDestinationWithParsedBatchVKKnown28NoPok
, verifyOwnershipDestinationWithParsedVKKnown28NoPok
- , verifyCommittedProofMergedWithVK
- , verifyCommittedProofPokBatch
, verifyOwnershipWithVK
)
import qualified PlutusLedgerApi.V3 as V3
@@ -133,24 +124,12 @@ proofWithCommitmentY y proof =
<> B.integerToByteString BigEndian 48 y
<> B.sliceByteString 288 48 proof
-replaceProofSlice :: Integer -> Integer -> BuiltinByteString -> BuiltinByteString -> BuiltinByteString
-replaceProofSlice offset width replacement proof =
- B.sliceByteString 0 offset proof
- <> replacement
- <> B.sliceByteString (offset + width) (336 - offset - width) proof
-
flipFirstBit :: BuiltinByteString -> BuiltinByteString
flipFirstBit bytes =
let firstByte = B.indexByteString bytes 0
flipped = if even firstByte then firstByte + 1 else firstByte - 1
in B.consByteString flipped (B.sliceByteString 1 (B.lengthOfByteString bytes - 1) bytes)
-flipBitAt :: Integer -> BuiltinByteString -> BuiltinByteString
-flipBitAt offset bytes =
- B.sliceByteString 0 offset bytes
- <> flipFirstBit (B.sliceByteString offset 1 bytes)
- <> B.sliceByteString (offset + 1) (B.lengthOfByteString bytes - offset - 1) bytes
-
batchPowers :: Integer -> Int -> [Integer]
batchPowers challenge count =
take count (iterate nextPower 1)
@@ -340,10 +319,6 @@ runReclaimGlobalMulti :: BuiltinByteString -> V3.ScriptContext -> Bool
runReclaimGlobalMulti verifierKey ctx =
reclaimGlobalMultiValidator paramCurrencySymbol paramTokenName verifierKey (V3.toBuiltinData ctx)
-runReclaimGlobalMultiV2 :: BuiltinByteString -> V3.ScriptContext -> Bool
-runReclaimGlobalMultiV2 verifierKey ctx =
- V2Multi.reclaimGlobalMultiValidator paramCurrencySymbol paramTokenName verifierKey (V3.toBuiltinData ctx)
-
runRawReclaimBase :: V3.Credential -> V3.ScriptContext -> Bool
runRawReclaimBase credential ctx =
builtinBoolToBool $
@@ -743,136 +718,6 @@ main = do
shrinkReclaimBaseDifferentialCase
reclaimBaseDifferentialProperty
]
- , testGroup "ReclaimGlobalMulti benchmark-only merged finalVerify"
- [ testCase "M3/M4 Multi paired corpus and eager-vkX wiring mutations reject" $ do
- let parsed = parseVerifyingKey multiVk
- s = ownershipProofBatchMergeChallenge multiProof
- case groth16VerifyCommittedParsedNoPok parsed (Proof multiProof) (Scalar multiPub) of
- CommittedProofCheck commitment pok a b c vkX -> do
- let grothLhs = B.bls12_381_millerLoop a b
- merged currentVkX currentC currentCommitment currentPok =
- verifyCommittedProofMergedWithVK parsed grothLhs currentVkX currentC currentCommitment currentPok s
- oldGroth currentVkX currentC =
- B.bls12_381_finalVerify
- grothLhs
- ( parsedAlphaBeta parsed
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop currentVkX (parsedGamma parsed)
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop currentC (parsedDelta parsed)
- )
- delta = parsedIc0 parsed
- (actualLhs, actualRhs) = committedProofMergedSidesWithVK parsed grothLhs vkX c commitment pok s
- expectedOldGrothRhs =
- parsedAlphaBeta parsed
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop vkX (parsedGamma parsed)
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop c (parsedDelta parsed)
- expectedLhs =
- grothLhs
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop (s `B.bls12_381_G1_scalarMul` pok) (parsedCkG parsed)
- expectedRhs =
- expectedOldGrothRhs
- `B.bls12_381_mulMlResult` B.bls12_381_millerLoop
- (B.bls12_381_G1_neg (s `B.bls12_381_G1_scalarMul` commitment))
- (parsedCkGSN parsed)
- assertBool "M4 correct Multi merge rejected" (merged vkX c commitment pok)
- assertBool "M4 actual Multi LHS differs from independent oracle" (B.bls12_381_finalVerify actualLhs expectedLhs)
- assertBool "M4 actual Multi RHS differs from independent oracle" (B.bls12_381_finalVerify actualRhs expectedRhs)
- forM_ [(grothScalar, pokScalar) | grothScalar <- [1, 2], pokScalar <- [3, 4]] $ \(grothScalar, pokScalar) -> do
- let changedC = c `B.bls12_381_G1_add` (grothScalar `B.bls12_381_G1_scalarMul` delta)
- changedPok = pok `B.bls12_381_G1_add` (pokScalar `B.bls12_381_G1_scalarMul` delta)
- assertBool "M3 old Groth unexpectedly accepted" (not (oldGroth vkX changedC))
- assertBool "M3 old PoK unexpectedly accepted" (not (verifyCommittedProofPokBatch parsed commitment changedPok))
- assertBool "M3 paired Multi error accepted" (not (merged vkX changedC commitment changedPok))
- let omittedD = vkX `B.bls12_381_G1_add` B.bls12_381_G1_neg commitment
- doubledD = vkX `B.bls12_381_G1_add` commitment
- scaledD = vkX `B.bls12_381_G1_add` ((s - 1) `B.bls12_381_G1_scalarMul` commitment)
- swappedBases =
- B.bls12_381_finalVerify
- (B.bls12_381_millerLoop pok (parsedCkGSN parsed))
- (B.bls12_381_millerLoop (B.bls12_381_G1_neg commitment) (parsedCkG parsed))
- assertBool "M4 omitted eager D accepted" (not (merged omittedD c commitment pok))
- assertBool "M4 doubled eager D accepted" (not (merged doubledD c commitment pok))
- assertBool "M4 substituted scaled D accepted" (not (merged scaledD c commitment pok))
- assertBool "M4 swapped ckG/ckGSN accepted" (not swappedBases)
- , testCase "M1-M3 Multi positive and component negatives match old validator" $ do
- let positiveContext =
- reclaimGlobalMultiContext multiProof 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- oldPositive <- safeBool (runReclaimGlobalMulti multiVk positiveContext)
- v2Positive <- safeBool (runReclaimGlobalMultiV2 multiVk positiveContext)
- oldPositive @?= True
- v2Positive @?= oldPositive
- forM_
- [ ("A", 0)
- , ("B", 48)
- , ("C", 144)
- , ("D", 192)
- , ("PoK", 288)
- ] $ \(label, offset) -> do
- let changedContext =
- reclaimGlobalMultiContext (flipBitAt offset multiProof) 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- oldNegative <- safeBool (runReclaimGlobalMulti multiVk changedContext)
- v2Negative <- safeBool (runReclaimGlobalMultiV2 multiVk changedContext)
- assertBool (label <> " Multi mutation accepted by old validator") (not oldNegative)
- assertBool (label <> " Multi mutation accepted by V2 validator") (not v2Negative)
- v2Negative @?= oldNegative
- let donorMutations =
- [ ("C-valid-subgroup", replaceProofSlice 144 48 (B.sliceByteString 144 48 destinationProof) multiProof)
- , ("D-valid-subgroup", replaceProofSlice 192 96 (B.sliceByteString 192 96 destinationProof) multiProof)
- , ("PoK-valid-subgroup", replaceProofSlice 288 48 (B.sliceByteString 288 48 destinationProof) multiProof)
- , ( "C+PoK-valid-subgroup"
- , replaceProofSlice 288 48 (B.sliceByteString 288 48 destinationProof) $
- replaceProofSlice 144 48 (B.sliceByteString 144 48 destinationProof) multiProof
- )
- ]
- forM_ donorMutations $ \(label, changedProof) -> do
- let changedContext =
- reclaimGlobalMultiContext changedProof 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- oldNegative <- safeBool (runReclaimGlobalMulti multiVk changedContext)
- v2Negative <- safeBool (runReclaimGlobalMultiV2 multiVk changedContext)
- assertBool (label <> " accepted by old Multi") (not oldNegative)
- assertBool (label <> " accepted by V2 Multi") (not v2Negative)
- v2Negative @?= oldNegative
- , testCase "M2 Multi statement and destination negatives match old validator" $ do
- let negativeContexts =
- [ reclaimGlobalMultiContext multiProof 0 0
- [differentOwnerReclaimBaseInput, reclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- , reclaimGlobalMultiContext multiProof 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [changedDestinationOutput]
- , reclaimGlobalMultiContext multiProof 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [underpaidDestinationOutput]
- , reclaimGlobalMultiContext multiProof 0 0
- [reclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- , reclaimGlobalMultiContext multiProof 0 0
- [reclaimBaseInput, thirdOwnerReclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- , reclaimGlobalMultiContext destinationProof 0 0
- [reclaimBaseInput, differentOwnerReclaimBaseInput]
- [paramInput]
- [exactDestinationOutput]
- ]
- forM_ negativeContexts $ \ctx -> do
- oldNegative <- safeBool (runReclaimGlobalMulti multiVk ctx)
- v2Negative <- safeBool (runReclaimGlobalMultiV2 multiVk ctx)
- oldNegative @?= False
- v2Negative @?= oldNegative
- ]
, testGroup "ZK-02 statement-bound ReclaimGlobal V2"
[ testCase "golden ordinary transcript frames key hash, count, proof, and digest exactly" $ do
let verifierKeyHash = B.blake2b_256 destinationVk
diff --git a/deployments/reclaim/preprod/disabled.sample.json b/deployments/reclaim/preprod/disabled.sample.json
index ffa3c4d3..fdaac8e9 100644
--- a/deployments/reclaim/preprod/disabled.sample.json
+++ b/deployments/reclaim/preprod/disabled.sample.json
@@ -14,7 +14,7 @@
"script_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"rewarding_credential": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"params_currency_symbol": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
- "verifier_vk_hash": "blake2b256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "verifier_vk_hash": "blake2b256:1111111111111111111111111111111111111111111111111111111111111111",
"proof_profile": "single-destination"
},
"params_utxo": {
diff --git a/docs/README.md b/docs/README.md
index 37a63719..08196178 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,7 +20,21 @@ that foundation.
- [`non-technical-ownership-proof-runbook.md`](non-technical-ownership-proof-runbook.md):
fixture and real local smoke commands.
- [`trusted-setup-ceremony.md`](trusted-setup-ceremony.md): setup provenance and
- signed key-bundle handling.
+ 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.
- [`proof-assets-release-inventory.md`](proof-assets-release-inventory.md): the
current release identity and coherence values.
@@ -60,6 +74,13 @@ 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):
installed Edge/Lace profile plus desktop-install smoke automation.
- [`proof-helper-windows-release-plan.md`](proof-helper-windows-release-plan.md):
diff --git a/docs/browser-proving-asset-hosting.md b/docs/browser-proving-asset-hosting.md
index a14b3d28..2f74bf1a 100644
--- a/docs/browser-proving-asset-hosting.md
+++ b/docs/browser-proving-asset-hosting.md
@@ -239,8 +239,10 @@ descriptor's runtime URLs must be same-origin paths (validated); `pk_url`/`ccs_u
may be absolute ranged-host URLs. A disabled example lives in
`deployments/reclaim/preprod/disabled.sample.json`.
-The client refuses browser proving unless the preflight-reported `vk_hash`
-equals `deployment.verifierVkHash`.
+The client refuses browser proving unless the preflight-reported native
+`vk_hash` equals `deployment.proofVkHash`. The separate
+`deployment.verifierVkHash` is the Cardano wire-format VK hash embedded in
+`ReclaimGlobal`.
## Gotcha: chunk-manifest `base_url` needs a trailing slash
diff --git a/docs/browser-proving.md b/docs/browser-proving.md
index b78429eb..2564b0e4 100644
--- a/docs/browser-proving.md
+++ b/docs/browser-proving.md
@@ -100,11 +100,11 @@ The accepted Gate G1 signed-r8 run used `streampk-sharded-groth16` with all
W1-W7 flags, 16 applied Workers, 16 shards, and range-fetch concurrency two.
It completed proof construction in 70.400 seconds, peaked at 1.4593 GiB main
WASM heap, verified locally and through the compiled contract, and passed the
-complete tamper and five-case fault suites. The production-host confirmation
-completed in 115.770 seconds / 1.4627 GiB under substantially heavier
-concurrent load; it confirms coherence rather than replacing the accepted G1
-performance result. The old 111.461-second / 2.316-GiB O4/O2 run remains the
-ideal-host pre-optimization reference only.
+complete tamper and then-current five-case fault suites. The production-host
+confirmation completed in 115.770 seconds / 1.4627 GiB under substantially
+heavier concurrent load; it confirms coherence rather than replacing the
+accepted G1 performance result. The old 111.461-second / 2.316-GiB O4/O2 run
+remains the ideal-host pre-optimization reference only.
Credential-discovery release evidence uses immutable release
`proof-assets-ownership-destination-v2-preprod-9fac96b-g3a-2m-key-discovery-r1`.
@@ -114,6 +114,41 @@ seconds with 0.833 GiB peak main-WASM heap and `verified_locally=true`. Heavy
unrelated host work contaminated the timing sample, so it qualifies the full
discovery-to-proof path and artifact coherence, not a new performance record.
+The bounded worker/chunk recovery change passed its final 2026-07-31
+no-regression gate against a same-revision baseline. The counterbalanced gate
+ran three clean, locally verified proofs per runtime for each cache mode with
+16 workers and shards, range-fetch concurrency two, W1/W2/W3/W5/W6/W7,
+`GOGC=15`, and `GOMEMLIMIT=3200MiB`. Cold-cache median proving time was
+`51.470 s` for the recovery candidate versus `51.881 s` baseline (`-0.792%`);
+warm-cache median was `48.370 s` versus `48.609 s` (`-0.492%`). Median peak
+heap was `0.110%` lower in the cold gate and `0.111%` lower in the warm gate.
+Both passed the hard ceilings of `0.5%` proving-time regression and `1.0%`
+peak-heap regression, with no accepted sample carrying a transient
+contamination observation. This qualifies the healthy path as
+performance-preserving; the small apparent improvements are benchmark noise,
+not an optimization claim.
+
+The HTTP-Range compatibility fix was held to the same ceiling before release.
+An initial Go/WASM fallback was rejected because an otherwise-dormant code
+change increased the guarded cold median from `39.101 s` to `41.363 s`
+(`+5.785%`). The accepted design instead leaves both proving WASM binaries
+byte-identical and intercepts only proving-key range requests in the outer
+`prover-worker.js`. A healthy HTTP 206 response is returned untouched with one
+native request; only an observed HTTP 200 cancels the full-object body and
+switches that operation to the signed, digest-verified 2 MiB chunks.
+
+The corrected production-worker A/B gate ran the pre-change and final workers
+through their real message protocol, three clean locally verified proofs per
+worker in each cache mode. Cold medians were `43.443 s` baseline and `41.969 s`
+candidate (`-3.393%`); warm medians were `42.144 s` and `40.216 s`
+(`-4.575%`). Median peak heap changed by `+0.177%` cold and `+0.118%` warm.
+All samples passed the unchanged contamination guards and the `0.5%` time /
+`1.0%` heap ceilings. The favorable time deltas are treated as benchmark noise,
+not as an optimization claim. Unit and browser fault gates additionally cover
+the no-extra-request 206 path, sticky 200-to-chunk recovery, signature and
+SHA-256 rejection, bounded retry recovery, abort, and chunk corruption without
+CPU fallback.
+
These are browser-prover source defaults and proof-runtime measurements. They
do not change claim batching by themselves. Until the statement-bound V2
deployment is activated, the current Preprod V1 manifest remains authoritative
diff --git a/docs/circuit-proving-optimization-candidates.md b/docs/circuit-proving-optimization-candidates.md
new file mode 100644
index 00000000..c016ef6a
--- /dev/null
+++ b/docs/circuit-proving-optimization-candidates.md
@@ -0,0 +1,158 @@
+# Circuit and proving optimization candidates
+
+**Original survey:** 2026-07-13
+
+**Status refresh:** 2026-07-28
+
+**Scope:** `root-ownership-destination-v2/bls12-381/groth16`, its browser
+runtime, and its desktop/native proving path.
+
+This document preserves the useful findings from the original optimization
+worktree and reconciles them with the current codebase. It is a candidate
+survey, not permission to change the production statement or coherence set.
+
+## Current baseline
+
+The production circuit itself has not adopted the surveyed circuit changes:
+
+- 1,789,750 R1CS constraints;
+- K=21;
+- one commitment;
+- circuit ID `root-ownership-destination-v2/bls12-381/groth16`.
+
+The tracked gate in `internal/circuit/ownershipdest/gate_test.go` remains the
+source of truth. A circuit change requires a new circuit identity, ceremony,
+VK/PK/CCS, Cardano export, contract parameters, proof release, fixtures,
+formal/negative evidence, and deployment coherence refresh.
+
+Runtime work materially changed the wall-clock baseline without changing the
+circuit. The current browser reference in
+`browser-proving-remote-chunk-matrix.md` is:
+
+- warm 16-worker: **41.46 s**;
+- cold 16-worker: **47.68 s**;
+- peak main heap: about **0.83 GiB**;
+- locally verified proofs.
+
+Those results supersede the original roughly 70-second browser baseline.
+Therefore the old “2.9 seconds per 100k constraints” conversion and every
+wall-clock projection derived from it must be remeasured; they are not current
+performance promises.
+
+## Constraint profile retained from the original survey
+
+The original gnark profile compiled a 1,791,413-constraint module-cache build:
+
+| Bucket | Constraints | Share | Interpretation |
+| --- | ---: | ---: | --- |
+| `BatchInvert` | 1,009,552 | 56.4% | Log-derivative lookup query inverses. |
+| `AssertIsEqual` | 500,936 | 28.0% | Includes about 416,081 range-check recompositions. |
+| `DivUnchecked` | 131,328 | 7.3% | Two 65,536-row byte tables and one 256-row range table. |
+| `AssertIsBoolean` | 103,229 | 5.8% | Bit decompositions and small-width checks. |
+| `MulAcc`/`Mul` | 44,135 | 2.5% | Mainly emulated ed25519 arithmetic. |
+
+The +1,663 discrepancy from the 1,789,750 tracked gate was traced to
+vendor-versus-module-cache resolution. Measurements used for adoption must be
+repeated in the bootstrapped, drift-checked vendor environment and must match
+the tracked constraint gate before comparison.
+
+## Circuit candidates
+
+| ID | Candidate | Original result | Current status | Decision notes |
+| --- | --- | --- | --- | --- |
+| R1 | Single-limb range-check fast path | Measured 1,789,750 → 1,396,464, a reduction of 393,286 (22.0%). Golden witnesses solved. | **Not adopted** | Highest-value circuit candidate. Recreate as a reviewed gnark patch, add malicious-hint negatives and drift provenance, then remeasure current browser/native proving. It changes the circuit and coherence set. |
+| R2 | Reuse SHA-512 `Maj` cross-round XOR | Measured 1,789,750 → 1,772,020, a reduction of 17,730. SHA/HMAC differential tests passed. | **Not adopted** | Small, low-complexity pure identity/wire reuse. Reprototype on current source and combine only after independent circuit review. |
+| S1 | Union-cut schedule-word decomposition | Estimated −23k before R1, roughly −14k…−18k after R1. | **Not prototyped/adopted** | Low mathematical risk, but validate exact-width recomposition and do not sum estimates naively with R1. |
+| S2 | Constant-fold `sigmaRot` schedule words | Estimated −9k…−10k before R1, roughly −6k…−8k after R1. | **Not prototyped/adopted** | Reasonable bundle item; prove every folded input is compile-time constant. |
+| E1 | ed25519 fixed-base window 4 → 5/6 | Estimated −25k…−35k. | **Not prototyped/adopted** | Compile/profile first; verify table/mux costs and all scalar edge cases. |
+| E2 | ed25519 limbs 4×64 → 3×85 | Estimated −15k…−25k. | **Not prototyped/adopted** | Higher implementation risk. Audit gnark overflow and hidden limb-width assumptions before treating the estimate as feasible. |
+| C7 | Incremental soft-parent points | Isolated measured saving about 10,968. | **Deferred** | Reopens the CKD proof argument and requires the residual `kL_child` top-bit pin. Do only if that audit surface is deliberately reopened. |
+
+R1’s original mechanism remains technically plausible: gnark 0.15's
+commit-based range checker decomposes and recomposes even a single limb. For
+checks no wider than the eight-bit lookup base, querying the original value
+(and its shifted copy for narrower widths) can preserve the same membership
+facts with fewer wires. This must be reviewed as a soundness-sensitive vendor
+change, not a mechanical performance patch.
+
+R2 uses:
+
+```text
+Maj(a,b,c) = b XOR ((a XOR b) AND (b XOR c))
+```
+
+and reuses the prior round's rotated XOR. It introduces no hints or lookup-table
+change, but still changes the R1CS and therefore requires the full release
+coherence process.
+
+## Lower-priority circuit ideas
+
+| Candidate | Original estimate | Disposition |
+| --- | ---: | --- |
+| Drop selected redundant byte checks | −35k…−49k before R1 | **Do not pursue without an airtight consumption proof.** It touches the same hint/range assumption class that previously produced a soundness bug. |
+| Trim genuinely unused HMAC feed-forward output | −450…−500 | Safe but low return; only fold into an already-reviewed bundle. |
+| Deduplicate CKD `splitByte`/canonical-bit checks | −100…−190 | Audit churn exceeds value. |
+| Remove ed25519 F1/F2 self-checks | About −800 | Keep the intentional defense in depth. |
+
+Confirmed dead ends remain:
+
+- no production OR/NOT table opportunity;
+- range-check base is already circuit-wide appropriate;
+- BLAKE2b is too small to justify a dedicated split table;
+- `Ch` has no analogous cross-round reuse;
+- the 28 SHA-512 compressions are statement-minimal;
+- divergent HMAC messages prevent further prefix sharing;
+- witness-supplied chain codes still require validation; and
+- merged C6-style lookup shortcuts remain rejected as unsound.
+
+## Runtime candidates reconciled with current code
+
+| Candidate | Original status | Current status |
+| --- | --- | --- |
+| Chunk/range-aligned PK fetching | Candidate, estimated −8…−15 s | **Substantially implemented.** Signed chunks, exact range validation, sharded ranged MSM, prefetch controls, and hosted range evidence exist. This work helped move the reference from ~70 s to 41–48 s. Continue optimizing measured fetched-byte amplification rather than assuming the old 59.2% waste figure. |
+| Native desktop MSM task fix | Measured 3.84 s after removing native `NbTasks:1` | **Implemented.** Native uses `runtime.NumCPU()` while JS/WASM retains one task per worker. Product routing and signed helper releases remain release-readiness work, not prover math work. |
+| Persistent verified chunk cache | Candidate | **Not implemented as durable OPFS/IndexedDB cache.** Current worker verified-chunk caching is session/runtime scoped. Any durable cache must bind bytes to signed manifest identity and verify before activation. |
+| Increase range-fetch concurrency | A/B candidate | **Tunable and implemented.** Keep workload/device/CDN A/B evidence; more concurrency can raise peak memory and contention. |
+| WASM-specific MSM window | Research candidate | **Not adopted.** Benchmark only with identical proof verification and memory telemetry. |
+| FFT or witness parallelism | Low expected return | Still low priority: the MSM window dominates and overlaps FFT; witness solve is a small fraction of the current run. |
+| WebGPU/custom kernels | Long-term research | Separate audited research program, not a near-term release optimization. |
+
+## Statement-level ideas are not optimizations
+
+- Accepting an intermediate `m/1852'/1815'` key would remove substantial CKD
+ work but weaken the claim. It would be a new statement, domain, circuit,
+ ceremony, audit, UI claim, and deployment—not a faster implementation of the
+ current claim.
+- Replacing the destination BLAKE2b digest with a field hash remains
+ unattractive: the in-circuit digest binding is small and Plutus has a native
+ BLAKE2b primitive.
+- Prefix-sharing across multiple credentials applies to the separate
+ multi-credential circuit family. The deployed reclaim path intentionally uses
+ one full destination-bound proof and digest per V2 slot.
+- DRep role 3 is unsupported. Adding it would be a separately reviewed protocol
+ release, not a proving optimization.
+
+## Updated recommendation
+
+1. **Do not change the circuit merely because the current production ceremony
+ is still NO-GO.** First decide whether the roughly 41–48 second browser
+ baseline is acceptable and whether a circuit refresh would delay the
+ already-large Mainnet/MPC review surface.
+2. Prefer ceremony-free work first: reduce fetched bytes and cold-start
+ overhead, finish durable verified caching if justified, improve capability
+ routing, and complete signed desktop-helper distribution.
+3. If a circuit v3 is justified, start with isolated current-tree recreations of
+ R1 and R2. Record constraint counts, compile/solve/prove/verify times,
+ browser cold/warm results, native results, peak memory, and malicious witness
+ tests before selecting a bundle.
+4. Prototype S1/S2/E1/E2 independently. Do not add estimated savings; measure
+ interactions after R1.
+5. Require a written statement-equivalence review, full golden/differential and
+ negative battery, formal-assurance refresh, new circuit ID, new ceremony,
+ new native/Cardano keys, contract/script rebuild, signed proof release, and
+ real Preprod contract-path evidence.
+
+The original survey’s most important conclusion still holds: K=20 requires at
+most 1,048,576 constraints, so even the proposed low-risk bundle was expected
+to remain K=21. Chasing K=20 by weakening the statement or removing
+soundness-relevant checks is not justified.
diff --git a/docs/manual-lace-claim-flow-qa-plan.md b/docs/manual-lace-claim-flow-qa-plan.md
index c5691965..e075cb4c 100644
--- a/docs/manual-lace-claim-flow-qa-plan.md
+++ b/docs/manual-lace-claim-flow-qa-plan.md
@@ -494,7 +494,9 @@ The command fails before browser startup unless:
safety for the selected Git remote;
- the ignored repository `.env.local` selects the canonical Preprod reclaim
manifest; and
-- the ignored dedicated Lace `profile.env` exists with both required wallets.
+- the ignored persistent Lace `profile.env` and its already-initialized profile
+ directory exist with both required wallets. The lane must reuse this state
+ and must not bootstrap a replacement profile.
Linked worktrees automatically look for those two ignored files in the primary
checkout. They can instead be selected explicitly with
@@ -569,10 +571,12 @@ does not install a partial or unprovisioned hosted workflow.
- `pnpm typecheck`, the Next production build, Node syntax checks, direct
reclaim-manifest verification, and `git diff --check` pass for the reviewed
executable tree.
-- A fresh ignored Lace 2.1.1 profile was built with only the repo-backed
+- The canonical persistent Lace 2.1.1 profile was built once on 2026-07-31
+ with only the repo-backed
`compromised_user` and `safe_claim_destination` Preprod fixtures, a generated
test-only password persisted in a mode-0600 ignored `profile.env`, and the
- Testnet network selected. `pnpm e2e:preprod:lace:setup` then passed against
+ Testnet network selected. It is reused for future runs rather than recreated.
+ `pnpm e2e:preprod:lace:validate-profile` then passed against
that profile and reported the two account-center labels and distinct redacted
addresses. The driver selects each containing wallet card by label instead of
using an array index; the fixture funder remains outside Lace.
diff --git a/docs/preprod-e2e.md b/docs/preprod-e2e.md
index 8a80edd7..312f0d81 100644
--- a/docs/preprod-e2e.md
+++ b/docs/preprod-e2e.md
@@ -48,7 +48,8 @@ The harness also requires:
- `RECLAIM_REVIEW_TOKEN_SECRET` and a configured Preprod provider;
- `RECLAIM_DEPLOYMENT_MANIFEST_JSON` or one supported manifest path variable;
- a loopback destination helper target and token for the desktop provider;
-- a signed destination key bundle whose VK hash matches the deployment;
+- a signed destination key bundle whose native VK hash matches the
+ deployment's `proof.vk_hash`;
- a lowercase native-asset unit for the full injected-wallet lane.
Source the repo-root `.env.local` when serving the local app so claim and
@@ -227,6 +228,17 @@ The Next build and server stay in production mode. Only the
separate fixture-funding driver drops production mode from its own process; it
is not injected into the app and Lace remains the transaction-signing wallet.
+The default Lace state is the persistent local profile at
+`output/playwright/lace-e2e-preprod-profile-v2`, provisioned on 2026-07-31.
+Keep that directory and its mode-0600 `profile.env` together and reuse them for
+future runs. The validator and guarded claim lane never bootstrap a profile:
+they require the existing Chromium state, require `PW_USER_DATA_DIR` to point
+to the directory containing the selected `profile.env`, and fail before browser
+launch if the directory is missing or uninitialized. Restore this profile and
+its saved password if either is lost; do not create a replacement merely to
+make an E2E run proceed. Validate it without spending funds with
+`pnpm --dir apps/ownership-proof-web e2e:preprod:lace:validate-profile`.
+
Before the app tab is created, the driver unlocks and selects the compromised
test wallet so the extension can inject its real CIP-30 provider at document
creation. It first removes any stale authorization for the exact local origin
@@ -315,6 +327,11 @@ The deployed coherence set pins:
`b1c03cf24376bcd6c743cb372169ff71f93b210e0d8d52b2c6831808f50ded80`;
- Cardano/on-chain VK hash
`06ce913c931a53561fe5d022ed45a5fbc033b06d80eebdd9f646d23a05b7d5c4`;
+
+The deployment manifest records the first as `proof.vk_hash`. It records the
+second as both `proof.cardano_vk_blake2b256` and
+`reclaim_global.verifier_vk_hash`; the V2 batch-transcript preflight uses that
+same Cardano hash.
- signed asset prefix `proof-assets/preprod-9fac96b-g3a/`;
- proving key size 1,288,707,133 bytes, 615 two-MiB chunks, and CCS size
129,221,468 bytes.
diff --git a/docs/reclaim-contracts-spec.md b/docs/reclaim-contracts-spec.md
index ae2ffc23..cf8e84ce 100644
--- a/docs/reclaim-contracts-spec.md
+++ b/docs/reclaim-contracts-spec.md
@@ -24,7 +24,11 @@ Developer entrypoints:
validator, with one full destination-bound proof and one authenticated
statement digest per matching input.
- `src/Ownership/ReclaimGlobalMulti.hs`: one count-specific proof for an
- ordered set of matching inputs.
+ ordered set of matching inputs. This reference implementation is currently
+ unused by the ownership-proof web app and production deployments:
+ `ReclaimGlobalV2` was selected because its smaller proving key enables faster
+ browser proving. `ReclaimGlobalMulti` remains available for developers
+ studying a batched proof-verification contract.
- `test/VerifySpec.hs`: real-proof positives plus proof/order/destination/value
negative cases.
- `test-support/ScriptContextBuilder.hs`: transaction-context fixtures shared
@@ -243,7 +247,9 @@ off-chain mistakes harder to detect.
destination-bound proof. It scans all spending inputs whose payment credential is
the deployed `ReclaimBase` script hash, aggregates their credential hashes and
values, and requires one proof that covers the full ordered credential set and
-the destination address.
+the destination address. It is retained as a developer reference rather than a
+web-app or production deployment path; the canonical `ReclaimGlobalV2` path uses
+a smaller proving key and therefore provides faster browser proving.
### Parameters
diff --git a/docs/reclaim-funding-page.md b/docs/reclaim-funding-page.md
index d1c2d2b8..06671e54 100644
--- a/docs/reclaim-funding-page.md
+++ b/docs/reclaim-funding-page.md
@@ -201,7 +201,12 @@ these environment variables:
- `RECLAIM_GLOBAL_SCRIPT_HASH`
- `RECLAIM_PARAMS_CURRENCY_SYMBOL`
- `RECLAIM_PARAMS_TOKEN_NAME`
-- `RECLAIM_VERIFIER_VK_HASH`
+- `RECLAIM_VERIFIER_VK_HASH` (Cardano wire-format VK hash embedded on-chain)
+- `RECLAIM_PROOF_VK_HASH` (native gnark VK hash used by provers)
+- `RECLAIM_PROOF_CARDANO_VK_BLAKE2B256` (must equal
+ `RECLAIM_VERIFIER_VK_HASH`)
+- `RECLAIM_GLOBAL_BATCH_TRANSCRIPT_VK_HASH` (the same Cardano hash used by the
+ V2 transcript preflight)
- `RECLAIM_CONTRACT_VERSION`
- `RECLAIM_SOURCE_COMMIT`
@@ -272,8 +277,9 @@ Before enabling deposits:
- Publish a valid reclaim deployment manifest.
- Verify the `ReclaimBase` address from the manifest matches the deployed
script parameters.
-- Verify the `ReclaimGlobalV2` verifier key hash matches the published proof
- helper/verifier key bundle.
+- Verify `ReclaimGlobalV2` embeds the published Cardano wire-format verifier
+ key hash, while `proof.vk_hash` independently matches the native proof-helper
+ key bundle.
- Run a preprod deposit from the page.
- Confirm the output has inline `ReclaimBaseDatum` with the expected payment key
hash.
diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md
index d32a19c0..debbe884 100644
--- a/docs/trusted-setup-ceremony.md
+++ b/docs/trusted-setup-ceremony.md
@@ -1,7 +1,18 @@
# Trusted Setup Ceremony
-This project now has a reproducible local ceremony path for the ownership
-Groth16 keys:
+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).
+
+The commands, transcripts, and trust claims are not interchangeable.
+
+## Single-Actor Local Setup
+
+Run the local path with:
```sh
go run ./cmd/proof-tool setup-ceremony \
@@ -37,10 +48,23 @@ the setup into a public multi-party ceremony. Public users must either trust the
named setup operator and release signing key, or require a true public MPC
ceremony or a transparent proof system.
-For a production release, run from a clean tagged commit with
+For a signed rehearsal or explicitly trusted single-operator release, run from
+a clean tagged commit with
`--require-clean-git`, record the operator and host controls in release notes,
publish the signed bundle and transcript, and keep the Ed25519 private signing
-key outside the published bundle.
+key outside the published bundle. Do not label such a bundle "multi-party",
+"trustless", or production MPC evidence.
+
+The dedicated MPC command uses gnark's BLS12-381 `mpcsetup` package, requires
+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.
## Toxic Waste Handling
@@ -52,3 +76,8 @@ system, but Go does not provide a ceremony-grade zeroization proof.
For stronger production hygiene, use an ephemeral controlled host, disable or
destroy swap, avoid persistent crash dumps, publish `TOXIC-WASTE-HANDLING.md`,
and destroy the ceremony host or VM after the artifacts are signed and copied.
+
+The MPC path narrows the trust assumption to require at least one honest
+independent contributor in each phase, but it does not cryptographically prove
+that a contributor erased its randomness. Every accepted participant must use
+and attest to the host controls in the MPC runbook.
diff --git a/docs/vercel-preprod-browser-proving-deployment-plan.md b/docs/vercel-preprod-browser-proving-deployment-plan.md
index 1347199c..f4415409 100644
--- a/docs/vercel-preprod-browser-proving-deployment-plan.md
+++ b/docs/vercel-preprod-browser-proving-deployment-plan.md
@@ -194,14 +194,17 @@ but the browser-proving surface that **must** be committed together is:
node apps/ownership-proof-web/scripts/verify-reclaim-manifest.mjs deployments/reclaim/preprod/live.local.json
2. Record the identity values you will reuse: `deployment_id`, `network: Preprod`,
- `network_id: 0`, and `reclaim_global.verifier_vk_hash` == `proof.vk_hash` ==
- `blake2b256:6057da91…d430a`. Keep the existing `source_commit` for this
+ and `network_id: 0`. The native prover pin is `proof.vk_hash` ==
+ `blake2b256:6057da91…d430a`; the on-chain pin is
+ `reclaim_global.verifier_vk_hash` == `proof.cardano_vk_blake2b256` ==
+ `blake2b256:d35ce804…17acf`. Keep the existing `source_commit` for this
on-chain deployment and verify it is an ancestor of the webapp release commit:
git merge-base --is-ancestor "$(jq -r .source_commit deployments/reclaim/preprod/live.local.json)" HEAD
-The descriptor's browser-proving `vk_hash` chain must terminate at this
-`verifierVkHash` or the client refuses to prove.
+The descriptor's browser-proving `vk_hash` chain must terminate at
+`proofVkHash` (the native `proof.vk_hash`), not `verifierVkHash` (the Cardano
+wire-key hash embedded on-chain), or the client refuses to prove.
### Step 2 — Verify and maintain the ranged asset host (PK + CCS) — complete
diff --git a/docs/worker-owned-pk-fetch-design.md b/docs/worker-owned-pk-fetch-design.md
index 45b3fc09..fa027568 100644
--- a/docs/worker-owned-pk-fetch-design.md
+++ b/docs/worker-owned-pk-fetch-design.md
@@ -155,6 +155,36 @@ and local acceptance gates passed. Later pinned-decode and commitment/overlap
work superseded the w16/s64 tuning; current defaults and evidence are in
`browser-proving.md`.
+### Chunk-local transient retry follow-up
+
+The slower-network mitigation adds one bounded retry for a transient failure
+while fetching an individual authenticated chunk. The worker first performs a
+single direct fetch; only a network/body-read failure or HTTP 408, 425, 429, or
+5xx response enters the delayed retry path. A successful fetch therefore does
+not allocate a retry timer, issue an extra request, or discard already-verified
+chunks. Integrity failures remain terminal.
+
+The candidate and production workers share the implementation and are covered
+by the W7 unit gate. The final 2026-07-31 acceptance used separately built
+baseline and recovery-candidate runtimes from the same source revision,
+temporary signed manifests, three counterbalanced samples per role, 16 workers
+and shards, range-fetch concurrency two, W1/W2/W3/W5/W6/W7, `GOGC=15`, and
+`GOMEMLIMIT=3200MiB`. Every accepted sample produced a locally verified proof
+and had a clean preflight with no transient contamination observation.
+
+The cold-cache median was `51,470 ms` for the recovery candidate versus
+`51,881 ms` for baseline (`-0.792%`), with median peak heap `0.82785 GiB`
+versus `0.82877 GiB` (`-0.110%`). The warm-cache median was `48,370 ms`
+versus `48,609 ms` (`-0.492%`), with median peak heap `0.82687 GiB` versus
+`0.82779 GiB` (`-0.111%`). Both passed the hard acceptance limits of at most
+`0.5%` proving-time regression and at most `1.0%` peak-heap regression. The
+healthy path made no retry and the observed result is evidence of no
+measurable regression, not a claim that retry logic makes proving faster.
+
+The repository worker sources are updated for this follow-up; immutable
+production release copies and their signed manifests are intentionally not
+rewritten without the release signing ceremony.
+
## Original Baseline Evidence
The isolated browser WASM experiment already generated and locally verified a
@@ -631,7 +661,10 @@ Failure response:
```json
{
"id": 42,
- "error": "chunk 17 blake2b256 mismatch"
+ "error": "fetch chunk 17 returned status 503",
+ "error_code": "chunk-fetch-http",
+ "retryable": true,
+ "retry_after_ms": 1000
}
```
@@ -639,6 +672,34 @@ The worker must include the request id in every response. The dispatcher must
demultiplex replies by id and must support multiple outstanding requests per
worker before over-sharding is benchmarked.
+The main runtime, not Worker-provided message text, owns shard retry policy.
+The worker's local same-chunk retry is fixed by the implementation above; the
+runtime still allow-lists transient network/HTTP failures and worker
+termination or initialization failures at the shard boundary. Chunk encoding,
+size, and digest failures, invalid partials, stale replies, unknown error
+codes, and compute failures remain terminal even if a Worker claims they are
+retryable. A failed shard gets at most three total attempts; worker-liveness
+failures replace only that worker, while transient fetch failures retain the
+worker and its verified W7 cache. Already accepted partials from other
+asynchronous shards are preserved.
+
+Before escalating a transient chunk transport failure to the shard boundary,
+the MSM worker makes one bounded same-chunk retry (two fetch attempts total).
+The retry uses a capped exponential delay with deterministic per-chunk jitter
+and honors a numeric `Retry-After` value up to 30 seconds. The healthy path
+does not allocate a timer or issue an extra request. Only network/body-read
+failures and HTTP 408, 425, 429, or 5xx responses are retryable; status,
+encoding, size, and digest failures remain terminal. The worker reports the
+attempt count for evidence, while the existing outer shard retry remains the
+backstop for a transport that stays unavailable.
+
+The reply wait uses a five-minute inactivity lease, matching the browser
+proving safety margin, and a 20-minute absolute cap. A generation-bound
+two-word `SharedArrayBuffer` records progress after each already-verified fetch
+window and before compute. Progress can renew the inactivity lease but never
+the absolute cap. The successful path has no polling or heartbeat messages and
+does not alter chunk concurrency, fetch order, hashing, decode, or MSM work.
+
## Worker Fetch Algorithm
For a task `{section, lo, hi}`:
@@ -655,7 +716,8 @@ For a task `{section, lo, hi}`:
4. Find all chunks intersecting `[start, end_exclusive)`.
5. For each chunk:
- check cache by `{asset_id, chunk_size, chunk_index, blake2b256}`,
- - if absent, fetch exact chunk path or exact chunk range,
+ - if absent, fetch exact chunk path or exact chunk range; retry one
+ transient network/body/408/425/429/5xx failure locally before escalating,
- require status `206` for range fetches,
- require byte count equals chunk size,
- hash chunk with BLAKE2b-256 and SHA-256,
@@ -807,8 +869,10 @@ Add a command, likely under `cmd/proof-tool`, that:
1. Starts from a verified key bundle directory.
2. Runs the existing key-bundle verification path.
3. Reads the reclaim deployment manifest.
-4. Asserts `proof.vk_hash`, `reclaim_global.verifier_vk_hash`, and generated
- Cardano VK hash match.
+4. Asserts `proof.vk_hash` matches the native gnark key bundle, while
+ `reclaim_global.verifier_vk_hash` and
+ `proof.cardano_vk_blake2b256` both match the generated Cardano wire-format
+ VK hash.
5. Generates the proving-key section index.
6. Generates the serialized CCS if needed and records its hash.
7. Splits `ownership.pk` into fixed raw chunks.
diff --git a/experiments/wasm-prover/fault/README.md b/experiments/wasm-prover/fault/README.md
index e1f4cc40..be3c82f8 100644
--- a/experiments/wasm-prover/fault/README.md
+++ b/experiments/wasm-prover/fault/README.md
@@ -1,8 +1,8 @@
# Runtime fault gates
-The runner defines five fail-closed outcome contracts: worker termination,
-served PK chunk corruption, aborted range fetch, reload/retry, and the
-4-core/8-GB memory-pressure profile.
+The runner defines six recovery/fail-closed outcome contracts: worker
+termination, served PK chunk corruption, recoverable and exhausted range
+fetches, reload/retry, and the 4-core/8-GB memory-pressure profile.
```sh
node experiments/wasm-prover/fault/run.mjs --case reload-retry --opt-w2 --workers 8 --deadline-ms 180000
@@ -26,12 +26,39 @@ cumulative runtime options in both the result and proof trace.
Chunk corruption changes one byte in an authenticated PK chunk and requires a
`chunk-digest-mismatch` failure with a confirmed server hit, no CPU fallback,
and no partial proof. With `--opt-w7`, this also proves corrupt bytes cannot be
-hidden by or inserted into the verified per-worker cache. Network
-abort terminates two range responses and returns a terminal error for the third
-attempt; the runtime must report the bounded `3/3` retry exhaustion and must not
-demote the authenticated-transport failure to CPU proving. Worker termination
-kills a worker after an MSM range dispatch and requires a structured
-`worker-terminated` failure with no hang or partial acceptance.
+hidden by or inserted into the verified per-worker cache. A transient chunk
+fetch gets one bounded same-chunk retry (two fetch attempts total) before the
+outer shard retry is considered; the worker unit gate covers both the retry
+and its candidate/production parity. Network recovery returns two retryable
+transport responses, then requires chunk-local retries to complete and the
+final proof to verify locally without requiring an outer shard restart.
+Network abort interrupts two responses and
+returns a terminal error for subsequent attempts; the runtime must report
+bounded `3/3` exhaustion and must not demote the authenticated-transport
+failure to CPU proving. A separate Worker unit gate requires rejected `fetch`
+promises to carry the retryable network error code; this avoids treating
+Chromium's transparent replay of idempotent GETs as runtime retry evidence.
+
+Worker termination injects the error event produced by a crashed Worker and
+then terminates it after an MSM range dispatch. The runtime must replace only
+that worker, retry the same shard, and locally verify the completed proof
+without CPU fallback. Programmatic `Worker.terminate()` is silent by browser
+definition, so the separate progress-aware watchdog tests cover silent
+disappearance and its bounded five-minute inactivity/20-minute absolute
+deadlines.
+
+The final bounded-recovery acceptance on 2026-07-31 exercised the changed
+paths with real browser proofs. `network-recover` returned two transient 503
+responses, observed two chunk-local retries and zero outer shard restarts,
+completed on the third server hit, and verified locally without CPU fallback
+or a partial proof.
+`worker-kill` replaced one worker, retried once, and verified locally.
+`chunk-corruption` failed closed on the first authenticated hit with
+`chunk-digest-mismatch` and no runtime retry. Persistent `network-abort`
+exhausted the declared `3/3` shard-attempt budget and failed closed with no CPU
+fallback or partial proof. Unit tests separately cover the healthy no-timer
+path, the local same-chunk retry, structured error allow-list, inactivity
+renewal, absolute deadline, and candidate/production worker parity.
Reload/retry waits for a
`prove` progress stage, reloads the same tab, requires that the in-flight
@@ -70,4 +97,4 @@ Accepted outcomes and exact error classes are declared in `cases.mjs`.
Unsupported future controls remain fail-closed: they must produce
`TODO_UNSUPPORTED`, never a skip or a recorded pass. Each W finding reruns the
whole suite with its cumulative prerequisite flags enabled, and W1 additionally
-checks cancellation of all outstanding work after a killed queued job.
+checks bounded same-shard recovery without accepting a partial result.
diff --git a/experiments/wasm-prover/fault/browser-adapter.mjs b/experiments/wasm-prover/fault/browser-adapter.mjs
index 4998dad4..fb68c23f 100644
--- a/experiments/wasm-prover/fault/browser-adapter.mjs
+++ b/experiments/wasm-prover/fault/browser-adapter.mjs
@@ -2,11 +2,22 @@ import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import path from 'node:path';
-export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}, optimizationFlags = {}, workerCount = 8 }) {
+export async function createFaultBrowserAdapter({
+ repoRoot,
+ baseURL,
+ tuning = {},
+ optimizationFlags = {},
+ workerCount = 8,
+ artifactOverrides = {},
+ privateInputs = {},
+}) {
const require = createRequire(import.meta.url);
const { chromium } = require(path.join(repoRoot, 'apps/ownership-proof-web/node_modules/playwright'));
const browser = await chromium.launch({ headless: true, chromiumSandbox: false });
const context = await browser.newContext();
+ await context.addInitScript((inputs) => {
+ globalThis.__benchmarkPrivateRequest = structuredClone(inputs || {});
+ }, privateInputs);
await context.addInitScript(() => {
const NativeWorker = globalThis.Worker;
globalThis.__faultWorkers = [];
@@ -21,6 +32,12 @@ export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}
if (globalThis.__killWorkerOnNextShard && message?.type === 'msm-section-range') {
globalThis.__killWorkerOnNextShard = false;
setTimeout(() => {
+ // Worker.terminate() is intentionally silent in browsers. Inject
+ // the error event a crashed Worker would emit, then terminate it;
+ // the separate Go watchdog tests cover a silent disappearance.
+ if (typeof this.onerror === 'function') {
+ this.onerror({ message: 'fault injection: worker terminated mid-shard' });
+ }
this.terminate();
}, 0);
}
@@ -42,8 +59,15 @@ export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}
}
await page.goto(baseURL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__proverLoaded === true, null, { timeout: 0 });
+ await page.evaluate((overrides) => {
+ globalThis.__defaultProofRequest.artifacts = {
+ ...(globalThis.__defaultProofRequest.artifacts || {}),
+ ...(overrides || {}),
+ };
+ }, artifactOverrides);
}
await loadFreshPage();
+ await resetFaultServer(baseURL);
return {
async capabilities() {
@@ -94,10 +118,19 @@ export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}
return runtime;
},
async runFault(testCase) {
+ if (testCase.id !== 'chunk-corruption' && testCase.id !== 'network-abort' && testCase.id !== 'network-recover') {
+ await resetFaultServer(baseURL);
+ }
if (testCase.id === 'worker-kill') {
return runWorkerKill(page, tuning);
}
- if (testCase.id === 'chunk-corruption' || testCase.id === 'network-abort') {
+ if (testCase.id === 'chunk-corruption' || testCase.id === 'network-abort' || testCase.id === 'network-recover') {
+ // CDP's browser-cache clear does not clear the authenticated W7 LRU
+ // inside an already-running Worker. Isolate transport cases with a
+ // fresh page and Worker pool so a clean chunk cached by an earlier
+ // case cannot mask the injected response.
+ await page.close();
+ await loadFreshPage();
return runTransportFault(page, baseURL, testCase.id, tuning);
}
if (testCase.id === 'memory-pressure') {
@@ -176,6 +209,7 @@ export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}
await browser.close().catch(() => {});
},
async close() {
+ await resetFaultServer(baseURL).catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
},
@@ -184,19 +218,20 @@ export async function createFaultBrowserAdapter({ repoRoot, baseURL, tuning = {}
async function runWorkerKill(page, tuning = {}) {
await page.evaluate(() => { globalThis.__killWorkerOnNextShard = true; });
- const firstAttempt = startProof(page, tuning);
- const termination = firstAttempt.then(
- () => ({ terminated: false, error: 'proof unexpectedly completed after worker termination' }),
- (error) => ({ terminated: true, error: error?.message || String(error) }),
- );
- const outcome = await termination;
- const fallback = classifyCPUFallbackFailure(outcome.error);
+ let result;
+ let error = '';
+ try {
+ result = await startProof(page, tuning);
+ } catch (caught) {
+ error = caught?.message || String(caught);
+ }
+ const fallback = result ? classifyCPUFallbackSuccess(result) : classifyCPUFallbackFailure(error);
+ const retryCount = countTraceEvents(result, 'msm-shard-retry');
return {
- status: outcome.terminated ? 'failed-closed' : 'unsafe-completed',
- error_class: /worker-terminated|worker.*(?:error|terminated)|terminated.*worker/i.test(outcome.error)
- ? 'worker-terminated'
- : 'unexpected-worker-error',
- error: outcome.error,
+ status: result?.verified_locally === true ? 'recovered' : 'failed-closed',
+ verified_locally: result?.verified_locally === true,
+ retry_count: retryCount,
+ error,
cpu_fallback: fallback.state === 'observed' ? true : fallback.state === 'none' ? false : null,
cpu_fallback_state: fallback.state,
hung: false,
@@ -205,7 +240,15 @@ async function runWorkerKill(page, tuning = {}) {
}
async function runTransportFault(page, baseURL, id, tuning = {}) {
- const mode = id === 'chunk-corruption' ? 'chunk-corruption' : 'network-abort';
+ const cdp = await page.context().newCDPSession(page);
+ try {
+ await cdp.send('Network.enable');
+ await cdp.send('Network.clearBrowserCache');
+ } finally {
+ await cdp.detach();
+ }
+ const mode = id === 'chunk-corruption' ? 'chunk-corruption' :
+ id === 'network-recover' ? 'network-recover' : 'network-abort';
const arm = await fetch(new URL('/__wasm-prover-fault/arm', baseURL), {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -215,29 +258,60 @@ async function runTransportFault(page, baseURL, id, tuning = {}) {
let result;
let error = '';
try {
- result = await startProof(page, tuning);
+ // Readahead intentionally swallows transfer failures and would consume a
+ // transport fault before an authenticated MSM shard observes it.
+ result = await startProof(page, { ...tuning, chunk_readahead: 0 });
} catch (caught) {
error = caught?.message || String(caught);
}
const status = await fetch(new URL('/__wasm-prover-fault/status', baseURL)).then((response) => response.json());
+ await resetFaultServer(baseURL);
const expectedClass = id === 'chunk-corruption' ? 'chunk-digest-mismatch' : 'range-fetch-aborted';
+ const runtimeRetryCount = countTraceEvents(result, 'msm-shard-retry');
+ const chunkRetryCount = countChunkFetchRetries(result);
const fallback = result
? classifyCPUFallbackSuccess(result)
: classifyCPUFallbackFailure(error);
return {
- status: result ? 'unsafe-completed' : 'failed-closed',
- error_class: error.includes(expectedClass) ? expectedClass : 'unexpected-transport-error',
+ status: id === 'network-recover' && result?.verified_locally === true
+ ? 'recovered'
+ : result ? 'unsafe-completed' : 'failed-closed',
+ verified_locally: result?.verified_locally === true,
+ error_class: error.includes(expectedClass) ? expectedClass : result ? '' : 'unexpected-transport-error',
error,
retry_count: status.retry_count,
retry_max: status.retry_max,
+ chunk_retry_count: chunkRetryCount,
+ runtime_retry_count: runtimeRetryCount,
cpu_fallback: fallback.state === 'observed' ? true : fallback.state === 'none' ? false : null,
cpu_fallback_state: fallback.state,
- partial_proof: !!result,
+ partial_proof: id === 'network-recover' ? false : !!result,
hung: false,
server_hit_count: status.hit_count,
};
}
+async function resetFaultServer(baseURL) {
+ const response = await fetch(new URL('/__wasm-prover-fault/reset', baseURL), { method: 'POST' });
+ if (!response.ok) throw new Error(`fault server reset returned ${response.status}`);
+}
+
+function countTraceEvents(result, stage) {
+ const events = Array.isArray(result?.trace?.events) ? result.trace.events : [];
+ return events.filter((event) => event?.stage === stage).length;
+}
+
+function countChunkFetchRetries(result) {
+ const events = Array.isArray(result?.trace?.events) ? result.trace.events : [];
+ return events.reduce((total, event) => {
+ if (event?.stage !== 'shard' || event?.phase !== 'measure') return total;
+ const attempts = Number(event.fields?.fetch_attempts);
+ const requests = Number(event.fields?.fetch_requests);
+ if (!Number.isSafeInteger(attempts) || !Number.isSafeInteger(requests)) return total;
+ return total + Math.max(0, attempts - requests);
+ }, 0);
+}
+
function startProof(page, tuning = {}) {
return page.evaluate(async (runtimeTuning) => {
const request = structuredClone(globalThis.__defaultProofRequest);
diff --git a/experiments/wasm-prover/fault/cases.mjs b/experiments/wasm-prover/fault/cases.mjs
index a0100a61..32a32873 100644
--- a/experiments/wasm-prover/fault/cases.mjs
+++ b/experiments/wasm-prover/fault/cases.mjs
@@ -4,9 +4,10 @@ export const faultCases = Object.freeze([
{
id: 'worker-kill',
capability: 'worker_kill_mid_shard',
- dependency: 'W1 worker dispatch/cancellation fault hook',
+ dependency: 'W1 worker error/termination fault hook plus same-shard replacement',
accept(outcome) {
- return outcome.status === 'failed-closed' && outcome.error_class === 'worker-terminated' &&
+ return outcome.status === 'recovered' && outcome.verified_locally === true &&
+ Number.isSafeInteger(outcome.retry_count) && outcome.retry_count > 0 &&
outcome.cpu_fallback === false && outcome.cpu_fallback_state === 'none' &&
!outcome.hung && !outcome.partial_proof;
},
@@ -26,6 +27,29 @@ export const faultCases = Object.freeze([
!outcome.hung;
},
},
+ {
+ id: 'network-recover',
+ capability: 'abort_range_fetch',
+ dependency: 'fault-serving seam plus bounded same-shard retry',
+ accept(outcome) {
+ return outcome.status === 'recovered' &&
+ outcome.verified_locally === true &&
+ Number.isSafeInteger(outcome.retry_count) &&
+ outcome.retry_count > 0 &&
+ Number.isSafeInteger(outcome.retry_max) &&
+ outcome.retry_count <= outcome.retry_max &&
+ Number.isSafeInteger(outcome.chunk_retry_count) &&
+ outcome.chunk_retry_count > 0 &&
+ outcome.chunk_retry_count < outcome.retry_max &&
+ Number.isSafeInteger(outcome.runtime_retry_count) &&
+ outcome.runtime_retry_count >= 0 &&
+ outcome.runtime_retry_count < outcome.retry_max &&
+ outcome.cpu_fallback === false &&
+ outcome.cpu_fallback_state === 'none' &&
+ !outcome.hung &&
+ !outcome.partial_proof;
+ },
+ },
{
id: 'network-abort',
capability: 'abort_range_fetch',
diff --git a/experiments/wasm-prover/fault/run.mjs b/experiments/wasm-prover/fault/run.mjs
index 947b7189..40588bba 100644
--- a/experiments/wasm-prover/fault/run.mjs
+++ b/experiments/wasm-prover/fault/run.mjs
@@ -13,6 +13,12 @@ const faultDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(faultDir, '../../..');
const parsedFlags = parseOptimizationFlags(process.argv.slice(2));
const options = parseArgs(parsedFlags.rest);
+const artifactOverrides = options.artifactOverridesFile
+ ? JSON.parse(await fs.readFile(options.artifactOverridesFile, 'utf8'))
+ : {};
+const privateInputs = options.privateInputsFile
+ ? JSON.parse(await fs.readFile(options.privateInputsFile, 'utf8'))
+ : {};
validateFaultWorkerCount(options.workers);
const cases = selectFaultCases(options.cases);
const adapter = await createFaultBrowserAdapter({
@@ -21,6 +27,8 @@ const adapter = await createFaultBrowserAdapter({
tuning: { ...toRuntimeTuning(parsedFlags.flags), worker_count: options.workers },
optimizationFlags: parsedFlags.flags,
workerCount: options.workers,
+ artifactOverrides,
+ privateInputs,
});
try {
const report = await runFaultCases(cases, adapter, { deadlineMs: options.deadlineMs, workerCount: options.workers });
@@ -39,6 +47,8 @@ function parseArgs(args) {
outputDir: path.join(repoRoot, 'experiments/wasm-prover/output'),
deadlineMs: 180_000,
workers: 8,
+ artifactOverridesFile: '',
+ privateInputsFile: '',
};
for (let index = 0; index < args.length; index++) {
const arg = args[index];
@@ -52,6 +62,8 @@ function parseArgs(args) {
else if (arg === '--output-dir') options.outputDir = path.resolve(next());
else if (arg === '--deadline-ms') options.deadlineMs = Number(next());
else if (arg === '--workers') options.workers = Number(next());
+ else if (arg === '--artifact-overrides') options.artifactOverridesFile = path.resolve(next());
+ else if (arg === '--private-inputs-file') options.privateInputsFile = path.resolve(next());
else throw new Error(`unknown argument ${arg}`);
}
return options;
diff --git a/experiments/wasm-prover/fault/server-control.mjs b/experiments/wasm-prover/fault/server-control.mjs
index 83185d04..32034177 100644
--- a/experiments/wasm-prover/fault/server-control.mjs
+++ b/experiments/wasm-prover/fault/server-control.mjs
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
-const supportedModes = new Set(['chunk-corruption', 'network-abort']);
+const supportedModes = new Set(['chunk-corruption', 'network-abort', 'network-recover']);
export function createFaultServerControl() {
let state = cleanState();
@@ -29,7 +29,7 @@ export function createFaultServerControl() {
armed: true,
hit_count: 0,
retry_count: 0,
- retry_max: body.mode === 'network-abort' ? 3 : 1,
+ retry_max: body.mode === 'network-abort' || body.mode === 'network-recover' ? 3 : 1,
};
sendJSON(res, 200, state);
return true;
@@ -42,13 +42,21 @@ export function createFaultServerControl() {
return false;
}
const mode = state.mode;
- const retryCount = state.retry_count + 1;
+ const nextRetryCount = state.retry_count + 1;
+ const retryCount = Math.min(nextRetryCount, state.retry_max);
+ const recovering = mode === 'network-recover';
state = {
...state,
- armed: mode === 'network-abort' && retryCount < state.retry_max,
+ armed: mode === 'network-abort' || (recovering && retryCount < state.retry_max),
hit_count: state.hit_count + 1,
retry_count: retryCount,
};
+ if (recovering && retryCount >= state.retry_max) {
+ // The final request is served normally by the underlying asset host;
+ // the first two retryable responses prove that the same shard was
+ // retried without restarting the proof.
+ return false;
+ }
const raw = await readFile(filePath);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
@@ -60,7 +68,16 @@ export function createFaultServerControl() {
res.end(corrupted);
return true;
}
- if (retryCount >= state.retry_max) {
+ if (recovering) {
+ // Chromium may transparently replay an idempotent GET when a response
+ // body is cut mid-stream. A retryable HTTP response is observable at
+ // the Worker boundary and therefore proves the runtime retry path.
+ const message = Buffer.from('fault injection: transient range fetch failure\n');
+ res.writeHead(503, { 'Content-Length': message.length });
+ res.end(message);
+ return true;
+ }
+ if (mode === 'network-abort' && retryCount >= state.retry_max) {
const message = Buffer.from('fault injection: bounded range fetch retries exhausted\n');
res.writeHead(503, { 'Content-Length': message.length });
res.end(message);
diff --git a/experiments/wasm-prover/scripts/guarded-browser-benchmark.mjs b/experiments/wasm-prover/scripts/guarded-browser-benchmark.mjs
index d799db34..3e29a450 100644
--- a/experiments/wasm-prover/scripts/guarded-browser-benchmark.mjs
+++ b/experiments/wasm-prover/scripts/guarded-browser-benchmark.mjs
@@ -48,6 +48,7 @@ const defaults = {
optW8: null,
artifactOverridesFile: "",
artifactOverrides: null,
+ proverWorkerURL: "",
privateInputsFile: "",
privateInputs: null,
acceptRemoteHarnessPrivateInputExposure: false,
@@ -315,21 +316,73 @@ async function runBrowserBenchmark(
};
const flowStarted = performance.now();
const preparedStarted = performance.now();
- const prepared = await globalThis.preflightProofAssets(
- JSON.stringify({
- artifacts: req.artifacts,
- tuning: req.tuning,
- }),
- );
- const preparedMS = performance.now() - preparedStarted;
- const result = await globalThis.proveDestination(
- JSON.stringify(req),
- (progress) => {
- const stage = document.getElementById("stage");
- if (stage)
- stage.textContent = `${testCase.name}: ${progress.stage}`;
- },
- );
+ let prepared;
+ let preparedMS;
+ let result;
+ if (testCase.prover_worker_url) {
+ const worker = new Worker(testCase.prover_worker_url);
+ let messageID = 0;
+ const callWorker = (type, payload = {}) =>
+ new Promise((resolve, reject) => {
+ const id = ++messageID;
+ const cleanup = () => {
+ worker.removeEventListener("message", onMessage);
+ worker.removeEventListener("error", onError);
+ };
+ const onMessage = (event) => {
+ const message = event.data || {};
+ if (message.id !== id || message.type === "progress") return;
+ cleanup();
+ if (message.type === "error") {
+ reject(new Error(message.message || "prover worker failed"));
+ } else {
+ resolve(message.result ?? message);
+ }
+ };
+ const onError = (event) => {
+ cleanup();
+ reject(event.error || new Error(event.message || "prover worker crashed"));
+ };
+ worker.addEventListener("message", onMessage);
+ worker.addEventListener("error", onError);
+ worker.postMessage({ id, type, ...payload });
+ });
+ try {
+ await callWorker("init", {
+ wasmUrl: req.artifacts.proof_wasm_url,
+ wasmExecUrl: new URL("/proof-runtime/wasm_exec.js", location.href).href,
+ msmWorkerWasmUrl: req.artifacts.msm_worker_wasm_url,
+ gogc: testCase.gogc,
+ gomemlimit: testCase.gomemlimit,
+ });
+ prepared = await callWorker("preflight", {
+ requestJson: JSON.stringify({
+ artifacts: req.artifacts,
+ tuning: req.tuning,
+ }),
+ });
+ preparedMS = performance.now() - preparedStarted;
+ result = await callWorker("prove", { requestJson: JSON.stringify(req) });
+ } finally {
+ worker.terminate();
+ }
+ } else {
+ prepared = await globalThis.preflightProofAssets(
+ JSON.stringify({
+ artifacts: req.artifacts,
+ tuning: req.tuning,
+ }),
+ );
+ preparedMS = performance.now() - preparedStarted;
+ result = await globalThis.proveDestination(
+ JSON.stringify(req),
+ (progress) => {
+ const stage = document.getElementById("stage");
+ if (stage)
+ stage.textContent = `${testCase.name}: ${progress.stage}`;
+ },
+ );
+ }
const keyManifestRaw = await (
await fetch(req.artifacts.manifest_url)
).text();
@@ -404,6 +457,9 @@ async function runBrowserBenchmark(
name: options.caseName,
tuning: runtimeTuning,
artifacts: options.artifactOverrides,
+ prover_worker_url: options.proverWorkerURL,
+ gogc: options.gogc,
+ gomemlimit: options.gomemlimit,
},
);
@@ -569,6 +625,7 @@ function buildSummary({
chunk_readahead: options.chunkReadahead,
opt_w8: options.optW8,
cache_mode: options.cacheMode,
+ prover_worker_url: options.proverWorkerURL || "",
browser_profile_dir: options.browserProfileDir || "",
gogc: options.gogc,
gomemlimit: options.gomemlimit,
@@ -659,6 +716,11 @@ async function collectSample(phase, previousCPU, ownPids, options) {
const externalProcesses = processes
.filter((p) => !ownPids.has(p.pid))
.filter((p) => !isSamplerProcess(p))
+ // ps %CPU is a lifetime average, so a stopped process can retain a large
+ // historical value even though it consumes no benchmark resources. Keep
+ // active processes under the existing strict thresholds and exclude only
+ // stopped/traced and zombie states.
+ .filter((p) => !/^[TZ]/.test(p.stat))
.filter((p) => p.pcpu >= 1)
.sort((a, b) => b.pcpu - a.pcpu)
.slice(0, 10);
@@ -697,7 +759,7 @@ async function collectSample(phase, previousCPU, ownPids, options) {
function isSamplerProcess(processInfo) {
return (
processInfo.comm === "ps" &&
- processInfo.args === "ps -eo pid=,ppid=,pcpu=,comm=,args="
+ processInfo.args === "ps -eo pid=,ppid=,pcpu=,stat=,comm=,args="
);
}
@@ -766,21 +828,22 @@ async function descendantPIDs(rootPid) {
async function readProcesses() {
const stdout = await execFileText("ps", [
"-eo",
- "pid=,ppid=,pcpu=,comm=,args=",
+ "pid=,ppid=,pcpu=,stat=,comm=,args=",
]);
return stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
- const match = /^(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)\s*(.*)$/.exec(line);
+ const match = /^(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) return null;
return {
pid: Number(match[1]),
ppid: Number(match[2]),
pcpu: Number(match[3]),
- comm: match[4],
- args: match[5] || "",
+ stat: match[4],
+ comm: match[5],
+ args: match[6] || "",
};
})
.filter(Boolean);
@@ -982,6 +1045,9 @@ function parseArgs(args, base) {
case "--artifact-overrides":
options.artifactOverridesFile = path.resolve(nextValue());
break;
+ case "--prover-worker-url":
+ options.proverWorkerURL = nextValue();
+ break;
case "--accept-remote-harness-private-input-exposure":
options.acceptRemoteHarnessPrivateInputExposure = true;
break;
@@ -1182,6 +1248,7 @@ Options:
--chunk-readahead N Dispatch-order HTTP-cache warm lanes (0 disables, 1-4). Default: runtime default
--opt-w8 / --no-opt-w8 Toggle computeH FFT workers (opt_w8). Default: runtime default
--artifact-overrides FILE Public artifact URL overrides JSON.
+ --prover-worker-url URL Exercise the production outer worker protocol instead of direct Go calls.
--private-inputs-file FILE Local proof inputs injected into the harness page before navigation.
Loopback harnesses only, unless the exposure flag below is passed.
--accept-remote-harness-private-input-exposure
diff --git a/experiments/wasm-prover/scripts/recovery-perf-ab.mjs b/experiments/wasm-prover/scripts/recovery-perf-ab.mjs
new file mode 100644
index 00000000..43cd54bf
--- /dev/null
+++ b/experiments/wasm-prover/scripts/recovery-perf-ab.mjs
@@ -0,0 +1,295 @@
+// Counterbalanced A/B proving-time gate for the bounded shard-recovery change.
+//
+// The recovery work must not cost proving time. This harness runs the guarded
+// browser benchmark alternately against an unmodified "baseline" runtime and
+// the "candidate" runtime built from the same source with only the recovery
+// patch applied, then compares paired medians under a hard regression ceiling.
+//
+// Both runtimes are served from prebuilt asset trees rather than rebuilt here,
+// so a run measures only the runtime delta. Build them with the staging
+// scripts and place them at /{baseline,candidate}-assets. The tree holds
+// real per-role prover-worker.js / msmworker.wasm / proof-destination.wasm / worker.js /
+// chunk-manifest.{json,sig}; the heavy proving-key chunks are symlinks into
+// the shared release stage, so the two roles differ only by runtime.
+//
+// The fixtures directory carries a witness-bearing private-inputs file and is
+// therefore expected to live under the gitignored output/ tree. Never move it
+// into a tracked path.
+//
+// node experiments/wasm-prover/scripts/recovery-perf-ab.mjs cold
+// node experiments/wasm-prover/scripts/recovery-perf-ab.mjs warm
+//
+// Accepted samples are cached: rerunning resumes at the first role/repeat that
+// has no clean artifact, so a host-contamination abort never discards work
+// that already passed its guards.
+
+import { execFileSync, spawn } from "node:child_process";
+import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(scriptDir, "../../..");
+
+const mode = process.argv[2];
+if (mode !== "cold" && mode !== "warm") {
+ throw new Error("usage: node recovery-perf-ab.mjs cold|warm");
+}
+
+const abDir = path.resolve(
+ process.env.RECOVERY_AB_DIR || path.join(repo, "output/recovery-perf-ab"),
+);
+const fixtures = path.join(abDir, "fixtures");
+const outputDir = path.join(abDir, "bench", mode);
+mkdirSync(outputDir, { recursive: true });
+
+const roles = {
+ baseline: {
+ port: 8789,
+ assets: path.join(abDir, "baseline-assets"),
+ overrides: path.join(fixtures, "overrides-baseline.json"),
+ profile: path.join(abDir, "profile-baseline"),
+ },
+ candidate: {
+ port: 8788,
+ assets: path.join(abDir, "candidate-assets"),
+ overrides: path.join(fixtures, "overrides-candidate.json"),
+ profile: path.join(abDir, "profile-candidate"),
+ },
+};
+
+// Counterbalanced ABBA-style order: neither role systematically occupies the
+// warmest or coldest position in the session, so slow thermal drift cannot
+// masquerade as a runtime difference.
+const sequence = [
+ ["baseline", 1],
+ ["candidate", 1],
+ ["candidate", 2],
+ ["baseline", 2],
+ ["baseline", 3],
+ ["candidate", 3],
+];
+
+const acceptance = {
+ maximum_median_proving_regression_percent: 0.5,
+ maximum_median_heap_regression_percent: 1.0,
+};
+
+for (const [role, selected] of Object.entries(roles)) {
+ for (const required of ["chunk-manifest.json", "prover-worker.js", "msmworker.wasm", "proof-destination.wasm", "worker.js"]) {
+ const candidatePath = path.join(selected.assets, required);
+ if (!existsSync(candidatePath)) {
+ throw new Error(`${role} asset tree is missing ${required} (${candidatePath})`);
+ }
+ }
+ if (!existsSync(selected.overrides)) {
+ throw new Error(`${role} artifact overrides missing: ${selected.overrides}`);
+ }
+}
+const privateInputs = path.join(fixtures, "private-inputs.json");
+if (!existsSync(privateInputs)) {
+ throw new Error(`private inputs fixture missing: ${privateInputs}`);
+}
+
+// server.mjs refuses to guess GOROOT (it serves wasm_exec.js from the active
+// toolchain), so resolve it here rather than depending on the caller's shell.
+const goRoot =
+ process.env.GOROOT ||
+ execFileSync("go", ["env", "GOROOT"], { cwd: repo, encoding: "utf8" }).trim();
+if (!goRoot) throw new Error("unable to resolve GOROOT for the asset servers");
+
+const servers = [];
+function startServer(role) {
+ const selected = roles[role];
+ const child = spawn(process.execPath, ["experiments/wasm-prover/web/server.mjs"], {
+ cwd: repo,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ GOROOT: goRoot,
+ PORT: String(selected.port),
+ PROOF_CHUNK_ASSETS_DIR: selected.assets,
+ PROOF_KEY_BUNDLE_DIR: selected.assets,
+ },
+ });
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => process.stderr.write(`[${role}] ${chunk}`));
+ child.on("exit", (code, signal) => {
+ child.exitInfo = `exited ${code ?? signal}`;
+ });
+ servers.push(child);
+ return child;
+}
+
+async function waitForServer(role) {
+ const { port } = roles[role];
+ const child = roles[role].process;
+ for (let attempt = 0; attempt < 120; attempt += 1) {
+ // Fail fast when the server died on startup instead of burning the
+ // full readiness budget on a process that will never listen.
+ if (child?.exitInfo) {
+ throw new Error(`${role} server ${child.exitInfo} before becoming ready (see [${role}] output above)`);
+ }
+ try {
+ const response = await fetch(`http://127.0.0.1:${port}/`, { cache: "no-store" });
+ if (response.ok) return;
+ } catch {
+ // not listening yet
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`${role} server did not become ready on port ${port}`);
+}
+
+function stopServers() {
+ for (const child of servers) {
+ if (!child.killed) child.kill("SIGTERM");
+ }
+}
+process.on("exit", stopServers);
+for (const signal of ["SIGINT", "SIGTERM"]) {
+ process.on(signal, () => {
+ stopServers();
+ process.exit(130);
+ });
+}
+
+function acceptedArtifact(artifact, summary) {
+ return (
+ artifact?.verified_locally === true &&
+ artifact.benchmark_guard?.accepted === true &&
+ summary?.preflight?.ok === true &&
+ (summary.observed_transient_reasons || []).length === 0 &&
+ summary.contaminated === false &&
+ summary.aborted === false
+ );
+}
+
+function run(command, args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { cwd: repo, stdio: "inherit" });
+ child.on("error", reject);
+ child.on("exit", (code, signal) => {
+ if (code === 0) resolve();
+ else reject(new Error(`${args[2]} exited ${code ?? signal}`));
+ });
+ });
+}
+
+function median(values) {
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length / 2)];
+}
+
+for (const role of Object.keys(roles)) {
+ roles[role].process = startServer(role);
+}
+await Promise.all(Object.keys(roles).map((role) => waitForServer(role)));
+
+const runs = [];
+for (const [role, repeat] of sequence) {
+ const selected = roles[role];
+ const name = `recovery-${mode}-r${repeat}-${role}`;
+ const args = [
+ "experiments/wasm-prover/scripts/guarded-browser-benchmark.mjs",
+ "--case", name,
+ "--base-url", `http://127.0.0.1:${selected.port}/`,
+ "--output-dir", outputDir,
+ "--workers", "16",
+ "--shards", "16",
+ "--rf", "2",
+ "--chunk-prefetch-window", "2",
+ "--artifact-overrides", selected.overrides,
+ "--prover-worker-url", `http://127.0.0.1:${selected.port}/proof-assets/prover-worker.js`,
+ "--private-inputs-file", privateInputs,
+ "--browser-profile-dir", selected.profile,
+ "--cache-mode", mode,
+ "--preflight-seconds", "8",
+ "--sample-ms", "2000",
+ "--max-load-per-core", "0.35",
+ "--min-preflight-idle-percent", "85",
+ "--max-external-process-cpu-percent", "110",
+ "--max-external-total-cpu-percent", "250",
+ "--contamination-samples", "3",
+ "--gogc", "15",
+ "--gomemlimit", "3200MiB",
+ "--cpu-list", "16-31",
+ "--pinned-decode",
+ "--opt-w1", "--opt-w2", "--opt-w3", "--opt-w5", "--opt-w6", "--opt-w7",
+ ];
+ const artifactPath = path.join(outputDir, `${name}.json`);
+ const summaryPath = path.join(outputDir, `${name}.summary.json`);
+ let artifact;
+ let summary;
+ if (existsSync(artifactPath) && existsSync(summaryPath)) {
+ artifact = JSON.parse(readFileSync(artifactPath, "utf8"));
+ summary = JSON.parse(readFileSync(summaryPath, "utf8"));
+ }
+ if (acceptedArtifact(artifact, summary)) {
+ process.stdout.write(`[resume] reusing clean sample ${name} (${artifact.prove_ms} ms)\n`);
+ } else {
+ await run(process.execPath, args);
+ artifact = JSON.parse(readFileSync(artifactPath, "utf8"));
+ summary = JSON.parse(readFileSync(summaryPath, "utf8"));
+ }
+ if (!acceptedArtifact(artifact, summary)) {
+ throw new Error(`${name} did not pass its guarded acceptance checks`);
+ }
+ runs.push({
+ role,
+ repeat,
+ name,
+ prove_ms: artifact.prove_ms,
+ wall_seconds: artifact.wall_seconds,
+ peak_heap_gib: artifact.peak_heap_gib,
+ engine: artifact.engine,
+ worker_count: artifact.trace?.worker_count,
+ runtime_options: artifact.runtime_options,
+ contamination: summary.sample_summary,
+ });
+}
+
+stopServers();
+
+const baseline = runs.filter((entry) => entry.role === "baseline");
+const candidate = runs.filter((entry) => entry.role === "candidate");
+const baselineMedianMS = median(baseline.map((entry) => entry.prove_ms));
+const candidateMedianMS = median(candidate.map((entry) => entry.prove_ms));
+const baselineMedianHeap = median(baseline.map((entry) => entry.peak_heap_gib));
+const candidateMedianHeap = median(candidate.map((entry) => entry.peak_heap_gib));
+const provingRegressionPercent = (candidateMedianMS / baselineMedianMS - 1) * 100;
+const heapRegressionPercent = (candidateMedianHeap / baselineMedianHeap - 1) * 100;
+
+const report = {
+ schema: "proof-recovery-performance-ab-v1",
+ mode,
+ protocol: {
+ sequence,
+ cpu_list: "16-31",
+ note:
+ "Browser and runner pinned to CPUs 16-31. Per-process background CPU capped at 110%, " +
+ "aggregate background at 250%, and any transient threshold violation rejects the run.",
+ acceptance,
+ },
+ runs,
+ summary: {
+ baseline_median_prove_ms: baselineMedianMS,
+ candidate_median_prove_ms: candidateMedianMS,
+ proving_regression_percent: provingRegressionPercent,
+ baseline_median_peak_heap_gib: baselineMedianHeap,
+ candidate_median_peak_heap_gib: candidateMedianHeap,
+ heap_regression_percent: heapRegressionPercent,
+ accepted:
+ provingRegressionPercent <= acceptance.maximum_median_proving_regression_percent &&
+ heapRegressionPercent <= acceptance.maximum_median_heap_regression_percent,
+ },
+};
+
+writeFileSync(
+ path.join(outputDir, `recovery-${mode}-ab.json`),
+ `${JSON.stringify(report, null, 2)}\n`,
+);
+process.stdout.write(`${JSON.stringify(report.summary, null, 2)}\n`);
+if (!report.summary.accepted) process.exitCode = 4;
diff --git a/experiments/wasm-prover/tests/prover-worker-range-fallback.test.mjs b/experiments/wasm-prover/tests/prover-worker-range-fallback.test.mjs
new file mode 100644
index 00000000..52bc6f94
--- /dev/null
+++ b/experiments/wasm-prover/tests/prover-worker-range-fallback.test.mjs
@@ -0,0 +1,203 @@
+import assert from 'node:assert/strict';
+import { createHash, webcrypto } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const workerSource = await readFile(
+ new URL('../../../apps/ownership-proof-web/public/proof-runtime/prover-worker.js', import.meta.url),
+ 'utf8',
+);
+
+test('healthy proving-key 206 path adds no fallback requests', async () => {
+ const calls = [];
+ const healthy = new Response(new Uint8Array([2, 3]), { status: 206 });
+ const context = workerContext(async (input, init) => {
+ calls.push({ input: String(input), range: new Headers(init?.headers).get('range') });
+ return healthy;
+ });
+ installRequest(context, proofRequest('11'.repeat(32)));
+
+ const response = await context.self.fetch('https://pk.example/ownership.pk', {
+ headers: { Range: 'bytes=1-2' },
+ });
+ assert.equal(response, healthy);
+ assert.deepEqual(calls, [{ input: 'https://pk.example/ownership.pk', range: 'bytes=1-2' }]);
+});
+
+test('ignored Range switches to signed SHA-verified chunks and stays sticky', async () => {
+ const fixture = await signedFixture();
+ let pkRequests = 0;
+ let cancellations = 0;
+ let manifestRequests = 0;
+ const context = workerContext(async (input) => {
+ const url = String(input);
+ if (url === 'https://pk.example/ownership.pk') {
+ pkRequests += 1;
+ return {
+ status: 200,
+ body: { async cancel() { cancellations += 1; } },
+ };
+ }
+ if (url === fixture.manifestURL) {
+ manifestRequests += 1;
+ return new Response(fixture.manifestRaw, { status: 200 });
+ }
+ if (url === fixture.signatureURL) return new Response(fixture.signatureHex, { status: 200 });
+ const chunk = fixture.chunksByURL.get(url);
+ if (chunk) return new Response(chunk, { status: 200 });
+ throw new Error(`unexpected URL ${url}`);
+ });
+ installRequest(context, proofRequest(fixture.publicKeyHex));
+
+ const first = await context.self.fetch('https://pk.example/ownership.pk', {
+ headers: { Range: 'bytes=2-5' },
+ });
+ assert.equal(first.status, 206);
+ assert.equal(first.headers.get('content-range'), 'bytes 2-5/8');
+ assert.equal(Buffer.from(await first.arrayBuffer()).toString(), 'cdef');
+
+ const second = await context.self.fetch('https://pk.example/ownership.pk', {
+ headers: { Range: 'bytes=6-7' },
+ });
+ assert.equal(Buffer.from(await second.arrayBuffer()).toString(), 'gh');
+ assert.equal(pkRequests, 1, 'sticky fallback must not repeat the broken full-object request');
+ assert.equal(cancellations, 1, 'the ignored-Range body must be cancelled immediately');
+ assert.equal(manifestRequests, 1, 'the signed manifest is verified once per prover operation');
+});
+
+test('signed chunk fallback rejects corrupt bytes', async () => {
+ const fixture = await signedFixture();
+ const context = workerContext(async (input) => {
+ const url = String(input);
+ if (url === 'https://pk.example/ownership.pk') {
+ return { status: 200, body: { async cancel() {} } };
+ }
+ if (url === fixture.manifestURL) return new Response(fixture.manifestRaw, { status: 200 });
+ if (url === fixture.signatureURL) return new Response(fixture.signatureHex, { status: 200 });
+ if (url.endsWith('/chunk-0')) return new Response('xxxx', { status: 200 });
+ const chunk = fixture.chunksByURL.get(url);
+ if (chunk) return new Response(chunk, { status: 200 });
+ throw new Error(`unexpected URL ${url}`);
+ });
+ installRequest(context, proofRequest(fixture.publicKeyHex));
+
+ await assert.rejects(
+ context.self.fetch('https://pk.example/ownership.pk', { headers: { Range: 'bytes=0-1' } }),
+ /chunk 0 sha256 mismatch/,
+ );
+});
+
+test('signed chunk fallback rejects an untrusted manifest signature', async () => {
+ const fixture = await signedFixture();
+ const context = workerContext(async (input) => {
+ const url = String(input);
+ if (url === 'https://pk.example/ownership.pk') {
+ return { status: 200, body: { async cancel() {} } };
+ }
+ if (url === fixture.manifestURL) return new Response(fixture.manifestRaw, { status: 200 });
+ if (url === fixture.signatureURL) return new Response(fixture.signatureHex, { status: 200 });
+ throw new Error(`unexpected URL ${url}`);
+ });
+ installRequest(context, proofRequest('11'.repeat(32)));
+
+ await assert.rejects(
+ context.self.fetch('https://pk.example/ownership.pk', { headers: { Range: 'bytes=0-1' } }),
+ /signature verification failed/,
+ );
+});
+
+test('signed chunk fallback rejects a path that escapes its transport directory', async () => {
+ const fixture = await signedFixture({ paths: ['%2e%2e/chunk-0', 'chunk-1'] });
+ const context = workerContext(async (input) => {
+ const url = String(input);
+ if (url === 'https://pk.example/ownership.pk') {
+ return { status: 200, body: { async cancel() {} } };
+ }
+ if (url === fixture.manifestURL) return new Response(fixture.manifestRaw, { status: 200 });
+ if (url === fixture.signatureURL) return new Response(fixture.signatureHex, { status: 200 });
+ throw new Error(`unexpected URL ${url}`);
+ });
+ installRequest(context, proofRequest(fixture.publicKeyHex));
+
+ await assert.rejects(
+ context.self.fetch('https://pk.example/ownership.pk', { headers: { Range: 'bytes=0-1' } }),
+ /path escapes its base URL/,
+ );
+});
+
+function workerContext(fetchImpl) {
+ const self = {
+ fetch: fetchImpl,
+ location: { href: 'https://app.example/proof-runtime/prover-worker.js' },
+ postMessage() {},
+ };
+ const context = vm.createContext({
+ self,
+ crypto: webcrypto,
+ Headers,
+ Response,
+ TextDecoder,
+ Uint8Array,
+ URL,
+ WebAssembly,
+ setTimeout,
+ clearTimeout,
+ });
+ vm.runInContext(workerSource, context, { filename: 'prover-worker.js' });
+ return context;
+}
+
+function installRequest(context, request) {
+ context.__requestJSON = JSON.stringify(request);
+ vm.runInContext('activeRangeFallback = rangeFallbackContext(__requestJSON)', context);
+}
+
+function proofRequest(publicKeyHex) {
+ return {
+ master_xprv_hex: 'not-inspected-by-the-transport-adapter',
+ artifacts: {
+ pk_url: 'https://pk.example/ownership.pk',
+ chunk_manifest_url: 'https://app.example/assets/chunk-manifest.json',
+ chunk_manifest_sig_url: 'https://app.example/assets/chunk-manifest.sig',
+ chunk_manifest_public_key_hex: publicKeyHex,
+ },
+ };
+}
+
+async function signedFixture({ paths = ['chunk-0', 'chunk-1'] } = {}) {
+ const rawChunks = [Buffer.from('abcd'), Buffer.from('efgh')];
+ const chunks = rawChunks.map((raw, index) => ({
+ index,
+ offset: index * 4,
+ size: 4,
+ path: paths[index],
+ sha256: `sha256:${createHash('sha256').update(raw).digest('hex')}`,
+ blake2b256: `blake2b256:${'00'.repeat(32)}`,
+ }));
+ const manifest = {
+ schema: 'proof-tool-proof-assets-chunk-manifest-v1',
+ coherence: { proving_key_size: 8 },
+ transport: {
+ base_url: 'https://chunks.example/base/',
+ content_encoding: 'identity',
+ requires_https: true,
+ },
+ proving_key: { chunk_size: 4, chunks },
+ proving_key_index: { file_size: 8 },
+ };
+ const manifestRaw = new TextEncoder().encode(JSON.stringify(manifest));
+ const keyPair = await webcrypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
+ const publicKey = new Uint8Array(await webcrypto.subtle.exportKey('raw', keyPair.publicKey));
+ const signature = new Uint8Array(await webcrypto.subtle.sign({ name: 'Ed25519' }, keyPair.privateKey, manifestRaw));
+ const manifestURL = 'https://app.example/assets/chunk-manifest.json';
+ const signatureURL = 'https://app.example/assets/chunk-manifest.sig';
+ return {
+ manifestURL,
+ signatureURL,
+ manifestRaw,
+ publicKeyHex: Buffer.from(publicKey).toString('hex'),
+ signatureHex: Buffer.from(signature).toString('hex'),
+ chunksByURL: new Map(rawChunks.map((raw, index) => [new URL(paths[index], 'https://chunks.example/base/').href, raw])),
+ };
+}
diff --git a/experiments/wasm-prover/tests/runtime-infrastructure.test.mjs b/experiments/wasm-prover/tests/runtime-infrastructure.test.mjs
index eb5c708e..e092224a 100644
--- a/experiments/wasm-prover/tests/runtime-infrastructure.test.mjs
+++ b/experiments/wasm-prover/tests/runtime-infrastructure.test.mjs
@@ -800,6 +800,27 @@ test("every fault outcome rejects each reviewed false-pass shape", () => {
}),
false,
);
+ assert.equal(
+ workerKill.accept({ ...safe["worker-kill"], retry_count: 0 }),
+ false,
+ );
+ const networkRecover = faultCases.find((item) => item.id === "network-recover");
+ assert.equal(
+ networkRecover.accept({ ...safe["network-recover"], verified_locally: false }),
+ false,
+ );
+ assert.equal(
+ networkRecover.accept({ ...safe["network-recover"], retry_count: 0 }),
+ false,
+ );
+ assert.equal(
+ networkRecover.accept({ ...safe["network-recover"], chunk_retry_count: 0 }),
+ false,
+ );
+ assert.equal(
+ networkRecover.accept({ ...safe["network-recover"], runtime_retry_count: -1 }),
+ false,
+ );
const network = faultCases.find((item) => item.id === "network-abort");
assert.equal(
network.accept({ ...safe["network-abort"], partial_proof: true }),
@@ -973,8 +994,9 @@ test("persistence audit requires every source or an explicit unsupported reason"
function safeFaultOutcomes() {
return {
"worker-kill": {
- status: "failed-closed",
- error_class: "worker-terminated",
+ status: "recovered",
+ verified_locally: true,
+ retry_count: 1,
cpu_fallback: false,
cpu_fallback_state: "none",
hung: false,
@@ -989,6 +1011,18 @@ function safeFaultOutcomes() {
hung: false,
server_hit_count: 1,
},
+ "network-recover": {
+ status: "recovered",
+ verified_locally: true,
+ retry_count: 2,
+ retry_max: 3,
+ chunk_retry_count: 2,
+ runtime_retry_count: 0,
+ partial_proof: false,
+ cpu_fallback: false,
+ cpu_fallback_state: "none",
+ hung: false,
+ },
"network-abort": {
status: "failed-closed",
error_class: "range-fetch-aborted",
diff --git a/experiments/wasm-prover/tests/w7-worker-cache.test.mjs b/experiments/wasm-prover/tests/w7-worker-cache.test.mjs
index 0534c988..85068ca9 100644
--- a/experiments/wasm-prover/tests/w7-worker-cache.test.mjs
+++ b/experiments/wasm-prover/tests/w7-worker-cache.test.mjs
@@ -7,6 +7,13 @@ const workerSource = await readFile(
new URL("../web/worker.js", import.meta.url),
"utf8",
);
+const productionWorkerSource = await readFile(
+ new URL(
+ "../../../apps/ownership-proof-web/public/proof-runtime/msm-worker.js",
+ import.meta.url,
+ ),
+ "utf8",
+);
const legacyWorkerSource = await readFile(
new URL("./fixtures/legacy-worker-no-w7.js", import.meta.url),
"utf8",
@@ -17,14 +24,23 @@ function workerHarness(source = workerSource) {
let fetchCount = 0;
let activeFetches = 0;
let maxActiveFetches = 0;
+ let timerCount = 0;
let tick = 0;
+ const hostSetTimeout = setTimeout;
const context = vm.createContext({
+ ArrayBuffer,
+ Atomics,
+ Int32Array,
+ SharedArrayBuffer,
URL,
Uint8Array,
WebAssembly: {},
importScripts() {},
performance: { now: () => ++tick },
- setTimeout,
+ setTimeout(callback, delay, ...args) {
+ timerCount++;
+ return hostSetTimeout(callback, delay, ...args);
+ },
self: {
__msmengineVerifyChunkBytes(raw) {
if (raw[0] === 0) return "chunk sha256 mismatch";
@@ -36,18 +52,25 @@ function workerHarness(source = workerSource) {
fetchCount++;
activeFetches++;
maxActiveFetches = Math.max(maxActiveFetches, activeFetches);
- const queuedValue = queued.shift();
- if (!queuedValue) throw new Error("unexpected fetch");
- const bytes = await queuedValue;
- const copy = Uint8Array.from(bytes);
- activeFetches--;
- return {
- status: 200,
- headers: { get: () => "identity" },
- async arrayBuffer() {
- return copy.buffer;
- },
- };
+ try {
+ const queuedValue = queued.shift();
+ if (!queuedValue) throw new Error("unexpected fetch");
+ if (queuedValue.kind === "network-error") throw queuedValue.error;
+ if (queuedValue.kind === "response") {
+ return queuedValue.response;
+ }
+ const bytes = await queuedValue;
+ const copy = Uint8Array.from(bytes);
+ return {
+ status: 200,
+ headers: { get: () => "identity" },
+ async arrayBuffer() {
+ return copy.buffer;
+ },
+ };
+ } finally {
+ activeFetches--;
+ }
},
});
vm.runInContext(source, context, { filename: "worker.js" });
@@ -56,7 +79,28 @@ function workerHarness(source = workerSource) {
queue(bytes) {
queued.push(bytes);
},
+ queueNetworkError(error = new Error("network unavailable")) {
+ queued.push({ kind: "network-error", error });
+ },
+ queueHTTP(status, retryAfter = "") {
+ queued.push({
+ kind: "response",
+ response: {
+ status,
+ headers: {
+ get(name) {
+ return name === "retry-after" ? retryAfter : "identity";
+ },
+ },
+ body: { async cancel() {} },
+ async arrayBuffer() {
+ return new Uint8Array(0).buffer;
+ },
+ },
+ });
+ },
fetchCount: () => fetchCount,
+ timerCount: () => timerCount,
maxActiveFetches: () => maxActiveFetches,
cacheSize: () => vm.runInContext("verifiedChunkCache.size", context),
telemetry: (enabled = false) => {
@@ -100,6 +144,26 @@ function workerHarness(source = workerSource) {
};
return vm.runInContext("runSectionRange(testMessage)", context);
},
+ errorPayload: (code, retryable) => {
+ context.testCode = code;
+ context.testRetryable = retryable;
+ return vm.runInContext(
+ 'workerErrorPayload(workerTaskError(testCode, "test failure", testRetryable, 1234))',
+ context,
+ );
+ },
+ markProgress: (requestID, messageID, completed) => {
+ const progress = new SharedArrayBuffer(8);
+ const view = new Int32Array(progress);
+ Atomics.store(view, 0, requestID);
+ context.testProgressMessage = { id: messageID, progress };
+ context.testCompleted = completed;
+ vm.runInContext(
+ "markWorkerProgress(workerProgressState(testProgressMessage), testCompleted)",
+ context,
+ );
+ return [Atomics.load(view, 0), Atomics.load(view, 1)];
+ },
};
}
@@ -187,6 +251,8 @@ test("W7 reports fetched, hashed, and cache-hit bytes", async () => {
});
assert.equal(first.timings.cache_hits, 0);
assert.equal(first.timings.cache_misses, 1);
+ assert.equal(first.timings.fetch_attempts, 1);
+ assert.equal(harness.timerCount(), 0);
const second = await harness.fetchSection(plan);
assert.deepEqual({ ...second.bytes }, {
@@ -198,9 +264,58 @@ test("W7 reports fetched, hashed, and cache-hit bytes", async () => {
assert.equal(second.timings.hash_ms, 0);
assert.equal(second.timings.cache_hits, 1);
assert.equal(second.timings.cache_misses, 0);
+ assert.equal(second.timings.fetch_attempts, 0);
assert.equal(harness.fetchCount(), 1);
});
+test("candidate and production workers retry only the affected chunk", async () => {
+ for (const source of [workerSource, productionWorkerSource]) {
+ const harness = workerHarness(source);
+ const chunk = pinnedChunk();
+ harness.queueNetworkError();
+ harness.queue([2, 2, 3, 4]);
+
+ const result = await harness.fetchChunk(chunk, true);
+
+ assert.equal(result.attempts, 2);
+ assert.equal(harness.fetchCount(), 2);
+ assert.equal(harness.timerCount(), 1);
+ assert.equal(harness.cacheSize(), 1);
+ assert.equal(result.cacheHit, false);
+ }
+});
+
+test("a retryable HTTP response is retried, while a terminal status is not", async () => {
+ const recovered = workerHarness();
+ recovered.queueHTTP(503);
+ recovered.queue([2, 2, 3, 4]);
+ const result = await recovered.fetchChunk(pinnedChunk(), false);
+ assert.equal(result.attempts, 2);
+ assert.equal(recovered.fetchCount(), 2);
+ assert.equal(recovered.timerCount(), 1);
+
+ const terminal = workerHarness();
+ terminal.queueHTTP(404);
+ await assert.rejects(
+ terminal.fetchChunk(pinnedChunk(), false),
+ (error) => error?.workerCode === "chunk-fetch-http" && error?.retryable === false,
+ );
+ assert.equal(terminal.fetchCount(), 1);
+});
+
+test("persistent network failure remains bounded at the chunk retry budget", async () => {
+ const harness = workerHarness();
+ harness.queueNetworkError();
+ harness.queueNetworkError();
+ await assert.rejects(
+ harness.fetchChunk(pinnedChunk(), false),
+ (error) => error?.workerCode === "chunk-fetch-network" && error?.retryable === true,
+ );
+ assert.equal(harness.fetchCount(), 2);
+ assert.equal(harness.timerCount(), 1);
+ assert.equal(harness.cacheSize(), 0);
+});
+
test("candidate worker telemetry reports Go heap, optional JS heap, and verified W7 cache bytes", async () => {
const harness = workerHarness();
harness.setGoMemStats({
@@ -330,3 +445,31 @@ test("chunk prefetch window bounds concurrent verified requests", async () => {
assert.equal(result.timings.fetch_requests, 4);
assert.equal(result.timings.cache_misses, 4);
});
+
+test("worker failures carry structured retry authority", () => {
+ const harness = workerHarness();
+ const retryable = harness.errorPayload("chunk-fetch-network", true);
+ assert.deepEqual({ ...retryable }, {
+ message: "test failure",
+ code: "chunk-fetch-network",
+ retryable: true,
+ retryAfterMS: 1234,
+ });
+ const terminal = harness.errorPayload("chunk-integrity", false);
+ assert.equal(terminal.code, "chunk-integrity");
+ assert.equal(terminal.retryable, false);
+});
+
+test("a rejected fetch is a structured retryable network failure", async () => {
+ const harness = workerHarness();
+ await assert.rejects(
+ harness.fetchChunk(pinnedChunk(), false),
+ (error) => error?.workerCode === "chunk-fetch-network" && error?.retryable === true,
+ );
+});
+
+test("progress counter advances only for its current request generation", () => {
+ const harness = workerHarness();
+ assert.deepEqual(harness.markProgress(41, 42, 3), [41, 0]);
+ assert.deepEqual(harness.markProgress(42, 42, 3), [42, 3]);
+});
diff --git a/experiments/wasm-prover/web/worker.js b/experiments/wasm-prover/web/worker.js
index a9c2d7d1..7e6e9873 100644
--- a/experiments/wasm-prover/web/worker.js
+++ b/experiments/wasm-prover/web/worker.js
@@ -22,6 +22,115 @@ let readyPromise = null;
const TUNING_VALUE = /^[A-Za-z0-9.]+$/;
+function workerTaskError(code, message, retryable = false, retryAfterMS = 0) {
+ const error = new Error(message);
+ error.workerCode = code;
+ error.retryable = retryable === true;
+ error.retryAfterMS = Number.isSafeInteger(retryAfterMS) && retryAfterMS > 0 ? retryAfterMS : 0;
+ return error;
+}
+
+function workerErrorPayload(error) {
+ const message = String(error && error.message ? error.message : error);
+ const code = typeof error?.workerCode === 'string' ? error.workerCode : 'worker-compute';
+ return {
+ message,
+ code,
+ retryable: error?.retryable === true,
+ retryAfterMS: Number.isSafeInteger(error?.retryAfterMS) ? error.retryAfterMS : 0,
+ };
+}
+
+function retryAfterMilliseconds(response) {
+ const value = (response.headers.get('retry-after') || '').trim();
+ if (!/^\d+$/.test(value)) return 0;
+ const milliseconds = Number(value) * 1000;
+ return Number.isSafeInteger(milliseconds) && milliseconds <= 30_000 ? milliseconds : 0;
+}
+
+// Keep transient recovery below the shard boundary. A successful fetch does
+// not allocate a timer or issue an additional request; only an explicitly
+// retryable transport failure takes this path. The outer shard retry remains
+// available for a worker that terminates or stays unavailable.
+const CHUNK_FETCH_MAX_ATTEMPTS = 2;
+const CHUNK_RETRY_BASE_MS = 250;
+const CHUNK_RETRY_MAX_MS = 30_000;
+
+function chunkRetryDelayMilliseconds(chunk, attempt, retryAfterMS = 0) {
+ const shift = Math.min(Math.max(attempt - 1, 0), 3);
+ const base = Math.min(CHUNK_RETRY_MAX_MS, CHUNK_RETRY_BASE_MS * (2 ** shift));
+ const index = Number.isSafeInteger(chunk?.index) && chunk.index >= 0 ? chunk.index : 0;
+ // Deterministic jitter avoids synchronized retries without adding a random
+ // source or any work to successful requests.
+ const jitter = (index * 37 + attempt * 17) % 101;
+ return Math.min(CHUNK_RETRY_MAX_MS, Math.max(retryAfterMS, base + jitter));
+}
+
+async function retryChunkFetchOrThrow(error, chunk, attempt) {
+ if (error?.retryable !== true || attempt >= CHUNK_FETCH_MAX_ATTEMPTS) throw error;
+ const delay = chunkRetryDelayMilliseconds(chunk, attempt, error.retryAfterMS || 0);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+}
+
+async function fetchChunkAttempt(chunkURL, chunk) {
+ let response;
+ try {
+ response = await fetch(chunkURL, { cache: 'force-cache' });
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `fetch chunk ${chunk.index}: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+ if (response.status !== 200) {
+ const retryable = response.status === 408 || response.status === 425 ||
+ response.status === 429 || response.status >= 500;
+ const failure = workerTaskError(
+ 'chunk-fetch-http',
+ `fetch chunk ${chunk.index} returned status ${response.status}`,
+ retryable,
+ retryable ? retryAfterMilliseconds(response) : 0,
+ );
+ if (response.body && typeof response.body.cancel === 'function') {
+ try { await response.body.cancel(); } catch { /* best effort */ }
+ }
+ throw failure;
+ }
+ try {
+ return { response, raw: new Uint8Array(await response.arrayBuffer()) };
+ } catch (error) {
+ throw workerTaskError(
+ 'chunk-fetch-network',
+ `read chunk ${chunk.index} body: ${String(error && error.message ? error.message : error)}`,
+ true,
+ );
+ }
+}
+
+function workerProgressState(message) {
+ if (
+ !(message?.progress instanceof SharedArrayBuffer) ||
+ !Number.isSafeInteger(message.id)
+ ) {
+ return null;
+ }
+ const state = new Int32Array(message.progress);
+ return state.length >= 2 ? { state, requestID: message.id } : null;
+}
+
+function markWorkerProgress(progress, completedWindows) {
+ if (
+ !progress ||
+ !Number.isSafeInteger(completedWindows) ||
+ completedWindows <= 0 ||
+ Atomics.load(progress.state, 0) !== progress.requestID
+ ) {
+ return;
+ }
+ Atomics.store(progress.state, 1, completedWindows);
+}
+
function tuningFromLocation(name, fallback) {
try {
const raw = new URL(self.location.href).searchParams.get(name);
@@ -148,34 +257,40 @@ async function fetchVerifiedChunk(baseURL, chunk, optW7 = false) {
if (optW7) {
const cached = cachedVerifiedChunk(cacheKey, chunk);
if (cached) {
- return { raw: cached, fetchMS: 0, hashMS: 0, fetchedBytes: 0, cacheHit: true, cacheMiss: false };
+ return { raw: cached, fetchMS: 0, hashMS: 0, fetchedBytes: 0, cacheHit: true, cacheMiss: false, attempts: 0 };
}
}
const fetchStarted = performance.now();
- const response = await fetch(resolveChunkURL(baseURL, chunk.path), { cache: 'force-cache' });
- const raw = new Uint8Array(await response.arrayBuffer());
- const fetchMS = performance.now() - fetchStarted;
- if (response.status !== 200) {
- throw new Error(`fetch chunk ${chunk.index} returned status ${response.status}`);
+ const chunkURL = resolveChunkURL(baseURL, chunk.path);
+ let attempt = 1;
+ let fetched;
+ try {
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
+ } catch (error) {
+ await retryChunkFetchOrThrow(error, chunk, attempt);
+ attempt = 2;
+ fetched = await fetchChunkAttempt(chunkURL, chunk);
}
+ const { response, raw } = fetched;
+ const fetchMS = performance.now() - fetchStarted;
const encoding = (response.headers.get('content-encoding') || '').trim();
if (encoding && encoding !== 'identity') {
- throw new Error(`chunk ${chunk.index} content-encoding ${encoding}, want identity`);
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} content-encoding ${encoding}, want identity`);
}
if (raw.byteLength !== chunk.size) {
- throw new Error(`chunk ${chunk.index} size ${raw.byteLength}, want ${chunk.size}`);
+ throw workerTaskError('chunk-integrity', `chunk ${chunk.index} size ${raw.byteLength}, want ${chunk.size}`);
}
const hashStarted = performance.now();
const digestError = self.__msmengineVerifyChunkBytes(raw, chunk.sha256, chunk.blake2b256);
- if (digestError) throw new Error(digestError);
+ if (digestError) throw workerTaskError('chunk-integrity', digestError);
const hashMS = performance.now() - hashStarted;
// Verify-before-cache is the W7 security boundary. No error path above can
// populate the LRU, so corrupt bytes are fetched and rejected again.
if (optW7) insertVerifiedChunk(cacheKey, raw);
- return { raw, fetchMS, hashMS, fetchedBytes: raw.byteLength, cacheHit: false, cacheMiss: optW7 };
+ return { raw, fetchMS, hashMS, fetchedBytes: raw.byteLength, cacheHit: false, cacheMiss: optW7, attempts: attempt };
}
-async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = false, prefetchWindow = 2) {
+async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = false, prefetchWindow = 2, onProgress) {
if (!plan || typeof plan !== 'object') throw new Error('pk section plan is required');
const section = plan.sections && plan.sections[sectionName];
if (!section) throw new Error(`section ${sectionName} not found in pk section plan`);
@@ -203,6 +318,7 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
cache_hits: 0,
cache_misses: 0,
fetch_requests: 0,
+ fetch_attempts: 0,
w7_applied: optW7 ? 1 : 0,
};
const bytes = { fetched: 0, hashed: 0, cache_hit: 0, used: pointsRaw.byteLength };
@@ -223,11 +339,12 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
);
for (let index = 0; index < window.length; index += 1) {
const chunk = window[index];
- const { raw, fetchMS, hashMS, fetchedBytes, cacheHit, cacheMiss } = verified[index];
+ const { raw, fetchMS, hashMS, fetchedBytes, cacheHit, cacheMiss, attempts } = verified[index];
timings.fetch_ms += fetchMS;
timings.hash_ms += hashMS;
timings.cache_hits += cacheHit ? 1 : 0;
timings.cache_misses += cacheMiss ? 1 : 0;
+ timings.fetch_attempts += attempts || 0;
bytes.fetched += fetchedBytes;
bytes.hashed += cacheHit ? 0 : raw.byteLength;
bytes.cache_hit += cacheHit ? raw.byteLength : 0;
@@ -238,6 +355,7 @@ async function fetchSectionPointBytes(plan, sectionName, lo, hi, g2, optW7 = fal
pointsRaw.set(raw.subarray(useStart - chunkStart, useEnd - chunkStart), useStart - start);
timings.slice_ms += performance.now() - sliceStarted;
}
+ if (typeof onProgress === 'function') onProgress(Math.floor(offset / windowSize) + 1);
}
return { pointsRaw, timings, bytes };
}
@@ -268,6 +386,8 @@ function copyTimingFields(dst, src) {
async function runSectionRange(msg) {
const plan = typeof msg.pkPlan === 'string' ? JSON.parse(msg.pkPlan) : msg.pkPlan;
+ const progress = workerProgressState(msg);
+ let completedWindows = 0;
const { pointsRaw, timings, bytes } = await fetchSectionPointBytes(
plan,
msg.section,
@@ -276,7 +396,12 @@ async function runSectionRange(msg) {
msg.g2,
msg.optW7 === true,
msg.chunkPrefetchWindow,
+ (value) => {
+ completedWindows = value;
+ markWorkerProgress(progress, value);
+ },
);
+ markWorkerProgress(progress, completedWindows + 1);
const scsU8 = new Uint8Array(msg.scs);
const computeStarted = performance.now();
let partial;
@@ -342,6 +467,13 @@ self.onmessage = async (e) => {
// transferable — hand ownership to the main thread to avoid a copy.
self.postMessage({ id, partial, compute_ms: timings.compute_ms || computeMS, timings }, [partial.buffer]);
} catch (err) {
- self.postMessage({ id: msg && msg.id, error: String(err && err.message ? err.message : err) });
+ const failure = workerErrorPayload(err);
+ self.postMessage({
+ id: msg && msg.id,
+ error: failure.message,
+ error_code: failure.code,
+ retryable: failure.retryable,
+ retry_after_ms: failure.retryAfterMS,
+ });
}
};
diff --git a/formal/assurance/coverage-matrix.md b/formal/assurance/coverage-matrix.md
index fdb4820b..a3654302 100644
--- a/formal/assurance/coverage-matrix.md
+++ b/formal/assurance/coverage-matrix.md
@@ -66,7 +66,6 @@ so there is no V1 production or comparison surface to select accidentally.
| Surface | Classification | Evidence and treatment |
| --- | --- | --- |
| `Ownership.Verify` | Shared production logic | Not independently exported, but its BLS/Groth16 parser and verifier are reachable from the exact active ReclaimGlobalV2 artifact. Covered by `RG-5`, `RG-6`, cross-language vectors, and the trust report. Cryptographic knowledge soundness remains an explicit assumption. |
-| `Ownership.ReclaimGlobalMultiV2Bench` | Excluded benchmark | Exposed by the Cabal library but never imported by the exporter; used only by benchmarks/tests. |
| `bench/Bench.hs` and `bench/ProfileV4.hs` | Supporting evidence | Benchmark executables, not validators deployed by the application. Their capacity and differential results may support fuel/exunit boundaries but cannot replace formal theorems. |
| `export/VerifyDestinationProof.hs` | Supporting evidence | Off-chain executable that evaluates repository-backed proof material. It is not an on-chain entrypoint. |
| `test-support/ReclaimBaseOracle.hs` | Supporting oracle | Test-only typed oracle used to check the raw ReclaimBase rewrite. It is not deployed and cannot be the subject substituted for the compiled script. |
diff --git a/go.mod b/go.mod
index bd86f315..a341d7fb 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,13 @@
module proof-tool
-go 1.26.0
+go 1.26.5
require (
filippo.io/edwards25519 v1.2.0
github.com/consensys/gnark v0.15.0
github.com/consensys/gnark-crypto v0.20.1
+ github.com/drand/drand/v2 v2.1.6
+ github.com/klauspost/compress v1.19.0
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.0
@@ -15,9 +17,11 @@ require (
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/drand/kyber v1.3.2 // indirect
+ github.com/drand/kyber-bls12381 v0.3.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
- github.com/klauspost/compress v1.19.0 // indirect
+ github.com/kilic/bls12-381 v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -27,6 +31,7 @@ require (
github.com/rs/zerolog v1.34.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
+ go.dedis.ch/fixbuf v1.0.3 // indirect
golang.org/x/sync v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 4a4ce4b0..108aae32 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,13 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/consensys/gnark v0.15.0 h1:MwNpcGP2PawnGR3T9AnXDQS67aY22QTNb2Go8p/1gto=
github.com/consensys/gnark v0.15.0/go.mod h1:RIWXG9Gl+Ls2enSayeA/NdcM/FI3OOf6AqNdI2Jv8QU=
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
@@ -12,6 +16,13 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/drand/drand/v2 v2.1.6 h1:QpQ6FPy5JMPRTSFvD9HtqDRse/t9dQhDZj7hNMCjM6w=
+github.com/drand/drand/v2 v2.1.6/go.mod h1:uzfT/XfGMYyKOBnHWwSUFlYS4Ig1uSwNu11DXyAoaRw=
+github.com/drand/kyber v1.3.2 h1:Cf3NNcb5bV3eODopr3XVHzImjDK40GiObhFUFG93Zeo=
+github.com/drand/kyber v1.3.2/go.mod h1:ciDFWoC7ajb89niGJnS4C1Xeo4lSJMmbi+km5w8juAI=
+github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAebZQJxI=
+github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -19,6 +30,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
+github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4=
+github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -44,27 +57,36 @@ github.com/ronanh/intcomp v1.1.1/go.mod h1:7FOLy3P3Zj3er/kVrU/pl+Ql7JFZj7bwliMGk
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+go.dedis.ch/fixbuf v1.0.3 h1:hGcV9Cd/znUxlusJ64eAlExS+5cJDIyTyEG+otu5wQs=
+go.dedis.ch/fixbuf v1.0.3/go.mod h1:yzJMt34Wa5xD37V5RTdmp38cz3QhMagdGoem9anUalw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go
index 6dddc194..5cf20b9a 100644
--- a/internal/artifact/artifact.go
+++ b/internal/artifact/artifact.go
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"os"
+
+ "proof-tool/internal/strictjson"
)
const (
@@ -107,7 +109,7 @@ func readJSON(path string, value any) error {
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
- if err := json.Unmarshal(b, value); err != nil {
+ if err := strictjson.Unmarshal(b, value); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
return nil
diff --git a/internal/keybundle/keybundle.go b/internal/keybundle/keybundle.go
new file mode 100644
index 00000000..eef8ea8c
--- /dev/null
+++ b/internal/keybundle/keybundle.go
@@ -0,0 +1,269 @@
+// Package keybundle verifies signed native gnark proving/verifying-key bundles
+// and provides the Ed25519 key-loading primitives shared by ceremony tools.
+package keybundle
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+
+ "proof-tool/internal/artifact"
+ "proof-tool/internal/keyprofile"
+ "proof-tool/internal/strictjson"
+)
+
+const (
+ ManifestFile = "manifest.json"
+ ManifestSignatureFile = "manifest.sig"
+ ManifestPublicKeyFile = "manifest-public-key.hex"
+ maxManifestBytes = 2 << 20
+ maxSignatureHexBytes = ed25519.SignatureSize*2 + 2
+ maxPublicKeyHexBytes = ed25519.PublicKeySize*2 + 2
+ maxPrivateKeyHexBytes = ed25519.PrivateKeySize*2 + 2
+)
+
+// VerifyOptions defines the signed bundle identity and trust anchor expected by
+// Verify. PublicKeyHex must come from the caller's chosen trust channel.
+type VerifyOptions struct {
+ KeysDir string
+ KeyVersion string
+ PublicKeyHex string
+ ExpectedSignatureKeyID string
+ RequireProvingKey bool
+}
+
+// Verify checks the supported circuit profile, native PK/VK file pins,
+// signature-key identity, and Ed25519 signature over the exact manifest bytes.
+func Verify(opts VerifyOptions) (*artifact.KeyManifest, error) {
+ if strings.TrimSpace(opts.PublicKeyHex) == "" {
+ return nil, errors.New("trusted manifest public key is required")
+ }
+ manifestBytes, err := readBoundedRegular(
+ filepath.Join(opts.KeysDir, ManifestFile),
+ maxManifestBytes,
+ false,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("read manifest: %w", err)
+ }
+ if err := verifyManifestSignatureBytes(
+ manifestBytes,
+ filepath.Join(opts.KeysDir, ManifestSignatureFile),
+ opts.PublicKeyHex,
+ ); err != nil {
+ return nil, err
+ }
+ signedManifest, err := parseManifestBytes(manifestBytes)
+ if err != nil {
+ return nil, err
+ }
+ keyVersion := opts.KeyVersion
+ if strings.TrimSpace(keyVersion) == "" {
+ keyVersion = signedManifest.KeyVersion
+ }
+ profile, err := keyprofile.ForKeyVersion(keyVersion)
+ if err != nil {
+ return nil, err
+ }
+ status := profile.Inspect(opts.KeysDir, opts.RequireProvingKey)
+ if !status.Ready {
+ return nil, fmt.Errorf("key bundle is not ready: %s", status.Error)
+ }
+ if err := requireManifestMatch(signedManifest, status.Manifest); err != nil {
+ return nil, err
+ }
+ manifest := signedManifest
+ if opts.ExpectedSignatureKeyID != "" && manifest.SignatureKeyID != opts.ExpectedSignatureKeyID {
+ return nil, fmt.Errorf(
+ "manifest signature_key_id %q, want %q",
+ manifest.SignatureKeyID,
+ opts.ExpectedSignatureKeyID,
+ )
+ }
+ return manifest, nil
+}
+
+// VerifyManifestSignature verifies the detached Ed25519 signature over the
+// exact manifest bytes.
+func VerifyManifestSignature(manifestPath, signaturePath, publicKeyHex string) error {
+ manifestBytes, err := readBoundedRegular(manifestPath, maxManifestBytes, false)
+ if err != nil {
+ return fmt.Errorf("read manifest: %w", err)
+ }
+ return verifyManifestSignatureBytes(manifestBytes, signaturePath, publicKeyHex)
+}
+
+func verifyManifestSignatureBytes(manifestBytes []byte, signaturePath, publicKeyHex string) error {
+ signatureHex, err := readBoundedRegular(signaturePath, maxSignatureHexBytes, false)
+ if err != nil {
+ return fmt.Errorf("read manifest signature: %w", err)
+ }
+ signature, err := hex.DecodeString(strings.TrimSpace(string(signatureHex)))
+ if err != nil {
+ return fmt.Errorf("decode manifest signature hex: %w", err)
+ }
+ if len(signature) != ed25519.SignatureSize {
+ return fmt.Errorf("manifest signature is %d bytes, want %d", len(signature), ed25519.SignatureSize)
+ }
+ publicKey, err := DecodePublicKeyHex(publicKeyHex)
+ if err != nil {
+ return err
+ }
+ if !ed25519.Verify(publicKey, manifestBytes, signature) {
+ return errors.New("manifest signature verification failed")
+ }
+ return nil
+}
+
+func parseManifestBytes(data []byte) (*artifact.KeyManifest, error) {
+ var manifest artifact.KeyManifest
+ if err := strictjson.Unmarshal(data, &manifest); err != nil {
+ return nil, fmt.Errorf("parse manifest: %w", err)
+ }
+ if manifest.Schema != artifact.ManifestSchema {
+ return nil, fmt.Errorf(
+ "manifest schema %q, want %q",
+ manifest.Schema,
+ artifact.ManifestSchema,
+ )
+ }
+ return &manifest, nil
+}
+
+func requireManifestMatch(signed, inspected *artifact.KeyManifest) error {
+ if signed == nil || inspected == nil || !reflect.DeepEqual(*signed, *inspected) {
+ return errors.New("manifest changed after signature verification")
+ }
+ return nil
+}
+
+// ManifestPublicKeyForVerification resolves a caller-supplied public key or,
+// for local integrity checks, the copy bundled beside the manifest. The
+// returned bool is true only for a caller-supplied trust anchor.
+func ManifestPublicKeyForVerification(keysDir, publicKeyHex, publicKeyFile string) (string, bool, error) {
+ if publicKeyHex != "" && publicKeyFile != "" {
+ return "", false, errors.New("use only one of --manifest-public-key or --manifest-public-key-file")
+ }
+ if publicKeyHex != "" {
+ return strings.TrimSpace(publicKeyHex), true, nil
+ }
+ if publicKeyFile != "" {
+ value, err := readTrimmedFile(publicKeyFile)
+ return value, true, err
+ }
+ value, err := readTrimmedFile(filepath.Join(keysDir, ManifestPublicKeyFile))
+ return value, false, err
+}
+
+// LoadExistingPrivateKey loads an existing hex-encoded Ed25519 seed or private
+// key and derives its public key. It never creates or modifies any file.
+func LoadExistingPrivateKey(path string) (ed25519.PrivateKey, ed25519.PublicKey, error) {
+ if strings.TrimSpace(path) == "" {
+ return nil, nil, errors.New("signing key path is required")
+ }
+ rawHex, err := readBoundedRegular(path, maxPrivateKeyHexBytes, true)
+ if err != nil {
+ return nil, nil, fmt.Errorf("read signing key %s: %w", path, err)
+ }
+ privateKey, err := DecodePrivateKeyHex(strings.TrimSpace(string(rawHex)))
+ if err != nil {
+ return nil, nil, fmt.Errorf("read signing key %s: %w", path, err)
+ }
+ publicKey := privateKey.Public().(ed25519.PublicKey)
+ return privateKey, publicKey, nil
+}
+
+// DecodePrivateKeyHex accepts either a 32-byte Ed25519 seed or a 64-byte
+// Ed25519 private key.
+func DecodePrivateKeyHex(value string) (ed25519.PrivateKey, error) {
+ raw, err := hex.DecodeString(strings.TrimSpace(value))
+ if err != nil {
+ return nil, err
+ }
+ switch len(raw) {
+ case ed25519.SeedSize:
+ return ed25519.NewKeyFromSeed(raw), nil
+ case ed25519.PrivateKeySize:
+ derived := ed25519.NewKeyFromSeed(raw[:ed25519.SeedSize])
+ if !bytes.Equal(raw, derived) {
+ return nil, errors.New("Ed25519 private key public half does not match its seed")
+ }
+ return derived, nil
+ default:
+ return nil, fmt.Errorf(
+ "Ed25519 private key is %d bytes, want %d-byte seed or %d-byte private key",
+ len(raw),
+ ed25519.SeedSize,
+ ed25519.PrivateKeySize,
+ )
+ }
+}
+
+// DecodePublicKeyHex decodes the 32-byte Ed25519 public key used to authenticate
+// key manifests.
+func DecodePublicKeyHex(value string) (ed25519.PublicKey, error) {
+ raw, err := hex.DecodeString(strings.TrimSpace(value))
+ if err != nil {
+ return nil, fmt.Errorf("decode manifest public key hex: %w", err)
+ }
+ if len(raw) != ed25519.PublicKeySize {
+ return nil, fmt.Errorf("manifest public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize)
+ }
+ return ed25519.PublicKey(raw), nil
+}
+
+func readTrimmedFile(path string) (string, error) {
+ value, err := readBoundedRegular(path, maxPublicKeyHexBytes, false)
+ if err != nil {
+ return "", fmt.Errorf("read %s: %w", path, err)
+ }
+ return strings.TrimSpace(string(value)), nil
+}
+
+func readBoundedRegular(path string, maximum int64, secret bool) ([]byte, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, err
+ }
+ if !linkInfo.Mode().IsRegular() {
+ return nil, fmt.Errorf("%s is not a regular file", path)
+ }
+ if secret && runtime.GOOS != "windows" && linkInfo.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%s has group/world permission bits; require mode 0600 or stricter", path)
+ }
+ if linkInfo.Size() <= 0 || linkInfo.Size() > maximum {
+ return nil, fmt.Errorf("%s size %d is outside [1,%d]", path, linkInfo.Size(), maximum)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || !os.SameFile(linkInfo, info) {
+ return nil, fmt.Errorf("%s changed while being opened", path)
+ }
+ if info.Size() != linkInfo.Size() {
+ return nil, fmt.Errorf("%s changed size while being opened", path)
+ }
+ data := make([]byte, info.Size())
+ if _, err := io.ReadFull(file, data); err != nil {
+ return nil, err
+ }
+ var extra [1]byte
+ if n, err := file.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
+ return nil, fmt.Errorf("%s changed while being read", path)
+ }
+ return data, nil
+}
diff --git a/internal/keybundle/keybundle_test.go b/internal/keybundle/keybundle_test.go
new file mode 100644
index 00000000..a2f28b13
--- /dev/null
+++ b/internal/keybundle/keybundle_test.go
@@ -0,0 +1,127 @@
+package keybundle
+
+import (
+ "crypto/ed25519"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "proof-tool/internal/artifact"
+)
+
+func TestLoadExistingPrivateKeyDoesNotGenerate(t *testing.T) {
+ dir := t.TempDir()
+ missingPath := filepath.Join(dir, "missing.private.hex")
+ if _, _, err := LoadExistingPrivateKey(missingPath); err == nil {
+ t.Fatal("missing private key unexpectedly loaded")
+ }
+ if _, err := os.Stat(missingPath); !os.IsNotExist(err) {
+ t.Fatalf("missing key was created: %v", err)
+ }
+
+ seed := make([]byte, ed25519.SeedSize)
+ for i := range seed {
+ seed[i] = byte(i + 1)
+ }
+ keyPath := filepath.Join(dir, "existing.private.hex")
+ if err := os.WriteFile(keyPath, []byte(hex.EncodeToString(seed)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ privateKey, publicKey, err := LoadExistingPrivateKey(keyPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(privateKey) != ed25519.PrivateKeySize || len(publicKey) != ed25519.PublicKeySize {
+ t.Fatalf("key sizes = %d/%d", len(privateKey), len(publicKey))
+ }
+}
+
+func TestLoadExistingPrivateKeyRejectsSymlinkAndLoosePermissions(t *testing.T) {
+ dir := t.TempDir()
+ seed := make([]byte, ed25519.SeedSize)
+ target := filepath.Join(dir, "target.private.hex")
+ if err := os.WriteFile(target, []byte(hex.EncodeToString(seed)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(dir, "link.private.hex")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := LoadExistingPrivateKey(link); err == nil {
+ t.Fatal("symlinked private key was accepted")
+ }
+
+ if runtime.GOOS != "windows" {
+ loose := filepath.Join(dir, "loose.private.hex")
+ if err := os.WriteFile(loose, []byte(hex.EncodeToString(seed)+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := LoadExistingPrivateKey(loose); err == nil || !strings.Contains(err.Error(), "permission") {
+ t.Fatalf("loosely permissioned private key error = %v", err)
+ }
+ }
+}
+
+func TestLoadExistingPrivateKeyRejectsOversizedFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "oversized.private.hex")
+ if err := os.WriteFile(path, []byte(strings.Repeat("0", maxPrivateKeyHexBytes+1)), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := LoadExistingPrivateKey(path); err == nil || !strings.Contains(err.Error(), "outside") {
+ t.Fatalf("oversized private key error = %v", err)
+ }
+}
+
+func TestDecodePrivateKeyHexRejectsInconsistentPublicHalf(t *testing.T) {
+ privateKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
+ privateKey[ed25519.SeedSize] ^= 1
+ if _, err := DecodePrivateKeyHex(hex.EncodeToString(privateKey)); err == nil ||
+ !strings.Contains(err.Error(), "public half") {
+ t.Fatalf("inconsistent private key error = %v", err)
+ }
+}
+
+func TestVerifyManifestSignatureRejectsTampering(t *testing.T) {
+ dir := t.TempDir()
+ manifestPath := filepath.Join(dir, ManifestFile)
+ signaturePath := filepath.Join(dir, ManifestSignatureFile)
+ publicKey, privateKey, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest := []byte("{\"schema\":\"test\"}\n")
+ if err := os.WriteFile(manifestPath, manifest, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ signature := hex.EncodeToString(ed25519.Sign(privateKey, manifest)) + "\n"
+ if err := os.WriteFile(signaturePath, []byte(signature), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyManifestSignature(manifestPath, signaturePath, hex.EncodeToString(publicKey)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(manifestPath, []byte("{\"schema\":\"tampered\"}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ err = VerifyManifestSignature(manifestPath, signaturePath, hex.EncodeToString(publicKey))
+ if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
+ t.Fatalf("tampered manifest err = %v", err)
+ }
+}
+
+func TestRequireManifestMatchRejectsInspectedManifestMismatch(t *testing.T) {
+ signed := &artifact.KeyManifest{
+ Schema: artifact.ManifestSchema,
+ KeyVersion: "signed-version",
+ CircuitID: "signed-circuit",
+ }
+ inspected := *signed
+ inspected.CircuitID = "swapped-circuit"
+ if err := requireManifestMatch(signed, &inspected); err == nil ||
+ !strings.Contains(err.Error(), "changed after signature verification") {
+ t.Fatalf("manifest mismatch error = %v", err)
+ }
+}
diff --git a/internal/keyprofile/profile.go b/internal/keyprofile/profile.go
new file mode 100644
index 00000000..03cc316b
--- /dev/null
+++ b/internal/keyprofile/profile.go
@@ -0,0 +1,75 @@
+// Package keyprofile defines the proof-circuit profiles accepted by key
+// ceremony and bundle tooling.
+package keyprofile
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/consensys/gnark/constraint"
+
+ "proof-tool/internal/artifact"
+ "proof-tool/internal/circuit/ownership"
+ "proof-tool/internal/circuit/ownershipdest"
+ "proof-tool/internal/prover"
+)
+
+// Profile binds a key version to the exact circuit identity and the prover
+// operations used to compile and inspect its native gnark key bundle.
+type Profile struct {
+ KeyVersion string
+ CircuitID string
+ Label string
+ DefaultKeysDir string
+ Compile func() (constraint.ConstraintSystem, error)
+ Inspect func(string, bool) prover.BundleStatus
+ LoadVerifier func(string) (*prover.OwnershipBundle, error)
+}
+
+// ForBundle resolves the profile explicitly when keyVersion is supplied, or
+// from the bundle's manifest when it is omitted.
+func ForBundle(keysDir, keyVersion string) (Profile, error) {
+ if strings.TrimSpace(keyVersion) != "" {
+ return ForKeyVersion(keyVersion)
+ }
+ manifest, err := artifact.ReadKeyManifest(filepath.Join(keysDir, "manifest.json"))
+ if err != nil {
+ return Profile{}, err
+ }
+ return ForKeyVersion(manifest.KeyVersion)
+}
+
+// ForKeyVersion returns the circuit profile for a supported production key
+// identity. Legacy destination key versions are deliberately rejected.
+func ForKeyVersion(keyVersion string) (Profile, error) {
+ switch strings.TrimSpace(keyVersion) {
+ case prover.DefaultKeyVersion:
+ return Profile{
+ KeyVersion: prover.DefaultKeyVersion,
+ CircuitID: ownership.CircuitID,
+ Label: "ownership",
+ DefaultKeysDir: prover.DefaultKeyDir(),
+ Compile: prover.CompileOwnership,
+ Inspect: prover.InspectOwnershipBundle,
+ LoadVerifier: prover.LoadOwnershipVerifier,
+ }, nil
+ case prover.DefaultDestinationKeyVersion:
+ return Profile{
+ KeyVersion: prover.DefaultDestinationKeyVersion,
+ CircuitID: ownershipdest.CircuitID,
+ Label: "ownership destination",
+ DefaultKeysDir: prover.DefaultDestinationKeyDir(),
+ Compile: prover.CompileOwnershipDestination,
+ Inspect: prover.InspectOwnershipDestinationBundle,
+ LoadVerifier: prover.LoadOwnershipDestinationVerifier,
+ }, nil
+ default:
+ return Profile{}, fmt.Errorf(
+ "unsupported key version %q; expected %q or %q",
+ keyVersion,
+ prover.DefaultKeyVersion,
+ prover.DefaultDestinationKeyVersion,
+ )
+ }
+}
diff --git a/internal/keyprofile/profile_test.go b/internal/keyprofile/profile_test.go
new file mode 100644
index 00000000..cc1f5763
--- /dev/null
+++ b/internal/keyprofile/profile_test.go
@@ -0,0 +1,35 @@
+package keyprofile
+
+import (
+ "strings"
+ "testing"
+
+ "proof-tool/internal/circuit/ownership"
+ "proof-tool/internal/circuit/ownershipdest"
+ "proof-tool/internal/prover"
+)
+
+func TestForKeyVersion(t *testing.T) {
+ ownershipProfile, err := ForKeyVersion(prover.DefaultKeyVersion)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ownershipProfile.CircuitID != ownership.CircuitID {
+ t.Fatalf("ownership circuit id = %q", ownershipProfile.CircuitID)
+ }
+
+ destinationProfile, err := ForKeyVersion(prover.DefaultDestinationKeyVersion)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if destinationProfile.CircuitID != ownershipdest.CircuitID {
+ t.Fatalf("destination circuit id = %q", destinationProfile.CircuitID)
+ }
+ if destinationProfile.KeyVersion != "ownership-destination-v2" {
+ t.Fatalf("destination key version = %q", destinationProfile.KeyVersion)
+ }
+
+ if _, err := ForKeyVersion("ownership-destination-v1"); err == nil || !strings.Contains(err.Error(), "unsupported key version") {
+ t.Fatalf("legacy key version err = %v", err)
+ }
+}
diff --git a/internal/mpcceremony/adversarial_test.go b/internal/mpcceremony/adversarial_test.go
new file mode 100644
index 00000000..72a2f714
--- /dev/null
+++ b/internal/mpcceremony/adversarial_test.go
@@ -0,0 +1,1599 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ cs "github.com/consensys/gnark/constraint/bls12-381"
+ "github.com/consensys/gnark/frontend"
+ "github.com/consensys/gnark/frontend/cs/r1cs"
+)
+
+const adversarialTinyDomain = uint64(8)
+
+type adversarialCommittedCircuit struct {
+ Public frontend.Variable `gnark:",public"`
+ Secret frontend.Variable
+}
+
+func (c *adversarialCommittedCircuit) Define(api frontend.API) error {
+ committer, ok := api.(frontend.Committer)
+ if !ok {
+ return errors.New("compiler does not implement frontend.Committer")
+ }
+ commitment, err := committer.Commit(c.Secret)
+ if err != nil {
+ return err
+ }
+ api.AssertIsDifferent(commitment, 0)
+ api.AssertIsEqual(c.Public, c.Secret)
+ return nil
+}
+
+func adversarialCompileCommitted(t testing.TB) *cs.R1CS {
+ t.Helper()
+ compiled, err := frontend.Compile(
+ ecc.BLS12_381.ScalarField(),
+ r1cs.NewBuilder,
+ &adversarialCommittedCircuit{},
+ )
+ if err != nil {
+ t.Fatalf("compile committed test circuit: %v", err)
+ }
+ native, ok := compiled.(*cs.R1CS)
+ if !ok {
+ t.Fatalf("compiled circuit type = %T, want *bls12-381.R1CS", compiled)
+ }
+ return native
+}
+
+func adversarialSerialize(t testing.TB, value io.WriterTo) []byte {
+ t.Helper()
+ var encoded bytes.Buffer
+ if _, err := value.WriteTo(&encoded); err != nil {
+ t.Fatalf("serialize %T: %v", value, err)
+ }
+ return encoded.Bytes()
+}
+
+func adversarialPhase1Contribution(t testing.TB) *gnarkmpc.Phase1 {
+ t.Helper()
+ contribution, err := ContributePhase1(adversarialTinyDomain, nil)
+ if err != nil {
+ t.Fatalf("create Phase 1 contribution: %v", err)
+ }
+ return contribution
+}
+
+func adversarialPhase2Contribution(t testing.TB) (*gnarkmpc.Phase2, Phase2Shape) {
+ t.Helper()
+ phase1 := adversarialPhase1Contribution(t)
+ commons, err := SealPhase1(adversarialTinyDomain, bytes.Repeat([]byte{0x41}, 32), []*gnarkmpc.Phase1{phase1})
+ if err != nil {
+ t.Fatalf("seal Phase 1: %v", err)
+ }
+ var phase2 gnarkmpc.Phase2
+ _ = phase2.Initialize(adversarialCompileCommitted(t), commons)
+ phase2.Contribute()
+ shape, err := DerivePhase2Shape(&phase2)
+ if err != nil {
+ t.Fatalf("derive Phase 2 shape: %v", err)
+ }
+ return &phase2, shape
+}
+
+func adversarialWriteRaw(t *testing.T, path string, raw []byte) {
+ t.Helper()
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatalf("write raw artifact: %v", err)
+ }
+}
+
+func adversarialPrivateKey(fill byte) ed25519.PrivateKey {
+ return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{fill}, ed25519.SeedSize))
+}
+
+func adversarialAttestation(t *testing.T) ContributionAttestation {
+ t.Helper()
+ attestation, err := NewContributionAttestation(ContributionAttestation{
+ CeremonyID: "sha256:" + strings.Repeat("11", 32),
+ Phase: Phase1,
+ PhaseID: "sha256:" + strings.Repeat("22", 32),
+ Index: 1,
+ ParticipantID: "participant-01",
+ ParticipantKeyID: "participant-key-01",
+ PreviousPayload: ArtifactRef{
+ Name: "phase1/genesis.bin",
+ Digest: NewDigest([]byte("genesis")),
+ },
+ OutputPayload: ArtifactRef{
+ Name: "phase1/contribution-01.bin",
+ Digest: NewDigest([]byte("contribution")),
+ },
+ PreviousAcceptanceID: "sha256:" + strings.Repeat("33", 32),
+ ToolBinary: NewDigest([]byte("tool binary")),
+ SourceCommit: strings.Repeat("44", 20),
+ GnarkVersion: GnarkVersion,
+ GnarkCryptoVersion: GnarkCryptoVersion,
+ DrandVersion: DrandVersion,
+ Environment: ContributionEnvironment{
+ OS: "linux",
+ Architecture: "amd64",
+ EntropySource: "operating-system-csprng",
+ SwapDisabled: true,
+ CrashDumpsDisabled: true,
+ TelemetryDisabled: true,
+ EphemeralEnvironment: true,
+ EphemeralDestructionRequired: true,
+ },
+ ContributedAt: "2026-07-23T12:00:00Z",
+ })
+ if err != nil {
+ t.Fatalf("create contribution attestation: %v", err)
+ }
+ return attestation
+}
+
+func adversarialIdentity(t *testing.T, id string, fill byte) Identity {
+ t.Helper()
+ privateKey := adversarialPrivateKey(fill)
+ identity, err := NewIdentity(
+ id,
+ "Test "+id,
+ id+"-key",
+ privateKey.Public().(ed25519.PublicKey),
+ )
+ if err != nil {
+ t.Fatalf("create identity %q: %v", id, err)
+ }
+ return identity
+}
+
+func adversarialErasure(
+ t *testing.T,
+ contribution ContributionAttestation,
+ destroyedAt string,
+) ErasureAttestation {
+ t.Helper()
+ erasure, err := NewErasureAttestation(ErasureAttestation{
+ CeremonyID: contribution.CeremonyID,
+ Phase: contribution.Phase,
+ PhaseID: contribution.PhaseID,
+ Index: contribution.Index,
+ ParticipantID: contribution.ParticipantID,
+ ParticipantKeyID: contribution.ParticipantKeyID,
+ ContributionAttestationID: contribution.AttestationID,
+ OutputPayload: contribution.OutputPayload,
+ DestroyedAt: destroyedAt,
+ ProcessTerminated: true,
+ EphemeralStorageDestroyed: true,
+ NoBackupRetained: true,
+ })
+ if err != nil {
+ t.Fatalf("create erasure attestation: %v", err)
+ }
+ return erasure
+}
+
+func adversarialDefinition(t *testing.T) CeremonyDefinition {
+ t.Helper()
+ coordinator := adversarialIdentity(t, "coordinator", 0x01)
+ releaseSigner := adversarialIdentity(t, "release-signer", 0x02)
+ auditors := []Identity{
+ adversarialIdentity(t, "auditor-01", 0x03),
+ adversarialIdentity(t, "auditor-02", 0x04),
+ }
+ participants := []Participant{
+ {Identity: adversarialIdentity(t, "participant-01", 0x11)},
+ {Identity: adversarialIdentity(t, "participant-02", 0x12)},
+ {Identity: adversarialIdentity(t, "participant-03", 0x13)},
+ }
+ definition, err := NewCeremonyDefinition(DefinitionOptions{
+ Mode: ModeProduction,
+ 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,
+ InternalVariables: 3,
+ SecretVariables: 2,
+ PublicVariables: 1,
+ DomainSize: 8,
+ Phase2Shape: Phase2Shape{
+ Commitments: 1,
+ PKK: 1,
+ Z: 7,
+ SigmaCKK: []uint32{1},
+ ChallengeLength: 0,
+ },
+ },
+ Software: SoftwareBinding{
+ ProofToolVersion: "0.1.0",
+ GnarkVersion: GnarkVersion,
+ GnarkCryptoVersion: GnarkCryptoVersion,
+ DrandVersion: DrandVersion,
+ GoVersion: ProductionGoVersion,
+ GoOS: ProductionGOOS,
+ GoArch: ProductionGOARCH,
+ GoAMD64: ProductionGOAMD64,
+ Compiler: ProductionCompiler,
+ BuildMode: ProductionBuildMode,
+ CGOEnabled: false,
+ TrimPath: true,
+ SourceCommit: strings.Repeat("6b", 20),
+ SourceDirty: false,
+ ToolBinary: NewDigest([]byte("mpc-ceremony binary")),
+ },
+ Coordinator: coordinator,
+ ReleaseSigner: releaseSigner,
+ Auditors: auditors,
+ Roster: participants,
+ Phase1Policy: PhasePolicy{
+ Participants: []string{"participant-01", "participant-02", "participant-03"},
+ Minimum: 3,
+ },
+ Phase2Policy: PhasePolicy{
+ Participants: []string{"participant-01", "participant-02", "participant-03"},
+ Minimum: 3,
+ },
+ BeaconPolicy: BeaconPolicy{
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ ChainHashHex: BeaconQuicknetChainHash,
+ PublicKeyHex: BeaconQuicknetPublicKey,
+ Scheme: BeaconQuicknetScheme,
+ GenesisTimeUnix: BeaconQuicknetGenesis,
+ PeriodSeconds: BeaconQuicknetPeriod,
+ Extraction: BeaconExtractionV1,
+ MinimumChallengeBytes: 32,
+ MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds,
+ FutureRoundRequired: true,
+ },
+ Phase1Genesis: ArtifactRef{
+ Name: "phase1/genesis.bin",
+ Digest: NewDigest([]byte("phase1 genesis")),
+ },
+ })
+ if err != nil {
+ t.Fatalf("create ceremony definition: %v", err)
+ }
+ return definition
+}
+
+func adversarialCandidate(t *testing.T, definition CeremonyDefinition) CandidateMetadata {
+ t.Helper()
+ ref := func(name, value string) ArtifactRef {
+ return ArtifactRef{Name: name, Digest: NewDigest([]byte(value))}
+ }
+ phase1 := PhaseSummary{
+ Phase: Phase1,
+ PhaseID: NewDigest([]byte("phase1 id")).SHA256,
+ Genesis: definition.Phase1Genesis,
+ Chain: ref("phase1-chain.json", "phase1 chain"),
+ ChainHeadID: NewDigest([]byte("phase1 head")).SHA256,
+ ContributionCount: 3,
+ Participants: []string{"participant-01", "participant-02", "participant-03"},
+ CloseID: NewDigest([]byte("phase1 close")).SHA256,
+ BeaconID: NewDigest([]byte("phase1 beacon")).SHA256,
+ SealID: NewDigest([]byte("phase1 seal")).SHA256,
+ Outputs: []ArtifactRef{ref("commons.bin", "commons")},
+ }
+ provingKey := ref(NativeProvingKeyFile, "proving key")
+ verifyingKey := ref(NativeVerifyingKeyFile, "verifying key")
+ phase2 := PhaseSummary{
+ Phase: Phase2,
+ PhaseID: NewDigest([]byte("phase2 id")).SHA256,
+ Genesis: ref("phase2-genesis.bin", "phase2 genesis"),
+ Chain: ref("phase2-chain.json", "phase2 chain"),
+ ChainHeadID: NewDigest([]byte("phase2 head")).SHA256,
+ ContributionCount: 3,
+ Participants: []string{"participant-01", "participant-02", "participant-03"},
+ CloseID: NewDigest([]byte("phase2 close")).SHA256,
+ BeaconID: NewDigest([]byte("phase2 beacon")).SHA256,
+ SealID: NewDigest([]byte("phase2 seal")).SHA256,
+ Outputs: []ArtifactRef{provingKey, verifyingKey},
+ }
+ candidate, err := NewCandidateMetadata(CandidateMetadata{
+ CeremonyID: definition.CeremonyID,
+ Definition: ref("ceremony.json", "definition"),
+ Circuit: definition.Circuit,
+ Phase1: phase1,
+ Phase2: phase2,
+ ConstraintSystem: definition.Circuit.R1CS,
+ ProvingKey: provingKey,
+ VerifyingKey: verifyingKey,
+ CardanoVerifyingKey: ref(CardanoVKBytesFile, "cardano vk"),
+ CardanoVKHex: ref(CardanoVKHexFile, "cardano vk hex"),
+ CardanoVKFormat: ref(CardanoVKFormatFile, "cardano vk format"),
+ VerificationReport: ref(VerificationReportFile, "verification report"),
+ PublicEvidence: ref(PublicEvidenceFile, "public finalization evidence"),
+ Phase2SealRecord: ref(Phase2SealFile, "phase2 seal record"),
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ FinalizedAt: "2026-07-23T13:00:00Z",
+ })
+ if err != nil {
+ t.Fatalf("create release candidate fixture: %v", err)
+ }
+ return candidate
+}
+
+func adversarialSignedAudit(
+ t *testing.T,
+ definition CeremonyDefinition,
+ candidate CandidateMetadata,
+ auditorIndex int,
+ auditedAt string,
+ outputs []ArtifactRef,
+) AuditArtifact {
+ t.Helper()
+ candidateBytes, err := MarshalCanonical(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replayRoot, err := replayRootSHA256(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ auditor := definition.Auditors[auditorIndex]
+ record, err := NewAuditRecord(AuditRecord{
+ CeremonyID: definition.CeremonyID,
+ AuditorID: auditor.ID,
+ AuditorKeyID: auditor.KeyID,
+ Definition: candidate.Definition,
+ Phase1Chain: candidate.Phase1.Chain,
+ Phase2Chain: candidate.Phase2.Chain,
+ Phase1SealID: candidate.Phase1.SealID,
+ Phase2SealID: candidate.Phase2.SealID,
+ ReplayRootSHA256: replayRoot,
+ Outputs: outputs,
+ Passed: true,
+ Findings: []string{},
+ AuditedAt: auditedAt,
+ })
+ if err != nil {
+ t.Fatalf("create audit record: %v", err)
+ }
+ recordBytes, signatureBytes, err := SignRecord(
+ record,
+ auditor.KeyID,
+ adversarialPrivateKey(byte(0x03+auditorIndex)),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ recordPath := filepath.Join(dir, "audit-"+string(rune('1'+auditorIndex))+".json")
+ signaturePath := filepath.Join(dir, "audit-"+string(rune('1'+auditorIndex))+".sig")
+ adversarialWriteRaw(t, recordPath, recordBytes)
+ adversarialWriteRaw(t, signaturePath, signatureBytes)
+
+ expectedCandidateRef := ArtifactRef{Name: CandidateMetadataFile, Digest: NewDigest(candidateBytes)}
+ if !slices.Contains(outputs, expectedCandidateRef) {
+ t.Fatal("audit fixture does not bind candidate metadata")
+ }
+ return AuditArtifact{RecordPath: recordPath, SignaturePath: signaturePath}
+}
+
+func TestCeremonyDefinitionRejectsMetadataDrift(t *testing.T) {
+ valid := adversarialDefinition(t)
+ if err := valid.Validate(); err != nil {
+ t.Fatalf("valid definition rejected: %v", err)
+ }
+
+ cases := []struct {
+ name string
+ mutate func(*CeremonyDefinition)
+ }{
+ {name: "wrong curve", mutate: func(d *CeremonyDefinition) { d.Circuit.Curve = "BN254" }},
+ {name: "wrong backend", mutate: func(d *CeremonyDefinition) { d.Circuit.Backend = "plonk" }},
+ {name: "wrong circuit", mutate: func(d *CeremonyDefinition) { d.Circuit.CircuitID = "other-circuit" }},
+ {name: "wrong key version", mutate: func(d *CeremonyDefinition) { d.Circuit.KeyVersion = "other-key" }},
+ {name: "wrong domain", mutate: func(d *CeremonyDefinition) { d.Circuit.DomainSize = 16 }},
+ {name: "wrong R1CS digest", mutate: func(d *CeremonyDefinition) {
+ d.Circuit.R1CS.Digest = NewDigest([]byte("different r1cs"))
+ }},
+ {name: "wrong gnark", mutate: func(d *CeremonyDefinition) { d.Software.GnarkVersion = "v0.14.0" }},
+ {name: "wrong gnark crypto", mutate: func(d *CeremonyDefinition) {
+ d.Software.GnarkCryptoVersion = "v0.19.0"
+ }},
+ {name: "wrong drand", mutate: func(d *CeremonyDefinition) {
+ d.Software.DrandVersion = "v2.1.5"
+ }},
+ {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"
+ }},
+ {name: "wrong target OS", mutate: func(d *CeremonyDefinition) {
+ d.Software.GoOS = "darwin"
+ }},
+ {name: "wrong target architecture", mutate: func(d *CeremonyDefinition) {
+ d.Software.GoArch = "arm64"
+ }},
+ {name: "wrong amd64 level", mutate: func(d *CeremonyDefinition) {
+ d.Software.GoAMD64 = "v3"
+ }},
+ {name: "wrong compiler", mutate: func(d *CeremonyDefinition) {
+ d.Software.Compiler = "gccgo"
+ }},
+ {name: "wrong build mode", mutate: func(d *CeremonyDefinition) {
+ d.Software.BuildMode = "pie"
+ }},
+ {name: "CGO enabled", mutate: func(d *CeremonyDefinition) {
+ d.Software.CGOEnabled = true
+ }},
+ {name: "trimpath disabled", mutate: func(d *CeremonyDefinition) {
+ d.Software.TrimPath = false
+ }},
+ {name: "different tool binary", mutate: func(d *CeremonyDefinition) {
+ d.Software.ToolBinary = NewDigest([]byte("different binary"))
+ }},
+ {name: "duplicate participant", mutate: func(d *CeremonyDefinition) {
+ d.Roster[1] = d.Roster[0]
+ }},
+ {name: "below phase threshold", mutate: func(d *CeremonyDefinition) {
+ d.Phase1Policy.Minimum = 0
+ }},
+ {name: "beacon policy weakened", mutate: func(d *CeremonyDefinition) {
+ d.BeaconPolicy.FutureRoundRequired = false
+ }},
+ {name: "beacon witness lead weakened", mutate: func(d *CeremonyDefinition) {
+ d.BeaconPolicy.MinimumWitnessLeadSeconds = ProductionMinimumWitnessLeadSeconds - 1
+ }},
+ {name: "beacon chain replaced", mutate: func(d *CeremonyDefinition) {
+ d.BeaconPolicy.ChainHashHex = strings.Repeat("00", 32)
+ }},
+ {name: "beacon public key replaced", mutate: func(d *CeremonyDefinition) {
+ d.BeaconPolicy.PublicKeyHex = strings.Repeat("00", 96)
+ }},
+ {name: "beacon timing replaced", mutate: func(d *CeremonyDefinition) {
+ d.BeaconPolicy.GenesisTimeUnix++
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ changed := valid
+ changed.Roster = append([]Participant(nil), valid.Roster...)
+ changed.Phase1Policy = clonePhasePolicy(valid.Phase1Policy)
+ changed.Phase2Policy = clonePhasePolicy(valid.Phase2Policy)
+ tc.mutate(&changed)
+ if err := changed.Validate(); err == nil {
+ t.Fatal("drifted ceremony metadata unexpectedly accepted")
+ }
+ })
+ }
+}
+
+func adversarialChainRecord(
+ t *testing.T,
+ definition CeremonyDefinition,
+ phaseID string,
+ index uint8,
+ participantID string,
+ previousPayload ArtifactRef,
+ previousRecordID string,
+ outputLabel string,
+) ChainRecord {
+ t.Helper()
+ record, err := NewChainRecord(ChainRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ Index: index,
+ ParticipantID: participantID,
+ PreviousPayload: previousPayload,
+ OutputPayload: ArtifactRef{Name: "phase1/" + outputLabel + ".bin", Digest: NewDigest([]byte(outputLabel))},
+ AttestationID: NewDigest([]byte(outputLabel + " attestation id")).SHA256,
+ Attestation: ArtifactRef{Name: "phase1/" + outputLabel + ".attestation.json", Digest: NewDigest([]byte(outputLabel + " attestation"))},
+ AttestationSignature: ArtifactRef{
+ Name: "phase1/" + outputLabel + ".attestation.sig",
+ Digest: NewDigest([]byte(outputLabel + " attestation signature")),
+ },
+ ErasureID: NewDigest([]byte(outputLabel + " erasure id")).SHA256,
+ Erasure: ArtifactRef{
+ Name: "phase1/" + outputLabel + ".erasure.json",
+ Digest: NewDigest([]byte(outputLabel + " erasure")),
+ },
+ ErasureSignature: ArtifactRef{
+ Name: "phase1/" + outputLabel + ".erasure.sig",
+ Digest: NewDigest([]byte(outputLabel + " erasure signature")),
+ },
+ Verification: ArtifactRef{Name: "phase1/" + outputLabel + ".verification.json", Digest: NewDigest([]byte(outputLabel + " verification"))},
+ PreviousRecordID: previousRecordID,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AcceptedAt: "2026-07-23T12:00:0" + string(rune('0'+index)) + "Z",
+ })
+ if err != nil {
+ t.Fatalf("create chain record %d: %v", index, err)
+ }
+ return record
+}
+
+func TestCoordinatorResignedVerificationCannotDriftFromAcceptedRecord(t *testing.T) {
+ definition := adversarialDefinition(t)
+ phaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, definition.Phase1Genesis, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ baseChain, err := NewChain(definition.CeremonyID, Phase1, phaseID, definition.Phase1Genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ headPayload, err := baseChain.HeadPayload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ headID, err := baseChain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := adversarialChainRecord(
+ t,
+ definition,
+ phaseID,
+ 1,
+ "participant-01",
+ headPayload,
+ headID,
+ "contribution-1",
+ )
+ valid := ContributionVerification{
+ Schema: verificationSchema,
+ VerificationMode: directTransitionVerification,
+ CeremonyID: record.CeremonyID,
+ Phase: record.Phase,
+ PhaseID: record.PhaseID,
+ Index: record.Index,
+ ParticipantID: record.ParticipantID,
+ PreviousPayload: record.PreviousPayload,
+ OutputPayload: record.OutputPayload,
+ AttestationID: record.AttestationID,
+ ErasureID: record.ErasureID,
+ PreviousRecordID: record.PreviousRecordID,
+ CoordinatorID: record.CoordinatorID,
+ CoordinatorKeyID: record.CoordinatorKeyID,
+ Passed: true,
+ VerifiedAt: record.AcceptedAt,
+ }
+ if err := validateContributionVerification(record, valid); err != nil {
+ t.Fatalf("valid verification rejected: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*ContributionVerification)
+ }{
+ {
+ name: "erasure id",
+ mutate: func(verification *ContributionVerification) {
+ verification.ErasureID = NewDigest([]byte("different erasure")).SHA256
+ },
+ },
+ {
+ name: "verified timestamp",
+ mutate: func(verification *ContributionVerification) {
+ verification.VerifiedAt = "2026-07-23T12:00:02Z"
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ verification := valid
+ tc.mutate(&verification)
+ verificationBytes, err := MarshalCanonical(verification)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ resignedRecord := record
+ resignedRecord.Verification.Digest = NewDigest(verificationBytes)
+ resignedRecord, err = NewChainRecord(resignedRecord)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resignedChain := baseChain
+ if err := resignedChain.Append(resignedRecord); err != nil {
+ t.Fatal(err)
+ }
+
+ coordinatorPrivate := adversarialPrivateKey(0x01)
+ chainBytes, signatureBytes, err := SignRecord(
+ resignedChain,
+ definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ var authenticated Chain
+ if err := VerifySignedRecord(
+ chainBytes,
+ signatureBytes,
+ &authenticated,
+ definition.Coordinator.KeyID,
+ coordinatorPrivate.Public().(ed25519.PublicKey),
+ ); err != nil {
+ t.Fatalf("maliciously re-signed chain did not authenticate: %v", err)
+ }
+ var archivedVerification ContributionVerification
+ if err := UnmarshalCanonical(verificationBytes, &archivedVerification); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateContributionVerification(
+ authenticated.Records[0],
+ archivedVerification,
+ ); err == nil {
+ t.Fatal("coordinator-authenticated verification drift unexpectedly accepted")
+ }
+ })
+ }
+}
+
+func TestAcceptedChainRejectsReorderReplayAndForkMerge(t *testing.T) {
+ definition := adversarialDefinition(t)
+ phaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, definition.Phase1Genesis, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ chain, err := NewChain(definition.CeremonyID, Phase1, phaseID, definition.Phase1Genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for index, participantID := range definition.Phase1Policy.Participants {
+ headPayload, err := chain.HeadPayload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ headID, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := adversarialChainRecord(
+ t,
+ definition,
+ phaseID,
+ uint8(index+1),
+ participantID,
+ headPayload,
+ headID,
+ "contribution-"+string(rune('1'+index)),
+ )
+ if err := chain.Append(record); err != nil {
+ t.Fatalf("append valid record %d: %v", index+1, err)
+ }
+ }
+ if err := chain.ValidateAgainstDefinition(definition); err != nil {
+ t.Fatalf("valid accepted chain rejected: %v", err)
+ }
+
+ regressedTime := chain
+ regressedTime.Records = append([]ChainRecord(nil), chain.Records[:2]...)
+ second := regressedTime.Records[1]
+ second.AcceptedAt = regressedTime.Records[0].AcceptedAt
+ second, err = NewChainRecord(second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ regressedTime.Records[1] = second
+ if err := regressedTime.Validate(); err == nil {
+ t.Fatal("accepted chain with non-increasing acceptance timestamps unexpectedly validated")
+ }
+
+ reordered := chain
+ reordered.Records = append([]ChainRecord(nil), chain.Records...)
+ reordered.Records[1], reordered.Records[2] = reordered.Records[2], reordered.Records[1]
+ if err := reordered.Validate(); err == nil {
+ t.Fatal("reordered accepted chain unexpectedly validated")
+ }
+
+ replayed := chain
+ replayed.Records = append([]ChainRecord(nil), chain.Records...)
+ replayed.Records[2] = replayed.Records[1]
+ if err := replayed.Validate(); err == nil {
+ t.Fatal("replayed accepted record unexpectedly validated")
+ }
+
+ // Two candidates derived from the same accepted head are individually
+ // valid forks. Exactly one may advance a chain; the stale sibling must
+ // never be merged afterward.
+ base, err := NewChain(definition.CeremonyID, Phase1, phaseID, definition.Phase1Genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ genesisID, err := base.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ first := adversarialChainRecord(
+ t, definition, phaseID, 1, "participant-01",
+ definition.Phase1Genesis, genesisID, "fork-root",
+ )
+ if err := base.Append(first); err != nil {
+ t.Fatal(err)
+ }
+ headPayload, _ := base.HeadPayload()
+ headID, _ := base.HeadRecordID()
+ left := adversarialChainRecord(
+ t, definition, phaseID, 2, "participant-02",
+ headPayload, headID, "fork-left",
+ )
+ right := adversarialChainRecord(
+ t, definition, phaseID, 2, "participant-02",
+ headPayload, headID, "fork-right",
+ )
+ leftChain := base
+ leftChain.Records = append([]ChainRecord(nil), base.Records...)
+ if err := leftChain.Append(left); err != nil {
+ t.Fatalf("append left fork: %v", err)
+ }
+ rightChain := base
+ rightChain.Records = append([]ChainRecord(nil), base.Records...)
+ if err := rightChain.Append(right); err != nil {
+ t.Fatalf("append right fork: %v", err)
+ }
+ if err := leftChain.Append(right); err == nil {
+ t.Fatal("stale right fork unexpectedly merged after left fork")
+ }
+ if len(leftChain.Records) != 2 || leftChain.Records[1].RecordID != left.RecordID {
+ t.Fatal("rejected fork append mutated the accepted chain")
+ }
+}
+
+func TestAcceptanceRecordMustExactlyBindAttestation(t *testing.T) {
+ definition := adversarialDefinition(t)
+ phaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, definition.Phase1Genesis, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ chain, err := NewChain(definition.CeremonyID, Phase1, phaseID, definition.Phase1Genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previousRecordID, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ output := ArtifactRef{Name: "phase1/contribution-1.bin", Digest: NewDigest([]byte("output one"))}
+ attestation, err := NewContributionAttestation(ContributionAttestation{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: "participant-01",
+ ParticipantKeyID: definition.Roster[0].Identity.KeyID,
+ PreviousPayload: definition.Phase1Genesis,
+ OutputPayload: output,
+ PreviousAcceptanceID: previousRecordID,
+ ToolBinary: definition.Software.ToolBinary,
+ SourceCommit: definition.Software.SourceCommit,
+ GnarkVersion: definition.Software.GnarkVersion,
+ GnarkCryptoVersion: definition.Software.GnarkCryptoVersion,
+ DrandVersion: definition.Software.DrandVersion,
+ Environment: ContributionEnvironment{
+ OS: "linux",
+ Architecture: "amd64",
+ EntropySource: "operating-system-csprng",
+ SwapDisabled: true,
+ CrashDumpsDisabled: true,
+ TelemetryDisabled: true,
+ EphemeralEnvironment: true,
+ EphemeralDestructionRequired: true,
+ },
+ ContributedAt: "2026-07-23T12:01:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ erasure := adversarialErasure(t, attestation, "2026-07-23T12:01:30Z")
+ record, err := NewChainRecord(ChainRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: attestation.ParticipantID,
+ PreviousPayload: attestation.PreviousPayload,
+ OutputPayload: attestation.OutputPayload,
+ AttestationID: attestation.AttestationID,
+ Attestation: ArtifactRef{Name: "phase1/contribution-1.attestation.json", Digest: NewDigest([]byte("attestation"))},
+ AttestationSignature: ArtifactRef{
+ Name: "phase1/contribution-1.attestation.sig",
+ Digest: NewDigest([]byte("signature")),
+ },
+ ErasureID: erasure.ErasureID,
+ Erasure: ArtifactRef{
+ Name: "phase1/contribution-1.erasure.json",
+ Digest: NewDigest([]byte("erasure")),
+ },
+ ErasureSignature: ArtifactRef{
+ Name: "phase1/contribution-1.erasure.sig",
+ Digest: NewDigest([]byte("erasure signature")),
+ },
+ Verification: ArtifactRef{Name: "phase1/contribution-1.verification.json", Digest: NewDigest([]byte("verification"))},
+ PreviousRecordID: previousRecordID,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AcceptedAt: "2026-07-23T12:02:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(definition, chain, attestation, erasure, record); err != nil {
+ t.Fatalf("valid attestation acceptance rejected: %v", err)
+ }
+
+ atDefinition := attestation
+ atDefinition.ContributedAt = definition.CreatedAt
+ atDefinition, err = NewContributionAttestation(atDefinition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ atDefinitionErasure := adversarialErasure(t, atDefinition, "2026-07-23T12:00:01Z")
+ atDefinitionRecord := record
+ atDefinitionRecord.AttestationID = atDefinition.AttestationID
+ atDefinitionRecord.ErasureID = atDefinitionErasure.ErasureID
+ atDefinitionRecord.AcceptedAt = "2026-07-23T12:00:02Z"
+ atDefinitionRecord, err = NewChainRecord(atDefinitionRecord)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(
+ definition,
+ chain,
+ atDefinition,
+ atDefinitionErasure,
+ atDefinitionRecord,
+ ); err == nil {
+ t.Fatal("contribution at the definition timestamp unexpectedly accepted")
+ }
+
+ acceptedBeforeDestruction := record
+ acceptedBeforeDestruction.AcceptedAt = erasure.DestroyedAt
+ acceptedBeforeDestruction, err = NewChainRecord(acceptedBeforeDestruction)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(
+ definition,
+ chain,
+ attestation,
+ erasure,
+ acceptedBeforeDestruction,
+ ); err == nil {
+ t.Fatal("acceptance at the erasure timestamp unexpectedly accepted")
+ }
+
+ advancedChain := chain
+ advancedChain.Records = append([]ChainRecord(nil), chain.Records...)
+ if err := advancedChain.Append(record); err != nil {
+ t.Fatal(err)
+ }
+ secondOutput := ArtifactRef{
+ Name: "phase1/contribution-2.bin",
+ Digest: NewDigest([]byte("output two")),
+ }
+ secondAttestation := attestation
+ secondAttestation.Index = 2
+ secondAttestation.ParticipantID = "participant-02"
+ secondAttestation.ParticipantKeyID = definition.Roster[1].Identity.KeyID
+ secondAttestation.PreviousPayload = record.OutputPayload
+ secondAttestation.OutputPayload = secondOutput
+ secondAttestation.PreviousAcceptanceID = record.RecordID
+ secondAttestation.ContributedAt = record.AcceptedAt
+ secondAttestation, err = NewContributionAttestation(secondAttestation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondErasure := adversarialErasure(t, secondAttestation, "2026-07-23T12:02:30Z")
+ secondRecord, err := NewChainRecord(ChainRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ Index: 2,
+ ParticipantID: secondAttestation.ParticipantID,
+ PreviousPayload: secondAttestation.PreviousPayload,
+ OutputPayload: secondAttestation.OutputPayload,
+ AttestationID: secondAttestation.AttestationID,
+ Attestation: ArtifactRef{
+ Name: "phase1/contribution-2.attestation.json",
+ Digest: NewDigest([]byte("attestation two")),
+ },
+ AttestationSignature: ArtifactRef{
+ Name: "phase1/contribution-2.attestation.sig",
+ Digest: NewDigest([]byte("signature two")),
+ },
+ ErasureID: secondErasure.ErasureID,
+ Erasure: ArtifactRef{
+ Name: "phase1/contribution-2.erasure.json",
+ Digest: NewDigest([]byte("erasure two")),
+ },
+ ErasureSignature: ArtifactRef{
+ Name: "phase1/contribution-2.erasure.sig",
+ Digest: NewDigest([]byte("erasure signature two")),
+ },
+ Verification: ArtifactRef{
+ Name: "phase1/contribution-2.verification.json",
+ Digest: NewDigest([]byte("verification two")),
+ },
+ PreviousRecordID: record.RecordID,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AcceptedAt: "2026-07-23T12:03:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(
+ definition,
+ advancedChain,
+ secondAttestation,
+ secondErasure,
+ secondRecord,
+ ); err == nil {
+ t.Fatal("contribution at the previous acceptance timestamp unexpectedly accepted")
+ }
+
+ differentOutput := ArtifactRef{Name: "phase1/other.bin", Digest: NewDigest([]byte("other output"))}
+ mismatchedRecord := record
+ mismatchedRecord.OutputPayload = differentOutput
+ mismatchedRecord, err = NewChainRecord(mismatchedRecord)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(definition, chain, attestation, erasure, mismatchedRecord); err == nil {
+ t.Fatal("acceptance record for a different output unexpectedly bound the attestation")
+ }
+
+ wrongIdentity := attestation
+ wrongIdentity.ParticipantKeyID = "unregistered-participant-key"
+ wrongIdentity, err = NewContributionAttestation(wrongIdentity)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrongIdentityErasure := adversarialErasure(t, wrongIdentity, "2026-07-23T12:01:30Z")
+ wrongIdentityRecord := record
+ wrongIdentityRecord.AttestationID = wrongIdentity.AttestationID
+ wrongIdentityRecord.ErasureID = wrongIdentityErasure.ErasureID
+ wrongIdentityRecord, err = NewChainRecord(wrongIdentityRecord)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateAttestationAcceptance(
+ definition,
+ chain,
+ wrongIdentity,
+ wrongIdentityErasure,
+ wrongIdentityRecord,
+ ); err == nil {
+ t.Fatal("attestation under a different participant key unexpectedly accepted")
+ }
+}
+
+func TestErasureAttestationRequiresExactPostContributionDestruction(t *testing.T) {
+ contribution := adversarialAttestation(t)
+ erasure := adversarialErasure(t, contribution, "2026-07-23T12:00:01Z")
+ if err := ValidateErasureForContribution(contribution, erasure); err != nil {
+ t.Fatalf("valid post-contribution erasure rejected: %v", err)
+ }
+
+ notAfter := erasure
+ notAfter.DestroyedAt = contribution.ContributedAt
+ notAfter, err := NewErasureAttestation(notAfter)
+ if err != nil {
+ t.Fatalf("create chronologically invalid erasure record: %v", err)
+ }
+ if err := ValidateErasureForContribution(contribution, notAfter); err == nil {
+ t.Fatal("erasure at the contribution timestamp unexpectedly accepted")
+ }
+
+ wrongOutput := erasure
+ wrongOutput.OutputPayload = ArtifactRef{
+ Name: "phase1/different-output.bin",
+ Digest: NewDigest([]byte("different output")),
+ }
+ wrongOutput, err = NewErasureAttestation(wrongOutput)
+ if err != nil {
+ t.Fatalf("create wrong-output erasure record: %v", err)
+ }
+ if err := ValidateErasureForContribution(contribution, wrongOutput); err == nil {
+ t.Fatal("erasure for a different output unexpectedly accepted")
+ }
+
+ incomplete := erasure
+ incomplete.NoBackupRetained = false
+ if _, err := NewErasureAttestation(incomplete); err == nil {
+ t.Fatal("erasure with a retained backup unexpectedly accepted")
+ }
+}
+
+func TestPinnedQuicknetBeaconVerificationRejectsMalformedOrForgedEvidence(t *testing.T) {
+ // Public round-1 response from the pinned quicknet chain:
+ // https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/public/1
+ const valid = `{"round":1,"randomness":"1466a6cd24e327188770752f6134001c64d6efcc590ccc26b721611ad96f165a","signature":"b55e7cb2d5c613ee0b2e28d6750aabbb78c39dcc96bd9d38c2c2e12198df95571de8e8e402a0cc48871c7089a2b3af4b"}`
+ policy := adversarialDefinition(t).BeaconPolicy
+ randomness, err := VerifyDrandBeaconResponse(policy, 1, []byte(valid))
+ if err != nil {
+ t.Fatalf("verify pinned quicknet response: %v", err)
+ }
+ if randomness != "1466a6cd24e327188770752f6134001c64d6efcc590ccc26b721611ad96f165a" {
+ t.Fatalf("verified randomness = %q", randomness)
+ }
+
+ cases := []struct {
+ name string
+ expectedRound uint64
+ response string
+ }{
+ {name: "wrong committed round", expectedRound: 2, response: valid},
+ {
+ name: "forged randomness",
+ expectedRound: 1,
+ response: strings.Replace(
+ valid,
+ "1466a6cd24e327188770752f6134001c64d6efcc590ccc26b721611ad96f165a",
+ strings.Repeat("00", 32),
+ 1,
+ ),
+ },
+ {
+ name: "forged signature",
+ expectedRound: 1,
+ response: strings.Replace(
+ valid,
+ "b55e7cb2d5c613ee0b2e28d6750aabbb78c39dcc96bd9d38c2c2e12198df95571de8e8e402a0cc48871c7089a2b3af4b",
+ strings.Repeat("00", 48),
+ 1,
+ ),
+ },
+ {name: "unknown field", expectedRound: 1, response: strings.TrimSuffix(valid, "}") + `,"challenge":"00"}`},
+ {name: "duplicate key", expectedRound: 1, response: strings.Replace(valid, `"round":1`, `"round":1,"round":1`, 1)},
+ {name: "trailing JSON", expectedRound: 1, response: valid + `{}`},
+ {name: "chained response", expectedRound: 1, response: strings.TrimSuffix(valid, "}") + `,"previous_signature":"00"}`},
+ {name: "uppercase randomness", expectedRound: 1, response: strings.Replace(valid, `"randomness":"1`, `"randomness":"A`, 1)},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := VerifyDrandBeaconResponse(policy, tc.expectedRound, []byte(tc.response)); err == nil {
+ t.Fatal("invalid drand evidence unexpectedly verified")
+ }
+ })
+ }
+}
+
+func TestReleaseRequiresTwoExactChronologicalIndependentAudits(t *testing.T) {
+ definition := adversarialDefinition(t)
+ candidate := adversarialCandidate(t, definition)
+ candidateBytes, err := MarshalCanonical(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outputs := candidateAuditOutputs(candidate, ArtifactRef{
+ Name: CandidateMetadataFile,
+ Digest: NewDigest(candidateBytes),
+ })
+ first := adversarialSignedAudit(
+ t,
+ definition,
+ candidate,
+ 0,
+ "2026-07-23T13:01:00Z",
+ outputs,
+ )
+ second := adversarialSignedAudit(
+ t,
+ definition,
+ candidate,
+ 1,
+ "2026-07-23T13:02:00Z",
+ outputs,
+ )
+ refs, latest, err := verifyPassingAudits(
+ definition,
+ candidate,
+ []AuditArtifact{first, second},
+ )
+ if err != nil {
+ t.Fatalf("two exact independent audits rejected: %v", err)
+ }
+ if len(refs) != 2 || latest.Format(time.RFC3339Nano) != "2026-07-23T13:02:00Z" {
+ t.Fatalf("verified audit result = %d refs, latest %s", len(refs), latest.Format(time.RFC3339Nano))
+ }
+
+ if _, _, err := verifyPassingAudits(
+ definition,
+ candidate,
+ []AuditArtifact{first, first},
+ ); err == nil {
+ t.Fatal("same auditor and key counted twice toward release threshold")
+ }
+
+ extraOutputs := append(append([]ArtifactRef(nil), outputs...), ArtifactRef{
+ Name: "unexpected-auditor-output.txt",
+ Digest: NewDigest([]byte("unexpected output")),
+ })
+ extra := adversarialSignedAudit(
+ t,
+ definition,
+ candidate,
+ 0,
+ "2026-07-23T13:01:00Z",
+ extraOutputs,
+ )
+ if _, _, err := verifyPassingAudits(
+ definition,
+ candidate,
+ []AuditArtifact{extra, second},
+ ); err == nil {
+ t.Fatal("audit output superset unexpectedly treated as exact candidate binding")
+ }
+
+ predating := adversarialSignedAudit(
+ t,
+ definition,
+ candidate,
+ 0,
+ "2026-07-23T12:59:59Z",
+ outputs,
+ )
+ if _, _, err := verifyPassingAudits(
+ definition,
+ candidate,
+ []AuditArtifact{predating, second},
+ ); err == nil {
+ t.Fatal("audit predating candidate finalization unexpectedly accepted")
+ }
+
+ if err := validateReleaseChronology(latest, latest); err == nil {
+ t.Fatal("release at the latest audit timestamp unexpectedly accepted")
+ }
+ if err := validateReleaseChronology(latest.Add(time.Nanosecond), latest); err != nil {
+ t.Fatalf("release strictly after the latest audit rejected: %v", err)
+ }
+}
+
+func TestSignedContributionAttestationRejectsTamperingAndWrongTrust(t *testing.T) {
+ attestation := adversarialAttestation(t)
+ privateKey := adversarialPrivateKey(0x51)
+ publicKey := privateKey.Public().(ed25519.PublicKey)
+
+ recordBytes, signatureBytes, err := SignRecord(attestation, attestation.ParticipantKeyID, privateKey)
+ if err != nil {
+ t.Fatalf("sign attestation: %v", err)
+ }
+ var verified ContributionAttestation
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &verified,
+ attestation.ParticipantKeyID,
+ publicKey,
+ ); err != nil {
+ t.Fatalf("verify valid signed attestation: %v", err)
+ }
+ if verified.AttestationID != attestation.AttestationID {
+ t.Fatalf("verified attestation id = %q, want %q", verified.AttestationID, attestation.AttestationID)
+ }
+
+ tampered := bytes.Replace(recordBytes, []byte(`"phase":"phase1"`), []byte(`"phase":"phase2"`), 1)
+ if bytes.Equal(tampered, recordBytes) {
+ t.Fatal("test failed to tamper phase")
+ }
+ if err := VerifySignedRecord(
+ tampered,
+ signatureBytes,
+ &ContributionAttestation{},
+ attestation.ParticipantKeyID,
+ publicKey,
+ ); err == nil {
+ t.Fatal("tampered signed attestation unexpectedly verified")
+ }
+
+ wrongPrivateKey := adversarialPrivateKey(0x52)
+ wrongPublicKey := wrongPrivateKey.Public().(ed25519.PublicKey)
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &ContributionAttestation{},
+ attestation.ParticipantKeyID,
+ wrongPublicKey,
+ ); err == nil {
+ t.Fatal("signature unexpectedly verified under the wrong public key")
+ }
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &ContributionAttestation{},
+ "another-participant-key",
+ publicKey,
+ ); err == nil {
+ t.Fatal("signature unexpectedly verified under the wrong trusted key id")
+ }
+}
+
+func TestSignedContributionAttestationStillAppliesSemanticValidation(t *testing.T) {
+ attestation := adversarialAttestation(t)
+ attestation.GnarkVersion = "v0.14.0"
+
+ // SignExact deliberately allows already-canonical arbitrary artifacts.
+ // Even a cryptographically valid signature must not bypass strict typed
+ // validation when the bytes are consumed as an attestation.
+ invalidRecord, err := json.Marshal(attestation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privateKey := adversarialPrivateKey(0x61)
+ signature, err := SignExact(invalidRecord, attestation.ParticipantKeyID, privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signatureBytes, err := MarshalCanonical(signature)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = VerifySignedRecord(
+ invalidRecord,
+ signatureBytes,
+ &ContributionAttestation{},
+ attestation.ParticipantKeyID,
+ privateKey.Public().(ed25519.PublicKey),
+ )
+ if err == nil || !strings.Contains(err.Error(), "gnark_version") {
+ t.Fatalf("signed wrong-software attestation error = %v", err)
+ }
+}
+
+func TestCanonicalRecordDecoderRejectsDuplicateUnknownTrailingAndNoncanonical(t *testing.T) {
+ attestation := adversarialAttestation(t)
+ canonical, err := MarshalCanonical(attestation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := UnmarshalCanonical(canonical, &ContributionAttestation{}); err != nil {
+ t.Fatalf("canonical attestation rejected: %v", err)
+ }
+
+ var object map[string]json.RawMessage
+ if err := json.Unmarshal(canonical, &object); err != nil {
+ t.Fatal(err)
+ }
+ duplicate := append(bytes.Clone(canonical[:len(canonical)-1]), []byte(`,"phase":"phase1"}`)...)
+ unknown := append(bytes.Clone(canonical[:len(canonical)-1]), []byte(`,"unexpected":true}`)...)
+ pretty := new(bytes.Buffer)
+ if err := json.Indent(pretty, canonical, "", " "); err != nil {
+ t.Fatal(err)
+ }
+ cases := []struct {
+ name string
+ raw []byte
+ }{
+ {name: "duplicate field", raw: duplicate},
+ {name: "unknown field", raw: unknown},
+ {name: "trailing JSON", raw: append(bytes.Clone(canonical), []byte(`{}`)...)},
+ {name: "trailing whitespace", raw: append(bytes.Clone(canonical), '\n')},
+ {name: "pretty but noncanonical", raw: pretty.Bytes()},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := UnmarshalCanonical(tc.raw, &ContributionAttestation{}); err == nil {
+ t.Fatal("noncanonical record unexpectedly accepted")
+ }
+ })
+ }
+}
+
+func TestPhase1StrictReaderRejectsMalformedArtifacts(t *testing.T) {
+ contribution := adversarialPhase1Contribution(t)
+ valid := adversarialSerialize(t, contribution)
+ shape := Phase1Shape{DomainN: adversarialTinyDomain, ChallengeLength: 32}
+ expected, err := ExpectedPhase1Size(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if int64(len(valid)) != expected {
+ t.Fatalf("valid Phase 1 size = %d, expected %d", len(valid), expected)
+ }
+
+ // Phase1.WriteTo places three fixed-size update proofs before the embedded
+ // uint64 domain. A forged domain must be rejected by preflight before
+ // gnark's decoder can allocate vectors from it.
+ domainOffset := 3 * (48 + 96)
+ cases := []struct {
+ name string
+ raw func() []byte
+ }{
+ {
+ name: "truncated",
+ raw: func() []byte {
+ return bytes.Clone(valid[:len(valid)-1])
+ },
+ },
+ {
+ name: "trailing byte",
+ raw: func() []byte {
+ return append(bytes.Clone(valid), 0)
+ },
+ },
+ {
+ name: "uncompressed update proof point",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ out[0] &= 0x1f
+ return out
+ },
+ },
+ {
+ name: "oversized embedded domain",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ binary.BigEndian.PutUint64(out[domainOffset:domainOffset+8], math.MaxUint64)
+ return out
+ },
+ },
+ {
+ name: "wrong embedded domain",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ binary.BigEndian.PutUint64(out[domainOffset:domainOffset+8], adversarialTinyDomain*2)
+ return out
+ },
+ },
+ {
+ name: "wrong challenge length",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ out[len(out)-33] = 31
+ return out
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "phase1.bin")
+ adversarialWriteRaw(t, path, tc.raw())
+ if _, _, err := ReadPhase1File(path, shape); err == nil {
+ t.Fatal("malformed Phase 1 artifact unexpectedly accepted")
+ }
+ })
+ }
+}
+
+func TestPhase1PreflightRejectsTrailingData(t *testing.T) {
+ valid := adversarialSerialize(t, adversarialPhase1Contribution(t))
+ shape := Phase1Shape{DomainN: adversarialTinyDomain, ChallengeLength: 32}
+ if _, err := PreflightPhase1(bytes.NewReader(append(valid, 0)), shape); !errors.Is(err, ErrTrailingData) {
+ t.Fatalf("trailing byte error = %v, want ErrTrailingData", err)
+ }
+}
+
+func TestPhase2StrictReaderRejectsMaliciousPrefixesAndFraming(t *testing.T) {
+ contribution, shape := adversarialPhase2Contribution(t)
+ valid := adversarialSerialize(t, contribution)
+ expected, err := ExpectedPhase2Size(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if int64(len(valid)) != expected {
+ t.Fatalf("valid Phase 2 size = %d, expected %d", len(valid), expected)
+ }
+
+ // Phase2 starts with commitments(u16), Delta(G1), then PKK's uint32
+ // vector length. Those attacker-controlled prefixes must be checked before
+ // native decoding.
+ const (
+ commitmentsOffset = 0
+ deltaOffset = 2
+ pkkLengthOffset = deltaOffset + 48
+ )
+ cases := []struct {
+ name string
+ raw func() []byte
+ }{
+ {
+ name: "truncated",
+ raw: func() []byte {
+ return bytes.Clone(valid[:len(valid)/2])
+ },
+ },
+ {
+ name: "trailing byte",
+ raw: func() []byte {
+ return append(bytes.Clone(valid), 0xff)
+ },
+ },
+ {
+ name: "uncompressed delta",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ out[deltaOffset] &= 0x1f
+ return out
+ },
+ },
+ {
+ name: "max commitment count",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ binary.BigEndian.PutUint16(out[commitmentsOffset:commitmentsOffset+2], math.MaxUint16)
+ return out
+ },
+ },
+ {
+ name: "max PKK vector",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ binary.BigEndian.PutUint32(out[pkkLengthOffset:pkkLengthOffset+4], math.MaxUint32)
+ return out
+ },
+ },
+ {
+ name: "wrong challenge length",
+ raw: func() []byte {
+ out := bytes.Clone(valid)
+ out[len(out)-33] = 31
+ return out
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "phase2.bin")
+ adversarialWriteRaw(t, path, tc.raw())
+ if _, _, err := ReadPhase2File(path, shape); err == nil {
+ t.Fatal("malformed Phase 2 artifact unexpectedly accepted")
+ }
+ })
+ }
+}
+
+func TestArtifactWritersNeverReplaceAndDoNotMutateInputs(t *testing.T) {
+ phase1 := adversarialPhase1Contribution(t)
+ phase1Before := adversarialSerialize(t, phase1)
+ phase1Shape := Phase1Shape{DomainN: adversarialTinyDomain, ChallengeLength: 32}
+ phase1Path := filepath.Join(t.TempDir(), "phase1.bin")
+ if _, err := WritePhase1FileNoReplace(phase1Path, phase1, phase1Shape); err != nil {
+ t.Fatalf("write Phase 1: %v", err)
+ }
+ publishedBefore, err := os.ReadFile(phase1Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := WritePhase1FileNoReplace(phase1Path, phase1, phase1Shape); err == nil {
+ t.Fatal("second Phase 1 write unexpectedly replaced destination")
+ }
+ publishedAfter, err := os.ReadFile(phase1Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(publishedAfter, publishedBefore) {
+ t.Fatal("existing Phase 1 destination changed after rejected replacement")
+ }
+ if got := adversarialSerialize(t, phase1); !bytes.Equal(got, phase1Before) {
+ t.Fatal("Phase 1 input mutated by writer")
+ }
+
+ phase2, phase2Shape := adversarialPhase2Contribution(t)
+ phase2Before := adversarialSerialize(t, phase2)
+ phase2Path := filepath.Join(t.TempDir(), "phase2.bin")
+ if _, err := WritePhase2FileNoReplace(phase2Path, phase2, phase2Shape); err != nil {
+ t.Fatalf("write Phase 2: %v", err)
+ }
+ if _, err := WritePhase2FileNoReplace(phase2Path, phase2, phase2Shape); err == nil {
+ t.Fatal("second Phase 2 write unexpectedly replaced destination")
+ }
+ if got := adversarialSerialize(t, phase2); !bytes.Equal(got, phase2Before) {
+ t.Fatal("Phase 2 input mutated by writer")
+ }
+}
+
+func TestReplayRejectsReorderedContributionsWithoutMutatingArchive(t *testing.T) {
+ var phase1 []*gnarkmpc.Phase1
+ for range 3 {
+ next, err := ContributePhase1(adversarialTinyDomain, phase1)
+ if err != nil {
+ t.Fatalf("contribute Phase 1: %v", err)
+ }
+ phase1 = append(phase1, next)
+ }
+ archive := make([][]byte, len(phase1))
+ for i := range phase1 {
+ archive[i] = adversarialSerialize(t, phase1[i])
+ }
+
+ reordered := []*gnarkmpc.Phase1{phase1[0], phase1[2], phase1[1]}
+ if err := ReplayPhase1(adversarialTinyDomain, reordered); err == nil {
+ t.Fatal("reordered Phase 1 chain unexpectedly accepted")
+ }
+ for i := range phase1 {
+ if got := adversarialSerialize(t, phase1[i]); !bytes.Equal(got, archive[i]) {
+ t.Fatalf("archived Phase 1 contribution %d mutated during replay", i+1)
+ }
+ }
+ if err := ReplayPhase1(adversarialTinyDomain*2, phase1); err == nil {
+ t.Fatal("Phase 1 chain unexpectedly replayed under a different domain")
+ }
+ for _, size := range []int{0, 31, 33} {
+ if _, err := SealPhase1(adversarialTinyDomain, bytes.Repeat([]byte{1}, size), phase1); err == nil {
+ t.Fatalf("Phase 1 accepted a %d-byte beacon", size)
+ }
+ }
+}
+
+func TestFinalReplayAcceptsWorkflowNestedLogicalArtifactName(t *testing.T) {
+ root := t.TempDir()
+ logicalName := "phase1/contributions/0001/contribution.bin"
+ path := filepath.Join(root, filepath.FromSlash(logicalName))
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ payload := []byte("accepted workflow contribution")
+ if err := os.WriteFile(path, payload, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ expected := ArtifactRef{Name: logicalName, Digest: NewDigest(payload)}
+
+ if err := requireArchivedArtifact(root, path, NewDigest(payload), expected); err != nil {
+ t.Fatalf("workflow-produced nested artifact rejected by final replay: %v", err)
+ }
+}
+
+func TestWorkflowArtifactReadRejectsSymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ outside := t.TempDir()
+ outsidePhase := filepath.Join(outside, "phase1")
+ if err := os.Mkdir(outsidePhase, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ payload := []byte("outside transcript root")
+ if err := os.WriteFile(filepath.Join(outsidePhase, "artifact.bin"), payload, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outsidePhase, filepath.Join(root, "phase1")); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+
+ ref := ArtifactRef{
+ Name: "phase1/artifact.bin",
+ Digest: NewDigest(payload),
+ }
+ if _, err := verifyArtifactBytes(root, ref, 1<<20); err == nil {
+ t.Fatal("artifact reached through a symlink outside the transcript root was accepted")
+ }
+}
+
+func TestSignedRecordPublicationFailureLeavesNoUnsignedRecord(t *testing.T) {
+ root := t.TempDir()
+ recordPath := filepath.Join(root, "record.json")
+ signaturePath := filepath.Join(root, "missing", "record.sig")
+
+ err := writeSignedRecordNoReplace(
+ recordPath,
+ signaturePath,
+ adversarialAttestation(t),
+ "participant-key-01",
+ adversarialPrivateKey(0x71),
+ )
+ if err == nil {
+ t.Fatal("signed-record publication unexpectedly succeeded without a signature directory")
+ }
+ if _, statErr := os.Lstat(recordPath); !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("failed publication left an unsigned record at %q: %v", recordPath, statErr)
+ }
+}
diff --git a/internal/mpcceremony/attestation.go b/internal/mpcceremony/attestation.go
new file mode 100644
index 00000000..f153380d
--- /dev/null
+++ b/internal/mpcceremony/attestation.go
@@ -0,0 +1,399 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+)
+
+type DetachedSignature struct {
+ Schema string `json:"schema"`
+ Algorithm string `json:"algorithm"`
+ KeyID string `json:"key_id"`
+ PublicKeyFingerprint string `json:"public_key_fingerprint"`
+ SignedSHA256 string `json:"signed_sha256"`
+ SignatureHex string `json:"signature_hex"`
+}
+
+func (s DetachedSignature) Validate() error {
+ if s.Schema != DetachedSignatureSchema {
+ return fmt.Errorf("signature schema %q, want %q", s.Schema, DetachedSignatureSchema)
+ }
+ if s.Algorithm != SignatureAlgorithm {
+ return fmt.Errorf("signature algorithm %q, want %q", s.Algorithm, SignatureAlgorithm)
+ }
+ if err := validateID("signature key_id", s.KeyID); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(s.PublicKeyFingerprint, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("signature public_key_fingerprint: %w", err)
+ }
+ if err := validateTaggedHex(s.SignedSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("signature signed_sha256: %w", err)
+ }
+ if err := validateHex(s.SignatureHex, ed25519.SignatureSize); err != nil {
+ return fmt.Errorf("signature_hex: %w", err)
+ }
+ return nil
+}
+
+// SignExact signs bytes exactly as supplied. Use SignRecord for typed records;
+// SignExact exists for already-canonical artifacts such as a persisted manifest.
+func SignExact(data []byte, keyID string, privateKey ed25519.PrivateKey) (DetachedSignature, error) {
+ if len(data) == 0 {
+ return DetachedSignature{}, errors.New("cannot sign empty data")
+ }
+ if err := validateID("signature key_id", keyID); err != nil {
+ return DetachedSignature{}, err
+ }
+ if len(privateKey) != ed25519.PrivateKeySize {
+ return DetachedSignature{}, fmt.Errorf("Ed25519 private key is %d bytes, want %d", len(privateKey), ed25519.PrivateKeySize)
+ }
+ publicKey, ok := privateKey.Public().(ed25519.PublicKey)
+ if !ok || len(publicKey) != ed25519.PublicKeySize {
+ return DetachedSignature{}, errors.New("derive Ed25519 public key")
+ }
+ signature := DetachedSignature{
+ Schema: DetachedSignatureSchema,
+ Algorithm: SignatureAlgorithm,
+ KeyID: keyID,
+ PublicKeyFingerprint: taggedSHA256(publicKey),
+ SignedSHA256: taggedSHA256(data),
+ SignatureHex: hex.EncodeToString(ed25519.Sign(privateKey, data)),
+ }
+ return signature, signature.Validate()
+}
+
+func VerifyExact(data []byte, signature DetachedSignature, expectedKeyID string, publicKey ed25519.PublicKey) error {
+ if err := signature.Validate(); err != nil {
+ return err
+ }
+ if len(data) == 0 {
+ return errors.New("signed data is empty")
+ }
+ if len(publicKey) != ed25519.PublicKeySize {
+ return fmt.Errorf("Ed25519 public key is %d bytes, want %d", len(publicKey), ed25519.PublicKeySize)
+ }
+ if signature.KeyID != expectedKeyID {
+ return fmt.Errorf("signature key_id %q, want %q", signature.KeyID, expectedKeyID)
+ }
+ if signature.PublicKeyFingerprint != taggedSHA256(publicKey) {
+ return errors.New("signature public-key fingerprint mismatch")
+ }
+ if signature.SignedSHA256 != taggedSHA256(data) {
+ return errors.New("signature signed-data digest mismatch")
+ }
+ rawSignature, err := hex.DecodeString(signature.SignatureHex)
+ if err != nil {
+ return fmt.Errorf("decode signature: %w", err)
+ }
+ if !ed25519.Verify(publicKey, data, rawSignature) {
+ return errors.New("Ed25519 signature verification failed")
+ }
+ return nil
+}
+
+// SignRecord returns separately persisted canonical record and detached
+// signature bytes.
+func SignRecord(record any, keyID string, privateKey ed25519.PrivateKey) ([]byte, []byte, error) {
+ recordBytes, err := MarshalCanonical(record)
+ if err != nil {
+ return nil, nil, err
+ }
+ signature, err := SignExact(recordBytes, keyID, privateKey)
+ if err != nil {
+ return nil, nil, err
+ }
+ signatureBytes, err := MarshalCanonical(signature)
+ if err != nil {
+ return nil, nil, err
+ }
+ return recordBytes, signatureBytes, nil
+}
+
+// VerifySignedRecord authenticates exact bytes before strict parsing. The
+// public key is supplied out of band; the signature never supplies trust.
+func VerifySignedRecord(recordBytes, signatureBytes []byte, destination any, expectedKeyID string, publicKey ed25519.PublicKey) error {
+ var signature DetachedSignature
+ if err := UnmarshalCanonical(signatureBytes, &signature); err != nil {
+ return fmt.Errorf("signature: %w", err)
+ }
+ if err := VerifyExact(recordBytes, signature, expectedKeyID, publicKey); err != nil {
+ return err
+ }
+ if err := UnmarshalCanonical(recordBytes, destination); err != nil {
+ return fmt.Errorf("signed record: %w", err)
+ }
+ return nil
+}
+
+type ContributionEnvironment struct {
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ EntropySource string `json:"entropy_source"`
+ SwapDisabled bool `json:"swap_disabled"`
+ CrashDumpsDisabled bool `json:"crash_dumps_disabled"`
+ TelemetryDisabled bool `json:"telemetry_disabled"`
+ EphemeralEnvironment bool `json:"ephemeral_environment"`
+ EphemeralDestructionRequired bool `json:"ephemeral_destruction_required"`
+}
+
+func (e ContributionEnvironment) Validate() error {
+ if e.OS == "" || e.Architecture == "" {
+ return errors.New("contribution environment OS and architecture are required")
+ }
+ if e.EntropySource != "operating-system-csprng" {
+ return fmt.Errorf("entropy_source %q, want operating-system-csprng", e.EntropySource)
+ }
+ if !e.SwapDisabled || !e.CrashDumpsDisabled || !e.TelemetryDisabled ||
+ !e.EphemeralEnvironment || !e.EphemeralDestructionRequired {
+ return errors.New("all production contribution environment controls and the post-contribution destruction plan must be attested")
+ }
+ return nil
+}
+
+type ErasureAttestation struct {
+ Schema string `json:"schema"`
+ ErasureID string `json:"erasure_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Index uint8 `json:"index"`
+ ParticipantID string `json:"participant_id"`
+ ParticipantKeyID string `json:"participant_key_id"`
+ ContributionAttestationID string `json:"contribution_attestation_id"`
+ OutputPayload ArtifactRef `json:"output_payload"`
+ DestroyedAt string `json:"destroyed_at"`
+ ProcessTerminated bool `json:"process_terminated"`
+ EphemeralStorageDestroyed bool `json:"ephemeral_storage_destroyed"`
+ NoBackupRetained bool `json:"no_backup_retained"`
+}
+
+func NewErasureAttestation(attestation ErasureAttestation) (ErasureAttestation, error) {
+ attestation.Schema = ErasureAttestationSchema
+ attestation.ErasureID = ""
+ id, err := ComputeErasureAttestationID(attestation)
+ if err != nil {
+ return ErasureAttestation{}, err
+ }
+ attestation.ErasureID = id
+ if err := attestation.Validate(); err != nil {
+ return ErasureAttestation{}, err
+ }
+ return attestation, nil
+}
+
+func ComputeErasureAttestationID(attestation ErasureAttestation) (string, error) {
+ attestation.ErasureID = ""
+ if err := attestation.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/erasure-attestation/v1", attestation)
+}
+
+func (a ErasureAttestation) Validate() error {
+ if err := a.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeErasureAttestationID(a)
+ if err != nil {
+ return err
+ }
+ if a.ErasureID != expected {
+ return fmt.Errorf("erasure_id %q, want %q", a.ErasureID, expected)
+ }
+ return nil
+}
+
+func (a ErasureAttestation) validate(requireID bool) error {
+ if a.Schema != ErasureAttestationSchema {
+ return fmt.Errorf("erasure schema %q, want %q", a.Schema, ErasureAttestationSchema)
+ }
+ if requireID {
+ if err := validateTaggedHex(a.ErasureID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("erasure_id: %w", err)
+ }
+ } else if a.ErasureID != "" {
+ return errors.New("erasure_id must be empty while computing identity")
+ }
+ if err := validateTaggedHex(a.CeremonyID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("ceremony_id: %w", err)
+ }
+ if err := a.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(a.PhaseID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("phase_id: %w", err)
+ }
+ if a.Index == 0 || a.Index > MaxParticipants {
+ return fmt.Errorf("erasure index %d must be between 1 and %d", a.Index, MaxParticipants)
+ }
+ if err := validateID("participant_id", a.ParticipantID); err != nil {
+ return err
+ }
+ if err := validateID("participant_key_id", a.ParticipantKeyID); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(a.ContributionAttestationID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("contribution_attestation_id: %w", err)
+ }
+ if err := a.OutputPayload.Validate(); err != nil {
+ return fmt.Errorf("output_payload: %w", err)
+ }
+ if err := validateTimestamp("destroyed_at", a.DestroyedAt); err != nil {
+ return err
+ }
+ if !a.ProcessTerminated || !a.EphemeralStorageDestroyed || !a.NoBackupRetained {
+ return errors.New("erasure attestation requires process termination, ephemeral storage destruction, and no retained backup")
+ }
+ return nil
+}
+
+// ValidateErasureForContribution binds a completed erasure statement to the
+// exact contribution and enforces that destruction occurred afterward.
+func ValidateErasureForContribution(contribution ContributionAttestation, erasure ErasureAttestation) error {
+ if err := contribution.Validate(); err != nil {
+ return fmt.Errorf("contribution: %w", err)
+ }
+ if err := erasure.Validate(); err != nil {
+ return fmt.Errorf("erasure: %w", err)
+ }
+ if erasure.CeremonyID != contribution.CeremonyID ||
+ erasure.Phase != contribution.Phase ||
+ erasure.PhaseID != contribution.PhaseID ||
+ erasure.Index != contribution.Index ||
+ erasure.ParticipantID != contribution.ParticipantID ||
+ erasure.ParticipantKeyID != contribution.ParticipantKeyID ||
+ erasure.ContributionAttestationID != contribution.AttestationID ||
+ erasure.OutputPayload != contribution.OutputPayload {
+ return errors.New("erasure attestation does not exactly bind contribution")
+ }
+ contributedAt, _ := time.Parse(time.RFC3339Nano, contribution.ContributedAt)
+ destroyedAt, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt)
+ if !destroyedAt.After(contributedAt) {
+ return errors.New("destroyed_at must be strictly after contributed_at")
+ }
+ return nil
+}
+
+type ContributionAttestation struct {
+ Schema string `json:"schema"`
+ AttestationID string `json:"attestation_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Index uint8 `json:"index"`
+ ParticipantID string `json:"participant_id"`
+ ParticipantKeyID string `json:"participant_key_id"`
+ PreviousPayload ArtifactRef `json:"previous_payload"`
+ OutputPayload ArtifactRef `json:"output_payload"`
+ PreviousAcceptanceID string `json:"previous_acceptance_id"`
+ ToolBinary Digest `json:"tool_binary"`
+ SourceCommit string `json:"source_commit"`
+ GnarkVersion string `json:"gnark_version"`
+ GnarkCryptoVersion string `json:"gnark_crypto_version"`
+ DrandVersion string `json:"drand_version"`
+ Environment ContributionEnvironment `json:"environment"`
+ ContributedAt string `json:"contributed_at"`
+}
+
+func NewContributionAttestation(attestation ContributionAttestation) (ContributionAttestation, error) {
+ attestation.Schema = ContributionAttestationSchema
+ attestation.AttestationID = ""
+ id, err := ComputeContributionAttestationID(attestation)
+ if err != nil {
+ return ContributionAttestation{}, err
+ }
+ attestation.AttestationID = id
+ if err := attestation.Validate(); err != nil {
+ return ContributionAttestation{}, err
+ }
+ return attestation, nil
+}
+
+func ComputeContributionAttestationID(attestation ContributionAttestation) (string, error) {
+ attestation.AttestationID = ""
+ if err := attestation.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/contribution-attestation/v1", attestation)
+}
+
+func (a ContributionAttestation) Validate() error {
+ if err := a.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeContributionAttestationID(a)
+ if err != nil {
+ return err
+ }
+ if a.AttestationID != expected {
+ return fmt.Errorf("attestation_id %q, want %q", a.AttestationID, expected)
+ }
+ return nil
+}
+
+func (a ContributionAttestation) validate(requireID bool) error {
+ if a.Schema != ContributionAttestationSchema {
+ return fmt.Errorf("attestation schema %q, want %q", a.Schema, ContributionAttestationSchema)
+ }
+ if requireID {
+ if err := validateTaggedHex(a.AttestationID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("attestation_id: %w", err)
+ }
+ } else if a.AttestationID != "" {
+ return errors.New("attestation_id must be empty while computing identity")
+ }
+ if err := validateTaggedHex(a.CeremonyID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("ceremony_id: %w", err)
+ }
+ if err := a.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(a.PhaseID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("phase_id: %w", err)
+ }
+ if a.Index == 0 || a.Index > MaxParticipants {
+ return fmt.Errorf("contribution index %d must be between 1 and %d", a.Index, MaxParticipants)
+ }
+ if err := validateID("participant_id", a.ParticipantID); err != nil {
+ return err
+ }
+ if err := validateID("participant_key_id", a.ParticipantKeyID); err != nil {
+ return err
+ }
+ if err := a.PreviousPayload.Validate(); err != nil {
+ return fmt.Errorf("previous_payload: %w", err)
+ }
+ if err := a.OutputPayload.Validate(); err != nil {
+ return fmt.Errorf("output_payload: %w", err)
+ }
+ if a.PreviousPayload.Digest.SHA256 == a.OutputPayload.Digest.SHA256 {
+ return errors.New("contribution output must differ from previous payload")
+ }
+ if err := validateTaggedHex(a.PreviousAcceptanceID, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("previous_acceptance_id: %w", err)
+ }
+ if err := a.ToolBinary.Validate(); err != nil {
+ return fmt.Errorf("tool_binary: %w", err)
+ }
+ if err := validateHex(a.SourceCommit, 20); err != nil {
+ return fmt.Errorf("source_commit: %w", err)
+ }
+ if a.GnarkVersion != GnarkVersion {
+ return fmt.Errorf("gnark_version %q, want %q", a.GnarkVersion, GnarkVersion)
+ }
+ if a.GnarkCryptoVersion != GnarkCryptoVersion {
+ return fmt.Errorf("gnark_crypto_version %q, want %q", a.GnarkCryptoVersion, GnarkCryptoVersion)
+ }
+ if a.DrandVersion != DrandVersion {
+ return fmt.Errorf("drand_version %q, want %q", a.DrandVersion, DrandVersion)
+ }
+ if err := a.Environment.Validate(); err != nil {
+ return fmt.Errorf("environment: %w", err)
+ }
+ return validateTimestamp("contributed_at", a.ContributedAt)
+}
diff --git a/internal/mpcceremony/attestation_test.go b/internal/mpcceremony/attestation_test.go
new file mode 100644
index 00000000..ff767d81
--- /dev/null
+++ b/internal/mpcceremony/attestation_test.go
@@ -0,0 +1,87 @@
+package mpcceremony
+
+import (
+ "testing"
+)
+
+func TestErasureAttestationBindsCompletedPostContributionDestruction(t *testing.T) {
+ contribution := adversarialAttestation(t)
+ erasure, err := NewErasureAttestation(ErasureAttestation{
+ CeremonyID: contribution.CeremonyID,
+ Phase: contribution.Phase,
+ PhaseID: contribution.PhaseID,
+ Index: contribution.Index,
+ ParticipantID: contribution.ParticipantID,
+ ParticipantKeyID: contribution.ParticipantKeyID,
+ ContributionAttestationID: contribution.AttestationID,
+ OutputPayload: contribution.OutputPayload,
+ DestroyedAt: "2026-07-23T12:00:01Z",
+ ProcessTerminated: true,
+ EphemeralStorageDestroyed: true,
+ NoBackupRetained: true,
+ })
+ if err != nil {
+ t.Fatalf("new erasure attestation: %v", err)
+ }
+ if err := ValidateErasureForContribution(contribution, erasure); err != nil {
+ t.Fatalf("valid erasure rejected: %v", err)
+ }
+
+ notAfter := erasure
+ notAfter.DestroyedAt = contribution.ContributedAt
+ notAfter, err = NewErasureAttestation(notAfter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateErasureForContribution(contribution, notAfter); err == nil {
+ t.Fatal("erasure at contribution time unexpectedly accepted")
+ }
+
+ wrongOutput := erasure
+ wrongOutput.OutputPayload = ArtifactRef{
+ Name: "phase1/another-output.bin",
+ Digest: NewDigest([]byte("another output")),
+ }
+ wrongOutput, err = NewErasureAttestation(wrongOutput)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateErasureForContribution(contribution, wrongOutput); err == nil {
+ t.Fatal("erasure for another output unexpectedly accepted")
+ }
+}
+
+func TestErasureAttestationRequiresAllNarrowClaims(t *testing.T) {
+ contribution := adversarialAttestation(t)
+ base := ErasureAttestation{
+ CeremonyID: contribution.CeremonyID,
+ Phase: contribution.Phase,
+ PhaseID: contribution.PhaseID,
+ Index: contribution.Index,
+ ParticipantID: contribution.ParticipantID,
+ ParticipantKeyID: contribution.ParticipantKeyID,
+ ContributionAttestationID: contribution.AttestationID,
+ OutputPayload: contribution.OutputPayload,
+ DestroyedAt: "2026-07-23T12:01:00Z",
+ ProcessTerminated: true,
+ EphemeralStorageDestroyed: true,
+ NoBackupRetained: true,
+ }
+ cases := []struct {
+ name string
+ mutate func(*ErasureAttestation)
+ }{
+ {"process", func(a *ErasureAttestation) { a.ProcessTerminated = false }},
+ {"storage", func(a *ErasureAttestation) { a.EphemeralStorageDestroyed = false }},
+ {"backup", func(a *ErasureAttestation) { a.NoBackupRetained = false }},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ candidate := base
+ test.mutate(&candidate)
+ if _, err := NewErasureAttestation(candidate); err == nil {
+ t.Fatal("incomplete erasure claim unexpectedly accepted")
+ }
+ })
+ }
+}
diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go
new file mode 100644
index 00000000..7d982761
--- /dev/null
+++ b/internal/mpcceremony/audit.go
@@ -0,0 +1,1543 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/consensys/gnark/backend/groth16"
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/artifact"
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/prover"
+)
+
+const (
+ OperationalEvidenceBundleFile = "operational/evidence-bundle.json"
+ OperationalEvidenceSignatureFile = "operational/evidence-bundle.sig"
+)
+
+type AuditOptions struct {
+ Replay ReplayPaths
+ Circuit *CompiledCircuit
+ CandidateDir string
+ AuditorID string
+ AuditorSigningKey string
+ OutPath string
+ SignatureOutPath string
+ AuditedAt time.Time
+}
+
+type AuditResult struct {
+ Record AuditRecord
+ RecordPath string
+ SignaturePath string
+}
+
+type AuditArtifact struct {
+ RecordPath string
+ SignaturePath string
+ LogicalName string
+}
+
+type SignReleaseOptions struct {
+ DefinitionPath string
+ DefinitionSignaturePath string
+ CoordinatorPublicKeyHex string
+ CandidateDir string
+ ReleaseDir string
+ Audits []AuditArtifact
+ OperationalEvidenceRoot string
+ OperationalBundlePath string
+ OperationalSignaturePath string
+ ReleaseSigningKey string
+ SignatureKeyID string
+ ReleasedAt time.Time
+}
+
+type SignReleaseResult struct {
+ ManifestPath string
+ ManifestSignature string
+ ManifestPublicKey string
+ FinalTranscript string
+ OperationalEvidence string
+ ChecksumsPath string
+}
+
+type VerifyReleaseOptions struct {
+ DefinitionPath string
+ DefinitionSignaturePath string
+ CoordinatorPublicKeyHex string
+ KeysDir string
+ TrustedPublicKeyHex string
+ ExpectedSignatureKeyID string
+ RequireProvingKey bool
+}
+
+type VerifyReleaseResult struct {
+ Manifest *artifact.KeyManifest
+ Transcript FinalTranscript
+ Candidate CandidateMetadata
+}
+
+// Audit independently replays both phases from explicit immutable paths,
+// reproduces the final native keys and Cardano verifier bytes, validates the
+// coordinator-signed candidate, and emits a signed passing audit record.
+func Audit(options AuditOptions) (*AuditResult, error) {
+ if options.Circuit == nil || options.Circuit.R1CS == nil {
+ return nil, errors.New("independently compiled destination-v2 circuit is required")
+ }
+ if options.AuditedAt.IsZero() || options.AuditedAt.Location() != time.UTC {
+ return nil, errors.New("audited_at must be a non-zero UTC time")
+ }
+ replay, err := loadReplay(options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyRunningSoftwareForMode(replay.definition.Software, replay.definition.Mode); err != nil {
+ return nil, fmt.Errorf("running auditor software: %w", err)
+ }
+ if err := ValidateCircuitBinding(options.Circuit, replay.definition.Circuit); err != nil {
+ return nil, err
+ }
+ auditor, ok := auditorByID(replay.definition, options.AuditorID)
+ if !ok {
+ return nil, fmt.Errorf("auditor %q is not enrolled in the ceremony definition", options.AuditorID)
+ }
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(options.AuditorSigningKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(auditor, publicKey); err != nil {
+ return nil, fmt.Errorf("auditor signing key: %w", err)
+ }
+ candidate, candidateRef, err := verifyCandidate(replay.definition, replay.definitionRef, options.CandidateDir)
+ if err != nil {
+ return nil, err
+ }
+ candidateTime, err := time.Parse(time.RFC3339Nano, candidate.FinalizedAt)
+ if err != nil {
+ return nil, err
+ }
+ if !options.AuditedAt.After(candidateTime) {
+ return nil, errors.New("audited_at must strictly postdate candidate finalization")
+ }
+ phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, options.CandidateDir)
+ if err != nil {
+ return nil, err
+ }
+ if err := ValidateSeal(replay.phase2Close, replay.phase2Beacon, phase2Seal); err != nil {
+ return nil, fmt.Errorf("candidate phase2 seal: %w", err)
+ }
+ replay.phase2Seal = phase2Seal
+ replayed, err := replayAll(options.Circuit, replay, options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ if err := compareCandidateToReplay(
+ options.Circuit,
+ replay,
+ replayed.pk,
+ replayed.vk,
+ candidate,
+ options.CandidateDir,
+ options.AuditedAt,
+ ); err != nil {
+ return nil, err
+ }
+ replayRoot, err := replayRootSHA256(candidate)
+ if err != nil {
+ return nil, err
+ }
+ outputs := candidateAuditOutputs(candidate, candidateRef)
+ record, err := NewAuditRecord(AuditRecord{
+ Schema: AuditRecordSchema,
+ CeremonyID: replay.definition.CeremonyID,
+ AuditorID: auditor.ID,
+ AuditorKeyID: auditor.KeyID,
+ Definition: replay.definitionRef,
+ Phase1Chain: replay.phase1ChainRef,
+ Phase2Chain: replay.phase2ChainRef,
+ Phase1SealID: replay.phase1Seal.SealID,
+ Phase2SealID: replay.phase2Seal.SealID,
+ ReplayRootSHA256: replayRoot,
+ Outputs: outputs,
+ Passed: true,
+ Findings: []string{},
+ AuditedAt: options.AuditedAt.Format(time.RFC3339Nano),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if filepath.Clean(options.OutPath) == filepath.Clean(options.SignatureOutPath) {
+ return nil, errors.New("audit record and signature output paths must differ")
+ }
+ if err := writeSignedRecordNoReplace(
+ options.OutPath,
+ options.SignatureOutPath,
+ record,
+ auditor.KeyID,
+ privateKey,
+ ); err != nil {
+ return nil, err
+ }
+ return &AuditResult{Record: record, RecordPath: options.OutPath, SignaturePath: options.SignatureOutPath}, nil
+}
+
+func compareCandidateToReplay(
+ circuit *CompiledCircuit,
+ replay loadedReplay,
+ pk groth16.ProvingKey,
+ vk groth16.VerifyingKey,
+ candidate CandidateMetadata,
+ dir string,
+ auditedAt time.Time,
+) error {
+ loadedCCS, err := ReadR1CSFile(filepath.Join(dir, candidate.ConstraintSystem.Name), replay.definition.Circuit)
+ if err != nil {
+ return fmt.Errorf("candidate frozen R1CS: %w", err)
+ }
+ if err := ValidateCircuitBinding(loadedCCS, circuit.Binding); err != nil {
+ return fmt.Errorf("candidate R1CS differs from independent compile: %w", err)
+ }
+ pkDigest, err := rawProvingKeyDigest(pk)
+ if err != nil {
+ return err
+ }
+ if pkDigest != candidate.ProvingKey.Digest {
+ return errors.New("independent replay proving key differs from candidate")
+ }
+ vkDigest, err := writerDigest(vk)
+ if err != nil {
+ return err
+ }
+ if vkDigest != candidate.VerifyingKey.Digest {
+ return errors.New("independent replay verifying key differs from candidate")
+ }
+ cardanoVK, format, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ return err
+ }
+ if format != expectedCardanoBSB22 || len(cardanoVK) != prover.CardanoVKCommitmentLen {
+ return errors.New("independent replay Cardano verifying key is not exact BSB22 encoding")
+ }
+ if NewDigest(cardanoVK) != candidate.CardanoVerifyingKey.Digest {
+ return errors.New("independent replay Cardano verifying key differs from candidate")
+ }
+ if err := verifyCardanoFiles(dir, candidate, vk); err != nil {
+ return err
+ }
+ var candidateReport VerificationReport
+ if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &candidateReport); err != nil {
+ return err
+ }
+ if candidateReport.CardanoVKRawDigest != NewDigest(cardanoVK) ||
+ candidateReport.CardanoVKBytes != len(cardanoVK) ||
+ candidateReport.CardanoVKFormat != format ||
+ candidateReport.CardanoProofBytes != prover.CardanoProofCommitmentLen ||
+ candidateReport.CardanoProofFormat != expectedCardanoBSB22 ||
+ !candidateReport.NativeProofVerified ||
+ !candidateReport.WrongCredentialRejected ||
+ !candidateReport.WrongDestinationRejected ||
+ !candidateReport.WrongDigestRejected ||
+ !candidateReport.WrongProofRejected ||
+ !candidateReport.WrongVKRejected ||
+ !candidateReport.ProofTruncationRejected ||
+ !candidateReport.ProofAppendRejected {
+ return errors.New("candidate verification report is not reproduced by independent evidence")
+ }
+ if err := verifyPublicFinalizationEvidence(dir, candidate, candidateReport); err != nil {
+ return err
+ }
+ if _, _, _, err := loadAndVerifyPublicEvidence(
+ filepath.Join(dir, candidate.PublicEvidence.Name),
+ replay.definition.CeremonyID,
+ vk,
+ cardanoVK,
+ candidate.CardanoVerifyingKey,
+ ); err != nil {
+ return fmt.Errorf("independent native public-evidence verification: %w", err)
+ }
+ return nil
+}
+
+// SignRelease validates at least two distinct, enrolled, signed passing
+// audits, assembles the final setup transcript and key manifest without
+// replacing candidate files, then signs the exact manifest with the distinct
+// pre-existing release key.
+func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) {
+ if options.ReleasedAt.IsZero() || options.ReleasedAt.Location() != time.UTC {
+ return nil, errors.New("released_at must be a non-zero UTC time")
+ }
+ var definition CeremonyDefinition
+ coordinatorPublicKey, err := keybundle.DecodePublicKeyHex(options.CoordinatorPublicKeyHex)
+ if err != nil {
+ return nil, fmt.Errorf("trusted coordinator public key: %w", err)
+ }
+ definitionRef, err := readTrustedDefinition(
+ options.DefinitionPath,
+ options.DefinitionSignaturePath,
+ coordinatorPublicKey,
+ &definition,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(definition.Coordinator, coordinatorPublicKey); err != nil {
+ return nil, err
+ }
+ if err := VerifyRunningSoftwareForMode(definition.Software, definition.Mode); err != nil {
+ return nil, fmt.Errorf("running release-signing software: %w", err)
+ }
+ candidate, _, err := verifyCandidate(definition, definitionRef, options.CandidateDir)
+ if err != nil {
+ return nil, err
+ }
+ if options.SignatureKeyID != definition.ReleaseSigner.KeyID {
+ return nil, fmt.Errorf(
+ "release signature key id %q, want signed definition key id %q",
+ options.SignatureKeyID,
+ definition.ReleaseSigner.KeyID,
+ )
+ }
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(options.ReleaseSigningKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(definition.ReleaseSigner, publicKey); err != nil {
+ return nil, fmt.Errorf("release signing key: %w", err)
+ }
+ _, latestAudit, err := verifyPassingAudits(definition, candidate, options.Audits)
+ if err != nil {
+ return nil, err
+ }
+ if err := validateReleaseChronology(options.ReleasedAt, latestAudit); err != nil {
+ return nil, err
+ }
+ operationalEvidence, err := verifyReleaseOperationalEvidence(
+ definition,
+ coordinatorPublicKey,
+ candidate,
+ options.OperationalEvidenceRoot,
+ options.OperationalBundlePath,
+ options.OperationalSignaturePath,
+ options.ReleasedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("verify required operational evidence: %w", err)
+ }
+ if filepath.Clean(options.ReleaseDir) == filepath.Clean(options.CandidateDir) {
+ return nil, errors.New("release directory must be distinct from candidate directory")
+ }
+ stagingDir, err := createRecoveryStagingDir(options.ReleaseDir)
+ if err != nil {
+ return nil, err
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ _ = os.RemoveAll(stagingDir)
+ }
+ }()
+ for _, name := range append(candidateChecksumNames(), CandidateChecksumsFile) {
+ if err := copyRegularNoReplace(
+ filepath.Join(options.CandidateDir, name),
+ filepath.Join(stagingDir, name),
+ ); err != nil {
+ return nil, err
+ }
+ }
+ bundledAudits, err := bundleAuditArtifacts(options.Audits, stagingDir)
+ if err != nil {
+ return nil, err
+ }
+ auditRefs, _, err := verifyPassingAudits(definition, candidate, bundledAudits)
+ if err != nil {
+ return nil, fmt.Errorf("verify bundled audits: %w", err)
+ }
+ reservedReleaseNames := append(
+ releaseChecksumNames(len(bundledAudits), nil),
+ ReleaseChecksumsFile,
+ )
+ if err := copyOperationalEvidence(
+ options.OperationalEvidenceRoot,
+ stagingDir,
+ operationalEvidence,
+ reservedReleaseNames,
+ ); err != nil {
+ return nil, fmt.Errorf("bundle operational evidence: %w", err)
+ }
+ bundledOperationalEvidence, err := verifyReleaseOperationalEvidence(
+ definition,
+ coordinatorPublicKey,
+ candidate,
+ stagingDir,
+ filepath.Join(stagingDir, filepath.FromSlash(OperationalEvidenceBundleFile)),
+ filepath.Join(stagingDir, filepath.FromSlash(OperationalEvidenceSignatureFile)),
+ options.ReleasedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("verify bundled operational evidence: %w", err)
+ }
+ if !reflect.DeepEqual(bundledOperationalEvidence, operationalEvidence) {
+ return nil, errors.New("bundled operational evidence differs from verified release input")
+ }
+ transcript, err := NewFinalTranscript(FinalTranscript{
+ Schema: FinalTranscriptSchema,
+ CeremonyID: definition.CeremonyID,
+ Definition: definitionRef,
+ Circuit: definition.Circuit,
+ Phase1: candidate.Phase1,
+ Phase2: candidate.Phase2,
+ Audits: auditRefs,
+ OperationalEvidence: operationalEvidence.BundleRef,
+ ProvingKey: candidate.ProvingKey,
+ VerifyingKey: candidate.VerifyingKey,
+ CardanoVerifyingKey: candidate.CardanoVerifyingKey,
+ FinalizedAt: options.ReleasedAt.Format(time.RFC3339Nano),
+ })
+ if err != nil {
+ return nil, err
+ }
+ transcriptBytes, err := MarshalCanonical(transcript)
+ if err != nil {
+ return nil, err
+ }
+ transcriptPath := filepath.Join(stagingDir, FinalTranscriptFile)
+ if err := writeBytesNoReplace(transcriptPath, transcriptBytes, 0o600); err != nil {
+ return nil, err
+ }
+ manifest := artifact.KeyManifest{
+ Schema: artifact.ManifestSchema,
+ KeyVersion: definition.Circuit.KeyVersion,
+ CircuitID: definition.Circuit.CircuitID,
+ Curve: definition.Circuit.Curve,
+ Backend: definition.Circuit.Backend,
+ VKHash: candidate.VerifyingKey.Digest.Blake2b256,
+ ProvingKeySHA256: candidate.ProvingKey.Digest.SHA256,
+ ProvingKeyBlake2b256: candidate.ProvingKey.Digest.Blake2b256,
+ ProvingKeySize: candidate.ProvingKey.Digest.Size,
+ VerifyingKeySHA256: candidate.VerifyingKey.Digest.SHA256,
+ VerifyingKeySize: candidate.VerifyingKey.Digest.Size,
+ ConstraintSystemHash: candidate.ConstraintSystem.Digest.Blake2b256,
+ CircuitSourceCommit: definition.Software.SourceCommit,
+ ProofToolVersion: definition.Software.ProofToolVersion,
+ GnarkVersion: definition.Software.GnarkVersion,
+ SetupTranscriptHash: NewDigest(transcriptBytes).Blake2b256,
+ PublishedAt: options.ReleasedAt.Format(time.RFC3339Nano),
+ SignatureKeyID: definition.ReleaseSigner.KeyID,
+ }
+ manifestBytes, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ manifestBytes = append(manifestBytes, '\n')
+ manifestPath := filepath.Join(stagingDir, keybundle.ManifestFile)
+ if err := writeBytesNoReplace(manifestPath, manifestBytes, 0o600); err != nil {
+ return nil, err
+ }
+ signaturePath := filepath.Join(stagingDir, keybundle.ManifestSignatureFile)
+ signatureHex := hex.EncodeToString(ed25519.Sign(privateKey, manifestBytes)) + "\n"
+ if err := writeBytesNoReplace(signaturePath, []byte(signatureHex), 0o600); err != nil {
+ return nil, err
+ }
+ publicKeyPath := filepath.Join(stagingDir, keybundle.ManifestPublicKeyFile)
+ if err := writeBytesNoReplace(publicKeyPath, []byte(hex.EncodeToString(publicKey)+"\n"), 0o600); err != nil {
+ return nil, err
+ }
+ checksumsPath := filepath.Join(stagingDir, ReleaseChecksumsFile)
+ if err := writeChecksumsNoReplace(
+ stagingDir,
+ checksumsPath,
+ releaseChecksumNames(len(bundledAudits), operationalEvidence.Names),
+ ); err != nil {
+ return nil, err
+ }
+ if _, err := VerifyRelease(VerifyReleaseOptions{
+ DefinitionPath: options.DefinitionPath,
+ DefinitionSignaturePath: options.DefinitionSignaturePath,
+ CoordinatorPublicKeyHex: options.CoordinatorPublicKeyHex,
+ KeysDir: stagingDir,
+ TrustedPublicKeyHex: hex.EncodeToString(publicKey),
+ ExpectedSignatureKeyID: options.SignatureKeyID,
+ RequireProvingKey: true,
+ }); err != nil {
+ return nil, fmt.Errorf("strict release self-verification: %w", err)
+ }
+ if err := syncDirectory(stagingDir); err != nil {
+ return nil, err
+ }
+ if err := publishReleaseDirectory(stagingDir, options.ReleaseDir); err != nil {
+ return nil, fmt.Errorf("atomically publish fresh release directory: %w", err)
+ }
+ committed = true
+ return &SignReleaseResult{
+ ManifestPath: filepath.Join(options.ReleaseDir, keybundle.ManifestFile),
+ ManifestSignature: filepath.Join(options.ReleaseDir, keybundle.ManifestSignatureFile),
+ ManifestPublicKey: filepath.Join(options.ReleaseDir, keybundle.ManifestPublicKeyFile),
+ FinalTranscript: filepath.Join(options.ReleaseDir, FinalTranscriptFile),
+ OperationalEvidence: filepath.Join(
+ options.ReleaseDir,
+ filepath.FromSlash(OperationalEvidenceBundleFile),
+ ),
+ ChecksumsPath: filepath.Join(options.ReleaseDir, ReleaseChecksumsFile),
+ }, nil
+}
+
+// VerifyRelease authenticates the manifest using only the caller-supplied
+// trust key and rechecks the final transcript, independent audits, frozen
+// circuit, native keys, Cardano export, candidate signature, and checksums.
+func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) {
+ if strings.TrimSpace(options.TrustedPublicKeyHex) == "" {
+ return nil, errors.New("out-of-band trusted release public key is required")
+ }
+ if !options.RequireProvingKey {
+ return nil, errors.New("production release verification requires the native proving key")
+ }
+ var definition CeremonyDefinition
+ coordinatorPublicKey, err := keybundle.DecodePublicKeyHex(options.CoordinatorPublicKeyHex)
+ if err != nil {
+ return nil, fmt.Errorf("trusted coordinator public key: %w", err)
+ }
+ definitionRef, err := readTrustedDefinition(
+ options.DefinitionPath,
+ options.DefinitionSignaturePath,
+ coordinatorPublicKey,
+ &definition,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(definition.Coordinator, coordinatorPublicKey); err != nil {
+ return nil, err
+ }
+ if options.ExpectedSignatureKeyID != definition.ReleaseSigner.KeyID {
+ return nil, errors.New("expected release signature key id does not match ceremony definition")
+ }
+ trustedKey, err := keybundle.DecodePublicKeyHex(options.TrustedPublicKeyHex)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(definition.ReleaseSigner, trustedKey); err != nil {
+ return nil, fmt.Errorf("trusted release public key: %w", err)
+ }
+ manifest, err := keybundle.Verify(keybundle.VerifyOptions{
+ KeysDir: options.KeysDir,
+ KeyVersion: definition.Circuit.KeyVersion,
+ PublicKeyHex: options.TrustedPublicKeyHex,
+ ExpectedSignatureKeyID: options.ExpectedSignatureKeyID,
+ RequireProvingKey: options.RequireProvingKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ bundledPublicKey, err := readRegularFile(filepath.Join(options.KeysDir, keybundle.ManifestPublicKeyFile))
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(string(bundledPublicKey)) != strings.ToLower(strings.TrimSpace(options.TrustedPublicKeyHex)) {
+ return nil, errors.New("bundled release public key differs from out-of-band trust key")
+ }
+ candidate, _, err := verifyCandidate(definition, definitionRef, options.KeysDir)
+ if err != nil {
+ return nil, err
+ }
+ var transcript FinalTranscript
+ transcriptRef, err := readCanonicalFile(filepath.Join(options.KeysDir, FinalTranscriptFile), &transcript)
+ if err != nil {
+ return nil, err
+ }
+ bundledAudits, err := bundledAuditsForTranscript(options.KeysDir, transcript.Audits)
+ if err != nil {
+ return nil, err
+ }
+ auditRefs, latestAudit, err := verifyPassingAudits(definition, candidate, bundledAudits)
+ if err != nil {
+ return nil, err
+ }
+ transcriptTime, _ := time.Parse(time.RFC3339Nano, transcript.FinalizedAt)
+ if err := validateReleaseChronology(transcriptTime, latestAudit); err != nil {
+ return nil, fmt.Errorf("final transcript: %w", err)
+ }
+ operationalEvidence, err := verifyReleaseOperationalEvidence(
+ definition,
+ coordinatorPublicKey,
+ candidate,
+ options.KeysDir,
+ filepath.Join(options.KeysDir, filepath.FromSlash(OperationalEvidenceBundleFile)),
+ filepath.Join(options.KeysDir, filepath.FromSlash(OperationalEvidenceSignatureFile)),
+ transcriptTime,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("required operational evidence: %w", err)
+ }
+ if transcript.CeremonyID != definition.CeremonyID ||
+ transcript.Definition != definitionRef ||
+ !equalCircuitBinding(transcript.Circuit, definition.Circuit) ||
+ !reflect.DeepEqual(transcript.Phase1, candidate.Phase1) ||
+ !reflect.DeepEqual(transcript.Phase2, candidate.Phase2) ||
+ transcript.ProvingKey != candidate.ProvingKey ||
+ transcript.VerifyingKey != candidate.VerifyingKey ||
+ transcript.CardanoVerifyingKey != candidate.CardanoVerifyingKey ||
+ transcript.OperationalEvidence != operationalEvidence.BundleRef ||
+ !slices.Equal(transcript.Audits, auditRefs) {
+ return nil, errors.New(
+ "final transcript does not exactly bind candidate, definition, audits, and operational evidence",
+ )
+ }
+ if manifest.SetupTranscriptHash != transcriptRef.Digest.Blake2b256 {
+ return nil, errors.New("manifest setup_transcript_hash does not match final transcript")
+ }
+ if manifest.PublishedAt != transcript.FinalizedAt {
+ return nil, errors.New("manifest published_at does not match final transcript release time")
+ }
+ if manifest.KeyVersion != definition.Circuit.KeyVersion ||
+ manifest.CircuitID != definition.Circuit.CircuitID ||
+ manifest.Curve != definition.Circuit.Curve ||
+ manifest.Backend != definition.Circuit.Backend ||
+ manifest.CircuitSourceCommit != definition.Software.SourceCommit ||
+ manifest.ProofToolVersion != definition.Software.ProofToolVersion ||
+ manifest.GnarkVersion != definition.Software.GnarkVersion ||
+ manifest.ConstraintSystemHash != candidate.ConstraintSystem.Digest.Blake2b256 ||
+ manifest.VKHash != candidate.VerifyingKey.Digest.Blake2b256 ||
+ manifest.ProvingKeySHA256 != candidate.ProvingKey.Digest.SHA256 ||
+ manifest.ProvingKeyBlake2b256 != candidate.ProvingKey.Digest.Blake2b256 ||
+ manifest.ProvingKeySize != candidate.ProvingKey.Digest.Size ||
+ manifest.VerifyingKeySHA256 != candidate.VerifyingKey.Digest.SHA256 ||
+ manifest.VerifyingKeySize != candidate.VerifyingKey.Digest.Size ||
+ len(manifest.ArtifactURLs) != 0 {
+ return nil, errors.New("manifest does not exactly bind candidate key artifacts and signed provenance")
+ }
+ if _, err := ReadR1CSFile(filepath.Join(options.KeysDir, candidate.ConstraintSystem.Name), definition.Circuit); err != nil {
+ return nil, err
+ }
+ vk, err := prover.LoadVK(filepath.Join(options.KeysDir, NativeVerifyingKeyFile))
+ if err != nil {
+ return nil, err
+ }
+ if err := verifyCardanoFiles(options.KeysDir, candidate, vk); err != nil {
+ return nil, err
+ }
+ if err := verifyChecksumsExact(
+ options.KeysDir,
+ filepath.Join(options.KeysDir, CandidateChecksumsFile),
+ candidateChecksumNames(),
+ ); err != nil {
+ return nil, err
+ }
+ if err := verifyChecksumsExact(
+ options.KeysDir,
+ filepath.Join(options.KeysDir, ReleaseChecksumsFile),
+ releaseChecksumNames(len(bundledAudits), operationalEvidence.Names),
+ ); err != nil {
+ return nil, err
+ }
+ if err := verifyReleaseTreeExact(
+ options.KeysDir,
+ len(bundledAudits),
+ operationalEvidence.Names,
+ ); err != nil {
+ return nil, err
+ }
+ return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate}, nil
+}
+
+func verifyCandidate(
+ definition CeremonyDefinition,
+ definitionRef ArtifactRef,
+ dir string,
+) (CandidateMetadata, ArtifactRef, error) {
+ candidateBytes, err := readRegularFile(filepath.Join(dir, CandidateMetadataFile))
+ if err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ signatureBytes, err := readRegularFile(filepath.Join(dir, CandidateSignatureFile))
+ if err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ publicKey, err := keybundle.DecodePublicKeyHex(definition.Coordinator.Ed25519PublicKeyHex)
+ if err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ var candidate CandidateMetadata
+ if err := VerifySignedRecord(
+ candidateBytes,
+ signatureBytes,
+ &candidate,
+ definition.Coordinator.KeyID,
+ publicKey,
+ ); err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, fmt.Errorf("candidate coordinator signature: %w", err)
+ }
+ if candidate.CeremonyID != definition.CeremonyID ||
+ candidate.Definition != definitionRef ||
+ !equalCircuitBinding(candidate.Circuit, definition.Circuit) ||
+ candidate.CoordinatorID != definition.Coordinator.ID ||
+ candidate.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return CandidateMetadata{}, ArtifactRef{}, errors.New("candidate does not exactly bind ceremony definition")
+ }
+ for _, ref := range candidateFileRefs(candidate) {
+ actual, err := artifactRefForFile(ref.Name, filepath.Join(dir, ref.Name))
+ if err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ if actual != ref {
+ return CandidateMetadata{}, ArtifactRef{}, fmt.Errorf("candidate artifact %q digest mismatch", ref.Name)
+ }
+ }
+ if err := verifyChecksumsExact(
+ dir,
+ filepath.Join(dir, CandidateChecksumsFile),
+ candidateChecksumNames(),
+ ); err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ var report VerificationReport
+ if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &report); err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ if err := verifyPublicFinalizationEvidence(dir, candidate, report); err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ if _, err := loadCandidatePhase2Seal(definition, candidate, dir); err != nil {
+ return CandidateMetadata{}, ArtifactRef{}, err
+ }
+ ref := ArtifactRef{Name: CandidateMetadataFile, Digest: NewDigest(candidateBytes)}
+ return candidate, ref, nil
+}
+
+func candidateFileRefs(candidate CandidateMetadata) []ArtifactRef {
+ return []ArtifactRef{
+ candidate.ConstraintSystem,
+ candidate.ProvingKey,
+ candidate.VerifyingKey,
+ candidate.CardanoVerifyingKey,
+ candidate.CardanoVKHex,
+ candidate.CardanoVKFormat,
+ candidate.VerificationReport,
+ candidate.PublicEvidence,
+ candidate.Phase2SealRecord,
+ }
+}
+
+func candidateAuditOutputs(candidate CandidateMetadata, candidateRef ArtifactRef) []ArtifactRef {
+ return []ArtifactRef{
+ candidateRef,
+ candidate.ConstraintSystem,
+ candidate.ProvingKey,
+ candidate.VerifyingKey,
+ candidate.CardanoVerifyingKey,
+ candidate.CardanoVKHex,
+ candidate.CardanoVKFormat,
+ candidate.VerificationReport,
+ candidate.PublicEvidence,
+ candidate.Phase2SealRecord,
+ }
+}
+
+func verifyPublicFinalizationEvidence(
+ dir string,
+ candidate CandidateMetadata,
+ report VerificationReport,
+) error {
+ if report.CeremonyID != candidate.CeremonyID {
+ return errors.New("verification report ceremony id differs from candidate")
+ }
+ if report.PublicEvidence != candidate.PublicEvidence {
+ return errors.New("verification report does not hash-bind candidate public evidence")
+ }
+ if report.CardanoVKRawDigest != candidate.CardanoVerifyingKey.Digest {
+ return errors.New("verification report Cardano key digest differs from candidate")
+ }
+ var evidence PublicFinalizationEvidence
+ ref, err := readCanonicalFile(filepath.Join(dir, candidate.PublicEvidence.Name), &evidence)
+ if err != nil {
+ return err
+ }
+ ref.Name = candidate.PublicEvidence.Name
+ if ref != candidate.PublicEvidence {
+ return errors.New("public finalization evidence artifact digest mismatch")
+ }
+ if evidence.CeremonyID != candidate.CeremonyID ||
+ evidence.CardanoVerifyingKey != candidate.CardanoVerifyingKey {
+ return errors.New("public finalization evidence does not bind candidate ceremony and Cardano key")
+ }
+ if evidence.CardanoProofRawDigest != report.CardanoProofRawDigest {
+ return errors.New("verification report proof digest differs from public finalization evidence")
+ }
+ return nil
+}
+
+func loadCandidatePhase2Seal(
+ definition CeremonyDefinition,
+ candidate CandidateMetadata,
+ dir string,
+) (SealRecord, error) {
+ recordPath := filepath.Join(dir, candidate.Phase2SealRecord.Name)
+ recordBytes, err := readRegularFile(recordPath)
+ if err != nil {
+ return SealRecord{}, err
+ }
+ if actual := (ArtifactRef{Name: candidate.Phase2SealRecord.Name, Digest: NewDigest(recordBytes)}); actual != candidate.Phase2SealRecord {
+ return SealRecord{}, errors.New("candidate phase2 seal record digest mismatch")
+ }
+ signatureBytes, err := readRegularFile(filepath.Join(dir, Phase2SealSignatureFile))
+ if err != nil {
+ return SealRecord{}, err
+ }
+ publicKey, err := keybundle.DecodePublicKeyHex(definition.Coordinator.Ed25519PublicKeyHex)
+ if err != nil {
+ return SealRecord{}, err
+ }
+ var seal SealRecord
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &seal,
+ definition.Coordinator.KeyID,
+ publicKey,
+ ); err != nil {
+ return SealRecord{}, fmt.Errorf("phase2 seal signature: %w", err)
+ }
+ if seal.CeremonyID != candidate.CeremonyID ||
+ seal.Phase != Phase2 ||
+ seal.PhaseID != candidate.Phase2.PhaseID ||
+ seal.CloseID != candidate.Phase2.CloseID ||
+ seal.BeaconID != candidate.Phase2.BeaconID ||
+ seal.SealID != candidate.Phase2.SealID ||
+ !slices.Equal(seal.Outputs, candidate.Phase2.Outputs) {
+ return SealRecord{}, errors.New("phase2 seal does not exactly bind candidate phase summary")
+ }
+ return seal, nil
+}
+
+func verifyCardanoFiles(dir string, candidate CandidateMetadata, vk groth16.VerifyingKey) error {
+ raw, format, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ return err
+ }
+ if format != expectedCardanoBSB22 ||
+ len(raw) != prover.CardanoVKCommitmentLen ||
+ NewDigest(raw) != candidate.CardanoVerifyingKey.Digest {
+ return errors.New("cardano verifying key is not coherent exact BSB22 bytes")
+ }
+ storedRaw, err := readRegularFile(filepath.Join(dir, candidate.CardanoVerifyingKey.Name))
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(storedRaw, raw) {
+ return errors.New("cardano raw verifying-key file differs from native serializer")
+ }
+ storedHex, err := readRegularFile(filepath.Join(dir, candidate.CardanoVKHex.Name))
+ if err != nil {
+ return err
+ }
+ expectedHex := hex.EncodeToString(raw) + "\n"
+ if string(storedHex) != expectedHex {
+ return errors.New("cardano verifying-key hex is not exact lowercase hex of raw bytes")
+ }
+ storedFormat, err := readRegularFile(filepath.Join(dir, candidate.CardanoVKFormat.Name))
+ if err != nil {
+ return err
+ }
+ if string(storedFormat) != format+"\n" {
+ return errors.New("cardano verifying-key format file does not match native serializer")
+ }
+ return nil
+}
+
+func verifyPassingAudits(
+ definition CeremonyDefinition,
+ candidate CandidateMetadata,
+ inputs []AuditArtifact,
+) ([]ArtifactRef, time.Time, error) {
+ if len(inputs) < 2 {
+ return nil, time.Time{}, errors.New("at least two independently signed audit reports are required")
+ }
+ replayRoot, err := replayRootSHA256(candidate)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ candidateTime, err := time.Parse(time.RFC3339Nano, candidate.FinalizedAt)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ seenAuditor := make(map[string]struct{}, len(inputs))
+ seenKey := make(map[string]struct{}, len(inputs))
+ refs := make([]ArtifactRef, 0, len(inputs))
+ var latestAudit time.Time
+ candidateBytes, err := MarshalCanonical(candidate)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ expectedOutputs := candidateAuditOutputs(candidate, ArtifactRef{
+ Name: CandidateMetadataFile,
+ Digest: NewDigest(candidateBytes),
+ })
+ for index, input := range inputs {
+ recordBytes, err := readRegularFile(input.RecordPath)
+ if err != nil {
+ return nil, time.Time{}, fmt.Errorf("audit %d: %w", index, err)
+ }
+ signatureBytes, err := readRegularFile(input.SignaturePath)
+ if err != nil {
+ return nil, time.Time{}, fmt.Errorf("audit %d signature: %w", index, err)
+ }
+ var unsigned AuditRecord
+ if err := UnmarshalCanonical(recordBytes, &unsigned); err != nil {
+ return nil, time.Time{}, fmt.Errorf("audit %d: %w", index, err)
+ }
+ auditor, ok := auditorByID(definition, unsigned.AuditorID)
+ if !ok || auditor.KeyID != unsigned.AuditorKeyID {
+ return nil, time.Time{}, fmt.Errorf("audit %d signer is not enrolled", index)
+ }
+ publicKey, err := keybundle.DecodePublicKeyHex(auditor.Ed25519PublicKeyHex)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ var record AuditRecord
+ if err := VerifySignedRecord(recordBytes, signatureBytes, &record, auditor.KeyID, publicKey); err != nil {
+ return nil, time.Time{}, fmt.Errorf("audit %d signature: %w", index, err)
+ }
+ if !record.Passed || len(record.Findings) != 0 {
+ return nil, time.Time{}, fmt.Errorf("audit %d is not a passing audit", index)
+ }
+ if record.CeremonyID != definition.CeremonyID ||
+ record.Definition != candidate.Definition ||
+ record.Phase1Chain != candidate.Phase1.Chain ||
+ record.Phase2Chain != candidate.Phase2.Chain ||
+ record.Phase1SealID != candidate.Phase1.SealID ||
+ record.Phase2SealID != candidate.Phase2.SealID ||
+ record.ReplayRootSHA256 != replayRoot {
+ return nil, time.Time{}, fmt.Errorf("audit %d does not bind this exact candidate replay", index)
+ }
+ if !slices.Equal(record.Outputs, expectedOutputs) {
+ return nil, time.Time{}, fmt.Errorf("audit %d outputs are not the exact candidate output set", index)
+ }
+ auditedAt, _ := time.Parse(time.RFC3339Nano, record.AuditedAt)
+ if !auditedAt.After(candidateTime) {
+ return nil, time.Time{}, fmt.Errorf("audit %d does not strictly postdate candidate finalization", index)
+ }
+ if auditedAt.After(latestAudit) {
+ latestAudit = auditedAt
+ }
+ if _, duplicate := seenAuditor[record.AuditorID]; duplicate {
+ return nil, time.Time{}, fmt.Errorf("auditor %q appears more than once", record.AuditorID)
+ }
+ if _, duplicate := seenKey[record.AuditorKeyID]; duplicate {
+ return nil, time.Time{}, fmt.Errorf("auditor key %q appears more than once", record.AuditorKeyID)
+ }
+ seenAuditor[record.AuditorID] = struct{}{}
+ seenKey[record.AuditorKeyID] = struct{}{}
+ name := input.LogicalName
+ if name == "" {
+ name = filepath.Base(input.RecordPath)
+ }
+ ref := ArtifactRef{Name: name, Digest: NewDigest(recordBytes)}
+ if err := ref.Validate(); err != nil {
+ return nil, time.Time{}, err
+ }
+ refs = append(refs, ref)
+ }
+ return refs, latestAudit, nil
+}
+
+func validateReleaseChronology(releasedAt, latestAudit time.Time) error {
+ if !releasedAt.After(latestAudit) {
+ return errors.New("released_at must strictly postdate every accepted independent audit")
+ }
+ return nil
+}
+
+func auditorByID(definition CeremonyDefinition, id string) (Identity, bool) {
+ for _, auditor := range definition.Auditors {
+ if auditor.ID == id {
+ return auditor, true
+ }
+ }
+ return Identity{}, false
+}
+
+func replayRootSHA256(candidate CandidateMetadata) (string, error) {
+ value := struct {
+ CandidateID string `json:"candidate_id"`
+ Phase1SealID string `json:"phase1_seal_id"`
+ Phase2SealID string `json:"phase2_seal_id"`
+ ProvingKey ArtifactRef `json:"proving_key"`
+ VerifyingKey ArtifactRef `json:"verifying_key"`
+ CardanoVK ArtifactRef `json:"cardano_vk"`
+ }{
+ candidate.CandidateID,
+ candidate.Phase1.SealID,
+ candidate.Phase2.SealID,
+ candidate.ProvingKey,
+ candidate.VerifyingKey,
+ candidate.CardanoVerifyingKey,
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/full-replay/v1", value)
+}
+
+func rawProvingKeyDigest(pk groth16.ProvingKey) (Digest, error) {
+ raw, ok := pk.(interface {
+ WriteRawTo(io.Writer) (int64, error)
+ })
+ if !ok {
+ return Digest{}, fmt.Errorf("proving key type %T does not support native raw serialization", pk)
+ }
+ return streamingDigest(raw.WriteRawTo)
+}
+
+func streamingDigest(write func(io.Writer) (int64, error)) (Digest, error) {
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return Digest{}, err
+ }
+ n, err := write(io.MultiWriter(sha, blake))
+ if err != nil {
+ return Digest{}, err
+ }
+ digest := Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake.Sum(nil)),
+ Size: n,
+ }
+ return digest, digest.Validate()
+}
+
+func verifyChecksumsExact(dir, checksumPath string, expectedNames []string) error {
+ data, err := readRegularFile(checksumPath)
+ if err != nil {
+ return err
+ }
+ checksumName, err := logicalPathWithin(dir, checksumPath)
+ if err != nil {
+ return fmt.Errorf("checksum file path: %w", err)
+ }
+ lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
+ if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
+ return errors.New("checksum file is empty")
+ }
+ expected := append([]string(nil), expectedNames...)
+ slices.Sort(expected)
+ if len(lines) != len(expected) {
+ return fmt.Errorf("checksum file has %d entries, want exactly %d", len(lines), len(expected))
+ }
+ seen := make(map[string]struct{}, len(lines))
+ for index, line := range lines {
+ if len(line) < 67 || line[64:66] != " " {
+ return errors.New("invalid checksum line")
+ }
+ hashHex, name := line[:64], line[66:]
+ if _, err := hex.DecodeString(hashHex); err != nil {
+ return errors.New("invalid checksum hash")
+ }
+ if err := validateArtifactName(name); err != nil || name == checksumName {
+ return errors.New("invalid checksum artifact name")
+ }
+ if name != expected[index] {
+ return fmt.Errorf("checksum entry %d is %q, want %q", index, name, expected[index])
+ }
+ if _, duplicate := seen[name]; duplicate {
+ return fmt.Errorf("duplicate checksum for %q", name)
+ }
+ seen[name] = struct{}{}
+ path, err := resolveArtifactPath(dir, name)
+ if err != nil {
+ return err
+ }
+ ref, err := artifactRefForFile(name, path)
+ if err != nil {
+ return err
+ }
+ if strings.TrimPrefix(ref.Digest.SHA256, "sha256:") != hashHex {
+ return fmt.Errorf("checksum mismatch for %q", name)
+ }
+ }
+ return nil
+}
+
+func candidateChecksumNames() []string {
+ return []string{
+ prover.DestinationConstraintSystemFile,
+ NativeProvingKeyFile,
+ NativeVerifyingKeyFile,
+ CardanoVKBytesFile,
+ CardanoVKHexFile,
+ CardanoVKFormatFile,
+ VerificationReportFile,
+ PublicEvidenceFile,
+ Phase2SealFile,
+ Phase2SealSignatureFile,
+ CandidateMetadataFile,
+ CandidateSignatureFile,
+ }
+}
+
+func releaseChecksumNames(auditCount int, operationalNames []string) []string {
+ names := append(candidateChecksumNames(),
+ CandidateChecksumsFile,
+ FinalTranscriptFile,
+ keybundle.ManifestFile,
+ keybundle.ManifestSignatureFile,
+ keybundle.ManifestPublicKeyFile,
+ )
+ for index := 0; index < auditCount; index++ {
+ names = append(
+ names,
+ fmt.Sprintf("audits/%04d.json", index+1),
+ fmt.Sprintf("audits/%04d.sig", index+1),
+ )
+ }
+ names = append(names, operationalNames...)
+ return names
+}
+
+type verifiedReleaseOperationalEvidence struct {
+ Verified VerifiedOperationalEvidence
+ BundleRef SignedArtifactRefs
+ Names []string
+}
+
+func verifyReleaseOperationalEvidence(
+ definition CeremonyDefinition,
+ coordinatorPublicKey ed25519.PublicKey,
+ candidate CandidateMetadata,
+ root, bundlePath, signaturePath string,
+ releasedAt time.Time,
+) (verifiedReleaseOperationalEvidence, error) {
+ if strings.TrimSpace(root) == "" {
+ return verifiedReleaseOperationalEvidence{}, errors.New("operational evidence root is required")
+ }
+ bundleName, err := logicalPathWithin(root, bundlePath)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf("operational evidence bundle path: %w", err)
+ }
+ if bundleName != OperationalEvidenceBundleFile {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf(
+ "operational evidence bundle name %q, want %q",
+ bundleName,
+ OperationalEvidenceBundleFile,
+ )
+ }
+ signatureName, err := logicalPathWithin(root, signaturePath)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf("operational evidence signature path: %w", err)
+ }
+ if signatureName != OperationalEvidenceSignatureFile {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf(
+ "operational evidence signature name %q, want %q",
+ signatureName,
+ OperationalEvidenceSignatureFile,
+ )
+ }
+ bundleBytes, err := readRegularBounded(bundlePath, maxSignedRecordBytes)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, err
+ }
+ signatureBytes, err := readRegularBounded(signaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, err
+ }
+ var bundle OperationalEvidenceBundle
+ if err := UnmarshalCanonical(bundleBytes, &bundle); err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf("operational evidence bundle: %w", err)
+ }
+ phase1Close, err := LoadAuthenticatedCloseEvidence(root, bundle.Phase1.Close)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf("phase1 close evidence: %w", err)
+ }
+ phase2Close, err := LoadAuthenticatedCloseEvidence(root, bundle.Phase2.Close)
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf("phase2 close evidence: %w", err)
+ }
+ verified, err := VerifyOperationalEvidenceBundle(VerifyOperationalEvidenceOptions{
+ Definition: definition,
+ CoordinatorPublicKey: coordinatorPublicKey,
+ EvidenceRoot: root,
+ BundleBytes: bundleBytes,
+ BundleSignatureBytes: signatureBytes,
+ Phase1Close: phase1Close,
+ Phase2Close: phase2Close,
+ })
+ if err != nil {
+ return verifiedReleaseOperationalEvidence{}, err
+ }
+ if phase1Close.Record.CloseID != candidate.Phase1.CloseID ||
+ phase2Close.Record.CloseID != candidate.Phase2.CloseID {
+ return verifiedReleaseOperationalEvidence{}, errors.New(
+ "operational evidence closes do not match finalized candidate phase summaries",
+ )
+ }
+ if err := releaseChainMatchesCandidate(
+ root,
+ verified.Bundle.Phase1.AcceptedChain.Record,
+ candidate.Phase1,
+ ); err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf(
+ "operational phase1 chain does not match finalized candidate: %w",
+ err,
+ )
+ }
+ if err := releaseChainMatchesCandidate(
+ root,
+ verified.Bundle.Phase2.AcceptedChain.Record,
+ candidate.Phase2,
+ ); err != nil {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf(
+ "operational phase2 chain does not match finalized candidate: %w",
+ err,
+ )
+ }
+ assembledAt, _ := time.Parse(time.RFC3339Nano, verified.Bundle.AssembledAt)
+ if !releasedAt.After(assembledAt) {
+ return verifiedReleaseOperationalEvidence{}, errors.New(
+ "release time must strictly postdate operational evidence assembly",
+ )
+ }
+ bundleRef := SignedArtifactRefs{
+ Record: ArtifactRef{
+ Name: OperationalEvidenceBundleFile,
+ Digest: verified.BundleDigest,
+ },
+ Signature: ArtifactRef{
+ Name: OperationalEvidenceSignatureFile,
+ Digest: verified.BundleSignature,
+ },
+ }
+ names := make([]string, 0, len(verified.ReferencedArtifacts)+2)
+ names = append(names, bundleRef.Record.Name, bundleRef.Signature.Name)
+ for _, ref := range verified.ReferencedArtifacts {
+ names = append(names, ref.Name)
+ }
+ slices.Sort(names)
+ for index := 1; index < len(names); index++ {
+ if names[index-1] == names[index] {
+ return verifiedReleaseOperationalEvidence{}, fmt.Errorf(
+ "operational release artifact name %q is reused",
+ names[index],
+ )
+ }
+ }
+ return verifiedReleaseOperationalEvidence{
+ Verified: verified,
+ BundleRef: bundleRef,
+ Names: names,
+ }, nil
+}
+
+func releaseChainMatchesCandidate(
+ root string,
+ operational ArtifactRef,
+ candidate PhaseSummary,
+) error {
+ if operational.Digest != candidate.Chain.Digest {
+ return errors.New("accepted-chain digest differs")
+ }
+ raw, err := verifyArtifactBytes(root, operational, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(raw, &chain); err != nil {
+ return err
+ }
+ head, err := chain.HeadRecordID()
+ if err != nil {
+ return err
+ }
+ participants, err := chain.ParticipantIDs()
+ if err != nil {
+ return err
+ }
+ if chain.Phase != candidate.Phase ||
+ chain.PhaseID != candidate.PhaseID ||
+ chain.Genesis != candidate.Genesis ||
+ head != candidate.ChainHeadID ||
+ len(chain.Records) != int(candidate.ContributionCount) ||
+ !slices.Equal(participants, candidate.Participants) {
+ return errors.New("accepted-chain phase, head, count, or participants differ")
+ }
+ return nil
+}
+
+func copyOperationalEvidence(
+ sourceRoot, stagingDir string,
+ evidence verifiedReleaseOperationalEvidence,
+ reserved []string,
+) error {
+ reservedNames := make(map[string]struct{}, len(reserved))
+ for _, name := range reserved {
+ reservedNames[name] = struct{}{}
+ }
+ refs := make([]ArtifactRef, 0, len(evidence.Verified.ReferencedArtifacts)+2)
+ refs = append(refs, evidence.BundleRef.Record, evidence.BundleRef.Signature)
+ refs = append(refs, evidence.Verified.ReferencedArtifacts...)
+ for _, ref := range refs {
+ if _, collision := reservedNames[ref.Name]; collision {
+ return fmt.Errorf("operational artifact %q collides with a release artifact", ref.Name)
+ }
+ if strings.HasPrefix(ref.Name, "audits/") {
+ return fmt.Errorf("operational artifact %q is reserved for audit evidence", ref.Name)
+ }
+ sourcePath, err := resolveArtifactPath(sourceRoot, ref.Name)
+ if err != nil {
+ return err
+ }
+ destinationPath, err := resolveArtifactPath(stagingDir, ref.Name)
+ if err != nil {
+ return err
+ }
+ if err := mkdirAllPrivateDurable(filepath.Dir(destinationPath)); err != nil {
+ return err
+ }
+ if err := copyRegularNoReplace(sourcePath, destinationPath); err != nil {
+ return err
+ }
+ copied, err := artifactRefForFile(ref.Name, destinationPath)
+ if err != nil {
+ return err
+ }
+ if copied != ref {
+ return fmt.Errorf("copied operational artifact %q digest mismatch", ref.Name)
+ }
+ }
+ return nil
+}
+
+func bundleAuditArtifacts(inputs []AuditArtifact, stagingDir string) ([]AuditArtifact, error) {
+ auditDir := filepath.Join(stagingDir, "audits")
+ if err := os.Mkdir(auditDir, 0o700); err != nil {
+ return nil, err
+ }
+ result := make([]AuditArtifact, len(inputs))
+ for index, input := range inputs {
+ logicalRecord := fmt.Sprintf("audits/%04d.json", index+1)
+ logicalSignature := fmt.Sprintf("audits/%04d.sig", index+1)
+ recordPath := filepath.Join(stagingDir, filepath.FromSlash(logicalRecord))
+ signaturePath := filepath.Join(stagingDir, filepath.FromSlash(logicalSignature))
+ if err := copyRegularNoReplace(input.RecordPath, recordPath); err != nil {
+ return nil, err
+ }
+ if err := copyRegularNoReplace(input.SignaturePath, signaturePath); err != nil {
+ return nil, err
+ }
+ result[index] = AuditArtifact{
+ RecordPath: recordPath,
+ SignaturePath: signaturePath,
+ LogicalName: logicalRecord,
+ }
+ }
+ return result, nil
+}
+
+func bundledAuditsForTranscript(keysDir string, refs []ArtifactRef) ([]AuditArtifact, error) {
+ result := make([]AuditArtifact, len(refs))
+ for index, ref := range refs {
+ expected := fmt.Sprintf("audits/%04d.json", index+1)
+ if ref.Name != expected {
+ return nil, fmt.Errorf("transcript audit %d name %q, want %q", index, ref.Name, expected)
+ }
+ recordPath, err := resolveArtifactPath(keysDir, ref.Name)
+ if err != nil {
+ return nil, err
+ }
+ signatureName := fmt.Sprintf("audits/%04d.sig", index+1)
+ signaturePath, err := resolveArtifactPath(keysDir, signatureName)
+ if err != nil {
+ return nil, err
+ }
+ actual, err := artifactRefForFile(ref.Name, recordPath)
+ if err != nil {
+ return nil, err
+ }
+ if actual != ref {
+ return nil, fmt.Errorf("bundled audit %d digest mismatch", index)
+ }
+ result[index] = AuditArtifact{
+ RecordPath: recordPath,
+ SignaturePath: signaturePath,
+ LogicalName: ref.Name,
+ }
+ }
+ return result, nil
+}
+
+func createReleaseStagingDir(releaseDir, candidateDir string) (string, error) {
+ if filepath.Clean(releaseDir) == filepath.Clean(candidateDir) {
+ return "", errors.New("release directory must be distinct from candidate directory")
+ }
+ return createFreshStagingDir(releaseDir)
+}
+
+func createFreshStagingDir(destination string) (string, error) {
+ if strings.TrimSpace(destination) == "" {
+ return "", errors.New("fresh destination directory is required")
+ }
+ if _, err := os.Lstat(destination); err == nil {
+ return "", fmt.Errorf("destination directory %q already exists: %w", destination, fs.ErrExist)
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return "", err
+ }
+ parent := filepath.Dir(destination)
+ info, err := os.Lstat(parent)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("destination directory parent is not a real directory")
+ }
+ staging, err := os.MkdirTemp(parent, "."+filepath.Base(destination)+".partial-*")
+ if err != nil {
+ return "", err
+ }
+ if err := os.Chmod(staging, 0o700); err != nil {
+ _ = os.RemoveAll(staging)
+ return "", err
+ }
+ return staging, nil
+}
+
+func copyRegularNoReplace(source, destination string) (err error) {
+ destinationDir := filepath.Dir(destination)
+ sourceInfo, err := os.Lstat(source)
+ if err != nil {
+ return err
+ }
+ if !sourceInfo.Mode().IsRegular() || sourceInfo.Size() <= 0 || sourceInfo.Size() > MaxArtifactSize {
+ return fmt.Errorf("release source %q is not a bounded regular file", source)
+ }
+ input, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer input.Close()
+ openInfo, err := input.Stat()
+ if err != nil {
+ return err
+ }
+ if !openInfo.Mode().IsRegular() ||
+ !os.SameFile(sourceInfo, openInfo) ||
+ openInfo.Size() != sourceInfo.Size() {
+ return fmt.Errorf("release source %q changed while being opened", source)
+ }
+ output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ remove := true
+ defer func() {
+ _ = output.Close()
+ if remove {
+ _ = os.Remove(destination)
+ _ = syncDirectory(destinationDir)
+ }
+ }()
+ n, err := io.CopyN(output, input, sourceInfo.Size())
+ if err != nil {
+ return err
+ }
+ if n != sourceInfo.Size() {
+ return fmt.Errorf("release source %q changed size while copying", source)
+ }
+ var extra [1]byte
+ if n, err := input.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
+ return fmt.Errorf("release source %q grew while copying", source)
+ }
+ finalInfo, err := input.Stat()
+ if err != nil {
+ return err
+ }
+ if !finalInfo.Mode().IsRegular() ||
+ !os.SameFile(sourceInfo, finalInfo) ||
+ finalInfo.Size() != sourceInfo.Size() {
+ return fmt.Errorf("release source %q changed while being copied", source)
+ }
+ if err := output.Sync(); err != nil {
+ return err
+ }
+ if err := output.Close(); err != nil {
+ return err
+ }
+ if err := syncDirectory(destinationDir); err != nil {
+ return err
+ }
+ remove = false
+ return nil
+}
+
+func publishReleaseDirectory(stagingDir, releaseDir string) (err error) {
+ return publishDirectoryNoReplaceOrExact(stagingDir, releaseDir)
+}
+
+func verifyReleaseTreeExact(dir string, auditCount int, operationalNames []string) error {
+ expectedFiles := make(map[string]struct{})
+ expectedDirectories := map[string]struct{}{".": {}}
+ for _, name := range append(
+ releaseChecksumNames(auditCount, operationalNames),
+ ReleaseChecksumsFile,
+ ) {
+ if err := validateArtifactName(name); err != nil {
+ return fmt.Errorf("expected release artifact %q: %w", name, err)
+ }
+ if _, duplicate := expectedFiles[name]; duplicate {
+ return fmt.Errorf("expected release artifact %q is duplicated", name)
+ }
+ expectedFiles[name] = struct{}{}
+ for parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(name))); parent != "."; parent = filepath.ToSlash(filepath.Dir(filepath.FromSlash(parent))) {
+ expectedDirectories[parent] = struct{}{}
+ }
+ }
+ err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ relative, err := filepath.Rel(dir, path)
+ if err != nil {
+ return err
+ }
+ name := filepath.ToSlash(relative)
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("release-tree entry %q is a symbolic link", name)
+ }
+ if entry.IsDir() {
+ if _, ok := expectedDirectories[name]; !ok {
+ return fmt.Errorf("unexpected release-tree directory %q", name)
+ }
+ delete(expectedDirectories, name)
+ return nil
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("release-tree entry %q is not a regular file", name)
+ }
+ if _, ok := expectedFiles[name]; !ok {
+ return fmt.Errorf("unexpected release-tree entry %q", name)
+ }
+ delete(expectedFiles, name)
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ if len(expectedFiles) != 0 || len(expectedDirectories) != 0 {
+ return errors.New("release tree is missing required files or directories")
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/audit_operational_test.go b/internal/mpcceremony/audit_operational_test.go
new file mode 100644
index 00000000..101889ff
--- /dev/null
+++ b/internal/mpcceremony/audit_operational_test.go
@@ -0,0 +1,127 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestReleaseOperationalEvidenceBindsCandidateAndChronology(t *testing.T) {
+ fixture := newOperationalBundleFixture(t)
+ writeFixtureFile(
+ t,
+ fixture.root,
+ OperationalEvidenceBundleFile,
+ fixture.bundleBytes,
+ )
+ writeFixtureFile(
+ t,
+ fixture.root,
+ OperationalEvidenceSignatureFile,
+ fixture.signatureBytes,
+ )
+
+ summary := func(
+ phase PhaseOperationalEvidence,
+ close AuthenticatedCloseEvidence,
+ ) PhaseSummary {
+ t.Helper()
+ raw, err := verifyArtifactBytes(
+ fixture.root,
+ phase.AcceptedChain.Record,
+ maxSignedRecordBytes,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(raw, &chain); err != nil {
+ t.Fatal(err)
+ }
+ head, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ participants, err := chain.ParticipantIDs()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return PhaseSummary{
+ Phase: chain.Phase,
+ PhaseID: chain.PhaseID,
+ Genesis: chain.Genesis,
+ Chain: ArtifactRef{
+ Name: filepath.Base(phase.AcceptedChain.Record.Name),
+ Digest: phase.AcceptedChain.Record.Digest,
+ },
+ ChainHeadID: head,
+ ContributionCount: uint8(len(chain.Records)),
+ Participants: participants,
+ CloseID: close.Record.CloseID,
+ }
+ }
+ candidate := CandidateMetadata{
+ Phase1: summary(fixture.bundle.Phase1, fixture.phase1Close),
+ Phase2: summary(fixture.bundle.Phase2, fixture.phase2Close),
+ }
+ bundlePath := filepath.Join(
+ fixture.root,
+ filepath.FromSlash(OperationalEvidenceBundleFile),
+ )
+ signaturePath := filepath.Join(
+ fixture.root,
+ filepath.FromSlash(OperationalEvidenceSignatureFile),
+ )
+ assembledAt, err := time.Parse(time.RFC3339Nano, fixture.bundle.AssembledAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ verify := func(candidate CandidateMetadata, releasedAt time.Time) error {
+ _, err := verifyReleaseOperationalEvidence(
+ fixture.definition,
+ fixture.coordinatorKey.Public().(ed25519.PublicKey),
+ candidate,
+ fixture.root,
+ bundlePath,
+ signaturePath,
+ releasedAt,
+ )
+ return err
+ }
+ coordinatorPublicKey := fixture.coordinatorKey.Public().(ed25519.PublicKey)
+ result, err := verifyReleaseOperationalEvidence(
+ fixture.definition,
+ coordinatorPublicKey,
+ candidate,
+ fixture.root,
+ bundlePath,
+ signaturePath,
+ assembledAt.Add(time.Second),
+ )
+ if err != nil {
+ t.Fatalf("release rejected complete operational evidence: %v", err)
+ }
+ if result.BundleRef.Record.Name != OperationalEvidenceBundleFile ||
+ result.BundleRef.Signature.Name != OperationalEvidenceSignatureFile ||
+ len(result.Names) != len(result.Verified.ReferencedArtifacts)+2 {
+ t.Fatal("release operational evidence result omitted bundle or referenced artifacts")
+ }
+
+ wrongChain := candidate
+ wrongChain.Phase1.Chain = refForTest(
+ wrongChain.Phase1.Chain.Name,
+ []byte("different accepted chain"),
+ )
+ if err := verify(wrongChain, assembledAt.Add(time.Second)); err == nil {
+ t.Fatal("release accepted operational evidence for a different candidate chain")
+ }
+ wrongClose := candidate
+ wrongClose.Phase2.CloseID = NewDigest([]byte("different close")).SHA256
+ if err := verify(wrongClose, assembledAt.Add(time.Second)); err == nil {
+ t.Fatal("release accepted operational evidence for a different candidate close")
+ }
+ if err := verify(candidate, assembledAt); err == nil {
+ t.Fatal("release timestamp equal to bundle assembly was accepted")
+ }
+}
diff --git a/internal/mpcceremony/beacon.go b/internal/mpcceremony/beacon.go
new file mode 100644
index 00000000..c77364eb
--- /dev/null
+++ b/internal/mpcceremony/beacon.go
@@ -0,0 +1,165 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ drandcrypto "github.com/drand/drand/v2/crypto"
+)
+
+const (
+ maxDrandResponseBytes = 1 << 20
+ quicknetSignatureBytes = 48
+)
+
+// drandHTTPBeacon is the exact public HTTP response shape archived by this
+// ceremony. The quicknet scheme is unchained, so previous_signature must be
+// absent or empty.
+type drandHTTPBeacon struct {
+ Round uint64 `json:"round"`
+ Randomness string `json:"randomness"`
+ Signature string `json:"signature"`
+ PreviousSignature string `json:"previous_signature,omitempty"`
+}
+
+type verifiedDrandBeacon struct {
+ round uint64
+ signature []byte
+}
+
+func (b verifiedDrandBeacon) GetPreviousSignature() []byte { return nil }
+func (b verifiedDrandBeacon) GetRound() uint64 { return b.round }
+func (b verifiedDrandBeacon) GetSignature() []byte { return b.signature }
+
+// VerifyDrandBeaconResponse strictly parses and cryptographically verifies an
+// archived drand response against the public key and scheme pinned in the
+// signed ceremony definition. It returns randomness derived from the verified
+// signature, never an operator-supplied value.
+func VerifyDrandBeaconResponse(
+ policy BeaconPolicy,
+ expectedRound uint64,
+ rawResponse []byte,
+) (string, error) {
+ if err := policy.Validate(); err != nil {
+ return "", fmt.Errorf("beacon policy: %w", err)
+ }
+ if expectedRound == 0 {
+ return "", errors.New("expected drand round must be positive")
+ }
+ if len(rawResponse) == 0 || len(rawResponse) > maxDrandResponseBytes {
+ return "", fmt.Errorf("drand response size %d is outside [1,%d]", len(rawResponse), maxDrandResponseBytes)
+ }
+ if err := rejectDuplicateKeysAndTrailing(rawResponse); err != nil {
+ return "", fmt.Errorf("strict drand response JSON: %w", err)
+ }
+ var response drandHTTPBeacon
+ decoder := json.NewDecoder(bytes.NewReader(rawResponse))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&response); err != nil {
+ return "", fmt.Errorf("decode drand response: %w", err)
+ }
+ if err := requireJSONEOF(decoder); err != nil {
+ return "", err
+ }
+ if response.Round != expectedRound {
+ return "", fmt.Errorf("drand round %d, want committed round %d", response.Round, expectedRound)
+ }
+ if response.PreviousSignature != "" {
+ return "", errors.New("quicknet unchained response must not contain a previous signature")
+ }
+ signature, err := decodeLowerHexExact("drand signature", response.Signature, quicknetSignatureBytes)
+ if err != nil {
+ return "", err
+ }
+ responseRandomness, err := decodeLowerHexExact("drand randomness", response.Randomness, 32)
+ if err != nil {
+ return "", err
+ }
+
+ scheme, err := drandcrypto.SchemeFromName(policy.Scheme)
+ if err != nil {
+ return "", fmt.Errorf("load pinned drand scheme: %w", err)
+ }
+ publicKeyBytes, err := hex.DecodeString(policy.PublicKeyHex)
+ if err != nil {
+ return "", fmt.Errorf("decode pinned drand public key: %w", err)
+ }
+ publicKey := scheme.KeyGroup.Point()
+ if err := publicKey.UnmarshalBinary(publicKeyBytes); err != nil {
+ return "", fmt.Errorf("decode pinned drand public key point: %w", err)
+ }
+ beacon := verifiedDrandBeacon{round: response.Round, signature: signature}
+ if err := scheme.VerifyBeacon(beacon, publicKey); err != nil {
+ return "", fmt.Errorf("verify drand beacon signature: %w", err)
+ }
+ randomnessDigest := sha256.Sum256(signature)
+ derivedRandomness := randomnessDigest[:]
+ if !bytes.Equal(responseRandomness, derivedRandomness) {
+ return "", errors.New("drand response randomness does not equal SHA-256(signature)")
+ }
+ return hex.EncodeToString(derivedRandomness), nil
+}
+
+func requireJSONEOF(decoder *json.Decoder) error {
+ var trailing any
+ if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
+ if err == nil {
+ return errors.New("drand response contains trailing JSON")
+ }
+ return fmt.Errorf("read drand response trailer: %w", err)
+ }
+ return nil
+}
+
+func decodeLowerHexExact(name, value string, size int) ([]byte, error) {
+ if value == "" || value != strings.ToLower(value) {
+ return nil, fmt.Errorf("%s must be lowercase hexadecimal", name)
+ }
+ decoded, err := hex.DecodeString(value)
+ if err != nil || len(decoded) != size {
+ return nil, fmt.Errorf("%s must contain exactly %d lowercase hexadecimal bytes", name, size)
+ }
+ return decoded, nil
+}
+
+// VerifyBeaconRecordFiles reloads the immutable raw response referenced by a
+// signed beacon record and repeats the same offline provider verification.
+func VerifyBeaconRecordFiles(
+ trusted *TrustedCeremony,
+ transcriptRoot string,
+ closeRecord CloseRecord,
+ beacon BeaconRecord,
+) error {
+ if err := validateTrustedCeremony(trusted); err != nil {
+ return err
+ }
+ if err := ValidateBeacon(trusted.Definition, closeRecord, beacon); err != nil {
+ return err
+ }
+ rawResponse, err := verifyArtifactBytes(
+ transcriptRoot,
+ beacon.RawResponse,
+ maxDrandResponseBytes,
+ )
+ if err != nil {
+ return fmt.Errorf("load archived drand response: %w", err)
+ }
+ randomnessHex, err := VerifyDrandBeaconResponse(
+ trusted.Definition.BeaconPolicy,
+ closeRecord.BeaconRound,
+ rawResponse,
+ )
+ if err != nil {
+ return err
+ }
+ if randomnessHex != beacon.RandomnessHex {
+ return errors.New("signed beacon randomness differs from verified archived response")
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/beacon_test.go b/internal/mpcceremony/beacon_test.go
new file mode 100644
index 00000000..351c48ce
--- /dev/null
+++ b/internal/mpcceremony/beacon_test.go
@@ -0,0 +1,58 @@
+package mpcceremony
+
+import (
+ "strings"
+ "testing"
+)
+
+const quicknetRound42Response = `{"round":42,"randomness":"8ada64bae5c6c0f5540a6a13af56e663240edfbd2c76ac6a8f27671eb7259ce3","signature":"95a9f9f5b231b7714de1553105d8ffdf3dcda24cfdb1e689319bccf79a9c8ce430a91b811fbfaf763900bc998b5d686a"}`
+
+func quicknetPolicyForTest() BeaconPolicy {
+ return BeaconPolicy{
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ ChainHashHex: BeaconQuicknetChainHash,
+ PublicKeyHex: BeaconQuicknetPublicKey,
+ Scheme: BeaconQuicknetScheme,
+ GenesisTimeUnix: BeaconQuicknetGenesis,
+ PeriodSeconds: BeaconQuicknetPeriod,
+ Extraction: BeaconExtractionV1,
+ MinimumChallengeBytes: 32,
+ MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds,
+ FutureRoundRequired: true,
+ }
+}
+
+func TestVerifyDrandBeaconResponseOfficialQuicknetVector(t *testing.T) {
+ randomness, err := VerifyDrandBeaconResponse(
+ quicknetPolicyForTest(),
+ 42,
+ []byte(quicknetRound42Response),
+ )
+ if err != nil {
+ t.Fatalf("verify official quicknet round 42: %v", err)
+ }
+ const expected = "8ada64bae5c6c0f5540a6a13af56e663240edfbd2c76ac6a8f27671eb7259ce3"
+ if randomness != expected {
+ t.Fatalf("randomness = %q, want %q", randomness, expected)
+ }
+}
+
+func TestVerifyDrandBeaconResponseRejectsTamperingAndLooseJSON(t *testing.T) {
+ tests := map[string]string{
+ "wrong round": strings.Replace(quicknetRound42Response, `"round":42`, `"round":43`, 1),
+ "wrong randomness": strings.Replace(quicknetRound42Response, "8ada64", "9ada64", 1),
+ "wrong signature": strings.Replace(quicknetRound42Response, "95a9f9", "85a9f9", 1),
+ "unknown field": strings.TrimSuffix(quicknetRound42Response, "}") + `,"extra":true}`,
+ "duplicate round": strings.Replace(quicknetRound42Response, `"round":42`, `"round":42,"round":42`, 1),
+ "trailing JSON": quicknetRound42Response + `{}`,
+ "uppercase encoding": strings.Replace(quicknetRound42Response, "8ada64", "8ADA64", 1),
+ }
+ for name, raw := range tests {
+ t.Run(name, func(t *testing.T) {
+ if _, err := VerifyDrandBeaconResponse(quicknetPolicyForTest(), 42, []byte(raw)); err == nil {
+ t.Fatal("tampered response unexpectedly verified")
+ }
+ })
+ }
+}
diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go
new file mode 100644
index 00000000..4ed520f8
--- /dev/null
+++ b/internal/mpcceremony/chain.go
@@ -0,0 +1,1256 @@
+package mpcceremony
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "hash"
+ "math"
+ "slices"
+ "time"
+)
+
+type ChainRecord struct {
+ Schema string `json:"schema"`
+ RecordID string `json:"record_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Index uint8 `json:"index"`
+ ParticipantID string `json:"participant_id"`
+ PreviousPayload ArtifactRef `json:"previous_payload"`
+ OutputPayload ArtifactRef `json:"output_payload"`
+ AttestationID string `json:"attestation_id"`
+ Attestation ArtifactRef `json:"attestation"`
+ AttestationSignature ArtifactRef `json:"attestation_signature"`
+ ErasureID string `json:"erasure_id"`
+ Erasure ArtifactRef `json:"erasure"`
+ ErasureSignature ArtifactRef `json:"erasure_signature"`
+ Verification ArtifactRef `json:"verification"`
+ PreviousRecordID string `json:"previous_record_id"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ AcceptedAt string `json:"accepted_at"`
+}
+
+func NewChainRecord(record ChainRecord) (ChainRecord, error) {
+ record.Schema = ChainRecordSchema
+ record.RecordID = ""
+ id, err := ComputeChainRecordID(record)
+ if err != nil {
+ return ChainRecord{}, err
+ }
+ record.RecordID = id
+ if err := record.Validate(); err != nil {
+ return ChainRecord{}, err
+ }
+ return record, nil
+}
+
+func ComputeChainRecordID(record ChainRecord) (string, error) {
+ record.RecordID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/acceptance-record/v1", record)
+}
+
+func (r ChainRecord) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeChainRecordID(r)
+ if err != nil {
+ return err
+ }
+ if r.RecordID != expected {
+ return fmt.Errorf("record_id %q, want %q", r.RecordID, expected)
+ }
+ return nil
+}
+
+func (r ChainRecord) validate(requireID bool) error {
+ if r.Schema != ChainRecordSchema {
+ return fmt.Errorf("chain record schema %q, want %q", r.Schema, ChainRecordSchema)
+ }
+ if requireID {
+ if err := validateHashID("record_id", r.RecordID); err != nil {
+ return err
+ }
+ } else if r.RecordID != "" {
+ return errors.New("record_id must be empty while computing identity")
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := r.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("phase_id", r.PhaseID); err != nil {
+ return err
+ }
+ if r.Index == 0 || r.Index > MaxParticipants {
+ return fmt.Errorf("chain record index %d must be between 1 and %d", r.Index, MaxParticipants)
+ }
+ if err := validateID("participant_id", r.ParticipantID); err != nil {
+ return err
+ }
+ if err := r.PreviousPayload.Validate(); err != nil {
+ return fmt.Errorf("previous_payload: %w", err)
+ }
+ if err := r.OutputPayload.Validate(); err != nil {
+ return fmt.Errorf("output_payload: %w", err)
+ }
+ if r.PreviousPayload.Digest.SHA256 == r.OutputPayload.Digest.SHA256 {
+ return errors.New("accepted output payload must differ from previous payload")
+ }
+ if err := validateHashID("attestation_id", r.AttestationID); err != nil {
+ return err
+ }
+ for name, artifact := range map[string]ArtifactRef{
+ "attestation": r.Attestation,
+ "attestation_signature": r.AttestationSignature,
+ "erasure": r.Erasure,
+ "erasure_signature": r.ErasureSignature,
+ "verification": r.Verification,
+ } {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", name, err)
+ }
+ }
+ if err := validateHashID("erasure_id", r.ErasureID); err != nil {
+ return err
+ }
+ if err := validateHashID("previous_record_id", r.PreviousRecordID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_id", r.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", r.CoordinatorKeyID); err != nil {
+ return err
+ }
+ return validateTimestamp("accepted_at", r.AcceptedAt)
+}
+
+type Chain struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Genesis ArtifactRef `json:"genesis"`
+ Records []ChainRecord `json:"records"`
+}
+
+func NewChain(ceremonyID string, phase Phase, phaseID string, genesis ArtifactRef) (Chain, error) {
+ chain := Chain{
+ Schema: ChainSchema,
+ CeremonyID: ceremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ Genesis: genesis,
+ Records: []ChainRecord{},
+ }
+ return chain, chain.Validate()
+}
+
+func (c Chain) Validate() error {
+ if c.Schema != ChainSchema {
+ return fmt.Errorf("chain schema %q, want %q", c.Schema, ChainSchema)
+ }
+ if err := validateHashID("ceremony_id", c.CeremonyID); err != nil {
+ return err
+ }
+ if err := c.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("phase_id", c.PhaseID); err != nil {
+ return err
+ }
+ if err := c.Genesis.Validate(); err != nil {
+ return fmt.Errorf("genesis: %w", err)
+ }
+ if len(c.Records) > MaxParticipants {
+ return fmt.Errorf("chain contains %d records, maximum is %d", len(c.Records), MaxParticipants)
+ }
+ previousPayload := c.Genesis
+ previousRecordID, err := GenesisRecordID(c.CeremonyID, c.PhaseID, c.Genesis)
+ if err != nil {
+ return err
+ }
+ participants := make(map[string]struct{}, len(c.Records))
+ var previousAcceptedAt time.Time
+ for index, record := range c.Records {
+ if err := record.Validate(); err != nil {
+ return fmt.Errorf("record %d: %w", index, err)
+ }
+ acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt)
+ if index > 0 && !acceptedAt.After(previousAcceptedAt) {
+ return fmt.Errorf("record %d accepted_at must be strictly after record %d", index, index-1)
+ }
+ if record.CeremonyID != c.CeremonyID || record.Phase != c.Phase || record.PhaseID != c.PhaseID {
+ return fmt.Errorf("record %d ceremony or phase identity mismatch", index)
+ }
+ if int(record.Index) != index+1 {
+ return fmt.Errorf("record %d index is %d, want %d", index, record.Index, index+1)
+ }
+ if record.PreviousPayload != previousPayload {
+ return fmt.Errorf("record %d previous payload does not equal accepted chain head", index)
+ }
+ if record.PreviousRecordID != previousRecordID {
+ return fmt.Errorf("record %d previous_record_id %q, want %q", index, record.PreviousRecordID, previousRecordID)
+ }
+ if _, duplicate := participants[record.ParticipantID]; duplicate {
+ return fmt.Errorf("participant %q appears more than once in the phase", record.ParticipantID)
+ }
+ participants[record.ParticipantID] = struct{}{}
+ previousPayload = record.OutputPayload
+ previousRecordID = record.RecordID
+ previousAcceptedAt = acceptedAt
+ }
+ return nil
+}
+
+func (c *Chain) Append(record ChainRecord) error {
+ if c == nil {
+ return errors.New("chain is nil")
+ }
+ candidate := *c
+ candidate.Records = append(append([]ChainRecord(nil), c.Records...), record)
+ if err := candidate.Validate(); err != nil {
+ return err
+ }
+ c.Records = candidate.Records
+ return nil
+}
+
+func (c Chain) HeadRecordID() (string, error) {
+ if err := c.Validate(); err != nil {
+ return "", err
+ }
+ if len(c.Records) == 0 {
+ return GenesisRecordID(c.CeremonyID, c.PhaseID, c.Genesis)
+ }
+ return c.Records[len(c.Records)-1].RecordID, nil
+}
+
+func (c Chain) HeadPayload() (ArtifactRef, error) {
+ if err := c.Validate(); err != nil {
+ return ArtifactRef{}, err
+ }
+ if len(c.Records) == 0 {
+ return c.Genesis, nil
+ }
+ return c.Records[len(c.Records)-1].OutputPayload, nil
+}
+
+func (c Chain) ParticipantIDs() ([]string, error) {
+ if err := c.Validate(); err != nil {
+ return nil, err
+ }
+ result := make([]string, len(c.Records))
+ for index := range c.Records {
+ result[index] = c.Records[index].ParticipantID
+ }
+ return result, nil
+}
+
+func (c Chain) ValidateAgainstDefinition(definition CeremonyDefinition) error {
+ if err := c.Validate(); err != nil {
+ return err
+ }
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if c.CeremonyID != definition.CeremonyID {
+ return errors.New("chain ceremony_id does not match definition")
+ }
+ if c.Phase == Phase1 {
+ expectedPhaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, definition.Phase1Genesis, "")
+ if err != nil {
+ return err
+ }
+ if c.Genesis != definition.Phase1Genesis || c.PhaseID != expectedPhaseID {
+ return errors.New("phase1 chain genesis or phase_id does not match definition")
+ }
+ }
+ policy, err := definition.PolicyForPhase(c.Phase)
+ if err != nil {
+ return err
+ }
+ for index, record := range c.Records {
+ if index >= len(policy.Participants) || record.ParticipantID != policy.Participants[index] {
+ return fmt.Errorf("record %d participant %q does not match frozen order", index, record.ParticipantID)
+ }
+ participant, ok := definition.ParticipantByID(record.ParticipantID)
+ if !ok {
+ return fmt.Errorf("record %d participant %q is not in roster", index, record.ParticipantID)
+ }
+ if record.CoordinatorID != definition.Coordinator.ID || record.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return fmt.Errorf("record %d coordinator identity mismatch", index)
+ }
+ if record.AttestationID == "" || participant.Identity.KeyID == "" {
+ return fmt.Errorf("record %d participant metadata is incomplete", index)
+ }
+ }
+ return nil
+}
+
+func ValidateAttestationAcceptance(
+ definition CeremonyDefinition,
+ chain Chain,
+ attestation ContributionAttestation,
+ erasure ErasureAttestation,
+ record ChainRecord,
+) error {
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if err := chain.ValidateAgainstDefinition(definition); err != nil {
+ return err
+ }
+ if err := attestation.Validate(); err != nil {
+ return err
+ }
+ if err := ValidateErasureForContribution(attestation, erasure); err != nil {
+ return err
+ }
+ if err := record.Validate(); err != nil {
+ return err
+ }
+ expectedIndex := uint8(len(chain.Records) + 1)
+ headPayload, err := chain.HeadPayload()
+ if err != nil {
+ return err
+ }
+ headRecordID, err := chain.HeadRecordID()
+ if err != nil {
+ return err
+ }
+ if record.Index != expectedIndex || attestation.Index != expectedIndex ||
+ record.PreviousPayload != headPayload || attestation.PreviousPayload != headPayload ||
+ record.PreviousRecordID != headRecordID || attestation.PreviousAcceptanceID != headRecordID {
+ return errors.New("attestation and acceptance are not the next child of the accepted chain head")
+ }
+ if record.Index != attestation.Index ||
+ record.CeremonyID != attestation.CeremonyID ||
+ record.Phase != attestation.Phase ||
+ record.PhaseID != attestation.PhaseID ||
+ record.ParticipantID != attestation.ParticipantID ||
+ record.PreviousPayload != attestation.PreviousPayload ||
+ record.OutputPayload != attestation.OutputPayload ||
+ record.AttestationID != attestation.AttestationID ||
+ record.ErasureID != erasure.ErasureID ||
+ record.PreviousRecordID != attestation.PreviousAcceptanceID {
+ return errors.New("acceptance record does not exactly bind contribution attestation")
+ }
+ participant, ok := definition.ParticipantByID(attestation.ParticipantID)
+ if !ok || participant.Identity.KeyID != attestation.ParticipantKeyID {
+ return errors.New("attestation participant identity does not match definition")
+ }
+ if definition.Software.ToolBinary != attestation.ToolBinary ||
+ definition.Software.SourceCommit != attestation.SourceCommit ||
+ definition.Software.GnarkVersion != attestation.GnarkVersion ||
+ definition.Software.GnarkCryptoVersion != attestation.GnarkCryptoVersion ||
+ definition.Software.DrandVersion != attestation.DrandVersion {
+ return errors.New("attestation software binding does not match definition")
+ }
+ createdAt, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt)
+ contributedAt, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt)
+ destroyedAt, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt)
+ acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt)
+ if !contributedAt.After(createdAt) {
+ return errors.New("contributed_at must be strictly after the ceremony definition")
+ }
+ if len(chain.Records) > 0 {
+ previousAcceptedAt, _ := time.Parse(
+ time.RFC3339Nano,
+ chain.Records[len(chain.Records)-1].AcceptedAt,
+ )
+ if !contributedAt.After(previousAcceptedAt) {
+ return errors.New("contributed_at must be strictly after the previous acceptance")
+ }
+ }
+ if !acceptedAt.After(destroyedAt) {
+ return errors.New("accepted_at must be strictly after destroyed_at")
+ }
+ return nil
+}
+
+func GenesisRecordID(ceremonyID, phaseID string, genesis ArtifactRef) (string, error) {
+ if err := validateHashID("ceremony_id", ceremonyID); err != nil {
+ return "", err
+ }
+ if err := validateHashID("phase_id", phaseID); err != nil {
+ return "", err
+ }
+ if err := genesis.Validate(); err != nil {
+ return "", err
+ }
+ value := struct {
+ CeremonyID string `json:"ceremony_id"`
+ PhaseID string `json:"phase_id"`
+ Genesis ArtifactRef `json:"genesis"`
+ }{ceremonyID, phaseID, genesis}
+ return canonicalHash("proof-tool/mpc-ceremony/genesis-record/v1", value)
+}
+
+type CloseRecord struct {
+ Schema string `json:"schema"`
+ CloseID string `json:"close_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ FinalIndex uint8 `json:"final_index"`
+ FinalPayload ArtifactRef `json:"final_payload"`
+ ChainHeadID string `json:"chain_head_id"`
+ AcceptedParticipants []string `json:"accepted_participants"`
+ BeaconProvider string `json:"beacon_provider"`
+ BeaconNetwork string `json:"beacon_network"`
+ BeaconRound uint64 `json:"beacon_round"`
+ BeaconNotBefore string `json:"beacon_not_before"`
+ ClosedAt string `json:"closed_at"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+}
+
+func NewCloseRecord(record CloseRecord) (CloseRecord, error) {
+ record.Schema = CloseRecordSchema
+ record.CloseID = ""
+ id, err := ComputeCloseRecordID(record)
+ if err != nil {
+ return CloseRecord{}, err
+ }
+ record.CloseID = id
+ return record, record.Validate()
+}
+
+func ComputeCloseRecordID(record CloseRecord) (string, error) {
+ record.CloseID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/close-record/v1", record)
+}
+
+func (r CloseRecord) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeCloseRecordID(r)
+ if err != nil {
+ return err
+ }
+ if r.CloseID != expected {
+ return fmt.Errorf("close_id %q, want %q", r.CloseID, expected)
+ }
+ return nil
+}
+
+func (r CloseRecord) validate(requireID bool) error {
+ if r.Schema != CloseRecordSchema {
+ return fmt.Errorf("close schema %q, want %q", r.Schema, CloseRecordSchema)
+ }
+ if requireID {
+ if err := validateHashID("close_id", r.CloseID); err != nil {
+ return err
+ }
+ } else if r.CloseID != "" {
+ return errors.New("close_id must be empty while computing identity")
+ }
+ if err := validateRecordScope(r.CeremonyID, r.Phase, r.PhaseID); err != nil {
+ return err
+ }
+ if r.FinalIndex == 0 || r.FinalIndex > MaxParticipants || int(r.FinalIndex) != len(r.AcceptedParticipants) {
+ return errors.New("final_index must equal the non-empty accepted participant count")
+ }
+ if err := r.FinalPayload.Validate(); err != nil {
+ return fmt.Errorf("final_payload: %w", err)
+ }
+ if err := validateHashID("chain_head_id", r.ChainHeadID); err != nil {
+ return err
+ }
+ if err := validateUniqueIDs("accepted participant", r.AcceptedParticipants, MaxParticipants); err != nil {
+ return err
+ }
+ if err := validateID("beacon_provider", r.BeaconProvider); err != nil {
+ return err
+ }
+ if err := validateID("beacon_network", r.BeaconNetwork); err != nil {
+ return err
+ }
+ if r.BeaconRound == 0 {
+ return errors.New("beacon_round must be positive")
+ }
+ if err := validateTimestamp("beacon_not_before", r.BeaconNotBefore); err != nil {
+ return err
+ }
+ if err := validateTimestamp("closed_at", r.ClosedAt); err != nil {
+ return err
+ }
+ notBefore, _ := time.Parse(time.RFC3339Nano, r.BeaconNotBefore)
+ closed, _ := time.Parse(time.RFC3339Nano, r.ClosedAt)
+ if !notBefore.After(closed) {
+ return errors.New("beacon_not_before must be after closed_at")
+ }
+ roundTime, err := QuicknetRoundTime(r.BeaconRound)
+ if err != nil {
+ return err
+ }
+ if !notBefore.Equal(roundTime) {
+ return fmt.Errorf(
+ "beacon_not_before %s does not equal pinned beacon round %d schedule %s",
+ r.BeaconNotBefore,
+ r.BeaconRound,
+ roundTime.Format(time.RFC3339),
+ )
+ }
+ if err := validateID("coordinator_id", r.CoordinatorID); err != nil {
+ return err
+ }
+ return validateID("coordinator_key_id", r.CoordinatorKeyID)
+}
+
+func ValidateClose(definition CeremonyDefinition, chain Chain, close CloseRecord) error {
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if err := chain.ValidateAgainstDefinition(definition); err != nil {
+ return err
+ }
+ if err := close.Validate(); err != nil {
+ return err
+ }
+ head, _ := chain.HeadRecordID()
+ payload, _ := chain.HeadPayload()
+ participants, _ := chain.ParticipantIDs()
+ policy, _ := definition.PolicyForPhase(chain.Phase)
+ if definition.Mode == ModeProduction && len(chain.Records) != len(policy.Participants) {
+ return fmt.Errorf(
+ "production phase has %d accepted contributions, but all %d scheduled participants are required",
+ len(chain.Records),
+ len(policy.Participants),
+ )
+ }
+ if definition.Mode == ModeRehearsal && len(chain.Records) < int(policy.Minimum) {
+ return fmt.Errorf("phase has %d accepted contributions, minimum is %d", len(chain.Records), policy.Minimum)
+ }
+ if close.CeremonyID != chain.CeremonyID || close.Phase != chain.Phase || close.PhaseID != chain.PhaseID ||
+ int(close.FinalIndex) != len(chain.Records) || close.FinalPayload != payload ||
+ close.ChainHeadID != head || !slices.Equal(close.AcceptedParticipants, participants) {
+ return errors.New("close record does not exactly bind accepted chain")
+ }
+ if close.BeaconProvider != definition.BeaconPolicy.Provider ||
+ close.BeaconNetwork != definition.BeaconPolicy.Network {
+ return errors.New("close record beacon does not match definition policy")
+ }
+ if close.CoordinatorID != definition.Coordinator.ID || close.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return errors.New("close record coordinator does not match definition")
+ }
+ createdAt, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt)
+ closedAt, _ := time.Parse(time.RFC3339Nano, close.ClosedAt)
+ notBefore, _ := time.Parse(time.RFC3339Nano, close.BeaconNotBefore)
+ if !closedAt.After(createdAt) {
+ return errors.New("close record must be created after the ceremony definition")
+ }
+ if len(chain.Records) > 0 {
+ finalAcceptedAt, _ := time.Parse(
+ time.RFC3339Nano,
+ chain.Records[len(chain.Records)-1].AcceptedAt,
+ )
+ if !closedAt.After(finalAcceptedAt) {
+ return errors.New("close record must be created strictly after the final acceptance")
+ }
+ }
+ roundTime, err := QuicknetRoundTime(close.BeaconRound)
+ if err != nil {
+ return err
+ }
+ if !roundTime.Equal(notBefore) {
+ return fmt.Errorf(
+ "beacon_not_before %s does not equal pinned beacon round %d schedule %s",
+ close.BeaconNotBefore,
+ close.BeaconRound,
+ roundTime.Format(time.RFC3339),
+ )
+ }
+ if !roundTime.After(closedAt) {
+ return errors.New("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(
+ "beacon round lead %s is below signed minimum %s",
+ roundTime.Sub(closedAt),
+ minimumLead,
+ )
+ }
+ return nil
+}
+
+type BeaconRecord struct {
+ Schema string `json:"schema"`
+ BeaconID string `json:"beacon_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ CloseID string `json:"close_id"`
+ Provider string `json:"provider"`
+ Network string `json:"network"`
+ Round uint64 `json:"round"`
+ PublishedAt string `json:"published_at"`
+ RawResponse ArtifactRef `json:"raw_response"`
+ RandomnessHex string `json:"randomness_hex"`
+ ChallengeHex string `json:"challenge_hex"`
+ ChallengeSHA256 string `json:"challenge_sha256"`
+}
+
+func NewBeaconRecord(record BeaconRecord) (BeaconRecord, error) {
+ record.Schema = BeaconRecordSchema
+ record.BeaconID = ""
+ randomness, err := decodeBeaconRandomness(record.RandomnessHex)
+ if err != nil {
+ return BeaconRecord{}, err
+ }
+ challenge, err := DeriveBeaconChallenge(
+ record.CloseID,
+ record.Provider,
+ record.Network,
+ record.Round,
+ randomness,
+ )
+ if err != nil {
+ return BeaconRecord{}, err
+ }
+ challengeHex := hex.EncodeToString(challenge)
+ challengeSHA256 := taggedSHA256(challenge)
+ if record.ChallengeHex != "" && record.ChallengeHex != challengeHex {
+ return BeaconRecord{}, errors.New("caller-supplied challenge_hex does not match deterministic beacon challenge")
+ }
+ if record.ChallengeSHA256 != "" && record.ChallengeSHA256 != challengeSHA256 {
+ return BeaconRecord{}, errors.New("caller-supplied challenge_sha256 does not match deterministic beacon challenge")
+ }
+ record.ChallengeHex = challengeHex
+ record.ChallengeSHA256 = challengeSHA256
+ id, err := ComputeBeaconRecordID(record)
+ if err != nil {
+ return BeaconRecord{}, err
+ }
+ record.BeaconID = id
+ return record, record.Validate()
+}
+
+func ComputeBeaconRecordID(record BeaconRecord) (string, error) {
+ record.BeaconID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/beacon-record/v1", record)
+}
+
+func (r BeaconRecord) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeBeaconRecordID(r)
+ if err != nil {
+ return err
+ }
+ if r.BeaconID != expected {
+ return fmt.Errorf("beacon_id %q, want %q", r.BeaconID, expected)
+ }
+ return nil
+}
+
+func (r BeaconRecord) validate(requireID bool) error {
+ if r.Schema != BeaconRecordSchema {
+ return fmt.Errorf("beacon schema %q, want %q", r.Schema, BeaconRecordSchema)
+ }
+ if requireID {
+ if err := validateHashID("beacon_id", r.BeaconID); err != nil {
+ return err
+ }
+ } else if r.BeaconID != "" {
+ return errors.New("beacon_id must be empty while computing identity")
+ }
+ if err := validateRecordScope(r.CeremonyID, r.Phase, r.PhaseID); err != nil {
+ return err
+ }
+ if err := validateHashID("close_id", r.CloseID); err != nil {
+ return err
+ }
+ if err := validateID("provider", r.Provider); err != nil {
+ return err
+ }
+ if err := validateID("network", r.Network); err != nil {
+ return err
+ }
+ if r.Round == 0 {
+ return errors.New("beacon round must be positive")
+ }
+ if err := validateTimestamp("published_at", r.PublishedAt); err != nil {
+ return err
+ }
+ if err := r.RawResponse.Validate(); err != nil {
+ return fmt.Errorf("raw_response: %w", err)
+ }
+ randomness, err := decodeBeaconRandomness(r.RandomnessHex)
+ if err != nil {
+ return err
+ }
+ challenge, err := DeriveBeaconChallenge(r.CloseID, r.Provider, r.Network, r.Round, randomness)
+ if err != nil {
+ return err
+ }
+ if r.ChallengeHex != hex.EncodeToString(challenge) ||
+ r.ChallengeSHA256 != taggedSHA256(challenge) {
+ return errors.New("beacon challenge does not match deterministic derivation")
+ }
+ return nil
+}
+
+func ValidateBeacon(definition CeremonyDefinition, close CloseRecord, beacon BeaconRecord) error {
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if err := close.Validate(); err != nil {
+ return err
+ }
+ if err := beacon.Validate(); err != nil {
+ return err
+ }
+ if close.CeremonyID != definition.CeremonyID ||
+ close.BeaconProvider != definition.BeaconPolicy.Provider ||
+ close.BeaconNetwork != definition.BeaconPolicy.Network {
+ return errors.New("close record does not match ceremony definition or beacon policy")
+ }
+ if beacon.CeremonyID != close.CeremonyID || beacon.Phase != close.Phase ||
+ beacon.PhaseID != close.PhaseID || beacon.CloseID != close.CloseID ||
+ beacon.Provider != close.BeaconProvider || beacon.Network != close.BeaconNetwork ||
+ beacon.Round != close.BeaconRound {
+ return errors.New("beacon record does not exactly bind close record")
+ }
+ randomness, err := decodeBeaconRandomness(beacon.RandomnessHex)
+ if err != nil {
+ return err
+ }
+ challenge, err := DeriveBeaconChallenge(
+ close.CloseID,
+ close.BeaconProvider,
+ close.BeaconNetwork,
+ close.BeaconRound,
+ randomness,
+ )
+ if err != nil {
+ return err
+ }
+ if beacon.ChallengeHex != hex.EncodeToString(challenge) ||
+ beacon.ChallengeSHA256 != taggedSHA256(challenge) {
+ return errors.New("beacon challenge does not match the bound close record")
+ }
+ published, _ := time.Parse(time.RFC3339Nano, beacon.PublishedAt)
+ notBefore, _ := time.Parse(time.RFC3339Nano, close.BeaconNotBefore)
+ if published.Before(notBefore) {
+ return errors.New("beacon was published before the committed future time")
+ }
+ roundTime, err := QuicknetRoundTime(close.BeaconRound)
+ if err != nil {
+ return err
+ }
+ if published.Before(roundTime) {
+ return errors.New("beacon published_at predates the pinned quicknet round schedule")
+ }
+ if len(challenge) < int(definition.BeaconPolicy.MinimumChallengeBytes) {
+ return errors.New("beacon challenge is shorter than definition policy")
+ }
+ return nil
+}
+
+// QuicknetRoundTime returns the scheduled UTC publication time for a pinned
+// drand quicknet mainnet round.
+func QuicknetRoundTime(round uint64) (time.Time, error) {
+ if round == 0 {
+ return time.Time{}, errors.New("beacon round must be positive")
+ }
+ offset := round - 1
+ period := uint64(BeaconQuicknetPeriod)
+ maxOffset := uint64(math.MaxInt64-BeaconQuicknetGenesis) / period
+ if offset > maxOffset {
+ return time.Time{}, errors.New("beacon round time overflows int64 Unix seconds")
+ }
+ seconds := BeaconQuicknetGenesis + int64(offset*period)
+ return time.Unix(seconds, 0).UTC(), 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.
+func DeriveBeaconChallenge(closeID, provider, network string, round uint64, randomness []byte) ([]byte, error) {
+ if err := validateHashID("close_id", closeID); err != nil {
+ return nil, err
+ }
+ if provider != BeaconProviderDrand {
+ return nil, fmt.Errorf("beacon provider %q, want %q", provider, BeaconProviderDrand)
+ }
+ if network != BeaconNetworkQuicknet {
+ return nil, fmt.Errorf("beacon network %q, want %q", network, BeaconNetworkQuicknet)
+ }
+ if round == 0 {
+ return nil, errors.New("beacon round must be positive")
+ }
+ if len(randomness) != sha256.Size {
+ return nil, fmt.Errorf("beacon randomness must contain exactly %d bytes, got %d", sha256.Size, len(randomness))
+ }
+
+ digest := sha256.New()
+ digest.Write([]byte("proof-tool/mpc-ceremony/beacon-challenge/v1"))
+ digest.Write([]byte{0})
+ writeBeaconChallengeField(digest, []byte(closeID))
+ writeBeaconChallengeField(digest, []byte(provider))
+ writeBeaconChallengeField(digest, []byte(network))
+ var encodedRound [8]byte
+ binary.BigEndian.PutUint64(encodedRound[:], round)
+ digest.Write(encodedRound[:])
+ writeBeaconChallengeField(digest, randomness)
+ return digest.Sum(nil), nil
+}
+
+func writeBeaconChallengeField(destination hash.Hash, value []byte) {
+ var encodedLength [4]byte
+ binary.BigEndian.PutUint32(encodedLength[:], uint32(len(value)))
+ destination.Write(encodedLength[:])
+ destination.Write(value)
+}
+
+func decodeBeaconRandomness(value string) ([]byte, error) {
+ if len(value) != sha256.Size*2 {
+ return nil, fmt.Errorf("randomness_hex must contain exactly %d bytes", sha256.Size)
+ }
+ if value != stringLower(value) {
+ return nil, errors.New("randomness_hex must be lowercase hexadecimal")
+ }
+ randomness, err := hex.DecodeString(value)
+ if err != nil {
+ return nil, errors.New("randomness_hex must be lowercase hexadecimal")
+ }
+ return randomness, nil
+}
+
+type SealRecord struct {
+ Schema string `json:"schema"`
+ SealID string `json:"seal_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ CloseID string `json:"close_id"`
+ BeaconID string `json:"beacon_id"`
+ FinalPayload ArtifactRef `json:"final_payload"`
+ Outputs []ArtifactRef `json:"outputs"`
+ SealedAt string `json:"sealed_at"`
+}
+
+func NewSealRecord(record SealRecord) (SealRecord, error) {
+ record.Schema = SealRecordSchema
+ record.SealID = ""
+ id, err := ComputeSealRecordID(record)
+ if err != nil {
+ return SealRecord{}, err
+ }
+ record.SealID = id
+ return record, record.Validate()
+}
+
+func ComputeSealRecordID(record SealRecord) (string, error) {
+ record.SealID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/seal-record/v1", record)
+}
+
+func (r SealRecord) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeSealRecordID(r)
+ if err != nil {
+ return err
+ }
+ if r.SealID != expected {
+ return fmt.Errorf("seal_id %q, want %q", r.SealID, expected)
+ }
+ return nil
+}
+
+func (r SealRecord) validate(requireID bool) error {
+ if r.Schema != SealRecordSchema {
+ return fmt.Errorf("seal schema %q, want %q", r.Schema, SealRecordSchema)
+ }
+ if requireID {
+ if err := validateHashID("seal_id", r.SealID); err != nil {
+ return err
+ }
+ } else if r.SealID != "" {
+ return errors.New("seal_id must be empty while computing identity")
+ }
+ if err := validateRecordScope(r.CeremonyID, r.Phase, r.PhaseID); err != nil {
+ return err
+ }
+ if err := validateHashID("close_id", r.CloseID); err != nil {
+ return err
+ }
+ if err := validateHashID("beacon_id", r.BeaconID); err != nil {
+ return err
+ }
+ if err := r.FinalPayload.Validate(); err != nil {
+ return fmt.Errorf("final_payload: %w", err)
+ }
+ if err := validateArtifactList("seal outputs", r.Outputs, 8); err != nil {
+ return err
+ }
+ return validateTimestamp("sealed_at", r.SealedAt)
+}
+
+func ValidateSeal(close CloseRecord, beacon BeaconRecord, seal SealRecord) error {
+ if err := close.Validate(); err != nil {
+ return err
+ }
+ if err := beacon.Validate(); err != nil {
+ return err
+ }
+ if err := seal.Validate(); err != nil {
+ return err
+ }
+ if seal.CeremonyID != close.CeremonyID || seal.Phase != close.Phase ||
+ seal.PhaseID != close.PhaseID || seal.CloseID != close.CloseID ||
+ seal.BeaconID != beacon.BeaconID || seal.FinalPayload != close.FinalPayload {
+ return errors.New("seal record does not exactly bind close and beacon records")
+ }
+ sealed, _ := time.Parse(time.RFC3339Nano, seal.SealedAt)
+ published, _ := time.Parse(time.RFC3339Nano, beacon.PublishedAt)
+ if sealed.Before(published) {
+ return errors.New("seal predates beacon publication")
+ }
+ return nil
+}
+
+type AuditRecord struct {
+ Schema string `json:"schema"`
+ AuditID string `json:"audit_id"`
+ CeremonyID string `json:"ceremony_id"`
+ AuditorID string `json:"auditor_id"`
+ AuditorKeyID string `json:"auditor_key_id"`
+ Definition ArtifactRef `json:"definition"`
+ Phase1Chain ArtifactRef `json:"phase1_chain"`
+ Phase2Chain ArtifactRef `json:"phase2_chain"`
+ Phase1SealID string `json:"phase1_seal_id"`
+ Phase2SealID string `json:"phase2_seal_id"`
+ ReplayRootSHA256 string `json:"replay_root_sha256"`
+ Outputs []ArtifactRef `json:"outputs"`
+ Passed bool `json:"passed"`
+ Findings []string `json:"findings"`
+ AuditedAt string `json:"audited_at"`
+}
+
+func NewAuditRecord(record AuditRecord) (AuditRecord, error) {
+ record.Schema = AuditRecordSchema
+ record.AuditID = ""
+ id, err := ComputeAuditRecordID(record)
+ if err != nil {
+ return AuditRecord{}, err
+ }
+ record.AuditID = id
+ return record, record.Validate()
+}
+
+func ComputeAuditRecordID(record AuditRecord) (string, error) {
+ record.AuditID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/audit-record/v1", record)
+}
+
+func (r AuditRecord) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeAuditRecordID(r)
+ if err != nil {
+ return err
+ }
+ if r.AuditID != expected {
+ return fmt.Errorf("audit_id %q, want %q", r.AuditID, expected)
+ }
+ return nil
+}
+
+func (r AuditRecord) validate(requireID bool) error {
+ if r.Schema != AuditRecordSchema {
+ return fmt.Errorf("audit schema %q, want %q", r.Schema, AuditRecordSchema)
+ }
+ if requireID {
+ if err := validateHashID("audit_id", r.AuditID); err != nil {
+ return err
+ }
+ } else if r.AuditID != "" {
+ return errors.New("audit_id must be empty while computing identity")
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := validateID("auditor_id", r.AuditorID); err != nil {
+ return err
+ }
+ if err := validateID("auditor_key_id", r.AuditorKeyID); err != nil {
+ return err
+ }
+ for label, artifact := range map[string]ArtifactRef{
+ "definition": r.Definition,
+ "phase1_chain": r.Phase1Chain,
+ "phase2_chain": r.Phase2Chain,
+ } {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ }
+ if err := validateHashID("phase1_seal_id", r.Phase1SealID); err != nil {
+ return err
+ }
+ if err := validateHashID("phase2_seal_id", r.Phase2SealID); err != nil {
+ return err
+ }
+ if err := validateHashID("replay_root_sha256", r.ReplayRootSHA256); err != nil {
+ return err
+ }
+ if err := validateArtifactList("audit outputs", r.Outputs, 16); err != nil {
+ return err
+ }
+ if r.Passed && len(r.Findings) != 0 {
+ return errors.New("passing audit must not contain findings")
+ }
+ if !r.Passed && len(r.Findings) == 0 {
+ return errors.New("failed audit must contain at least one finding")
+ }
+ for _, finding := range r.Findings {
+ if finding == "" {
+ return errors.New("audit findings must not be empty")
+ }
+ }
+ return validateTimestamp("audited_at", r.AuditedAt)
+}
+
+type PhaseSummary struct {
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Genesis ArtifactRef `json:"genesis"`
+ Chain ArtifactRef `json:"chain"`
+ ChainHeadID string `json:"chain_head_id"`
+ ContributionCount uint8 `json:"contribution_count"`
+ Participants []string `json:"participants"`
+ CloseID string `json:"close_id"`
+ BeaconID string `json:"beacon_id"`
+ SealID string `json:"seal_id"`
+ Outputs []ArtifactRef `json:"outputs"`
+}
+
+func (s PhaseSummary) Validate() error {
+ if err := s.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("phase_id", s.PhaseID); err != nil {
+ return err
+ }
+ if err := s.Genesis.Validate(); err != nil {
+ return fmt.Errorf("genesis: %w", err)
+ }
+ if err := s.Chain.Validate(); err != nil {
+ return fmt.Errorf("chain: %w", err)
+ }
+ if err := validateHashID("chain_head_id", s.ChainHeadID); err != nil {
+ return err
+ }
+ if s.ContributionCount == 0 || int(s.ContributionCount) != len(s.Participants) {
+ return errors.New("contribution_count must equal non-empty participants")
+ }
+ if err := validateUniqueIDs("participant", s.Participants, MaxParticipants); err != nil {
+ return err
+ }
+ for label, id := range map[string]string{
+ "close_id": s.CloseID,
+ "beacon_id": s.BeaconID,
+ "seal_id": s.SealID,
+ } {
+ if err := validateHashID(label, id); err != nil {
+ return err
+ }
+ }
+ return validateArtifactList("phase outputs", s.Outputs, 8)
+}
+
+type FinalTranscript struct {
+ Schema string `json:"schema"`
+ TranscriptID string `json:"transcript_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Definition ArtifactRef `json:"definition"`
+ Circuit CircuitBinding `json:"circuit"`
+ Phase1 PhaseSummary `json:"phase1"`
+ Phase2 PhaseSummary `json:"phase2"`
+ Audits []ArtifactRef `json:"audits"`
+ OperationalEvidence SignedArtifactRefs `json:"operational_evidence"`
+ ProvingKey ArtifactRef `json:"proving_key"`
+ VerifyingKey ArtifactRef `json:"verifying_key"`
+ CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"`
+ FinalizedAt string `json:"finalized_at"`
+}
+
+func NewFinalTranscript(record FinalTranscript) (FinalTranscript, error) {
+ record.Schema = FinalTranscriptSchema
+ record.TranscriptID = ""
+ id, err := ComputeFinalTranscriptID(record)
+ if err != nil {
+ return FinalTranscript{}, err
+ }
+ record.TranscriptID = id
+ return record, record.Validate()
+}
+
+func ComputeFinalTranscriptID(record FinalTranscript) (string, error) {
+ record.TranscriptID = ""
+ if err := record.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/final-transcript/v1", record)
+}
+
+func (r FinalTranscript) Validate() error {
+ if err := r.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeFinalTranscriptID(r)
+ if err != nil {
+ return err
+ }
+ if r.TranscriptID != expected {
+ return fmt.Errorf("transcript_id %q, want %q", r.TranscriptID, expected)
+ }
+ return nil
+}
+
+func (r FinalTranscript) validate(requireID bool) error {
+ if r.Schema != FinalTranscriptSchema {
+ return fmt.Errorf("transcript schema %q, want %q", r.Schema, FinalTranscriptSchema)
+ }
+ if requireID {
+ if err := validateHashID("transcript_id", r.TranscriptID); err != nil {
+ return err
+ }
+ } else if r.TranscriptID != "" {
+ return errors.New("transcript_id must be empty while computing identity")
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := r.Definition.Validate(); err != nil {
+ return fmt.Errorf("definition: %w", err)
+ }
+ if err := r.Circuit.Validate(); err != nil {
+ return fmt.Errorf("circuit: %w", err)
+ }
+ if err := r.Phase1.Validate(); err != nil {
+ return fmt.Errorf("phase1: %w", err)
+ }
+ if r.Phase1.Phase != Phase1 {
+ return errors.New("phase1 summary has wrong phase")
+ }
+ if err := r.Phase2.Validate(); err != nil {
+ return fmt.Errorf("phase2: %w", err)
+ }
+ if r.Phase2.Phase != Phase2 {
+ return errors.New("phase2 summary has wrong phase")
+ }
+ if len(r.Audits) < 2 {
+ return errors.New("final transcript requires at least two independent audit artifacts")
+ }
+ if err := validateArtifactList("audits", r.Audits, MaxParticipants); err != nil {
+ return err
+ }
+ if err := r.OperationalEvidence.Validate(); err != nil {
+ return fmt.Errorf("operational_evidence: %w", err)
+ }
+ for label, artifact := range map[string]ArtifactRef{
+ "proving_key": r.ProvingKey,
+ "verifying_key": r.VerifyingKey,
+ "cardano_verifying_key": r.CardanoVerifyingKey,
+ } {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ }
+ return validateTimestamp("finalized_at", r.FinalizedAt)
+}
+
+func validateRecordScope(ceremonyID string, phase Phase, phaseID string) error {
+ if err := validateHashID("ceremony_id", ceremonyID); err != nil {
+ return err
+ }
+ if err := phase.Validate(); err != nil {
+ return err
+ }
+ return validateHashID("phase_id", phaseID)
+}
+
+func validateHashID(label, value string) error {
+ if err := validateTaggedHex(value, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ return nil
+}
+
+func validateUniqueIDs(label string, values []string, maximum int) error {
+ if len(values) == 0 || len(values) > maximum {
+ return fmt.Errorf("%s list must contain between 1 and %d entries", label, maximum)
+ }
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ if err := validateID(label, value); err != nil {
+ return err
+ }
+ if _, duplicate := seen[value]; duplicate {
+ return fmt.Errorf("%s %q is duplicated", label, value)
+ }
+ seen[value] = struct{}{}
+ }
+ return nil
+}
+
+func validateArtifactList(label string, artifacts []ArtifactRef, maximum int) error {
+ if len(artifacts) == 0 || len(artifacts) > maximum {
+ return fmt.Errorf("%s must contain between 1 and %d artifacts", label, maximum)
+ }
+ names := make(map[string]struct{}, len(artifacts))
+ for index, artifact := range artifacts {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s %d: %w", label, index, err)
+ }
+ if _, duplicate := names[artifact.Name]; duplicate {
+ return fmt.Errorf("%s artifact name %q is duplicated", label, artifact.Name)
+ }
+ names[artifact.Name] = struct{}{}
+ }
+ return nil
+}
+
+func stringLower(value string) string {
+ result := make([]byte, len(value))
+ for index := range value {
+ c := value[index]
+ if c >= 'A' && c <= 'Z' {
+ c += 'a' - 'A'
+ }
+ result[index] = c
+ }
+ return string(result)
+}
diff --git a/internal/mpcceremony/chain_test.go b/internal/mpcceremony/chain_test.go
new file mode 100644
index 00000000..ddc0065e
--- /dev/null
+++ b/internal/mpcceremony/chain_test.go
@@ -0,0 +1,393 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "encoding/hex"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestDeriveBeaconChallengeIsExactAndDomainBound(t *testing.T) {
+ closeID := "sha256:" + strings.Repeat("11", 32)
+ randomness := bytes.Repeat([]byte{0x42}, 32)
+
+ challenge, err := DeriveBeaconChallenge(closeID, BeaconProviderDrand, BeaconNetworkQuicknet, 1234, randomness)
+ if err != nil {
+ t.Fatalf("derive challenge: %v", err)
+ }
+ if len(challenge) != 32 {
+ t.Fatalf("challenge length = %d, want 32", len(challenge))
+ }
+ if got := hex.EncodeToString(challenge); got != "85b20a01a58bec4cffd9e10f6c94df3c056fcfa391f8ea30ec4888cb5c5cd49f" {
+ t.Fatalf("challenge vector = %s", got)
+ }
+ again, err := DeriveBeaconChallenge(closeID, BeaconProviderDrand, BeaconNetworkQuicknet, 1234, randomness)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(challenge, again) {
+ t.Fatal("same beacon tuple produced different challenges")
+ }
+
+ cases := []struct {
+ name string
+ closeID string
+ provider string
+ network string
+ round uint64
+ randomness []byte
+ }{
+ {"close", "sha256:" + strings.Repeat("12", 32), BeaconProviderDrand, BeaconNetworkQuicknet, 1234, randomness},
+ {"round", closeID, BeaconProviderDrand, BeaconNetworkQuicknet, 1235, randomness},
+ {"randomness", closeID, BeaconProviderDrand, BeaconNetworkQuicknet, 1234, bytes.Repeat([]byte{0x43}, 32)},
+ }
+ if _, err := DeriveBeaconChallenge(closeID, "other", BeaconNetworkQuicknet, 1234, randomness); err == nil {
+ t.Fatal("unsupported beacon provider unexpectedly accepted")
+ }
+ if _, err := DeriveBeaconChallenge(closeID, BeaconProviderDrand, "other", 1234, randomness); err == nil {
+ t.Fatal("unsupported beacon network unexpectedly accepted")
+ }
+ if _, err := DeriveBeaconChallenge(
+ closeID,
+ BeaconProviderDrand,
+ BeaconNetworkQuicknet,
+ 1234,
+ append(bytes.Clone(randomness), 0),
+ ); err == nil {
+ t.Fatal("non-32-byte quicknet randomness unexpectedly accepted")
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ changed, err := DeriveBeaconChallenge(
+ test.closeID,
+ test.provider,
+ test.network,
+ test.round,
+ test.randomness,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(challenge, changed) {
+ t.Fatal("changed beacon tuple retained the same challenge")
+ }
+ })
+ }
+}
+
+func TestNewBeaconRecordOwnsChallengeDerivation(t *testing.T) {
+ randomness := bytes.Repeat([]byte{0x51}, 32)
+ base := BeaconRecord{
+ CeremonyID: "sha256:" + strings.Repeat("21", 32),
+ Phase: Phase1,
+ PhaseID: "sha256:" + strings.Repeat("22", 32),
+ CloseID: "sha256:" + strings.Repeat("23", 32),
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ Round: 9876,
+ PublishedAt: "2026-07-23T13:00:00Z",
+ RawResponse: ArtifactRef{
+ Name: "beacons/phase1-response.json",
+ Digest: NewDigest([]byte("authenticated beacon response")),
+ },
+ RandomnessHex: hex.EncodeToString(randomness),
+ }
+ record, err := NewBeaconRecord(base)
+ if err != nil {
+ t.Fatalf("new beacon record: %v", err)
+ }
+ expected, err := DeriveBeaconChallenge(
+ base.CloseID,
+ base.Provider,
+ base.Network,
+ base.Round,
+ randomness,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if record.ChallengeHex != hex.EncodeToString(expected) {
+ t.Fatalf("challenge_hex = %q, want deterministic derivation", record.ChallengeHex)
+ }
+ if record.ChallengeSHA256 != taggedSHA256(expected) {
+ t.Fatal("challenge_sha256 does not bind derived challenge")
+ }
+ if err := record.Validate(); err != nil {
+ t.Fatalf("derived record rejected: %v", err)
+ }
+
+ arbitrary := base
+ arbitrary.ChallengeHex = strings.Repeat("00", 32)
+ if _, err := NewBeaconRecord(arbitrary); err == nil {
+ t.Fatal("caller-selected challenge unexpectedly accepted")
+ }
+ arbitrary = base
+ arbitrary.ChallengeSHA256 = "sha256:" + strings.Repeat("00", 32)
+ if _, err := NewBeaconRecord(arbitrary); err == nil {
+ t.Fatal("caller-selected challenge digest unexpectedly accepted")
+ }
+
+ tampered := record
+ tampered.RandomnessHex = strings.Repeat("52", 32)
+ if err := tampered.Validate(); err == nil {
+ t.Fatal("randomness tamper retaining the old challenge unexpectedly validated")
+ }
+}
+
+func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) {
+ definition := adversarialDefinition(t)
+ phaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, definition.Phase1Genesis, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ chain, err := NewChain(definition.CeremonyID, Phase1, phaseID, definition.Phase1Genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for index, participantID := range definition.Phase1Policy.Participants {
+ head, err := chain.HeadPayload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ headID, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := adversarialChainRecord(
+ t,
+ definition,
+ phaseID,
+ uint8(index+1),
+ participantID,
+ head,
+ headID,
+ "beacon-bound-"+participantID,
+ )
+ if err := chain.Append(record); err != nil {
+ t.Fatal(err)
+ }
+ }
+ finalPayload, _ := chain.HeadPayload()
+ chainHead, _ := chain.HeadRecordID()
+ participants, _ := chain.ParticipantIDs()
+ roundTime, err := QuicknetRoundTime(30699432)
+ if err != nil {
+ t.Fatal(err)
+ }
+ minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second
+ closeRecord, err := NewCloseRecord(CloseRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ FinalIndex: uint8(len(chain.Records)),
+ FinalPayload: finalPayload,
+ ChainHeadID: chainHead,
+ AcceptedParticipants: participants,
+ BeaconProvider: definition.BeaconPolicy.Provider,
+ 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,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateClose(definition, chain, closeRecord); err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+ belowLead := exactLead
+ belowLead.ClosedAt = "2026-07-23T14:01:00.000000001Z"
+ belowLead, err = NewCloseRecord(belowLead)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateClose(definition, chain, belowLead); err == nil {
+ t.Fatal("close below signed minimum witness lead unexpectedly accepted")
+ }
+ atFinalAcceptance := closeRecord
+ atFinalAcceptance.ClosedAt = chain.Records[len(chain.Records)-1].AcceptedAt
+ atFinalAcceptance, err = NewCloseRecord(atFinalAcceptance)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateClose(definition, chain, atFinalAcceptance); err == nil {
+ t.Fatal("close at the final acceptance timestamp unexpectedly accepted")
+ }
+ pastRound := closeRecord
+ pastRound.BeaconRound = 10001
+ pastRoundTime, err := QuicknetRoundTime(pastRound.BeaconRound)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pastRound.BeaconNotBefore = pastRoundTime.Format(time.RFC3339)
+ pastRound.ClosedAt = pastRoundTime.Add(-time.Second).Format(time.RFC3339)
+ pastRound, err = NewCloseRecord(pastRound)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateClose(definition, chain, pastRound); err == nil {
+ t.Fatal("past beacon round unexpectedly accepted at phase close")
+ }
+ beacon, err := NewBeaconRecord(BeaconRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ CloseID: closeRecord.CloseID,
+ Provider: closeRecord.BeaconProvider,
+ Network: closeRecord.BeaconNetwork,
+ Round: closeRecord.BeaconRound,
+ PublishedAt: "2026-07-24T14:01:00Z",
+ RawResponse: ArtifactRef{
+ Name: "beacons/phase1-response.json",
+ Digest: NewDigest([]byte("phase1 public beacon evidence")),
+ },
+ RandomnessHex: strings.Repeat("a5", 32),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateBeacon(definition, closeRecord, beacon); err != nil {
+ t.Fatalf("bound deterministic beacon rejected: %v", err)
+ }
+
+ otherClose := beacon
+ otherClose.CloseID = "sha256:" + strings.Repeat("99", 32)
+ otherClose.ChallengeHex = ""
+ otherClose.ChallengeSHA256 = ""
+ otherClose, err = NewBeaconRecord(otherClose)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateBeacon(definition, closeRecord, otherClose); err == nil {
+ t.Fatal("challenge derived for another close record unexpectedly accepted")
+ }
+}
+
+func TestCloseRequiresCompleteProductionRosterButKeepsRehearsalThreshold(t *testing.T) {
+ buildAcceptedPrefix := func(
+ t *testing.T,
+ definition CeremonyDefinition,
+ count int,
+ ) (Chain, string) {
+ t.Helper()
+ phaseID, err := ComputePhaseID(
+ definition.CeremonyID,
+ Phase1,
+ definition.Phase1Genesis,
+ "",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ chain, err := NewChain(
+ definition.CeremonyID,
+ Phase1,
+ phaseID,
+ definition.Phase1Genesis,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ for index, participantID := range definition.Phase1Policy.Participants[:count] {
+ head, err := chain.HeadPayload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ headID, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := adversarialChainRecord(
+ t,
+ definition,
+ phaseID,
+ uint8(index+1),
+ participantID,
+ head,
+ headID,
+ "close-policy-"+participantID,
+ )
+ if err := chain.Append(record); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return chain, phaseID
+ }
+ buildClose := func(t *testing.T, definition CeremonyDefinition, chain Chain, phaseID string) CloseRecord {
+ t.Helper()
+ payload, err := chain.HeadPayload()
+ if err != nil {
+ t.Fatal(err)
+ }
+ headID, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ participants, err := chain.ParticipantIDs()
+ if err != nil {
+ t.Fatal(err)
+ }
+ roundTime, err := QuicknetRoundTime(30699432)
+ if err != nil {
+ t.Fatal(err)
+ }
+ minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second
+ closeRecord, err := NewCloseRecord(CloseRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: phaseID,
+ FinalIndex: uint8(len(chain.Records)),
+ FinalPayload: payload,
+ ChainHeadID: headID,
+ AcceptedParticipants: participants,
+ BeaconProvider: definition.BeaconPolicy.Provider,
+ 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,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return closeRecord
+ }
+
+ production := adversarialDefinition(t)
+ productionChain, productionPhaseID := buildAcceptedPrefix(t, production, 2)
+ productionClose := buildClose(t, production, productionChain, productionPhaseID)
+ err := ValidateClose(production, productionChain, productionClose)
+ if err == nil || !strings.Contains(err.Error(), "all 3 scheduled participants") {
+ t.Fatalf("incomplete production close error = %v, want complete-roster rejection", err)
+ }
+
+ rehearsal := adversarialDefinition(t)
+ rehearsal.CeremonyID = ""
+ rehearsal.Mode = ModeRehearsal
+ rehearsal.Phase1Policy = clonePhasePolicy(rehearsal.Phase1Policy)
+ rehearsal.Phase1Policy.Minimum = 2
+ rehearsal.Phase2Policy = clonePhasePolicy(rehearsal.Phase2Policy)
+ rehearsal.Phase2Policy.Minimum = 2
+ rehearsal, err = FinalizeCeremonyDefinition(rehearsal)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rehearsalChain, rehearsalPhaseID := buildAcceptedPrefix(t, rehearsal, 2)
+ rehearsalClose := buildClose(t, rehearsal, rehearsalChain, rehearsalPhaseID)
+ if err := ValidateClose(rehearsal, rehearsalChain, rehearsalClose); err != nil {
+ t.Fatalf("rehearsal close at signed minimum rejected: %v", err)
+ }
+}
diff --git a/internal/mpcceremony/close_timing_test.go b/internal/mpcceremony/close_timing_test.go
new file mode 100644
index 00000000..38766759
--- /dev/null
+++ b/internal/mpcceremony/close_timing_test.go
@@ -0,0 +1,64 @@
+package mpcceremony
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestValidateCloseCommitTimeBoundaries(t *testing.T) {
+ t.Parallel()
+
+ roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
+ const minimumLead uint32 = 300
+ requiredLead := time.Duration(minimumLead)*time.Second + closePublicationSafetyMargin
+ closedAt := roundTime.Add(-requiredLead - time.Second)
+
+ if err := validateCloseCommitTime(
+ closedAt,
+ roundTime.Add(-requiredLead),
+ roundTime,
+ minimumLead,
+ ); err != nil {
+ t.Fatalf("exact publication boundary rejected: %v", err)
+ }
+ if err := validateCloseCommitTime(
+ closedAt,
+ roundTime.Add(-requiredLead+time.Nanosecond),
+ roundTime,
+ minimumLead,
+ ); err == nil || !strings.Contains(err.Error(), "below required") {
+ t.Fatalf("publication below boundary error = %v, want lead rejection", err)
+ }
+}
+
+func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) {
+ t.Parallel()
+
+ roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
+ closedAt := roundTime.Add(-time.Hour)
+ if err := validateCloseCommitTime(
+ closedAt,
+ closedAt.Add(-time.Nanosecond),
+ roundTime,
+ 300,
+ ); err == nil || !strings.Contains(err.Error(), "moved backwards") {
+ t.Fatalf("clock rollback error = %v, want rollback rejection", err)
+ }
+ if err := validateCloseCommitTime(
+ time.Time{},
+ closedAt,
+ roundTime,
+ 300,
+ ); err == nil || !strings.Contains(err.Error(), "zero time") {
+ t.Fatalf("zero closed_at error = %v, want zero-time rejection", err)
+ }
+ if err := validateCloseCommitTime(
+ closedAt,
+ time.Time{},
+ roundTime,
+ 300,
+ ); err == nil || !strings.Contains(err.Error(), "zero time") {
+ t.Fatalf("zero commit time error = %v, want zero-time rejection", err)
+ }
+}
diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go
new file mode 100644
index 00000000..d6c4025f
--- /dev/null
+++ b/internal/mpcceremony/decision.go
@@ -0,0 +1,1342 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "time"
+
+ "proof-tool/internal/artifact"
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/strictjson"
+)
+
+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
+)
+
+type ProductionDecisionOutcome string
+
+const (
+ DecisionGO ProductionDecisionOutcome = "GO"
+ DecisionNOGO ProductionDecisionOutcome = "NO-GO"
+)
+
+type ProductionGateStatus string
+
+const (
+ GatePASS ProductionGateStatus = "PASS"
+ GateFAIL ProductionGateStatus = "FAIL"
+ GatePENDING ProductionGateStatus = "PENDING"
+)
+
+type ProductionGate string
+
+const (
+ GateSignedRelease ProductionGate = "signed-release"
+ GateOperationalEvidence ProductionGate = "operational-evidence"
+ GateIndependentAudits ProductionGate = "two-independent-audits"
+ GateExternalAudit ProductionGate = "third-party-security-audit"
+ GateK21Rehearsal ProductionGate = "exact-k21-rehearsal"
+ GateMainnetDeploymentPlan ProductionGate = "mainnet-deployment-plan"
+ GateFormalChecklist ProductionGate = "formal-go-no-go-checklist"
+ GateParticipantIndependent ProductionGate = "participant-independence"
+ GateParticipantHost ProductionGate = "participant-host-security"
+ GateParticipantEntropy ProductionGate = "participant-entropy"
+ GateParticipantErasure ProductionGate = "participant-erasure"
+ GatePublicWitnessing ProductionGate = "public-witnessing"
+ GateImmutableMirrors ProductionGate = "immutable-independent-mirrors"
+ GateLiveTwentyParty ProductionGate = "live-twenty-party-ceremony"
+)
+
+var requiredProductionGates = [...]ProductionGate{
+ GateSignedRelease,
+ GateOperationalEvidence,
+ GateIndependentAudits,
+ GateExternalAudit,
+ GateK21Rehearsal,
+ GateMainnetDeploymentPlan,
+ GateFormalChecklist,
+ GateParticipantIndependent,
+ GateParticipantHost,
+ GateParticipantEntropy,
+ GateParticipantErasure,
+ GatePublicWitnessing,
+ GateImmutableMirrors,
+ GateLiveTwentyParty,
+}
+
+// LocatedArtifactRef binds immutable content to the exact publication URI
+// reviewed by the decision signers. Verification always hashes a caller-
+// supplied local copy; it never fetches a URI or trusts mutable network state.
+type LocatedArtifactRef struct {
+ URI string `json:"uri"`
+ Artifact ArtifactRef `json:"artifact"`
+}
+
+func (r LocatedArtifactRef) Validate() error {
+ if err := validateImmutableEvidenceURI(r.URI); err != nil {
+ return err
+ }
+ return r.Artifact.Validate()
+}
+
+type SignedLocatedArtifact struct {
+ Record LocatedArtifactRef `json:"record"`
+ Signature LocatedArtifactRef `json:"signature"`
+}
+
+func (r SignedLocatedArtifact) Validate() error {
+ if err := r.Record.Validate(); err != nil {
+ return fmt.Errorf("record: %w", err)
+ }
+ if err := r.Signature.Validate(); err != nil {
+ return fmt.Errorf("signature: %w", err)
+ }
+ if r.Record == r.Signature {
+ return errors.New("signed record and signature must be distinct artifacts")
+ }
+ return nil
+}
+
+// SignedReleaseEvidence pins the release-signature inputs plus the
+// coordinator-signed candidate and canonical final transcript. ReleaseID is
+// derived from this entire structure, rather than supplied by an operator.
+type SignedReleaseEvidence struct {
+ ReleaseID string `json:"release_id"`
+ CandidateID string `json:"candidate_id"`
+ Manifest LocatedArtifactRef `json:"manifest"`
+ ManifestSignature LocatedArtifactRef `json:"manifest_signature"`
+ ManifestPublicKey LocatedArtifactRef `json:"manifest_public_key"`
+ Candidate SignedLocatedArtifact `json:"candidate"`
+ FinalTranscript LocatedArtifactRef `json:"final_transcript"`
+ Artifacts []LocatedArtifactRef `json:"artifacts"`
+}
+
+// SignedReleaseEvidenceDraft is the operator-authored release binding before
+// its content-derived release_id is computed.
+type SignedReleaseEvidenceDraft struct {
+ CandidateID string `json:"candidate_id"`
+ Manifest LocatedArtifactRef `json:"manifest"`
+ ManifestSignature LocatedArtifactRef `json:"manifest_signature"`
+ ManifestPublicKey LocatedArtifactRef `json:"manifest_public_key"`
+ Candidate SignedLocatedArtifact `json:"candidate"`
+ FinalTranscript LocatedArtifactRef `json:"final_transcript"`
+ Artifacts []LocatedArtifactRef `json:"artifacts"`
+}
+
+func (d SignedReleaseEvidenceDraft) release() (SignedReleaseEvidence, error) {
+ return NewSignedReleaseEvidence(SignedReleaseEvidence{
+ CandidateID: d.CandidateID,
+ Manifest: d.Manifest,
+ ManifestSignature: d.ManifestSignature,
+ ManifestPublicKey: d.ManifestPublicKey,
+ Candidate: d.Candidate,
+ FinalTranscript: d.FinalTranscript,
+ Artifacts: d.Artifacts,
+ })
+}
+
+func NewSignedReleaseEvidence(value SignedReleaseEvidence) (SignedReleaseEvidence, error) {
+ value.ReleaseID = ""
+ id, err := computeSignedReleaseID(value)
+ if err != nil {
+ return SignedReleaseEvidence{}, err
+ }
+ value.ReleaseID = id
+ return value, value.Validate()
+}
+
+func (r SignedReleaseEvidence) Validate() error {
+ if err := validateHashID("release_id", r.ReleaseID); err != nil {
+ return err
+ }
+ expected, err := computeSignedReleaseID(r)
+ if err != nil {
+ return err
+ }
+ if r.ReleaseID != expected {
+ return fmt.Errorf("release_id %q, want %q", r.ReleaseID, expected)
+ }
+ if err := validateHashID("candidate_id", r.CandidateID); err != nil {
+ return err
+ }
+ for label, ref := range map[string]LocatedArtifactRef{
+ "manifest": r.Manifest,
+ "manifest_signature": r.ManifestSignature,
+ "manifest_public_key": r.ManifestPublicKey,
+ "final_transcript": r.FinalTranscript,
+ } {
+ if err := ref.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ }
+ if err := r.Candidate.Validate(); err != nil {
+ return fmt.Errorf("candidate: %w", err)
+ }
+ if len(r.Artifacts) < 16 || len(r.Artifacts) > MaxProductionReleaseArtifacts {
+ return fmt.Errorf(
+ "signed release artifact tree must contain between 16 and %d files, got %d",
+ MaxProductionReleaseArtifacts,
+ len(r.Artifacts),
+ )
+ }
+ previous := ""
+ for index, ref := range r.Artifacts {
+ if err := ref.Validate(); err != nil {
+ return fmt.Errorf("release artifact %d: %w", index, err)
+ }
+ if index > 0 && ref.Artifact.Name <= previous {
+ return errors.New("release artifacts must be ordered by unique logical name")
+ }
+ previous = ref.Artifact.Name
+ }
+ return nil
+}
+
+func computeSignedReleaseID(value SignedReleaseEvidence) (string, error) {
+ value.ReleaseID = ""
+ if err := validateHashID("candidate_id", value.CandidateID); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/signed-release/v1", value)
+}
+
+type ProductionAuditEvidence struct {
+ AuditorID string `json:"auditor_id"`
+ AuditorKeyID string `json:"auditor_key_id"`
+ Audit SignedLocatedArtifact `json:"audit"`
+}
+
+func (e ProductionAuditEvidence) Validate() error {
+ if err := validateID("auditor_id", e.AuditorID); err != nil {
+ return err
+ }
+ if err := validateID("auditor_key_id", e.AuditorKeyID); err != nil {
+ return err
+ }
+ return e.Audit.Validate()
+}
+
+type ExternalAuditEvidence struct {
+ Auditor Identity `json:"auditor"`
+ Report LocatedArtifactRef `json:"report"`
+ Signoff LocatedArtifactRef `json:"signoff"`
+}
+
+func (e ExternalAuditEvidence) Validate() error {
+ if err := e.Auditor.Validate(); err != nil {
+ return fmt.Errorf("external auditor: %w", err)
+ }
+ if err := e.Report.Validate(); err != nil {
+ return fmt.Errorf("external audit report: %w", err)
+ }
+ if err := e.Signoff.Validate(); err != nil {
+ return fmt.Errorf("external audit signoff: %w", err)
+ }
+ if e.Report == e.Signoff {
+ return errors.New("external audit report and signoff must be distinct artifacts")
+ }
+ return nil
+}
+
+type K21RehearsalEvidence struct {
+ KeyVersion string `json:"key_version"`
+ CircuitID string `json:"circuit_id"`
+ Curve string `json:"curve"`
+ Backend string `json:"backend"`
+ Constraints uint64 `json:"constraints"`
+ DomainSize uint64 `json:"domain_size"`
+ Evidence LocatedArtifactRef `json:"evidence"`
+}
+
+type SourceReleaseEvidence struct {
+ SourceCommit string `json:"source_commit"`
+ SignedTag string `json:"signed_tag"`
+ SignatureFormat string `json:"signature_format"`
+ SignerFingerprintHex string `json:"signer_fingerprint_hex"`
+ SignedTagObject LocatedArtifactRef `json:"signed_tag_object"`
+}
+
+func (e SourceReleaseEvidence) Validate() error {
+ if err := validateHex(e.SourceCommit, 20); err != nil {
+ return fmt.Errorf("source_commit: %w", err)
+ }
+ if e.SignedTag == "" || len(e.SignedTag) > 160 {
+ return errors.New("signed_tag must contain 1 to 160 characters")
+ }
+ for _, r := range e.SignedTag {
+ if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') &&
+ (r < '0' || r > '9') && !strings.ContainsRune("._/+@-", r) {
+ return fmt.Errorf("signed_tag %q contains an unsupported character", e.SignedTag)
+ }
+ }
+ if e.SignatureFormat != "openpgp-primary-key-v4" {
+ return fmt.Errorf("signature_format %q, want openpgp-primary-key-v4", e.SignatureFormat)
+ }
+ if err := validateHex(e.SignerFingerprintHex, 20); err != nil {
+ return fmt.Errorf("signer_fingerprint_hex: %w", err)
+ }
+ return e.SignedTagObject.Validate()
+}
+
+func (e K21RehearsalEvidence) Validate() error {
+ if e.KeyVersion != KeyVersionDestinationV2 ||
+ e.CircuitID != CircuitIDDestinationV2 ||
+ e.Curve != CurveBLS12381 ||
+ e.Backend != BackendGroth16 {
+ return errors.New("K21 rehearsal must bind the exact ownership-destination-v2 BLS12-381 Groth16 circuit")
+ }
+ if e.Constraints == 0 {
+ return errors.New("K21 rehearsal constraint count must be positive")
+ }
+ if e.DomainSize != 1<<21 {
+ return fmt.Errorf("K21 rehearsal domain_size %d, want %d", e.DomainSize, uint64(1<<21))
+ }
+ return e.Evidence.Validate()
+}
+
+type ProductionGateResult struct {
+ Gate ProductionGate `json:"gate"`
+ Status ProductionGateStatus `json:"status"`
+ Evidence []LocatedArtifactRef `json:"evidence"`
+ Rationale string `json:"rationale"`
+}
+
+func (g ProductionGateResult) Validate() error {
+ switch g.Status {
+ case GatePASS:
+ if len(g.Evidence) == 0 {
+ return fmt.Errorf("PASS gate %q requires immutable evidence", g.Gate)
+ }
+ case GateFAIL, GatePENDING:
+ if strings.TrimSpace(g.Rationale) == "" || g.Rationale != strings.TrimSpace(g.Rationale) {
+ return fmt.Errorf("%s gate %q requires a non-empty trimmed rationale", g.Status, g.Gate)
+ }
+ default:
+ return fmt.Errorf("unsupported production gate status %q", g.Status)
+ }
+ if len(g.Evidence) > 32 {
+ return fmt.Errorf("gate %q has more than 32 evidence artifacts", g.Gate)
+ }
+ previous := ""
+ for index, ref := range g.Evidence {
+ if err := ref.Validate(); err != nil {
+ return fmt.Errorf("gate %q evidence %d: %w", g.Gate, index, err)
+ }
+ if index > 0 && ref.URI <= previous {
+ return fmt.Errorf("gate %q evidence must be ordered by unique URI", g.Gate)
+ }
+ previous = ref.URI
+ }
+ if g.Rationale != "" && (g.Rationale != strings.TrimSpace(g.Rationale) || len(g.Rationale) > 2048) {
+ return fmt.Errorf("gate %q rationale must be trimmed and at most 2048 bytes", g.Gate)
+ }
+ return nil
+}
+
+// ProductionDecision is a canonical, content-addressed GO/NO-GO record. It
+// does not infer operational or organizational facts: external gate results
+// must be supported by pinned evidence and accepted by the required signers.
+type ProductionDecision struct {
+ Schema string `json:"schema"`
+ DecisionID string `json:"decision_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Release SignedReleaseEvidence `json:"release"`
+ SourceRelease SourceReleaseEvidence `json:"source_release"`
+ OperationalEvidence SignedLocatedArtifact `json:"operational_evidence"`
+ Audits []ProductionAuditEvidence `json:"audits"`
+ ExternalAudits []ExternalAuditEvidence `json:"external_audits"`
+ K21Rehearsal K21RehearsalEvidence `json:"k21_rehearsal"`
+ MainnetDeploymentPlan LocatedArtifactRef `json:"mainnet_deployment_plan"`
+ FormalChecklist LocatedArtifactRef `json:"formal_checklist"`
+ Gates []ProductionGateResult `json:"gates"`
+ Decision ProductionDecisionOutcome `json:"decision"`
+ DecidedAt string `json:"decided_at"`
+}
+
+// ProductionDecisionDraft is a strict canonical operator input. It omits both
+// content-derived IDs so an operator cannot accidentally sign stale IDs copied
+// from another release or decision.
+type ProductionDecisionDraft struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Release SignedReleaseEvidenceDraft `json:"release"`
+ SourceRelease SourceReleaseEvidence `json:"source_release"`
+ OperationalEvidence SignedLocatedArtifact `json:"operational_evidence"`
+ Audits []ProductionAuditEvidence `json:"audits"`
+ ExternalAudits []ExternalAuditEvidence `json:"external_audits"`
+ K21Rehearsal K21RehearsalEvidence `json:"k21_rehearsal"`
+ MainnetDeploymentPlan LocatedArtifactRef `json:"mainnet_deployment_plan"`
+ FormalChecklist LocatedArtifactRef `json:"formal_checklist"`
+ Gates []ProductionGateResult `json:"gates"`
+ Decision ProductionDecisionOutcome `json:"decision"`
+ DecidedAt string `json:"decided_at"`
+}
+
+func (d ProductionDecisionDraft) Validate() error {
+ _, err := d.decision()
+ return err
+}
+
+func (d ProductionDecisionDraft) decision() (ProductionDecision, error) {
+ if d.Schema != ProductionDecisionDraftSchema {
+ return ProductionDecision{}, fmt.Errorf(
+ "production decision draft schema %q, want %q",
+ d.Schema,
+ ProductionDecisionDraftSchema,
+ )
+ }
+ release, err := d.Release.release()
+ if err != nil {
+ return ProductionDecision{}, fmt.Errorf("draft release: %w", err)
+ }
+ return NewProductionDecision(ProductionDecision{
+ CeremonyID: d.CeremonyID,
+ Release: release,
+ SourceRelease: d.SourceRelease,
+ OperationalEvidence: d.OperationalEvidence,
+ Audits: d.Audits,
+ ExternalAudits: d.ExternalAudits,
+ K21Rehearsal: d.K21Rehearsal,
+ MainnetDeploymentPlan: d.MainnetDeploymentPlan,
+ FormalChecklist: d.FormalChecklist,
+ Gates: d.Gates,
+ Decision: d.Decision,
+ DecidedAt: d.DecidedAt,
+ })
+}
+
+// PrepareProductionDecision strictly parses a canonical draft, derives both
+// content IDs, and checks its ceremony/circuit/role bindings before returning
+// the exact bytes that the accountable roles must sign.
+func PrepareProductionDecision(
+ definition CeremonyDefinition,
+ draftBytes []byte,
+) (ProductionDecision, []byte, error) {
+ if err := definition.Validate(); err != nil {
+ return ProductionDecision{}, nil, err
+ }
+ var draft ProductionDecisionDraft
+ if err := UnmarshalCanonical(draftBytes, &draft); err != nil {
+ return ProductionDecision{}, nil, fmt.Errorf("production decision draft: %w", err)
+ }
+ decision, err := draft.decision()
+ if err != nil {
+ return ProductionDecision{}, nil, err
+ }
+ if err := validateProductionDecisionBinding(definition, decision); err != nil {
+ return ProductionDecision{}, nil, err
+ }
+ record, err := MarshalCanonical(decision)
+ if err != nil {
+ return ProductionDecision{}, nil, err
+ }
+ return decision, record, nil
+}
+
+func NewProductionDecision(value ProductionDecision) (ProductionDecision, error) {
+ value.Schema = ProductionDecisionSchema
+ value.DecisionID = ""
+ id, err := computeProductionDecisionID(value)
+ if err != nil {
+ return ProductionDecision{}, err
+ }
+ value.DecisionID = id
+ return value, value.Validate()
+}
+
+func (d ProductionDecision) Validate() error {
+ if d.Schema != ProductionDecisionSchema {
+ return fmt.Errorf("production decision schema %q, want %q", d.Schema, ProductionDecisionSchema)
+ }
+ if err := validateHashID("decision_id", d.DecisionID); err != nil {
+ return err
+ }
+ expected, err := computeProductionDecisionID(d)
+ if err != nil {
+ return err
+ }
+ if d.DecisionID != expected {
+ return fmt.Errorf("decision_id %q, want %q", d.DecisionID, expected)
+ }
+ if err := validateHashID("ceremony_id", d.CeremonyID); err != nil {
+ return err
+ }
+ if err := d.Release.Validate(); err != nil {
+ return fmt.Errorf("release: %w", err)
+ }
+ if err := d.SourceRelease.Validate(); err != nil {
+ return fmt.Errorf("source_release: %w", err)
+ }
+ 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))
+ }
+ for index, audit := range d.Audits {
+ if err := audit.Validate(); err != nil {
+ return fmt.Errorf("audit %d: %w", index, err)
+ }
+ if index > 0 && audit.AuditorID <= d.Audits[index-1].AuditorID {
+ return errors.New("audits must be ordered by distinct auditor_id")
+ }
+ }
+ 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))
+ }
+ for index, external := range d.ExternalAudits {
+ if err := external.Validate(); err != nil {
+ return fmt.Errorf("external audit %d: %w", index, err)
+ }
+ 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 err := d.K21Rehearsal.Validate(); err != nil {
+ return err
+ }
+ if err := d.MainnetDeploymentPlan.Validate(); err != nil {
+ return fmt.Errorf("mainnet_deployment_plan: %w", err)
+ }
+ if err := d.FormalChecklist.Validate(); err != nil {
+ return fmt.Errorf("formal_checklist: %w", err)
+ }
+ if path.Ext(d.FormalChecklist.Artifact.Name) != ".md" {
+ return errors.New("formal checklist artifact must be Markdown with a .md logical name")
+ }
+ if len(d.Gates) != len(requiredProductionGates) {
+ return fmt.Errorf("production decision has %d gates, want exactly %d", len(d.Gates), len(requiredProductionGates))
+ }
+ allPass := true
+ for index, expectedGate := range requiredProductionGates {
+ gate := d.Gates[index]
+ if gate.Gate != expectedGate {
+ return fmt.Errorf("gate %d is %q, want %q", index, gate.Gate, expectedGate)
+ }
+ if err := gate.Validate(); err != nil {
+ return err
+ }
+ allPass = allPass && gate.Status == GatePASS
+ }
+ switch d.Decision {
+ case DecisionGO:
+ if !allPass {
+ return errors.New("GO decision requires every production gate to be PASS")
+ }
+ case DecisionNOGO:
+ if allPass {
+ return errors.New("NO-GO decision must enumerate at least one FAIL or PENDING gate")
+ }
+ default:
+ return fmt.Errorf("unsupported production decision %q", d.Decision)
+ }
+ if err := validateTimestamp("decided_at", d.DecidedAt); err != nil {
+ return err
+ }
+ return validateLocatedArtifactCoherence(d)
+}
+
+func computeProductionDecisionID(value ProductionDecision) (string, error) {
+ value.DecisionID = ""
+ if value.Schema != ProductionDecisionSchema {
+ return "", fmt.Errorf("production decision schema %q, want %q", value.Schema, ProductionDecisionSchema)
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/production-decision/v1", value)
+}
+
+type DecisionSignerRole string
+
+const (
+ DecisionSignerCoordinator DecisionSignerRole = "coordinator"
+ DecisionSignerAuditor DecisionSignerRole = "auditor"
+ DecisionSignerRelease DecisionSignerRole = "release_signer"
+)
+
+type ProductionDecisionSignature struct {
+ Schema string `json:"schema"`
+ Role DecisionSignerRole `json:"role"`
+ SignerID string `json:"signer_id"`
+ Signature DetachedSignature `json:"signature"`
+}
+
+func (s ProductionDecisionSignature) Validate() error {
+ if s.Schema != ProductionDecisionSignatureSchema {
+ return fmt.Errorf("decision signature schema %q, want %q", s.Schema, ProductionDecisionSignatureSchema)
+ }
+ switch s.Role {
+ case DecisionSignerCoordinator, DecisionSignerAuditor, DecisionSignerRelease:
+ default:
+ return fmt.Errorf("unsupported decision signer role %q", s.Role)
+ }
+ if err := validateID("decision signer_id", s.SignerID); err != nil {
+ return err
+ }
+ return s.Signature.Validate()
+}
+
+func SignProductionDecision(
+ definition CeremonyDefinition,
+ decisionBytes []byte,
+ role DecisionSignerRole,
+ signerID string,
+ privateKey ed25519.PrivateKey,
+) ([]byte, error) {
+ var decision ProductionDecision
+ if err := UnmarshalCanonical(decisionBytes, &decision); err != nil {
+ return nil, fmt.Errorf("production decision: %w", err)
+ }
+ identity, err := decisionSignerIdentity(definition, decision, role, signerID)
+ if err != nil {
+ return nil, err
+ }
+ publicKey, err := identityPublicKey(identity)
+ if err != nil {
+ return nil, err
+ }
+ if len(privateKey) != ed25519.PrivateKeySize ||
+ !bytes.Equal(privateKey[ed25519.PrivateKeySize-ed25519.PublicKeySize:], publicKey) {
+ return nil, errors.New("decision signing key does not match the required ceremony identity")
+ }
+ detached, err := SignExact(decisionBytes, identity.KeyID, privateKey)
+ if err != nil {
+ return nil, err
+ }
+ return MarshalCanonical(ProductionDecisionSignature{
+ Schema: ProductionDecisionSignatureSchema,
+ Role: role,
+ SignerID: signerID,
+ Signature: detached,
+ })
+}
+
+type VerifyProductionDecisionOptions struct {
+ Definition CeremonyDefinition
+ DecisionBytes []byte
+ SignatureBytes [][]byte
+ EvidenceRoot string
+}
+
+type VerifyProductionDecisionEvidenceOptions struct {
+ Definition CeremonyDefinition
+ DecisionBytes []byte
+ EvidenceRoot string
+}
+
+type VerifiedProductionDecision struct {
+ Decision ProductionDecision
+ DecisionDigest Digest
+ VerifiedSigners []string
+ VerifiedArtifacts []LocatedArtifactRef
+}
+
+// VerifyProductionDecision authenticates all supplied signatures, verifies
+// every pinned local evidence byte string, and fail-closes the GO threshold.
+// URI retrieval and real-world independence/erasure claims remain external.
+func VerifyProductionDecision(options VerifyProductionDecisionOptions) (VerifiedProductionDecision, error) {
+ evidence, err := VerifyProductionDecisionEvidence(VerifyProductionDecisionEvidenceOptions{
+ Definition: options.Definition,
+ DecisionBytes: options.DecisionBytes,
+ EvidenceRoot: options.EvidenceRoot,
+ })
+ if err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ decision := evidence.Decision
+
+ seen := make(map[string]struct{}, len(options.SignatureBytes))
+ verified := make([]string, 0, len(options.SignatureBytes))
+ for index, raw := range options.SignatureBytes {
+ var signature ProductionDecisionSignature
+ if err := UnmarshalCanonical(raw, &signature); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("decision signature %d: %w", index, err)
+ }
+ identity, err := decisionSignerIdentity(
+ options.Definition,
+ decision,
+ signature.Role,
+ signature.SignerID,
+ )
+ if err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("decision signature %d: %w", index, err)
+ }
+ key := string(signature.Role) + "\x00" + signature.SignerID
+ if _, duplicate := seen[key]; duplicate {
+ return VerifiedProductionDecision{}, fmt.Errorf("duplicate decision signature for %s %q", signature.Role, signature.SignerID)
+ }
+ seen[key] = struct{}{}
+ publicKey, err := identityPublicKey(identity)
+ if err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ if err := VerifyExact(
+ options.DecisionBytes,
+ signature.Signature,
+ identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("decision signature %d: %w", index, err)
+ }
+ verified = append(verified, key)
+ }
+ if len(verified) == 0 {
+ return VerifiedProductionDecision{}, errors.New("production decision has no verified signatures")
+ }
+ if decision.Decision == DecisionGO {
+ required := requiredDecisionSigners(options.Definition, decision)
+ for _, signer := range required {
+ if _, ok := seen[signer]; !ok {
+ role, id, _ := strings.Cut(signer, "\x00")
+ return VerifiedProductionDecision{}, fmt.Errorf("GO decision is missing required %s signature from %q", role, id)
+ }
+ }
+ if len(seen) != len(required) {
+ return VerifiedProductionDecision{}, errors.New("GO decision contains a signature outside the exact required threshold")
+ }
+ }
+ slices.Sort(verified)
+ evidence.VerifiedSigners = verified
+ return evidence, nil
+}
+
+// VerifyProductionDecisionEvidence performs the complete public-evidence
+// verification without requiring decision signatures. GO signers use this
+// before their private signing key is loaded.
+func VerifyProductionDecisionEvidence(
+ options VerifyProductionDecisionEvidenceOptions,
+) (VerifiedProductionDecision, error) {
+ if err := options.Definition.Validate(); err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ var decision ProductionDecision
+ if err := UnmarshalCanonical(options.DecisionBytes, &decision); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("production decision: %w", err)
+ }
+ if err := validateProductionDecisionBinding(options.Definition, decision); err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ artifacts := uniqueLocatedArtifacts(decision)
+ for _, located := range artifacts {
+ resolved, err := resolveArtifactPath(options.EvidenceRoot, located.Artifact.Name)
+ if err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ actual, err := artifactRefForFile(located.Artifact.Name, resolved)
+ if err != nil {
+ return VerifiedProductionDecision{}, err
+ }
+ if actual != located.Artifact {
+ return VerifiedProductionDecision{}, fmt.Errorf("decision evidence %q changed or has the wrong digest", located.Artifact.Name)
+ }
+ }
+ if err := verifyDecisionRelease(options.Definition, decision, options.EvidenceRoot); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("signed release evidence: %w", err)
+ }
+ if err := verifyDecisionOperationalEvidence(options.Definition, decision, options.EvidenceRoot); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("operational evidence: %w", err)
+ }
+ if err := verifyDecisionAudits(options.Definition, decision, options.EvidenceRoot); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("independent audits: %w", err)
+ }
+ if err := verifyDecisionExternalAudits(decision, options.EvidenceRoot); err != nil {
+ return VerifiedProductionDecision{}, fmt.Errorf("external audit: %w", err)
+ }
+ return VerifiedProductionDecision{
+ Decision: decision,
+ DecisionDigest: NewDigest(options.DecisionBytes),
+ VerifiedArtifacts: artifacts,
+ }, nil
+}
+
+func validateProductionDecisionBinding(definition CeremonyDefinition, decision ProductionDecision) error {
+ if decision.CeremonyID != definition.CeremonyID {
+ return errors.New("production decision ceremony_id does not match the signed definition")
+ }
+ if decision.SourceRelease.SourceCommit != definition.Software.SourceCommit {
+ return errors.New("production decision source release does not match ceremony build provenance")
+ }
+ if decision.K21Rehearsal.KeyVersion != definition.Circuit.KeyVersion ||
+ decision.K21Rehearsal.CircuitID != definition.Circuit.CircuitID ||
+ decision.K21Rehearsal.Curve != definition.Circuit.Curve ||
+ decision.K21Rehearsal.Backend != definition.Circuit.Backend ||
+ decision.K21Rehearsal.Constraints != definition.Circuit.Constraints ||
+ decision.K21Rehearsal.DomainSize != definition.Circuit.DomainSize {
+ return errors.New("K21 rehearsal does not bind the ceremony definition's exact compiled circuit")
+ }
+ for _, audit := range decision.Audits {
+ enrolled, ok := auditorByID(definition, audit.AuditorID)
+ if !ok || enrolled.KeyID != audit.AuditorKeyID {
+ return fmt.Errorf("decision auditor %q is not enrolled with key %q", audit.AuditorID, audit.AuditorKeyID)
+ }
+ }
+ for _, external := range decision.ExternalAudits {
+ externalFP := external.Auditor.PublicKeyFingerprint
+ if externalFP == definition.Coordinator.PublicKeyFingerprint ||
+ externalFP == definition.ReleaseSigner.PublicKeyFingerprint {
+ return errors.New("external auditor key must be distinct from ceremony coordinator and release signer")
+ }
+ for _, auditor := range definition.Auditors {
+ if externalFP == auditor.PublicKeyFingerprint {
+ return errors.New("external auditor key must be distinct from enrolled ceremony auditors")
+ }
+ }
+ }
+ return nil
+}
+
+func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDecision, root string) error {
+ if err := verifyDecisionReleaseTree(decision.Release, root); err != nil {
+ return err
+ }
+ manifestBytes, err := decisionArtifactBytes(root, decision.Release.Manifest, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signatureBytes, err := decisionArtifactBytes(root, decision.Release.ManifestSignature, 4096)
+ if err != nil {
+ return err
+ }
+ publicKeyBytes, err := decisionArtifactBytes(root, decision.Release.ManifestPublicKey, 4096)
+ if err != nil {
+ return err
+ }
+ expectedPublicKey := definition.ReleaseSigner.Ed25519PublicKeyHex + "\n"
+ if string(publicKeyBytes) != expectedPublicKey {
+ return errors.New("release public-key artifact does not exactly match the enrolled release signer")
+ }
+ if len(signatureBytes) != ed25519.SignatureSize*2+1 ||
+ signatureBytes[len(signatureBytes)-1] != '\n' {
+ return errors.New("release manifest signature is not exact lowercase hex plus newline")
+ }
+ rawSignature, err := hex.DecodeString(string(signatureBytes[:len(signatureBytes)-1]))
+ if err != nil {
+ return errors.New("decode release manifest signature")
+ }
+ releaseKey, err := identityPublicKey(definition.ReleaseSigner)
+ if err != nil {
+ return err
+ }
+ if !ed25519.Verify(releaseKey, manifestBytes, rawSignature) {
+ return errors.New("release manifest Ed25519 signature verification failed")
+ }
+ var manifest artifact.KeyManifest
+ if err := strictjson.Unmarshal(manifestBytes, &manifest); err != nil {
+ return fmt.Errorf("release manifest: %w", err)
+ }
+ if manifest.Schema != artifact.ManifestSchema ||
+ manifest.SignatureKeyID != definition.ReleaseSigner.KeyID ||
+ manifest.KeyVersion != definition.Circuit.KeyVersion ||
+ manifest.CircuitID != definition.Circuit.CircuitID ||
+ manifest.Curve != definition.Circuit.Curve ||
+ manifest.Backend != definition.Circuit.Backend {
+ return errors.New("release manifest does not bind the ceremony circuit and release signer")
+ }
+
+ candidateBytes, err := decisionArtifactBytes(root, decision.Release.Candidate.Record, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ candidateSignatureBytes, err := decisionArtifactBytes(root, decision.Release.Candidate.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ coordinatorKey, err := identityPublicKey(definition.Coordinator)
+ if err != nil {
+ return err
+ }
+ var candidate CandidateMetadata
+ if err := VerifySignedRecord(
+ candidateBytes,
+ candidateSignatureBytes,
+ &candidate,
+ definition.Coordinator.KeyID,
+ coordinatorKey,
+ ); err != nil {
+ return fmt.Errorf("release candidate: %w", err)
+ }
+ definitionBytes, err := MarshalCanonical(definition)
+ if err != nil {
+ return err
+ }
+ if candidate.CandidateID != decision.Release.CandidateID ||
+ candidate.CeremonyID != definition.CeremonyID ||
+ candidate.Definition.Digest != NewDigest(definitionBytes) ||
+ !equalCircuitBinding(candidate.Circuit, definition.Circuit) ||
+ candidate.CoordinatorID != definition.Coordinator.ID ||
+ candidate.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return errors.New("release candidate does not exactly bind the decision and ceremony")
+ }
+ releaseDirName := path.Dir(decision.Release.Manifest.Artifact.Name)
+ requiredTreeRefs := []ArtifactRef{
+ decision.Release.Manifest.Artifact,
+ decision.Release.ManifestSignature.Artifact,
+ decision.Release.ManifestPublicKey.Artifact,
+ decision.Release.Candidate.Record.Artifact,
+ decision.Release.Candidate.Signature.Artifact,
+ decision.Release.FinalTranscript.Artifact,
+ }
+ for _, candidateRef := range candidateFileRefs(candidate) {
+ requiredTreeRefs = append(requiredTreeRefs, releaseTreeArtifact(releaseDirName, candidateRef))
+ }
+ treeRefs := make(map[ArtifactRef]struct{}, len(decision.Release.Artifacts))
+ treeNames := make(map[string]struct{}, len(decision.Release.Artifacts))
+ for _, located := range decision.Release.Artifacts {
+ treeRefs[located.Artifact] = struct{}{}
+ treeNames[located.Artifact.Name] = struct{}{}
+ }
+ for _, required := range requiredTreeRefs {
+ if _, ok := treeRefs[required]; !ok {
+ return fmt.Errorf("signed release tree does not contain exact required artifact %q", required.Name)
+ }
+ }
+ for _, name := range []string{CandidateChecksumsFile, ReleaseChecksumsFile} {
+ fullName := path.Join(releaseDirName, name)
+ if releaseDirName == "." {
+ fullName = name
+ }
+ if _, ok := treeNames[fullName]; !ok {
+ return fmt.Errorf("signed release tree is missing %q", fullName)
+ }
+ }
+ if manifest.VKHash != candidate.VerifyingKey.Digest.Blake2b256 ||
+ manifest.ProvingKeySHA256 != candidate.ProvingKey.Digest.SHA256 ||
+ manifest.ProvingKeyBlake2b256 != candidate.ProvingKey.Digest.Blake2b256 ||
+ manifest.ProvingKeySize != candidate.ProvingKey.Digest.Size ||
+ manifest.VerifyingKeySHA256 != candidate.VerifyingKey.Digest.SHA256 ||
+ manifest.VerifyingKeySize != candidate.VerifyingKey.Digest.Size ||
+ manifest.ConstraintSystemHash != candidate.ConstraintSystem.Digest.Blake2b256 ||
+ manifest.CircuitSourceCommit != definition.Software.SourceCommit ||
+ manifest.ProofToolVersion != definition.Software.ProofToolVersion ||
+ manifest.GnarkVersion != definition.Software.GnarkVersion ||
+ len(manifest.ArtifactURLs) != 0 {
+ return errors.New("release manifest does not bind the candidate key artifacts")
+ }
+
+ transcriptBytes, err := decisionArtifactBytes(root, decision.Release.FinalTranscript, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var transcript FinalTranscript
+ 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),
+ }
+ expectedOperationalRefs := SignedArtifactRefs{
+ Record: releaseLogicalArtifact(
+ releaseDirName,
+ decision.OperationalEvidence.Record.Artifact,
+ ),
+ Signature: releaseLogicalArtifact(
+ releaseDirName,
+ decision.OperationalEvidence.Signature.Artifact,
+ ),
+ }
+ if transcript.CeremonyID != definition.CeremonyID ||
+ transcript.Definition != candidate.Definition ||
+ !equalCircuitBinding(transcript.Circuit, candidate.Circuit) ||
+ !reflect.DeepEqual(transcript.Phase1, candidate.Phase1) ||
+ !reflect.DeepEqual(transcript.Phase2, candidate.Phase2) ||
+ !slices.Equal(transcript.Audits, expectedAuditRefs) ||
+ transcript.OperationalEvidence != expectedOperationalRefs ||
+ transcript.ProvingKey != candidate.ProvingKey ||
+ transcript.VerifyingKey != candidate.VerifyingKey ||
+ transcript.CardanoVerifyingKey != candidate.CardanoVerifyingKey ||
+ manifest.SetupTranscriptHash != NewDigest(transcriptBytes).Blake2b256 {
+ return errors.New("final transcript does not cohere with decision, candidate, audits, operational evidence, and manifest")
+ }
+ if manifest.PublishedAt != transcript.FinalizedAt {
+ return errors.New("release manifest published_at does not match final transcript")
+ }
+ transcriptTime, err := time.Parse(time.RFC3339Nano, transcript.FinalizedAt)
+ if err != nil {
+ return fmt.Errorf("final transcript release time: %w", err)
+ }
+ coordinatorKey, err = identityPublicKey(definition.Coordinator)
+ if err != nil {
+ return err
+ }
+ releaseRoot, err := resolveArtifactPath(root, releaseDirName)
+ if err != nil {
+ return err
+ }
+ operationalEvidence, err := verifyReleaseOperationalEvidence(
+ definition,
+ coordinatorKey,
+ candidate,
+ releaseRoot,
+ filepath.Join(releaseRoot, filepath.FromSlash(OperationalEvidenceBundleFile)),
+ filepath.Join(releaseRoot, filepath.FromSlash(OperationalEvidenceSignatureFile)),
+ transcriptTime,
+ )
+ if err != nil {
+ return fmt.Errorf("full operational evidence verification: %w", err)
+ }
+ if transcript.OperationalEvidence != operationalEvidence.BundleRef ||
+ expectedOperationalRefs != operationalEvidence.BundleRef {
+ return errors.New("decision operational evidence does not match the recursively verified release bundle")
+ }
+ if err := verifyChecksumsExact(
+ releaseRoot,
+ filepath.Join(releaseRoot, CandidateChecksumsFile),
+ candidateChecksumNames(),
+ ); err != nil {
+ return fmt.Errorf("candidate checksums: %w", err)
+ }
+ if err := verifyChecksumsExact(
+ releaseRoot,
+ filepath.Join(releaseRoot, ReleaseChecksumsFile),
+ releaseChecksumNames(len(decision.Audits), operationalEvidence.Names),
+ ); err != nil {
+ return fmt.Errorf("release checksums: %w", err)
+ }
+ if err := verifyReleaseTreeExact(
+ releaseRoot,
+ len(decision.Audits),
+ operationalEvidence.Names,
+ ); err != nil {
+ return fmt.Errorf("exact release artifact set: %w", err)
+ }
+ return nil
+}
+
+func releaseTreeArtifact(releaseDirName string, logical ArtifactRef) ArtifactRef {
+ name := path.Join(releaseDirName, logical.Name)
+ if releaseDirName == "." {
+ name = logical.Name
+ }
+ return ArtifactRef{Name: name, Digest: logical.Digest}
+}
+
+func releaseLogicalArtifact(releaseDirName string, tree ArtifactRef) ArtifactRef {
+ if releaseDirName == "." {
+ return tree
+ }
+ return ArtifactRef{
+ Name: strings.TrimPrefix(tree.Name, releaseDirName+"/"),
+ Digest: tree.Digest,
+ }
+}
+
+func verifyDecisionReleaseTree(release SignedReleaseEvidence, root string) error {
+ releaseDirName := path.Dir(release.Manifest.Artifact.Name)
+ if path.Base(release.Manifest.Artifact.Name) != keybundle.ManifestFile {
+ return fmt.Errorf("release manifest logical name must end in %q", keybundle.ManifestFile)
+ }
+ releaseDir, err := resolveArtifactPath(root, releaseDirName)
+ if err != nil {
+ return err
+ }
+ expected := make(map[string]LocatedArtifactRef, len(release.Artifacts))
+ for _, located := range release.Artifacts {
+ if path.Dir(located.Artifact.Name) != releaseDirName &&
+ !strings.HasPrefix(located.Artifact.Name, releaseDirName+"/") {
+ return fmt.Errorf("release artifact %q is outside release directory %q", located.Artifact.Name, releaseDirName)
+ }
+ expected[located.Artifact.Name] = located
+ }
+ actual := make(map[string]struct{}, len(expected))
+ err = filepath.WalkDir(releaseDir, func(file string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if file == releaseDir {
+ return nil
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("release tree contains forbidden symlink %q", file)
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ if !entry.Type().IsRegular() {
+ return fmt.Errorf("release tree contains non-regular file %q", file)
+ }
+ relative, err := filepath.Rel(root, file)
+ if err != nil {
+ return err
+ }
+ name := filepath.ToSlash(relative)
+ located, ok := expected[name]
+ if !ok {
+ return fmt.Errorf("release tree contains unpinned file %q", name)
+ }
+ ref, err := artifactRefForFile(name, file)
+ if err != nil {
+ return err
+ }
+ if ref != located.Artifact {
+ return fmt.Errorf("release artifact %q changed or has the wrong digest", name)
+ }
+ actual[name] = struct{}{}
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ if len(actual) != len(expected) {
+ return errors.New("one or more pinned release artifacts are missing from the exact release tree")
+ }
+ return nil
+}
+
+func verifyDecisionOperationalEvidence(definition CeremonyDefinition, decision ProductionDecision, root string) error {
+ recordBytes, err := decisionArtifactBytes(root, decision.OperationalEvidence.Record, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signatureBytes, err := decisionArtifactBytes(root, decision.OperationalEvidence.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ publicKey, err := identityPublicKey(definition.Coordinator)
+ if err != nil {
+ return err
+ }
+ var bundle OperationalEvidenceBundle
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &bundle,
+ definition.Coordinator.KeyID,
+ publicKey,
+ ); err != nil {
+ return err
+ }
+ if bundle.CeremonyID != definition.CeremonyID ||
+ bundle.CoordinatorID != definition.Coordinator.ID ||
+ bundle.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return errors.New("operational evidence bundle does not bind the ceremony coordinator")
+ }
+ return nil
+}
+
+func verifyDecisionAudits(definition CeremonyDefinition, decision ProductionDecision, root string) error {
+ candidateBytes, err := decisionArtifactBytes(root, decision.Release.Candidate.Record, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var candidate CandidateMetadata
+ if err := UnmarshalCanonical(candidateBytes, &candidate); err != nil {
+ return err
+ }
+ replayRoot, err := replayRootSHA256(candidate)
+ if err != nil {
+ return err
+ }
+ expectedOutputs := candidateAuditOutputs(candidate, ArtifactRef{
+ Name: CandidateMetadataFile, Digest: decision.Release.Candidate.Record.Artifact.Digest,
+ })
+ for index, evidence := range decision.Audits {
+ recordBytes, err := decisionArtifactBytes(root, evidence.Audit.Record, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signatureBytes, err := decisionArtifactBytes(root, evidence.Audit.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ identity, _ := auditorByID(definition, evidence.AuditorID)
+ publicKey, err := identityPublicKey(identity)
+ if err != nil {
+ return err
+ }
+ var audit AuditRecord
+ if err := VerifySignedRecord(recordBytes, signatureBytes, &audit, identity.KeyID, publicKey); err != nil {
+ return fmt.Errorf("audit %d signature: %w", index, err)
+ }
+ if audit.AuditorID != evidence.AuditorID ||
+ audit.AuditorKeyID != evidence.AuditorKeyID ||
+ audit.CeremonyID != definition.CeremonyID ||
+ !audit.Passed ||
+ len(audit.Findings) != 0 ||
+ audit.Definition != candidate.Definition ||
+ audit.Phase1Chain != candidate.Phase1.Chain ||
+ audit.Phase2Chain != candidate.Phase2.Chain ||
+ audit.Phase1SealID != candidate.Phase1.SealID ||
+ audit.Phase2SealID != candidate.Phase2.SealID ||
+ audit.ReplayRootSHA256 != replayRoot ||
+ !slices.Equal(audit.Outputs, expectedOutputs) {
+ return fmt.Errorf("audit %d is not a passing exact-candidate audit", index)
+ }
+ }
+ return nil
+}
+
+func verifyDecisionExternalAudits(decision ProductionDecision, root string) error {
+ for index, external := range decision.ExternalAudits {
+ reportBytes, err := decisionArtifactBytes(root, external.Report, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signoffBytes, err := decisionArtifactBytes(root, external.Signoff, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var signoff DetachedSignature
+ if err := UnmarshalCanonical(signoffBytes, &signoff); err != nil {
+ return err
+ }
+ publicKey, err := identityPublicKey(external.Auditor)
+ if err != nil {
+ return err
+ }
+ if err := VerifyExact(reportBytes, signoff, external.Auditor.KeyID, publicKey); err != nil {
+ return fmt.Errorf("external audit %d: %w", index, err)
+ }
+ }
+ return nil
+}
+
+func decisionArtifactBytes(root string, located LocatedArtifactRef, maximum int64) ([]byte, error) {
+ return verifyArtifactBytes(root, located.Artifact, maximum)
+}
+
+func decisionSignerIdentity(
+ definition CeremonyDefinition,
+ decision ProductionDecision,
+ role DecisionSignerRole,
+ id string,
+) (Identity, error) {
+ if err := validateProductionDecisionBinding(definition, decision); err != nil {
+ return Identity{}, err
+ }
+ switch role {
+ case DecisionSignerCoordinator:
+ if id != definition.Coordinator.ID {
+ return Identity{}, errors.New("coordinator decision signature has the wrong identity")
+ }
+ return definition.Coordinator, nil
+ case DecisionSignerRelease:
+ if id != definition.ReleaseSigner.ID {
+ return Identity{}, errors.New("release-signer decision signature has the wrong identity")
+ }
+ return definition.ReleaseSigner, nil
+ case DecisionSignerAuditor:
+ for _, audit := range decision.Audits {
+ if audit.AuditorID == id {
+ identity, ok := auditorByID(definition, id)
+ if !ok || identity.KeyID != audit.AuditorKeyID {
+ break
+ }
+ return identity, nil
+ }
+ }
+ return Identity{}, errors.New("auditor decision signature is not from either audit bound by the decision")
+ default:
+ return Identity{}, fmt.Errorf("unsupported decision signer role %q", role)
+ }
+}
+
+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,
+ }
+}
+
+func validateLocatedArtifactCoherence(decision ProductionDecision) error {
+ byName := make(map[string]LocatedArtifactRef)
+ byURI := make(map[string]LocatedArtifactRef)
+ for _, ref := range allLocatedArtifacts(decision) {
+ if previous, ok := byName[ref.Artifact.Name]; ok && previous != ref {
+ return fmt.Errorf("artifact name %q is bound to conflicting evidence", ref.Artifact.Name)
+ }
+ if previous, ok := byURI[ref.URI]; ok && previous != ref {
+ return fmt.Errorf("artifact URI %q is bound to conflicting evidence", ref.URI)
+ }
+ byName[ref.Artifact.Name] = ref
+ byURI[ref.URI] = ref
+ }
+ return nil
+}
+
+func allLocatedArtifacts(decision ProductionDecision) []LocatedArtifactRef {
+ refs := []LocatedArtifactRef{
+ decision.Release.Manifest,
+ decision.Release.ManifestSignature,
+ decision.Release.ManifestPublicKey,
+ decision.Release.Candidate.Record,
+ decision.Release.Candidate.Signature,
+ decision.Release.FinalTranscript,
+ 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,
+ }
+ refs = append(refs, decision.Release.Artifacts...)
+ for _, external := range decision.ExternalAudits {
+ refs = append(refs, external.Report, external.Signoff)
+ }
+ for _, gate := range decision.Gates {
+ refs = append(refs, gate.Evidence...)
+ }
+ return refs
+}
+
+func uniqueLocatedArtifacts(decision ProductionDecision) []LocatedArtifactRef {
+ byURI := make(map[string]LocatedArtifactRef)
+ for _, ref := range allLocatedArtifacts(decision) {
+ byURI[ref.URI] = ref
+ }
+ result := make([]LocatedArtifactRef, 0, len(byURI))
+ for _, ref := range byURI {
+ result = append(result, ref)
+ }
+ slices.SortFunc(result, func(a, b LocatedArtifactRef) int {
+ return strings.Compare(a.URI, b.URI)
+ })
+ return result
+}
+
+func validateImmutableEvidenceURI(value string) error {
+ if value == "" || len(value) > 2048 || value != strings.TrimSpace(value) {
+ return errors.New("evidence URI must be non-empty, trimmed, and at most 2048 bytes")
+ }
+ parsed, err := url.Parse(value)
+ if err != nil || parsed.String() != value {
+ return errors.New("evidence URI must use canonical URL encoding")
+ }
+ if parsed.User != nil || parsed.Fragment != "" {
+ return errors.New("evidence URI must not contain userinfo or a fragment")
+ }
+ switch parsed.Scheme {
+ case "https", "ipfs":
+ default:
+ return fmt.Errorf("evidence URI scheme %q is not an immutable-publication transport", parsed.Scheme)
+ }
+ if parsed.Host == "" {
+ return errors.New("evidence URI must contain a host or content identifier")
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/decision_test.go b/internal/mpcceremony/decision_test.go
new file mode 100644
index 00000000..c227d066
--- /dev/null
+++ b/internal/mpcceremony/decision_test.go
@@ -0,0 +1,1106 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "proof-tool/internal/artifact"
+)
+
+type productionDecisionFixture struct {
+ root string
+ definition CeremonyDefinition
+ decision ProductionDecision
+ record []byte
+ signatures [][]byte
+}
+
+func TestProductionDecisionGORequiresExactRoleThresholdAndEvidence(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ evidenceOnly, err := VerifyProductionDecisionEvidence(
+ VerifyProductionDecisionEvidenceOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ EvidenceRoot: fixture.root,
+ },
+ )
+ if err != nil {
+ t.Fatalf("verify production GO evidence before signing: %v", err)
+ }
+ if len(evidenceOnly.VerifiedSigners) != 0 ||
+ evidenceOnly.Decision.DecisionID != fixture.decision.DecisionID {
+ t.Fatalf("evidence-only result = %#v", evidenceOnly)
+ }
+ verified, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures,
+ EvidenceRoot: fixture.root,
+ })
+ if err != nil {
+ t.Fatalf("verify production GO decision: %v", err)
+ }
+ if verified.Decision.Decision != DecisionGO || len(verified.VerifiedSigners) != 4 {
+ t.Fatalf("verified decision = %q with %d signers", verified.Decision.Decision, len(verified.VerifiedSigners))
+ }
+
+ for index := range fixture.signatures {
+ missing := append([][]byte(nil), fixture.signatures[:index]...)
+ missing = append(missing, fixture.signatures[index+1:]...)
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: missing,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "missing required") {
+ t.Fatalf("missing signature %d error = %v", index, err)
+ }
+ }
+
+ duplicate := append(append([][]byte(nil), fixture.signatures...), fixture.signatures[0])
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: duplicate,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "duplicate decision signature") {
+ t.Fatalf("duplicate signature error = %v", err)
+ }
+}
+
+func TestPrepareProductionDecisionDerivesExactSignedRecord(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ draft := productionDecisionDraft(fixture.decision)
+ draftBytes, err := MarshalCanonical(draft)
+ if err != nil {
+ t.Fatal(err)
+ }
+ prepared, preparedBytes, err := PrepareProductionDecision(fixture.definition, draftBytes)
+ if err != nil {
+ t.Fatalf("prepare production decision: %v", err)
+ }
+ if prepared.DecisionID != fixture.decision.DecisionID ||
+ prepared.Release.ReleaseID != fixture.decision.Release.ReleaseID ||
+ !slices.Equal(preparedBytes, fixture.record) {
+ t.Fatal("prepared decision does not exactly reproduce the content-addressed record")
+ }
+
+ unknown := append([]byte(nil), draftBytes[:len(draftBytes)-1]...)
+ unknown = append(unknown, []byte(`,"unknown":true}`)...)
+ if _, _, err := PrepareProductionDecision(fixture.definition, unknown); err == nil {
+ t.Fatal("decision prepare accepted an unknown draft field")
+ }
+ if _, _, err := PrepareProductionDecision(
+ fixture.definition,
+ append(append([]byte(nil), draftBytes...), '\n'),
+ ); err == nil {
+ t.Fatal("decision prepare accepted trailing bytes")
+ }
+}
+
+func TestSignedReleaseInventorySupportsTwentyPartyOperationalScale(t *testing.T) {
+ const representativeFiles = 1024
+ artifacts := make([]LocatedArtifactRef, representativeFiles)
+ for index := range artifacts {
+ name := fmt.Sprintf("release/operational/artifact-%04d.json", index)
+ artifacts[index] = LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: ArtifactRef{
+ Name: name,
+ Digest: NewDigest([]byte(name)),
+ },
+ }
+ }
+ input := SignedReleaseEvidence{
+ CandidateID: "sha256:" + strings.Repeat("71", 32),
+ Manifest: decisionScaleArtifact("release/manifest.json", "manifest"),
+ ManifestSignature: decisionScaleArtifact(
+ "release/manifest.sig",
+ "manifest signature",
+ ),
+ ManifestPublicKey: decisionScaleArtifact(
+ "release/manifest-public-key.hex",
+ "manifest public key",
+ ),
+ Candidate: SignedLocatedArtifact{
+ Record: decisionScaleArtifact("release/candidate.json", "candidate"),
+ Signature: decisionScaleArtifact("release/candidate.sig.json", "candidate signature"),
+ },
+ FinalTranscript: decisionScaleArtifact("release/setup-transcript.json", "transcript"),
+ Artifacts: artifacts,
+ }
+ if _, err := NewSignedReleaseEvidence(input); err != nil {
+ t.Fatalf("representative 20-party release inventory rejected: %v", err)
+ }
+
+ input.Artifacts = make([]LocatedArtifactRef, MaxProductionReleaseArtifacts+1)
+ for index := range input.Artifacts {
+ name := fmt.Sprintf("release/overflow/artifact-%04d", index)
+ input.Artifacts[index] = LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: ArtifactRef{
+ Name: name,
+ Digest: NewDigest([]byte(name)),
+ },
+ }
+ }
+ if _, err := NewSignedReleaseEvidence(input); err == nil ||
+ !strings.Contains(err.Error(), "4096") {
+ t.Fatalf("oversized release inventory error = %v", err)
+ }
+}
+
+func TestProductionDecisionRejectsWrongRoleWrongKeyAndChangedEvidence(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+
+ var coordinator ProductionDecisionSignature
+ if err := UnmarshalCanonical(fixture.signatures[0], &coordinator); err != nil {
+ t.Fatal(err)
+ }
+ coordinator.Role = DecisionSignerRelease
+ wrongRole, err := MarshalCanonical(coordinator)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signatures := append([][]byte(nil), fixture.signatures...)
+ signatures[0] = wrongRole
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "wrong identity") {
+ t.Fatalf("wrong-role error = %v", err)
+ }
+
+ wrongKey, err := SignExact(
+ fixture.record,
+ fixture.definition.Coordinator.KeyID,
+ adversarialPrivateKey(0x7e),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ coordinator.Role = DecisionSignerCoordinator
+ coordinator.Signature = wrongKey
+ wrongKeyBytes, err := MarshalCanonical(coordinator)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signatures[0] = wrongKeyBytes
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "fingerprint mismatch") {
+ t.Fatalf("wrong-key error = %v", err)
+ }
+
+ checklistPath := filepath.Join(fixture.root, fixture.decision.FormalChecklist.Artifact.Name)
+ if err := os.WriteFile(checklistPath, []byte("# changed\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "changed or has the wrong digest") {
+ t.Fatalf("changed-evidence error = %v", err)
+ }
+}
+
+func TestProductionDecisionHashesTheExactReleaseTree(t *testing.T) {
+ t.Run("changed proving key", func(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ provingKeyPath := filepath.Join(
+ fixture.root,
+ "release",
+ NativeProvingKeyFile,
+ )
+ if err := os.WriteFile(provingKeyPath, []byte("different proving key"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "changed or has the wrong digest") {
+ t.Fatalf("changed proving-key error = %v", err)
+ }
+ })
+
+ t.Run("unpinned file", func(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ if err := os.WriteFile(
+ filepath.Join(fixture.root, "release", "unreviewed.txt"),
+ []byte("not in the signed inventory"),
+ 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "unpinned file") {
+ t.Fatalf("unpinned release-file error = %v", err)
+ }
+ })
+}
+
+func TestProductionDecisionRequiresTwoDistinctExternalAuditSignoffs(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ value := fixture.decision
+ value.DecisionID = ""
+ value.ExternalAudits[1].Auditor = value.ExternalAudits[0].Auditor
+ if _, err := NewProductionDecision(value); err == nil ||
+ !strings.Contains(err.Error(), "distinct auditor identity") {
+ t.Fatalf("duplicate external auditor error = %v", err)
+ }
+
+ fixture = newProductionDecisionFixture(t, DecisionGO)
+ secondReport := fixture.decision.ExternalAudits[1].Report
+ if err := os.WriteFile(
+ filepath.Join(fixture.root, secondReport.Artifact.Name),
+ []byte("changed second external audit report\n"),
+ 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "changed or has the wrong digest") {
+ t.Fatalf("changed second external-audit report error = %v", err)
+ }
+}
+
+func TestProductionDecisionRejectsResignedSemanticOperationalTamper(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ releaseRoot := filepath.Join(fixture.root, "release")
+ bundlePath := filepath.Join(releaseRoot, filepath.FromSlash(OperationalEvidenceBundleFile))
+ bundleBytes, err := os.ReadFile(bundlePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var bundle OperationalEvidenceBundle
+ if err := UnmarshalCanonical(bundleBytes, &bundle); err != nil {
+ t.Fatal(err)
+ }
+
+ handoffPair := bundle.Phase1.AcceptedHeads[0].OutboundHandoff
+ handoffBytes, err := verifyArtifactBytes(releaseRoot, handoffPair.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var handoff TransferHandoff
+ if err := UnmarshalCanonical(handoffBytes, &handoff); err != nil {
+ t.Fatal(err)
+ }
+ handoff.SenderID, handoff.RecipientID = handoff.RecipientID, handoff.SenderID
+ handoff.SenderKeyID, handoff.RecipientKeyID =
+ handoff.RecipientKeyID, handoff.SenderKeyID
+ rewriteSignedPair(
+ t,
+ releaseRoot,
+ handoffPair,
+ handoff,
+ adversarialPrivateKey(0x11),
+ )
+ bundle.Phase1.AcceptedHeads[0].OutboundHandoff =
+ refreshPair(t, releaseRoot, handoffPair)
+
+ bundleBytes, bundleSignatureBytes, err := SignRecord(
+ bundle,
+ fixture.definition.Coordinator.KeyID,
+ adversarialPrivateKey(0x01),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(bundlePath, bundleBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ bundleSignaturePath := filepath.Join(
+ releaseRoot,
+ filepath.FromSlash(OperationalEvidenceSignatureFile),
+ )
+ if err := os.WriteFile(bundleSignaturePath, bundleSignatureBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ transcriptPath := filepath.Join(releaseRoot, FinalTranscriptFile)
+ transcriptBytes, err := os.ReadFile(transcriptPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var transcript FinalTranscript
+ if err := UnmarshalCanonical(transcriptBytes, &transcript); err != nil {
+ t.Fatal(err)
+ }
+ transcript.OperationalEvidence = SignedArtifactRefs{
+ Record: mustArtifactRef(t, releaseRoot, OperationalEvidenceBundleFile),
+ Signature: mustArtifactRef(
+ t,
+ releaseRoot,
+ OperationalEvidenceSignatureFile,
+ ),
+ }
+ transcript.TranscriptID = ""
+ transcript, err = NewFinalTranscript(transcript)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transcriptBytes, err = MarshalCanonical(transcript)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(transcriptPath, transcriptBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ manifestPath := filepath.Join(releaseRoot, "manifest.json")
+ manifestBytes, err := os.ReadFile(manifestPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var manifest artifact.KeyManifest
+ if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
+ t.Fatal(err)
+ }
+ manifest.SetupTranscriptHash = NewDigest(transcriptBytes).Blake2b256
+ manifestBytes, err = json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifestBytes = append(manifestBytes, '\n')
+ if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ manifestSignature := hex.EncodeToString(
+ ed25519.Sign(adversarialPrivateKey(0x02), manifestBytes),
+ ) + "\n"
+ if err := os.WriteFile(
+ filepath.Join(releaseRoot, "manifest.sig"),
+ []byte(manifestSignature),
+ 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+
+ rewriteChecksumFileFromExistingNames(
+ t,
+ releaseRoot,
+ filepath.Join(releaseRoot, ReleaseChecksumsFile),
+ )
+
+ decision := fixture.decision
+ oldOperational := decision.OperationalEvidence
+ oldManifest := decision.Release.Manifest
+ decision.OperationalEvidence = SignedLocatedArtifact{
+ Record: refreshedLocated(t, fixture.root, oldOperational.Record),
+ Signature: refreshedLocated(t, fixture.root, oldOperational.Signature),
+ }
+ decision.Release.Manifest = refreshedLocated(t, fixture.root, decision.Release.Manifest)
+ decision.Release.ManifestSignature =
+ refreshedLocated(t, fixture.root, decision.Release.ManifestSignature)
+ decision.Release.FinalTranscript =
+ refreshedLocated(t, fixture.root, decision.Release.FinalTranscript)
+ decision.Release.Artifacts = locatedTree(t, fixture.root, "release")
+ decision.Release.ReleaseID = ""
+ decision.Release, err = NewSignedReleaseEvidence(decision.Release)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for gateIndex := range decision.Gates {
+ for evidenceIndex, evidence := range decision.Gates[gateIndex].Evidence {
+ switch evidence {
+ case oldOperational.Record:
+ decision.Gates[gateIndex].Evidence[evidenceIndex] =
+ decision.OperationalEvidence.Record
+ case oldOperational.Signature:
+ decision.Gates[gateIndex].Evidence[evidenceIndex] =
+ decision.OperationalEvidence.Signature
+ case oldManifest:
+ decision.Gates[gateIndex].Evidence[evidenceIndex] =
+ decision.Release.Manifest
+ }
+ }
+ }
+ decision.DecisionID = ""
+ decision, err = NewProductionDecision(decision)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decisionBytes, err := MarshalCanonical(decision)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signatures := signDecisionFixture(t, fixture.definition, decisionBytes)
+ if _, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: decisionBytes,
+ SignatureBytes: signatures,
+ EvidenceRoot: fixture.root,
+ }); err == nil || !strings.Contains(err.Error(), "outbound") {
+ t.Fatalf("re-signed semantic custody violation error = %v", err)
+ }
+}
+
+func TestProductionDecisionCanonicalParserRejectsUnknownAndTrailingFields(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionNOGO)
+ unknown := append([]byte(nil), fixture.record[:len(fixture.record)-1]...)
+ unknown = append(unknown, []byte(`,"unknown":true}`)...)
+ var decision ProductionDecision
+ if err := UnmarshalCanonical(unknown, &decision); err == nil {
+ t.Fatal("unknown production-decision field accepted")
+ }
+ if err := UnmarshalCanonical(append(append([]byte(nil), fixture.record...), '\n'), &decision); err == nil {
+ t.Fatal("trailing production-decision bytes accepted")
+ }
+
+ var signature ProductionDecisionSignature
+ if err := UnmarshalCanonical(fixture.signatures[0], &signature); err != nil {
+ t.Fatal(err)
+ }
+ trailing := append(append([]byte(nil), fixture.signatures[0]...), '\n')
+ if err := UnmarshalCanonical(trailing, &signature); err == nil {
+ t.Fatal("trailing decision-signature bytes accepted")
+ }
+}
+
+func TestProductionDecisionNOGOMayBeSignedByOneAuthorizedRole(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionNOGO)
+ verified, err := VerifyProductionDecision(VerifyProductionDecisionOptions{
+ Definition: fixture.definition,
+ DecisionBytes: fixture.record,
+ SignatureBytes: fixture.signatures[:1],
+ EvidenceRoot: fixture.root,
+ })
+ if err != nil {
+ t.Fatalf("verify signed NO-GO: %v", err)
+ }
+ if verified.Decision.Decision != DecisionNOGO || len(verified.VerifiedSigners) != 1 {
+ t.Fatalf("verified NO-GO = %#v", verified)
+ }
+}
+
+func newProductionDecisionFixture(t *testing.T, outcome ProductionDecisionOutcome) productionDecisionFixture {
+ t.Helper()
+ operationalFixture := newOperationalBundleFixture(t)
+ root := t.TempDir()
+ copyRegularTree(t, operationalFixture.root, filepath.Join(root, "release"))
+ definition := operationalFixture.definition
+ definitionBytes, err := MarshalCanonical(definition)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ candidate := adversarialCandidate(t, definition)
+ candidate.Schema = ""
+ candidate.CandidateID = ""
+ candidate.Definition = ArtifactRef{Name: "release/ceremony.json", Digest: NewDigest(definitionBytes)}
+ candidate.Phase1 = operationalPhaseSummary(
+ t,
+ operationalFixture.root,
+ operationalFixture.bundle.Phase1,
+ candidate.Phase1,
+ )
+ candidate.Phase2 = operationalPhaseSummary(
+ t,
+ operationalFixture.root,
+ operationalFixture.bundle.Phase2,
+ candidate.Phase2,
+ )
+ candidate, err = NewCandidateMetadata(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ candidateBytes, candidateSignatureBytes, err := SignRecord(
+ candidate,
+ definition.Coordinator.KeyID,
+ adversarialPrivateKey(0x01),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ candidateLocated := writeLocated(t, root, "release/candidate.json", candidateBytes)
+ candidateSignatureLocated := writeLocated(t, root, "release/candidate.sig.json", candidateSignatureBytes)
+ for name, content := range map[string]string{
+ candidate.ConstraintSystem.Name: "r1cs",
+ candidate.ProvingKey.Name: "proving key",
+ candidate.VerifyingKey.Name: "verifying key",
+ candidate.CardanoVerifyingKey.Name: "cardano vk",
+ candidate.CardanoVKHex.Name: "cardano vk hex",
+ candidate.CardanoVKFormat.Name: "cardano vk format",
+ candidate.VerificationReport.Name: "verification report",
+ candidate.PublicEvidence.Name: "public finalization evidence",
+ candidate.Phase2SealRecord.Name: "phase2 seal record",
+ Phase2SealSignatureFile: "phase2 seal signature",
+ } {
+ writeLocated(t, root, "release/"+name, []byte(content))
+ }
+
+ auditEvidence := make([]ProductionAuditEvidence, 2)
+ auditRefs := make([]ArtifactRef, 2)
+ replayRoot, err := replayRootSHA256(candidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for index, auditor := range definition.Auditors[:2] {
+ record, err := NewAuditRecord(AuditRecord{
+ CeremonyID: definition.CeremonyID,
+ AuditorID: auditor.ID,
+ AuditorKeyID: auditor.KeyID,
+ Definition: candidate.Definition,
+ Phase1Chain: candidate.Phase1.Chain,
+ Phase2Chain: candidate.Phase2.Chain,
+ Phase1SealID: candidate.Phase1.SealID,
+ Phase2SealID: candidate.Phase2.SealID,
+ ReplayRootSHA256: replayRoot,
+ Outputs: candidateAuditOutputs(candidate, ArtifactRef{
+ Name: CandidateMetadataFile, Digest: candidateLocated.Artifact.Digest,
+ }),
+ Passed: true,
+ Findings: []string{},
+ AuditedAt: "2026-07-23T14:0" + string(rune('1'+index)) + ":00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ recordBytes, signatureBytes, err := SignRecord(
+ record,
+ auditor.KeyID,
+ adversarialPrivateKey(byte(0x03+index)),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ audit := SignedLocatedArtifact{
+ Record: writeLocated(
+ t,
+ root,
+ "release/audits/"+fmt.Sprintf("%04d.json", index+1),
+ recordBytes,
+ ),
+ Signature: writeLocated(
+ t,
+ root,
+ "release/audits/"+fmt.Sprintf("%04d.sig", index+1),
+ signatureBytes,
+ ),
+ }
+ auditEvidence[index] = ProductionAuditEvidence{
+ AuditorID: auditor.ID, AuditorKeyID: auditor.KeyID, Audit: audit,
+ }
+ auditRefs[index] = ArtifactRef{
+ Name: strings.TrimPrefix(audit.Record.Artifact.Name, "release/"),
+ Digest: audit.Record.Artifact.Digest,
+ }
+ }
+
+ operational := SignedLocatedArtifact{
+ Record: writeLocated(
+ t,
+ root,
+ "release/"+OperationalEvidenceBundleFile,
+ operationalFixture.bundleBytes,
+ ),
+ Signature: writeLocated(
+ t,
+ root,
+ "release/"+OperationalEvidenceSignatureFile,
+ operationalFixture.signatureBytes,
+ ),
+ }
+
+ transcript, err := NewFinalTranscript(FinalTranscript{
+ CeremonyID: definition.CeremonyID,
+ Definition: candidate.Definition,
+ Circuit: definition.Circuit,
+ Phase1: candidate.Phase1,
+ Phase2: candidate.Phase2,
+ Audits: auditRefs,
+ OperationalEvidence: SignedArtifactRefs{
+ Record: ArtifactRef{
+ Name: strings.TrimPrefix(operational.Record.Artifact.Name, "release/"),
+ Digest: operational.Record.Artifact.Digest,
+ },
+ Signature: ArtifactRef{
+ Name: strings.TrimPrefix(operational.Signature.Artifact.Name, "release/"),
+ Digest: operational.Signature.Artifact.Digest,
+ },
+ },
+ ProvingKey: candidate.ProvingKey,
+ VerifyingKey: candidate.VerifyingKey,
+ CardanoVerifyingKey: candidate.CardanoVerifyingKey,
+ FinalizedAt: "2026-07-23T15:00:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ transcriptBytes, err := MarshalCanonical(transcript)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transcriptLocated := writeLocated(t, root, "release/"+FinalTranscriptFile, transcriptBytes)
+
+ manifest := artifact.KeyManifest{
+ Schema: artifact.ManifestSchema,
+ KeyVersion: definition.Circuit.KeyVersion,
+ CircuitID: definition.Circuit.CircuitID,
+ Curve: definition.Circuit.Curve,
+ Backend: definition.Circuit.Backend,
+ VKHash: candidate.VerifyingKey.Digest.Blake2b256,
+ ProvingKeySHA256: candidate.ProvingKey.Digest.SHA256,
+ ProvingKeyBlake2b256: candidate.ProvingKey.Digest.Blake2b256,
+ ProvingKeySize: candidate.ProvingKey.Digest.Size,
+ VerifyingKeySHA256: candidate.VerifyingKey.Digest.SHA256,
+ VerifyingKeySize: candidate.VerifyingKey.Digest.Size,
+ ConstraintSystemHash: candidate.ConstraintSystem.Digest.Blake2b256,
+ CircuitSourceCommit: definition.Software.SourceCommit,
+ ProofToolVersion: definition.Software.ProofToolVersion,
+ GnarkVersion: definition.Software.GnarkVersion,
+ SetupTranscriptHash: NewDigest(transcriptBytes).Blake2b256,
+ PublishedAt: "2026-07-23T15:00:00Z",
+ SignatureKeyID: definition.ReleaseSigner.KeyID,
+ }
+ manifestBytes, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifestBytes = append(manifestBytes, '\n')
+ releasePrivateKey := adversarialPrivateKey(0x02)
+ manifestSignature := hex.EncodeToString(ed25519.Sign(releasePrivateKey, manifestBytes)) + "\n"
+ manifestLocated := writeLocated(t, root, "release/manifest.json", manifestBytes)
+ manifestSignatureLocated := writeLocated(t, root, "release/manifest.sig", []byte(manifestSignature))
+ manifestPublicKeyLocated := writeLocated(
+ t,
+ root,
+ "release/manifest-public-key.hex",
+ []byte(definition.ReleaseSigner.Ed25519PublicKeyHex+"\n"),
+ )
+ releaseRoot := filepath.Join(root, "release")
+ transcriptTime, err := time.Parse(time.RFC3339Nano, transcript.FinalizedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ verifiedOperational, err := verifyReleaseOperationalEvidence(
+ definition,
+ operationalFixture.coordinatorKey.Public().(ed25519.PublicKey),
+ candidate,
+ releaseRoot,
+ filepath.Join(releaseRoot, filepath.FromSlash(OperationalEvidenceBundleFile)),
+ filepath.Join(releaseRoot, filepath.FromSlash(OperationalEvidenceSignatureFile)),
+ transcriptTime,
+ )
+ if err != nil {
+ t.Fatalf("verify decision fixture operational evidence: %v", err)
+ }
+ if err := writeChecksumsNoReplace(
+ releaseRoot,
+ filepath.Join(releaseRoot, CandidateChecksumsFile),
+ candidateChecksumNames(),
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeChecksumsNoReplace(
+ releaseRoot,
+ filepath.Join(releaseRoot, ReleaseChecksumsFile),
+ releaseChecksumNames(2, verifiedOperational.Names),
+ ); err != nil {
+ t.Fatal(err)
+ }
+ release, err := NewSignedReleaseEvidence(SignedReleaseEvidence{
+ CandidateID: candidate.CandidateID,
+ Manifest: manifestLocated,
+ ManifestSignature: manifestSignatureLocated,
+ ManifestPublicKey: manifestPublicKeyLocated,
+ Candidate: SignedLocatedArtifact{
+ Record: candidateLocated, Signature: candidateSignatureLocated,
+ },
+ FinalTranscript: transcriptLocated,
+ Artifacts: locatedTree(t, root, "release"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ external := adversarialIdentity(t, "external-auditor", 0x05)
+ externalReportBytes := []byte("independent security audit report\n")
+ externalReport := writeLocated(t, root, "external/security-audit.pdf", externalReportBytes)
+ externalSignoff, err := SignExact(externalReportBytes, external.KeyID, adversarialPrivateKey(0x05))
+ if err != nil {
+ t.Fatal(err)
+ }
+ externalSignoffBytes, err := MarshalCanonical(externalSignoff)
+ if err != nil {
+ t.Fatal(err)
+ }
+ externalSignoffLocated := writeLocated(t, root, "external/security-audit.sig.json", externalSignoffBytes)
+ rehearsal := writeLocated(t, root, "rehearsal/k21-evidence.tar.zst", []byte("exact K21 rehearsal evidence"))
+ tagObject := writeLocated(t, root, "source/v1.0.0-mainnet.tag", []byte("signed annotated tag object"))
+ deployment := writeLocated(t, root, "governance/mainnet-deployment-plan.md", []byte("# Mainnet deployment plan\n"))
+ checklist := writeLocated(t, root, "governance/go-no-go-checklist.md", []byte("# Formal GO/NO-GO checklist\n"))
+
+ gateEvidence := map[ProductionGate]LocatedArtifactRef{
+ GateSignedRelease: manifestLocated,
+ GateOperationalEvidence: operational.Record,
+ GateIndependentAudits: auditEvidence[0].Audit.Record,
+ GateExternalAudit: externalReport,
+ GateK21Rehearsal: rehearsal,
+ GateMainnetDeploymentPlan: deployment,
+ GateFormalChecklist: checklist,
+ GateParticipantIndependent: checklist,
+ GateParticipantHost: checklist,
+ GateParticipantEntropy: checklist,
+ GateParticipantErasure: checklist,
+ GatePublicWitnessing: operational.Record,
+ GateImmutableMirrors: operational.Record,
+ GateLiveTwentyParty: checklist,
+ }
+ gates := make([]ProductionGateResult, len(requiredProductionGates))
+ for index, gate := range requiredProductionGates {
+ gates[index] = ProductionGateResult{
+ Gate: gate, Status: GatePASS, Evidence: []LocatedArtifactRef{gateEvidence[gate]},
+ }
+ }
+ if outcome == DecisionNOGO {
+ gates[len(gates)-1].Status = GatePENDING
+ gates[len(gates)-1].Rationale = "The live twenty-party production ceremony has not occurred."
+ }
+ decision, err := NewProductionDecision(ProductionDecision{
+ CeremonyID: definition.CeremonyID,
+ Release: release,
+ SourceRelease: SourceReleaseEvidence{
+ SourceCommit: definition.Software.SourceCommit,
+ SignedTag: "v1.0.0-mainnet",
+ SignatureFormat: "openpgp-primary-key-v4",
+ SignerFingerprintHex: strings.Repeat("ab", 20),
+ SignedTagObject: tagObject,
+ },
+ OperationalEvidence: operational,
+ Audits: auditEvidence,
+ ExternalAudits: []ExternalAuditEvidence{
+ {Auditor: external, Report: externalReport, Signoff: externalSignoffLocated},
+ {
+ Auditor: adversarialIdentity(t, "external-auditor-b", 0x06),
+ Report: writeLocated(t, root, "external/security-audit-b.pdf", []byte("independent security audit report b\n")),
+ Signoff: signedExternalAuditFixture(
+ t, root, "external/security-audit-b.sig.json",
+ []byte("independent security audit report b\n"),
+ adversarialIdentity(t, "external-auditor-b", 0x06),
+ adversarialPrivateKey(0x06),
+ ),
+ },
+ },
+ K21Rehearsal: K21RehearsalEvidence{
+ KeyVersion: definition.Circuit.KeyVersion,
+ CircuitID: definition.Circuit.CircuitID,
+ Curve: definition.Circuit.Curve,
+ Backend: definition.Circuit.Backend,
+ Constraints: definition.Circuit.Constraints,
+ DomainSize: definition.Circuit.DomainSize,
+ Evidence: rehearsal,
+ },
+ MainnetDeploymentPlan: deployment,
+ FormalChecklist: checklist,
+ Gates: gates,
+ Decision: outcome,
+ DecidedAt: "2026-07-23T16:00:00Z",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ recordBytes, err := MarshalCanonical(decision)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signers := []struct {
+ role DecisionSignerRole
+ id string
+ key byte
+ }{
+ {DecisionSignerCoordinator, definition.Coordinator.ID, 0x01},
+ {DecisionSignerAuditor, definition.Auditors[0].ID, 0x03},
+ {DecisionSignerAuditor, definition.Auditors[1].ID, 0x04},
+ {DecisionSignerRelease, definition.ReleaseSigner.ID, 0x02},
+ }
+ signatures := make([][]byte, len(signers))
+ for index, signer := range signers {
+ signatures[index], err = SignProductionDecision(
+ definition,
+ recordBytes,
+ signer.role,
+ signer.id,
+ adversarialPrivateKey(signer.key),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return productionDecisionFixture{
+ root: root, definition: definition, decision: decision, record: recordBytes, signatures: signatures,
+ }
+}
+
+func operationalPhaseSummary(
+ t *testing.T,
+ root string,
+ evidence PhaseOperationalEvidence,
+ summary PhaseSummary,
+) PhaseSummary {
+ t.Helper()
+ chainBytes, err := verifyArtifactBytes(root, evidence.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(chainBytes, &chain); err != nil {
+ t.Fatal(err)
+ }
+ closeBytes, err := verifyArtifactBytes(root, evidence.Close.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var closeRecord CloseRecord
+ if err := UnmarshalCanonical(closeBytes, &closeRecord); err != nil {
+ t.Fatal(err)
+ }
+ head, err := chain.HeadRecordID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ participants, err := chain.ParticipantIDs()
+ if err != nil {
+ t.Fatal(err)
+ }
+ summary.Phase = chain.Phase
+ summary.PhaseID = chain.PhaseID
+ summary.Genesis = chain.Genesis
+ summary.Chain = evidence.AcceptedChain.Record
+ summary.ChainHeadID = head
+ summary.ContributionCount = uint8(len(chain.Records))
+ summary.Participants = participants
+ summary.CloseID = closeRecord.CloseID
+ return summary
+}
+
+func mustArtifactRef(t *testing.T, root, name string) ArtifactRef {
+ t.Helper()
+ ref, err := artifactRefForFile(name, filepath.Join(root, filepath.FromSlash(name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return ref
+}
+
+func decisionScaleArtifact(name, content string) LocatedArtifactRef {
+ return LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: ArtifactRef{
+ Name: name,
+ Digest: NewDigest([]byte(content)),
+ },
+ }
+}
+
+func refreshedLocated(
+ t *testing.T,
+ root string,
+ located LocatedArtifactRef,
+) LocatedArtifactRef {
+ t.Helper()
+ path, err := resolveArtifactPath(root, located.Artifact.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref, err := artifactRefForFile(located.Artifact.Name, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return LocatedArtifactRef{URI: located.URI, Artifact: ref}
+}
+
+func rewriteChecksumFileFromExistingNames(t *testing.T, root, checksumPath string) {
+ t.Helper()
+ raw, err := os.ReadFile(checksumPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
+ names := make([]string, len(lines))
+ for index, line := range lines {
+ if len(line) < 67 || line[64:66] != " " {
+ t.Fatalf("invalid fixture checksum line %q", line)
+ }
+ names[index] = line[66:]
+ }
+ if err := os.Remove(checksumPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeChecksumsNoReplace(root, checksumPath, names); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func signDecisionFixture(
+ t *testing.T,
+ definition CeremonyDefinition,
+ record []byte,
+) [][]byte {
+ t.Helper()
+ signers := []struct {
+ role DecisionSignerRole
+ id string
+ key byte
+ }{
+ {DecisionSignerCoordinator, definition.Coordinator.ID, 0x01},
+ {DecisionSignerAuditor, definition.Auditors[0].ID, 0x03},
+ {DecisionSignerAuditor, definition.Auditors[1].ID, 0x04},
+ {DecisionSignerRelease, definition.ReleaseSigner.ID, 0x02},
+ }
+ result := make([][]byte, len(signers))
+ for index, signer := range signers {
+ var err error
+ result[index], err = SignProductionDecision(
+ definition,
+ record,
+ signer.role,
+ signer.id,
+ adversarialPrivateKey(signer.key),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return result
+}
+
+func writeLocated(t *testing.T, root, name string, data []byte) LocatedArtifactRef {
+ t.Helper()
+ target := filepath.Join(root, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(target, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: ArtifactRef{Name: name, Digest: NewDigest(data)},
+ }
+}
+
+func locatedTree(t *testing.T, root, directory string) []LocatedArtifactRef {
+ t.Helper()
+ var result []LocatedArtifactRef
+ err := filepath.WalkDir(filepath.Join(root, directory), func(file string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ data, err := os.ReadFile(file)
+ if err != nil {
+ return err
+ }
+ name, err := filepath.Rel(root, file)
+ if err != nil {
+ return err
+ }
+ name = filepath.ToSlash(name)
+ result = append(result, LocatedArtifactRef{
+ URI: "https://evidence.example/" + name,
+ Artifact: ArtifactRef{Name: name, Digest: NewDigest(data)},
+ })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ slices.SortFunc(result, func(a, b LocatedArtifactRef) int {
+ return strings.Compare(a.Artifact.Name, b.Artifact.Name)
+ })
+ return result
+}
+
+func signedExternalAuditFixture(
+ t *testing.T,
+ root, name string,
+ report []byte,
+ auditor Identity,
+ privateKey ed25519.PrivateKey,
+) LocatedArtifactRef {
+ t.Helper()
+ signature, err := SignExact(report, auditor.KeyID, privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := MarshalCanonical(signature)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return writeLocated(t, root, name, data)
+}
+
+func productionDecisionDraft(decision ProductionDecision) ProductionDecisionDraft {
+ return ProductionDecisionDraft{
+ Schema: ProductionDecisionDraftSchema,
+ CeremonyID: decision.CeremonyID,
+ Release: SignedReleaseEvidenceDraft{
+ CandidateID: decision.Release.CandidateID,
+ Manifest: decision.Release.Manifest,
+ ManifestSignature: decision.Release.ManifestSignature,
+ ManifestPublicKey: decision.Release.ManifestPublicKey,
+ Candidate: decision.Release.Candidate,
+ FinalTranscript: decision.Release.FinalTranscript,
+ Artifacts: decision.Release.Artifacts,
+ },
+ SourceRelease: decision.SourceRelease,
+ OperationalEvidence: decision.OperationalEvidence,
+ Audits: decision.Audits,
+ ExternalAudits: decision.ExternalAudits,
+ K21Rehearsal: decision.K21Rehearsal,
+ MainnetDeploymentPlan: decision.MainnetDeploymentPlan,
+ FormalChecklist: decision.FormalChecklist,
+ Gates: decision.Gates,
+ Decision: decision.Decision,
+ DecidedAt: decision.DecidedAt,
+ }
+}
+
+func TestProductionDecisionGateOrderIsFixedAndGOFailCloses(t *testing.T) {
+ fixture := newProductionDecisionFixture(t, DecisionGO)
+ changed := fixture.decision
+ changed.DecisionID = ""
+ changed.Gates = slices.Clone(changed.Gates)
+ changed.Gates[0], changed.Gates[1] = changed.Gates[1], changed.Gates[0]
+ if _, err := NewProductionDecision(changed); err == nil || !strings.Contains(err.Error(), "gate 0") {
+ t.Fatalf("reordered gate error = %v", err)
+ }
+
+ changed = fixture.decision
+ changed.DecisionID = ""
+ changed.Gates = slices.Clone(changed.Gates)
+ changed.Gates[len(changed.Gates)-1].Status = GatePENDING
+ changed.Gates[len(changed.Gates)-1].Rationale = "Live ceremony is pending."
+ if _, err := NewProductionDecision(changed); err == nil || !strings.Contains(err.Error(), "every production gate") {
+ t.Fatalf("GO with pending gate error = %v", err)
+ }
+}
diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go
new file mode 100644
index 00000000..cd39d223
--- /dev/null
+++ b/internal/mpcceremony/definition.go
@@ -0,0 +1,317 @@
+package mpcceremony
+
+import (
+ "errors"
+ "fmt"
+)
+
+const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60
+
+type CeremonyDefinition struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Mode string `json:"mode"`
+ CreatedAt string `json:"created_at"`
+ SessionNonceHex string `json:"session_nonce_hex"`
+ Circuit CircuitBinding `json:"circuit"`
+ Software SoftwareBinding `json:"software"`
+ Coordinator Identity `json:"coordinator"`
+ ReleaseSigner Identity `json:"release_signer"`
+ Auditors []Identity `json:"auditors"`
+ Roster []Participant `json:"roster"`
+ Phase1Policy PhasePolicy `json:"phase1_policy"`
+ Phase2Policy PhasePolicy `json:"phase2_policy"`
+ BeaconPolicy BeaconPolicy `json:"beacon_policy"`
+ Phase1Genesis ArtifactRef `json:"phase1_genesis"`
+}
+
+type DefinitionOptions struct {
+ Mode string
+ CreatedAt string
+ SessionNonceHex string
+ Circuit CircuitBinding
+ Software SoftwareBinding
+ Coordinator Identity
+ ReleaseSigner Identity
+ Auditors []Identity
+ Roster []Participant
+ Phase1Policy PhasePolicy
+ Phase2Policy PhasePolicy
+ BeaconPolicy BeaconPolicy
+ Phase1Genesis ArtifactRef
+}
+
+func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error) {
+ definition := CeremonyDefinition{
+ Schema: DefinitionSchema,
+ Mode: options.Mode,
+ CreatedAt: options.CreatedAt,
+ SessionNonceHex: options.SessionNonceHex,
+ Circuit: options.Circuit,
+ Software: options.Software,
+ Coordinator: options.Coordinator,
+ ReleaseSigner: options.ReleaseSigner,
+ Auditors: append([]Identity(nil), options.Auditors...),
+ Roster: append([]Participant(nil), options.Roster...),
+ Phase1Policy: clonePhasePolicy(options.Phase1Policy),
+ Phase2Policy: clonePhasePolicy(options.Phase2Policy),
+ BeaconPolicy: options.BeaconPolicy,
+ Phase1Genesis: options.Phase1Genesis,
+ }
+ id, err := ComputeCeremonyID(definition)
+ if err != nil {
+ return CeremonyDefinition{}, err
+ }
+ definition.CeremonyID = id
+ if err := definition.Validate(); err != nil {
+ return CeremonyDefinition{}, err
+ }
+ return definition, nil
+}
+
+// FinalizeCeremonyDefinition validates an assembled definition and fills its
+// content-derived CeremonyID. It is useful to decouple expensive circuit
+// compilation from metadata construction.
+func FinalizeCeremonyDefinition(definition CeremonyDefinition) (CeremonyDefinition, error) {
+ definition.Schema = DefinitionSchema
+ definition.CeremonyID = ""
+ id, err := ComputeCeremonyID(definition)
+ if err != nil {
+ return CeremonyDefinition{}, err
+ }
+ definition.CeremonyID = id
+ if err := definition.Validate(); err != nil {
+ return CeremonyDefinition{}, err
+ }
+ return definition, nil
+}
+
+func ComputeCeremonyID(definition CeremonyDefinition) (string, error) {
+ definition.CeremonyID = ""
+ if err := definition.validate(false); err != nil {
+ return "", err
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/root/v1", definition)
+}
+
+func (d CeremonyDefinition) Validate() error {
+ if err := d.validate(true); err != nil {
+ return err
+ }
+ expected, err := ComputeCeremonyID(d)
+ if err != nil {
+ return err
+ }
+ if d.CeremonyID != expected {
+ return fmt.Errorf("ceremony_id %q, want %q", d.CeremonyID, expected)
+ }
+ return nil
+}
+
+func (d CeremonyDefinition) validate(requireID bool) error {
+ if d.Schema != DefinitionSchema {
+ return fmt.Errorf("definition schema %q, want %q", d.Schema, DefinitionSchema)
+ }
+ if requireID {
+ if err := validateTaggedHex(d.CeremonyID, "sha256:", 32); err != nil {
+ return fmt.Errorf("ceremony_id: %w", err)
+ }
+ } else if d.CeremonyID != "" {
+ return errors.New("ceremony_id must be empty while computing the definition identity")
+ }
+ switch d.Mode {
+ case ModeRehearsal:
+ case ModeProduction:
+ if d.Software.SourceDirty {
+ return errors.New("production ceremony requires a clean source tree")
+ }
+ if err := validateProductionBuildProfile(
+ d.Software.GoVersion,
+ d.Software.GoOS,
+ d.Software.GoArch,
+ d.Software.GoAMD64,
+ d.Software.Compiler,
+ d.Software.BuildMode,
+ d.Software.CGOEnabled,
+ d.Software.TrimPath,
+ ); err != nil {
+ return fmt.Errorf("production software profile: %w", err)
+ }
+ default:
+ return fmt.Errorf("mode %q, want %q or %q", d.Mode, ModeRehearsal, ModeProduction)
+ }
+ if err := validateTimestamp("created_at", d.CreatedAt); err != nil {
+ return err
+ }
+ if err := validateHex(d.SessionNonceHex, 32); err != nil {
+ return fmt.Errorf("session_nonce_hex: %w", err)
+ }
+ if err := d.Circuit.Validate(); err != nil {
+ return fmt.Errorf("circuit: %w", err)
+ }
+ if err := d.Software.Validate(); err != nil {
+ return fmt.Errorf("software: %w", err)
+ }
+ if err := d.Coordinator.Validate(); err != nil {
+ return fmt.Errorf("coordinator: %w", err)
+ }
+ if err := d.ReleaseSigner.Validate(); err != nil {
+ return fmt.Errorf("release_signer: %w", err)
+ }
+ if d.ReleaseSigner.ID == d.Coordinator.ID || d.ReleaseSigner.KeyID == d.Coordinator.KeyID {
+ return errors.New("release signer must be distinct from coordinator")
+ }
+ if len(d.Auditors) < 2 {
+ return errors.New("at least two independent auditors are required")
+ }
+ identityIDs := map[string]string{
+ d.Coordinator.ID: "coordinator",
+ d.ReleaseSigner.ID: "release signer",
+ }
+ keyIDs := map[string]string{
+ d.Coordinator.KeyID: "coordinator",
+ d.ReleaseSigner.KeyID: "release signer",
+ }
+ publicKeyFingerprints := map[string]string{
+ d.Coordinator.PublicKeyFingerprint: "coordinator",
+ }
+ if previous, exists := publicKeyFingerprints[d.ReleaseSigner.PublicKeyFingerprint]; exists {
+ return fmt.Errorf("release signer public key duplicates %s", previous)
+ }
+ publicKeyFingerprints[d.ReleaseSigner.PublicKeyFingerprint] = "release signer"
+ for index, auditor := range d.Auditors {
+ if err := auditor.Validate(); err != nil {
+ return fmt.Errorf("auditor %d: %w", index, err)
+ }
+ if previous, exists := identityIDs[auditor.ID]; exists {
+ return fmt.Errorf("auditor identity %q duplicates %s", auditor.ID, previous)
+ }
+ if previous, exists := keyIDs[auditor.KeyID]; exists {
+ return fmt.Errorf("auditor key %q duplicates %s", auditor.KeyID, previous)
+ }
+ if previous, exists := publicKeyFingerprints[auditor.PublicKeyFingerprint]; exists {
+ return fmt.Errorf("auditor public key duplicates %s", previous)
+ }
+ identityIDs[auditor.ID] = "auditor"
+ keyIDs[auditor.KeyID] = "auditor"
+ publicKeyFingerprints[auditor.PublicKeyFingerprint] = "auditor"
+ }
+ if len(d.Roster) == 0 || len(d.Roster) > MaxParticipants {
+ return fmt.Errorf("roster must contain between 1 and %d participants", MaxParticipants)
+ }
+ roster := make(map[string]Participant, len(d.Roster))
+ for index, participant := range d.Roster {
+ if err := participant.Validate(); err != nil {
+ return fmt.Errorf("roster participant %d: %w", index, err)
+ }
+ id := participant.Identity.ID
+ keyID := participant.Identity.KeyID
+ if _, duplicate := roster[id]; duplicate {
+ return fmt.Errorf("roster participant %q is duplicated", id)
+ }
+ if previous, exists := identityIDs[id]; exists {
+ return fmt.Errorf("participant identity %q duplicates %s", id, previous)
+ }
+ if previous, exists := keyIDs[keyID]; exists {
+ return fmt.Errorf("participant key %q duplicates %s", keyID, previous)
+ }
+ if previous, exists := publicKeyFingerprints[participant.Identity.PublicKeyFingerprint]; exists {
+ return fmt.Errorf("participant public key duplicates %s", previous)
+ }
+ roster[id] = participant
+ identityIDs[id] = "participant"
+ keyIDs[keyID] = "participant"
+ publicKeyFingerprints[participant.Identity.PublicKeyFingerprint] = "participant"
+ }
+ if err := d.Phase1Policy.Validate(roster); err != nil {
+ return fmt.Errorf("phase1_policy: %w", err)
+ }
+ if err := d.Phase2Policy.Validate(roster); err != nil {
+ return fmt.Errorf("phase2_policy: %w", err)
+ }
+ if d.Mode == ModeProduction {
+ if d.BeaconPolicy.MinimumWitnessLeadSeconds < ProductionMinimumWitnessLeadSeconds {
+ return fmt.Errorf(
+ "production beacon minimum_witness_lead_seconds %d is below required %d",
+ d.BeaconPolicy.MinimumWitnessLeadSeconds,
+ ProductionMinimumWitnessLeadSeconds,
+ )
+ }
+ if len(d.Roster) < 2 {
+ return errors.New("production ceremony requires at least two distinct roster participants")
+ }
+ if len(d.Phase1Policy.Participants) < 2 {
+ return errors.New("production phase1 policy requires at least two scheduled participants")
+ }
+ if int(d.Phase1Policy.Minimum) != len(d.Phase1Policy.Participants) {
+ return errors.New("production phase1 minimum must equal the complete scheduled participant count")
+ }
+ if len(d.Phase2Policy.Participants) < 2 {
+ return errors.New("production phase2 policy requires at least two scheduled participants")
+ }
+ if int(d.Phase2Policy.Minimum) != len(d.Phase2Policy.Participants) {
+ return errors.New("production phase2 minimum must equal the complete scheduled participant count")
+ }
+ }
+ if err := d.BeaconPolicy.Validate(); err != nil {
+ return fmt.Errorf("beacon_policy: %w", err)
+ }
+ if err := d.Phase1Genesis.Validate(); err != nil {
+ return fmt.Errorf("phase1_genesis: %w", err)
+ }
+ return nil
+}
+
+func (d CeremonyDefinition) PolicyForPhase(phase Phase) (PhasePolicy, error) {
+ switch phase {
+ case Phase1:
+ return clonePhasePolicy(d.Phase1Policy), nil
+ case Phase2:
+ return clonePhasePolicy(d.Phase2Policy), nil
+ default:
+ return PhasePolicy{}, fmt.Errorf("unsupported phase %q", phase)
+ }
+}
+
+func (d CeremonyDefinition) ParticipantByID(id string) (Participant, bool) {
+ for _, participant := range d.Roster {
+ if participant.Identity.ID == id {
+ return participant, true
+ }
+ }
+ return Participant{}, false
+}
+
+func ComputePhaseID(ceremonyID string, phase Phase, genesis ArtifactRef, parentSealID string) (string, error) {
+ if err := validateTaggedHex(ceremonyID, "sha256:", 32); err != nil {
+ return "", fmt.Errorf("ceremony_id: %w", err)
+ }
+ if err := phase.Validate(); err != nil {
+ return "", err
+ }
+ if err := genesis.Validate(); err != nil {
+ return "", fmt.Errorf("genesis: %w", err)
+ }
+ if phase == Phase1 && parentSealID != "" {
+ return "", errors.New("phase1 must not have a parent seal")
+ }
+ if phase == Phase2 {
+ if err := validateTaggedHex(parentSealID, "sha256:", 32); err != nil {
+ return "", fmt.Errorf("phase2 parent seal: %w", err)
+ }
+ }
+ value := struct {
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ Genesis ArtifactRef `json:"genesis"`
+ ParentSealID string `json:"parent_seal_id"`
+ }{ceremonyID, phase, genesis, parentSealID}
+ return canonicalHash("proof-tool/mpc-ceremony/phase/v1", value)
+}
+
+func clonePhasePolicy(policy PhasePolicy) PhasePolicy {
+ return PhasePolicy{
+ Participants: append([]string(nil), policy.Participants...),
+ Minimum: policy.Minimum,
+ }
+}
diff --git a/internal/mpcceremony/definition_test.go b/internal/mpcceremony/definition_test.go
new file mode 100644
index 00000000..91618ad3
--- /dev/null
+++ b/internal/mpcceremony/definition_test.go
@@ -0,0 +1,124 @@
+package mpcceremony
+
+import "testing"
+
+func TestProductionDefinitionRequiresMultipleParticipantsInBothPhases(t *testing.T) {
+ valid := adversarialDefinition(t)
+
+ oneParticipant := valid
+ oneParticipant.CeremonyID = ""
+ oneParticipant.Roster = append([]Participant(nil), valid.Roster[:1]...)
+ oneParticipant.Phase1Policy = PhasePolicy{
+ Participants: []string{valid.Roster[0].Identity.ID},
+ Minimum: 1,
+ }
+ oneParticipant.Phase2Policy = oneParticipant.Phase1Policy
+ if _, err := FinalizeCeremonyDefinition(oneParticipant); err == nil {
+ t.Fatal("single-participant production ceremony unexpectedly accepted")
+ }
+
+ lowMinimum := valid
+ lowMinimum.CeremonyID = ""
+ lowMinimum.Phase1Policy = clonePhasePolicy(valid.Phase1Policy)
+ lowMinimum.Phase1Policy.Minimum = 1
+ if _, err := FinalizeCeremonyDefinition(lowMinimum); err == nil {
+ t.Fatal("production phase1 minimum 1 unexpectedly accepted")
+ }
+ lowMinimum = valid
+ lowMinimum.CeremonyID = ""
+ lowMinimum.Phase2Policy = clonePhasePolicy(valid.Phase2Policy)
+ lowMinimum.Phase2Policy.Minimum = 1
+ if _, err := FinalizeCeremonyDefinition(lowMinimum); err == nil {
+ t.Fatal("production phase2 minimum 1 unexpectedly accepted")
+ }
+
+ partialThreshold := valid
+ partialThreshold.CeremonyID = ""
+ partialThreshold.Phase1Policy = clonePhasePolicy(valid.Phase1Policy)
+ partialThreshold.Phase1Policy.Minimum = uint8(len(partialThreshold.Phase1Policy.Participants) - 1)
+ if _, err := FinalizeCeremonyDefinition(partialThreshold); err == nil {
+ t.Fatal("production minimum below the complete scheduled roster unexpectedly accepted")
+ }
+}
+
+func TestRehearsalDefinitionMayUseOneParticipant(t *testing.T) {
+ valid := adversarialDefinition(t)
+ rehearsal := valid
+ rehearsal.CeremonyID = ""
+ rehearsal.Mode = ModeRehearsal
+ rehearsal.Roster = append([]Participant(nil), valid.Roster[:1]...)
+ rehearsal.Phase1Policy = PhasePolicy{
+ Participants: []string{valid.Roster[0].Identity.ID},
+ Minimum: 1,
+ }
+ rehearsal.Phase2Policy = rehearsal.Phase1Policy
+ if _, err := FinalizeCeremonyDefinition(rehearsal); err != nil {
+ t.Fatalf("single-participant rehearsal rejected: %v", err)
+ }
+}
+
+func TestDefinitionRequiresUniquePublicKeysAcrossAllRoles(t *testing.T) {
+ reusePublicKey := func(destination *Identity, source Identity) {
+ destination.Ed25519PublicKeyHex = source.Ed25519PublicKeyHex
+ destination.PublicKeyFingerprint = source.PublicKeyFingerprint
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*CeremonyDefinition)
+ }{
+ {
+ name: "release signer and coordinator",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.ReleaseSigner, definition.Coordinator)
+ },
+ },
+ {
+ name: "auditor and coordinator",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Auditors[0], definition.Coordinator)
+ },
+ },
+ {
+ name: "two auditors",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Auditors[1], definition.Auditors[0])
+ },
+ },
+ {
+ name: "participant and coordinator",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Roster[0].Identity, definition.Coordinator)
+ },
+ },
+ {
+ name: "participant and release signer",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Roster[0].Identity, definition.ReleaseSigner)
+ },
+ },
+ {
+ name: "participant and auditor",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Roster[0].Identity, definition.Auditors[0])
+ },
+ },
+ {
+ name: "two participants",
+ mutate: func(definition *CeremonyDefinition) {
+ reusePublicKey(&definition.Roster[1].Identity, definition.Roster[0].Identity)
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ definition := adversarialDefinition(t)
+ definition.CeremonyID = ""
+ test.mutate(&definition)
+ if _, err := FinalizeCeremonyDefinition(definition); err == nil {
+ t.Fatal("definition with reused Ed25519 public key unexpectedly accepted")
+ }
+ })
+ }
+}
diff --git a/internal/mpcceremony/direct_acceptance_boundary_test.go b/internal/mpcceremony/direct_acceptance_boundary_test.go
new file mode 100644
index 00000000..f684a68d
--- /dev/null
+++ b/internal/mpcceremony/direct_acceptance_boundary_test.go
@@ -0,0 +1,342 @@
+package mpcceremony
+
+import (
+ "encoding/hex"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+type directAcceptanceFixture struct {
+ ceremonyRoot string
+ circuit *CompiledCircuit
+ trusted *TrustedCeremony
+ coordinatorKeyPath string
+ phase1SealPath string
+ phase1SealSignature string
+ phase1Chain1 PhaseTranscriptPaths
+ phase2Chain0 PhaseTranscriptPaths
+ phase2Chain1 PhaseTranscriptPaths
+}
+
+func TestCoordinatorDirectTransitionProtocolBoundaries(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping signed two-phase workflow boundary tests in the fast gate")
+ }
+ fixture := newDirectAcceptanceFixture(t)
+
+ t.Run("Phase1 rejects candidate derived from genesis instead of authenticated head", func(t *testing.T) {
+ chain, err := loadVerifiedPhase1Files(
+ fixture.trusted,
+ fixture.circuit,
+ fixture.phase1Chain1,
+ )
+ if err != nil {
+ t.Fatalf("load authenticated Phase 1 prefix: %v", err)
+ }
+ authenticatedHead, err := phase1FileLoader(
+ fixture.ceremonyRoot,
+ chain,
+ fixture.circuit.Binding.DomainSize,
+ )(0)
+ if err != nil {
+ t.Fatalf("load authenticated Phase 1 head: %v", err)
+ }
+ wrongPredecessorCandidate, err := ContributePhase1(
+ fixture.circuit.Binding.DomainSize,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("make fresh Phase 1 candidate from genesis: %v", err)
+ }
+ if err := verifyPhase1Transition(
+ fixture.circuit.Binding.DomainSize,
+ authenticatedHead,
+ wrongPredecessorCandidate,
+ ); err == nil {
+ t.Fatal("direct Phase 1 transition accepted a candidate derived from genesis instead of the authenticated head")
+ }
+ if err := ReplayPhase1(
+ fixture.circuit.Binding.DomainSize,
+ []*gnarkmpc.Phase1{authenticatedHead, wrongPredecessorCandidate},
+ ); err == nil {
+ t.Fatal("full Phase 1 replay accepted an invalid second edge")
+ }
+ })
+
+ t.Run("Phase2 rejects candidate derived from genesis instead of authenticated head", func(t *testing.T) {
+ commons, seal, _, err := loadAuthenticatedPhase1CommonsForCoordinator(
+ fixture.trusted,
+ fixture.circuit,
+ fixture.ceremonyRoot,
+ fixture.phase1SealPath,
+ fixture.phase1SealSignature,
+ )
+ if err != nil {
+ t.Fatalf("load authenticated Phase 1 commons: %v", err)
+ }
+ chain, err := loadVerifiedPhase2Files(
+ fixture.trusted,
+ fixture.circuit,
+ commons,
+ seal,
+ fixture.phase2Chain1,
+ )
+ if err != nil {
+ t.Fatalf("load authenticated Phase 2 prefix: %v", err)
+ }
+ authenticatedHead, err := phase2FileLoader(
+ fixture.ceremonyRoot,
+ chain,
+ contributionPhase2Shape(fixture.circuit.Binding.Phase2Shape),
+ )(0)
+ if err != nil {
+ t.Fatalf("load authenticated Phase 2 head: %v", err)
+ }
+ wrongPredecessorCandidate, err := ContributePhase2(
+ fixture.circuit,
+ commons,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("make fresh Phase 2 candidate from genesis: %v", err)
+ }
+ if err := verifyPhase2Transition(
+ authenticatedHead,
+ wrongPredecessorCandidate,
+ ); err == nil {
+ t.Fatal("direct Phase 2 transition accepted a candidate derived from genesis instead of the authenticated head")
+ }
+ if err := ReplayPhase2(
+ fixture.circuit,
+ commons,
+ []*gnarkmpc.Phase2{authenticatedHead, wrongPredecessorCandidate},
+ ); err == nil {
+ t.Fatal("full Phase 2 replay accepted an invalid second edge")
+ }
+ })
+
+ t.Run("Phase2 rejects signed forged commons against authentic genesis", func(t *testing.T) {
+ forgedSealPath, forgedSealSignaturePath, forgedChain := forgeCommonsAndRebindEmptyPhase2Chain(
+ t,
+ fixture,
+ )
+ commons, seal, _, err := loadAuthenticatedPhase1CommonsForCoordinator(
+ fixture.trusted,
+ fixture.circuit,
+ fixture.ceremonyRoot,
+ forgedSealPath,
+ forgedSealSignaturePath,
+ )
+ if err != nil {
+ t.Fatalf("forged coordinator-signed commons did not reach Phase 2 genesis binding: %v", err)
+ }
+ _, err = loadVerifiedPhase2Files(
+ fixture.trusted,
+ fixture.circuit,
+ commons,
+ seal,
+ forgedChain,
+ )
+ if err == nil || !strings.Contains(err.Error(), "genesis is not the deterministic circuit/commons initialization") {
+ t.Fatalf("forged-commons Phase 2 boundary error = %v, want authentic-genesis mismatch", err)
+ }
+ })
+}
+
+func newDirectAcceptanceFixture(t *testing.T) directAcceptanceFixture {
+ t.Helper()
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("resolve boundary test source path")
+ }
+ repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
+ root := t.TempDir()
+ helperPath := filepath.Join(root, "mpc-workflow-helper")
+ build := exec.Command(
+ "go",
+ "build",
+ "-o",
+ helperPath,
+ "./internal/mpcceremony/testdata/workflowhelper",
+ )
+ build.Dir = repoRoot
+ if output, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build ordinary workflow helper: %v\n%s", err, output)
+ }
+ workflowRoot := filepath.Join(root, "workflow")
+ run := exec.Command(helperPath, workflowRoot)
+ run.Dir = repoRoot
+ if output, err := run.CombinedOutput(); err != nil {
+ t.Fatalf("run signed workflow helper: %v\n%s", err, output)
+ }
+
+ circuit, err := BindDestinationV2R1CS(adversarialCompileCommitted(t))
+ if err != nil {
+ t.Fatalf("bind replay circuit: %v", err)
+ }
+ ceremonyRoot := filepath.Join(workflowRoot, "ceremony")
+ keyRoot := filepath.Join(workflowRoot, "identity-keys")
+ trust := TrustPaths{
+ DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"),
+ DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"),
+ CoordinatorPublicKeyPath: filepath.Join(keyRoot, "trusted-coordinator.ed25519.public.hex"),
+ }
+ trusted, err := LoadSignedDefinition(trust)
+ if err != nil {
+ t.Fatalf("load signed workflow fixture: %v", err)
+ }
+ phasePaths := func(phase Phase, index int) PhaseTranscriptPaths {
+ chainPath := filepath.Join(
+ ceremonyRoot,
+ string(phase),
+ fmt.Sprintf("chain-%04d.json", index),
+ )
+ return PhaseTranscriptPaths{
+ RootDir: ceremonyRoot,
+ ChainPath: chainPath,
+ ChainSignaturePath: DefaultSignaturePath(chainPath),
+ }
+ }
+ sealPath := filepath.Join(ceremonyRoot, "phase1", "sealed", "seal.json")
+ return directAcceptanceFixture{
+ ceremonyRoot: ceremonyRoot,
+ circuit: circuit,
+ trusted: trusted,
+ coordinatorKeyPath: filepath.Join(keyRoot, "coordinator.ed25519.private.hex"),
+ phase1SealPath: sealPath,
+ phase1SealSignature: DefaultSignaturePath(sealPath),
+ phase1Chain1: phasePaths(Phase1, 1),
+ phase2Chain0: phasePaths(Phase2, 0),
+ phase2Chain1: phasePaths(Phase2, 1),
+ }
+}
+
+func forgeCommonsAndRebindEmptyPhase2Chain(
+ t *testing.T,
+ fixture directAcceptanceFixture,
+) (string, string, PhaseTranscriptPaths) {
+ t.Helper()
+ var beacon BeaconRecord
+ if err := loadCoordinatorSignedRecord(
+ fixture.trusted,
+ filepath.Join(fixture.ceremonyRoot, "phase1", "beacon", "record.json"),
+ filepath.Join(fixture.ceremonyRoot, "phase1", "beacon", "record.sig"),
+ &beacon,
+ ); err != nil {
+ t.Fatalf("load authentic Phase 1 beacon: %v", err)
+ }
+ challenge, err := hex.DecodeString(beacon.ChallengeHex)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrongHead := gnarkmpc.NewPhase1(fixture.circuit.Binding.DomainSize)
+ wrongCommons := wrongHead.Seal(challenge)
+ forgedDir := filepath.Join(fixture.ceremonyRoot, "phase1", "coordinator-forged-sealed")
+ if err := os.Mkdir(forgedDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ commonsPath := filepath.Join(forgedDir, "commons.bin")
+ commonsDigest, err := WriteCommonsFileNoReplace(
+ commonsPath,
+ &wrongCommons,
+ CommonsShape{DomainN: fixture.circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ t.Fatalf("write forged commons: %v", err)
+ }
+
+ var seal SealRecord
+ if err := loadCoordinatorSignedRecord(
+ fixture.trusted,
+ fixture.phase1SealPath,
+ fixture.phase1SealSignature,
+ &seal,
+ ); err != nil {
+ t.Fatalf("load authentic Phase 1 seal: %v", err)
+ }
+ commonsName, err := logicalPathWithin(fixture.ceremonyRoot, commonsPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ commonsOutputFound := false
+ for index := range seal.Outputs {
+ if strings.HasSuffix(seal.Outputs[index].Name, "/commons.bin") {
+ seal.Outputs[index] = ArtifactRef{
+ Name: commonsName,
+ Digest: modelDigest(commonsDigest),
+ }
+ commonsOutputFound = true
+ }
+ }
+ if !commonsOutputFound {
+ t.Fatal("authentic Phase 1 seal has no commons output")
+ }
+ seal, err = NewSealRecord(seal)
+ if err != nil {
+ t.Fatalf("rebuild signed forged seal: %v", err)
+ }
+ coordinatorPrivate, _, err := loadMatchingPrivateKey(
+ fixture.coordinatorKeyPath,
+ fixture.trusted.Definition.Coordinator,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ sealPath := filepath.Join(forgedDir, "seal.json")
+ sealSignaturePath := filepath.Join(forgedDir, "seal.sig")
+ if err := writeSignedRecordNoReplace(
+ sealPath,
+ sealSignaturePath,
+ seal,
+ fixture.trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ ); err != nil {
+ t.Fatalf("write signed forged seal: %v", err)
+ }
+
+ authenticChain, err := LoadSignedChain(fixture.trusted, fixture.phase2Chain0)
+ if err != nil {
+ t.Fatalf("load authentic empty Phase 2 chain: %v", err)
+ }
+ forgedPhaseID, err := ComputePhaseID(
+ fixture.trusted.Definition.CeremonyID,
+ Phase2,
+ authenticChain.Genesis,
+ seal.SealID,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ forgedChain, err := NewChain(
+ fixture.trusted.Definition.CeremonyID,
+ Phase2,
+ forgedPhaseID,
+ authenticChain.Genesis,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ chainPath := filepath.Join(fixture.ceremonyRoot, "phase2", "forged-chain-0000.json")
+ chainSignaturePath := DefaultSignaturePath(chainPath)
+ if err := writeSignedRecordNoReplace(
+ chainPath,
+ chainSignaturePath,
+ forgedChain,
+ fixture.trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ ); err != nil {
+ t.Fatalf("write signed forged Phase 2 chain: %v", err)
+ }
+ return sealPath, sealSignaturePath, PhaseTranscriptPaths{
+ RootDir: fixture.ceremonyRoot,
+ ChainPath: chainPath,
+ ChainSignaturePath: chainSignaturePath,
+ }
+}
diff --git a/internal/mpcceremony/engine_test.go b/internal/mpcceremony/engine_test.go
new file mode 100644
index 00000000..ca362123
--- /dev/null
+++ b/internal/mpcceremony/engine_test.go
@@ -0,0 +1,353 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ "github.com/consensys/gnark/backend/groth16"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ "github.com/consensys/gnark/frontend"
+ "github.com/consensys/gnark/frontend/cs/r1cs"
+
+ "proof-tool/internal/prover"
+)
+
+type engineCommittedCircuit struct {
+ Public frontend.Variable `gnark:",public"`
+ Secret frontend.Variable
+}
+
+func (c *engineCommittedCircuit) Define(api frontend.API) error {
+ committer, ok := api.(frontend.Committer)
+ if !ok {
+ return errors.New("compiler does not implement frontend.Committer")
+ }
+ commitment, err := committer.Commit(c.Secret)
+ if err != nil {
+ return err
+ }
+ api.AssertIsDifferent(commitment, 0)
+ api.AssertIsEqual(api.Mul(c.Secret, c.Secret), c.Public)
+ return nil
+}
+
+func compileEngineCircuit(t *testing.T) *CompiledCircuit {
+ t.Helper()
+ compiled, err := frontend.Compile(
+ ecc.BLS12_381.ScalarField(),
+ r1cs.NewBuilder,
+ &engineCommittedCircuit{},
+ )
+ if err != nil {
+ t.Fatalf("compile tiny committed circuit: %v", err)
+ }
+ circuit, err := BindDestinationV2R1CS(compiled)
+ if err != nil {
+ t.Fatalf("bind tiny committed circuit: %v", err)
+ }
+ return circuit
+}
+
+func serializeEngineArtifact(t *testing.T, value io.WriterTo) []byte {
+ t.Helper()
+ var encoded bytes.Buffer
+ if _, err := value.WriteTo(&encoded); err != nil {
+ t.Fatalf("serialize %T: %v", value, err)
+ }
+ return encoded.Bytes()
+}
+
+func assertEngineArchiveUnchanged(t *testing.T, before [][]byte, values []io.WriterTo) {
+ t.Helper()
+ if len(before) != len(values) {
+ t.Fatalf("archive snapshot length %d != value length %d", len(before), len(values))
+ }
+ for i := range values {
+ if got := serializeEngineArtifact(t, values[i]); !bytes.Equal(got, before[i]) {
+ t.Fatalf("archived contribution %d was mutated", i+1)
+ }
+ }
+}
+
+func TestRunGnarkVerificationContainsPanics(t *testing.T) {
+ err := runGnarkVerification("adversarial verifier", func() error {
+ panic("upstream failure")
+ })
+ if err == nil || err.Error() != "adversarial verifier panic: upstream failure" {
+ t.Fatalf("contained panic error = %v", err)
+ }
+
+ sentinel := errors.New("invalid update")
+ if err := runGnarkVerification("ordinary verifier", func() error {
+ return sentinel
+ }); !errors.Is(err, sentinel) {
+ t.Fatalf("ordinary verifier error = %v, want sentinel", err)
+ }
+}
+
+func TestStreamCloneContainsDecoderPanic(t *testing.T) {
+ err := streamClone(bytes.NewBufferString("canonical bytes"), panicReaderFrom{})
+ if err == nil || !strings.Contains(err.Error(), "deserialize transcript panic: decoder failure") {
+ t.Fatalf("contained stream decoder panic error = %v", err)
+ }
+}
+
+func TestTinyCommittedCircuitMPCEngineRoundTrip(t *testing.T) {
+ circuit := compileEngineCircuit(t)
+ domainN := circuit.Binding.DomainSize
+ if domainN >= 1<<10 {
+ t.Fatalf("tiny test circuit unexpectedly selected domain %d", domainN)
+ }
+ if circuit.Binding.Phase2Shape.Commitments != 1 {
+ t.Fatalf("commitments = %d, want 1", circuit.Binding.Phase2Shape.Commitments)
+ }
+ phase1Genesis, phase1GenesisShape, err := InitializePhase1(domainN)
+ if err != nil {
+ t.Fatalf("initialize Phase 1: %v", err)
+ }
+ if phase1GenesisShape.ChallengeLength != 0 || len(phase1Genesis.Challenge) != 0 {
+ t.Fatalf(
+ "Phase 1 genesis challenge lengths = shape:%d object:%d, want 0",
+ phase1GenesisShape.ChallengeLength,
+ len(phase1Genesis.Challenge),
+ )
+ }
+
+ var phase1 []*gnarkmpc.Phase1
+ for range 2 {
+ before := make([][]byte, len(phase1))
+ values := make([]io.WriterTo, len(phase1))
+ for i := range phase1 {
+ before[i] = serializeEngineArtifact(t, phase1[i])
+ values[i] = phase1[i]
+ }
+ next, err := ContributePhase1(domainN, phase1)
+ if err != nil {
+ t.Fatalf("contribute Phase 1: %v", err)
+ }
+ assertEngineArchiveUnchanged(t, before, values)
+ phase1 = append(phase1, next)
+ }
+ if err := ReplayPhase1(domainN, phase1); err != nil {
+ t.Fatalf("replay Phase 1: %v", err)
+ }
+ phase1BeforeSeal := make([][]byte, len(phase1))
+ phase1Values := make([]io.WriterTo, len(phase1))
+ for i := range phase1 {
+ phase1BeforeSeal[i] = serializeEngineArtifact(t, phase1[i])
+ phase1Values[i] = phase1[i]
+ }
+ commons, err := SealPhase1(domainN, bytes.Repeat([]byte{0x31}, contributionChallengeSize), phase1)
+ if err != nil {
+ t.Fatalf("seal Phase 1: %v", err)
+ }
+ assertEngineArchiveUnchanged(t, phase1BeforeSeal, phase1Values)
+
+ initialPhase2, initializedShape, err := InitializePhase2(circuit, commons)
+ if err != nil {
+ t.Fatalf("initialize Phase 2: %v", err)
+ }
+ if !equalPhase2Shape(initializedShape, circuit.Binding.Phase2Shape) {
+ t.Fatalf("initialized Phase 2 shape = %+v, analytical binding = %+v", initializedShape, circuit.Binding.Phase2Shape)
+ }
+ if len(initialPhase2.Challenge) != 0 {
+ t.Fatalf("initial Phase 2 challenge length = %d, want 0", len(initialPhase2.Challenge))
+ }
+
+ var phase2 []*gnarkmpc.Phase2
+ for range 2 {
+ before := make([][]byte, len(phase2))
+ values := make([]io.WriterTo, len(phase2))
+ for i := range phase2 {
+ before[i] = serializeEngineArtifact(t, phase2[i])
+ values[i] = phase2[i]
+ }
+ next, err := ContributePhase2(circuit, commons, phase2)
+ if err != nil {
+ t.Fatalf("contribute Phase 2: %v", err)
+ }
+ assertEngineArchiveUnchanged(t, before, values)
+ phase2 = append(phase2, next)
+ }
+ if err := ReplayPhase2(circuit, commons, phase2); err != nil {
+ t.Fatalf("replay Phase 2: %v", err)
+ }
+ phase2BeforeSeal := make([][]byte, len(phase2))
+ phase2Values := make([]io.WriterTo, len(phase2))
+ for i := range phase2 {
+ phase2BeforeSeal[i] = serializeEngineArtifact(t, phase2[i])
+ phase2Values[i] = phase2[i]
+ }
+ pk, vk, err := SealPhase2(
+ circuit,
+ commons,
+ bytes.Repeat([]byte{0x32}, contributionChallengeSize),
+ phase2,
+ )
+ if err != nil {
+ t.Fatalf("seal Phase 2: %v", err)
+ }
+ assertEngineArchiveUnchanged(t, phase2BeforeSeal, phase2Values)
+
+ assignment := &engineCommittedCircuit{Secret: 3, Public: 9}
+ witness, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField())
+ if err != nil {
+ t.Fatalf("build witness: %v", err)
+ }
+ publicWitness, err := witness.Public()
+ if err != nil {
+ t.Fatalf("build public witness: %v", err)
+ }
+ proof, err := groth16.Prove(circuit.R1CS, pk, witness)
+ if err != nil {
+ t.Fatalf("prove with MPC key: %v", err)
+ }
+ if err := groth16.Verify(proof, vk, publicWitness); err != nil {
+ t.Fatalf("verify with MPC key: %v", err)
+ }
+ cardanoVK, format, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ t.Fatalf("serialize Cardano VK: %v", err)
+ }
+ if format != "groth16-bls12-381-bsb22" {
+ t.Fatalf("Cardano VK format = %q", format)
+ }
+ if len(cardanoVK) != prover.CardanoVKCommitmentLen {
+ t.Fatalf("Cardano VK length = %d, want %d", len(cardanoVK), prover.CardanoVKCommitmentLen)
+ }
+}
+
+func TestEngineRejectsWrongCurveAndInvalidChallenges(t *testing.T) {
+ wrongCurve, err := frontend.Compile(
+ ecc.BN254.ScalarField(),
+ r1cs.NewBuilder,
+ &engineCommittedCircuit{},
+ )
+ if err != nil {
+ t.Fatalf("compile wrong-curve circuit: %v", err)
+ }
+ if _, err := BindDestinationV2R1CS(wrongCurve); err == nil {
+ t.Fatal("BN254 R1CS unexpectedly accepted as BLS12-381")
+ }
+
+ circuit := compileEngineCircuit(t)
+ domainN := circuit.Binding.DomainSize
+ phase1, err := ContributePhase1(domainN, nil)
+ if err != nil {
+ t.Fatalf("contribute Phase 1: %v", err)
+ }
+ if _, err := SealPhase1(domainN, make([]byte, contributionChallengeSize-1), []*gnarkmpc.Phase1{phase1}); err == nil {
+ t.Fatal("short Phase 1 beacon unexpectedly accepted")
+ }
+
+ badPhase1 := new(gnarkmpc.Phase1)
+ if err := streamClone(phase1, badPhase1); err != nil {
+ t.Fatal(err)
+ }
+ badPhase1.Challenge = badPhase1.Challenge[:contributionChallengeSize-1]
+ if err := ReplayPhase1(domainN, []*gnarkmpc.Phase1{badPhase1}); err == nil {
+ t.Fatal("short Phase 1 contribution challenge unexpectedly accepted")
+ }
+
+ commons, err := SealPhase1(
+ domainN,
+ bytes.Repeat([]byte{0x41}, contributionChallengeSize),
+ []*gnarkmpc.Phase1{phase1},
+ )
+ if err != nil {
+ t.Fatalf("seal Phase 1: %v", err)
+ }
+ phase2, err := ContributePhase2(circuit, commons, nil)
+ if err != nil {
+ t.Fatalf("contribute Phase 2: %v", err)
+ }
+ if _, _, err := SealPhase2(
+ circuit,
+ commons,
+ make([]byte, contributionChallengeSize+1),
+ []*gnarkmpc.Phase2{phase2},
+ ); err == nil {
+ t.Fatal("long Phase 2 beacon unexpectedly accepted")
+ }
+ badPhase2 := new(gnarkmpc.Phase2)
+ if err := streamClone(phase2, badPhase2); err != nil {
+ t.Fatal(err)
+ }
+ badPhase2.Challenge = nil
+ if err := ReplayPhase2(circuit, commons, []*gnarkmpc.Phase2{badPhase2}); err == nil {
+ t.Fatal("empty Phase 2 contribution challenge unexpectedly accepted")
+ }
+}
+
+func TestFrozenR1CSNoReplaceRoundTrip(t *testing.T) {
+ circuit := compileEngineCircuit(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, prover.DestinationConstraintSystemFile)
+
+ digest, err := WriteR1CSFileNoReplace(path, circuit)
+ if err != nil {
+ t.Fatalf("write frozen R1CS: %v", err)
+ }
+ if digest != circuit.Binding.R1CS.Digest {
+ t.Fatalf("written digest = %+v, want %+v", digest, circuit.Binding.R1CS.Digest)
+ }
+ loaded, err := ReadR1CSFile(path, circuit.Binding)
+ if err != nil {
+ t.Fatalf("read frozen R1CS: %v", err)
+ }
+ if err := ValidateCircuitBinding(loaded, circuit.Binding); err != nil {
+ t.Fatalf("validate loaded binding: %v", err)
+ }
+ if _, err := WriteR1CSFileNoReplace(path, circuit); err == nil {
+ t.Fatal("second frozen R1CS write unexpectedly replaced destination")
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw[len(raw)/2] ^= 1
+ tamperedPath := filepath.Join(dir, "tampered.ccs")
+ if err := os.WriteFile(tamperedPath, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadR1CSFile(tamperedPath, circuit.Binding); err == nil {
+ t.Fatal("tampered frozen R1CS unexpectedly accepted")
+ }
+}
+
+func TestLoadedReplayReadsCompleteChain(t *testing.T) {
+ const count = 3
+ circuit := compileEngineCircuit(t)
+ domainN := circuit.Binding.DomainSize
+ phase1 := make([]*gnarkmpc.Phase1, 0, count)
+ for range count {
+ next, err := ContributePhase1(domainN, phase1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ phase1 = append(phase1, next)
+ }
+
+ loads := 0
+ err := ReplayPhase1Loaded(domainN, len(phase1), func(index int) (*gnarkmpc.Phase1, error) {
+ loads++
+ decoded := new(gnarkmpc.Phase1)
+ if err := streamClone(phase1[index], decoded); err != nil {
+ return nil, err
+ }
+ return decoded, nil
+ })
+ if err != nil {
+ t.Fatalf("loaded Phase 1 replay: %v", err)
+ }
+ if loads != count {
+ t.Fatalf("loader calls = %d, want %d", loads, count)
+ }
+}
diff --git a/internal/mpcceremony/files.go b/internal/mpcceremony/files.go
new file mode 100644
index 00000000..7e9294ae
--- /dev/null
+++ b/internal/mpcceremony/files.go
@@ -0,0 +1,393 @@
+package mpcceremony
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ "golang.org/x/crypto/blake2b"
+)
+
+// ReadPhase1File performs allocation-free preflight before native gnark
+// deserialization and requires the native object to round-trip canonically.
+func ReadPhase1File(path string, shape Phase1Shape) (*gnarkmpc.Phase1, ArtifactDigest, error) {
+ expected, err := ExpectedPhase1Size(shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ f, err := openRegularExact(path, expected)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ defer f.Close()
+
+ digest, err := PreflightPhase1(io.NewSectionReader(f, 0, expected), shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("preflight Phase 1 %q: %w", path, err)
+ }
+
+ var artifact gnarkmpc.Phase1
+ if err := nativeReadExact(io.NewSectionReader(f, 0, expected), expected, &artifact); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("decode Phase 1 %q: %w", path, err)
+ }
+ if len(artifact.Challenge) != int(shape.ChallengeLength) {
+ return nil, ArtifactDigest{}, fmt.Errorf("%w: decoded Phase 1 challenge length %d, expected %d", ErrInvalidShape, len(artifact.Challenge), shape.ChallengeLength)
+ }
+ if err := requireCanonicalRoundTrip(&artifact, digest); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("canonical Phase 1 %q: %w", path, err)
+ }
+ return &artifact, digest, nil
+}
+
+// ReadCommonsFile safely reads a canonical native gnark SrsCommons artifact.
+func ReadCommonsFile(path string, shape CommonsShape) (*gnarkmpc.SrsCommons, ArtifactDigest, error) {
+ expected, err := ExpectedCommonsSize(shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ f, err := openRegularExact(path, expected)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ defer f.Close()
+
+ digest, err := PreflightCommons(io.NewSectionReader(f, 0, expected), shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("preflight SRS commons %q: %w", path, err)
+ }
+
+ var artifact gnarkmpc.SrsCommons
+ if err := nativeReadExact(io.NewSectionReader(f, 0, expected), expected, &artifact); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("decode SRS commons %q: %w", path, err)
+ }
+ if err := requireCanonicalRoundTrip(&artifact, digest); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("canonical SRS commons %q: %w", path, err)
+ }
+ return &artifact, digest, nil
+}
+
+// ReadPhase2File safely reads a canonical native gnark Phase 2 artifact.
+func ReadPhase2File(path string, shape Phase2Shape) (*gnarkmpc.Phase2, ArtifactDigest, error) {
+ expected, err := ExpectedPhase2Size(shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ f, err := openRegularExact(path, expected)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ defer f.Close()
+
+ digest, err := PreflightPhase2(io.NewSectionReader(f, 0, expected), shape)
+ if err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("preflight Phase 2 %q: %w", path, err)
+ }
+
+ var artifact gnarkmpc.Phase2
+ if err := nativeReadExact(io.NewSectionReader(f, 0, expected), expected, &artifact); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("decode Phase 2 %q: %w", path, err)
+ }
+ if err := validateDecodedPhase2(&artifact, shape); err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ if err := requireCanonicalRoundTrip(&artifact, digest); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("canonical Phase 2 %q: %w", path, err)
+ }
+ return &artifact, digest, nil
+}
+
+func validateDecodedPhase2(p *gnarkmpc.Phase2, shape Phase2Shape) error {
+ if len(p.Sigmas) != int(shape.Commitments) ||
+ len(p.Parameters.G2.Sigma) != int(shape.Commitments) ||
+ len(p.Parameters.G1.SigmaCKK) != int(shape.Commitments) {
+ return fmt.Errorf("%w: decoded Phase 2 commitment structure mismatch", ErrInvalidShape)
+ }
+ if len(p.Parameters.G1.PKK) != int(shape.PKK) {
+ return fmt.Errorf("%w: decoded Phase 2 PKK length %d, expected %d", ErrInvalidShape, len(p.Parameters.G1.PKK), shape.PKK)
+ }
+ if len(p.Parameters.G1.Z) != int(shape.Z) {
+ return fmt.Errorf("%w: decoded Phase 2 Z length %d, expected %d", ErrInvalidShape, len(p.Parameters.G1.Z), shape.Z)
+ }
+ for i, expected := range shape.SigmaCKK {
+ if len(p.Parameters.G1.SigmaCKK[i]) != int(expected) {
+ return fmt.Errorf("%w: decoded Phase 2 SigmaCKK[%d] length %d, expected %d", ErrInvalidShape, i, len(p.Parameters.G1.SigmaCKK[i]), expected)
+ }
+ }
+ if len(p.Challenge) != int(shape.ChallengeLength) {
+ return fmt.Errorf("%w: decoded Phase 2 challenge length %d, expected %d", ErrInvalidShape, len(p.Challenge), shape.ChallengeLength)
+ }
+ return nil
+}
+
+func openRegularExact(path string, expected int64) (*os.File, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, fmt.Errorf("inspect MPC artifact %q: %w", path, err)
+ }
+ if linkInfo.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("MPC artifact %q must not be a symbolic link", path)
+ }
+ if !linkInfo.Mode().IsRegular() {
+ return nil, fmt.Errorf("MPC artifact %q is not a regular file", path)
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open MPC artifact %q: %w", path, err)
+ }
+ info, err := f.Stat()
+ if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("stat MPC artifact %q: %w", path, err)
+ }
+ if !info.Mode().IsRegular() {
+ f.Close()
+ return nil, fmt.Errorf("MPC artifact %q is not a regular file", path)
+ }
+ if !os.SameFile(linkInfo, info) {
+ f.Close()
+ return nil, fmt.Errorf("MPC artifact %q changed while being opened", path)
+ }
+ if info.Size() != expected {
+ f.Close()
+ return nil, fmt.Errorf("%w: MPC artifact %q is %d bytes, expected exactly %d", ErrInvalidShape, path, info.Size(), expected)
+ }
+ return f, nil
+}
+
+type fullReadReader struct {
+ r io.Reader
+}
+
+func (r fullReadReader) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+ return io.ReadFull(r.r, p)
+}
+
+func nativeReadExact(r io.Reader, expected int64, dst io.ReaderFrom) error {
+ limited := &io.LimitedReader{R: r, N: expected}
+ n, err := readFromWithPanicBoundary(
+ "native MPC decoder",
+ dst,
+ fullReadReader{r: limited},
+ )
+ if err != nil {
+ return err
+ }
+ if n != expected {
+ return fmt.Errorf("%w: native decoder consumed %d bytes, expected %d", ErrInvalidShape, n, expected)
+ }
+ if limited.N != 0 {
+ return fmt.Errorf("%w: native decoder left %d bytes", ErrTrailingData, limited.N)
+ }
+ return nil
+}
+
+func requireCanonicalRoundTrip(src io.WriterTo, expected ArtifactDigest) error {
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return fmt.Errorf("initialize BLAKE2b-256: %w", err)
+ }
+ n, err := writeToWithPanicBoundary(
+ "native MPC canonical encoder",
+ src,
+ io.MultiWriter(sha, blake),
+ )
+ if err != nil {
+ return err
+ }
+ if n != expected.Size {
+ return fmt.Errorf("%w: native encoder wrote %d bytes, expected %d", ErrInvalidShape, n, expected.Size)
+ }
+ var shaSum [sha256.Size]byte
+ var blakeSum [blake2b.Size256]byte
+ copy(shaSum[:], sha.Sum(nil))
+ copy(blakeSum[:], blake.Sum(nil))
+ if shaSum != expected.SHA256 || blakeSum != expected.BLAKE2b256 {
+ return errors.New("native canonical serialization digest does not match input")
+ }
+ return nil
+}
+
+// WritePhase1FileNoReplace writes, syncs, strictly reads back, and publishes a
+// native Phase 1 artifact without ever replacing an existing destination.
+func WritePhase1FileNoReplace(path string, artifact *gnarkmpc.Phase1, shape Phase1Shape) (ArtifactDigest, error) {
+ if artifact == nil {
+ return ArtifactDigest{}, errors.New("nil Phase 1 artifact")
+ }
+ expected, err := ExpectedPhase1Size(shape)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ return atomicWriteNoReplace(path, expected, artifact.WriteTo, func(tempPath string) (ArtifactDigest, error) {
+ _, digest, err := ReadPhase1File(tempPath, shape)
+ return digest, err
+ })
+}
+
+// WriteCommonsFileNoReplace writes a native SrsCommons artifact atomically
+// with no-replace semantics.
+func WriteCommonsFileNoReplace(path string, artifact *gnarkmpc.SrsCommons, shape CommonsShape) (ArtifactDigest, error) {
+ if artifact == nil {
+ return ArtifactDigest{}, errors.New("nil SRS commons artifact")
+ }
+ expected, err := ExpectedCommonsSize(shape)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ return atomicWriteNoReplace(path, expected, artifact.WriteTo, func(tempPath string) (ArtifactDigest, error) {
+ _, digest, err := ReadCommonsFile(tempPath, shape)
+ return digest, err
+ })
+}
+
+// WritePhase2FileNoReplace writes a native Phase 2 artifact atomically with
+// no-replace semantics.
+func WritePhase2FileNoReplace(path string, artifact *gnarkmpc.Phase2, shape Phase2Shape) (ArtifactDigest, error) {
+ if artifact == nil {
+ return ArtifactDigest{}, errors.New("nil Phase 2 artifact")
+ }
+ expected, err := ExpectedPhase2Size(shape)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ return atomicWriteNoReplace(path, expected, artifact.WriteTo, func(tempPath string) (ArtifactDigest, error) {
+ _, digest, err := ReadPhase2File(tempPath, shape)
+ return digest, err
+ })
+}
+
+func atomicWriteNoReplace(
+ path string,
+ expected int64,
+ write func(io.Writer) (int64, error),
+ validate func(string) (ArtifactDigest, error),
+) (digest ArtifactDigest, err error) {
+ if path == "" {
+ return ArtifactDigest{}, errors.New("empty MPC artifact output path")
+ }
+ dir := filepath.Dir(path)
+ base := filepath.Base(path)
+ dirInfo, err := os.Stat(dir)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("stat MPC artifact output directory %q: %w", dir, err)
+ }
+ if !dirInfo.IsDir() {
+ return ArtifactDigest{}, fmt.Errorf("MPC artifact output parent %q is not a directory", dir)
+ }
+
+ temp, err := os.CreateTemp(dir, "."+base+".partial-*")
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("create MPC artifact temporary file: %w", err)
+ }
+ tempPath := temp.Name()
+ tempOpen := true
+ defer func() {
+ if tempOpen {
+ _ = temp.Close()
+ }
+ _ = os.Remove(tempPath)
+ }()
+ if err := temp.Chmod(0o600); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("restrict MPC artifact temporary permissions: %w", err)
+ }
+
+ n, err := writeWithPanicBoundary("native MPC artifact encoder", write, temp)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("write MPC artifact temporary file: %w", err)
+ }
+ if n != expected {
+ return ArtifactDigest{}, fmt.Errorf("%w: native encoder wrote %d bytes, expected %d", ErrInvalidShape, n, expected)
+ }
+ info, err := temp.Stat()
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("stat MPC artifact temporary file: %w", err)
+ }
+ if info.Size() != expected {
+ return ArtifactDigest{}, fmt.Errorf("%w: temporary file is %d bytes, expected %d", ErrInvalidShape, info.Size(), expected)
+ }
+ if err := temp.Sync(); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("sync MPC artifact temporary file: %w", err)
+ }
+ if err := temp.Close(); err != nil {
+ tempOpen = false
+ return ArtifactDigest{}, fmt.Errorf("close MPC artifact temporary file: %w", err)
+ }
+ tempOpen = false
+
+ digest, err = validate(tempPath)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("validate MPC artifact temporary file: %w", err)
+ }
+ if digest.Size != expected {
+ return ArtifactDigest{}, fmt.Errorf("%w: validated file is %d bytes, expected %d", ErrInvalidShape, digest.Size, expected)
+ }
+
+ if err := publishFileNoReplace(tempPath, path); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("publish MPC artifact without replacement: %w", err)
+ }
+ return digest, nil
+}
+
+func readFromWithPanicBoundary(
+ label string,
+ dst io.ReaderFrom,
+ src io.Reader,
+) (n int64, err error) {
+ if dst == nil || src == nil {
+ return 0, fmt.Errorf("%s requires a reader and decoder", label)
+ }
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ n = 0
+ err = fmt.Errorf("%s panic: %v", label, recovered)
+ }
+ }()
+ return dst.ReadFrom(src)
+}
+
+func writeToWithPanicBoundary(
+ label string,
+ src io.WriterTo,
+ dst io.Writer,
+) (n int64, err error) {
+ if src == nil || dst == nil {
+ return 0, fmt.Errorf("%s requires an encoder and writer", label)
+ }
+ return writeWithPanicBoundary(label, src.WriteTo, dst)
+}
+
+func writeWithPanicBoundary(
+ label string,
+ write func(io.Writer) (int64, error),
+ dst io.Writer,
+) (n int64, err error) {
+ if write == nil || dst == nil {
+ return 0, fmt.Errorf("%s requires an encoder and writer", label)
+ }
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ n = 0
+ err = fmt.Errorf("%s panic: %v", label, recovered)
+ }
+ }()
+ return write(dst)
+}
+
+func syncDirectory(dir string) error {
+ f, err := os.Open(dir)
+ if err != nil {
+ return fmt.Errorf("open MPC artifact output directory for sync: %w", err)
+ }
+ defer f.Close()
+ if err := f.Sync(); err != nil {
+ return fmt.Errorf("sync MPC artifact output directory: %w", err)
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/files_test.go b/internal/mpcceremony/files_test.go
new file mode 100644
index 00000000..fd54acd7
--- /dev/null
+++ b/internal/mpcceremony/files_test.go
@@ -0,0 +1,300 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+func TestFullReadReaderFillsRequestedBuffer(t *testing.T) {
+ t.Parallel()
+
+ source := &oneByteReader{r: bytes.NewReader([]byte("challenge"))}
+ got := make([]byte, len("challenge"))
+ n, err := (fullReadReader{r: source}).Read(got)
+ if err != nil {
+ t.Fatalf("full read: %v", err)
+ }
+ if n != len(got) || string(got) != "challenge" {
+ t.Fatalf("read %d bytes %q", n, got)
+ }
+
+ short := make([]byte, 10)
+ if _, err := (fullReadReader{r: &oneByteReader{r: bytes.NewReader([]byte("short"))}}).Read(short); !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("short read error = %v; want io.ErrUnexpectedEOF", err)
+ }
+}
+
+type oneByteReader struct {
+ r *bytes.Reader
+}
+
+func (r *oneByteReader) Read(p []byte) (int, error) {
+ if len(p) > 1 {
+ p = p[:1]
+ }
+ return r.r.Read(p)
+}
+
+type panicReaderFrom struct{}
+
+func (panicReaderFrom) ReadFrom(io.Reader) (int64, error) {
+ panic("decoder failure")
+}
+
+type panicWriterTo struct{}
+
+func (panicWriterTo) WriteTo(io.Writer) (int64, error) {
+ panic("encoder failure")
+}
+
+func TestNativeCodecPanicBoundaries(t *testing.T) {
+ t.Parallel()
+
+ if err := nativeReadExact(bytes.NewReader([]byte{0}), 1, panicReaderFrom{}); err == nil ||
+ err.Error() != "native MPC decoder panic: decoder failure" {
+ t.Fatalf("decoder panic error = %v", err)
+ }
+ if _, err := writeToWithPanicBoundary(
+ "test encoder",
+ panicWriterTo{},
+ io.Discard,
+ ); err == nil || err.Error() != "test encoder panic: encoder failure" {
+ t.Fatalf("encoder panic error = %v", err)
+ }
+}
+
+func TestReadPhase1FileStrict(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ phase1 := gnarkmpc.NewPhase1(2)
+ encoded := writeNative(t, phase1)
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ got, digest, err := ReadPhase1File(path, Phase1Shape{DomainN: 2})
+ if err != nil {
+ t.Fatalf("strict read: %v", err)
+ }
+ if got == nil || digest.Size != int64(len(encoded)) {
+ t.Fatalf("unexpected read result: object=%v digest=%+v", got, digest)
+ }
+}
+
+func TestReadPhase1FilePreservesNativePointValidation(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ encoded := writeNative(t, gnarkmpc.NewPhase1(2))
+ // Keep the canonical compressed-infinity mask so structural preflight
+ // succeeds, but violate the native infinity encoding's all-zero payload.
+ encoded[1] = 1
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := ReadPhase1File(path, Phase1Shape{DomainN: 2}); err == nil {
+ t.Fatal("invalid compressed infinity unexpectedly passed native decoding")
+ }
+}
+
+func TestReadFileRejectsWrongSizeAndNonRegular(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ phase1 := gnarkmpc.NewPhase1(2)
+ encoded := append(writeNative(t, phase1), 0)
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := ReadPhase1File(path, Phase1Shape{DomainN: 2}); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("wrong-size error = %v; want ErrInvalidShape", err)
+ }
+ if _, _, err := ReadPhase1File(dir, Phase1Shape{DomainN: 2}); err == nil {
+ t.Fatal("directory unexpectedly accepted as artifact")
+ }
+}
+
+func TestReadPhase2FileStrict(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase2.bin")
+ phase2, shape := smallPhase2(32)
+ if err := os.WriteFile(path, writeNative(t, phase2), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, digest, err := ReadPhase2File(path, shape)
+ if err != nil {
+ t.Fatalf("strict Phase 2 read: %v", err)
+ }
+ if got == nil || !bytes.Equal(got.Challenge, digest.Challenge) {
+ t.Fatal("strict Phase 2 read lost challenge")
+ }
+}
+
+func TestWritePhase1FileNoReplace(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ phase1 := gnarkmpc.NewPhase1(2)
+ shape := Phase1Shape{DomainN: 2}
+
+ digest, err := WritePhase1FileNoReplace(path, phase1, shape)
+ if err != nil {
+ t.Fatalf("write Phase 1: %v", err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("permissions = %04o; want 0600", info.Mode().Perm())
+ }
+ if info.Size() != digest.Size {
+ t.Fatalf("published size = %d, digest size = %d", info.Size(), digest.Size)
+ }
+
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := WritePhase1FileNoReplace(path, phase1, shape); !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("second write error = %v; want fs.ErrExist", err)
+ }
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(before, after) {
+ t.Fatal("failed no-replace write changed existing artifact")
+ }
+ assertNoPartials(t, dir)
+}
+
+func TestWriteNoReplaceDoesNotPublishInvalidShape(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ phase1 := gnarkmpc.NewPhase1(2)
+ _, err := WritePhase1FileNoReplace(path, phase1, Phase1Shape{DomainN: 4})
+ if err == nil {
+ t.Fatal("shape-mismatched write unexpectedly succeeded")
+ }
+ if _, statErr := os.Stat(path); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("final path after failed write: %v", statErr)
+ }
+ assertNoPartials(t, dir)
+}
+
+func TestWriteNoReplaceRejectsExistingSymlink(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ target := filepath.Join(dir, "missing-target")
+ if err := os.Symlink(target, path); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+
+ phase1 := gnarkmpc.NewPhase1(2)
+ _, err := WritePhase1FileNoReplace(path, phase1, Phase1Shape{DomainN: 2})
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("symlink output error = %v; want fs.ErrExist", err)
+ }
+ linkTarget, err := os.Readlink(path)
+ if err != nil {
+ t.Fatalf("read preserved symlink: %v", err)
+ }
+ if linkTarget != target {
+ t.Fatalf("symlink target = %q; want %q", linkTarget, target)
+ }
+ assertNoPartials(t, dir)
+}
+
+func TestWritePhase2AndCommonsNoReplace(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ phase2Path := filepath.Join(dir, "phase2.bin")
+ phase2, phase2Shape := smallPhase2(32)
+ if _, err := WritePhase2FileNoReplace(phase2Path, phase2, phase2Shape); err != nil {
+ t.Fatalf("write Phase 2: %v", err)
+ }
+ if _, _, err := ReadPhase2File(phase2Path, phase2Shape); err != nil {
+ t.Fatalf("read written Phase 2: %v", err)
+ }
+
+ phase1 := gnarkmpc.NewPhase1(2)
+ commons := phase1.Seal([]byte("test beacon"))
+ commonsPath := filepath.Join(dir, "commons.bin")
+ commonsShape := CommonsShape{DomainN: 2}
+ if _, err := WriteCommonsFileNoReplace(commonsPath, &commons, commonsShape); err != nil {
+ t.Fatalf("write commons: %v", err)
+ }
+ if _, _, err := ReadCommonsFile(commonsPath, commonsShape); err != nil {
+ t.Fatalf("read written commons: %v", err)
+ }
+}
+
+func TestConcurrentNoReplaceHasSingleWinner(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "phase1.bin")
+ phase1 := gnarkmpc.NewPhase1(2)
+ shape := Phase1Shape{DomainN: 2}
+
+ var wg sync.WaitGroup
+ errs := make(chan error, 2)
+ for range 2 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := WritePhase1FileNoReplace(path, phase1, shape)
+ errs <- err
+ }()
+ }
+ wg.Wait()
+ close(errs)
+
+ var successes, exists int
+ for err := range errs {
+ switch {
+ case err == nil:
+ successes++
+ case errors.Is(err, fs.ErrExist):
+ exists++
+ default:
+ t.Errorf("unexpected writer error: %v", err)
+ }
+ }
+ if successes != 1 || exists != 1 {
+ t.Fatalf("successes=%d exists=%d; want 1/1", successes, exists)
+ }
+ assertNoPartials(t, dir)
+}
+
+func assertNoPartials(t *testing.T, dir string) {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join(dir, ".*.partial-*"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(matches) != 0 {
+ t.Fatalf("temporary files remain: %v", matches)
+ }
+}
diff --git a/internal/mpcceremony/finalize.go b/internal/mpcceremony/finalize.go
new file mode 100644
index 00000000..6d7b8f6a
--- /dev/null
+++ b/internal/mpcceremony/finalize.go
@@ -0,0 +1,1932 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "math/big"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
+ "github.com/consensys/gnark/backend/groth16"
+ groth16bls12381 "github.com/consensys/gnark/backend/groth16/bls12-381"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ "github.com/consensys/gnark/backend/witness"
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/prover"
+)
+
+const (
+ CandidateMetadataSchema = "proof-tool-mpc-release-candidate-v2"
+ VerificationReportSchema = "proof-tool-mpc-verification-report-v2"
+ PublicEvidenceSchema = "proof-tool-mpc-public-finalization-evidence-v1"
+ CandidateMetadataFile = "candidate.json"
+ CandidateSignatureFile = "candidate.sig.json"
+ VerificationReportFile = "verification-report.json"
+ PublicEvidenceFile = "public-finalization-evidence.json"
+ PreliminaryMetadataSchema = "proof-tool-mpc-preliminary-final-keys-v1"
+ PreliminaryMetadataFile = "preliminary-final-keys.json"
+ PreliminarySignatureFile = "preliminary-final-keys.sig.json"
+ PreliminaryChecksumsFile = "preliminary-checksums.sha256"
+ CardanoVKBytesFile = "cardano-vk.bin"
+ CardanoVKHexFile = "cardano-vk.hex"
+ CardanoVKFormatFile = "cardano-vk-format.txt"
+ CandidateChecksumsFile = "candidate-checksums.sha256"
+ Phase2SealFile = "phase2-seal.json"
+ Phase2SealSignatureFile = "phase2-seal.sig.json"
+ FinalTranscriptFile = "setup-transcript.json"
+ ReleaseChecksumsFile = "checksums.sha256"
+ NativeProvingKeyFile = "ownership.pk"
+ NativeVerifyingKeyFile = "ownership.vk"
+ PublicCredentialBytes = 28
+ PublicDestinationBytes = 58
+ DestinationPublicDomain = "ROOT-OWNERSHIP-DESTINATION-v1"
+ GoldenPublicCredentialHex = "19e07fbcc7577359d6c51f1e49cf1b0bf4c943b48ba4e4905a8702e4"
+ GoldenPublicDestinationHex = "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111" +
+ "0000000000000000000000000000000000000000000000000000000000"
+ expectedCardanoBSB22 = "groth16-bls12-381-bsb22"
+ PublicEvidenceFixture = "repository-golden-destination-v2"
+)
+
+// ReplayPaths names every immutable input required to independently replay
+// both phases. Contribution paths must be in accepted-chain order. No
+// "latest" lookup or directory scan is performed by the engine.
+type ReplayPaths struct {
+ TranscriptRoot string
+ CoordinatorPublicKeyHex string
+ DefinitionPath string
+ DefinitionSignaturePath string
+ Phase1ChainPath string
+ Phase1ChainSignaturePath string
+ Phase1ClosePath string
+ Phase1CloseSignaturePath string
+ Phase1BeaconPath string
+ Phase1BeaconSignaturePath string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ Phase2ChainPath string
+ Phase2ChainSignaturePath string
+ Phase2ClosePath string
+ Phase2CloseSignaturePath string
+ Phase2BeaconPath string
+ Phase2BeaconSignaturePath string
+}
+
+type FinalizeOptions struct {
+ Replay ReplayPaths
+ Circuit *CompiledCircuit
+ OutDir string
+ CoordinatorSigningKey string
+ PublicEvidencePath string
+ FinalizedAt time.Time
+}
+
+type PrepareFinalizationOptions struct {
+ Replay ReplayPaths
+ Circuit *CompiledCircuit
+ OutDir string
+ CoordinatorSigningKey string
+ PreparedAt time.Time
+}
+
+type PreliminaryFinalKeys struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Definition ArtifactRef `json:"definition"`
+ Circuit CircuitBinding `json:"circuit"`
+ Phase1Chain ArtifactRef `json:"phase1_chain"`
+ Phase2Chain ArtifactRef `json:"phase2_chain"`
+ ConstraintSystem ArtifactRef `json:"constraint_system"`
+ ProvingKey ArtifactRef `json:"proving_key"`
+ VerifyingKey ArtifactRef `json:"verifying_key"`
+ CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"`
+ CardanoVKHex ArtifactRef `json:"cardano_vk_hex"`
+ CardanoVKFormat ArtifactRef `json:"cardano_vk_format"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ PreparedAt string `json:"prepared_at"`
+}
+
+func (p PreliminaryFinalKeys) Validate() error {
+ if p.Schema != PreliminaryMetadataSchema {
+ return fmt.Errorf("preliminary metadata schema %q, want %q", p.Schema, PreliminaryMetadataSchema)
+ }
+ if err := validateHashID("ceremony_id", p.CeremonyID); err != nil {
+ return err
+ }
+ for label, ref := range map[string]ArtifactRef{
+ "definition": p.Definition,
+ "phase1_chain": p.Phase1Chain,
+ "phase2_chain": p.Phase2Chain,
+ "constraint_system": p.ConstraintSystem,
+ "proving_key": p.ProvingKey,
+ "verifying_key": p.VerifyingKey,
+ "cardano_verifying_key": p.CardanoVerifyingKey,
+ "cardano_vk_hex": p.CardanoVKHex,
+ "cardano_vk_format": p.CardanoVKFormat,
+ } {
+ if err := ref.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ }
+ if err := p.Circuit.Validate(); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_id", p.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", p.CoordinatorKeyID); err != nil {
+ return err
+ }
+ return validateTimestamp("prepared_at", p.PreparedAt)
+}
+
+type PrepareFinalizationResult struct {
+ OutDir string
+ MetadataPath string
+ SignaturePath string
+ ProvingKeyPath string
+ VerifyingKeyPath string
+ CardanoVKPath string
+ ChecksumsPath string
+}
+
+type FinalizeResult struct {
+ CeremonyID string
+ OutDir string
+ Candidate CandidateMetadata
+ CandidatePath string
+ CandidateSigPath string
+ VerificationPath string
+ ProvingKeyPath string
+ VerifyingKeyPath string
+ ConstraintSystem string
+ CardanoVKPath string
+ CandidateChecksum string
+}
+
+type VerificationReport struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Fixture string `json:"fixture"`
+ NativeProofVerified bool `json:"native_proof_verified"`
+ WrongCredentialRejected bool `json:"wrong_credential_rejected"`
+ WrongDestinationRejected bool `json:"wrong_destination_rejected"`
+ WrongDigestRejected bool `json:"wrong_digest_rejected"`
+ WrongProofRejected bool `json:"wrong_proof_rejected"`
+ WrongVKRejected bool `json:"wrong_vk_rejected"`
+ ProofTruncationRejected bool `json:"proof_truncation_rejected"`
+ ProofAppendRejected bool `json:"proof_append_rejected"`
+ CardanoProofFormat string `json:"cardano_proof_format"`
+ CardanoProofBytes int `json:"cardano_proof_bytes"`
+ CardanoProofRawDigest Digest `json:"cardano_proof_raw_digest"`
+ CardanoVKFormat string `json:"cardano_vk_format"`
+ CardanoVKBytes int `json:"cardano_vk_bytes"`
+ CardanoVKRawDigest Digest `json:"cardano_vk_raw_digest"`
+ PublicEvidence ArtifactRef `json:"public_evidence"`
+ CheckedAt string `json:"checked_at"`
+}
+
+type publicEvidenceVerification struct {
+ NativeProofVerified bool
+ WrongCredentialRejected bool
+ WrongDestinationRejected bool
+ WrongDigestRejected bool
+ WrongProofRejected bool
+ WrongVKRejected bool
+ ProofTruncationRejected bool
+ ProofAppendRejected bool
+}
+
+func (r VerificationReport) Validate() error {
+ if r.Schema != VerificationReportSchema {
+ return fmt.Errorf("verification report schema %q, want %q", r.Schema, VerificationReportSchema)
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if r.Fixture != PublicEvidenceFixture {
+ return fmt.Errorf("verification fixture %q, want %q", r.Fixture, PublicEvidenceFixture)
+ }
+ if !r.NativeProofVerified ||
+ !r.WrongCredentialRejected ||
+ !r.WrongDestinationRejected ||
+ !r.WrongDigestRejected ||
+ !r.WrongProofRejected ||
+ !r.WrongVKRejected ||
+ !r.ProofTruncationRejected ||
+ !r.ProofAppendRejected {
+ return errors.New("verification report requires positive and negative proof evidence")
+ }
+ if r.CardanoProofFormat != expectedCardanoBSB22 ||
+ r.CardanoProofBytes != prover.CardanoProofCommitmentLen {
+ return errors.New("verification report has unexpected Cardano proof encoding")
+ }
+ if err := r.CardanoProofRawDigest.Validate(); err != nil {
+ return fmt.Errorf("cardano_proof_raw_digest: %w", err)
+ }
+ if r.CardanoProofRawDigest.Size != int64(r.CardanoProofBytes) {
+ return errors.New("verification report Cardano proof digest size differs from proof byte count")
+ }
+ if r.CardanoVKFormat != expectedCardanoBSB22 ||
+ r.CardanoVKBytes != prover.CardanoVKCommitmentLen {
+ return errors.New("verification report has unexpected Cardano verifying-key encoding")
+ }
+ if err := r.CardanoVKRawDigest.Validate(); err != nil {
+ return fmt.Errorf("cardano_vk_raw_digest: %w", err)
+ }
+ if r.CardanoVKRawDigest.Size != int64(r.CardanoVKBytes) {
+ return errors.New("verification report Cardano verifying-key digest size differs from key byte count")
+ }
+ if err := r.PublicEvidence.Validate(); err != nil {
+ return fmt.Errorf("public_evidence: %w", err)
+ }
+ if r.PublicEvidence.Name != PublicEvidenceFile {
+ return fmt.Errorf("public evidence artifact is %q, want %q", r.PublicEvidence.Name, PublicEvidenceFile)
+ }
+ return validateTimestamp("checked_at", r.CheckedAt)
+}
+
+// PublicFinalizationEvidence is safe to publish. It contains only the public
+// statement and the Cardano wire proof produced by the finalized keys. It
+// deliberately excludes the master XPrv, seed, derivation path, and any wallet
+// material.
+type PublicFinalizationEvidence struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Fixture string `json:"fixture"`
+ CredentialHex string `json:"credential_hex"`
+ DestinationHex string `json:"destination_hex"`
+ PublicInputDigestHex string `json:"public_input_digest_hex"`
+ CardanoProofHex string `json:"cardano_proof_hex"`
+ CardanoProofFormat string `json:"cardano_proof_format"`
+ CardanoProofRawDigest Digest `json:"cardano_proof_raw_digest"`
+ CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"`
+}
+
+func (e PublicFinalizationEvidence) Validate() error {
+ if e.Schema != PublicEvidenceSchema {
+ return fmt.Errorf("public evidence schema %q, want %q", e.Schema, PublicEvidenceSchema)
+ }
+ if err := validateHashID("ceremony_id", e.CeremonyID); err != nil {
+ return err
+ }
+ if e.Fixture != PublicEvidenceFixture {
+ return fmt.Errorf("public evidence fixture %q, want %q", e.Fixture, PublicEvidenceFixture)
+ }
+ if e.CredentialHex != GoldenPublicCredentialHex ||
+ e.DestinationHex != GoldenPublicDestinationHex {
+ return errors.New("public evidence does not use the exact repository golden public vector")
+ }
+ if err := validateHex(e.CredentialHex, PublicCredentialBytes); err != nil {
+ return fmt.Errorf("credential_hex: %w", err)
+ }
+ if err := validateHex(e.DestinationHex, PublicDestinationBytes); err != nil {
+ return fmt.Errorf("destination_hex: %w", err)
+ }
+ if err := validateHex(e.PublicInputDigestHex, blake2b.Size256); err != nil {
+ return fmt.Errorf("public_input_digest_hex: %w", err)
+ }
+ if err := validateHex(e.CardanoProofHex, prover.CardanoProofCommitmentLen); err != nil {
+ return fmt.Errorf("cardano_proof_hex: %w", err)
+ }
+ if e.CardanoProofFormat != expectedCardanoBSB22 {
+ return fmt.Errorf("cardano proof format %q, want %q", e.CardanoProofFormat, expectedCardanoBSB22)
+ }
+ if err := e.CardanoProofRawDigest.Validate(); err != nil {
+ return fmt.Errorf("cardano_proof_raw_digest: %w", err)
+ }
+ if e.CardanoProofRawDigest.Size != prover.CardanoProofCommitmentLen {
+ return errors.New("public evidence proof digest size differs from exact Cardano proof length")
+ }
+ proof, _ := hex.DecodeString(e.CardanoProofHex)
+ if NewDigest(proof) != e.CardanoProofRawDigest {
+ return errors.New("public evidence proof digest differs from proof_hex")
+ }
+ if err := e.CardanoVerifyingKey.Validate(); err != nil {
+ return fmt.Errorf("cardano_verifying_key: %w", err)
+ }
+ if e.CardanoVerifyingKey.Name != CardanoVKBytesFile {
+ return fmt.Errorf("cardano verifying-key artifact is %q, want %q", e.CardanoVerifyingKey.Name, CardanoVKBytesFile)
+ }
+ credential, _ := hex.DecodeString(e.CredentialHex)
+ destination, _ := hex.DecodeString(e.DestinationHex)
+ digest := publicInputDigest(credential, destination)
+ if hex.EncodeToString(digest) != e.PublicInputDigestHex {
+ return errors.New("public input digest does not bind the public credential and destination")
+ }
+ return nil
+}
+
+// CandidateMetadata binds replay inputs to unsigned release artifacts. It is
+// coordinator-signed, but deliberately is not a release manifest. Independent
+// auditors must reproduce it before the distinct release signer may act.
+type CandidateMetadata struct {
+ Schema string `json:"schema"`
+ CandidateID string `json:"candidate_id"`
+ CeremonyID string `json:"ceremony_id"`
+ Definition ArtifactRef `json:"definition"`
+ Circuit CircuitBinding `json:"circuit"`
+ Phase1 PhaseSummary `json:"phase1"`
+ Phase2 PhaseSummary `json:"phase2"`
+ ConstraintSystem ArtifactRef `json:"constraint_system"`
+ ProvingKey ArtifactRef `json:"proving_key"`
+ VerifyingKey ArtifactRef `json:"verifying_key"`
+ CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"`
+ CardanoVKHex ArtifactRef `json:"cardano_vk_hex"`
+ CardanoVKFormat ArtifactRef `json:"cardano_vk_format"`
+ VerificationReport ArtifactRef `json:"verification_report"`
+ PublicEvidence ArtifactRef `json:"public_finalization_evidence"`
+ Phase2SealRecord ArtifactRef `json:"phase2_seal_record"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ FinalizedAt string `json:"finalized_at"`
+}
+
+func NewCandidateMetadata(candidate CandidateMetadata) (CandidateMetadata, error) {
+ candidate.Schema = CandidateMetadataSchema
+ candidate.CandidateID = ""
+ id, err := computeCandidateID(candidate)
+ if err != nil {
+ return CandidateMetadata{}, err
+ }
+ candidate.CandidateID = id
+ return candidate, candidate.Validate()
+}
+
+func (c CandidateMetadata) Validate() error {
+ if c.Schema != CandidateMetadataSchema {
+ return fmt.Errorf("candidate schema %q, want %q", c.Schema, CandidateMetadataSchema)
+ }
+ if err := validateHashID("candidate_id", c.CandidateID); err != nil {
+ return err
+ }
+ expected, err := computeCandidateID(c)
+ if err != nil {
+ return err
+ }
+ if c.CandidateID != expected {
+ return fmt.Errorf("candidate_id %q, want %q", c.CandidateID, expected)
+ }
+ if err := validateHashID("ceremony_id", c.CeremonyID); err != nil {
+ return err
+ }
+ if err := c.Definition.Validate(); err != nil {
+ return fmt.Errorf("definition: %w", err)
+ }
+ if err := c.Circuit.Validate(); err != nil {
+ return fmt.Errorf("circuit: %w", err)
+ }
+ if err := c.Phase1.Validate(); err != nil || c.Phase1.Phase != Phase1 {
+ return fmt.Errorf("phase1: invalid summary: %w", err)
+ }
+ if err := c.Phase2.Validate(); err != nil || c.Phase2.Phase != Phase2 {
+ return fmt.Errorf("phase2: invalid summary: %w", err)
+ }
+ for label, ref := range map[string]ArtifactRef{
+ "constraint_system": c.ConstraintSystem,
+ "proving_key": c.ProvingKey,
+ "verifying_key": c.VerifyingKey,
+ "cardano_verifying_key": c.CardanoVerifyingKey,
+ "cardano_vk_hex": c.CardanoVKHex,
+ "cardano_vk_format": c.CardanoVKFormat,
+ "verification_report": c.VerificationReport,
+ "public_evidence": c.PublicEvidence,
+ "phase2_seal_record": c.Phase2SealRecord,
+ } {
+ if err := ref.Validate(); err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ }
+ if err := validateID("coordinator_id", c.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", c.CoordinatorKeyID); err != nil {
+ return err
+ }
+ return validateTimestamp("finalized_at", c.FinalizedAt)
+}
+
+func computeCandidateID(candidate CandidateMetadata) (string, error) {
+ candidate.CandidateID = ""
+ if candidate.Schema != CandidateMetadataSchema {
+ return "", fmt.Errorf("candidate schema %q, want %q", candidate.Schema, CandidateMetadataSchema)
+ }
+ return canonicalHash("proof-tool/mpc-ceremony/release-candidate/v1", candidate)
+}
+
+type loadedReplay struct {
+ definition CeremonyDefinition
+ definitionRef ArtifactRef
+ phase1Chain Chain
+ phase1ChainRef ArtifactRef
+ phase1Close CloseRecord
+ phase1Beacon BeaconRecord
+ phase1Seal SealRecord
+ phase2Chain Chain
+ phase2ChainRef ArtifactRef
+ phase2Close CloseRecord
+ phase2Beacon BeaconRecord
+ phase2Seal SealRecord
+}
+
+type replayedKeys struct {
+ commons *gnarkmpc.SrsCommons
+ pk groth16.ProvingKey
+ vk groth16.VerifyingKey
+}
+
+// PrepareFinalization independently replays the ceremony and publishes only a
+// coordinator-signed preliminary key tree. This tree exists solely so a
+// separate local proof tool can create public proof evidence. It is not a
+// candidate, has no candidate/release manifest, and is rejected by audit and
+// release commands.
+func PrepareFinalization(options PrepareFinalizationOptions) (*PrepareFinalizationResult, error) {
+ if options.Circuit == nil || options.Circuit.R1CS == nil {
+ return nil, errors.New("compiled destination-v2 circuit is required")
+ }
+ if options.PreparedAt.IsZero() || options.PreparedAt.Location() != time.UTC {
+ return nil, errors.New("prepared_at must be a non-zero UTC time")
+ }
+ loaded, err := loadReplay(options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyRunningSoftwareForMode(loaded.definition.Software, loaded.definition.Mode); err != nil {
+ return nil, fmt.Errorf("running preliminary finalizer software: %w", err)
+ }
+ if err := ValidateCircuitBinding(options.Circuit, loaded.definition.Circuit); err != nil {
+ return nil, err
+ }
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(options.CoordinatorSigningKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(loaded.definition.Coordinator, publicKey); err != nil {
+ return nil, fmt.Errorf("coordinator signing key: %w", err)
+ }
+ replayed, err := replayAll(options.Circuit, loaded, options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ stagingDir, err := createRecoveryStagingDir(options.OutDir)
+ if err != nil {
+ return nil, err
+ }
+ cleanup := true
+ defer func() {
+ if cleanup {
+ _ = os.RemoveAll(stagingDir)
+ _ = syncDirectory(filepath.Dir(stagingDir))
+ }
+ }()
+
+ ccsPath := filepath.Join(stagingDir, prover.DestinationConstraintSystemFile)
+ ccsRef, err := writeR1CSNoReplace(ccsPath, options.Circuit)
+ if err != nil {
+ return nil, err
+ }
+ pkPath := filepath.Join(stagingDir, NativeProvingKeyFile)
+ if err := saveNativeNoReplace(pkPath, func(path string) error { return prover.SavePK(replayed.pk, path) }); err != nil {
+ return nil, err
+ }
+ vkPath := filepath.Join(stagingDir, NativeVerifyingKeyFile)
+ if err := saveNativeNoReplace(vkPath, func(path string) error { return prover.SaveVK(replayed.vk, path) }); err != nil {
+ return nil, err
+ }
+ pkRef, err := artifactRefForFile(NativeProvingKeyFile, pkPath)
+ if err != nil {
+ return nil, err
+ }
+ vkRef, err := artifactRefForFile(NativeVerifyingKeyFile, vkPath)
+ if err != nil {
+ return nil, err
+ }
+ cardanoRaw, cardanoFormat, err := prover.SerializeCardanoVK(replayed.vk)
+ if err != nil {
+ return nil, err
+ }
+ if cardanoFormat != expectedCardanoBSB22 || len(cardanoRaw) != prover.CardanoVKCommitmentLen {
+ return nil, errors.New("preliminary Cardano verifying key is not exact BSB22 encoding")
+ }
+ cardanoPath := filepath.Join(stagingDir, CardanoVKBytesFile)
+ cardanoHexPath := filepath.Join(stagingDir, CardanoVKHexFile)
+ cardanoFormatPath := filepath.Join(stagingDir, CardanoVKFormatFile)
+ if err := writeBytesNoReplace(cardanoPath, cardanoRaw, 0o600); err != nil {
+ return nil, err
+ }
+ if err := writeBytesNoReplace(cardanoHexPath, []byte(hex.EncodeToString(cardanoRaw)+"\n"), 0o600); err != nil {
+ return nil, err
+ }
+ if err := writeBytesNoReplace(cardanoFormatPath, []byte(cardanoFormat+"\n"), 0o600); err != nil {
+ return nil, err
+ }
+ cardanoRef, err := artifactRefForFile(CardanoVKBytesFile, cardanoPath)
+ if err != nil {
+ return nil, err
+ }
+ cardanoHexRef, err := artifactRefForFile(CardanoVKHexFile, cardanoHexPath)
+ if err != nil {
+ return nil, err
+ }
+ cardanoFormatRef, err := artifactRefForFile(CardanoVKFormatFile, cardanoFormatPath)
+ if err != nil {
+ return nil, err
+ }
+ metadata := PreliminaryFinalKeys{
+ Schema: PreliminaryMetadataSchema,
+ CeremonyID: loaded.definition.CeremonyID,
+ Definition: loaded.definitionRef,
+ Circuit: loaded.definition.Circuit,
+ Phase1Chain: loaded.phase1ChainRef,
+ Phase2Chain: loaded.phase2ChainRef,
+ ConstraintSystem: ccsRef,
+ ProvingKey: pkRef,
+ VerifyingKey: vkRef,
+ CardanoVerifyingKey: cardanoRef,
+ CardanoVKHex: cardanoHexRef,
+ CardanoVKFormat: cardanoFormatRef,
+ CoordinatorID: loaded.definition.Coordinator.ID,
+ CoordinatorKeyID: loaded.definition.Coordinator.KeyID,
+ PreparedAt: options.PreparedAt.Format(time.RFC3339Nano),
+ }
+ metadataPath := filepath.Join(stagingDir, PreliminaryMetadataFile)
+ signaturePath := filepath.Join(stagingDir, PreliminarySignatureFile)
+ if err := writeSignedRecordNoReplace(
+ metadataPath,
+ signaturePath,
+ metadata,
+ loaded.definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return nil, err
+ }
+ checksumsPath := filepath.Join(stagingDir, PreliminaryChecksumsFile)
+ names := []string{
+ prover.DestinationConstraintSystemFile,
+ NativeProvingKeyFile,
+ NativeVerifyingKeyFile,
+ CardanoVKBytesFile,
+ CardanoVKHexFile,
+ CardanoVKFormatFile,
+ PreliminaryMetadataFile,
+ PreliminarySignatureFile,
+ }
+ if err := writeChecksumsNoReplace(stagingDir, checksumsPath, names); err != nil {
+ return nil, err
+ }
+ if err := syncDirectory(stagingDir); err != nil {
+ return nil, err
+ }
+ if err := publishReleaseDirectory(stagingDir, options.OutDir); err != nil {
+ return nil, err
+ }
+ cleanup = false
+ return &PrepareFinalizationResult{
+ OutDir: options.OutDir,
+ MetadataPath: filepath.Join(options.OutDir, PreliminaryMetadataFile),
+ SignaturePath: filepath.Join(options.OutDir, PreliminarySignatureFile),
+ ProvingKeyPath: filepath.Join(options.OutDir, NativeProvingKeyFile),
+ VerifyingKeyPath: filepath.Join(options.OutDir, NativeVerifyingKeyFile),
+ CardanoVKPath: filepath.Join(options.OutDir, CardanoVKBytesFile),
+ ChecksumsPath: filepath.Join(options.OutDir, PreliminaryChecksumsFile),
+ }, nil
+}
+
+// VerifyPreliminaryFinalKeys authenticates the non-release key tree using the
+// caller's out-of-band coordinator trust key and checks every exact artifact.
+func VerifyPreliminaryFinalKeys(
+ dir string,
+ coordinatorPublicKeyHex string,
+) (PreliminaryFinalKeys, error) {
+ publicKey, err := keybundle.DecodePublicKeyHex(coordinatorPublicKeyHex)
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ recordBytes, err := readRegularFile(filepath.Join(dir, PreliminaryMetadataFile))
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ signatureBytes, err := readRegularFile(filepath.Join(dir, PreliminarySignatureFile))
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ var metadata PreliminaryFinalKeys
+ if err := UnmarshalCanonical(recordBytes, &metadata); err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ if err := VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ &metadata,
+ metadata.CoordinatorKeyID,
+ publicKey,
+ ); err != nil {
+ return PreliminaryFinalKeys{}, fmt.Errorf("preliminary key signature: %w", err)
+ }
+ refs := []ArtifactRef{
+ metadata.ConstraintSystem,
+ metadata.ProvingKey,
+ metadata.VerifyingKey,
+ metadata.CardanoVerifyingKey,
+ metadata.CardanoVKHex,
+ metadata.CardanoVKFormat,
+ }
+ for _, ref := range refs {
+ actual, err := artifactRefForFile(ref.Name, filepath.Join(dir, ref.Name))
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ if actual != ref {
+ return PreliminaryFinalKeys{}, fmt.Errorf("preliminary artifact %q digest mismatch", ref.Name)
+ }
+ }
+ names := []string{
+ prover.DestinationConstraintSystemFile,
+ NativeProvingKeyFile,
+ NativeVerifyingKeyFile,
+ CardanoVKBytesFile,
+ CardanoVKHexFile,
+ CardanoVKFormatFile,
+ PreliminaryMetadataFile,
+ PreliminarySignatureFile,
+ }
+ if err := verifyChecksumsExact(
+ dir,
+ filepath.Join(dir, PreliminaryChecksumsFile),
+ names,
+ ); err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ expectedEntries := make(map[string]struct{}, len(names)+1)
+ for _, name := range names {
+ expectedEntries[name] = struct{}{}
+ }
+ expectedEntries[PreliminaryChecksumsFile] = struct{}{}
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ for _, entry := range entries {
+ if _, ok := expectedEntries[entry.Name()]; !ok {
+ return PreliminaryFinalKeys{}, fmt.Errorf("unexpected preliminary key-tree entry %q", entry.Name())
+ }
+ delete(expectedEntries, entry.Name())
+ info, err := entry.Info()
+ if err != nil {
+ return PreliminaryFinalKeys{}, err
+ }
+ if !info.Mode().IsRegular() {
+ return PreliminaryFinalKeys{}, fmt.Errorf("preliminary key-tree entry %q is not a regular file", entry.Name())
+ }
+ }
+ if len(expectedEntries) != 0 {
+ return PreliminaryFinalKeys{}, errors.New("preliminary key tree is incomplete")
+ }
+ return metadata, nil
+}
+
+// Finalize replays every accepted contribution, applies both committed
+// beacons, writes native gnark artifacts into a fresh directory, reloads them,
+// runs real repository-backed proof evidence, and coordinator-signs an
+// unsigned release candidate. It never creates a release signing key or a
+// release manifest.
+func Finalize(options FinalizeOptions) (*FinalizeResult, error) {
+ if options.Circuit == nil || options.Circuit.R1CS == nil {
+ return nil, errors.New("compiled destination-v2 circuit is required")
+ }
+ if options.FinalizedAt.IsZero() {
+ return nil, errors.New("finalized_at is required")
+ }
+ if options.FinalizedAt.Location() != time.UTC {
+ return nil, errors.New("finalized_at must use UTC")
+ }
+ if strings.TrimSpace(options.PublicEvidencePath) == "" {
+ return nil, errors.New("public finalization evidence path is required")
+ }
+ if _, err := readRegularFile(options.PublicEvidencePath); err != nil {
+ return nil, fmt.Errorf("public finalization evidence preflight: %w", err)
+ }
+ loaded, err := loadReplay(options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyRunningSoftwareForMode(loaded.definition.Software, loaded.definition.Mode); err != nil {
+ return nil, fmt.Errorf("running finalizer software: %w", err)
+ }
+ if err := ValidateCircuitBinding(options.Circuit, loaded.definition.Circuit); err != nil {
+ return nil, err
+ }
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(options.CoordinatorSigningKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireIdentityKey(loaded.definition.Coordinator, publicKey); err != nil {
+ return nil, fmt.Errorf("coordinator signing key: %w", err)
+ }
+ replayed, err := replayAll(options.Circuit, loaded, options.Replay)
+ if err != nil {
+ return nil, err
+ }
+ stagingDir, err := createRecoveryStagingDir(options.OutDir)
+ if err != nil {
+ return nil, err
+ }
+
+ cleanup := true
+ defer func() {
+ // The authoritative destination is published only after the complete
+ // candidate has been synced. A handled failure removes only this
+ // invocation's unpublished staging directory.
+ if cleanup {
+ _ = os.RemoveAll(stagingDir)
+ _ = syncDirectory(filepath.Dir(stagingDir))
+ }
+ }()
+
+ ccsPath := filepath.Join(stagingDir, prover.DestinationConstraintSystemFile)
+ ccsRef, err := writeR1CSNoReplace(ccsPath, options.Circuit)
+ if err != nil {
+ return nil, err
+ }
+ pkPath := filepath.Join(stagingDir, NativeProvingKeyFile)
+ if err := saveNativeNoReplace(pkPath, func(path string) error {
+ return prover.SavePK(replayed.pk, path)
+ }); err != nil {
+ return nil, err
+ }
+ vkPath := filepath.Join(stagingDir, NativeVerifyingKeyFile)
+ if err := saveNativeNoReplace(vkPath, func(path string) error {
+ return prover.SaveVK(replayed.vk, path)
+ }); err != nil {
+ return nil, err
+ }
+ if _, err := prover.LoadPK(pkPath); err != nil {
+ return nil, fmt.Errorf("reload finalized proving key: %w", err)
+ }
+ vk, err := prover.LoadVK(vkPath)
+ if err != nil {
+ return nil, fmt.Errorf("reload finalized verifying key: %w", err)
+ }
+ pkRef, err := artifactRefForFile(NativeProvingKeyFile, pkPath)
+ if err != nil {
+ return nil, err
+ }
+ vkRef, err := artifactRefForFile(NativeVerifyingKeyFile, vkPath)
+ if err != nil {
+ return nil, err
+ }
+ phase2Seal, err := NewSealRecord(SealRecord{
+ Schema: SealRecordSchema,
+ CeremonyID: loaded.definition.CeremonyID,
+ Phase: Phase2,
+ PhaseID: loaded.phase2Chain.PhaseID,
+ CloseID: loaded.phase2Close.CloseID,
+ BeaconID: loaded.phase2Beacon.BeaconID,
+ FinalPayload: loaded.phase2Close.FinalPayload,
+ Outputs: []ArtifactRef{pkRef, vkRef},
+ SealedAt: options.FinalizedAt.Format(time.RFC3339Nano),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create phase2 seal: %w", err)
+ }
+ if err := ValidateSeal(loaded.phase2Close, loaded.phase2Beacon, phase2Seal); err != nil {
+ return nil, fmt.Errorf("validate phase2 seal: %w", err)
+ }
+ phase2SealBytes, err := MarshalCanonical(phase2Seal)
+ if err != nil {
+ return nil, err
+ }
+ phase2SealPath := filepath.Join(stagingDir, Phase2SealFile)
+ if err := writeSignedRecordNoReplace(
+ phase2SealPath,
+ filepath.Join(stagingDir, Phase2SealSignatureFile),
+ phase2Seal,
+ loaded.definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return nil, err
+ }
+ phase2SealRef := ArtifactRef{Name: Phase2SealFile, Digest: NewDigest(phase2SealBytes)}
+ loaded.phase2Seal = phase2Seal
+
+ cardanoRaw, cardanoFormat, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ return nil, err
+ }
+ if cardanoFormat != expectedCardanoBSB22 || len(cardanoRaw) != prover.CardanoVKCommitmentLen {
+ return nil, fmt.Errorf(
+ "cardano verifying key is format %q and %d bytes, want %q and %d bytes",
+ cardanoFormat, len(cardanoRaw), expectedCardanoBSB22, prover.CardanoVKCommitmentLen,
+ )
+ }
+ cardanoRawPath := filepath.Join(stagingDir, CardanoVKBytesFile)
+ if err := writeBytesNoReplace(cardanoRawPath, cardanoRaw, 0o600); err != nil {
+ return nil, err
+ }
+ cardanoHexPath := filepath.Join(stagingDir, CardanoVKHexFile)
+ if err := writeBytesNoReplace(cardanoHexPath, []byte(hex.EncodeToString(cardanoRaw)+"\n"), 0o600); err != nil {
+ return nil, err
+ }
+ cardanoFormatPath := filepath.Join(stagingDir, CardanoVKFormatFile)
+ if err := writeBytesNoReplace(cardanoFormatPath, []byte(cardanoFormat+"\n"), 0o600); err != nil {
+ return nil, err
+ }
+ cardanoRawRef, err := artifactRefForFile(CardanoVKBytesFile, cardanoRawPath)
+ if err != nil {
+ return nil, err
+ }
+ cardanoHexRef, err := artifactRefForFile(CardanoVKHexFile, cardanoHexPath)
+ if err != nil {
+ return nil, err
+ }
+ cardanoFormatRef, err := artifactRefForFile(CardanoVKFormatFile, cardanoFormatPath)
+ if err != nil {
+ return nil, err
+ }
+
+ publicEvidence, publicEvidenceBytes, verification, err := loadAndVerifyPublicEvidence(
+ options.PublicEvidencePath,
+ loaded.definition.CeremonyID,
+ vk,
+ cardanoRaw,
+ cardanoRawRef,
+ )
+ if err != nil {
+ return nil, err
+ }
+ publicEvidencePath := filepath.Join(stagingDir, PublicEvidenceFile)
+ if err := writeBytesNoReplace(publicEvidencePath, publicEvidenceBytes, 0o600); err != nil {
+ return nil, err
+ }
+ publicEvidenceRef := ArtifactRef{Name: PublicEvidenceFile, Digest: NewDigest(publicEvidenceBytes)}
+ report := VerificationReport{
+ Schema: VerificationReportSchema,
+ CeremonyID: loaded.definition.CeremonyID,
+ Fixture: publicEvidence.Fixture,
+ NativeProofVerified: verification.NativeProofVerified,
+ WrongCredentialRejected: verification.WrongCredentialRejected,
+ WrongDestinationRejected: verification.WrongDestinationRejected,
+ WrongDigestRejected: verification.WrongDigestRejected,
+ WrongProofRejected: verification.WrongProofRejected,
+ WrongVKRejected: verification.WrongVKRejected,
+ ProofTruncationRejected: verification.ProofTruncationRejected,
+ ProofAppendRejected: verification.ProofAppendRejected,
+ CardanoProofFormat: publicEvidence.CardanoProofFormat,
+ CardanoProofBytes: prover.CardanoProofCommitmentLen,
+ CardanoProofRawDigest: publicEvidence.CardanoProofRawDigest,
+ CardanoVKFormat: cardanoFormat,
+ CardanoVKBytes: len(cardanoRaw),
+ CardanoVKRawDigest: NewDigest(cardanoRaw),
+ PublicEvidence: publicEvidenceRef,
+ CheckedAt: options.FinalizedAt.Format(time.RFC3339Nano),
+ }
+ if report.PublicEvidence != publicEvidenceRef {
+ return nil, errors.New("verification report does not hash-bind the exact public evidence artifact")
+ }
+ reportBytes, err := MarshalCanonical(report)
+ if err != nil {
+ return nil, err
+ }
+ reportPath := filepath.Join(stagingDir, VerificationReportFile)
+ if err := writeBytesNoReplace(reportPath, reportBytes, 0o600); err != nil {
+ return nil, err
+ }
+ reportRef := ArtifactRef{Name: VerificationReportFile, Digest: NewDigest(reportBytes)}
+
+ phase1Summary, err := phaseSummary(loaded.phase1Chain, loaded.phase1ChainRef, loaded.phase1Close, loaded.phase1Beacon, loaded.phase1Seal)
+ if err != nil {
+ return nil, err
+ }
+ phase2Summary, err := phaseSummary(loaded.phase2Chain, loaded.phase2ChainRef, loaded.phase2Close, loaded.phase2Beacon, loaded.phase2Seal)
+ if err != nil {
+ return nil, err
+ }
+ candidate, err := NewCandidateMetadata(CandidateMetadata{
+ Schema: CandidateMetadataSchema,
+ CeremonyID: loaded.definition.CeremonyID,
+ Definition: loaded.definitionRef,
+ Circuit: loaded.definition.Circuit,
+ Phase1: phase1Summary,
+ Phase2: phase2Summary,
+ ConstraintSystem: ccsRef,
+ ProvingKey: pkRef,
+ VerifyingKey: vkRef,
+ CardanoVerifyingKey: cardanoRawRef,
+ CardanoVKHex: cardanoHexRef,
+ CardanoVKFormat: cardanoFormatRef,
+ VerificationReport: reportRef,
+ PublicEvidence: publicEvidenceRef,
+ Phase2SealRecord: phase2SealRef,
+ CoordinatorID: loaded.definition.Coordinator.ID,
+ CoordinatorKeyID: loaded.definition.Coordinator.KeyID,
+ FinalizedAt: options.FinalizedAt.Format(time.RFC3339Nano),
+ })
+ if err != nil {
+ return nil, err
+ }
+ candidatePath := filepath.Join(stagingDir, CandidateMetadataFile)
+ signaturePath := filepath.Join(stagingDir, CandidateSignatureFile)
+ if err := writeSignedRecordNoReplace(
+ candidatePath,
+ signaturePath,
+ candidate,
+ loaded.definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return nil, err
+ }
+ checksumsPath := filepath.Join(stagingDir, CandidateChecksumsFile)
+ if err := writeChecksumsNoReplace(stagingDir, checksumsPath, candidateChecksumNames()); err != nil {
+ return nil, err
+ }
+ if err := syncDirectory(stagingDir); err != nil {
+ return nil, err
+ }
+ if err := publishReleaseDirectory(stagingDir, options.OutDir); err != nil {
+ return nil, fmt.Errorf("atomically publish finalized candidate directory: %w", err)
+ }
+ cleanup = false
+ return &FinalizeResult{
+ CeremonyID: loaded.definition.CeremonyID,
+ OutDir: options.OutDir,
+ Candidate: candidate,
+ CandidatePath: filepath.Join(options.OutDir, CandidateMetadataFile),
+ CandidateSigPath: filepath.Join(options.OutDir, CandidateSignatureFile),
+ VerificationPath: filepath.Join(options.OutDir, VerificationReportFile),
+ ProvingKeyPath: filepath.Join(options.OutDir, NativeProvingKeyFile),
+ VerifyingKeyPath: filepath.Join(options.OutDir, NativeVerifyingKeyFile),
+ ConstraintSystem: filepath.Join(options.OutDir, prover.DestinationConstraintSystemFile),
+ CardanoVKPath: filepath.Join(options.OutDir, CardanoVKBytesFile),
+ CandidateChecksum: filepath.Join(options.OutDir, CandidateChecksumsFile),
+ }, nil
+}
+
+func loadReplay(paths ReplayPaths) (loadedReplay, error) {
+ var result loadedReplay
+ var err error
+ coordinatorPublicKey, err := keybundle.DecodePublicKeyHex(paths.CoordinatorPublicKeyHex)
+ if err != nil {
+ return result, fmt.Errorf("trusted coordinator public key: %w", err)
+ }
+ if result.definitionRef, err = readTrustedDefinition(
+ paths.DefinitionPath,
+ paths.DefinitionSignaturePath,
+ coordinatorPublicKey,
+ &result.definition,
+ ); err != nil {
+ return result, fmt.Errorf("ceremony definition: %w", err)
+ }
+ if err := requireIdentityKey(result.definition.Coordinator, coordinatorPublicKey); err != nil {
+ return result, fmt.Errorf("trusted coordinator key does not match definition: %w", err)
+ }
+ coordinator := result.definition.Coordinator
+ if result.phase1ChainRef, err = readSignedCanonicalFile(
+ paths.Phase1ChainPath,
+ paths.Phase1ChainSignaturePath,
+ &result.phase1Chain,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 chain: %w", err)
+ }
+ if _, err = readSignedCanonicalFile(
+ paths.Phase1ClosePath,
+ paths.Phase1CloseSignaturePath,
+ &result.phase1Close,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 close: %w", err)
+ }
+ if _, err = readSignedCanonicalFile(
+ paths.Phase1BeaconPath,
+ paths.Phase1BeaconSignaturePath,
+ &result.phase1Beacon,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 beacon: %w", err)
+ }
+ if err := validateBeaconRawResponse(
+ result.definition,
+ paths.TranscriptRoot,
+ result.phase1Beacon,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 beacon raw response: %w", err)
+ }
+ if _, err = readSignedCanonicalFile(
+ paths.Phase1SealPath,
+ paths.Phase1SealSignaturePath,
+ &result.phase1Seal,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 seal: %w", err)
+ }
+ if result.phase2ChainRef, err = readSignedCanonicalFile(
+ paths.Phase2ChainPath,
+ paths.Phase2ChainSignaturePath,
+ &result.phase2Chain,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase2 chain: %w", err)
+ }
+ if _, err = readSignedCanonicalFile(
+ paths.Phase2ClosePath,
+ paths.Phase2CloseSignaturePath,
+ &result.phase2Close,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase2 close: %w", err)
+ }
+ if _, err = readSignedCanonicalFile(
+ paths.Phase2BeaconPath,
+ paths.Phase2BeaconSignaturePath,
+ &result.phase2Beacon,
+ coordinator,
+ ); err != nil {
+ return result, fmt.Errorf("phase2 beacon: %w", err)
+ }
+ if err := validateBeaconRawResponse(
+ result.definition,
+ paths.TranscriptRoot,
+ result.phase2Beacon,
+ ); err != nil {
+ return result, fmt.Errorf("phase2 beacon raw response: %w", err)
+ }
+ if err := validateReplayRecords(result); err != nil {
+ return result, err
+ }
+ if err := validateContributionEvidence(
+ paths.TranscriptRoot,
+ result.definition,
+ result.phase1Chain,
+ ); err != nil {
+ return result, fmt.Errorf("phase1 contribution evidence: %w", err)
+ }
+ if err := validateContributionEvidence(
+ paths.TranscriptRoot,
+ result.definition,
+ result.phase2Chain,
+ ); err != nil {
+ return result, fmt.Errorf("phase2 contribution evidence: %w", err)
+ }
+ return result, nil
+}
+
+func validateReplayRecords(replay loadedReplay) error {
+ if err := replay.phase1Chain.ValidateAgainstDefinition(replay.definition); err != nil {
+ return fmt.Errorf("phase1 chain: %w", err)
+ }
+ if replay.phase1Chain.Phase != Phase1 ||
+ replay.phase1Chain.Genesis != replay.definition.Phase1Genesis {
+ return errors.New("phase1 chain does not start at signed definition genesis")
+ }
+ phase1ID, err := ComputePhaseID(replay.definition.CeremonyID, Phase1, replay.definition.Phase1Genesis, "")
+ if err != nil || replay.phase1Chain.PhaseID != phase1ID {
+ return errors.New("phase1 chain phase identity mismatch")
+ }
+ if err := ValidateClose(replay.definition, replay.phase1Chain, replay.phase1Close); err != nil {
+ return fmt.Errorf("phase1 close: %w", err)
+ }
+ if err := ValidateBeacon(replay.definition, replay.phase1Close, replay.phase1Beacon); err != nil {
+ return fmt.Errorf("phase1 beacon: %w", err)
+ }
+ if err := ValidateSeal(replay.phase1Close, replay.phase1Beacon, replay.phase1Seal); err != nil {
+ return fmt.Errorf("phase1 seal: %w", err)
+ }
+ if err := replay.phase2Chain.ValidateAgainstDefinition(replay.definition); err != nil {
+ return fmt.Errorf("phase2 chain: %w", err)
+ }
+ if replay.phase2Chain.Phase != Phase2 {
+ return errors.New("phase2 chain has wrong phase")
+ }
+ phase2ID, err := ComputePhaseID(
+ replay.definition.CeremonyID,
+ Phase2,
+ replay.phase2Chain.Genesis,
+ replay.phase1Seal.SealID,
+ )
+ if err != nil || replay.phase2Chain.PhaseID != phase2ID {
+ return errors.New("phase2 chain phase identity mismatch")
+ }
+ if err := ValidateClose(replay.definition, replay.phase2Chain, replay.phase2Close); err != nil {
+ return fmt.Errorf("phase2 close: %w", err)
+ }
+ if err := ValidateBeacon(replay.definition, replay.phase2Close, replay.phase2Beacon); err != nil {
+ return fmt.Errorf("phase2 beacon: %w", err)
+ }
+ if replay.phase1Beacon.ChallengeSHA256 == replay.phase2Beacon.ChallengeSHA256 ||
+ (replay.phase1Beacon.Provider == replay.phase2Beacon.Provider &&
+ replay.phase1Beacon.Network == replay.phase2Beacon.Network &&
+ replay.phase1Beacon.Round == replay.phase2Beacon.Round) {
+ return errors.New("phase1 and phase2 must use distinct beacon challenges and rounds")
+ }
+ return nil
+}
+
+func replayAll(circuit *CompiledCircuit, records loadedReplay, paths ReplayPaths) (replayedKeys, error) {
+ genesis := gnarkmpc.NewPhase1(circuit.Binding.DomainSize)
+ genesisDigest, err := writerDigest(genesis)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("digest deterministic phase1 genesis: %w", err)
+ }
+ if genesisDigest != records.definition.Phase1Genesis.Digest {
+ return replayedKeys{}, errors.New("deterministic phase1 genesis does not match signed definition")
+ }
+ phase1GenesisPath, err := resolveArtifactPath(
+ paths.TranscriptRoot,
+ records.definition.Phase1Genesis.Name,
+ )
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("resolve phase1 genesis: %w", err)
+ }
+ _, archivedGenesisDigest, err := ReadPhase1File(
+ phase1GenesisPath,
+ Phase1Shape{DomainN: circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("read archived phase1 genesis: %w", err)
+ }
+ if artifactDigest(archivedGenesisDigest) != genesisDigest {
+ return replayedKeys{}, errors.New("archived phase1 genesis differs from deterministic genesis")
+ }
+ phase1Shape := Phase1Shape{DomainN: circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize}
+ phase1Loader := func(index int) (*gnarkmpc.Phase1, error) {
+ expected := records.phase1Chain.Records[index].OutputPayload
+ path, err := resolveArtifactPath(paths.TranscriptRoot, expected.Name)
+ if err != nil {
+ return nil, err
+ }
+ value, digest, err := ReadPhase1File(path, phase1Shape)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireArchivedArtifact(
+ paths.TranscriptRoot,
+ path,
+ artifactDigest(digest),
+ expected,
+ ); err != nil {
+ return nil, err
+ }
+ return value, nil
+ }
+ phase1Challenge, err := exactBeaconChallenge(records.phase1Beacon)
+ if err != nil {
+ return replayedKeys{}, err
+ }
+ commons, err := SealPhase1Loaded(circuit.Binding.DomainSize, phase1Challenge, len(records.phase1Chain.Records), phase1Loader)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("full phase1 replay: %w", err)
+ }
+ commonsDigest, err := writerDigest(commons)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("digest phase1 commons: %w", err)
+ }
+ commonsRef, err := sealOutputByDigest(records.phase1Seal, commonsDigest)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("phase1 seal output: %w", err)
+ }
+ phase1CommonsPath, err := resolveArtifactPath(paths.TranscriptRoot, commonsRef.Name)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("resolve phase1 commons: %w", err)
+ }
+ _, archivedCommonsDigest, err := ReadCommonsFile(
+ phase1CommonsPath,
+ CommonsShape{DomainN: circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("read archived phase1 commons: %w", err)
+ }
+ if artifactDigest(archivedCommonsDigest) != commonsDigest {
+ return replayedKeys{}, errors.New("archived phase1 commons differs from full replay")
+ }
+
+ initialPhase2, _, err := InitializePhase2(circuit, commons)
+ if err != nil {
+ return replayedKeys{}, err
+ }
+ initialDigest, err := writerDigest(initialPhase2)
+ if err != nil {
+ return replayedKeys{}, err
+ }
+ if initialDigest != records.phase2Chain.Genesis.Digest {
+ return replayedKeys{}, errors.New("deterministic phase2 genesis does not match signed chain")
+ }
+ phase2GenesisPath, err := resolveArtifactPath(
+ paths.TranscriptRoot,
+ records.phase2Chain.Genesis.Name,
+ )
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("resolve phase2 genesis: %w", err)
+ }
+ _, archivedPhase2GenesisDigest, err := ReadPhase2File(
+ phase2GenesisPath,
+ circuit.Binding.Phase2Shape,
+ )
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("read archived phase2 genesis: %w", err)
+ }
+ if artifactDigest(archivedPhase2GenesisDigest) != initialDigest {
+ return replayedKeys{}, errors.New("archived phase2 genesis differs from deterministic genesis")
+ }
+ phase2Shape := circuit.Binding.Phase2Shape
+ phase2Shape.ChallengeLength = contributionChallengeSize
+ phase2Loader := func(index int) (*gnarkmpc.Phase2, error) {
+ expected := records.phase2Chain.Records[index].OutputPayload
+ path, err := resolveArtifactPath(paths.TranscriptRoot, expected.Name)
+ if err != nil {
+ return nil, err
+ }
+ value, digest, err := ReadPhase2File(path, phase2Shape)
+ if err != nil {
+ return nil, err
+ }
+ if err := requireArchivedArtifact(
+ paths.TranscriptRoot,
+ path,
+ artifactDigest(digest),
+ expected,
+ ); err != nil {
+ return nil, err
+ }
+ return value, nil
+ }
+ phase2Challenge, err := exactBeaconChallenge(records.phase2Beacon)
+ if err != nil {
+ return replayedKeys{}, err
+ }
+ pk, vk, err := SealPhase2Loaded(circuit, commons, phase2Challenge, len(records.phase2Chain.Records), phase2Loader)
+ if err != nil {
+ return replayedKeys{}, fmt.Errorf("full phase2 replay: %w", err)
+ }
+ return replayedKeys{commons: commons, pk: pk, vk: vk}, nil
+}
+
+func loadAndVerifyPublicEvidence(
+ path string,
+ ceremonyID string,
+ vk groth16.VerifyingKey,
+ cardanoVK []byte,
+ cardanoVKRef ArtifactRef,
+) (PublicFinalizationEvidence, []byte, publicEvidenceVerification, error) {
+ var verification publicEvidenceVerification
+ if strings.TrimSpace(path) == "" {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public finalization evidence path is required")
+ }
+ data, err := readRegularFile(path)
+ if err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, err
+ }
+ var evidence PublicFinalizationEvidence
+ if err := UnmarshalCanonical(data, &evidence); err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, fmt.Errorf("public finalization evidence: %w", err)
+ }
+ if evidence.CeremonyID != ceremonyID {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public finalization evidence ceremony id differs from replay")
+ }
+ if cardanoVKRef.Name != CardanoVKBytesFile || cardanoVKRef.Digest != NewDigest(cardanoVK) {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public evidence Cardano key reference differs from exact serialized key")
+ }
+ if evidence.CardanoVerifyingKey != cardanoVKRef {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public finalization evidence binds a different Cardano verifying key")
+ }
+ proofBytes, err := hex.DecodeString(evidence.CardanoProofHex)
+ if err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, err
+ }
+ proof, err := parseCardanoProof(proofBytes)
+ if err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, fmt.Errorf("public evidence Cardano proof: %w", err)
+ }
+ digest, _ := hex.DecodeString(evidence.PublicInputDigestHex)
+ if err := verifyProofForDigest(vk, proof, digest); err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, fmt.Errorf("native verification of public evidence: %w", err)
+ }
+ verification.NativeProofVerified = true
+ credential, _ := hex.DecodeString(evidence.CredentialHex)
+ destination, _ := hex.DecodeString(evidence.DestinationHex)
+ changedDestination := bytes.Clone(destination)
+ changedDestination[0] ^= 1
+ wrongDigest := publicInputDigest(credential, changedDestination)
+ if err := verifyProofForDigest(vk, proof, wrongDigest); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public evidence proof accepted a changed destination")
+ }
+ verification.WrongDestinationRejected = true
+ changedCredential := bytes.Clone(credential)
+ changedCredential[0] ^= 1
+ wrongCredentialDigest := publicInputDigest(changedCredential, destination)
+ if err := verifyProofForDigest(vk, proof, wrongCredentialDigest); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public evidence proof accepted a changed credential")
+ }
+ verification.WrongCredentialRejected = true
+ changedDigest := bytes.Clone(digest)
+ changedDigest[0] ^= 1
+ if err := verifyProofForDigest(vk, proof, changedDigest); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public evidence proof accepted a changed public-input digest")
+ }
+ verification.WrongDigestRejected = true
+ changedProofBytes := bytes.Clone(proofBytes)
+ changedProofBytes[0] ^= 1
+ if changedProof, parseErr := parseCardanoProof(changedProofBytes); parseErr == nil {
+ if err := verifyProofForDigest(vk, changedProof, digest); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("changed public evidence proof was accepted")
+ }
+ }
+ verification.WrongProofRejected = true
+ wrongVK, err := cloneWrongVerifyingKey(vk)
+ if err != nil {
+ return PublicFinalizationEvidence{}, nil, verification, err
+ }
+ if err := verifyProofForDigest(wrongVK, proof, digest); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("public evidence proof accepted a changed verifying key")
+ }
+ verification.WrongVKRejected = true
+ if _, err := parseCardanoProof(proofBytes[:len(proofBytes)-1]); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("truncated public evidence proof was accepted")
+ }
+ verification.ProofTruncationRejected = true
+ appendedProof := append(bytes.Clone(proofBytes), 0)
+ if _, err := parseCardanoProof(appendedProof); err == nil {
+ return PublicFinalizationEvidence{}, nil, verification, errors.New("appended public evidence proof was accepted")
+ }
+ verification.ProofAppendRejected = true
+ return evidence, data, verification, nil
+}
+
+func publicInputDigest(credential, destination []byte) []byte {
+ preimage := make([]byte, 0, len(DestinationPublicDomain)+len(credential)+len(destination))
+ preimage = append(preimage, DestinationPublicDomain...)
+ preimage = append(preimage, credential...)
+ preimage = append(preimage, destination...)
+ digest := blake2b.Sum256(preimage)
+ return digest[:]
+}
+
+func publicInputScalar(digest []byte) *big.Int {
+ reversed := bytes.Clone(digest)
+ for left, right := 0, len(reversed)-1; left < right; left, right = left+1, right-1 {
+ reversed[left], reversed[right] = reversed[right], reversed[left]
+ }
+ value := new(big.Int).SetBytes(reversed)
+ return value.Mod(value, ecc.BLS12_381.ScalarField())
+}
+
+func parseCardanoProof(data []byte) (*groth16bls12381.Proof, error) {
+ if len(data) != prover.CardanoProofCommitmentLen {
+ return nil, fmt.Errorf("proof is %d bytes, want %d", len(data), prover.CardanoProofCommitmentLen)
+ }
+ proof := new(groth16bls12381.Proof)
+ if _, err := proof.Ar.SetBytes(data[:48]); err != nil {
+ return nil, fmt.Errorf("proof A: %w", err)
+ }
+ if _, err := proof.Bs.SetBytes(data[48:144]); err != nil {
+ return nil, fmt.Errorf("proof B: %w", err)
+ }
+ if _, err := proof.Krs.SetBytes(data[144:192]); err != nil {
+ return nil, fmt.Errorf("proof C: %w", err)
+ }
+ proof.Commitments = make([]bls12381.G1Affine, 1)
+ if _, err := proof.Commitments[0].SetBytes(data[prover.CmtOff:prover.PokOff]); err != nil {
+ return nil, fmt.Errorf("commitment: %w", err)
+ }
+ if _, err := proof.CommitmentPok.SetBytes(data[prover.PokOff:]); err != nil {
+ return nil, fmt.Errorf("commitment proof: %w", err)
+ }
+ return proof, nil
+}
+
+func verifyProofForDigest(vk groth16.VerifyingKey, proof groth16.Proof, digest []byte) error {
+ if len(digest) != blake2b.Size256 {
+ return errors.New("public input digest must be exactly 32 bytes")
+ }
+ publicWitness, err := witness.New(ecc.BLS12_381.ScalarField())
+ if err != nil {
+ return err
+ }
+ values := make(chan any, 1)
+ values <- publicInputScalar(digest)
+ close(values)
+ if err := publicWitness.Fill(1, 0, values); err != nil {
+ return err
+ }
+ return groth16.Verify(proof, vk, publicWitness)
+}
+
+func cloneWrongVerifyingKey(vk groth16.VerifyingKey) (groth16.VerifyingKey, error) {
+ var encoded bytes.Buffer
+ if _, err := vk.WriteTo(&encoded); err != nil {
+ return nil, fmt.Errorf("serialize verifying key for negative check: %w", err)
+ }
+ wrong := groth16.NewVerifyingKey(ecc.BLS12_381)
+ if _, err := wrong.ReadFrom(bytes.NewReader(encoded.Bytes())); err != nil {
+ return nil, fmt.Errorf("reload verifying key for negative check: %w", err)
+ }
+ concrete, ok := wrong.(*groth16bls12381.VerifyingKey)
+ if !ok {
+ return nil, fmt.Errorf("unexpected verifying key type %T", wrong)
+ }
+ if len(concrete.G1.K) == 0 {
+ return nil, errors.New("verifying key has no public-input basis")
+ }
+ // G1.K is consumed directly by gnark's verifier. Mutating Alpha after
+ // ReadFrom is not a valid negative test: the verifier uses the precomputed
+ // pairing E, so a stale Alpha field can change serialized/Cardano bytes
+ // without changing verification behavior.
+ original := concrete.G1.K[0]
+ if original.IsInfinity() {
+ return nil, errors.New("verifying key has an unusable identity public-input basis")
+ }
+ concrete.G1.K[0].Neg(&original)
+ if concrete.G1.K[0].Equal(&original) {
+ return nil, errors.New("failed to construct a distinct wrong verifying key")
+ }
+ return wrong, nil
+}
+
+func phaseSummary(chain Chain, chainRef ArtifactRef, close CloseRecord, beacon BeaconRecord, seal SealRecord) (PhaseSummary, error) {
+ head, err := chain.HeadRecordID()
+ if err != nil {
+ return PhaseSummary{}, err
+ }
+ participants, err := chain.ParticipantIDs()
+ if err != nil {
+ return PhaseSummary{}, err
+ }
+ summary := PhaseSummary{
+ Phase: chain.Phase,
+ PhaseID: chain.PhaseID,
+ Genesis: chain.Genesis,
+ Chain: chainRef,
+ ChainHeadID: head,
+ ContributionCount: uint8(len(chain.Records)),
+ Participants: participants,
+ CloseID: close.CloseID,
+ BeaconID: beacon.BeaconID,
+ SealID: seal.SealID,
+ Outputs: append([]ArtifactRef(nil), seal.Outputs...),
+ }
+ return summary, summary.Validate()
+}
+
+func exactBeaconChallenge(record BeaconRecord) ([]byte, error) {
+ challenge, err := hex.DecodeString(record.ChallengeHex)
+ if err != nil {
+ return nil, err
+ }
+ if len(challenge) != contributionChallengeSize {
+ return nil, fmt.Errorf("beacon challenge is %d bytes, want exactly %d", len(challenge), contributionChallengeSize)
+ }
+ return challenge, nil
+}
+
+func artifactDigest(value ArtifactDigest) Digest {
+ return Digest{
+ SHA256: "sha256:" + hex.EncodeToString(value.SHA256[:]),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(value.BLAKE2b256[:]),
+ Size: value.Size,
+ }
+}
+
+func writerDigest(value io.WriterTo) (Digest, error) {
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return Digest{}, err
+ }
+ n, err := writeToWithPanicBoundary(
+ "native key digest encoder",
+ value,
+ io.MultiWriter(sha, blake),
+ )
+ if err != nil {
+ return Digest{}, err
+ }
+ return Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake.Sum(nil)),
+ Size: n,
+ }, nil
+}
+
+func requireArchivedArtifact(root, path string, digest Digest, expected ArtifactRef) error {
+ if err := requireArtifactPath(root, path, expected); err != nil {
+ return err
+ }
+ if digest != expected.Digest {
+ return fmt.Errorf("artifact %q digest does not match accepted chain", expected.Name)
+ }
+ return nil
+}
+
+func requireArtifactPath(root, path string, expected ArtifactRef) error {
+ expectedPath, err := resolveArtifactPath(root, expected.Name)
+ if err != nil {
+ return err
+ }
+ if filepath.Clean(path) != filepath.Clean(expectedPath) {
+ return fmt.Errorf("artifact path %q, want explicit signed path %q", path, expectedPath)
+ }
+ return nil
+}
+
+func sealOutputByDigest(seal SealRecord, expected Digest) (ArtifactRef, error) {
+ var found ArtifactRef
+ for _, output := range seal.Outputs {
+ if output.Digest == expected {
+ if found.Name != "" {
+ return ArtifactRef{}, errors.New("seal repeats reproduced output digest")
+ }
+ found = output
+ }
+ }
+ if found.Name == "" {
+ return ArtifactRef{}, errors.New("seal does not bind reproduced output digest")
+ }
+ return found, nil
+}
+
+func readCanonicalFile(path string, destination any) (ArtifactRef, error) {
+ if strings.TrimSpace(path) == "" {
+ return ArtifactRef{}, errors.New("canonical artifact path is required")
+ }
+ data, err := readRegularFile(path)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ if err := UnmarshalCanonical(data, destination); err != nil {
+ return ArtifactRef{}, err
+ }
+ ref := ArtifactRef{Name: filepath.Base(path), Digest: NewDigest(data)}
+ return ref, ref.Validate()
+}
+
+func readTrustedDefinition(
+ recordPath string,
+ signaturePath string,
+ trustedPublicKey ed25519.PublicKey,
+ definition *CeremonyDefinition,
+) (ArtifactRef, error) {
+ recordBytes, err := readRegularFile(recordPath)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ signatureBytes, err := readRegularFile(signaturePath)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ var signature DetachedSignature
+ if err := UnmarshalCanonical(signatureBytes, &signature); err != nil {
+ return ArtifactRef{}, fmt.Errorf("definition signature: %w", err)
+ }
+ if err := VerifyExact(recordBytes, signature, signature.KeyID, trustedPublicKey); err != nil {
+ return ArtifactRef{}, fmt.Errorf("definition signature: %w", err)
+ }
+ if err := UnmarshalCanonical(recordBytes, definition); err != nil {
+ return ArtifactRef{}, err
+ }
+ if signature.KeyID != definition.Coordinator.KeyID {
+ return ArtifactRef{}, errors.New("definition signature key id does not match coordinator identity")
+ }
+ ref := ArtifactRef{Name: filepath.Base(recordPath), Digest: NewDigest(recordBytes)}
+ return ref, ref.Validate()
+}
+
+func readSignedCanonicalFile(
+ recordPath string,
+ signaturePath string,
+ destination any,
+ signer Identity,
+) (ArtifactRef, error) {
+ recordBytes, err := readRegularFile(recordPath)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ signatureBytes, err := readRegularFile(signaturePath)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ publicKey, err := keybundle.DecodePublicKeyHex(signer.Ed25519PublicKeyHex)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ if err := VerifySignedRecord(recordBytes, signatureBytes, destination, signer.KeyID, publicKey); err != nil {
+ return ArtifactRef{}, err
+ }
+ ref := ArtifactRef{Name: filepath.Base(recordPath), Digest: NewDigest(recordBytes)}
+ return ref, ref.Validate()
+}
+
+func validateContributionEvidence(
+ root string,
+ definition CeremonyDefinition,
+ chain Chain,
+) error {
+ for index, record := range chain.Records {
+ attestationBytes, err := verifyArtifactBytes(root, record.Attestation, maxSignedRecordBytes)
+ if err != nil {
+ return fmt.Errorf("contribution %d attestation artifact: %w", index+1, err)
+ }
+ signatureBytes, err := verifyArtifactBytes(root, record.AttestationSignature, maxSignedRecordBytes)
+ if err != nil {
+ return fmt.Errorf("contribution %d attestation signature artifact: %w", index+1, err)
+ }
+ erasureBytes, err := verifyArtifactBytes(root, record.Erasure, maxSignedRecordBytes)
+ if err != nil {
+ return fmt.Errorf("contribution %d erasure artifact: %w", index+1, err)
+ }
+ erasureSignatureBytes, err := verifyArtifactBytes(root, record.ErasureSignature, maxSignedRecordBytes)
+ if err != nil {
+ return fmt.Errorf("contribution %d erasure signature artifact: %w", index+1, err)
+ }
+ verificationBytes, err := verifyArtifactBytes(root, record.Verification, maxSignedRecordBytes)
+ if err != nil {
+ return fmt.Errorf("contribution %d verification artifact: %w", index+1, err)
+ }
+ var verification ContributionVerification
+ if err := UnmarshalCanonical(verificationBytes, &verification); err != nil {
+ return fmt.Errorf("contribution %d verification record: %w", index+1, err)
+ }
+ if err := validateContributionVerification(record, verification); err != nil {
+ return fmt.Errorf("contribution %d verification record: %w", index+1, err)
+ }
+ participant, ok := definition.ParticipantByID(record.ParticipantID)
+ if !ok {
+ return fmt.Errorf("contribution %d participant is not enrolled", index+1)
+ }
+ publicKey, err := keybundle.DecodePublicKeyHex(participant.Identity.Ed25519PublicKeyHex)
+ if err != nil {
+ return err
+ }
+ var attestation ContributionAttestation
+ if err := VerifySignedRecord(
+ attestationBytes,
+ signatureBytes,
+ &attestation,
+ participant.Identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return fmt.Errorf("contribution %d attestation signature: %w", index+1, err)
+ }
+ var erasure ErasureAttestation
+ if err := VerifySignedRecord(
+ erasureBytes,
+ erasureSignatureBytes,
+ &erasure,
+ participant.Identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return fmt.Errorf("contribution %d erasure signature: %w", index+1, err)
+ }
+ prefix := chain
+ prefix.Records = append([]ChainRecord(nil), chain.Records[:index]...)
+ if err := ValidateAttestationAcceptance(definition, prefix, attestation, erasure, record); err != nil {
+ return fmt.Errorf("contribution %d attestation binding: %w", index+1, err)
+ }
+ }
+ return nil
+}
+
+func validateBeaconRawResponse(definition CeremonyDefinition, root string, beacon BeaconRecord) error {
+ path, err := resolveArtifactPath(root, beacon.RawResponse.Name)
+ if err != nil {
+ return err
+ }
+ data, err := readRegularBounded(path, maxDrandResponseBytes)
+ if err != nil {
+ return err
+ }
+ if err := requireEvidenceArtifact(root, path, data, beacon.RawResponse); err != nil {
+ return err
+ }
+ randomnessHex, err := VerifyDrandBeaconResponse(
+ definition.BeaconPolicy,
+ beacon.Round,
+ data,
+ )
+ if err != nil {
+ return err
+ }
+ if randomnessHex != beacon.RandomnessHex {
+ return errors.New("signed beacon randomness differs from verified archived drand response")
+ }
+ return nil
+}
+
+func requireEvidenceArtifact(root, path string, data []byte, expected ArtifactRef) error {
+ expectedPath, err := resolveArtifactPath(root, expected.Name)
+ if err != nil {
+ return err
+ }
+ if filepath.Clean(path) != filepath.Clean(expectedPath) {
+ return fmt.Errorf("artifact path %q, want signed path %q", path, expectedPath)
+ }
+ if NewDigest(data) != expected.Digest {
+ return errors.New("artifact digest mismatch")
+ }
+ return nil
+}
+
+func artifactRefForFile(name, path string) (ArtifactRef, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ if !linkInfo.Mode().IsRegular() {
+ return ArtifactRef{}, fmt.Errorf("%q is not a regular file", path)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ if !info.Mode().IsRegular() {
+ return ArtifactRef{}, fmt.Errorf("%q is not a regular file", path)
+ }
+ if !os.SameFile(linkInfo, info) {
+ return ArtifactRef{}, fmt.Errorf("%q changed while being opened", path)
+ }
+ if info.Size() <= 0 || info.Size() > MaxArtifactSize {
+ return ArtifactRef{}, fmt.Errorf(
+ "%q size %d is outside [1,%d]",
+ path,
+ info.Size(),
+ MaxArtifactSize,
+ )
+ }
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ n, err := io.Copy(io.MultiWriter(sha, blake), file)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ if n != info.Size() {
+ return ArtifactRef{}, fmt.Errorf("%q changed size while hashing", path)
+ }
+ ref := ArtifactRef{
+ Name: name,
+ Digest: Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake.Sum(nil)),
+ Size: n,
+ },
+ }
+ return ref, ref.Validate()
+}
+
+func readRegularFile(path string) ([]byte, error) {
+ return readRegularBounded(path, maxSignedRecordBytes)
+}
+
+func requireIdentityKey(identity Identity, publicKey ed25519.PublicKey) error {
+ if err := identity.Validate(); err != nil {
+ return err
+ }
+ expected, err := hex.DecodeString(identity.Ed25519PublicKeyHex)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(expected, publicKey) {
+ return errors.New("public key does not match signed ceremony identity")
+ }
+ return nil
+}
+
+func makeFreshPrivateDir(path string) error {
+ if strings.TrimSpace(path) == "" {
+ return errors.New("fresh output directory is required")
+ }
+ parent := filepath.Dir(path)
+ info, err := os.Stat(parent)
+ if err != nil {
+ return fmt.Errorf("stat output parent: %w", err)
+ }
+ if !info.IsDir() {
+ return errors.New("output parent is not a directory")
+ }
+ if err := os.Mkdir(path, 0o700); err != nil {
+ return fmt.Errorf("create fresh output directory: %w", err)
+ }
+ return nil
+}
+
+func writeR1CSNoReplace(path string, circuit *CompiledCircuit) (ArtifactRef, error) {
+ digest, err := WriteR1CSFileNoReplace(path, circuit)
+ if err != nil {
+ return ArtifactRef{}, err
+ }
+ return ArtifactRef{Name: prover.DestinationConstraintSystemFile, Digest: digest}, nil
+}
+
+func saveNativeNoReplace(path string, save func(string) error) (err error) {
+ dir := filepath.Dir(path)
+ temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".partial-*")
+ if err != nil {
+ return err
+ }
+ tempPath := temp.Name()
+ if err := temp.Close(); err != nil {
+ return err
+ }
+ if err := os.Remove(tempPath); err != nil {
+ return err
+ }
+ defer os.Remove(tempPath)
+ if err := save(tempPath); err != nil {
+ return err
+ }
+ file, err := os.Open(tempPath)
+ if err != nil {
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ if err := publishFileNoReplace(tempPath, path); err != nil {
+ return fmt.Errorf("publish native artifact without replacement: %w", err)
+ }
+ return nil
+}
+
+func writeBytesNoReplace(path string, data []byte, mode fs.FileMode) (err error) {
+ if strings.TrimSpace(path) == "" {
+ return errors.New("output path is required")
+ }
+ dir := filepath.Dir(path)
+ file, err := os.CreateTemp(dir, "."+filepath.Base(path)+".partial-*")
+ if err != nil {
+ return fmt.Errorf("create temporary file for %s: %w", path, err)
+ }
+ tempPath := file.Name()
+ fileOpen := true
+ defer func() {
+ if fileOpen {
+ _ = file.Close()
+ }
+ _ = os.Remove(tempPath)
+ }()
+ if err := file.Chmod(mode); err != nil {
+ return fmt.Errorf("chmod temporary file for %s: %w", path, err)
+ }
+ n, err := file.Write(data)
+ if err != nil {
+ return fmt.Errorf("write %s: %w", path, err)
+ }
+ if n != len(data) {
+ return fmt.Errorf("write %s: %w", path, io.ErrShortWrite)
+ }
+ if err := file.Sync(); err != nil {
+ return fmt.Errorf("sync %s: %w", path, err)
+ }
+ if err := file.Close(); err != nil {
+ fileOpen = false
+ return fmt.Errorf("close %s: %w", path, err)
+ }
+ fileOpen = false
+ if err := publishFileNoReplace(tempPath, path); err != nil {
+ return fmt.Errorf("publish %s without replacement: %w", path, err)
+ }
+ return nil
+}
+
+func writeChecksumsNoReplace(dir, outputPath string, names []string) error {
+ sorted := append([]string(nil), names...)
+ sort.Strings(sorted)
+ var output strings.Builder
+ for _, name := range sorted {
+ path, err := resolveArtifactPath(dir, name)
+ if err != nil {
+ return err
+ }
+ ref, err := artifactRefForFile(name, path)
+ if err != nil {
+ return err
+ }
+ output.WriteString(strings.TrimPrefix(ref.Digest.SHA256, "sha256:"))
+ output.WriteString(" ")
+ output.WriteString(name)
+ output.WriteByte('\n')
+ }
+ return writeBytesNoReplace(outputPath, []byte(output.String()), 0o600)
+}
diff --git a/internal/mpcceremony/finalize_test.go b/internal/mpcceremony/finalize_test.go
new file mode 100644
index 00000000..f59a7a98
--- /dev/null
+++ b/internal/mpcceremony/finalize_test.go
@@ -0,0 +1,311 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "proof-tool/internal/keybundle"
+)
+
+func TestWriteBytesNoReplacePreservesExistingArtifact(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), "artifact.bin")
+ if err := writeBytesNoReplace(path, []byte("accepted"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ err := writeBytesNoReplace(path, []byte("replacement"), 0o600)
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("second write error = %v, want fs.ErrExist", err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "accepted" {
+ t.Fatalf("existing artifact changed to %q", got)
+ }
+}
+
+func TestMakeFreshPrivateDirRejectsExistingDirectory(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), "candidate")
+ if err := makeFreshPrivateDir(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := makeFreshPrivateDir(path); !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("second directory creation error = %v, want fs.ErrExist", err)
+ }
+}
+
+func TestRequireIdentityKeyRejectsDifferentExistingKey(t *testing.T) {
+ t.Parallel()
+
+ publicA, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicB, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ identity, err := NewIdentity("release", "Release signer", "release-key", publicA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := requireIdentityKey(identity, publicB); err == nil {
+ t.Fatal("different public key was accepted for signed identity")
+ }
+}
+
+func TestLoadExistingReleaseKeyNeverGeneratesMissingKey(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), "missing-release-key.hex")
+ if _, _, err := keybundle.LoadExistingPrivateKey(path); err == nil {
+ t.Fatal("missing release key was accepted")
+ }
+ if _, err := os.Lstat(path); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("missing release key was created: %v", err)
+ }
+}
+
+func TestExactBeaconChallengeRequiresExactly32Bytes(t *testing.T) {
+ t.Parallel()
+
+ record := BeaconRecord{ChallengeHex: hex.EncodeToString(make([]byte, 33))}
+ if _, err := exactBeaconChallenge(record); err == nil || !strings.Contains(err.Error(), "exactly 32") {
+ t.Fatalf("33-byte challenge error = %v", err)
+ }
+ record.ChallengeHex = hex.EncodeToString(make([]byte, 32))
+ challenge, err := exactBeaconChallenge(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(challenge) != 32 {
+ t.Fatalf("challenge length = %d", len(challenge))
+ }
+}
+
+func TestPublicFinalizationEvidenceBindsOnlyPublicStatementAndProof(t *testing.T) {
+ t.Parallel()
+
+ readGoldenHex := func(name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(
+ "..", "..", "contracts", "ownership-verifier", "testdata", name,
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := hex.DecodeString(strings.TrimSpace(string(data)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return decoded
+ }
+ credential, err := hex.DecodeString(GoldenPublicCredentialHex)
+ if err != nil {
+ t.Fatal(err)
+ }
+ destination, err := hex.DecodeString(GoldenPublicDestinationHex)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicInputDigest := readGoldenHex("ownership-destination-pub.hex")
+ proof := readGoldenHex("ownership-destination-proof.hex")
+ cardanoVK := readGoldenHex("ownership-destination-vk.hex")
+ evidence := PublicFinalizationEvidence{
+ Schema: PublicEvidenceSchema,
+ CeremonyID: NewDigest([]byte("ceremony")).SHA256,
+ Fixture: PublicEvidenceFixture,
+ CredentialHex: hex.EncodeToString(credential),
+ DestinationHex: hex.EncodeToString(destination),
+ PublicInputDigestHex: hex.EncodeToString(publicInputDigest),
+ CardanoProofHex: hex.EncodeToString(proof),
+ CardanoProofFormat: expectedCardanoBSB22,
+ CardanoProofRawDigest: NewDigest(proof),
+ CardanoVerifyingKey: refForTest(CardanoVKBytesFile, cardanoVK),
+ }
+ if err := evidence.Validate(); err != nil {
+ t.Fatalf("valid public evidence rejected: %v", err)
+ }
+ changedDestination := evidence
+ changedDestination.DestinationHex = changedDestination.DestinationHex[:len(changedDestination.DestinationHex)-1] + "1"
+ if err := changedDestination.Validate(); err == nil {
+ t.Fatal("public evidence accepted a destination that differs from its digest")
+ }
+ changedCredential := evidence
+ changedCredential.CredentialHex = "18" + changedCredential.CredentialHex[2:]
+ if err := changedCredential.Validate(); err == nil {
+ t.Fatal("public evidence accepted a non-golden credential")
+ }
+ changedProof := evidence
+ changedProof.CardanoProofHex = "34" + changedProof.CardanoProofHex[2:]
+ if err := changedProof.Validate(); err == nil {
+ t.Fatal("public evidence accepted proof bytes that differ from the reportable proof digest")
+ }
+}
+
+func refForTest(name string, content []byte) ArtifactRef {
+ return ArtifactRef{Name: name, Digest: NewDigest(content)}
+}
+
+func TestCandidateChecksumsDetectTampering(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ name := "candidate.json"
+ if err := writeBytesNoReplace(filepath.Join(dir, name), []byte("original"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ checksums := filepath.Join(dir, CandidateChecksumsFile)
+ if err := writeChecksumsNoReplace(dir, checksums, []string{name}); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyChecksumsExact(dir, checksums, []string{name}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, name), []byte("tampered"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyChecksumsExact(dir, checksums, []string{name}); err == nil {
+ t.Fatal("tampered candidate passed checksum verification")
+ }
+}
+
+func TestChecksumsRequireExactExpectedSet(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ for _, name := range []string{"a.bin", "b.bin"} {
+ if err := writeBytesNoReplace(filepath.Join(dir, name), []byte(name), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ checksums := filepath.Join(dir, CandidateChecksumsFile)
+ if err := writeChecksumsNoReplace(dir, checksums, []string{"a.bin"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyChecksumsExact(dir, checksums, []string{"a.bin", "b.bin"}); err == nil {
+ t.Fatal("checksum file omitting an expected artifact was accepted")
+ }
+}
+
+func TestReleaseChecksumsAndTreeRequireExactBundledAuditAndOperationalEvidenceSet(t *testing.T) {
+ dir := t.TempDir()
+ operationalNames := []string{
+ "operational/evidence-bundle.json",
+ "operational/evidence-bundle.sig",
+ "operational/enrollments/witness-01.json",
+ "operational/enrollments/witness-01.sig",
+ "operational/phase1/close.json",
+ "operational/phase1/close.sig",
+ }
+ names := releaseChecksumNames(2, operationalNames)
+ for _, name := range names {
+ path := filepath.Join(dir, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("artifact:"+name), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ checksums := filepath.Join(dir, ReleaseChecksumsFile)
+ if err := writeChecksumsNoReplace(dir, checksums, names); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyChecksumsExact(dir, checksums, names); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyReleaseTreeExact(dir, 2, operationalNames); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "unexpected.txt"), []byte("injected"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyReleaseTreeExact(dir, 2, operationalNames); err == nil {
+ t.Fatal("release tree accepted an unexpected injected file")
+ }
+}
+
+func TestArtifactRefRejectsSymlink(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.bin")
+ if err := os.WriteFile(target, []byte("artifact"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(dir, "link.bin")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := artifactRefForFile("link.bin", link); err == nil {
+ t.Fatal("symlink artifact was accepted")
+ }
+}
+
+func TestReleaseStagingRequiresFreshDistinctDestination(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ candidate := filepath.Join(parent, "candidate")
+ if err := os.Mkdir(candidate, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := createReleaseStagingDir(candidate, candidate); err == nil {
+ t.Fatal("candidate directory was accepted as release destination")
+ }
+ existing := filepath.Join(parent, "release")
+ if err := os.Mkdir(existing, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := createReleaseStagingDir(existing, candidate); !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("existing release destination error = %v, want fs.ErrExist", err)
+ }
+}
+
+func TestLinuxReleasePublicationNeverReplacesEmptyDestination(t *testing.T) {
+ t.Parallel()
+ if runtime.GOOS != "linux" {
+ t.Skip("production no-replace directory publication is Linux-specific")
+ }
+
+ parent := t.TempDir()
+ staging := filepath.Join(parent, "staging")
+ destination := filepath.Join(parent, "release")
+ if err := os.Mkdir(staging, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(staging, "artifact"), []byte("candidate"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(destination, 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := publishReleaseDirectory(staging, destination); !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("publish over empty destination error = %v, want fs.ErrExist", err)
+ }
+ if _, err := os.Stat(filepath.Join(staging, "artifact")); err != nil {
+ t.Fatalf("failed publication changed staging directory: %v", err)
+ }
+ entries, err := os.ReadDir(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatal("failed publication replaced or populated existing destination")
+ }
+}
diff --git a/internal/mpcceremony/fuzz_test.go b/internal/mpcceremony/fuzz_test.go
new file mode 100644
index 00000000..3b8a560b
--- /dev/null
+++ b/internal/mpcceremony/fuzz_test.go
@@ -0,0 +1,76 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "testing"
+)
+
+func FuzzCanonicalJSONParser(f *testing.F) {
+ f.Add([]byte(`{}`))
+ f.Add([]byte(`{"schema":"x","schema":"y"}`))
+ f.Add([]byte(`{"nested":{"a":1},"tail":[]}`))
+ f.Add([]byte(`null{}`))
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ // This scanner is the allocation-bounded first pass for every signed
+ // canonical record and for archived drand JSON. Arbitrary input must
+ // only return an error, never panic.
+ _ = rejectDuplicateKeysAndTrailing(data)
+ })
+}
+
+func FuzzPhase1Preflight(f *testing.F) {
+ var canonical bytes.Buffer
+ phase1, _, err := InitializePhase1(2)
+ if err != nil {
+ f.Fatalf("initialize Phase 1: %v", err)
+ }
+ if _, err := phase1.WriteTo(&canonical); err != nil {
+ f.Fatalf("serialize Phase 1 seed: %v", err)
+ }
+ f.Add(canonical.Bytes())
+ f.Add([]byte{})
+ f.Add(bytes.Repeat([]byte{0xff}, 128))
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ _, _ = PreflightPhase1(
+ bytes.NewReader(data),
+ Phase1Shape{DomainN: 2, ChallengeLength: 0},
+ )
+ })
+}
+
+func FuzzPhase2Preflight(f *testing.F) {
+ phase2, shape := adversarialPhase2Contribution(f)
+ canonical := adversarialSerialize(f, phase2)
+ f.Add(canonical)
+ f.Add([]byte{})
+ f.Add(bytes.Repeat([]byte{0xff}, 128))
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ _, _ = PreflightPhase2(bytes.NewReader(data), shape)
+ })
+}
+
+func FuzzDrandResponseParser(f *testing.F) {
+ policy := BeaconPolicy{
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ ChainHashHex: BeaconQuicknetChainHash,
+ PublicKeyHex: BeaconQuicknetPublicKey,
+ Scheme: BeaconQuicknetScheme,
+ GenesisTimeUnix: BeaconQuicknetGenesis,
+ PeriodSeconds: BeaconQuicknetPeriod,
+ Extraction: BeaconExtractionV1,
+ MinimumChallengeBytes: 32,
+ MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds,
+ FutureRoundRequired: true,
+ }
+ f.Add([]byte(`{"round":1,"randomness":"","signature":""}`))
+ f.Add([]byte(`{"round":1,"round":2}`))
+ f.Add([]byte{})
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ _, _ = VerifyDrandBeaconResponse(policy, 1, data)
+ })
+}
diff --git a/internal/mpcceremony/integration_test.go b/internal/mpcceremony/integration_test.go
new file mode 100644
index 00000000..fee59799
--- /dev/null
+++ b/internal/mpcceremony/integration_test.go
@@ -0,0 +1,659 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "encoding/hex"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ "github.com/consensys/gnark/backend/groth16"
+ groth16bls12381 "github.com/consensys/gnark/backend/groth16/bls12-381"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ "github.com/consensys/gnark/frontend"
+
+ "proof-tool/internal/prover"
+)
+
+func TestTinyCommittedCircuitThreeByThreeEndToEnd(t *testing.T) {
+ circuit, err := BindDestinationV2R1CS(adversarialCompileCommitted(t))
+ if err != nil {
+ t.Fatalf("bind tiny committed circuit: %v", err)
+ }
+ domainN := circuit.Binding.DomainSize
+ phase1Shape := Phase1Shape{DomainN: domainN, ChallengeLength: 32}
+
+ phase1Dir := t.TempDir()
+ phase1 := make([]*gnarkmpc.Phase1, 0, 3)
+ phase1Archive := make([][]byte, 0, 3)
+ for index := range 3 {
+ contribution, err := ContributePhase1(domainN, phase1)
+ if err != nil {
+ t.Fatalf("Phase 1 contribution %d: %v", index+1, err)
+ }
+ path := filepath.Join(phase1Dir, "contribution-"+string(rune('1'+index))+".bin")
+ if _, err := WritePhase1FileNoReplace(path, contribution, phase1Shape); err != nil {
+ t.Fatalf("write Phase 1 contribution %d: %v", index+1, err)
+ }
+ reloaded, digest, err := ReadPhase1File(path, phase1Shape)
+ if err != nil {
+ t.Fatalf("read Phase 1 contribution %d: %v", index+1, err)
+ }
+ if digest.Size <= 0 || len(digest.Challenge) != 32 {
+ t.Fatalf("Phase 1 contribution %d digest = %+v", index+1, digest)
+ }
+ phase1 = append(phase1, reloaded)
+ phase1Archive = append(phase1Archive, adversarialSerialize(t, reloaded))
+ }
+ if err := ReplayPhase1(domainN, phase1); err != nil {
+ t.Fatalf("replay Phase 1: %v", err)
+ }
+
+ phase1Beacon := bytes.Repeat([]byte{0xa1}, 32)
+ commons, err := SealPhase1(domainN, phase1Beacon, phase1)
+ if err != nil {
+ t.Fatalf("seal Phase 1: %v", err)
+ }
+ commonsAgain, err := SealPhase1(domainN, phase1Beacon, phase1)
+ if err != nil {
+ t.Fatalf("replay and reseal Phase 1: %v", err)
+ }
+ if left, right := adversarialSerialize(t, commons), adversarialSerialize(t, commonsAgain); !bytes.Equal(left, right) {
+ t.Fatal("replaying the same Phase 1 transcript and beacon produced different commons")
+ }
+ for i := range phase1 {
+ if got := adversarialSerialize(t, phase1[i]); !bytes.Equal(got, phase1Archive[i]) {
+ t.Fatalf("Phase 1 archive %d mutated during sealing", i+1)
+ }
+ }
+
+ commonsPath := filepath.Join(t.TempDir(), "commons.bin")
+ commonsShape := CommonsShape{DomainN: domainN}
+ if _, err := WriteCommonsFileNoReplace(commonsPath, commons, commonsShape); err != nil {
+ t.Fatalf("write commons: %v", err)
+ }
+ reloadedCommons, _, err := ReadCommonsFile(commonsPath, commonsShape)
+ if err != nil {
+ t.Fatalf("read commons: %v", err)
+ }
+
+ initialPhase2, initialShape, err := InitializePhase2(circuit, reloadedCommons)
+ if err != nil {
+ t.Fatalf("initialize Phase 2: %v", err)
+ }
+ if initialShape.ChallengeLength != 0 {
+ t.Fatalf("initial Phase 2 challenge length = %d, want 0", initialShape.ChallengeLength)
+ }
+ derivedInitialShape, err := DerivePhase2Shape(initialPhase2)
+ if err != nil {
+ t.Fatalf("derive initial Phase 2 shape: %v", err)
+ }
+ if !equalPhase2Shape(initialShape, derivedInitialShape) {
+ t.Fatalf("initial Phase 2 shapes differ: %+v != %+v", initialShape, derivedInitialShape)
+ }
+
+ phase2Dir := t.TempDir()
+ phase2 := make([]*gnarkmpc.Phase2, 0, 3)
+ phase2Archive := make([][]byte, 0, 3)
+ var contributionShape Phase2Shape
+ for index := range 3 {
+ contribution, err := ContributePhase2(circuit, reloadedCommons, phase2)
+ if err != nil {
+ t.Fatalf("Phase 2 contribution %d: %v", index+1, err)
+ }
+ contributionShape, err = DerivePhase2Shape(contribution)
+ if err != nil {
+ t.Fatalf("derive Phase 2 contribution %d shape: %v", index+1, err)
+ }
+ if contributionShape.ChallengeLength != 32 {
+ t.Fatalf("Phase 2 contribution challenge length = %d, want 32", contributionShape.ChallengeLength)
+ }
+ path := filepath.Join(phase2Dir, "contribution-"+string(rune('1'+index))+".bin")
+ if _, err := WritePhase2FileNoReplace(path, contribution, contributionShape); err != nil {
+ t.Fatalf("write Phase 2 contribution %d: %v", index+1, err)
+ }
+ reloaded, digest, err := ReadPhase2File(path, contributionShape)
+ if err != nil {
+ t.Fatalf("read Phase 2 contribution %d: %v", index+1, err)
+ }
+ if digest.Size <= 0 || len(digest.Challenge) != 32 {
+ t.Fatalf("Phase 2 contribution %d digest = %+v", index+1, digest)
+ }
+ phase2 = append(phase2, reloaded)
+ phase2Archive = append(phase2Archive, adversarialSerialize(t, reloaded))
+ }
+ if err := ReplayPhase2(circuit, reloadedCommons, phase2); err != nil {
+ t.Fatalf("replay Phase 2: %v", err)
+ }
+ reorderedPhase2 := []*gnarkmpc.Phase2{phase2[0], phase2[2], phase2[1]}
+ if err := ReplayPhase2(circuit, reloadedCommons, reorderedPhase2); err == nil {
+ t.Fatal("reordered Phase 2 transcript unexpectedly replayed")
+ }
+ for _, size := range []int{0, 31, 33} {
+ if _, _, err := SealPhase2(
+ circuit,
+ reloadedCommons,
+ bytes.Repeat([]byte{0xb2}, size),
+ phase2,
+ ); err == nil {
+ t.Fatalf("Phase 2 accepted a %d-byte beacon", size)
+ }
+ }
+
+ phase2Beacon := bytes.Repeat([]byte{0xb2}, 32)
+ pk, vk, err := SealPhase2(circuit, reloadedCommons, phase2Beacon, phase2)
+ if err != nil {
+ t.Fatalf("seal Phase 2: %v", err)
+ }
+ pkAgain, vkAgain, err := SealPhase2(circuit, reloadedCommons, phase2Beacon, phase2)
+ if err != nil {
+ t.Fatalf("replay and reseal Phase 2: %v", err)
+ }
+ if left, right := adversarialSerialize(t, pk), adversarialSerialize(t, pkAgain); !bytes.Equal(left, right) {
+ t.Fatal("replaying the same Phase 2 transcript and beacon produced different proving keys")
+ }
+ if left, right := adversarialSerialize(t, vk), adversarialSerialize(t, vkAgain); !bytes.Equal(left, right) {
+ t.Fatal("replaying the same Phase 2 transcript and beacon produced different verifying keys")
+ }
+ for i := range phase2 {
+ if got := adversarialSerialize(t, phase2[i]); !bytes.Equal(got, phase2Archive[i]) {
+ t.Fatalf("Phase 2 archive %d mutated during sealing", i+1)
+ }
+ }
+
+ assignment := &adversarialCommittedCircuit{Public: 7, Secret: 7}
+ witness, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField())
+ if err != nil {
+ t.Fatalf("build witness: %v", err)
+ }
+ publicWitness, err := witness.Public()
+ if err != nil {
+ t.Fatalf("extract public witness: %v", err)
+ }
+ proof, err := groth16.Prove(circuit.R1CS, pk, witness)
+ if err != nil {
+ t.Fatalf("prove with MPC key: %v", err)
+ }
+ if err := groth16.Verify(proof, vk, publicWitness); err != nil {
+ t.Fatalf("verify MPC proof: %v", err)
+ }
+ var staleAlphaEncoding bytes.Buffer
+ if _, err := vk.WriteTo(&staleAlphaEncoding); err != nil {
+ t.Fatal(err)
+ }
+ staleAlpha := groth16.NewVerifyingKey(ecc.BLS12_381)
+ if _, err := staleAlpha.ReadFrom(bytes.NewReader(staleAlphaEncoding.Bytes())); err != nil {
+ t.Fatal(err)
+ }
+ staleAlphaConcrete := staleAlpha.(*groth16bls12381.VerifyingKey)
+ staleAlphaConcrete.G1.Alpha.Neg(&staleAlphaConcrete.G1.Alpha)
+ originalCardanoVK, _, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staleAlphaCardanoVK, _, err := prover.SerializeCardanoVK(staleAlpha)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(originalCardanoVK, staleAlphaCardanoVK) {
+ t.Fatal("stale Alpha mutation did not change serialized Cardano VK")
+ }
+ // This captures the regression that motivated cloneWrongVerifyingKey's
+ // G1.K mutation: gnark verifies with the E pairing precomputed by ReadFrom,
+ // so mutating Alpha afterward changes bytes but not verifier behavior.
+ if err := groth16.Verify(proof, staleAlpha, publicWitness); err != nil {
+ t.Fatalf("pinned gnark no longer exhibits stale-Alpha behavior: %v", err)
+ }
+ wrongVK, err := cloneWrongVerifyingKey(vk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := groth16.Verify(proof, wrongVK, publicWitness); err == nil {
+ t.Fatal("verifier-consumed wrong VK unexpectedly accepted the proof")
+ }
+ wrongCardanoVK, _, err := prover.SerializeCardanoVK(wrongVK)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(originalCardanoVK, wrongCardanoVK) {
+ t.Fatal("wrong VK negative did not change Cardano VK semantics")
+ }
+ originalNativeDigest, err := writerDigest(vk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrongNativeDigest, err := writerDigest(wrongVK)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if originalNativeDigest == wrongNativeDigest {
+ t.Fatal("wrong VK negative did not change native VK semantics")
+ }
+ wrongPublicWitness, err := frontend.NewWitness(
+ &adversarialCommittedCircuit{Public: 8},
+ ecc.BLS12_381.ScalarField(),
+ frontend.PublicOnly(),
+ )
+ if err != nil {
+ t.Fatalf("build wrong public witness: %v", err)
+ }
+ if err := groth16.Verify(proof, vk, wrongPublicWitness); err == nil {
+ t.Fatal("MPC proof unexpectedly verified with the wrong public input")
+ }
+
+ cardanoVK, format, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ t.Fatalf("serialize Cardano VK: %v", err)
+ }
+ if format != "groth16-bls12-381-bsb22" {
+ t.Fatalf("Cardano VK format = %q", format)
+ }
+ if len(cardanoVK) != prover.CardanoVKCommitmentLen {
+ t.Fatalf("Cardano VK length = %d, want %d", len(cardanoVK), prover.CardanoVKCommitmentLen)
+ }
+ var independentlySerialized []byte
+ alpha := vk.G1.Alpha.Bytes()
+ beta := vk.G2.Beta.Bytes()
+ gamma := vk.G2.Gamma.Bytes()
+ delta := vk.G2.Delta.Bytes()
+ k0 := vk.G1.K[0].Bytes()
+ k1 := vk.G1.K[1].Bytes()
+ k2 := vk.G1.K[2].Bytes()
+ commitmentG := vk.CommitmentKeys[0].G.Bytes()
+ commitmentGSigmaNeg := vk.CommitmentKeys[0].GSigmaNeg.Bytes()
+ independentlySerialized = append(independentlySerialized, alpha[:]...)
+ independentlySerialized = append(independentlySerialized, beta[:]...)
+ independentlySerialized = append(independentlySerialized, gamma[:]...)
+ independentlySerialized = append(independentlySerialized, delta[:]...)
+ independentlySerialized = append(independentlySerialized, k0[:]...)
+ independentlySerialized = append(independentlySerialized, k1[:]...)
+ independentlySerialized = append(independentlySerialized, k2[:]...)
+ independentlySerialized = append(independentlySerialized, commitmentG[:]...)
+ independentlySerialized = append(independentlySerialized, commitmentGSigmaNeg[:]...)
+ if !bytes.Equal(cardanoVK, independentlySerialized) {
+ t.Fatal("Cardano VK bytes do not equal the independently serialized native MPC VK fields")
+ }
+
+ keyDir := t.TempDir()
+ pkPath := filepath.Join(keyDir, "ownership.pk")
+ vkPath := filepath.Join(keyDir, "ownership.vk")
+ if err := prover.SavePK(pk, pkPath); err != nil {
+ t.Fatalf("save native MPC PK: %v", err)
+ }
+ if err := prover.SaveVK(vk, vkPath); err != nil {
+ t.Fatalf("save native MPC VK: %v", err)
+ }
+ reloadedPK, err := prover.LoadPK(pkPath)
+ if err != nil {
+ t.Fatalf("reload native MPC PK: %v", err)
+ }
+ reloadedVK, err := prover.LoadVK(vkPath)
+ if err != nil {
+ t.Fatalf("reload native MPC VK: %v", err)
+ }
+ reloadedCardanoVK, reloadedFormat, err := prover.SerializeCardanoVK(reloadedVK)
+ if err != nil {
+ t.Fatalf("serialize reloaded Cardano VK: %v", err)
+ }
+ if reloadedFormat != format || !bytes.Equal(reloadedCardanoVK, cardanoVK) {
+ t.Fatal("native PK/VK persistence changed the Cardano VK")
+ }
+ reloadedProof, err := groth16.Prove(circuit.R1CS, reloadedPK, witness)
+ if err != nil {
+ t.Fatalf("prove with reloaded MPC key: %v", err)
+ }
+ if err := groth16.Verify(reloadedProof, reloadedVK, publicWitness); err != nil {
+ t.Fatalf("verify with reloaded MPC key: %v", err)
+ }
+}
+
+func TestSignedFileWorkflowRejectsReusedPhase1RoundBeforePublicationAndReplays(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping full signed two-phase ceremony lifecycle in the fast gate")
+ }
+ // Go test binaries omit dependency modules from debug.ReadBuildInfo. Build
+ // the helper as an ordinary main binary so the real workflow software gate
+ // verifies its exact executable, VCS, Go, gnark, gnark-crypto, and drand
+ // identity without adding a production bypass or test seam.
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("resolve integration test source path")
+ }
+ repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
+ helperPath := filepath.Join(t.TempDir(), "mpc-workflow-helper")
+ build := exec.Command(
+ "go",
+ "build",
+ "-o",
+ helperPath,
+ "./internal/mpcceremony/testdata/workflowhelper",
+ )
+ build.Dir = repoRoot
+ if output, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build ordinary workflow helper: %v\n%s", err, output)
+ }
+ workflowRoot := filepath.Join(t.TempDir(), "workflow")
+ run := exec.Command(helperPath, workflowRoot)
+ run.Dir = repoRoot
+ if output, err := run.CombinedOutput(); err != nil {
+ t.Fatalf("run signed workflow helper: %v\n%s", err, output)
+ }
+
+ circuit, err := BindDestinationV2R1CS(adversarialCompileCommitted(t))
+ if err != nil {
+ t.Fatalf("bind replay circuit: %v", err)
+ }
+ ceremonyRoot := filepath.Join(workflowRoot, "ceremony")
+ coordinatorPublicKey, err := os.ReadFile(filepath.Join(
+ workflowRoot,
+ "identity-keys",
+ "trusted-coordinator.ed25519.public.hex",
+ ))
+ if err != nil {
+ t.Fatal(err)
+ }
+ replayPaths := ReplayPaths{
+ TranscriptRoot: ceremonyRoot,
+ CoordinatorPublicKeyHex: strings.TrimSpace(string(coordinatorPublicKey)),
+ DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"),
+ DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"),
+ Phase1ChainPath: filepath.Join(ceremonyRoot, "phase1", "chain-0002.json"),
+ Phase1ChainSignaturePath: filepath.Join(ceremonyRoot, "phase1", "chain-0002.sig"),
+ Phase1ClosePath: filepath.Join(ceremonyRoot, "phase1", "closure", "record.json"),
+ Phase1CloseSignaturePath: filepath.Join(ceremonyRoot, "phase1", "closure", "record.sig"),
+ Phase1BeaconPath: filepath.Join(ceremonyRoot, "phase1", "beacon", "record.json"),
+ Phase1BeaconSignaturePath: filepath.Join(ceremonyRoot, "phase1", "beacon", "record.sig"),
+ Phase1SealPath: filepath.Join(ceremonyRoot, "phase1", "sealed", "seal.json"),
+ Phase1SealSignaturePath: filepath.Join(ceremonyRoot, "phase1", "sealed", "seal.sig"),
+ Phase2ChainPath: filepath.Join(ceremonyRoot, "phase2", "chain-0002.json"),
+ Phase2ChainSignaturePath: filepath.Join(ceremonyRoot, "phase2", "chain-0002.sig"),
+ Phase2ClosePath: filepath.Join(ceremonyRoot, "phase2", "closure", "record.json"),
+ Phase2CloseSignaturePath: filepath.Join(ceremonyRoot, "phase2", "closure", "record.sig"),
+ Phase2BeaconPath: filepath.Join(ceremonyRoot, "phase2", "beacon", "record.json"),
+ Phase2BeaconSignaturePath: filepath.Join(ceremonyRoot, "phase2", "beacon", "record.sig"),
+ }
+ loaded, err := loadReplay(replayPaths)
+ if err != nil {
+ t.Fatalf("load reduced signed replay paths: %v", err)
+ }
+ replayed, err := replayAll(circuit, loaded, replayPaths)
+ if err != nil {
+ t.Fatalf("replay complete signed file workflow: %v", err)
+ }
+ witness, err := frontend.NewWitness(
+ &adversarialCommittedCircuit{Public: 9, Secret: 9},
+ ecc.BLS12_381.ScalarField(),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicWitness, err := witness.Public()
+ if err != nil {
+ t.Fatal(err)
+ }
+ proof, err := groth16.Prove(circuit.R1CS, replayed.pk, witness)
+ if err != nil {
+ t.Fatalf("prove with fully replayed file-workflow key: %v", err)
+ }
+ if err := groth16.Verify(proof, replayed.vk, publicWitness); err != nil {
+ t.Fatalf("verify with fully replayed file-workflow key: %v", err)
+ }
+
+ trusted, err := LoadSignedDefinition(TrustPaths{
+ DefinitionPath: replayPaths.DefinitionPath,
+ DefinitionSignaturePath: replayPaths.DefinitionSignaturePath,
+ CoordinatorPublicKeyPath: filepath.Join(workflowRoot, "identity-keys", "trusted-coordinator.ed25519.public.hex"),
+ })
+ if err != nil {
+ t.Fatalf("load trust for Phase 2 boundary check: %v", err)
+ }
+ if _, _, _, err := loadPhase1CommonsForPhase2(
+ trusted,
+ circuit,
+ ceremonyRoot,
+ replayPaths.Phase1SealPath,
+ replayPaths.Phase1SealSignaturePath,
+ ); err != nil {
+ t.Fatalf("Phase 2 boundary rejected complete Phase 1 evidence: %v", err)
+ }
+
+ var phase1Beacon BeaconRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ replayPaths.Phase1BeaconPath,
+ replayPaths.Phase1BeaconSignaturePath,
+ &phase1Beacon,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ challenge, err := hex.DecodeString(phase1Beacon.ChallengeHex)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrongHead := gnarkmpc.NewPhase1(circuit.Binding.DomainSize)
+ wrongCommons := wrongHead.Seal(challenge)
+ forgedDir := filepath.Join(ceremonyRoot, "phase1", "forged-sealed")
+ if err := os.Mkdir(forgedDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ forgedCommonsPath := filepath.Join(forgedDir, "commons.bin")
+ forgedDigest, err := WriteCommonsFileNoReplace(
+ forgedCommonsPath,
+ &wrongCommons,
+ CommonsShape{DomainN: circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ var originalSeal SealRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ replayPaths.Phase1SealPath,
+ replayPaths.Phase1SealSignaturePath,
+ &originalSeal,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ forgedName, err := logicalPathWithin(ceremonyRoot, forgedCommonsPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ forgedSeal := originalSeal
+ forgedSeal.SealID = ""
+ forgedSeal.Outputs = append([]ArtifactRef(nil), originalSeal.Outputs...)
+ foundCommons := false
+ for index := range forgedSeal.Outputs {
+ if strings.HasSuffix(forgedSeal.Outputs[index].Name, "/commons.bin") {
+ forgedSeal.Outputs[index] = ArtifactRef{
+ Name: forgedName,
+ Digest: modelDigest(forgedDigest),
+ }
+ foundCommons = true
+ }
+ }
+ if !foundCommons {
+ t.Fatal("fixture Phase 1 seal has no commons output")
+ }
+ forgedSeal, err = NewSealRecord(forgedSeal)
+ if err != nil {
+ t.Fatal(err)
+ }
+ coordinatorPrivate, _, err := loadMatchingPrivateKey(
+ filepath.Join(workflowRoot, "identity-keys", "coordinator.ed25519.private.hex"),
+ trusted.Definition.Coordinator,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ forgedSealPath := filepath.Join(forgedDir, "seal.json")
+ forgedSignaturePath := filepath.Join(forgedDir, "seal.sig")
+ if err := writeSignedRecordNoReplace(
+ forgedSealPath,
+ forgedSignaturePath,
+ forgedSeal,
+ trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, err := loadPhase1CommonsForPhase2(
+ trusted,
+ circuit,
+ ceremonyRoot,
+ forgedSealPath,
+ forgedSignaturePath,
+ ); err == nil || !strings.Contains(err.Error(), "not derived") {
+ t.Fatalf(
+ "Phase 2 boundary accepted coordinator-signed commons from deterministic genesis: %v",
+ err,
+ )
+ }
+
+ hiddenClosePath := replayPaths.Phase1ClosePath + ".hidden"
+ if err := os.Rename(replayPaths.Phase1ClosePath, hiddenClosePath); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, err := loadPhase1CommonsForPhase2(
+ trusted,
+ circuit,
+ ceremonyRoot,
+ replayPaths.Phase1SealPath,
+ replayPaths.Phase1SealSignaturePath,
+ ); err == nil {
+ t.Fatal("Phase 2 boundary accepted a seal without independently replayable Phase 1 closure evidence")
+ }
+ if err := os.Rename(hiddenClosePath, replayPaths.Phase1ClosePath); err != nil {
+ t.Fatal(err)
+ }
+
+ originalClosureDir := filepath.Dir(replayPaths.Phase1ClosePath)
+ preservedClosureDir := originalClosureDir + ".historical"
+ if err := os.Rename(originalClosureDir, preservedClosureDir); err != nil {
+ t.Fatal(err)
+ }
+ roundTime, err := QuicknetRoundTime(42)
+ if err != nil {
+ t.Fatal(err)
+ }
+ closeOptions := ClosePhaseFilesOptions{
+ Trust: TrustPaths{
+ DefinitionPath: replayPaths.DefinitionPath,
+ DefinitionSignaturePath: replayPaths.DefinitionSignaturePath,
+ CoordinatorPublicKeyPath: filepath.Join(workflowRoot, "identity-keys", "trusted-coordinator.ed25519.public.hex"),
+ },
+ Circuit: circuit,
+ Phase: Phase1,
+ Transcript: PhaseTranscriptPaths{RootDir: ceremonyRoot, ChainPath: replayPaths.Phase1ChainPath, ChainSignaturePath: replayPaths.Phase1ChainSignaturePath},
+ CoordinatorPrivateKeyPath: filepath.Join(workflowRoot, "identity-keys", "coordinator.ed25519.private.hex"),
+ BeaconRound: 42,
+ }
+ expiredTimes := []time.Time{
+ roundTime.Add(-4 * time.Second),
+ roundTime.Add(-closePublicationSafetyMargin - time.Second + time.Nanosecond),
+ }
+ expiredClock := func() time.Time {
+ value := expiredTimes[0]
+ expiredTimes = expiredTimes[1:]
+ return value
+ }
+ if _, err := closePhaseFilesAuthenticated(
+ closeOptions,
+ trusted,
+ expiredClock,
+ ); err == nil ||
+ !strings.Contains(err.Error(), "below required") {
+ t.Fatalf("post-replay expired close error = %v, want publication-time rejection", err)
+ }
+ if _, err := os.Lstat(originalClosureDir); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("expired close published an atomic closure directory: %v", err)
+ }
+
+ validTimes := []time.Time{
+ roundTime.Add(-4 * time.Second),
+ roundTime.Add(-closePublicationSafetyMargin - time.Second),
+ }
+ validClock := func() time.Time {
+ value := validTimes[0]
+ validTimes = validTimes[1:]
+ return value
+ }
+ publishedClose, err := closePhaseFilesAuthenticated(
+ closeOptions,
+ trusted,
+ validClock,
+ )
+ if err != nil {
+ t.Fatalf("publish boundary-valid atomic closure: %v", err)
+ }
+ for _, path := range []string{
+ publishedClose.ClosePath,
+ publishedClose.SignaturePath,
+ } {
+ if info, err := os.Lstat(path); err != nil || !info.Mode().IsRegular() {
+ t.Fatalf("atomic closure member %q is absent or unsafe: %v", path, err)
+ }
+ }
+ retriedClose, err := publishReplayedPhaseClose(closeOptions, trusted, loaded.phase1Chain, func() time.Time {
+ panic("completed closure retry must not consult the clock")
+ })
+ if err != nil {
+ t.Fatalf("retry complete atomic closure after publication: %v", err)
+ }
+ if retriedClose.Close.CloseID != publishedClose.Close.CloseID {
+ t.Fatal("complete closure retry did not return the exact committed record")
+ }
+ if err := os.RemoveAll(originalClosureDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(preservedClosureDir, originalClosureDir); err != nil {
+ t.Fatal(err)
+ }
+
+ // The public close entry point must complete authentication and native
+ // replay before consulting its clock. Corrupting an accepted payload must
+ // therefore reject without ever reaching the injected clock.
+ phase1PayloadPath := filepath.Join(
+ ceremonyRoot,
+ filepath.FromSlash(loaded.phase1Chain.Records[0].OutputPayload.Name),
+ )
+ phase1PayloadBytes, err := os.ReadFile(phase1PayloadPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tamperedPhase1Payload := append([]byte(nil), phase1PayloadBytes...)
+ tamperedPhase1Payload[len(tamperedPhase1Payload)-1] ^= 0x01
+ if err := os.WriteFile(phase1PayloadPath, tamperedPhase1Payload, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := closePhaseFilesAuthenticated(closeOptions, trusted, func() time.Time {
+ panic("close clock consulted before failed replay")
+ }); err == nil {
+ t.Fatal("top-level close accepted a corrupted replay prefix")
+ }
+ if err := os.WriteFile(phase1PayloadPath, phase1PayloadBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // ReplayPaths intentionally carries no participant evidence paths. Those
+ // are resolved from authenticated chain refs, so derived-file tampering
+ // must still fail the replay loader.
+ erasurePath := filepath.Join(
+ ceremonyRoot,
+ "phase2",
+ "contributions",
+ "0002",
+ "erasure.json",
+ )
+ if err := os.WriteFile(erasurePath, []byte("tampered derived erasure evidence"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadReplay(replayPaths); err == nil {
+ t.Fatal("reduced replay accepted tampering in chain-referenced erasure evidence")
+ }
+}
diff --git a/internal/mpcceremony/lifecycle_adversarial_test.go b/internal/mpcceremony/lifecycle_adversarial_test.go
new file mode 100644
index 00000000..399a33c0
--- /dev/null
+++ b/internal/mpcceremony/lifecycle_adversarial_test.go
@@ -0,0 +1,207 @@
+// Copyright 2026 Midgard Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package mpcceremony
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestSignedLifecycleReleaseRejectsCrossArtifactTampering(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping complete signed finalization, operational-evidence, audit, and release lifecycle")
+ }
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("resolve lifecycle test source")
+ }
+ repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
+ binaryDir := t.TempDir()
+ workflowHelper := filepath.Join(binaryDir, "workflow-helper")
+ operationalHelper := filepath.Join(binaryDir, "operational-helper")
+ for output, pkg := range map[string]string{
+ workflowHelper: "./internal/mpcceremony/testdata/workflowhelper",
+ operationalHelper: "./scripts/mpc-rehearsal-operational-evidence",
+ } {
+ command := exec.Command("go", "build", "-o", output, pkg)
+ command.Dir = repoRoot
+ if combined, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("build %s: %v\n%s", pkg, err, combined)
+ }
+ }
+
+ workflowRoot := filepath.Join(t.TempDir(), "workflow")
+ command := exec.Command(workflowHelper, workflowRoot, operationalHelper)
+ command.Dir = repoRoot
+ if combined, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("run complete signed lifecycle: %v\n%s", err, combined)
+ }
+
+ ceremonyRoot := filepath.Join(workflowRoot, "ceremony")
+ coordinatorPublicKeyPath := filepath.Join(
+ workflowRoot,
+ "identity-keys",
+ "trusted-coordinator.ed25519.public.hex",
+ )
+ trusted, err := LoadSignedDefinition(TrustPaths{
+ DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"),
+ DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"),
+ CoordinatorPublicKeyPath: coordinatorPublicKeyPath,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ coordinatorPublicKey, err := os.ReadFile(coordinatorPublicKeyPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releaseDir := filepath.Join(workflowRoot, "release")
+ verifyOptions := VerifyReleaseOptions{
+ DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"),
+ DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"),
+ CoordinatorPublicKeyHex: strings.TrimSpace(string(coordinatorPublicKey)),
+ KeysDir: releaseDir,
+ TrustedPublicKeyHex: trusted.Definition.ReleaseSigner.Ed25519PublicKeyHex,
+ ExpectedSignatureKeyID: trusted.Definition.ReleaseSigner.KeyID,
+ RequireProvingKey: true,
+ }
+ verified, err := VerifyRelease(verifyOptions)
+ if err != nil {
+ t.Fatalf("verify complete signed lifecycle release: %v", err)
+ }
+ if verified.Candidate.Phase1.Chain.Name != "chain-0002.json" ||
+ verified.Candidate.Phase2.Chain.Name != "chain-0002.json" {
+ t.Fatalf(
+ "candidate does not retain replay-scope accepted-chain names: %q, %q",
+ verified.Candidate.Phase1.Chain.Name,
+ verified.Candidate.Phase2.Chain.Name,
+ )
+ }
+ var report VerificationReport
+ if _, err := readCanonicalFile(
+ filepath.Join(releaseDir, verified.Candidate.VerificationReport.Name),
+ &report,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if !report.NativeProofVerified ||
+ !report.WrongCredentialRejected ||
+ !report.WrongDestinationRejected ||
+ !report.WrongDigestRejected ||
+ !report.WrongProofRejected ||
+ !report.WrongVKRejected ||
+ !report.ProofTruncationRejected ||
+ !report.ProofAppendRejected {
+ t.Fatalf("finalization report was published without every executed proof check: %+v", report)
+ }
+
+ preliminaryClone := filepath.Join(t.TempDir(), "preliminary")
+ copyRegularTree(t, filepath.Join(workflowRoot, "preliminary"), preliminaryClone)
+ tamperRegularFile(t, filepath.Join(preliminaryClone, NativeVerifyingKeyFile))
+ if _, err := VerifyPreliminaryFinalKeys(
+ preliminaryClone,
+ verifyOptions.CoordinatorPublicKeyHex,
+ ); err == nil {
+ t.Fatal("authenticated preliminary key tree accepted a changed native VK")
+ }
+
+ tamperCases := []struct {
+ name string
+ file string
+ }{
+ {name: "candidate metadata", file: CandidateMetadataFile},
+ {name: "verification report", file: VerificationReportFile},
+ {name: "public proof evidence", file: PublicEvidenceFile},
+ {name: "native verifying key", file: NativeVerifyingKeyFile},
+ {name: "first independent audit", file: "audits/0001.json"},
+ {name: "operational bundle", file: OperationalEvidenceBundleFile},
+ {name: "authenticated accepted chain", file: "phase1/chain-0002.json"},
+ {
+ name: "inner returned-custody handoff",
+ file: "operational/phase1/heads/0001/return-handoff.json",
+ },
+ {name: "final transcript", file: FinalTranscriptFile},
+ {name: "signed manifest", file: "manifest.json"},
+ {name: "release checksums", file: ReleaseChecksumsFile},
+ }
+ for _, test := range tamperCases {
+ t.Run(test.name, func(t *testing.T) {
+ clone := filepath.Join(t.TempDir(), "release")
+ copyRegularTree(t, releaseDir, clone)
+ tamperRegularFile(t, filepath.Join(clone, filepath.FromSlash(test.file)))
+ options := verifyOptions
+ options.KeysDir = clone
+ if _, err := VerifyRelease(options); err == nil {
+ t.Fatalf("release verification accepted changed %s", test.file)
+ }
+ })
+ }
+}
+
+func copyRegularTree(t *testing.T, source, destination string) {
+ t.Helper()
+ err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ relative, err := filepath.Rel(source, path)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(destination, relative)
+ if entry.Type()&os.ModeSymlink != 0 {
+ return errors.New("test source tree contains a symlink")
+ }
+ if entry.IsDir() {
+ return os.MkdirAll(target, 0o700)
+ }
+ if !entry.Type().IsRegular() {
+ return errors.New("test source tree contains a non-regular file")
+ }
+ input, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ _ = input.Close()
+ return err
+ }
+ if _, err := io.Copy(output, input); err != nil {
+ _ = input.Close()
+ _ = output.Close()
+ return err
+ }
+ if err := input.Close(); err != nil {
+ _ = output.Close()
+ return err
+ }
+ return output.Close()
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func tamperRegularFile(t *testing.T, path string) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) == 0 {
+ t.Fatalf("cannot tamper empty file %s", path)
+ }
+ data[len(data)/2] ^= 1
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go
new file mode 100644
index 00000000..3a21165a
--- /dev/null
+++ b/internal/mpcceremony/model.go
@@ -0,0 +1,574 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "golang.org/x/crypto/blake2b"
+)
+
+const (
+ DefinitionSchema = "proof-tool-mpc-ceremony-definition-v1"
+ DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1"
+ ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v1"
+ ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v1"
+ ChainSchema = "proof-tool-mpc-accepted-chain-v1"
+ ChainRecordSchema = "proof-tool-mpc-acceptance-record-v1"
+ CloseRecordSchema = "proof-tool-mpc-close-record-v1"
+ BeaconRecordSchema = "proof-tool-mpc-beacon-record-v1"
+ SealRecordSchema = "proof-tool-mpc-seal-record-v1"
+ AuditRecordSchema = "proof-tool-mpc-audit-record-v1"
+ FinalTranscriptSchema = "proof-tool-mpc-final-transcript-v1"
+
+ KeyVersionDestinationV2 = "ownership-destination-v2"
+ CircuitIDDestinationV2 = "root-ownership-destination-v2/bls12-381/groth16"
+ CurveBLS12381 = "BLS12-381"
+ BackendGroth16 = "groth16"
+ GnarkVersion = "v0.15.0"
+ GnarkCryptoVersion = "v0.20.1"
+ DrandVersion = "v2.1.6"
+ ProductionGoVersion = "go1.26.5"
+ ProductionGOOS = "linux"
+ ProductionGOARCH = "amd64"
+ ProductionGOAMD64 = "v1"
+ ProductionCompiler = "gc"
+ ProductionBuildMode = "exe"
+ SignatureAlgorithm = "Ed25519"
+ BeaconExtractionV1 = "sha256-domain-separated-length-prefixed-v1"
+ BeaconProviderDrand = "drand"
+ BeaconNetworkQuicknet = "quicknet-mainnet"
+ BeaconQuicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
+ BeaconQuicknetPublicKey = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"
+ BeaconQuicknetScheme = "bls-unchained-g1-rfc9380"
+ BeaconQuicknetGenesis = int64(1692803367)
+ BeaconQuicknetPeriod = uint32(3)
+ ModeRehearsal = "rehearsal"
+ ModeProduction = "production"
+
+ MaxParticipants = 20
+)
+
+type Phase string
+
+const (
+ Phase1 Phase = "phase1"
+ Phase2 Phase = "phase2"
+)
+
+func (p Phase) Validate() error {
+ switch p {
+ case Phase1, Phase2:
+ return nil
+ default:
+ return fmt.Errorf("unsupported phase %q", p)
+ }
+}
+
+// Digest binds both hashes used by the existing proof artifact pipeline.
+type Digest struct {
+ SHA256 string `json:"sha256"`
+ Blake2b256 string `json:"blake2b256"`
+ Size int64 `json:"size"`
+}
+
+func NewDigest(data []byte) Digest {
+ sha := sha256.Sum256(data)
+ blake := blake2b.Sum256(data)
+ return Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha[:]),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake[:]),
+ Size: int64(len(data)),
+ }
+}
+
+func (d Digest) Validate() error {
+ if err := validateTaggedHex(d.SHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("sha256: %w", err)
+ }
+ if err := validateTaggedHex(d.Blake2b256, "blake2b256:", blake2b.Size256); err != nil {
+ return fmt.Errorf("blake2b256: %w", err)
+ }
+ if d.Size <= 0 {
+ return fmt.Errorf("size must be positive, got %d", d.Size)
+ }
+ return nil
+}
+
+type ArtifactRef struct {
+ Name string `json:"name"`
+ Digest Digest `json:"digest"`
+}
+
+func (r ArtifactRef) Validate() error {
+ if err := validateArtifactName(r.Name); err != nil {
+ return err
+ }
+ if err := r.Digest.Validate(); err != nil {
+ return fmt.Errorf("artifact %q digest: %w", r.Name, err)
+ }
+ return nil
+}
+
+type Identity struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ KeyID string `json:"key_id"`
+ Ed25519PublicKeyHex string `json:"ed25519_public_key_hex"`
+ PublicKeyFingerprint string `json:"public_key_fingerprint"`
+}
+
+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 := validateID("identity key_id", i.KeyID); err != nil {
+ return err
+ }
+ pub, err := decodeFixedHex(i.Ed25519PublicKeyHex, 32)
+ if 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)
+ }
+ return nil
+}
+
+func NewIdentity(id, displayName, keyID string, publicKey []byte) (Identity, error) {
+ result := Identity{
+ ID: id,
+ DisplayName: displayName,
+ KeyID: keyID,
+ Ed25519PublicKeyHex: hex.EncodeToString(publicKey),
+ PublicKeyFingerprint: taggedSHA256(publicKey),
+ }
+ return result, result.Validate()
+}
+
+type Participant struct {
+ Identity Identity `json:"identity"`
+}
+
+func (p Participant) Validate() error {
+ return p.Identity.Validate()
+}
+
+type PhasePolicy struct {
+ Participants []string `json:"participants"`
+ Minimum uint8 `json:"minimum"`
+}
+
+func (p PhasePolicy) Validate(roster map[string]Participant) error {
+ if len(p.Participants) == 0 {
+ return errors.New("phase participants must not be empty")
+ }
+ if len(p.Participants) > MaxParticipants {
+ return fmt.Errorf("phase participants exceed maximum %d", MaxParticipants)
+ }
+ if p.Minimum == 0 || int(p.Minimum) > len(p.Participants) {
+ return fmt.Errorf("phase minimum %d must be between 1 and %d", p.Minimum, len(p.Participants))
+ }
+ seen := make(map[string]struct{}, len(p.Participants))
+ for index, participantID := range p.Participants {
+ if _, ok := roster[participantID]; !ok {
+ return fmt.Errorf("phase participant %d %q is not in the roster", index, participantID)
+ }
+ if _, duplicate := seen[participantID]; duplicate {
+ return fmt.Errorf("phase participant %q is duplicated", participantID)
+ }
+ seen[participantID] = struct{}{}
+ }
+ return nil
+}
+
+type CircuitBinding struct {
+ KeyVersion string `json:"key_version"`
+ CircuitID string `json:"circuit_id"`
+ Curve string `json:"curve"`
+ Backend string `json:"backend"`
+ R1CS ArtifactRef `json:"r1cs"`
+ Constraints uint64 `json:"constraints"`
+ InternalVariables uint64 `json:"internal_variables"`
+ SecretVariables uint64 `json:"secret_variables"`
+ PublicVariables uint64 `json:"public_variables"`
+ DomainSize uint64 `json:"domain_size"`
+ Phase2Shape Phase2Shape `json:"phase2_shape"`
+}
+
+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)
+ }
+ if b.Curve != CurveBLS12381 {
+ return fmt.Errorf("curve %q, want %q", b.Curve, CurveBLS12381)
+ }
+ if b.Backend != BackendGroth16 {
+ return fmt.Errorf("backend %q, want %q", b.Backend, BackendGroth16)
+ }
+ if err := b.R1CS.Validate(); err != nil {
+ return fmt.Errorf("r1cs: %w", err)
+ }
+ if b.Constraints == 0 || b.InternalVariables == 0 || b.SecretVariables == 0 || b.PublicVariables == 0 {
+ return errors.New("circuit counts must all be positive")
+ }
+ if !isPowerOfTwo(b.DomainSize) || b.DomainSize < b.Constraints {
+ return fmt.Errorf("domain_size %d must be a power of two covering %d constraints", b.DomainSize, b.Constraints)
+ }
+ if err := b.Phase2Shape.Validate(); err != nil {
+ return fmt.Errorf("phase2_shape: %w", err)
+ }
+ if b.Phase2Shape.ChallengeLength != 0 {
+ return fmt.Errorf("phase2_shape challenge_length %d, want 0 for deterministic genesis", b.Phase2Shape.ChallengeLength)
+ }
+ return nil
+}
+
+type SoftwareBinding struct {
+ ProofToolVersion string `json:"proof_tool_version"`
+ GnarkVersion string `json:"gnark_version"`
+ GnarkCryptoVersion string `json:"gnark_crypto_version"`
+ DrandVersion string `json:"drand_version"`
+ GoVersion string `json:"go_version"`
+ GoOS string `json:"goos"`
+ GoArch string `json:"goarch"`
+ GoAMD64 string `json:"goamd64,omitempty"`
+ Compiler string `json:"compiler"`
+ BuildMode string `json:"build_mode"`
+ CGOEnabled bool `json:"cgo_enabled"`
+ TrimPath bool `json:"trimpath"`
+ SourceCommit string `json:"source_commit"`
+ SourceDirty bool `json:"source_dirty"`
+ ToolBinary Digest `json:"tool_binary"`
+}
+
+func (b SoftwareBinding) Validate() error {
+ if strings.TrimSpace(b.ProofToolVersion) == "" {
+ return errors.New("proof_tool_version is required")
+ }
+ if b.GnarkVersion != GnarkVersion {
+ return fmt.Errorf("gnark_version %q, want %q", b.GnarkVersion, GnarkVersion)
+ }
+ if b.GnarkCryptoVersion != GnarkCryptoVersion {
+ return fmt.Errorf("gnark_crypto_version %q, want %q", b.GnarkCryptoVersion, GnarkCryptoVersion)
+ }
+ if b.DrandVersion != DrandVersion {
+ return fmt.Errorf("drand_version %q, want %q", b.DrandVersion, DrandVersion)
+ }
+ if strings.TrimSpace(b.GoVersion) == "" {
+ return errors.New("go_version is required")
+ }
+ if strings.TrimSpace(b.GoOS) == "" {
+ return errors.New("goos is required")
+ }
+ if strings.TrimSpace(b.GoArch) == "" {
+ return errors.New("goarch is required")
+ }
+ if b.GoArch == ProductionGOARCH && strings.TrimSpace(b.GoAMD64) == "" {
+ return errors.New("goamd64 is required for amd64 binaries")
+ }
+ if strings.TrimSpace(b.Compiler) == "" {
+ return errors.New("compiler is required")
+ }
+ if strings.TrimSpace(b.BuildMode) == "" {
+ return errors.New("build_mode is required")
+ }
+ if err := validateHex(b.SourceCommit, 20); err != nil {
+ return fmt.Errorf("source_commit: %w", err)
+ }
+ if err := b.ToolBinary.Validate(); err != nil {
+ return fmt.Errorf("tool_binary: %w", err)
+ }
+ return nil
+}
+
+type BeaconPolicy struct {
+ Provider string `json:"provider"`
+ Network string `json:"network"`
+ ChainHashHex string `json:"chain_hash_hex"`
+ PublicKeyHex string `json:"public_key_hex"`
+ Scheme string `json:"scheme"`
+ GenesisTimeUnix int64 `json:"genesis_time_unix"`
+ PeriodSeconds uint32 `json:"period_seconds"`
+ Extraction string `json:"extraction"`
+ MinimumChallengeBytes uint16 `json:"minimum_challenge_bytes"`
+ MinimumWitnessLeadSeconds uint32 `json:"minimum_witness_lead_seconds"`
+ FutureRoundRequired bool `json:"future_round_required"`
+}
+
+func (p BeaconPolicy) Validate() error {
+ if p.Provider != BeaconProviderDrand {
+ return fmt.Errorf("beacon provider %q, want %q", p.Provider, BeaconProviderDrand)
+ }
+ if p.Network != BeaconNetworkQuicknet {
+ return fmt.Errorf("beacon network %q, want %q", p.Network, BeaconNetworkQuicknet)
+ }
+ if p.ChainHashHex != BeaconQuicknetChainHash {
+ return errors.New("beacon chain_hash_hex does not match pinned drand quicknet mainnet")
+ }
+ if p.PublicKeyHex != BeaconQuicknetPublicKey {
+ return errors.New("beacon public_key_hex does not match pinned drand quicknet mainnet")
+ }
+ if err := validateHex(p.PublicKeyHex, 96); err != nil {
+ return fmt.Errorf("beacon public_key_hex: %w", err)
+ }
+ if p.Scheme != BeaconQuicknetScheme {
+ return fmt.Errorf("beacon scheme %q, want %q", p.Scheme, BeaconQuicknetScheme)
+ }
+ if p.GenesisTimeUnix != BeaconQuicknetGenesis {
+ return fmt.Errorf("beacon genesis_time_unix %d, want %d", p.GenesisTimeUnix, BeaconQuicknetGenesis)
+ }
+ if p.PeriodSeconds != BeaconQuicknetPeriod {
+ return fmt.Errorf("beacon period_seconds %d, want %d", p.PeriodSeconds, BeaconQuicknetPeriod)
+ }
+ if p.Extraction != BeaconExtractionV1 {
+ return fmt.Errorf("beacon extraction %q, want %q", p.Extraction, BeaconExtractionV1)
+ }
+ if p.MinimumChallengeBytes != sha256.Size {
+ return fmt.Errorf("beacon minimum_challenge_bytes must be exactly %d", sha256.Size)
+ }
+ if p.MinimumWitnessLeadSeconds == 0 {
+ return errors.New("beacon minimum_witness_lead_seconds must be positive")
+ }
+ if !p.FutureRoundRequired {
+ return errors.New("beacon future_round_required must be true")
+ }
+ return nil
+}
+
+type validatable interface {
+ Validate() error
+}
+
+// MarshalCanonical is the sole encoding accepted for signed records. Struct field
+// order is part of the schema; callers must not pass maps.
+func MarshalCanonical(value any) ([]byte, error) {
+ if value == nil {
+ return nil, errors.New("cannot marshal nil canonical value")
+ }
+ if _, isMap := value.(map[string]any); isMap {
+ return nil, errors.New("canonical records must use fixed-field structs, not maps")
+ }
+ v, ok := value.(validatable)
+ if !ok {
+ return nil, errors.New("canonical records must implement Validate")
+ }
+ if err := v.Validate(); err != nil {
+ return nil, err
+ }
+ data, err := json.Marshal(value)
+ if err != nil {
+ return nil, fmt.Errorf("marshal canonical JSON: %w", err)
+ }
+ return data, nil
+}
+
+// UnmarshalCanonical rejects duplicate and unknown fields, trailing input, and
+// every byte encoding other than MarshalCanonical's exact output.
+func UnmarshalCanonical(data []byte, destination any) error {
+ if destination == nil {
+ return errors.New("canonical JSON destination is nil")
+ }
+ if err := rejectDuplicateKeysAndTrailing(data); err != nil {
+ return err
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(destination); err != nil {
+ return fmt.Errorf("decode canonical JSON: %w", err)
+ }
+ if token, err := decoder.Token(); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("unexpected trailing JSON token %v", token)
+ }
+ return fmt.Errorf("read canonical JSON trailer: %w", err)
+ }
+ v, ok := destination.(validatable)
+ if !ok {
+ return errors.New("canonical JSON destination does not implement Validate")
+ }
+ if err := v.Validate(); err != nil {
+ return err
+ }
+ canonical, err := json.Marshal(destination)
+ if err != nil {
+ return fmt.Errorf("remarshal canonical JSON: %w", err)
+ }
+ if !bytes.Equal(data, canonical) {
+ return errors.New("JSON is valid but is not in canonical encoding")
+ }
+ return nil
+}
+
+func canonicalHash(domain string, value any) (string, error) {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return "", err
+ }
+ hash := sha256.New()
+ hash.Write([]byte(domain))
+ hash.Write([]byte{0})
+ hash.Write(data)
+ return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
+}
+
+func rejectDuplicateKeysAndTrailing(data []byte) error {
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.UseNumber()
+ if err := scanJSONValue(decoder); err != nil {
+ return err
+ }
+ if token, err := decoder.Token(); err != io.EOF {
+ if err == nil {
+ return fmt.Errorf("unexpected trailing JSON token %v", token)
+ }
+ return fmt.Errorf("invalid trailing JSON: %w", err)
+ }
+ return nil
+}
+
+func scanJSONValue(decoder *json.Decoder) error {
+ token, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ delim, isDelim := token.(json.Delim)
+ if !isDelim {
+ return nil
+ }
+ switch delim {
+ case '{':
+ seen := make(map[string]struct{})
+ for decoder.More() {
+ keyToken, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON object key: %w", err)
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return errors.New("JSON object key is not a string")
+ }
+ if _, duplicate := seen[key]; duplicate {
+ return fmt.Errorf("duplicate JSON object key %q", key)
+ }
+ seen[key] = struct{}{}
+ if err := scanJSONValue(decoder); err != nil {
+ return err
+ }
+ }
+ end, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON object end: %w", err)
+ }
+ if end != json.Delim('}') {
+ return errors.New("invalid JSON object delimiter")
+ }
+ case '[':
+ for decoder.More() {
+ if err := scanJSONValue(decoder); err != nil {
+ return err
+ }
+ }
+ end, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON array end: %w", err)
+ }
+ if end != json.Delim(']') {
+ return errors.New("invalid JSON array delimiter")
+ }
+ default:
+ return fmt.Errorf("unexpected JSON delimiter %q", delim)
+ }
+ return nil
+}
+
+func validateTaggedHex(value, prefix string, bytes int) error {
+ if !strings.HasPrefix(value, prefix) {
+ return fmt.Errorf("must start with %q", prefix)
+ }
+ return validateHex(strings.TrimPrefix(value, prefix), bytes)
+}
+
+func validateHex(value string, bytes int) error {
+ if len(value) != bytes*2 {
+ return fmt.Errorf("must contain %d lowercase hexadecimal bytes", bytes)
+ }
+ if value != strings.ToLower(value) {
+ return errors.New("must use lowercase hexadecimal")
+ }
+ decoded, err := hex.DecodeString(value)
+ if err != nil || len(decoded) != bytes {
+ return fmt.Errorf("invalid hexadecimal value")
+ }
+ return nil
+}
+
+func decodeFixedHex(value string, bytes int) ([]byte, error) {
+ if err := validateHex(value, bytes); err != nil {
+ return nil, err
+ }
+ return hex.DecodeString(value)
+}
+
+func validateID(label, value string) error {
+ if value == "" || len(value) > 128 {
+ return fmt.Errorf("%s must contain 1 to 128 characters", label)
+ }
+ for _, r := range value {
+ if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' && r != ':' {
+ return fmt.Errorf("%s %q contains an unsupported character", label, value)
+ }
+ }
+ return nil
+}
+
+func validateArtifactName(value string) error {
+ if value == "" || len(value) > 512 || !utf8.ValidString(value) {
+ return errors.New("artifact name must be non-empty valid UTF-8 of at most 512 bytes")
+ }
+ 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)
+ }
+ return nil
+}
+
+func validateTimestamp(label, value string) error {
+ if value == "" || !strings.HasSuffix(value, "Z") {
+ return fmt.Errorf("%s must be a UTC RFC3339 timestamp ending in Z", label)
+ }
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ if parsed.Format(time.RFC3339Nano) != value {
+ return fmt.Errorf("%s is not a canonical RFC3339 timestamp", label)
+ }
+ return nil
+}
+
+func taggedSHA256(data []byte) string {
+ sum := sha256.Sum256(data)
+ return "sha256:" + hex.EncodeToString(sum[:])
+}
+
+func isPowerOfTwo(value uint64) bool {
+ return value != 0 && value&(value-1) == 0
+}
diff --git a/internal/mpcceremony/model_test.go b/internal/mpcceremony/model_test.go
new file mode 100644
index 00000000..fbb64c56
--- /dev/null
+++ b/internal/mpcceremony/model_test.go
@@ -0,0 +1,47 @@
+package mpcceremony
+
+import "testing"
+
+func TestBeaconPolicyPinsOfficialDrandQuicknetMainnet(t *testing.T) {
+ valid := BeaconPolicy{
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ ChainHashHex: BeaconQuicknetChainHash,
+ PublicKeyHex: BeaconQuicknetPublicKey,
+ Scheme: BeaconQuicknetScheme,
+ GenesisTimeUnix: BeaconQuicknetGenesis,
+ PeriodSeconds: BeaconQuicknetPeriod,
+ Extraction: BeaconExtractionV1,
+ MinimumChallengeBytes: 32,
+ MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds,
+ FutureRoundRequired: true,
+ }
+ if err := valid.Validate(); err != nil {
+ t.Fatalf("pinned beacon policy rejected: %v", err)
+ }
+ cases := []struct {
+ name string
+ mutate func(*BeaconPolicy)
+ }{
+ {"provider", func(p *BeaconPolicy) { p.Provider = "other" }},
+ {"network", func(p *BeaconPolicy) { p.Network = "quicknet-testnet" }},
+ {"chain hash", func(p *BeaconPolicy) { p.ChainHashHex = "00" + p.ChainHashHex[2:] }},
+ {"public key", func(p *BeaconPolicy) { p.PublicKeyHex = "00" + p.PublicKeyHex[2:] }},
+ {"scheme", func(p *BeaconPolicy) { p.Scheme = "other" }},
+ {"genesis", func(p *BeaconPolicy) { p.GenesisTimeUnix++ }},
+ {"period", func(p *BeaconPolicy) { p.PeriodSeconds++ }},
+ {"extraction", func(p *BeaconPolicy) { p.Extraction = "raw-randomness" }},
+ {"challenge length", func(p *BeaconPolicy) { p.MinimumChallengeBytes = 64 }},
+ {"witness lead", func(p *BeaconPolicy) { p.MinimumWitnessLeadSeconds = 0 }},
+ {"future round", func(p *BeaconPolicy) { p.FutureRoundRequired = false }},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ changed := valid
+ test.mutate(&changed)
+ if err := changed.Validate(); err == nil {
+ t.Fatal("modified beacon policy unexpectedly accepted")
+ }
+ })
+ }
+}
diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go
new file mode 100644
index 00000000..ab8080d2
--- /dev/null
+++ b/internal/mpcceremony/operational.go
@@ -0,0 +1,1067 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+)
+
+const (
+ EnrollmentRecordSchema = "proof-tool-mpc-enrollment-record-v1"
+ TransferHandoffSchema = "proof-tool-mpc-transfer-handoff-v1"
+ TransferReceiptSchema = "proof-tool-mpc-transfer-receipt-v1"
+ PublicWitnessReceiptSchema = "proof-tool-mpc-public-witness-receipt-v1"
+ MultiRelayBeaconEvidenceSchema = "proof-tool-mpc-multi-relay-beacon-evidence-v1"
+ ImmutableMirrorReceiptSchema = "proof-tool-mpc-immutable-mirror-receipt-v1"
+ GovernanceRecordSchema = "proof-tool-mpc-governance-record-v1"
+ OperationalSigningRequestSchema = "proof-tool-mpc-operational-signing-request-v1"
+)
+
+type OperationalRecordType string
+
+const (
+ RecordEnrollment OperationalRecordType = "enrollment"
+ RecordHandoff OperationalRecordType = "handoff"
+ RecordReceipt OperationalRecordType = "receipt"
+ RecordPublicWitness OperationalRecordType = "public-witness"
+ RecordBeaconEvidence OperationalRecordType = "beacon-evidence"
+ RecordMirrorReceipt OperationalRecordType = "mirror-receipt"
+ RecordEvidenceBundle OperationalRecordType = "evidence-bundle"
+ RecordGovernance OperationalRecordType = "governance"
+)
+
+func (t OperationalRecordType) Validate() error {
+ switch t {
+ case RecordEnrollment, RecordHandoff, RecordReceipt, RecordPublicWitness,
+ RecordBeaconEvidence, RecordMirrorReceipt, RecordEvidenceBundle, RecordGovernance:
+ return nil
+ default:
+ return fmt.Errorf("unsupported operational record type %q", t)
+ }
+}
+
+type EnrollmentRole string
+
+const (
+ EnrollmentCoordinator EnrollmentRole = "coordinator"
+ EnrollmentReleaseSigner EnrollmentRole = "release-signer"
+ EnrollmentAuditor EnrollmentRole = "auditor"
+ EnrollmentParticipant EnrollmentRole = "participant"
+ EnrollmentPublicWitness EnrollmentRole = "public-witness"
+ EnrollmentMirrorOperator EnrollmentRole = "mirror-operator"
+)
+
+// EnrollmentRecord is the proof-of-possession message signed by an enrolled
+// identity. Definition and roster digests make consent specific to one frozen
+// ceremony, while the disclosure digest preserves a reviewable independence
+// statement without embedding potentially sensitive prose in every mirror.
+type EnrollmentRecord struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Definition Digest `json:"definition"`
+ FullRosterSHA256 string `json:"full_roster_sha256"`
+ Identity Identity `json:"identity"`
+ Role EnrollmentRole `json:"role"`
+ RoleIndex uint16 `json:"role_index"`
+ IndependenceDisclosure ArtifactRef `json:"independence_disclosure"`
+ EnrolledAt string `json:"enrolled_at"`
+}
+
+func (r EnrollmentRecord) Validate() error {
+ if r.Schema != EnrollmentRecordSchema {
+ return fmt.Errorf("enrollment schema %q, want %q", r.Schema, EnrollmentRecordSchema)
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := r.Definition.Validate(); err != nil {
+ return fmt.Errorf("definition: %w", err)
+ }
+ if err := validateTaggedHex(r.FullRosterSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("full_roster_sha256: %w", err)
+ }
+ if err := r.Identity.Validate(); err != nil {
+ return fmt.Errorf("identity: %w", err)
+ }
+ switch r.Role {
+ case EnrollmentCoordinator, EnrollmentReleaseSigner, EnrollmentAuditor, EnrollmentParticipant,
+ EnrollmentPublicWitness, EnrollmentMirrorOperator:
+ default:
+ return fmt.Errorf("unsupported enrollment role %q", r.Role)
+ }
+ if r.RoleIndex == 0 {
+ return errors.New("role_index is one-based and must be positive")
+ }
+ if err := r.IndependenceDisclosure.Validate(); err != nil {
+ return fmt.Errorf("independence_disclosure: %w", err)
+ }
+ return validateTimestamp("enrolled_at", r.EnrolledAt)
+}
+
+// TransferSourceBinding freezes the implementation and circuit that produced
+// the transferred payload. It is copied into receipts so they remain
+// independently machine-checkable even if an envelope is unavailable.
+type TransferSourceBinding struct {
+ SourceCommit string `json:"source_commit"`
+ ToolBinary Digest `json:"tool_binary"`
+ R1CS ArtifactRef `json:"r1cs"`
+}
+
+func (b TransferSourceBinding) Validate() error {
+ if err := validateHex(b.SourceCommit, 20); err != nil {
+ return fmt.Errorf("source_commit: %w", err)
+ }
+ if err := b.ToolBinary.Validate(); err != nil {
+ return fmt.Errorf("tool_binary: %w", err)
+ }
+ if err := b.R1CS.Validate(); err != nil {
+ return fmt.Errorf("r1cs: %w", err)
+ }
+ return nil
+}
+
+// TransferHandoff is signed by SenderID before bytes leave its custody.
+type TransferHandoff struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ Index uint8 `json:"index"`
+ PredecessorHeadID string `json:"predecessor_head_id"`
+ Source TransferSourceBinding `json:"source"`
+ Files []ArtifactRef `json:"files"`
+ SenderID string `json:"sender_id"`
+ SenderKeyID string `json:"sender_key_id"`
+ RecipientID string `json:"recipient_id"`
+ RecipientKeyID string `json:"recipient_key_id"`
+ CreatedAt string `json:"created_at"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+func (r TransferHandoff) Validate() error {
+ if r.Schema != TransferHandoffSchema {
+ return fmt.Errorf("handoff schema %q, want %q", r.Schema, TransferHandoffSchema)
+ }
+ if err := validateOperationalScope(r.CeremonyID, r.Phase, r.Index, r.PredecessorHeadID); err != nil {
+ return err
+ }
+ if err := r.Source.Validate(); err != nil {
+ return fmt.Errorf("source: %w", err)
+ }
+ if err := validateArtifactSet("files", r.Files); err != nil {
+ return err
+ }
+ if err := validateTransferIdentities(r.SenderID, r.SenderKeyID, r.RecipientID, r.RecipientKeyID); err != nil {
+ return err
+ }
+ if err := validateTimestamp("created_at", r.CreatedAt); err != nil {
+ return err
+ }
+ if err := validateTimestamp("expires_at", r.ExpiresAt); err != nil {
+ return err
+ }
+ created, _ := time.Parse(time.RFC3339Nano, r.CreatedAt)
+ expires, _ := time.Parse(time.RFC3339Nano, r.ExpiresAt)
+ if !expires.After(created) {
+ return errors.New("expires_at must be strictly after created_at")
+ }
+ return nil
+}
+
+type TransferReceiptKind string
+
+const (
+ ReceiptReceiver TransferReceiptKind = "receiver"
+)
+
+// TransferReceipt is always signed by the receiving custodian named by the
+// handoff. Outbound and return custody therefore use separate handoffs and one
+// receiver acknowledgement for each direction.
+type TransferReceipt struct {
+ Schema string `json:"schema"`
+ Kind TransferReceiptKind `json:"kind"`
+ HandoffSHA256 string `json:"handoff_sha256"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ Index uint8 `json:"index"`
+ PredecessorHeadID string `json:"predecessor_head_id"`
+ Source TransferSourceBinding `json:"source"`
+ Files []ArtifactRef `json:"files"`
+ SenderID string `json:"sender_id"`
+ SenderKeyID string `json:"sender_key_id"`
+ RecipientID string `json:"recipient_id"`
+ RecipientKeyID string `json:"recipient_key_id"`
+ SignerID string `json:"signer_id"`
+ SignerKeyID string `json:"signer_key_id"`
+ ReceivedAt string `json:"received_at"`
+}
+
+func (r TransferReceipt) Validate() error {
+ if r.Schema != TransferReceiptSchema {
+ return fmt.Errorf("receipt schema %q, want %q", r.Schema, TransferReceiptSchema)
+ }
+ switch r.Kind {
+ case ReceiptReceiver:
+ default:
+ return fmt.Errorf("unsupported transfer receipt kind %q", r.Kind)
+ }
+ if err := validateTaggedHex(r.HandoffSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("handoff_sha256: %w", err)
+ }
+ if err := validateOperationalScope(r.CeremonyID, r.Phase, r.Index, r.PredecessorHeadID); err != nil {
+ return err
+ }
+ if err := r.Source.Validate(); err != nil {
+ return fmt.Errorf("source: %w", err)
+ }
+ if err := validateArtifactSet("files", r.Files); err != nil {
+ return err
+ }
+ if err := validateTransferIdentities(r.SenderID, r.SenderKeyID, r.RecipientID, r.RecipientKeyID); err != nil {
+ return err
+ }
+ if err := validateID("signer_id", r.SignerID); err != nil {
+ return err
+ }
+ if err := validateID("signer_key_id", r.SignerKeyID); err != nil {
+ return err
+ }
+ if r.SignerID != r.RecipientID || r.SignerKeyID != r.RecipientKeyID {
+ return errors.New("transfer receipt must be signed by the handoff recipient")
+ }
+ return validateTimestamp("received_at", r.ReceivedAt)
+}
+
+type PublicWitnessReceipt struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ CloseID string `json:"close_id"`
+ ChainHeadID string `json:"chain_head_id"`
+ Closure ArtifactRef `json:"closure"`
+ BeaconRound uint64 `json:"beacon_round"`
+ BeaconScheduledAt string `json:"beacon_scheduled_at"`
+ PublicationLocationSHA string `json:"publication_location_sha256"`
+ Witness Identity `json:"witness"`
+ ObservedAt string `json:"observed_at"`
+}
+
+func (r PublicWitnessReceipt) Validate() error {
+ if r.Schema != PublicWitnessReceiptSchema {
+ return fmt.Errorf("public witness schema %q, want %q", r.Schema, PublicWitnessReceiptSchema)
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := r.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("close_id", r.CloseID); err != nil {
+ return err
+ }
+ if err := validateHashID("chain_head_id", r.ChainHeadID); err != nil {
+ return err
+ }
+ if err := r.Closure.Validate(); err != nil {
+ return fmt.Errorf("closure: %w", err)
+ }
+ if r.BeaconRound == 0 {
+ return errors.New("beacon_round must be positive")
+ }
+ if err := validateTimestamp("beacon_scheduled_at", r.BeaconScheduledAt); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(r.PublicationLocationSHA, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("publication_location_sha256: %w", err)
+ }
+ if err := r.Witness.Validate(); err != nil {
+ return fmt.Errorf("witness: %w", err)
+ }
+ if err := validateTimestamp("observed_at", r.ObservedAt); err != nil {
+ return err
+ }
+ scheduled, _ := time.Parse(time.RFC3339Nano, r.BeaconScheduledAt)
+ observed, _ := time.Parse(time.RFC3339Nano, r.ObservedAt)
+ if !observed.Before(scheduled) {
+ return errors.New("public witness observation must be strictly before the beacon round")
+ }
+ return nil
+}
+
+type RelayObservation struct {
+ RelayID string `json:"relay_id"`
+ OperatorID string `json:"operator_id"`
+ EndpointSHA256 string `json:"endpoint_sha256"`
+ RawResponse ArtifactRef `json:"raw_response"`
+ RetrievedAt string `json:"retrieved_at"`
+ VerifiedRandomness string `json:"verified_randomness_hex"`
+}
+
+func (r RelayObservation) Validate() error {
+ if err := validateID("relay_id", r.RelayID); err != nil {
+ return err
+ }
+ if err := validateID("operator_id", r.OperatorID); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(r.EndpointSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("endpoint_sha256: %w", err)
+ }
+ if err := r.RawResponse.Validate(); err != nil {
+ return fmt.Errorf("raw_response: %w", err)
+ }
+ if err := validateTimestamp("retrieved_at", r.RetrievedAt); err != nil {
+ return err
+ }
+ return validateHex(r.VerifiedRandomness, sha256.Size)
+}
+
+type MultiRelayBeaconEvidence struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ CloseID string `json:"close_id"`
+ BeaconRound uint64 `json:"beacon_round"`
+ Provider string `json:"provider"`
+ Network string `json:"network"`
+ Observations []RelayObservation `json:"observations"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ RecordedAt string `json:"recorded_at"`
+}
+
+// ImmutableMirrorReceipt is signed by an independent archive operator after
+// durably storing the exact accepted-head artifacts.
+type ImmutableMirrorReceipt struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ Index uint8 `json:"index"`
+ AcceptedHeadID string `json:"accepted_head_id"`
+ Files []ArtifactRef `json:"files"`
+ Mirror Identity `json:"mirror"`
+ StorageLocationSHA256 string `json:"storage_location_sha256"`
+ StoredAt string `json:"stored_at"`
+}
+
+func (r ImmutableMirrorReceipt) Validate() error {
+ if r.Schema != ImmutableMirrorReceiptSchema {
+ return fmt.Errorf("mirror receipt schema %q, want %q", r.Schema, ImmutableMirrorReceiptSchema)
+ }
+ if err := validateOperationalScope(r.CeremonyID, r.Phase, r.Index, r.AcceptedHeadID); err != nil {
+ return err
+ }
+ if err := validateArtifactSet("files", r.Files); err != nil {
+ return err
+ }
+ if err := r.Mirror.Validate(); err != nil {
+ return fmt.Errorf("mirror: %w", err)
+ }
+ if err := validateTaggedHex(r.StorageLocationSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("storage_location_sha256: %w", err)
+ }
+ return validateTimestamp("stored_at", r.StoredAt)
+}
+
+func (r MultiRelayBeaconEvidence) Validate() error {
+ if r.Schema != MultiRelayBeaconEvidenceSchema {
+ return fmt.Errorf("multi-relay beacon schema %q, want %q", r.Schema, MultiRelayBeaconEvidenceSchema)
+ }
+ if err := validateHashID("ceremony_id", r.CeremonyID); err != nil {
+ return err
+ }
+ if err := r.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("close_id", r.CloseID); err != nil {
+ return err
+ }
+ if r.BeaconRound == 0 {
+ return errors.New("beacon_round must be positive")
+ }
+ if err := validateID("provider", r.Provider); err != nil {
+ return err
+ }
+ if err := validateID("network", r.Network); err != nil {
+ return err
+ }
+ if len(r.Observations) < 3 || len(r.Observations) > 16 {
+ return errors.New("multi-relay beacon evidence requires between 3 and 16 observations")
+ }
+ relayIDs := make(map[string]struct{}, len(r.Observations))
+ operatorIDs := make(map[string]struct{}, len(r.Observations))
+ endpoints := make(map[string]struct{}, len(r.Observations))
+ randomness := ""
+ previousRelay := ""
+ for index, observation := range r.Observations {
+ if err := observation.Validate(); err != nil {
+ return fmt.Errorf("observation %d: %w", index, err)
+ }
+ if index > 0 && observation.RelayID <= previousRelay {
+ return errors.New("beacon observations must be ordered by unique relay_id")
+ }
+ previousRelay = observation.RelayID
+ if _, duplicate := relayIDs[observation.RelayID]; duplicate {
+ return fmt.Errorf("relay_id %q is duplicated", observation.RelayID)
+ }
+ relayIDs[observation.RelayID] = struct{}{}
+ if _, duplicate := operatorIDs[observation.OperatorID]; duplicate {
+ return fmt.Errorf("beacon operator_id %q is duplicated", observation.OperatorID)
+ }
+ operatorIDs[observation.OperatorID] = struct{}{}
+ if _, duplicate := endpoints[observation.EndpointSHA256]; duplicate {
+ return errors.New("beacon relay endpoint digest is duplicated")
+ }
+ endpoints[observation.EndpointSHA256] = struct{}{}
+ if randomness == "" {
+ randomness = observation.VerifiedRandomness
+ } else if observation.VerifiedRandomness != randomness {
+ return errors.New("beacon relays do not agree on verified randomness")
+ }
+ }
+ if err := validateID("coordinator_id", r.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", r.CoordinatorKeyID); err != nil {
+ return err
+ }
+ if err := validateTimestamp("recorded_at", r.RecordedAt); err != nil {
+ return err
+ }
+ recorded, _ := time.Parse(time.RFC3339Nano, r.RecordedAt)
+ for _, observation := range r.Observations {
+ retrieved, _ := time.Parse(time.RFC3339Nano, observation.RetrievedAt)
+ if recorded.Before(retrieved) {
+ return fmt.Errorf("recorded_at predates retrieval from relay %q", observation.RelayID)
+ }
+ }
+ return nil
+}
+
+type GovernanceKind string
+
+const (
+ GovernanceIncident GovernanceKind = "incident"
+ GovernanceRejection GovernanceKind = "rejection"
+ GovernanceAbort GovernanceKind = "abort"
+ GovernanceRestart GovernanceKind = "restart"
+)
+
+type GovernanceRecord struct {
+ Schema string `json:"schema"`
+ Kind GovernanceKind `json:"kind"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ Index uint8 `json:"index"`
+ HeadID string `json:"head_id"`
+ Evidence []ArtifactRef `json:"evidence"`
+ ReasonCode string `json:"reason_code"`
+ StatementSHA256 string `json:"statement_sha256"`
+ NewCeremonyID string `json:"new_ceremony_id"`
+ SignerID string `json:"signer_id"`
+ SignerKeyID string `json:"signer_key_id"`
+ RecordedAt string `json:"recorded_at"`
+}
+
+func (r GovernanceRecord) Validate() error {
+ if r.Schema != GovernanceRecordSchema {
+ return fmt.Errorf("governance schema %q, want %q", r.Schema, GovernanceRecordSchema)
+ }
+ switch r.Kind {
+ case GovernanceIncident, GovernanceRejection, GovernanceAbort, GovernanceRestart:
+ default:
+ return fmt.Errorf("unsupported governance kind %q", r.Kind)
+ }
+ if err := validateOperationalScope(r.CeremonyID, r.Phase, r.Index, r.HeadID); err != nil {
+ return err
+ }
+ if err := validateArtifactSet("evidence", r.Evidence); err != nil {
+ return err
+ }
+ if err := validateID("reason_code", r.ReasonCode); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(r.StatementSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("statement_sha256: %w", err)
+ }
+ if r.Kind == GovernanceRestart {
+ if err := validateHashID("new_ceremony_id", r.NewCeremonyID); err != nil {
+ return err
+ }
+ if r.NewCeremonyID == r.CeremonyID {
+ return errors.New("restart must bind a distinct new_ceremony_id")
+ }
+ } else if r.NewCeremonyID != "" {
+ return errors.New("new_ceremony_id is permitted only for a restart record")
+ }
+ if err := validateID("signer_id", r.SignerID); err != nil {
+ return err
+ }
+ if err := validateID("signer_key_id", r.SignerKeyID); err != nil {
+ return err
+ }
+ return validateTimestamp("recorded_at", r.RecordedAt)
+}
+
+type OperationalSigningRequest struct {
+ Schema string `json:"schema"`
+ RecordType OperationalRecordType `json:"record_type"`
+ RecordSHA256 string `json:"record_sha256"`
+ RecordSize int64 `json:"record_size"`
+}
+
+func NewOperationalSigningRequest(recordType OperationalRecordType, canonical []byte) (OperationalSigningRequest, error) {
+ if err := recordType.Validate(); err != nil {
+ return OperationalSigningRequest{}, err
+ }
+ if len(canonical) == 0 {
+ return OperationalSigningRequest{}, errors.New("canonical record is empty")
+ }
+ request := OperationalSigningRequest{
+ Schema: OperationalSigningRequestSchema,
+ RecordType: recordType,
+ RecordSHA256: taggedSHA256(canonical),
+ RecordSize: int64(len(canonical)),
+ }
+ return request, request.Validate()
+}
+
+func (r OperationalSigningRequest) Validate() error {
+ if r.Schema != OperationalSigningRequestSchema {
+ return fmt.Errorf("signing request schema %q, want %q", r.Schema, OperationalSigningRequestSchema)
+ }
+ if err := r.RecordType.Validate(); err != nil {
+ return err
+ }
+ if err := validateTaggedHex(r.RecordSHA256, "sha256:", sha256.Size); err != nil {
+ return fmt.Errorf("record_sha256: %w", err)
+ }
+ if r.RecordSize <= 0 || r.RecordSize > 16<<20 {
+ return fmt.Errorf("record_size %d is outside [1,%d]", r.RecordSize, 16<<20)
+ }
+ return nil
+}
+
+// ParseOperationalRecord strictly accepts only the canonical bytes that will
+// be signed. It never normalizes attacker-controlled JSON before verification.
+func ParseOperationalRecord(recordType OperationalRecordType, canonical []byte) (any, error) {
+ var destination any
+ switch recordType {
+ case RecordEnrollment:
+ destination = &EnrollmentRecord{}
+ case RecordHandoff:
+ destination = &TransferHandoff{}
+ case RecordReceipt:
+ destination = &TransferReceipt{}
+ case RecordPublicWitness:
+ destination = &PublicWitnessReceipt{}
+ case RecordBeaconEvidence:
+ destination = &MultiRelayBeaconEvidence{}
+ case RecordMirrorReceipt:
+ destination = &ImmutableMirrorReceipt{}
+ case RecordEvidenceBundle:
+ destination = &OperationalEvidenceBundle{}
+ case RecordGovernance:
+ destination = &GovernanceRecord{}
+ default:
+ return nil, fmt.Errorf("unsupported operational record type %q", recordType)
+ }
+ if err := UnmarshalCanonical(canonical, destination); err != nil {
+ return nil, err
+ }
+ return destination, nil
+}
+
+// ImportOperationalSignature converts a raw offline Ed25519 signature into the
+// repository's detached signature format only after verifying it over the
+// exact exported canonical bytes.
+func ImportOperationalSignature(
+ canonical []byte,
+ keyID string,
+ publicKey ed25519.PublicKey,
+ rawSignature []byte,
+) (DetachedSignature, error) {
+ if len(canonical) == 0 {
+ return DetachedSignature{}, errors.New("canonical record is empty")
+ }
+ if err := validateID("signature key_id", keyID); err != nil {
+ return DetachedSignature{}, err
+ }
+ if len(publicKey) != ed25519.PublicKeySize {
+ return DetachedSignature{}, fmt.Errorf("Ed25519 public key is %d bytes, want %d", len(publicKey), ed25519.PublicKeySize)
+ }
+ if len(rawSignature) != ed25519.SignatureSize {
+ return DetachedSignature{}, fmt.Errorf("Ed25519 signature is %d bytes, want %d", len(rawSignature), ed25519.SignatureSize)
+ }
+ if !ed25519.Verify(publicKey, canonical, rawSignature) {
+ return DetachedSignature{}, errors.New("offline Ed25519 signature verification failed")
+ }
+ signature := DetachedSignature{
+ Schema: DetachedSignatureSchema,
+ Algorithm: SignatureAlgorithm,
+ KeyID: keyID,
+ PublicKeyFingerprint: taggedSHA256(publicKey),
+ SignedSHA256: taggedSHA256(canonical),
+ SignatureHex: hex.EncodeToString(rawSignature),
+ }
+ return signature, signature.Validate()
+}
+
+// VerifyOperationalRecordBinding checks immutable ceremony fields and the
+// record signer. Cross-record checks (receipt/handoff, witness quorum, relay
+// raw responses, and restart target) have dedicated validators below.
+func VerifyOperationalRecordBinding(
+ definition CeremonyDefinition,
+ definitionBytes []byte,
+ record any,
+) (Identity, error) {
+ if err := definition.Validate(); err != nil {
+ return Identity{}, err
+ }
+ if len(definitionBytes) == 0 {
+ return Identity{}, errors.New("canonical definition bytes are required")
+ }
+ var ceremonyID, signerID, signerKeyID string
+ switch r := record.(type) {
+ case *EnrollmentRecord:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.Identity.ID, r.Identity.KeyID
+ if err := verifyEnrollmentBinding(definition, definitionBytes, *r); err != nil {
+ return Identity{}, err
+ }
+ case *TransferHandoff:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.SenderID, r.SenderKeyID
+ if err := verifyTransferSource(definition, r.Source); err != nil {
+ return Identity{}, err
+ }
+ if err := verifyTransferParty(definition, r.SenderID, r.SenderKeyID); err != nil {
+ return Identity{}, fmt.Errorf("sender: %w", err)
+ }
+ if err := verifyTransferParty(definition, r.RecipientID, r.RecipientKeyID); err != nil {
+ return Identity{}, fmt.Errorf("recipient: %w", err)
+ }
+ case *TransferReceipt:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.SignerID, r.SignerKeyID
+ if err := verifyTransferSource(definition, r.Source); err != nil {
+ return Identity{}, err
+ }
+ if err := verifyTransferParty(definition, r.SenderID, r.SenderKeyID); err != nil {
+ return Identity{}, fmt.Errorf("sender: %w", err)
+ }
+ if err := verifyTransferParty(definition, r.RecipientID, r.RecipientKeyID); err != nil {
+ return Identity{}, fmt.Errorf("recipient: %w", err)
+ }
+ case *PublicWitnessReceipt:
+ ceremonyID = r.CeremonyID
+ signerID, signerKeyID = r.Witness.ID, r.Witness.KeyID
+ case *MultiRelayBeaconEvidence:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.CoordinatorID, r.CoordinatorKeyID
+ if r.Provider != definition.BeaconPolicy.Provider || r.Network != definition.BeaconPolicy.Network {
+ return Identity{}, errors.New("multi-relay beacon provider/network does not match ceremony")
+ }
+ case *ImmutableMirrorReceipt:
+ ceremonyID = r.CeremonyID
+ signerID, signerKeyID = r.Mirror.ID, r.Mirror.KeyID
+ case *OperationalEvidenceBundle:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.CoordinatorID, r.CoordinatorKeyID
+ case *GovernanceRecord:
+ ceremonyID, signerID, signerKeyID = r.CeremonyID, r.SignerID, r.SignerKeyID
+ default:
+ return Identity{}, fmt.Errorf("unsupported operational record %T", record)
+ }
+ if ceremonyID != definition.CeremonyID {
+ return Identity{}, errors.New("operational record ceremony_id does not match definition")
+ }
+ if enrollment, ok := record.(*EnrollmentRecord); ok {
+ return enrollment.Identity, nil
+ }
+ if witness, ok := record.(*PublicWitnessReceipt); ok {
+ if witness.Witness.KeyID != signerKeyID {
+ return Identity{}, errors.New("public witness signer key mismatch")
+ }
+ return witness.Witness, nil
+ }
+ if mirror, ok := record.(*ImmutableMirrorReceipt); ok {
+ if mirror.Mirror.KeyID != signerKeyID {
+ return Identity{}, errors.New("immutable mirror signer key mismatch")
+ }
+ return mirror.Mirror, nil
+ }
+ identity, ok := definitionIdentityByID(definition, signerID)
+ if !ok || identity.KeyID != signerKeyID {
+ return Identity{}, errors.New("operational record signer is not the matching enrolled identity")
+ }
+ return identity, nil
+}
+
+func VerifyTransferReceipt(handoffBytes []byte, handoff TransferHandoff, receipt TransferReceipt) error {
+ if err := handoff.Validate(); err != nil {
+ return fmt.Errorf("handoff: %w", err)
+ }
+ if err := receipt.Validate(); err != nil {
+ return fmt.Errorf("receipt: %w", err)
+ }
+ if taggedSHA256(handoffBytes) != receipt.HandoffSHA256 {
+ return errors.New("receipt handoff_sha256 does not match exact handoff bytes")
+ }
+ if receipt.CeremonyID != handoff.CeremonyID || receipt.Phase != handoff.Phase ||
+ receipt.Index != handoff.Index || receipt.PredecessorHeadID != handoff.PredecessorHeadID ||
+ receipt.Source != handoff.Source || !slices.Equal(receipt.Files, handoff.Files) ||
+ receipt.SenderID != handoff.SenderID || receipt.SenderKeyID != handoff.SenderKeyID ||
+ receipt.RecipientID != handoff.RecipientID || receipt.RecipientKeyID != handoff.RecipientKeyID {
+ return errors.New("receipt does not exactly bind handoff scope, source, files, sender, and recipient")
+ }
+ received, _ := time.Parse(time.RFC3339Nano, receipt.ReceivedAt)
+ created, _ := time.Parse(time.RFC3339Nano, handoff.CreatedAt)
+ expires, _ := time.Parse(time.RFC3339Nano, handoff.ExpiresAt)
+ if !received.After(created) || received.After(expires) {
+ return errors.New("receipt received_at is outside the handoff validity window")
+ }
+ return nil
+}
+
+type SignedPublicWitness struct {
+ RecordBytes []byte
+ SignatureBytes []byte
+ TrustedKey ed25519.PublicKey
+}
+
+func VerifyPublicWitnessQuorum(
+ definition CeremonyDefinition,
+ close CloseRecord,
+ closeBytes []byte,
+ receipts []SignedPublicWitness,
+ minimum int,
+) error {
+ if minimum < 2 {
+ return errors.New("public witness quorum minimum must be at least 2")
+ }
+ if len(receipts) < minimum {
+ return fmt.Errorf("have %d public witness receipts, need %d", len(receipts), minimum)
+ }
+ seenIDs := make(map[string]struct{}, len(receipts))
+ seenKeys := make(map[string]struct{}, len(receipts))
+ var common *PublicWitnessReceipt
+ for index, signed := range receipts {
+ var receipt PublicWitnessReceipt
+ if err := VerifySignedRecord(signed.RecordBytes, signed.SignatureBytes, &receipt, witnessKeyID(signed.RecordBytes), signed.TrustedKey); err != nil {
+ return fmt.Errorf("public witness %d: %w", index, err)
+ }
+ if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, receipt); err != nil {
+ return fmt.Errorf("public witness %d: %w", index, err)
+ }
+ embeddedKey, err := identityPublicKey(receipt.Witness)
+ if err != nil {
+ return fmt.Errorf("public witness %d identity: %w", index, err)
+ }
+ if !bytes.Equal(embeddedKey, signed.TrustedKey) {
+ return fmt.Errorf("public witness %d trusted key does not match receipt identity", index)
+ }
+ if _, duplicate := seenIDs[receipt.Witness.ID]; duplicate {
+ return fmt.Errorf("public witness identity %q is duplicated", receipt.Witness.ID)
+ }
+ seenIDs[receipt.Witness.ID] = struct{}{}
+ if _, duplicate := seenKeys[receipt.Witness.PublicKeyFingerprint]; duplicate {
+ return errors.New("public witness key is duplicated")
+ }
+ seenKeys[receipt.Witness.PublicKeyFingerprint] = struct{}{}
+ if common == nil {
+ copy := receipt
+ common = ©
+ } else if receipt.CeremonyID != common.CeremonyID || receipt.Phase != common.Phase ||
+ receipt.CloseID != common.CloseID || receipt.ChainHeadID != common.ChainHeadID ||
+ receipt.Closure != common.Closure || receipt.BeaconRound != common.BeaconRound ||
+ receipt.BeaconScheduledAt != common.BeaconScheduledAt {
+ return errors.New("public witness receipts do not attest the same closure and beacon round")
+ }
+ }
+ return nil
+}
+
+func ValidatePublicWitnessReceipt(
+ definition CeremonyDefinition,
+ close CloseRecord,
+ closeBytes []byte,
+ receipt PublicWitnessReceipt,
+) error {
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if err := close.Validate(); err != nil {
+ return err
+ }
+ if err := receipt.Validate(); err != nil {
+ return err
+ }
+ if identityOverlapsDefinition(definition, receipt.Witness) {
+ return errors.New("public witness identity or key overlaps a ceremony actor")
+ }
+ roundTime, err := QuicknetRoundTime(close.BeaconRound)
+ if err != nil {
+ return err
+ }
+ scheduled, _ := time.Parse(time.RFC3339Nano, receipt.BeaconScheduledAt)
+ observed, _ := time.Parse(time.RFC3339Nano, receipt.ObservedAt)
+ closed, _ := time.Parse(time.RFC3339Nano, close.ClosedAt)
+ if !scheduled.Equal(roundTime) {
+ return errors.New("public witness beacon_scheduled_at does not match pinned round schedule")
+ }
+ if !observed.After(closed) || !observed.Before(roundTime) {
+ return errors.New("public witness must observe publication after closure and before beacon round")
+ }
+ minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second
+ if roundTime.Sub(observed) < minimumLead {
+ return fmt.Errorf(
+ "public witness lead %s is below signed minimum %s",
+ roundTime.Sub(observed),
+ minimumLead,
+ )
+ }
+ if receipt.CeremonyID != definition.CeremonyID || receipt.CeremonyID != close.CeremonyID ||
+ receipt.Phase != close.Phase || receipt.CloseID != close.CloseID ||
+ receipt.ChainHeadID != close.ChainHeadID || receipt.BeaconRound != close.BeaconRound ||
+ receipt.Closure.Digest != NewDigest(closeBytes) {
+ return errors.New("public witness receipt does not exactly bind the signed closure")
+ }
+ return nil
+}
+
+func ValidateMultiRelayBeaconEvidence(
+ definition CeremonyDefinition,
+ close CloseRecord,
+ evidence MultiRelayBeaconEvidence,
+ rawResponses map[string][]byte,
+) error {
+ if err := definition.Validate(); err != nil {
+ return err
+ }
+ if err := close.Validate(); err != nil {
+ return err
+ }
+ if err := evidence.Validate(); err != nil {
+ return err
+ }
+ if evidence.CeremonyID != definition.CeremonyID || evidence.CeremonyID != close.CeremonyID ||
+ evidence.Phase != close.Phase || evidence.CloseID != close.CloseID ||
+ evidence.BeaconRound != close.BeaconRound ||
+ evidence.Provider != definition.BeaconPolicy.Provider ||
+ evidence.Network != definition.BeaconPolicy.Network ||
+ evidence.CoordinatorID != definition.Coordinator.ID ||
+ evidence.CoordinatorKeyID != definition.Coordinator.KeyID {
+ return errors.New("multi-relay beacon evidence does not exactly bind ceremony closure and coordinator")
+ }
+ roundTime, err := QuicknetRoundTime(close.BeaconRound)
+ if err != nil {
+ return err
+ }
+ for _, observation := range evidence.Observations {
+ raw, ok := rawResponses[observation.RelayID]
+ if !ok {
+ return fmt.Errorf("raw response for relay %q is missing", observation.RelayID)
+ }
+ if NewDigest(raw) != observation.RawResponse.Digest {
+ return fmt.Errorf("raw response for relay %q has wrong digest or size", observation.RelayID)
+ }
+ randomness, err := VerifyDrandBeaconResponse(definition.BeaconPolicy, close.BeaconRound, raw)
+ if err != nil {
+ return fmt.Errorf("relay %q: %w", observation.RelayID, err)
+ }
+ if randomness != observation.VerifiedRandomness {
+ return fmt.Errorf("relay %q verified randomness mismatch", observation.RelayID)
+ }
+ retrieved, _ := time.Parse(time.RFC3339Nano, observation.RetrievedAt)
+ if retrieved.Before(roundTime) {
+ return fmt.Errorf("relay %q response predates beacon round", observation.RelayID)
+ }
+ }
+ return nil
+}
+
+func ValidateRestartRecord(oldDefinition, newDefinition CeremonyDefinition, record GovernanceRecord) error {
+ if err := oldDefinition.Validate(); err != nil {
+ return err
+ }
+ if err := newDefinition.Validate(); err != nil {
+ return err
+ }
+ if err := record.Validate(); err != nil {
+ return err
+ }
+ if record.Kind != GovernanceRestart {
+ return errors.New("governance record is not a restart")
+ }
+ if record.CeremonyID != oldDefinition.CeremonyID || record.NewCeremonyID != newDefinition.CeremonyID {
+ return errors.New("restart record does not bind exact old and new ceremony IDs")
+ }
+ if oldDefinition.CeremonyID == newDefinition.CeremonyID {
+ return errors.New("restart definition did not create a fresh ceremony ID")
+ }
+ return nil
+}
+
+func verifyEnrollmentBinding(definition CeremonyDefinition, definitionBytes []byte, record EnrollmentRecord) error {
+ if record.Definition != NewDigest(definitionBytes) {
+ return errors.New("enrollment definition digest does not match exact canonical definition")
+ }
+ rosterBytes, err := json.Marshal(definition.Roster)
+ if err != nil {
+ return fmt.Errorf("marshal full roster: %w", err)
+ }
+ if record.FullRosterSHA256 != taggedSHA256(rosterBytes) {
+ return errors.New("enrollment full_roster_sha256 does not match frozen full roster")
+ }
+ identity, role, index, ok := definitionRoleAt(definition, record.Identity.ID)
+ switch record.Role {
+ case EnrollmentPublicWitness, EnrollmentMirrorOperator:
+ if ok || identityOverlapsDefinition(definition, record.Identity) {
+ return errors.New("external operational identity overlaps a ceremony actor")
+ }
+ default:
+ if !ok || identity != record.Identity || role != record.Role || index != record.RoleIndex {
+ return errors.New("enrollment identity, role, or one-based index does not match definition")
+ }
+ }
+ created, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt)
+ enrolled, _ := time.Parse(time.RFC3339Nano, record.EnrolledAt)
+ if enrolled.Before(created) {
+ return errors.New("enrollment predates ceremony definition")
+ }
+ return nil
+}
+
+func identityOverlapsDefinition(definition CeremonyDefinition, candidate Identity) bool {
+ all := []Identity{definition.Coordinator, definition.ReleaseSigner}
+ all = append(all, definition.Auditors...)
+ for _, participant := range definition.Roster {
+ all = append(all, participant.Identity)
+ }
+ for _, identity := range all {
+ if identity.ID == candidate.ID || identity.KeyID == candidate.KeyID ||
+ identity.PublicKeyFingerprint == candidate.PublicKeyFingerprint {
+ return true
+ }
+ }
+ return false
+}
+
+func verifyTransferSource(definition CeremonyDefinition, source TransferSourceBinding) error {
+ if source.SourceCommit != definition.Software.SourceCommit ||
+ source.ToolBinary != definition.Software.ToolBinary ||
+ source.R1CS != definition.Circuit.R1CS {
+ return errors.New("transfer source, binary, or R1CS binding does not match ceremony definition")
+ }
+ return nil
+}
+
+func verifyTransferParty(definition CeremonyDefinition, id, keyID string) error {
+ identity, ok := definitionIdentityByID(definition, id)
+ if !ok || identity.KeyID != keyID {
+ return errors.New("identity/key is not enrolled in the ceremony definition")
+ }
+ return nil
+}
+
+func definitionRoleAt(definition CeremonyDefinition, id string) (Identity, EnrollmentRole, uint16, bool) {
+ if definition.Coordinator.ID == id {
+ return definition.Coordinator, EnrollmentCoordinator, 1, true
+ }
+ if definition.ReleaseSigner.ID == id {
+ return definition.ReleaseSigner, EnrollmentReleaseSigner, 1, true
+ }
+ for index, identity := range definition.Auditors {
+ if identity.ID == id {
+ return identity, EnrollmentAuditor, uint16(index + 1), true
+ }
+ }
+ for index, participant := range definition.Roster {
+ if participant.Identity.ID == id {
+ return participant.Identity, EnrollmentParticipant, uint16(index + 1), true
+ }
+ }
+ return Identity{}, "", 0, false
+}
+
+func definitionIdentityByID(definition CeremonyDefinition, id string) (Identity, bool) {
+ identity, _, _, ok := definitionRoleAt(definition, id)
+ return identity, ok
+}
+
+func validateOperationalScope(ceremonyID string, phase Phase, index uint8, headID string) error {
+ if err := validateHashID("ceremony_id", ceremonyID); err != nil {
+ return err
+ }
+ if err := phase.Validate(); err != nil {
+ return err
+ }
+ if index == 0 || index > MaxParticipants {
+ return fmt.Errorf("index must be between 1 and %d", MaxParticipants)
+ }
+ return validateHashID("predecessor/head id", headID)
+}
+
+func validateTransferIdentities(senderID, senderKeyID, recipientID, recipientKeyID string) error {
+ if err := validateID("sender_id", senderID); err != nil {
+ return err
+ }
+ if err := validateID("sender_key_id", senderKeyID); err != nil {
+ return err
+ }
+ if err := validateID("recipient_id", recipientID); err != nil {
+ return err
+ }
+ if err := validateID("recipient_key_id", recipientKeyID); err != nil {
+ return err
+ }
+ if senderID == recipientID || senderKeyID == recipientKeyID {
+ return errors.New("transfer sender and recipient identities and keys must be distinct")
+ }
+ return nil
+}
+
+func validateArtifactSet(label string, artifacts []ArtifactRef) error {
+ if len(artifacts) == 0 || len(artifacts) > 128 {
+ return fmt.Errorf("%s must contain between 1 and 128 artifacts", label)
+ }
+ previous := ""
+ for index, artifact := range artifacts {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s %d: %w", label, index, err)
+ }
+ if index > 0 && artifact.Name <= previous {
+ return fmt.Errorf("%s must be ordered by unique artifact name", label)
+ }
+ previous = artifact.Name
+ }
+ return nil
+}
+
+func witnessKeyID(recordBytes []byte) string {
+ var receipt PublicWitnessReceipt
+ if err := json.Unmarshal(recordBytes, &receipt); err != nil {
+ return ""
+ }
+ return receipt.Witness.KeyID
+}
+
+func decodeOfflineSignature(data []byte) ([]byte, error) {
+ if len(data) == ed25519.SignatureSize {
+ return append([]byte(nil), data...), nil
+ }
+ trimmed := bytes.TrimSpace(data)
+ decoded := make([]byte, ed25519.SignatureSize)
+ n, err := hex.Decode(decoded, trimmed)
+ if err != nil || n != ed25519.SignatureSize || string(trimmed) != strings.ToLower(string(trimmed)) {
+ return nil, errors.New("offline signature must be 64 raw bytes or exactly 128 lowercase hexadecimal characters")
+ }
+ return decoded, nil
+}
+
+// DecodeOfflineSignature accepts the two conventional offline transport forms.
+func DecodeOfflineSignature(data []byte) ([]byte, error) {
+ return decodeOfflineSignature(data)
+}
diff --git a/internal/mpcceremony/operational_builder.go b/internal/mpcceremony/operational_builder.go
new file mode 100644
index 00000000..db7cb598
--- /dev/null
+++ b/internal/mpcceremony/operational_builder.go
@@ -0,0 +1,194 @@
+package mpcceremony
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// NewEnrollmentRecord derives the frozen definition and full-roster bindings;
+// callers cannot substitute either digest.
+func NewEnrollmentRecord(
+ definition CeremonyDefinition,
+ definitionBytes []byte,
+ identity Identity,
+ role EnrollmentRole,
+ roleIndex uint16,
+ disclosure ArtifactRef,
+ enrolledAt string,
+) (EnrollmentRecord, error) {
+ rosterBytes, err := json.Marshal(definition.Roster)
+ if err != nil {
+ return EnrollmentRecord{}, err
+ }
+ record := EnrollmentRecord{
+ Schema: EnrollmentRecordSchema,
+ CeremonyID: definition.CeremonyID,
+ Definition: NewDigest(definitionBytes),
+ FullRosterSHA256: taggedSHA256(rosterBytes),
+ Identity: identity,
+ Role: role,
+ RoleIndex: roleIndex,
+ IndependenceDisclosure: disclosure,
+ EnrolledAt: enrolledAt,
+ }
+ if err := record.Validate(); err != nil {
+ return EnrollmentRecord{}, err
+ }
+ if err := verifyEnrollmentBinding(definition, definitionBytes, record); err != nil {
+ return EnrollmentRecord{}, err
+ }
+ return record, nil
+}
+
+func NewTransferHandoff(
+ definition CeremonyDefinition,
+ phase Phase,
+ index uint8,
+ headID string,
+ files []ArtifactRef,
+ sender, recipient Identity,
+ createdAt, expiresAt string,
+) (TransferHandoff, error) {
+ record := TransferHandoff{
+ Schema: TransferHandoffSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ Index: index,
+ PredecessorHeadID: headID,
+ Source: TransferSourceBinding{
+ SourceCommit: definition.Software.SourceCommit,
+ ToolBinary: definition.Software.ToolBinary,
+ R1CS: definition.Circuit.R1CS,
+ },
+ Files: append([]ArtifactRef(nil), files...),
+ SenderID: sender.ID,
+ SenderKeyID: sender.KeyID,
+ RecipientID: recipient.ID,
+ RecipientKeyID: recipient.KeyID,
+ CreatedAt: createdAt,
+ ExpiresAt: expiresAt,
+ }
+ if err := record.Validate(); err != nil {
+ return TransferHandoff{}, err
+ }
+ if err := verifyTransferSource(definition, record.Source); err != nil {
+ return TransferHandoff{}, err
+ }
+ if err := verifyTransferParty(definition, record.SenderID, record.SenderKeyID); err != nil {
+ return TransferHandoff{}, err
+ }
+ if err := verifyTransferParty(definition, record.RecipientID, record.RecipientKeyID); err != nil {
+ return TransferHandoff{}, err
+ }
+ return record, nil
+}
+
+func NewTransferReceipt(
+ handoff TransferHandoff,
+ handoffBytes []byte,
+ kind TransferReceiptKind,
+ receivedAt string,
+) (TransferReceipt, error) {
+ signerID, signerKeyID := handoff.RecipientID, handoff.RecipientKeyID
+ record := TransferReceipt{
+ Schema: TransferReceiptSchema,
+ Kind: kind,
+ HandoffSHA256: taggedSHA256(handoffBytes),
+ CeremonyID: handoff.CeremonyID,
+ Phase: handoff.Phase,
+ Index: handoff.Index,
+ PredecessorHeadID: handoff.PredecessorHeadID,
+ Source: handoff.Source,
+ Files: append([]ArtifactRef(nil), handoff.Files...),
+ SenderID: handoff.SenderID,
+ SenderKeyID: handoff.SenderKeyID,
+ RecipientID: handoff.RecipientID,
+ RecipientKeyID: handoff.RecipientKeyID,
+ SignerID: signerID,
+ SignerKeyID: signerKeyID,
+ ReceivedAt: receivedAt,
+ }
+ if err := VerifyTransferReceipt(handoffBytes, handoff, record); err != nil {
+ return TransferReceipt{}, err
+ }
+ return record, nil
+}
+
+func NewImmutableMirrorReceipt(
+ ceremonyID string,
+ phase Phase,
+ index uint8,
+ acceptedHeadID string,
+ files []ArtifactRef,
+ mirror Identity,
+ locationSHA256, storedAt string,
+) (ImmutableMirrorReceipt, error) {
+ record := ImmutableMirrorReceipt{
+ Schema: ImmutableMirrorReceiptSchema,
+ CeremonyID: ceremonyID,
+ Phase: phase,
+ Index: index,
+ AcceptedHeadID: acceptedHeadID,
+ Files: append([]ArtifactRef(nil), files...),
+ Mirror: mirror,
+ StorageLocationSHA256: locationSHA256,
+ StoredAt: storedAt,
+ }
+ return record, record.Validate()
+}
+
+func NewPublicWitnessReceipt(
+ definition CeremonyDefinition,
+ close CloseRecord,
+ closeBytes []byte,
+ witness Identity,
+ closureName, locationSHA256, observedAt string,
+) (PublicWitnessReceipt, error) {
+ roundTime, err := QuicknetRoundTime(close.BeaconRound)
+ if err != nil {
+ return PublicWitnessReceipt{}, err
+ }
+ record := PublicWitnessReceipt{
+ Schema: PublicWitnessReceiptSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: close.Phase,
+ CloseID: close.CloseID,
+ ChainHeadID: close.ChainHeadID,
+ Closure: ArtifactRef{Name: closureName, Digest: NewDigest(closeBytes)},
+ BeaconRound: close.BeaconRound,
+ BeaconScheduledAt: roundTime.Format("2006-01-02T15:04:05Z"),
+ PublicationLocationSHA: locationSHA256,
+ Witness: witness,
+ ObservedAt: observedAt,
+ }
+ if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, record); err != nil {
+ return PublicWitnessReceipt{}, err
+ }
+ return record, nil
+}
+
+func NewMultiRelayBeaconEvidence(
+ definition CeremonyDefinition,
+ close CloseRecord,
+ observations []RelayObservation,
+ rawResponses map[string][]byte,
+ recordedAt string,
+) (MultiRelayBeaconEvidence, error) {
+ record := MultiRelayBeaconEvidence{
+ Schema: MultiRelayBeaconEvidenceSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: close.Phase,
+ CloseID: close.CloseID,
+ BeaconRound: close.BeaconRound,
+ Provider: definition.BeaconPolicy.Provider,
+ Network: definition.BeaconPolicy.Network,
+ Observations: append([]RelayObservation(nil), observations...),
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ RecordedAt: recordedAt,
+ }
+ if err := ValidateMultiRelayBeaconEvidence(definition, close, record, rawResponses); err != nil {
+ return MultiRelayBeaconEvidence{}, fmt.Errorf("multi-relay evidence: %w", err)
+ }
+ return record, nil
+}
diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go
new file mode 100644
index 00000000..49e2a310
--- /dev/null
+++ b/internal/mpcceremony/operational_bundle.go
@@ -0,0 +1,1146 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+)
+
+const OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v1"
+
+type SignedArtifactRefs struct {
+ Record ArtifactRef `json:"record"`
+ Signature ArtifactRef `json:"signature"`
+}
+
+func (r SignedArtifactRefs) Validate() error {
+ if err := r.Record.Validate(); err != nil {
+ return fmt.Errorf("record: %w", err)
+ }
+ if err := r.Signature.Validate(); err != nil {
+ return fmt.Errorf("signature: %w", err)
+ }
+ if r.Record.Name == r.Signature.Name {
+ return errors.New("signed artifact record and signature names must be distinct")
+ }
+ return nil
+}
+
+type AcceptedHeadOperationalEvidence struct {
+ Index uint8 `json:"index"`
+ PredecessorHeadID string `json:"predecessor_head_id"`
+ AcceptedHeadID string `json:"accepted_head_id"`
+ OutboundHandoff SignedArtifactRefs `json:"outbound_handoff"`
+ OutboundReceipt SignedArtifactRefs `json:"outbound_receipt"`
+ ReturnHandoff SignedArtifactRefs `json:"return_handoff"`
+ ReturnReceipt SignedArtifactRefs `json:"return_receipt"`
+ AcceptedChainPrefix SignedArtifactRefs `json:"accepted_chain_prefix"`
+ MirrorReceipts []SignedArtifactRefs `json:"mirror_receipts"`
+}
+
+func (e AcceptedHeadOperationalEvidence) Validate() error {
+ if e.Index == 0 || e.Index > MaxParticipants {
+ return fmt.Errorf("accepted head index must be between 1 and %d", MaxParticipants)
+ }
+ if err := validateHashID("accepted_head_id", e.AcceptedHeadID); err != nil {
+ return err
+ }
+ if err := validateHashID("predecessor_head_id", e.PredecessorHeadID); err != nil {
+ return err
+ }
+ if e.PredecessorHeadID == e.AcceptedHeadID {
+ return errors.New("accepted head must differ from predecessor head")
+ }
+ if err := e.OutboundHandoff.Validate(); err != nil {
+ return fmt.Errorf("outbound_handoff: %w", err)
+ }
+ if err := e.OutboundReceipt.Validate(); err != nil {
+ return fmt.Errorf("outbound_receipt: %w", err)
+ }
+ if err := e.ReturnHandoff.Validate(); err != nil {
+ return fmt.Errorf("return_handoff: %w", err)
+ }
+ if err := e.ReturnReceipt.Validate(); err != nil {
+ return fmt.Errorf("return_receipt: %w", err)
+ }
+ if err := e.AcceptedChainPrefix.Validate(); err != nil {
+ return fmt.Errorf("accepted_chain_prefix: %w", err)
+ }
+ if len(e.MirrorReceipts) < 2 || len(e.MirrorReceipts) > 8 {
+ return errors.New("accepted head requires between 2 and 8 immutable mirror receipts")
+ }
+ return validateSignedArtifactSet("mirror_receipts", e.MirrorReceipts)
+}
+
+type PhaseOperationalEvidence struct {
+ Phase Phase `json:"phase"`
+ AcceptedChain SignedArtifactRefs `json:"accepted_chain"`
+ Close SignedArtifactRefs `json:"close"`
+ AcceptedHeads []AcceptedHeadOperationalEvidence `json:"accepted_heads"`
+ PublicWitnessQuorum uint8 `json:"public_witness_quorum"`
+ PublicWitnessReceipts []SignedArtifactRefs `json:"public_witness_receipts"`
+ MultiRelayBeaconEvidence SignedArtifactRefs `json:"multi_relay_beacon_evidence"`
+ RawBeaconResponses []ArtifactRef `json:"raw_beacon_responses"`
+}
+
+func (p PhaseOperationalEvidence) Validate() error {
+ if err := p.Phase.Validate(); err != nil {
+ return err
+ }
+ if err := p.AcceptedChain.Validate(); err != nil {
+ return fmt.Errorf("accepted_chain: %w", err)
+ }
+ if err := p.Close.Validate(); err != nil {
+ return fmt.Errorf("close: %w", err)
+ }
+ if len(p.AcceptedHeads) == 0 || len(p.AcceptedHeads) > MaxParticipants {
+ return fmt.Errorf("accepted_heads must contain between 1 and %d entries", MaxParticipants)
+ }
+ for index, head := range p.AcceptedHeads {
+ if err := head.Validate(); err != nil {
+ return fmt.Errorf("accepted head %d: %w", index, err)
+ }
+ if head.Index != uint8(index+1) {
+ return errors.New("accepted heads must be complete and ordered by one-based index")
+ }
+ }
+ if p.PublicWitnessQuorum < 2 {
+ return errors.New("public_witness_quorum must be at least 2")
+ }
+ if len(p.PublicWitnessReceipts) < int(p.PublicWitnessQuorum) || len(p.PublicWitnessReceipts) > 32 {
+ return fmt.Errorf(
+ "public witness receipt count %d does not satisfy quorum %d or maximum 32",
+ len(p.PublicWitnessReceipts),
+ p.PublicWitnessQuorum,
+ )
+ }
+ if err := validateSignedArtifactSet("public_witness_receipts", p.PublicWitnessReceipts); err != nil {
+ return err
+ }
+ if err := p.MultiRelayBeaconEvidence.Validate(); err != nil {
+ return fmt.Errorf("multi_relay_beacon_evidence: %w", err)
+ }
+ if err := validateArtifactSet("raw_beacon_responses", p.RawBeaconResponses); err != nil {
+ return err
+ }
+ return nil
+}
+
+// OperationalEvidenceBundle is the one canonical release input for
+// independently witnessed pre-beacon publication and multi-relay beacon
+// retrieval in both phases. Every referenced byte string is content-addressed
+// and resolved below one caller-supplied evidence root.
+type OperationalEvidenceBundle struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ Enrollments []SignedArtifactRefs `json:"enrollments"`
+ GovernanceRecords []SignedArtifactRefs `json:"governance_records"`
+ Phase1 PhaseOperationalEvidence `json:"phase1"`
+ Phase2 PhaseOperationalEvidence `json:"phase2"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ AssembledAt string `json:"assembled_at"`
+}
+
+func (b OperationalEvidenceBundle) Validate() error {
+ if b.Schema != OperationalEvidenceBundleSchema {
+ return fmt.Errorf("operational evidence schema %q, want %q", b.Schema, OperationalEvidenceBundleSchema)
+ }
+ if err := validateHashID("ceremony_id", b.CeremonyID); err != nil {
+ return err
+ }
+ minimumEnrollments := 6 // coordinator, release signer, two auditors, one participant, one witness
+ if len(b.Enrollments) < minimumEnrollments || len(b.Enrollments) > 128 {
+ return fmt.Errorf("enrollments must contain between %d and 128 records", minimumEnrollments)
+ }
+ if err := validateSignedArtifactSet("enrollments", b.Enrollments); err != nil {
+ return err
+ }
+ if len(b.GovernanceRecords) > 128 {
+ return errors.New("governance_records exceeds maximum 128")
+ }
+ if len(b.GovernanceRecords) > 0 {
+ if err := validateSignedArtifactSet("governance_records", b.GovernanceRecords); err != nil {
+ return err
+ }
+ }
+ if err := b.Phase1.Validate(); err != nil {
+ return fmt.Errorf("phase1: %w", err)
+ }
+ if b.Phase1.Phase != Phase1 {
+ return errors.New("phase1 evidence has wrong phase")
+ }
+ if err := b.Phase2.Validate(); err != nil {
+ return fmt.Errorf("phase2: %w", err)
+ }
+ if b.Phase2.Phase != Phase2 {
+ return errors.New("phase2 evidence has wrong phase")
+ }
+ if err := validateID("coordinator_id", b.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", b.CoordinatorKeyID); err != nil {
+ return err
+ }
+ return validateTimestamp("assembled_at", b.AssembledAt)
+}
+
+type AuthenticatedCloseEvidence struct {
+ Record CloseRecord
+ RecordBytes []byte
+ SignatureBytes []byte
+}
+
+// LoadAuthenticatedCloseEvidence loads exact close bytes named by a signed
+// operational bundle. Authentication is deliberately completed only by
+// VerifyOperationalEvidenceBundle with the external coordinator trust anchor.
+func LoadAuthenticatedCloseEvidence(root string, refs SignedArtifactRefs) (AuthenticatedCloseEvidence, error) {
+ if err := refs.Validate(); err != nil {
+ return AuthenticatedCloseEvidence{}, err
+ }
+ recordBytes, err := verifyArtifactBytes(root, refs.Record, maxSignedRecordBytes)
+ if err != nil {
+ return AuthenticatedCloseEvidence{}, err
+ }
+ signatureBytes, err := verifyArtifactBytes(root, refs.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return AuthenticatedCloseEvidence{}, err
+ }
+ var record CloseRecord
+ if err := UnmarshalCanonical(recordBytes, &record); err != nil {
+ return AuthenticatedCloseEvidence{}, err
+ }
+ return AuthenticatedCloseEvidence{
+ Record: record,
+ RecordBytes: recordBytes,
+ SignatureBytes: signatureBytes,
+ }, nil
+}
+
+type VerifyOperationalEvidenceOptions struct {
+ Definition CeremonyDefinition
+ CoordinatorPublicKey ed25519.PublicKey
+ EvidenceRoot string
+ BundleBytes []byte
+ BundleSignatureBytes []byte
+ Phase1Close AuthenticatedCloseEvidence
+ Phase2Close AuthenticatedCloseEvidence
+}
+
+type VerifiedOperationalEvidence struct {
+ Bundle OperationalEvidenceBundle
+ BundleDigest Digest
+ BundleSignature Digest
+ ReferencedArtifacts []ArtifactRef
+}
+
+// VerifyOperationalEvidenceBundle fail-closes across the signed bundle,
+// authenticated close records, witness signatures/quorum/timing, every raw
+// relay response, and the pinned drand verification policy.
+func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) (VerifiedOperationalEvidence, error) {
+ if err := options.Definition.Validate(); err != nil {
+ return VerifiedOperationalEvidence{}, err
+ }
+ if len(options.CoordinatorPublicKey) != ed25519.PublicKeySize {
+ return VerifiedOperationalEvidence{}, errors.New("coordinator public key is invalid")
+ }
+ var bundle OperationalEvidenceBundle
+ if err := VerifySignedRecord(
+ options.BundleBytes,
+ options.BundleSignatureBytes,
+ &bundle,
+ options.Definition.Coordinator.KeyID,
+ options.CoordinatorPublicKey,
+ ); err != nil {
+ return VerifiedOperationalEvidence{}, fmt.Errorf("operational evidence bundle: %w", err)
+ }
+ if bundle.CeremonyID != options.Definition.CeremonyID ||
+ bundle.CoordinatorID != options.Definition.Coordinator.ID ||
+ bundle.CoordinatorKeyID != options.Definition.Coordinator.KeyID {
+ return VerifiedOperationalEvidence{}, errors.New("operational evidence bundle does not bind ceremony coordinator")
+ }
+ definitionBytes, err := MarshalCanonical(options.Definition)
+ if err != nil {
+ return VerifiedOperationalEvidence{}, err
+ }
+ enrollments, enrollmentRefs, err := verifyEnrollmentEvidence(
+ options.Definition,
+ definitionBytes,
+ options.EvidenceRoot,
+ bundle.Enrollments,
+ )
+ if err != nil {
+ return VerifiedOperationalEvidence{}, err
+ }
+ governanceRefs, err := verifyGovernanceEvidence(
+ options.Definition,
+ definitionBytes,
+ options.EvidenceRoot,
+ bundle.GovernanceRecords,
+ )
+ if err != nil {
+ return VerifiedOperationalEvidence{}, err
+ }
+ if options.Phase1Close.Record.BeaconRound == options.Phase2Close.Record.BeaconRound {
+ return VerifiedOperationalEvidence{}, errors.New("phase 1 and phase 2 operational evidence reuse a beacon round")
+ }
+
+ phase1Refs, err := verifyPhaseOperationalEvidence(
+ options.Definition,
+ options.CoordinatorPublicKey,
+ options.EvidenceRoot,
+ bundle.Phase1,
+ options.Phase1Close,
+ enrollments,
+ )
+ if err != nil {
+ return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err)
+ }
+ phase2Refs, err := verifyPhaseOperationalEvidence(
+ options.Definition,
+ options.CoordinatorPublicKey,
+ options.EvidenceRoot,
+ bundle.Phase2,
+ options.Phase2Close,
+ enrollments,
+ )
+ if err != nil {
+ return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err)
+ }
+ latest, err := latestOperationalTimestamp(options.EvidenceRoot, bundle)
+ if err != nil {
+ return VerifiedOperationalEvidence{}, err
+ }
+ assembled, _ := time.Parse(time.RFC3339Nano, bundle.AssembledAt)
+ if !assembled.After(latest) {
+ return VerifiedOperationalEvidence{}, fmt.Errorf(
+ "bundle assembled_at %s must strictly postdate latest operational evidence %s",
+ bundle.AssembledAt,
+ latest.Format(time.RFC3339Nano),
+ )
+ }
+ all := append(enrollmentRefs, governanceRefs...)
+ all = append(all, phase1Refs...)
+ all = append(all, phase2Refs...)
+ slices.SortFunc(all, func(a, b ArtifactRef) int {
+ if a.Name < b.Name {
+ return -1
+ }
+ if a.Name > b.Name {
+ return 1
+ }
+ return 0
+ })
+ unique := all[:0]
+ for _, ref := range all {
+ if len(unique) > 0 && unique[len(unique)-1].Name == ref.Name {
+ if unique[len(unique)-1] != ref {
+ return VerifiedOperationalEvidence{}, fmt.Errorf("operational artifact name %q is equivocated", ref.Name)
+ }
+ // A final accepted-chain file can also be the signed prefix for
+ // its last head. Preserve one exact content-addressed reference.
+ continue
+ }
+ unique = append(unique, ref)
+ }
+ all = unique
+ return VerifiedOperationalEvidence{
+ Bundle: bundle,
+ BundleDigest: NewDigest(options.BundleBytes),
+ BundleSignature: NewDigest(options.BundleSignatureBytes),
+ ReferencedArtifacts: all,
+ }, nil
+}
+
+func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) (time.Time, error) {
+ var latest time.Time
+ advance := func(value string) {
+ parsed, _ := time.Parse(time.RFC3339Nano, value)
+ if parsed.After(latest) {
+ latest = parsed
+ }
+ }
+ for _, pair := range bundle.Enrollments {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record EnrollmentRecord
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.EnrolledAt)
+ }
+ for _, pair := range bundle.GovernanceRecords {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record GovernanceRecord
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.RecordedAt)
+ }
+ for _, phase := range []PhaseOperationalEvidence{bundle.Phase1, bundle.Phase2} {
+ chainBytes, err := verifyArtifactBytes(root, phase.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(chainBytes, &chain); err != nil {
+ return time.Time{}, err
+ }
+ for _, record := range chain.Records {
+ advance(record.AcceptedAt)
+ }
+ closeBytes, err := verifyArtifactBytes(root, phase.Close.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var close CloseRecord
+ if err := UnmarshalCanonical(closeBytes, &close); err != nil {
+ return time.Time{}, err
+ }
+ advance(close.ClosedAt)
+ for _, head := range phase.AcceptedHeads {
+ for _, pair := range []SignedArtifactRefs{head.OutboundHandoff, head.ReturnHandoff} {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record TransferHandoff
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.CreatedAt)
+ }
+ for _, pair := range []SignedArtifactRefs{head.OutboundReceipt, head.ReturnReceipt} {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record TransferReceipt
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.ReceivedAt)
+ }
+ for _, pair := range head.MirrorReceipts {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record ImmutableMirrorReceipt
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.StoredAt)
+ }
+ }
+ for _, pair := range phase.PublicWitnessReceipts {
+ raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var record PublicWitnessReceipt
+ if err := UnmarshalCanonical(raw, &record); err != nil {
+ return time.Time{}, err
+ }
+ advance(record.ObservedAt)
+ }
+ beaconBytes, err := verifyArtifactBytes(root, phase.MultiRelayBeaconEvidence.Record, maxSignedRecordBytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ var beacon MultiRelayBeaconEvidence
+ if err := UnmarshalCanonical(beaconBytes, &beacon); err != nil {
+ return time.Time{}, err
+ }
+ advance(beacon.RecordedAt)
+ for _, observation := range beacon.Observations {
+ advance(observation.RetrievedAt)
+ }
+ }
+ if latest.IsZero() {
+ return time.Time{}, errors.New("operational evidence has no timestamp")
+ }
+ return latest, nil
+}
+
+func verifyPhaseOperationalEvidence(
+ definition CeremonyDefinition,
+ coordinatorPublicKey ed25519.PublicKey,
+ root string,
+ phaseEvidence PhaseOperationalEvidence,
+ authenticated AuthenticatedCloseEvidence,
+ enrollments map[string]EnrollmentRecord,
+) ([]ArtifactRef, error) {
+ if err := authenticated.Record.Validate(); err != nil {
+ return nil, err
+ }
+ if authenticated.Record.CeremonyID != definition.CeremonyID ||
+ authenticated.Record.Phase != phaseEvidence.Phase {
+ return nil, errors.New("authenticated close has wrong ceremony or phase")
+ }
+ var closeSignature DetachedSignature
+ if err := UnmarshalCanonical(authenticated.SignatureBytes, &closeSignature); err != nil {
+ return nil, fmt.Errorf("close signature: %w", err)
+ }
+ if err := VerifyExact(
+ authenticated.RecordBytes,
+ closeSignature,
+ definition.Coordinator.KeyID,
+ coordinatorPublicKey,
+ ); err != nil {
+ return nil, fmt.Errorf("authenticate close: %w", err)
+ }
+ if NewDigest(authenticated.RecordBytes) != phaseEvidence.Close.Record.Digest ||
+ NewDigest(authenticated.SignatureBytes) != phaseEvidence.Close.Signature.Digest {
+ return nil, errors.New("phase evidence close references do not match authenticated close bytes")
+ }
+ chainBytes, err := verifyArtifactBytes(root, phaseEvidence.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted chain: %w", err)
+ }
+ chainSignatureBytes, err := verifyArtifactBytes(root, phaseEvidence.AcceptedChain.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted chain signature: %w", err)
+ }
+ var chain Chain
+ if err := VerifySignedRecord(
+ chainBytes,
+ chainSignatureBytes,
+ &chain,
+ definition.Coordinator.KeyID,
+ coordinatorPublicKey,
+ ); err != nil {
+ return nil, fmt.Errorf("accepted chain: %w", err)
+ }
+ if chain.Phase != phaseEvidence.Phase {
+ return nil, errors.New("accepted chain has wrong phase")
+ }
+ if err := ValidateClose(definition, chain, authenticated.Record); err != nil {
+ return nil, fmt.Errorf("accepted chain/close coherence: %w", err)
+ }
+ payloadRefs := make([]ArtifactRef, 0, len(chain.Records)+1)
+ if err := verifyLargeOperationalArtifact(root, chain.Genesis); err != nil {
+ return nil, fmt.Errorf("accepted chain genesis: %w", err)
+ }
+ payloadRefs = append(payloadRefs, chain.Genesis)
+ for index, record := range chain.Records {
+ if err := verifyLargeOperationalArtifact(root, record.OutputPayload); err != nil {
+ return nil, fmt.Errorf("accepted head %d output payload: %w", index+1, err)
+ }
+ payloadRefs = append(payloadRefs, record.OutputPayload)
+ }
+ acceptedHeadIDs := make([]string, len(chain.Records))
+ for index, record := range chain.Records {
+ acceptedHeadIDs[index] = record.RecordID
+ }
+ if len(phaseEvidence.AcceptedHeads) != len(acceptedHeadIDs) {
+ return nil, errors.New("operational evidence does not cover every authenticated accepted head")
+ }
+ if acceptedHeadIDs[len(acceptedHeadIDs)-1] != authenticated.Record.ChainHeadID {
+ return nil, errors.New("authenticated accepted heads do not terminate at close chain head")
+ }
+ headRefs, err := verifyAcceptedHeadEvidence(
+ definition,
+ root,
+ phaseEvidence.Phase,
+ phaseEvidence.AcceptedHeads,
+ chain,
+ enrollments,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ witnesses := make([]SignedPublicWitness, len(phaseEvidence.PublicWitnessReceipts))
+ refs := []ArtifactRef{
+ phaseEvidence.AcceptedChain.Record,
+ phaseEvidence.AcceptedChain.Signature,
+ phaseEvidence.Close.Record,
+ phaseEvidence.Close.Signature,
+ }
+ refs = append(refs, payloadRefs...)
+ refs = append(refs, headRefs...)
+ for index, pair := range phaseEvidence.PublicWitnessReceipts {
+ recordBytes, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("witness %d record: %w", index, err)
+ }
+ signatureBytes, err := verifyArtifactBytes(root, pair.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("witness %d signature: %w", index, err)
+ }
+ var receipt PublicWitnessReceipt
+ if err := UnmarshalCanonical(recordBytes, &receipt); err != nil {
+ return nil, fmt.Errorf("witness %d: %w", index, err)
+ }
+ enrollment, ok := enrollments[receipt.Witness.ID]
+ if !ok || enrollment.Role != EnrollmentPublicWitness ||
+ enrollment.Identity != receipt.Witness {
+ return nil, fmt.Errorf("witness %q has no matching public-witness enrollment", receipt.Witness.ID)
+ }
+ publicKey, err := identityPublicKey(receipt.Witness)
+ if err != nil {
+ return nil, fmt.Errorf("witness %d identity: %w", index, err)
+ }
+ witnesses[index] = SignedPublicWitness{
+ RecordBytes: recordBytes,
+ SignatureBytes: signatureBytes,
+ TrustedKey: publicKey,
+ }
+ refs = append(refs, pair.Record, pair.Signature)
+ }
+ if err := VerifyPublicWitnessQuorum(
+ definition,
+ authenticated.Record,
+ authenticated.RecordBytes,
+ witnesses,
+ int(phaseEvidence.PublicWitnessQuorum),
+ ); err != nil {
+ return nil, err
+ }
+
+ beaconBytes, err := verifyArtifactBytes(
+ root,
+ phaseEvidence.MultiRelayBeaconEvidence.Record,
+ maxSignedRecordBytes,
+ )
+ if err != nil {
+ return nil, err
+ }
+ beaconSignatureBytes, err := verifyArtifactBytes(
+ root,
+ phaseEvidence.MultiRelayBeaconEvidence.Signature,
+ maxSignedRecordBytes,
+ )
+ if err != nil {
+ return nil, err
+ }
+ var beaconEvidence MultiRelayBeaconEvidence
+ if err := VerifySignedRecord(
+ beaconBytes,
+ beaconSignatureBytes,
+ &beaconEvidence,
+ definition.Coordinator.KeyID,
+ coordinatorPublicKey,
+ ); err != nil {
+ return nil, fmt.Errorf("multi-relay beacon evidence signature: %w", err)
+ }
+ rawResponses := make(map[string][]byte, len(phaseEvidence.RawBeaconResponses))
+ if len(phaseEvidence.RawBeaconResponses) != len(beaconEvidence.Observations) {
+ return nil, errors.New("raw response reference count does not match relay observations")
+ }
+ for _, rawRef := range phaseEvidence.RawBeaconResponses {
+ raw, err := verifyArtifactBytes(root, rawRef, maxDrandResponseBytes)
+ if err != nil {
+ return nil, err
+ }
+ matched := false
+ for _, observation := range beaconEvidence.Observations {
+ if observation.RawResponse == rawRef {
+ if _, duplicate := rawResponses[observation.RelayID]; duplicate {
+ return nil, fmt.Errorf("relay %q raw response is duplicated", observation.RelayID)
+ }
+ rawResponses[observation.RelayID] = raw
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return nil, fmt.Errorf("raw beacon response %q is not named by a relay observation", rawRef.Name)
+ }
+ }
+ if err := ValidateMultiRelayBeaconEvidence(
+ definition,
+ authenticated.Record,
+ beaconEvidence,
+ rawResponses,
+ ); err != nil {
+ return nil, err
+ }
+ refs = append(
+ refs,
+ phaseEvidence.MultiRelayBeaconEvidence.Record,
+ phaseEvidence.MultiRelayBeaconEvidence.Signature,
+ )
+ refs = append(refs, phaseEvidence.RawBeaconResponses...)
+ return refs, nil
+}
+
+func verifyLargeOperationalArtifact(root string, expected ArtifactRef) error {
+ path, err := resolveArtifactPath(root, expected.Name)
+ if err != nil {
+ return err
+ }
+ actual, err := artifactRefForFile(expected.Name, path)
+ if err != nil {
+ return err
+ }
+ if actual != expected {
+ return fmt.Errorf("artifact %q digest or size mismatch", expected.Name)
+ }
+ return nil
+}
+
+func verifyEnrollmentEvidence(
+ definition CeremonyDefinition,
+ definitionBytes []byte,
+ root string,
+ pairs []SignedArtifactRefs,
+) (map[string]EnrollmentRecord, []ArtifactRef, error) {
+ enrollments := make(map[string]EnrollmentRecord, len(pairs))
+ keys := make(map[string]struct{}, len(pairs))
+ roleIndexes := make(map[string]struct{}, len(pairs))
+ refs := make([]ArtifactRef, 0, len(pairs)*2)
+ for index, pair := range pairs {
+ recordBytes, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, nil, fmt.Errorf("enrollment %d: %w", index, err)
+ }
+ signatureBytes, err := verifyArtifactBytes(root, pair.Signature, maxSignedRecordBytes)
+ 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)
+ 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)
+ }
+ if _, err := verifyArtifactBytes(root, record.IndependenceDisclosure, 1<<20); err != nil {
+ return nil, nil, fmt.Errorf("enrollment %d independence disclosure: %w", index, err)
+ }
+ if _, duplicate := enrollments[signer.ID]; duplicate {
+ return nil, nil, fmt.Errorf("enrollment identity %q is duplicated", signer.ID)
+ }
+ if _, duplicate := keys[signer.PublicKeyFingerprint]; duplicate {
+ return nil, nil, errors.New("enrollment public key is duplicated")
+ }
+ roleIndex := fmt.Sprintf("%s:%d", record.Role, record.RoleIndex)
+ if _, duplicate := roleIndexes[roleIndex]; duplicate {
+ return nil, nil, fmt.Errorf("enrollment role/index %q is duplicated", roleIndex)
+ }
+ enrollments[signer.ID] = record
+ keys[signer.PublicKeyFingerprint] = struct{}{}
+ roleIndexes[roleIndex] = struct{}{}
+ refs = append(refs, pair.Record, pair.Signature, record.IndependenceDisclosure)
+ }
+ required := []Identity{definition.Coordinator, definition.ReleaseSigner}
+ required = append(required, definition.Auditors...)
+ for _, participant := range definition.Roster {
+ required = append(required, participant.Identity)
+ }
+ for _, identity := range required {
+ record, ok := enrollments[identity.ID]
+ if !ok || record.Identity != identity {
+ return nil, nil, fmt.Errorf("required proof-of-possession enrollment for %q is missing", identity.ID)
+ }
+ }
+ return enrollments, refs, nil
+}
+
+func verifyGovernanceEvidence(
+ definition CeremonyDefinition,
+ definitionBytes []byte,
+ root string,
+ pairs []SignedArtifactRefs,
+) ([]ArtifactRef, error) {
+ refs := make([]ArtifactRef, 0, len(pairs)*3)
+ for index, pair := range pairs {
+ recordAny, pairRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ pair,
+ RecordGovernance,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("governance record %d: %w", index, err)
+ }
+ record := recordAny.(*GovernanceRecord)
+ refs = append(refs, pairRefs...)
+ for evidenceIndex, evidence := range record.Evidence {
+ if err := verifyLargeOperationalArtifact(root, evidence); err != nil {
+ return nil, fmt.Errorf(
+ "governance record %d evidence %d: %w",
+ index,
+ evidenceIndex,
+ err,
+ )
+ }
+ refs = append(refs, evidence)
+ }
+ }
+ return refs, nil
+}
+
+func verifyAcceptedHeadEvidence(
+ definition CeremonyDefinition,
+ root string,
+ phase Phase,
+ heads []AcceptedHeadOperationalEvidence,
+ chain Chain,
+ enrollments map[string]EnrollmentRecord,
+) ([]ArtifactRef, error) {
+ definitionBytes, err := MarshalCanonical(definition)
+ if err != nil {
+ return nil, err
+ }
+ policy, err := definition.PolicyForPhase(phase)
+ if err != nil {
+ return nil, err
+ }
+ refs := make([]ArtifactRef, 0, len(heads)*10)
+ for index, evidence := range heads {
+ record := chain.Records[index]
+ if evidence.AcceptedHeadID != record.RecordID ||
+ evidence.PredecessorHeadID != record.PreviousRecordID {
+ return nil, fmt.Errorf("accepted head %d does not match authenticated predecessor/current chain IDs", index+1)
+ }
+ prefixBytes, err := verifyArtifactBytes(root, evidence.AcceptedChainPrefix.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d chain prefix: %w", index+1, err)
+ }
+ prefixSignatureBytes, err := verifyArtifactBytes(root, evidence.AcceptedChainPrefix.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d chain prefix signature: %w", index+1, err)
+ }
+ var signedPrefix Chain
+ coordinatorKey, err := identityPublicKey(definition.Coordinator)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifySignedRecord(
+ prefixBytes,
+ prefixSignatureBytes,
+ &signedPrefix,
+ definition.Coordinator.KeyID,
+ coordinatorKey,
+ ); err != nil {
+ return nil, fmt.Errorf("accepted head %d chain prefix: %w", index+1, err)
+ }
+ if signedPrefix.CeremonyID != chain.CeremonyID ||
+ signedPrefix.Phase != chain.Phase ||
+ signedPrefix.PhaseID != chain.PhaseID ||
+ signedPrefix.Genesis != chain.Genesis ||
+ len(signedPrefix.Records) != index+1 ||
+ !slices.Equal(signedPrefix.Records, chain.Records[:index+1]) {
+ return nil, fmt.Errorf("accepted head %d signed chain prefix is not the exact authenticated prefix", index+1)
+ }
+ refs = append(refs, evidence.AcceptedChainPrefix.Record, evidence.AcceptedChainPrefix.Signature)
+ participant, ok := definition.ParticipantByID(policy.Participants[index])
+ if !ok {
+ return nil, fmt.Errorf("scheduled participant %q is missing", policy.Participants[index])
+ }
+ attestationBytes, err := verifyArtifactBytes(root, record.Attestation, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d attestation: %w", index+1, err)
+ }
+ attestationSignature, err := verifyArtifactBytes(root, record.AttestationSignature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d attestation signature: %w", index+1, err)
+ }
+ var attestation ContributionAttestation
+ participantKey, err := identityPublicKey(participant.Identity)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifySignedRecord(
+ attestationBytes,
+ attestationSignature,
+ &attestation,
+ participant.Identity.KeyID,
+ participantKey,
+ ); err != nil {
+ return nil, fmt.Errorf("accepted head %d attestation: %w", index+1, err)
+ }
+ erasureBytes, err := verifyArtifactBytes(root, record.Erasure, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d erasure: %w", index+1, err)
+ }
+ erasureSignature, err := verifyArtifactBytes(root, record.ErasureSignature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d erasure signature: %w", index+1, err)
+ }
+ var erasure ErasureAttestation
+ if err := VerifySignedRecord(
+ erasureBytes,
+ erasureSignature,
+ &erasure,
+ participant.Identity.KeyID,
+ participantKey,
+ ); err != nil {
+ return nil, fmt.Errorf("accepted head %d erasure: %w", index+1, err)
+ }
+ if err := ValidateErasureForContribution(attestation, erasure); err != nil {
+ return nil, fmt.Errorf("accepted head %d erasure coherence: %w", index+1, err)
+ }
+ prefix := chain
+ prefix.Records = append([]ChainRecord(nil), chain.Records[:index]...)
+ if err := ValidateAttestationAcceptance(definition, prefix, attestation, erasure, record); err != nil {
+ return nil, fmt.Errorf("accepted head %d attestation/acceptance coherence: %w", index+1, err)
+ }
+ verificationBytes, err := verifyArtifactBytes(root, record.Verification, maxSignedRecordBytes)
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d verification: %w", index+1, err)
+ }
+ var verification ContributionVerification
+ if err := UnmarshalCanonical(verificationBytes, &verification); err != nil {
+ return nil, fmt.Errorf("accepted head %d verification: %w", index+1, err)
+ }
+ if err := validateContributionVerification(record, verification); err != nil {
+ return nil, fmt.Errorf("accepted head %d verification coherence: %w", index+1, err)
+ }
+ refs = append(
+ refs,
+ record.Attestation,
+ record.AttestationSignature,
+ record.Erasure,
+ record.ErasureSignature,
+ record.Verification,
+ )
+
+ outboundAny, pairRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ evidence.OutboundHandoff,
+ RecordHandoff,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d outbound handoff: %w", index+1, err)
+ }
+ outbound := outboundAny.(*TransferHandoff)
+ if outbound.Phase != phase || outbound.Index != uint8(index+1) ||
+ outbound.PredecessorHeadID != record.PreviousRecordID ||
+ outbound.SenderID != definition.Coordinator.ID ||
+ outbound.SenderKeyID != definition.Coordinator.KeyID ||
+ outbound.RecipientID != participant.Identity.ID ||
+ outbound.RecipientKeyID != participant.Identity.KeyID ||
+ !slices.Equal(outbound.Files, []ArtifactRef{record.PreviousPayload}) {
+ return nil, fmt.Errorf("accepted head %d outbound handoff does not bind coordinator, participant, predecessor, and input", index+1)
+ }
+ refs = append(refs, pairRefs...)
+
+ outboundReceiptAny, outboundReceiptRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ evidence.OutboundReceipt,
+ RecordReceipt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d outbound receipt: %w", index+1, err)
+ }
+ outboundReceipt := outboundReceiptAny.(*TransferReceipt)
+ if outboundReceipt.Kind != ReceiptReceiver {
+ return nil, fmt.Errorf("accepted head %d outbound receipt has wrong kind", index+1)
+ }
+ outboundBytes, err := verifyArtifactBytes(root, evidence.OutboundHandoff.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyTransferReceipt(outboundBytes, *outbound, *outboundReceipt); err != nil {
+ return nil, err
+ }
+ refs = append(refs, outboundReceiptRefs...)
+
+ returnHandoffAny, returnHandoffRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ evidence.ReturnHandoff,
+ RecordHandoff,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d return handoff: %w", index+1, err)
+ }
+ returnHandoff := returnHandoffAny.(*TransferHandoff)
+ expectedReturnFiles := []ArtifactRef{
+ record.Attestation,
+ record.AttestationSignature,
+ record.Erasure,
+ record.ErasureSignature,
+ record.OutputPayload,
+ }
+ slices.SortFunc(expectedReturnFiles, compareArtifactRefName)
+ if returnHandoff.Phase != phase || returnHandoff.Index != uint8(index+1) ||
+ returnHandoff.PredecessorHeadID != record.PreviousRecordID ||
+ returnHandoff.SenderID != participant.Identity.ID ||
+ returnHandoff.SenderKeyID != participant.Identity.KeyID ||
+ returnHandoff.RecipientID != definition.Coordinator.ID ||
+ returnHandoff.RecipientKeyID != definition.Coordinator.KeyID ||
+ !slices.Equal(returnHandoff.Files, expectedReturnFiles) {
+ return nil, fmt.Errorf("accepted head %d return handoff does not bind participant, coordinator, predecessor head, and output evidence", index+1)
+ }
+ refs = append(refs, returnHandoffRefs...)
+
+ returnReceiptAny, returnReceiptRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ evidence.ReturnReceipt,
+ RecordReceipt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d return receipt: %w", index+1, err)
+ }
+ returnReceipt := returnReceiptAny.(*TransferReceipt)
+ if returnReceipt.Kind != ReceiptReceiver {
+ return nil, fmt.Errorf("accepted head %d return receipt has wrong kind", index+1)
+ }
+ returnHandoffBytes, err := verifyArtifactBytes(root, evidence.ReturnHandoff.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyTransferReceipt(returnHandoffBytes, *returnHandoff, *returnReceipt); err != nil {
+ return nil, err
+ }
+ refs = append(refs, returnReceiptRefs...)
+
+ predecessorAcceptedAt := definition.CreatedAt
+ if index > 0 {
+ predecessorAcceptedAt = chain.Records[index-1].AcceptedAt
+ }
+ predecessorAccepted, _ := time.Parse(time.RFC3339Nano, predecessorAcceptedAt)
+ outboundCreated, _ := time.Parse(time.RFC3339Nano, outbound.CreatedAt)
+ outboundReceived, _ := time.Parse(time.RFC3339Nano, outboundReceipt.ReceivedAt)
+ contributed, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt)
+ destroyed, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt)
+ returnCreated, _ := time.Parse(time.RFC3339Nano, returnHandoff.CreatedAt)
+ returnReceived, _ := time.Parse(time.RFC3339Nano, returnReceipt.ReceivedAt)
+ accepted, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt)
+ if !outboundCreated.After(predecessorAccepted) ||
+ !outboundReceived.After(outboundCreated) ||
+ !contributed.After(outboundReceived) ||
+ !returnCreated.After(contributed) ||
+ !returnCreated.After(destroyed) ||
+ !returnReceived.After(returnCreated) ||
+ !accepted.After(returnReceived) {
+ return nil, fmt.Errorf("accepted head %d custody/contribution/erasure/acceptance timestamps are not strictly ordered", index+1)
+ }
+
+ 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)
+ for mirrorIndex, pair := range evidence.MirrorReceipts {
+ mirrorAny, mirrorRefs, err := verifyOperationalPair(
+ definition,
+ definitionBytes,
+ root,
+ pair,
+ RecordMirrorReceipt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("accepted head %d mirror %d: %w", index+1, mirrorIndex+1, err)
+ }
+ mirror := mirrorAny.(*ImmutableMirrorReceipt)
+ if mirror.CeremonyID != definition.CeremonyID || mirror.Phase != phase ||
+ mirror.Index != uint8(index+1) || mirror.AcceptedHeadID != evidence.AcceptedHeadID ||
+ !slices.Equal(mirror.Files, expectedMirrorFiles) {
+ return nil, fmt.Errorf("accepted head %d mirror %d does not bind exact head files", index+1, mirrorIndex+1)
+ }
+ stored, _ := time.Parse(time.RFC3339Nano, mirror.StoredAt)
+ if !stored.After(accepted) {
+ return nil, fmt.Errorf("accepted head %d mirror %d predates acceptance", index+1, mirrorIndex+1)
+ }
+ enrollment, ok := enrollments[mirror.Mirror.ID]
+ if !ok || enrollment.Role != EnrollmentMirrorOperator ||
+ enrollment.Identity != mirror.Mirror {
+ return nil, fmt.Errorf("mirror %q has no matching mirror-operator enrollment", mirror.Mirror.ID)
+ }
+ if _, duplicate := mirrorIDs[mirror.Mirror.ID]; duplicate {
+ return nil, fmt.Errorf("accepted head %d mirror identity is duplicated", index+1)
+ }
+ if _, duplicate := mirrorKeys[mirror.Mirror.PublicKeyFingerprint]; duplicate {
+ return nil, fmt.Errorf("accepted head %d mirror key is duplicated", index+1)
+ }
+ mirrorIDs[mirror.Mirror.ID] = struct{}{}
+ mirrorKeys[mirror.Mirror.PublicKeyFingerprint] = struct{}{}
+ refs = append(refs, mirrorRefs...)
+ }
+ }
+ return refs, nil
+}
+
+func compareArtifactRefName(a, b ArtifactRef) int {
+ return strings.Compare(a.Name, b.Name)
+}
+
+func verifyOperationalPair(
+ definition CeremonyDefinition,
+ definitionBytes []byte,
+ root string,
+ pair SignedArtifactRefs,
+ recordType OperationalRecordType,
+) (any, []ArtifactRef, error) {
+ recordBytes, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ return nil, nil, err
+ }
+ signatureBytes, err := verifyArtifactBytes(root, pair.Signature, maxSignedRecordBytes)
+ if err != nil {
+ return nil, nil, err
+ }
+ record, err := ParseOperationalRecord(recordType, recordBytes)
+ if err != nil {
+ return nil, nil, err
+ }
+ signer, err := VerifyOperationalRecordBinding(definition, definitionBytes, record)
+ if err != nil {
+ return nil, nil, 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, err
+ }
+ return record, []ArtifactRef{pair.Record, pair.Signature}, nil
+}
+
+func validateSignedArtifactSet(label string, artifacts []SignedArtifactRefs) error {
+ previous := ""
+ for index, artifact := range artifacts {
+ if err := artifact.Validate(); err != nil {
+ return fmt.Errorf("%s %d: %w", label, index, err)
+ }
+ if index > 0 && artifact.Record.Name <= previous {
+ return fmt.Errorf("%s must be ordered by unique record name", label)
+ }
+ previous = artifact.Record.Name
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go
new file mode 100644
index 00000000..8e95b89c
--- /dev/null
+++ b/internal/mpcceremony/operational_bundle_test.go
@@ -0,0 +1,1020 @@
+package mpcceremony
+
+import (
+ "crypto/ed25519"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+const quicknetRound43OperationalResponse = `{"round":43,"randomness":"c8f7c61c7024f8b45ffbf5be58b1f112a26be93c26f5461af9f8522233705dbb","signature":"a96a579010b3d2261959104b29b5b46685b3a6b6f6aae7304ef72d44fe7d44e667bcdd500935d7deb58a2b4d89419ea6"}`
+
+type operationalBundleFixture struct {
+ root string
+ definition CeremonyDefinition
+ coordinatorKey ed25519.PrivateKey
+ bundle OperationalEvidenceBundle
+ bundleBytes []byte
+ signatureBytes []byte
+ phase1Close AuthenticatedCloseEvidence
+ phase2Close AuthenticatedCloseEvidence
+ witnessKeys map[string]ed25519.PrivateKey
+}
+
+func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) {
+ fixture := newOperationalBundleFixture(t)
+ verify := func(f operationalBundleFixture) error {
+ _, err := VerifyOperationalEvidenceBundle(VerifyOperationalEvidenceOptions{
+ Definition: f.definition,
+ CoordinatorPublicKey: f.coordinatorKey.Public().(ed25519.PublicKey),
+ EvidenceRoot: f.root,
+ BundleBytes: f.bundleBytes,
+ BundleSignatureBytes: f.signatureBytes,
+ Phase1Close: f.phase1Close,
+ Phase2Close: f.phase2Close,
+ })
+ return err
+ }
+ if err := verify(fixture); err != nil {
+ t.Fatalf("complete operational bundle rejected: %v", err)
+ }
+
+ t.Run("missing enrollment", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ f.bundle.Enrollments = f.bundle.Enrollments[1:]
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("missing required proof-of-possession enrollment unexpectedly accepted")
+ }
+ })
+ t.Run("missing independence disclosure", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ recordBytes, err := verifyArtifactBytes(f.root, f.bundle.Enrollments[0].Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var enrollment EnrollmentRecord
+ if err := UnmarshalCanonical(recordBytes, &enrollment); err != nil {
+ t.Fatal(err)
+ }
+ path, err := resolveArtifactPath(f.root, enrollment.IndependenceDisclosure.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(f); err == nil {
+ t.Fatal("missing independence disclosure unexpectedly accepted")
+ }
+ })
+ t.Run("tampered contribution verification", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ chainBytes, err := verifyArtifactBytes(f.root, f.bundle.Phase1.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(chainBytes, &chain); err != nil {
+ t.Fatal(err)
+ }
+ path, err := resolveArtifactPath(f.root, chain.Records[0].Verification.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw[len(raw)-1] ^= 1
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(f); err == nil {
+ t.Fatal("tampered contribution verification unexpectedly accepted")
+ }
+ })
+ t.Run("incomplete mirror evidence", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ f.bundle.Phase1.AcceptedHeads[0].MirrorReceipts =
+ f.bundle.Phase1.AcceptedHeads[0].MirrorReceipts[:1]
+ resignInvalidBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("accepted head with one mirror unexpectedly accepted")
+ }
+ })
+ t.Run("swapped outbound custody direction", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ head := &f.bundle.Phase1.AcceptedHeads[0]
+ raw, err := verifyArtifactBytes(f.root, head.OutboundHandoff.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var handoff TransferHandoff
+ if err := UnmarshalCanonical(raw, &handoff); err != nil {
+ t.Fatal(err)
+ }
+ handoff.SenderID, handoff.RecipientID = handoff.RecipientID, handoff.SenderID
+ handoff.SenderKeyID, handoff.RecipientKeyID = handoff.RecipientKeyID, handoff.SenderKeyID
+ rewriteSignedPair(t, f.root, head.OutboundHandoff, handoff, adversarialPrivateKey(0x11))
+ head.OutboundHandoff = refreshPair(t, f.root, head.OutboundHandoff)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("participant-to-coordinator outbound direction unexpectedly accepted")
+ }
+ })
+ t.Run("return handoff uses future accepted id", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ head := &f.bundle.Phase1.AcceptedHeads[0]
+ raw, err := verifyArtifactBytes(f.root, head.ReturnHandoff.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var handoff TransferHandoff
+ if err := UnmarshalCanonical(raw, &handoff); err != nil {
+ t.Fatal(err)
+ }
+ handoff.PredecessorHeadID = head.AcceptedHeadID
+ rewriteSignedPair(t, f.root, head.ReturnHandoff, handoff, adversarialPrivateKey(0x11))
+ head.ReturnHandoff = refreshPair(t, f.root, head.ReturnHandoff)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("return handoff with future accepted record ID unexpectedly accepted")
+ }
+ })
+ t.Run("tampered accepted chain prefix", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ ref := f.bundle.Phase1.AcceptedHeads[0].AcceptedChainPrefix.Record
+ path, err := resolveArtifactPath(f.root, ref.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw[len(raw)-1] ^= 1
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(f); err == nil {
+ t.Fatal("tampered accepted chain prefix unexpectedly accepted")
+ }
+ })
+ t.Run("mirror omits accepted chain prefix", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ head := &f.bundle.Phase1.AcceptedHeads[0]
+ raw, err := verifyArtifactBytes(f.root, head.MirrorReceipts[0].Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var mirror ImmutableMirrorReceipt
+ if err := UnmarshalCanonical(raw, &mirror); err != nil {
+ t.Fatal(err)
+ }
+ filtered := mirror.Files[:0]
+ for _, ref := range mirror.Files {
+ if ref != head.AcceptedChainPrefix.Record && ref != head.AcceptedChainPrefix.Signature {
+ filtered = append(filtered, ref)
+ }
+ }
+ mirror.Files = filtered
+ rewriteSignedPair(t, f.root, head.MirrorReceipts[0], mirror, adversarialPrivateKey(0xa1))
+ head.MirrorReceipts[0] = refreshPair(t, f.root, head.MirrorReceipts[0])
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("mirror omitting authenticated chain prefix unexpectedly accepted")
+ }
+ })
+ t.Run("tampered accepted output payload", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ chainBytes, err := verifyArtifactBytes(f.root, f.bundle.Phase1.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(chainBytes, &chain); err != nil {
+ t.Fatal(err)
+ }
+ path, err := resolveArtifactPath(f.root, chain.Records[0].OutputPayload.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("tampered output payload"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(f); err == nil {
+ t.Fatal("tampered accepted output payload unexpectedly accepted")
+ }
+ })
+ t.Run("actor overlap witness", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ pair := f.bundle.Phase1.PublicWitnessReceipts[0]
+ var receipt PublicWitnessReceipt
+ recordBytes, err := verifyArtifactBytes(f.root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := UnmarshalCanonical(recordBytes, &receipt); err != nil {
+ t.Fatal(err)
+ }
+ receipt.Witness = f.definition.Roster[0].Identity
+ privateKey := adversarialPrivateKey(0x11)
+ rewriteSignedPair(t, f.root, pair, receipt, privateKey)
+ f.bundle.Phase1.PublicWitnessReceipts[0] = refreshPair(t, f.root, pair)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("participant self-witness unexpectedly accepted")
+ }
+ })
+ t.Run("less than three relay operators", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ pair := f.bundle.Phase1.MultiRelayBeaconEvidence
+ var evidence MultiRelayBeaconEvidence
+ recordBytes, err := verifyArtifactBytes(f.root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := UnmarshalCanonical(recordBytes, &evidence); err != nil {
+ t.Fatal(err)
+ }
+ evidence.Observations = evidence.Observations[:2]
+ rewriteInvalidSignedPair(t, f.root, pair, evidence, f.coordinatorKey, f.definition.Coordinator.KeyID)
+ f.bundle.Phase1.MultiRelayBeaconEvidence = refreshPair(t, f.root, pair)
+ f.bundle.Phase1.RawBeaconResponses = f.bundle.Phase1.RawBeaconResponses[:2]
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("two-operator relay evidence unexpectedly accepted")
+ }
+ })
+ t.Run("reused phase rounds", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ phase1Round := f.phase1Close.Record.BeaconRound
+ close2 := f.phase2Close.Record
+ close2.BeaconRound = phase1Round
+ reusedRoundTime, err := QuicknetRoundTime(phase1Round)
+ if err != nil {
+ t.Fatal(err)
+ }
+ close2.BeaconNotBefore = reusedRoundTime.Format(time.RFC3339)
+ close2, err = NewCloseRecord(close2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ recordBytes, signatureBytes, err := SignRecord(close2, f.definition.Coordinator.KeyID, f.coordinatorKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.phase2Close = AuthenticatedCloseEvidence{Record: close2, RecordBytes: recordBytes, SignatureBytes: signatureBytes}
+ f.bundle.Phase2.Close.Record.Digest = NewDigest(recordBytes)
+ f.bundle.Phase2.Close.Signature.Digest = NewDigest(signatureBytes)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("phase round reuse unexpectedly accepted")
+ }
+ })
+ t.Run("too late witness", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ pair := f.bundle.Phase1.PublicWitnessReceipts[0]
+ var receipt PublicWitnessReceipt
+ recordBytes, err := verifyArtifactBytes(f.root, pair.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := UnmarshalCanonical(recordBytes, &receipt); err != nil {
+ t.Fatal(err)
+ }
+ roundTime, _ := QuicknetRoundTime(receipt.BeaconRound)
+ receipt.ObservedAt = roundTime.Add(-24*time.Hour + time.Second).Format(time.RFC3339)
+ rewriteSignedPair(t, f.root, pair, receipt, f.witnessKeys[receipt.Witness.ID])
+ f.bundle.Phase1.PublicWitnessReceipts[0] = refreshPair(t, f.root, pair)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("too-late witness unexpectedly accepted")
+ }
+ })
+ t.Run("receipt equal to handoff time", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ head := &f.bundle.Phase1.AcceptedHeads[0]
+ handoffBytes, err := verifyArtifactBytes(f.root, head.OutboundHandoff.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var handoff TransferHandoff
+ if err := UnmarshalCanonical(handoffBytes, &handoff); err != nil {
+ t.Fatal(err)
+ }
+ receiptBytes, err := verifyArtifactBytes(f.root, head.OutboundReceipt.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var receipt TransferReceipt
+ if err := UnmarshalCanonical(receiptBytes, &receipt); err != nil {
+ t.Fatal(err)
+ }
+ receipt.ReceivedAt = handoff.CreatedAt
+ rewriteSignedPair(t, f.root, head.OutboundReceipt, receipt, adversarialPrivateKey(0x11))
+ head.OutboundReceipt = refreshPair(t, f.root, head.OutboundReceipt)
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("receipt at exact handoff creation time unexpectedly accepted")
+ }
+ })
+ t.Run("mirror equal to acceptance time", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ head := &f.bundle.Phase1.AcceptedHeads[0]
+ chainBytes, err := verifyArtifactBytes(f.root, f.bundle.Phase1.AcceptedChain.Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chain Chain
+ if err := UnmarshalCanonical(chainBytes, &chain); err != nil {
+ t.Fatal(err)
+ }
+ mirrorBytes, err := verifyArtifactBytes(f.root, head.MirrorReceipts[0].Record, maxSignedRecordBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var mirror ImmutableMirrorReceipt
+ if err := UnmarshalCanonical(mirrorBytes, &mirror); err != nil {
+ t.Fatal(err)
+ }
+ mirror.StoredAt = chain.Records[0].AcceptedAt
+ rewriteSignedPair(t, f.root, head.MirrorReceipts[0], mirror, adversarialPrivateKey(0xa1))
+ head.MirrorReceipts[0] = refreshPair(t, f.root, head.MirrorReceipts[0])
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("mirror at exact acceptance time unexpectedly accepted")
+ }
+ })
+ t.Run("assembled at latest evidence boundary", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ evidenceBytes, err := verifyArtifactBytes(
+ f.root,
+ f.bundle.Phase2.MultiRelayBeaconEvidence.Record,
+ maxSignedRecordBytes,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ var evidence MultiRelayBeaconEvidence
+ if err := UnmarshalCanonical(evidenceBytes, &evidence); err != nil {
+ t.Fatal(err)
+ }
+ f.bundle.AssembledAt = evidence.RecordedAt
+ resignBundle(t, &f)
+ if err := verify(f); err == nil {
+ t.Fatal("bundle assembled at exact latest evidence time unexpectedly accepted")
+ }
+ })
+ t.Run("chain tamper", func(t *testing.T) {
+ f := newOperationalBundleFixture(t)
+ path, err := resolveArtifactPath(f.root, f.bundle.Phase1.AcceptedChain.Record.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data[len(data)-1] ^= 1
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verify(f); err == nil {
+ t.Fatal("tampered accepted chain unexpectedly accepted")
+ }
+ })
+}
+
+func newOperationalBundleFixture(t *testing.T) operationalBundleFixture {
+ t.Helper()
+ definition := adversarialDefinition(t)
+ round42Time, _ := QuicknetRoundTime(42)
+ definition.Mode = ModeRehearsal
+ definition.CreatedAt = round42Time.Add(-30 * time.Hour).Format(time.RFC3339)
+ definition.Circuit.Constraints = 1_789_750
+ definition.Circuit.DomainSize = 1 << 21
+ definition.Phase1Policy.Minimum = 1
+ definition.Phase2Policy.Minimum = 1
+ var err error
+ definition, err = FinalizeCeremonyDefinition(definition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ definitionBytes, _ := MarshalCanonical(definition)
+ root := t.TempDir()
+ coordinatorKey := adversarialPrivateKey(0x01)
+ witnesses := []struct {
+ identity Identity
+ key ed25519.PrivateKey
+ }{
+ {adversarialIdentity(t, "public-witness-01", 0x91), adversarialPrivateKey(0x91)},
+ {adversarialIdentity(t, "public-witness-02", 0x92), adversarialPrivateKey(0x92)},
+ }
+ mirrors := []struct {
+ identity Identity
+ key ed25519.PrivateKey
+ }{
+ {adversarialIdentity(t, "mirror-operator-01", 0xa1), adversarialPrivateKey(0xa1)},
+ {adversarialIdentity(t, "mirror-operator-02", 0xa2), adversarialPrivateKey(0xa2)},
+ }
+
+ type enrollmentInput struct {
+ identity Identity
+ role EnrollmentRole
+ index uint16
+ key ed25519.PrivateKey
+ }
+ inputs := []enrollmentInput{
+ {definition.Coordinator, EnrollmentCoordinator, 1, coordinatorKey},
+ {definition.ReleaseSigner, EnrollmentReleaseSigner, 1, adversarialPrivateKey(0x02)},
+ {definition.Auditors[0], EnrollmentAuditor, 1, adversarialPrivateKey(0x03)},
+ {definition.Auditors[1], EnrollmentAuditor, 2, adversarialPrivateKey(0x04)},
+ }
+ for index, participant := range definition.Roster {
+ inputs = append(inputs, enrollmentInput{
+ participant.Identity,
+ EnrollmentParticipant,
+ uint16(index + 1),
+ adversarialPrivateKey(byte(0x11 + index)),
+ })
+ }
+ for index, witness := range witnesses {
+ inputs = append(inputs, enrollmentInput{witness.identity, EnrollmentPublicWitness, uint16(index + 1), witness.key})
+ }
+ for index, mirror := range mirrors {
+ inputs = append(inputs, enrollmentInput{mirror.identity, EnrollmentMirrorOperator, uint16(index + 1), mirror.key})
+ }
+ slices.SortFunc(inputs, func(a, b enrollmentInput) int {
+ return strings.Compare(a.identity.ID, b.identity.ID)
+ })
+ enrollments := make([]SignedArtifactRefs, 0, len(inputs))
+ for _, input := range inputs {
+ disclosureData := []byte("independence disclosure " + input.identity.ID)
+ disclosureName := "disclosures/" + input.identity.ID + ".json"
+ writeFixtureFile(t, root, disclosureName, disclosureData)
+ record, err := NewEnrollmentRecord(
+ definition,
+ definitionBytes,
+ input.identity,
+ input.role,
+ input.index,
+ ArtifactRef{
+ Name: disclosureName,
+ Digest: NewDigest(disclosureData),
+ },
+ round42Time.Add(-29*time.Hour).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ enrollments = append(enrollments, writeSignedFixturePair(
+ t,
+ root,
+ "enrollments/"+input.identity.ID,
+ record,
+ input.identity.KeyID,
+ input.key,
+ ))
+ }
+
+ phase1, close1 := buildOperationalPhaseFixture(
+ t, root, definition, Phase1, 42, quicknetRound42Response,
+ coordinatorKey, witnesses, mirrors,
+ )
+ phase2, close2 := buildOperationalPhaseFixture(
+ t, root, definition, Phase2, 43, quicknetRound43OperationalResponse,
+ coordinatorKey, witnesses, mirrors,
+ )
+ bundle := OperationalEvidenceBundle{
+ Schema: OperationalEvidenceBundleSchema,
+ CeremonyID: definition.CeremonyID,
+ Enrollments: enrollments,
+ Phase1: phase1,
+ Phase2: phase2,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AssembledAt: round42Time.Add(time.Hour).Format(time.RFC3339),
+ }
+ bundleBytes, signatureBytes, err := SignRecord(bundle, definition.Coordinator.KeyID, coordinatorKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ witnessKeys := make(map[string]ed25519.PrivateKey, len(witnesses))
+ for _, witness := range witnesses {
+ witnessKeys[witness.identity.ID] = witness.key
+ }
+ return operationalBundleFixture{
+ root: root,
+ definition: definition,
+ coordinatorKey: coordinatorKey,
+ bundle: bundle,
+ bundleBytes: bundleBytes,
+ signatureBytes: signatureBytes,
+ phase1Close: close1,
+ phase2Close: close2,
+ witnessKeys: witnessKeys,
+ }
+}
+
+func buildOperationalPhaseFixture(
+ t *testing.T,
+ root string,
+ definition CeremonyDefinition,
+ phase Phase,
+ round uint64,
+ rawResponse string,
+ coordinatorKey ed25519.PrivateKey,
+ witnesses, mirrors []struct {
+ identity Identity
+ key ed25519.PrivateKey
+ },
+) (PhaseOperationalEvidence, AuthenticatedCloseEvidence) {
+ t.Helper()
+ roundTime, _ := QuicknetRoundTime(round)
+ genesis := definition.Phase1Genesis
+ phaseID := "sha256:" + strings.Repeat("77", 32)
+ if phase == Phase1 {
+ var err error
+ phaseID, err = ComputePhaseID(definition.CeremonyID, Phase1, genesis, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ genesis = ArtifactRef{Name: "phase2/genesis.bin", Digest: NewDigest([]byte("phase2 genesis"))}
+ }
+ genesisBytes := []byte(string(phase) + " genesis")
+ writeFixtureFile(t, root, genesis.Name, genesisBytes)
+ chain, err := NewChain(definition.CeremonyID, phase, phaseID, genesis)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previousID, _ := GenesisRecordID(definition.CeremonyID, phaseID, genesis)
+ outputBytes := []byte(string(phase) + " accepted output")
+ outputPayload := ArtifactRef{
+ Name: string(phase) + "/accepted-01.bin",
+ Digest: NewDigest(outputBytes),
+ }
+ writeFixtureFile(t, root, outputPayload.Name, outputBytes)
+ attestation, err := NewContributionAttestation(ContributionAttestation{
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: definition.Roster[0].Identity.ID,
+ ParticipantKeyID: definition.Roster[0].Identity.KeyID,
+ PreviousPayload: genesis,
+ OutputPayload: outputPayload,
+ PreviousAcceptanceID: previousID,
+ ToolBinary: definition.Software.ToolBinary,
+ SourceCommit: definition.Software.SourceCommit,
+ GnarkVersion: GnarkVersion,
+ GnarkCryptoVersion: GnarkCryptoVersion,
+ DrandVersion: DrandVersion,
+ Environment: ContributionEnvironment{
+ OS: "linux",
+ Architecture: "amd64",
+ EntropySource: "operating-system-csprng",
+ SwapDisabled: true,
+ CrashDumpsDisabled: true,
+ TelemetryDisabled: true,
+ EphemeralEnvironment: true,
+ EphemeralDestructionRequired: true,
+ },
+ ContributedAt: roundTime.Add(-27 * time.Hour).Format(time.RFC3339),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ attestationPair := writeSignedFixturePair(
+ t, root, string(phase)+"/attestation", attestation,
+ definition.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11),
+ )
+ erasure, err := NewErasureAttestation(ErasureAttestation{
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: definition.Roster[0].Identity.ID,
+ ParticipantKeyID: definition.Roster[0].Identity.KeyID,
+ ContributionAttestationID: attestation.AttestationID,
+ OutputPayload: outputPayload,
+ DestroyedAt: roundTime.Add(-26*time.Hour - 30*time.Minute).Format(time.RFC3339),
+ ProcessTerminated: true,
+ EphemeralStorageDestroyed: true,
+ NoBackupRetained: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ erasurePair := writeSignedFixturePair(
+ t, root, string(phase)+"/erasure", erasure,
+ definition.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11),
+ )
+ verification := ContributionVerification{
+ Schema: verificationSchema,
+ VerificationMode: directTransitionVerification,
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: definition.Roster[0].Identity.ID,
+ PreviousPayload: genesis,
+ OutputPayload: outputPayload,
+ AttestationID: attestation.AttestationID,
+ ErasureID: erasure.ErasureID,
+ PreviousRecordID: previousID,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ Passed: true,
+ VerifiedAt: roundTime.Add(-26 * time.Hour).Format(time.RFC3339),
+ }
+ verificationBytes, err := MarshalCanonical(verification)
+ if err != nil {
+ t.Fatal(err)
+ }
+ verificationName := string(phase) + "/verification.json"
+ writeFixtureFile(t, root, verificationName, verificationBytes)
+ record, err := NewChainRecord(ChainRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ Index: 1,
+ ParticipantID: definition.Roster[0].Identity.ID,
+ PreviousPayload: genesis,
+ OutputPayload: outputPayload,
+ AttestationID: attestation.AttestationID,
+ Attestation: attestationPair.Record,
+ AttestationSignature: attestationPair.Signature,
+ ErasureID: erasure.ErasureID,
+ Erasure: erasurePair.Record,
+ ErasureSignature: erasurePair.Signature,
+ Verification: ArtifactRef{
+ Name: verificationName,
+ Digest: NewDigest(verificationBytes),
+ },
+ PreviousRecordID: previousID,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AcceptedAt: roundTime.Add(-26 * time.Hour).Format(time.RFC3339),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := chain.Append(record); err != nil {
+ t.Fatal(err)
+ }
+ chainPair := writeSignedFixturePair(
+ t, root, string(phase)+"/chain", chain,
+ definition.Coordinator.KeyID, coordinatorKey,
+ )
+ prefixPair := writeSignedFixturePair(
+ t, root, string(phase)+"/heads/0001/accepted-chain", chain,
+ definition.Coordinator.KeyID, coordinatorKey,
+ )
+ close, err := NewCloseRecord(CloseRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: phaseID,
+ FinalIndex: 1,
+ FinalPayload: record.OutputPayload,
+ ChainHeadID: record.RecordID,
+ AcceptedParticipants: []string{definition.Roster[0].Identity.ID},
+ BeaconProvider: BeaconProviderDrand,
+ BeaconNetwork: BeaconNetworkQuicknet,
+ 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 := SignRecord(close, definition.Coordinator.KeyID, coordinatorKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ closePair := writeExactFixturePair(t, root, string(phase)+"/close", closeBytes, closeSignature)
+
+ handoff, err := NewTransferHandoff(
+ definition, phase, 1, record.PreviousRecordID,
+ []ArtifactRef{record.PreviousPayload},
+ definition.Coordinator, definition.Roster[0].Identity,
+ roundTime.Add(-29*time.Hour).Format(time.RFC3339),
+ roundTime.Add(-27*time.Hour-30*time.Minute).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ handoffBytes, _ := MarshalCanonical(handoff)
+ handoffPair := writeSignedFixturePair(
+ t, root, string(phase)+"/heads/0001/handoff", handoff,
+ definition.Coordinator.KeyID, coordinatorKey,
+ )
+ receiver, err := NewTransferReceipt(
+ handoff, handoffBytes, ReceiptReceiver,
+ roundTime.Add(-28*time.Hour).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ receiverPair := writeSignedFixturePair(
+ t, root, string(phase)+"/heads/0001/receiver", receiver,
+ definition.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11),
+ )
+ returnFiles := []ArtifactRef{
+ record.Attestation,
+ record.AttestationSignature,
+ record.Erasure,
+ record.ErasureSignature,
+ record.OutputPayload,
+ }
+ slices.SortFunc(returnFiles, compareArtifactRefName)
+ returnHandoff, err := NewTransferHandoff(
+ definition, phase, 1, record.PreviousRecordID,
+ returnFiles,
+ definition.Roster[0].Identity, definition.Coordinator,
+ roundTime.Add(-26*time.Hour-20*time.Minute).Format(time.RFC3339),
+ roundTime.Add(-26*time.Hour-5*time.Minute).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ returnHandoffBytes, _ := MarshalCanonical(returnHandoff)
+ returnHandoffPair := writeSignedFixturePair(
+ t, root, string(phase)+"/heads/0001/return-handoff", returnHandoff,
+ definition.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11),
+ )
+ returnReceipt, err := NewTransferReceipt(
+ returnHandoff, returnHandoffBytes, ReceiptReceiver,
+ roundTime.Add(-26*time.Hour-10*time.Minute).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ returnPair := writeSignedFixturePair(
+ t, root, string(phase)+"/heads/0001/return", returnReceipt,
+ definition.Coordinator.KeyID, coordinatorKey,
+ )
+ mirrorPairs := make([]SignedArtifactRefs, len(mirrors))
+ mirrorFiles := append([]ArtifactRef(nil), returnFiles...)
+ mirrorFiles = append(
+ mirrorFiles,
+ record.Verification,
+ prefixPair.Record,
+ prefixPair.Signature,
+ )
+ slices.SortFunc(mirrorFiles, compareArtifactRefName)
+ for index, mirror := range mirrors {
+ mirrorReceipt := ImmutableMirrorReceipt{
+ Schema: ImmutableMirrorReceiptSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ Index: 1,
+ AcceptedHeadID: record.RecordID,
+ Files: mirrorFiles,
+ Mirror: mirror.identity,
+ StorageLocationSHA256: taggedSHA256([]byte("immutable://" + mirror.identity.ID)),
+ StoredAt: roundTime.Add(-25*time.Hour - 50*time.Minute).Format(time.RFC3339),
+ }
+ if err := mirrorReceipt.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ mirrorPairs[index] = writeSignedFixturePair(
+ t, root,
+ string(phase)+"/heads/0001/mirrors/"+mirror.identity.ID,
+ mirrorReceipt, mirror.identity.KeyID, mirror.key,
+ )
+ }
+ witnessPairs := make([]SignedArtifactRefs, len(witnesses))
+ for index, witness := range witnesses {
+ receipt, err := NewPublicWitnessReceipt(
+ definition,
+ close,
+ closeBytes,
+ witness.identity,
+ closePair.Record.Name,
+ taggedSHA256([]byte("https://public.example/"+string(phase)+"/"+witness.identity.ID)),
+ roundTime.Add(-24*time.Hour).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ witnessPairs[index] = writeSignedFixturePair(
+ t, root, string(phase)+"/witnesses/"+witness.identity.ID,
+ receipt, witness.identity.KeyID, witness.key,
+ )
+ }
+ rawBytes := []byte(rawResponse)
+ randomness, err := VerifyDrandBeaconResponse(definition.BeaconPolicy, round, rawBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ operatorIDs := []string{"cloudflare", "drand", "secureweb3"}
+ rawRefs := make([]ArtifactRef, len(operatorIDs))
+ observations := make([]RelayObservation, len(operatorIDs))
+ rawMap := make(map[string][]byte, len(operatorIDs))
+ for index, operatorID := range operatorIDs {
+ name := string(phase) + "/beacon/raw/" + operatorID + ".json"
+ writeFixtureFile(t, root, name, rawBytes)
+ rawRefs[index] = ArtifactRef{Name: name, Digest: NewDigest(rawBytes)}
+ relayID := "relay-" + operatorID
+ observations[index] = RelayObservation{
+ RelayID: relayID,
+ OperatorID: operatorID,
+ EndpointSHA256: taggedSHA256([]byte("https://" + operatorID + ".example/beacon")),
+ RawResponse: rawRefs[index],
+ RetrievedAt: roundTime.Format(time.RFC3339),
+ VerifiedRandomness: randomness,
+ }
+ rawMap[relayID] = rawBytes
+ }
+ slices.SortFunc(observations, func(a, b RelayObservation) int {
+ return strings.Compare(a.RelayID, b.RelayID)
+ })
+ evidence, err := NewMultiRelayBeaconEvidence(
+ definition, close, observations, rawMap,
+ roundTime.Add(time.Second).Format(time.RFC3339),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ beaconPair := writeSignedFixturePair(
+ t, root, string(phase)+"/beacon/evidence", evidence,
+ definition.Coordinator.KeyID, coordinatorKey,
+ )
+ return PhaseOperationalEvidence{
+ Phase: phase,
+ AcceptedChain: chainPair,
+ Close: closePair,
+ AcceptedHeads: []AcceptedHeadOperationalEvidence{{
+ Index: 1,
+ PredecessorHeadID: record.PreviousRecordID,
+ AcceptedHeadID: record.RecordID,
+ OutboundHandoff: handoffPair,
+ OutboundReceipt: receiverPair,
+ ReturnHandoff: returnHandoffPair,
+ ReturnReceipt: returnPair,
+ AcceptedChainPrefix: prefixPair,
+ MirrorReceipts: mirrorPairs,
+ }},
+ PublicWitnessQuorum: 2,
+ PublicWitnessReceipts: witnessPairs,
+ MultiRelayBeaconEvidence: beaconPair,
+ RawBeaconResponses: rawRefs,
+ }, AuthenticatedCloseEvidence{
+ Record: close,
+ RecordBytes: closeBytes,
+ SignatureBytes: closeSignature,
+ }
+}
+
+func writeSignedFixturePair(
+ t *testing.T,
+ root, stem string,
+ record any,
+ keyID string,
+ key ed25519.PrivateKey,
+) SignedArtifactRefs {
+ t.Helper()
+ recordBytes, signatureBytes, err := SignRecord(record, keyID, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return writeExactFixturePair(t, root, stem, recordBytes, signatureBytes)
+}
+
+func writeExactFixturePair(t *testing.T, root, stem string, recordBytes, signatureBytes []byte) SignedArtifactRefs {
+ t.Helper()
+ recordName, signatureName := stem+".json", stem+".sig"
+ writeFixtureFile(t, root, recordName, recordBytes)
+ writeFixtureFile(t, root, signatureName, signatureBytes)
+ return SignedArtifactRefs{
+ Record: ArtifactRef{Name: recordName, Digest: NewDigest(recordBytes)},
+ Signature: ArtifactRef{Name: signatureName, Digest: NewDigest(signatureBytes)},
+ }
+}
+
+func writeFixtureFile(t *testing.T, root, name string, data []byte) {
+ t.Helper()
+ path := filepath.Join(root, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func rewriteSignedPair(t *testing.T, root string, pair SignedArtifactRefs, record any, key ed25519.PrivateKey) {
+ t.Helper()
+ recordBytes, signatureBytes, err := SignRecord(record, keyIDForOperationalRecord(record), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeFixtureFile(t, root, pair.Record.Name, recordBytes)
+ writeFixtureFile(t, root, pair.Signature.Name, signatureBytes)
+}
+
+func rewriteInvalidSignedPair(
+ t *testing.T,
+ root string,
+ pair SignedArtifactRefs,
+ record any,
+ key ed25519.PrivateKey,
+ keyID string,
+) {
+ t.Helper()
+ recordBytes, err := jsonMarshalForNegative(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signature, err := SignExact(recordBytes, keyID, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signatureBytes, err := MarshalCanonical(signature)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeFixtureFile(t, root, pair.Record.Name, recordBytes)
+ writeFixtureFile(t, root, pair.Signature.Name, signatureBytes)
+}
+
+func refreshPair(t *testing.T, root string, pair SignedArtifactRefs) SignedArtifactRefs {
+ t.Helper()
+ record, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(pair.Record.Name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ signature, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(pair.Signature.Name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pair.Record.Digest = NewDigest(record)
+ pair.Signature.Digest = NewDigest(signature)
+ return pair
+}
+
+func resignBundle(t *testing.T, fixture *operationalBundleFixture) {
+ t.Helper()
+ var err error
+ fixture.bundleBytes, fixture.signatureBytes, err = SignRecord(
+ fixture.bundle,
+ fixture.definition.Coordinator.KeyID,
+ fixture.coordinatorKey,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func resignInvalidBundle(t *testing.T, fixture *operationalBundleFixture) {
+ t.Helper()
+ var err error
+ fixture.bundleBytes, err = jsonMarshalForNegative(fixture.bundle)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signature, err := SignExact(
+ fixture.bundleBytes,
+ fixture.definition.Coordinator.KeyID,
+ fixture.coordinatorKey,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixture.signatureBytes, err = MarshalCanonical(signature)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func jsonMarshalForNegative(value any) ([]byte, error) {
+ return json.Marshal(value)
+}
+
+func keyIDForOperationalRecord(record any) string {
+ switch r := record.(type) {
+ case PublicWitnessReceipt:
+ return r.Witness.KeyID
+ case TransferReceipt:
+ return r.SignerKeyID
+ case ImmutableMirrorReceipt:
+ return r.Mirror.KeyID
+ case TransferHandoff:
+ return r.SenderKeyID
+ case MultiRelayBeaconEvidence:
+ return r.CoordinatorKeyID
+ case EnrollmentRecord:
+ return r.Identity.KeyID
+ default:
+ return ""
+ }
+}
diff --git a/internal/mpcceremony/operational_test.go b/internal/mpcceremony/operational_test.go
new file mode 100644
index 00000000..95a48d96
--- /dev/null
+++ b/internal/mpcceremony/operational_test.go
@@ -0,0 +1,367 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestEnrollmentProofOfPossessionBindsFrozenDefinitionRosterRoleAndDisclosure(t *testing.T) {
+ definition := adversarialDefinition(t)
+ definitionBytes, err := MarshalCanonical(definition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rosterBytes, err := json.Marshal(definition.Roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := EnrollmentRecord{
+ Schema: EnrollmentRecordSchema,
+ CeremonyID: definition.CeremonyID,
+ Definition: NewDigest(definitionBytes),
+ FullRosterSHA256: taggedSHA256(rosterBytes),
+ Identity: definition.Roster[0].Identity,
+ Role: EnrollmentParticipant,
+ RoleIndex: 1,
+ IndependenceDisclosure: ArtifactRef{Name: "disclosures/participant-01.json", Digest: NewDigest([]byte("independent organization and host"))},
+ EnrolledAt: "2026-07-23T12:00:01Z",
+ }
+ canonical, err := MarshalCanonical(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privateKey := adversarialPrivateKey(0x11)
+ raw := ed25519.Sign(privateKey, canonical)
+ signature, err := ImportOperationalSignature(canonical, record.Identity.KeyID, privateKey.Public().(ed25519.PublicKey), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyExact(canonical, signature, record.Identity.KeyID, privateKey.Public().(ed25519.PublicKey)); err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := ParseOperationalRecord(RecordEnrollment, canonical)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, parsed); err != nil {
+ t.Fatal(err)
+ }
+
+ tampered := record
+ tampered.RoleIndex = 2
+ if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &tampered); err == nil {
+ t.Fatal("wrong roster index unexpectedly accepted")
+ }
+ tampered = record
+ tampered.FullRosterSHA256 = taggedSHA256([]byte("partial roster"))
+ if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &tampered); err == nil {
+ t.Fatal("partial roster digest unexpectedly accepted")
+ }
+ if _, err := ImportOperationalSignature(canonical, record.Identity.KeyID, privateKey.Public().(ed25519.PublicKey), bytes.Repeat([]byte{0}, 64)); err == nil {
+ t.Fatal("invalid offline signature unexpectedly imported")
+ }
+ nonCanonical := append([]byte(" "), canonical...)
+ if _, err := ParseOperationalRecord(RecordEnrollment, nonCanonical); err == nil {
+ t.Fatal("non-canonical signing bytes unexpectedly accepted")
+ }
+}
+
+func TestExternalEnrollmentRejectsCeremonyActorOverlap(t *testing.T) {
+ definition := adversarialDefinition(t)
+ definitionBytes, err := MarshalCanonical(definition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rosterBytes, _ := json.Marshal(definition.Roster)
+ record := EnrollmentRecord{
+ Schema: EnrollmentRecordSchema,
+ CeremonyID: definition.CeremonyID,
+ Definition: NewDigest(definitionBytes),
+ FullRosterSHA256: taggedSHA256(rosterBytes),
+ Identity: definition.Coordinator,
+ Role: EnrollmentPublicWitness,
+ RoleIndex: 1,
+ IndependenceDisclosure: ArtifactRef{Name: "disclosures/witness.json", Digest: NewDigest([]byte("not independent"))},
+ EnrolledAt: "2026-07-23T12:00:01Z",
+ }
+ if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &record); err == nil {
+ t.Fatal("ceremony coordinator unexpectedly enrolled as independent public witness")
+ }
+}
+
+func TestTransferReceiptBindsExactHandoffAndValidityWindow(t *testing.T) {
+ definition := adversarialDefinition(t)
+ source := TransferSourceBinding{
+ SourceCommit: definition.Software.SourceCommit,
+ ToolBinary: definition.Software.ToolBinary,
+ R1CS: definition.Circuit.R1CS,
+ }
+ files := []ArtifactRef{
+ {Name: "accepted/head-01.bin", Digest: NewDigest([]byte("accepted head"))},
+ }
+ handoff := TransferHandoff{
+ Schema: TransferHandoffSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ Index: 1,
+ PredecessorHeadID: "sha256:" + strings.Repeat("ab", 32),
+ Source: source,
+ Files: files,
+ SenderID: definition.Coordinator.ID,
+ SenderKeyID: definition.Coordinator.KeyID,
+ RecipientID: definition.Roster[0].Identity.ID,
+ RecipientKeyID: definition.Roster[0].Identity.KeyID,
+ CreatedAt: "2026-07-23T13:00:00Z",
+ ExpiresAt: "2026-07-23T15:00:00Z",
+ }
+ handoffBytes, err := MarshalCanonical(handoff)
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipt := TransferReceipt{
+ Schema: TransferReceiptSchema,
+ Kind: ReceiptReceiver,
+ HandoffSHA256: taggedSHA256(handoffBytes),
+ CeremonyID: handoff.CeremonyID,
+ Phase: handoff.Phase,
+ Index: handoff.Index,
+ PredecessorHeadID: handoff.PredecessorHeadID,
+ Source: handoff.Source,
+ Files: handoff.Files,
+ SenderID: handoff.SenderID,
+ SenderKeyID: handoff.SenderKeyID,
+ RecipientID: handoff.RecipientID,
+ RecipientKeyID: handoff.RecipientKeyID,
+ SignerID: handoff.RecipientID,
+ SignerKeyID: handoff.RecipientKeyID,
+ ReceivedAt: "2026-07-23T14:00:00Z",
+ }
+ if err := VerifyTransferReceipt(handoffBytes, handoff, receipt); err != nil {
+ t.Fatal(err)
+ }
+ tampered := receipt
+ tampered.Files = []ArtifactRef{{Name: files[0].Name, Digest: NewDigest([]byte("substitution"))}}
+ if err := VerifyTransferReceipt(handoffBytes, handoff, tampered); err == nil {
+ t.Fatal("file substitution unexpectedly accepted")
+ }
+ tampered = receipt
+ tampered.ReceivedAt = handoff.CreatedAt
+ if err := VerifyTransferReceipt(handoffBytes, handoff, tampered); err == nil {
+ t.Fatal("receipt at handoff creation boundary unexpectedly accepted")
+ }
+ tampered = receipt
+ tampered.ReceivedAt = "2026-07-23T15:00:01Z"
+ if err := VerifyTransferReceipt(handoffBytes, handoff, tampered); err == nil {
+ t.Fatal("expired receipt unexpectedly accepted")
+ }
+}
+
+func TestPublicWitnessLeadBoundaryAndActorIndependence(t *testing.T) {
+ definition := adversarialDefinition(t)
+ round := uint64(40_000_000)
+ roundTime, err := QuicknetRoundTime(round)
+ if err != nil {
+ t.Fatal(err)
+ }
+ close := operationalClose(t, definition, Phase1, round, roundTime.Add(-25*time.Hour))
+ closeBytes, err := MarshalCanonical(close)
+ if err != nil {
+ t.Fatal(err)
+ }
+ witness := adversarialIdentity(t, "public-witness-01", 0x91)
+ receipt := PublicWitnessReceipt{
+ Schema: PublicWitnessReceiptSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ CloseID: close.CloseID,
+ ChainHeadID: close.ChainHeadID,
+ Closure: ArtifactRef{Name: "phase1/closure.json", Digest: NewDigest(closeBytes)},
+ BeaconRound: round,
+ BeaconScheduledAt: roundTime.Format(time.RFC3339),
+ PublicationLocationSHA: taggedSHA256([]byte("https://independent.example/phase1/closure.json")),
+ Witness: witness,
+ ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339),
+ }
+ if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, receipt); err != nil {
+ t.Fatalf("exact signed minimum lead rejected: %v", err)
+ }
+ tooLate := receipt
+ tooLate.ObservedAt = roundTime.Add(-24*time.Hour + time.Second).Format(time.RFC3339)
+ if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, tooLate); err == nil {
+ t.Fatal("below-minimum witness lead unexpectedly accepted")
+ }
+ overlap := receipt
+ overlap.Witness = definition.Roster[0].Identity
+ if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, overlap); err == nil {
+ t.Fatal("participant self-witness unexpectedly accepted")
+ }
+}
+
+func TestPublicWitnessQuorumRejectsDuplicateIdentityAndKey(t *testing.T) {
+ definition := adversarialDefinition(t)
+ round := uint64(40_000_001)
+ roundTime, _ := QuicknetRoundTime(round)
+ close := operationalClose(t, definition, Phase1, round, roundTime.Add(-25*time.Hour))
+ closeBytes, _ := MarshalCanonical(close)
+ signed := func(id string, fill byte) SignedPublicWitness {
+ privateKey := adversarialPrivateKey(fill)
+ identity, err := NewIdentity(id, "Witness "+id, id+"-key", privateKey.Public().(ed25519.PublicKey))
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := PublicWitnessReceipt{
+ Schema: PublicWitnessReceiptSchema,
+ CeremonyID: definition.CeremonyID,
+ Phase: Phase1,
+ CloseID: close.CloseID,
+ ChainHeadID: close.ChainHeadID,
+ Closure: ArtifactRef{Name: "phase1/closure.json", Digest: NewDigest(closeBytes)},
+ BeaconRound: round,
+ BeaconScheduledAt: roundTime.Format(time.RFC3339),
+ PublicationLocationSHA: taggedSHA256([]byte(id)),
+ Witness: identity,
+ ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339),
+ }
+ recordBytes, signatureBytes, err := SignRecord(record, identity.KeyID, privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return SignedPublicWitness{RecordBytes: recordBytes, SignatureBytes: signatureBytes, TrustedKey: privateKey.Public().(ed25519.PublicKey)}
+ }
+ first := signed("witness-01", 0xa1)
+ second := signed("witness-02", 0xa2)
+ if err := VerifyPublicWitnessQuorum(definition, close, closeBytes, []SignedPublicWitness{first, second}, 2); err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyPublicWitnessQuorum(definition, close, closeBytes, []SignedPublicWitness{first, first}, 2); err == nil {
+ t.Fatal("duplicate witness unexpectedly satisfied quorum")
+ }
+ wrongTrust := second
+ wrongTrust.TrustedKey = first.TrustedKey
+ if err := VerifyPublicWitnessQuorum(definition, close, closeBytes, []SignedPublicWitness{first, wrongTrust}, 2); err == nil {
+ t.Fatal("witness identity/trust-key mismatch unexpectedly accepted")
+ }
+}
+
+func TestMultiRelayEvidenceRequiresThreeOperatorsEndpointsAndMatchingRandomness(t *testing.T) {
+ base := RelayObservation{
+ RelayID: "relay-01",
+ OperatorID: "drand",
+ EndpointSHA256: taggedSHA256([]byte("https://api.drand.sh")),
+ RawResponse: ArtifactRef{Name: "beacons/drand.json", Digest: NewDigest([]byte(quicknetRound42Response))},
+ RetrievedAt: "2026-07-23T12:00:00Z",
+ VerifiedRandomness: "8ada64bae5c6c0f5540a6a13af56e663240edfbd2c76ac6a8f27671eb7259ce3",
+ }
+ second := base
+ second.RelayID = "relay-02"
+ second.OperatorID = "cloudflare"
+ second.EndpointSHA256 = taggedSHA256([]byte("https://api.cloudflare.com"))
+ second.RawResponse.Name = "beacons/cloudflare.json"
+ third := base
+ third.RelayID = "relay-03"
+ third.OperatorID = "secureweb3"
+ third.EndpointSHA256 = taggedSHA256([]byte("https://api.secureweb3.com"))
+ third.RawResponse.Name = "beacons/secureweb3.json"
+ evidence := MultiRelayBeaconEvidence{
+ Schema: MultiRelayBeaconEvidenceSchema,
+ CeremonyID: "sha256:" + strings.Repeat("11", 32),
+ Phase: Phase1,
+ CloseID: "sha256:" + strings.Repeat("12", 32),
+ BeaconRound: 42,
+ Provider: BeaconProviderDrand,
+ Network: BeaconNetworkQuicknet,
+ Observations: []RelayObservation{base, second, third},
+ CoordinatorID: "coordinator",
+ CoordinatorKeyID: "coordinator-key",
+ RecordedAt: "2026-07-23T12:00:01Z",
+ }
+ if err := evidence.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ onlyTwo := evidence
+ onlyTwo.Observations = onlyTwo.Observations[:2]
+ if err := onlyTwo.Validate(); err == nil {
+ t.Fatal("two-relay evidence unexpectedly accepted")
+ }
+ duplicateOperator := evidence
+ duplicateOperator.Observations = append([]RelayObservation(nil), evidence.Observations...)
+ duplicateOperator.Observations[1].OperatorID = duplicateOperator.Observations[0].OperatorID
+ if err := duplicateOperator.Validate(); err == nil {
+ t.Fatal("same operator through different endpoint unexpectedly accepted")
+ }
+ mismatch := evidence
+ mismatch.Observations = append([]RelayObservation(nil), evidence.Observations...)
+ mismatch.Observations[2].VerifiedRandomness = strings.Repeat("00", 32)
+ if err := mismatch.Validate(); err == nil {
+ t.Fatal("relay randomness disagreement unexpectedly accepted")
+ }
+}
+
+func TestGovernanceRestartRequiresDistinctNewCeremonyAndEvidence(t *testing.T) {
+ record := GovernanceRecord{
+ Schema: GovernanceRecordSchema,
+ Kind: GovernanceRestart,
+ CeremonyID: "sha256:" + strings.Repeat("11", 32),
+ Phase: Phase1,
+ Index: 1,
+ HeadID: "sha256:" + strings.Repeat("22", 32),
+ Evidence: []ArtifactRef{{Name: "incidents/evidence.json", Digest: NewDigest([]byte("evidence"))}},
+ ReasonCode: "host-integrity-failure",
+ StatementSHA256: taggedSHA256([]byte("restart required")),
+ NewCeremonyID: "sha256:" + strings.Repeat("33", 32),
+ SignerID: "coordinator",
+ SignerKeyID: "coordinator-key",
+ RecordedAt: "2026-07-23T12:00:00Z",
+ }
+ if err := record.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ same := record
+ same.NewCeremonyID = same.CeremonyID
+ if err := same.Validate(); err == nil {
+ t.Fatal("restart retaining old ceremony ID unexpectedly accepted")
+ }
+ incident := record
+ incident.Kind = GovernanceIncident
+ if err := incident.Validate(); err == nil {
+ t.Fatal("non-restart record with new ceremony ID unexpectedly accepted")
+ }
+}
+
+func operationalClose(
+ t *testing.T,
+ definition CeremonyDefinition,
+ phase Phase,
+ round uint64,
+ closedAt time.Time,
+) CloseRecord {
+ t.Helper()
+ roundTime, err := QuicknetRoundTime(round)
+ if err != nil {
+ t.Fatal(err)
+ }
+ record, err := NewCloseRecord(CloseRecord{
+ CeremonyID: definition.CeremonyID,
+ Phase: phase,
+ PhaseID: "sha256:" + strings.Repeat("44", 32),
+ FinalIndex: 1,
+ FinalPayload: ArtifactRef{Name: string(phase) + "/final.bin", Digest: NewDigest([]byte("final"))},
+ ChainHeadID: "sha256:" + strings.Repeat("55", 32),
+ AcceptedParticipants: []string{definition.Roster[0].Identity.ID},
+ BeaconProvider: BeaconProviderDrand,
+ BeaconNetwork: BeaconNetworkQuicknet,
+ BeaconRound: round,
+ BeaconNotBefore: roundTime.Format(time.RFC3339),
+ ClosedAt: closedAt.Format(time.RFC3339),
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return record
+}
diff --git a/internal/mpcceremony/phase1.go b/internal/mpcceremony/phase1.go
new file mode 100644
index 00000000..fc84bcfe
--- /dev/null
+++ b/internal/mpcceremony/phase1.go
@@ -0,0 +1,336 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+const contributionChallengeSize = sha256.Size
+
+// Phase1Loader returns a freshly decoded contribution by zero-based ordinal.
+// File-oriented callers should implement it with the strict preflight reader.
+// Loader-based APIs keep fewer than 20 production-sized states from all being
+// retained in memory at once.
+type Phase1Loader func(index int) (*gnarkmpc.Phase1, error)
+
+// InitializePhase1 returns gnark's deterministic Powers-of-Tau genesis state
+// and the exact strict-reader shape for its canonical encoding.
+func InitializePhase1(domainN uint64) (*gnarkmpc.Phase1, Phase1Shape, error) {
+ if err := validateDomainN(domainN); err != nil {
+ return nil, Phase1Shape{}, fmt.Errorf("initialize Phase 1: %w", err)
+ }
+ initial := gnarkmpc.NewPhase1(domainN)
+ shape := Phase1Shape{DomainN: domainN, ChallengeLength: 0}
+ if err := shape.Validate(); err != nil {
+ return nil, Phase1Shape{}, fmt.Errorf("initial Phase 1 shape: %w", err)
+ }
+ return initial, shape, nil
+}
+
+// ReplayPhase1 verifies every Phase 1 transition from gnark's deterministic
+// genesis state. The supplied contribution objects are never mutated.
+func ReplayPhase1(domainN uint64, contributions []*gnarkmpc.Phase1) error {
+ _, err := replayPhase1State(domainN, len(contributions), phase1SliceLoader(contributions))
+ return err
+}
+
+// ReplayPhase1Loaded is the streaming-loader form of ReplayPhase1.
+func ReplayPhase1Loaded(domainN uint64, contributionCount int, load Phase1Loader) error {
+ _, err := replayPhase1State(domainN, contributionCount, load)
+ return err
+}
+
+// ContributePhase1 verifies the complete existing chain and returns one fresh
+// contribution derived from its head. Neither the chain nor its head is
+// mutated.
+//
+// gnark obtains contribution randomness from crypto/rand through
+// fr.Element.SetRandom. A failure in that path is reported as an error rather
+// than allowing a panic to escape the engine boundary.
+func ContributePhase1(domainN uint64, contributions []*gnarkmpc.Phase1) (*gnarkmpc.Phase1, error) {
+ return ContributePhase1Loaded(domainN, len(contributions), phase1SliceLoader(contributions))
+}
+
+// ContributePhase1Loaded is the streaming-loader form of ContributePhase1.
+func ContributePhase1Loaded(domainN uint64, contributionCount int, load Phase1Loader) (*gnarkmpc.Phase1, error) {
+ head, err := replayPhase1State(domainN, contributionCount, load)
+ if err != nil {
+ return nil, err
+ }
+
+ next := new(gnarkmpc.Phase1)
+ if err := streamClone(head, next); err != nil {
+ return nil, fmt.Errorf("clone Phase 1 head: %w", err)
+ }
+ if err := runGnarkMutation("Phase 1 contribution", next.Contribute); err != nil {
+ return nil, err
+ }
+ if err := requireContributionChallenge(next.Challenge, "new Phase 1 contribution"); err != nil {
+ return nil, err
+ }
+
+ // Verify the generated update before handing it to the caller. Verify may
+ // assign next.Challenge, but next is the newly allocated result, not an
+ // archived input.
+ if err := runGnarkVerification("verify new Phase 1 contribution", func() error {
+ return head.Verify(next)
+ }); err != nil {
+ return nil, fmt.Errorf("verify new Phase 1 contribution: %w", err)
+ }
+ return next, nil
+}
+
+// SealPhase1 verifies the complete Phase 1 chain and applies an exact 32-byte
+// public beacon challenge to a private clone of its head. At least one
+// contribution is required; beacon-only setup is intentionally excluded from
+// the production engine.
+func SealPhase1(domainN uint64, beaconChallenge []byte, contributions []*gnarkmpc.Phase1) (*gnarkmpc.SrsCommons, error) {
+ return SealPhase1Loaded(domainN, beaconChallenge, len(contributions), phase1SliceLoader(contributions))
+}
+
+// SealPhase1Loaded is the streaming-loader form of SealPhase1.
+func SealPhase1Loaded(
+ domainN uint64,
+ beaconChallenge []byte,
+ contributionCount int,
+ load Phase1Loader,
+) (*gnarkmpc.SrsCommons, error) {
+ if err := requireBeaconChallenge(beaconChallenge); err != nil {
+ return nil, err
+ }
+ if contributionCount == 0 {
+ return nil, errors.New("seal Phase 1: at least one contribution is required")
+ }
+ head, err := replayPhase1State(domainN, contributionCount, load)
+ if err != nil {
+ return nil, err
+ }
+
+ return sealReplayedPhase1Head(domainN, beaconChallenge, head)
+}
+
+// sealReplayedPhase1Head consumes a freshly replayed head. gnark's Seal
+// intentionally mutates that head, so callers must not retain or reuse it.
+func sealReplayedPhase1Head(
+ domainN uint64,
+ beaconChallenge []byte,
+ head *gnarkmpc.Phase1,
+) (*gnarkmpc.SrsCommons, error) {
+ if err := validateDomainN(domainN); err != nil {
+ return nil, fmt.Errorf("seal Phase 1: %w", err)
+ }
+ if err := requireBeaconChallenge(beaconChallenge); err != nil {
+ return nil, err
+ }
+ if head == nil {
+ return nil, errors.New("seal Phase 1: replayed head is required")
+ }
+ var commons gnarkmpc.SrsCommons
+ if err := runGnarkMutation("seal Phase 1", func() {
+ commons = head.Seal(bytes.Clone(beaconChallenge))
+ }); err != nil {
+ return nil, err
+ }
+ if err := validateCommonsDomain(&commons, domainN); err != nil {
+ return nil, fmt.Errorf("sealed Phase 1 commons: %w", err)
+ }
+ return &commons, nil
+}
+
+func replayPhase1State(domainN uint64, contributionCount int, load Phase1Loader) (*gnarkmpc.Phase1, error) {
+ if err := validateDomainN(domainN); err != nil {
+ return nil, fmt.Errorf("replay Phase 1: %w", err)
+ }
+ if contributionCount < 0 {
+ return nil, fmt.Errorf("replay Phase 1: contribution count %d is negative", contributionCount)
+ }
+ if contributionCount > 0 && load == nil {
+ return nil, errors.New("replay Phase 1: contribution loader is required")
+ }
+
+ previous, _, err := InitializePhase1(domainN)
+ if err != nil {
+ return nil, err
+ }
+ for i := 0; i < contributionCount; i++ {
+ archived, err := load(i)
+ if err != nil {
+ return nil, fmt.Errorf("load Phase 1 contribution %d: %w", i+1, err)
+ }
+ if archived == nil {
+ return nil, fmt.Errorf("replay Phase 1 contribution %d: nil contribution", i+1)
+ }
+ if err := requireContributionChallenge(archived.Challenge, fmt.Sprintf("Phase 1 contribution %d", i+1)); err != nil {
+ return nil, err
+ }
+
+ next := new(gnarkmpc.Phase1)
+ if err := streamClone(archived, next); err != nil {
+ return nil, fmt.Errorf("clone Phase 1 contribution %d: %w", i+1, err)
+ }
+ if err := verifyPhase1Transition(domainN, previous, next); err != nil {
+ return nil, fmt.Errorf("verify Phase 1 contribution %d: %w", i+1, err)
+ }
+ previous = next
+ }
+ return previous, nil
+}
+
+func verifyPhase1Transition(
+ domainN uint64,
+ previous *gnarkmpc.Phase1,
+ next *gnarkmpc.Phase1,
+) error {
+ if err := validateDomainN(domainN); err != nil {
+ return err
+ }
+ if previous == nil || next == nil {
+ return errors.New("Phase 1 transition requires previous and next states")
+ }
+ if err := requireContributionChallenge(next.Challenge, "Phase 1 transition"); err != nil {
+ return err
+ }
+ if err := runGnarkVerification(
+ "verify Phase 1 transition",
+ func() error { return previous.Verify(next) },
+ ); err != nil {
+ return err
+ }
+ return nil
+}
+
+func phase1SliceLoader(contributions []*gnarkmpc.Phase1) Phase1Loader {
+ return func(index int) (*gnarkmpc.Phase1, error) {
+ if index < 0 || index >= len(contributions) {
+ return nil, fmt.Errorf("contribution index %d outside [0,%d)", index, len(contributions))
+ }
+ return contributions[index], nil
+ }
+}
+
+func validateCommonsDomain(commons *gnarkmpc.SrsCommons, domainN uint64) error {
+ if commons == nil {
+ return errors.New("SRS commons are required")
+ }
+ if err := validateDomainN(domainN); err != nil {
+ return err
+ }
+ checks := []struct {
+ name string
+ got int
+ want uint64
+ }{
+ {name: "G1 Tau", got: len(commons.G1.Tau), want: 2*domainN - 1},
+ {name: "G1 AlphaTau", got: len(commons.G1.AlphaTau), want: domainN},
+ {name: "G1 BetaTau", got: len(commons.G1.BetaTau), want: domainN},
+ {name: "G2 Tau", got: len(commons.G2.Tau), want: domainN},
+ }
+ for _, check := range checks {
+ if uint64(check.got) != check.want {
+ return fmt.Errorf("%s length %d, want %d for domain %d", check.name, check.got, check.want, domainN)
+ }
+ }
+ return nil
+}
+
+func validateDomainN(domainN uint64) error {
+ return validateDomain(domainN)
+}
+
+func requireContributionChallenge(challenge []byte, label string) error {
+ if len(challenge) != contributionChallengeSize {
+ return fmt.Errorf("%s challenge is %d bytes, want %d", label, len(challenge), contributionChallengeSize)
+ }
+ return nil
+}
+
+func requireBeaconChallenge(challenge []byte) error {
+ if len(challenge) != contributionChallengeSize {
+ return fmt.Errorf("beacon challenge is %d bytes, want %d", len(challenge), contributionChallengeSize)
+ }
+ return nil
+}
+
+// streamClone makes a canonical serialization round trip without buffering a
+// production-sized transcript in memory. The destination must be a fresh,
+// zero-valued object of the corresponding type.
+func streamClone(src io.WriterTo, dst io.ReaderFrom) error {
+ if src == nil || dst == nil {
+ return errors.New("clone source and destination are required")
+ }
+
+ reader, writer := io.Pipe()
+ type writeResult struct {
+ n int64
+ err error
+ }
+ written := make(chan writeResult, 1)
+ go func() {
+ var result writeResult
+ defer func() {
+ _ = writer.CloseWithError(result.err)
+ written <- result
+ }()
+ result.n, result.err = writeToWithPanicBoundary("serialize transcript", src, writer)
+ }()
+
+ readN, readErr := readFromWithPanicBoundary("deserialize transcript", dst, reader)
+ if readErr != nil {
+ _ = reader.CloseWithError(readErr)
+ result := <-written
+ if result.err != nil {
+ return fmt.Errorf("read transcript: %w (writer: %v)", readErr, result.err)
+ }
+ return fmt.Errorf("read transcript: %w", readErr)
+ }
+ trailing, drainErr := io.Copy(io.Discard, reader)
+ closeErr := reader.Close()
+ result := <-written
+ if result.err != nil {
+ return fmt.Errorf("write transcript: %w", result.err)
+ }
+ if drainErr != nil {
+ return fmt.Errorf("check transcript EOF: %w", drainErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("close transcript reader: %w", closeErr)
+ }
+ if trailing != 0 {
+ return fmt.Errorf("transcript has %d trailing bytes", trailing)
+ }
+ if readN != result.n {
+ return fmt.Errorf("transcript byte count mismatch: read %d, wrote %d", readN, result.n)
+ }
+ return nil
+}
+
+func runGnarkMutation(label string, mutate func()) (err error) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ err = fmt.Errorf("%s panic: %v", label, recovered)
+ }
+ }()
+ mutate()
+ return nil
+}
+
+// runGnarkVerification keeps panics from the upstream verifier—including
+// crypto/rand or pairing failures—inside the error-returning engine boundary.
+// The caller must still supply a clone when the upstream verifier mutates its
+// argument while assigning the expected transcript challenge.
+func runGnarkVerification(label string, verify func() error) (err error) {
+ if verify == nil {
+ return fmt.Errorf("%s callback is required", label)
+ }
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ err = fmt.Errorf("%s panic: %v", label, recovered)
+ }
+ }()
+ return verify()
+}
diff --git a/internal/mpcceremony/phase2.go b/internal/mpcceremony/phase2.go
new file mode 100644
index 00000000..476b7c55
--- /dev/null
+++ b/internal/mpcceremony/phase2.go
@@ -0,0 +1,351 @@
+package mpcceremony
+
+import (
+ "errors"
+ "fmt"
+ "math"
+
+ groth16bls "github.com/consensys/gnark/backend/groth16/bls12-381"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+// Phase2Loader returns a freshly decoded contribution by zero-based ordinal.
+// File-oriented callers should implement it with the strict preflight reader.
+type Phase2Loader func(index int) (*gnarkmpc.Phase2, error)
+
+// InitializePhase2 deterministically derives the circuit-specific Phase 2
+// genesis state from the exact compiled circuit and sealed Phase 1 commons.
+func InitializePhase2(circuit *CompiledCircuit, commons *gnarkmpc.SrsCommons) (*gnarkmpc.Phase2, Phase2Shape, error) {
+ if err := validatePhase2Inputs(circuit, commons); err != nil {
+ return nil, Phase2Shape{}, err
+ }
+
+ initial := new(gnarkmpc.Phase2)
+ if err := runGnarkMutation("initialize Phase 2", func() {
+ _ = initial.Initialize(circuit.R1CS, commons)
+ }); err != nil {
+ return nil, Phase2Shape{}, err
+ }
+ shape, err := DerivePhase2Shape(initial)
+ if err != nil {
+ return nil, Phase2Shape{}, fmt.Errorf("derive initial Phase 2 shape: %w", err)
+ }
+ if shape.ChallengeLength != 0 {
+ return nil, Phase2Shape{}, fmt.Errorf("initial Phase 2 challenge is %d bytes, want 0", shape.ChallengeLength)
+ }
+ if !equalPhase2Shape(shape, circuit.Binding.Phase2Shape) {
+ return nil, Phase2Shape{}, fmt.Errorf(
+ "initialized Phase 2 shape %+v does not match circuit binding %+v",
+ shape,
+ circuit.Binding.Phase2Shape,
+ )
+ }
+ return initial, shape, nil
+}
+
+// DerivePhase2Shape returns the serialization/preflight shape of a Phase 2
+// object. It rejects inconsistent or non-representable slice lengths before
+// they can be used as trusted preflight limits.
+func DerivePhase2Shape(phase2 *gnarkmpc.Phase2) (Phase2Shape, error) {
+ if phase2 == nil {
+ return Phase2Shape{}, errors.New("Phase 2 object is required")
+ }
+
+ commitments := len(phase2.Parameters.G2.Sigma)
+ if commitments > int(MaxPhase2Commitments) {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 commitments %d exceed %d", commitments, MaxPhase2Commitments)
+ }
+ if len(phase2.Parameters.G1.SigmaCKK) != commitments {
+ return Phase2Shape{}, fmt.Errorf(
+ "Phase 2 SigmaCKK count %d does not match Sigma count %d",
+ len(phase2.Parameters.G1.SigmaCKK),
+ commitments,
+ )
+ }
+ if len(phase2.Sigmas) != commitments {
+ return Phase2Shape{}, fmt.Errorf(
+ "Phase 2 update-proof count %d does not match commitment count %d",
+ len(phase2.Sigmas),
+ commitments,
+ )
+ }
+ if len(phase2.Parameters.G1.PKK) > math.MaxUint32 {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 PKK length %d exceeds uint32", len(phase2.Parameters.G1.PKK))
+ }
+ if len(phase2.Parameters.G1.Z) > math.MaxUint32 {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 Z length %d exceeds uint32", len(phase2.Parameters.G1.Z))
+ }
+ if len(phase2.Challenge) > math.MaxUint8 {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 challenge length %d exceeds uint8", len(phase2.Challenge))
+ }
+
+ shape := Phase2Shape{
+ Commitments: uint16(commitments),
+ PKK: uint32(len(phase2.Parameters.G1.PKK)),
+ Z: uint32(len(phase2.Parameters.G1.Z)),
+ SigmaCKK: make([]uint32, commitments),
+ ChallengeLength: uint8(len(phase2.Challenge)),
+ }
+ for i := range phase2.Parameters.G1.SigmaCKK {
+ if len(phase2.Parameters.G1.SigmaCKK[i]) > math.MaxUint32 {
+ return Phase2Shape{}, fmt.Errorf(
+ "Phase 2 SigmaCKK[%d] length %d exceeds uint32",
+ i,
+ len(phase2.Parameters.G1.SigmaCKK[i]),
+ )
+ }
+ shape.SigmaCKK[i] = uint32(len(phase2.Parameters.G1.SigmaCKK[i]))
+ }
+ if err := shape.Validate(); err != nil {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 shape: %w", err)
+ }
+ return shape, nil
+}
+
+// ReplayPhase2 deterministically initializes Phase 2 and verifies every
+// contribution in order. Supplied contribution objects are never mutated.
+func ReplayPhase2(circuit *CompiledCircuit, commons *gnarkmpc.SrsCommons, contributions []*gnarkmpc.Phase2) error {
+ _, _, err := replayPhase2State(circuit, commons, len(contributions), phase2SliceLoader(contributions))
+ return err
+}
+
+// ReplayPhase2Loaded is the streaming-loader form of ReplayPhase2.
+func ReplayPhase2Loaded(
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ contributionCount int,
+ load Phase2Loader,
+) error {
+ _, _, err := replayPhase2State(circuit, commons, contributionCount, load)
+ return err
+}
+
+// ContributePhase2 verifies the complete Phase 2 chain and returns a fresh
+// contribution derived from its head. Neither the archived chain nor the
+// sealed Phase 1 commons is mutated.
+func ContributePhase2(circuit *CompiledCircuit, commons *gnarkmpc.SrsCommons, contributions []*gnarkmpc.Phase2) (*gnarkmpc.Phase2, error) {
+ return ContributePhase2Loaded(circuit, commons, len(contributions), phase2SliceLoader(contributions))
+}
+
+// ContributePhase2Loaded is the streaming-loader form of ContributePhase2.
+func ContributePhase2Loaded(
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ contributionCount int,
+ load Phase2Loader,
+) (*gnarkmpc.Phase2, error) {
+ head, _, err := replayPhase2State(circuit, commons, contributionCount, load)
+ if err != nil {
+ return nil, err
+ }
+
+ next := new(gnarkmpc.Phase2)
+ if err := streamClone(head, next); err != nil {
+ return nil, fmt.Errorf("clone Phase 2 head: %w", err)
+ }
+ if err := runGnarkMutation("Phase 2 contribution", next.Contribute); err != nil {
+ return nil, err
+ }
+ if err := requireContributionChallenge(next.Challenge, "new Phase 2 contribution"); err != nil {
+ return nil, err
+ }
+ if err := requireSamePhase2Structure(head, next); err != nil {
+ return nil, fmt.Errorf("new Phase 2 contribution shape: %w", err)
+ }
+ if err := runGnarkVerification("verify new Phase 2 contribution", func() error {
+ return head.Verify(next)
+ }); err != nil {
+ return nil, fmt.Errorf("verify new Phase 2 contribution: %w", err)
+ }
+ return next, nil
+}
+
+// SealPhase2 verifies the complete chain, applies an exact 32-byte public
+// beacon to a private clone of its head, and returns native gnark BLS12-381
+// proving and verifying keys.
+func SealPhase2(
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ beaconChallenge []byte,
+ contributions []*gnarkmpc.Phase2,
+) (*groth16bls.ProvingKey, *groth16bls.VerifyingKey, error) {
+ return SealPhase2Loaded(
+ circuit,
+ commons,
+ beaconChallenge,
+ len(contributions),
+ phase2SliceLoader(contributions),
+ )
+}
+
+// SealPhase2Loaded is the streaming-loader form of SealPhase2.
+func SealPhase2Loaded(
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ beaconChallenge []byte,
+ contributionCount int,
+ load Phase2Loader,
+) (*groth16bls.ProvingKey, *groth16bls.VerifyingKey, error) {
+ if err := requireBeaconChallenge(beaconChallenge); err != nil {
+ return nil, nil, err
+ }
+ if contributionCount == 0 {
+ return nil, nil, errors.New("seal Phase 2: at least one contribution is required")
+ }
+
+ head, evaluations, err := replayPhase2State(circuit, commons, contributionCount, load)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var provingKey, verifyingKey any
+ if err := runGnarkMutation("seal Phase 2", func() {
+ provingKey, verifyingKey = head.Seal(commons, evaluations, append([]byte(nil), beaconChallenge...))
+ }); err != nil {
+ return nil, nil, err
+ }
+ nativePK, ok := provingKey.(*groth16bls.ProvingKey)
+ if !ok {
+ return nil, nil, fmt.Errorf("seal Phase 2 proving key type is %T, want *bls12-381.ProvingKey", provingKey)
+ }
+ nativeVK, ok := verifyingKey.(*groth16bls.VerifyingKey)
+ if !ok {
+ return nil, nil, fmt.Errorf("seal Phase 2 verifying key type is %T, want *bls12-381.VerifyingKey", verifyingKey)
+ }
+ return nativePK, nativeVK, nil
+}
+
+func replayPhase2State(
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ contributionCount int,
+ load Phase2Loader,
+) (*gnarkmpc.Phase2, *gnarkmpc.Phase2Evaluations, error) {
+ if err := validatePhase2Inputs(circuit, commons); err != nil {
+ return nil, nil, err
+ }
+ if contributionCount < 0 {
+ return nil, nil, fmt.Errorf("replay Phase 2: contribution count %d is negative", contributionCount)
+ }
+ if contributionCount > 0 && load == nil {
+ return nil, nil, errors.New("replay Phase 2: contribution loader is required")
+ }
+
+ previous := new(gnarkmpc.Phase2)
+ var evaluations gnarkmpc.Phase2Evaluations
+ if err := runGnarkMutation("initialize Phase 2", func() {
+ evaluations = previous.Initialize(circuit.R1CS, commons)
+ }); err != nil {
+ return nil, nil, err
+ }
+ initialShape, err := DerivePhase2Shape(previous)
+ if err != nil {
+ return nil, nil, fmt.Errorf("derive initial Phase 2 shape: %w", err)
+ }
+ if !equalPhase2Shape(initialShape, circuit.Binding.Phase2Shape) {
+ return nil, nil, errors.New("initialized Phase 2 shape does not match circuit binding")
+ }
+
+ for i := 0; i < contributionCount; i++ {
+ archived, err := load(i)
+ if err != nil {
+ return nil, nil, fmt.Errorf("load Phase 2 contribution %d: %w", i+1, err)
+ }
+ if archived == nil {
+ return nil, nil, fmt.Errorf("replay Phase 2 contribution %d: nil contribution", i+1)
+ }
+ if err := requireContributionChallenge(archived.Challenge, fmt.Sprintf("Phase 2 contribution %d", i+1)); err != nil {
+ return nil, nil, err
+ }
+
+ next := new(gnarkmpc.Phase2)
+ if err := streamClone(archived, next); err != nil {
+ return nil, nil, fmt.Errorf("clone Phase 2 contribution %d: %w", i+1, err)
+ }
+ if err := requirePhase2Structure(initialShape, next); err != nil {
+ return nil, nil, fmt.Errorf("Phase 2 contribution %d shape: %w", i+1, err)
+ }
+ if err := verifyPhase2Transition(previous, next); err != nil {
+ return nil, nil, fmt.Errorf("verify Phase 2 contribution %d: %w", i+1, err)
+ }
+ previous = next
+ }
+ return previous, &evaluations, nil
+}
+
+func verifyPhase2Transition(previous, next *gnarkmpc.Phase2) error {
+ if previous == nil || next == nil {
+ return errors.New("Phase 2 transition requires previous and next states")
+ }
+ if err := requireContributionChallenge(next.Challenge, "Phase 2 transition"); err != nil {
+ return err
+ }
+ if err := requireSamePhase2Structure(previous, next); err != nil {
+ return err
+ }
+ return runGnarkVerification(
+ "verify Phase 2 transition",
+ func() error { return previous.Verify(next) },
+ )
+}
+
+func phase2SliceLoader(contributions []*gnarkmpc.Phase2) Phase2Loader {
+ return func(index int) (*gnarkmpc.Phase2, error) {
+ if index < 0 || index >= len(contributions) {
+ return nil, fmt.Errorf("contribution index %d outside [0,%d)", index, len(contributions))
+ }
+ return contributions[index], nil
+ }
+}
+
+func validatePhase2Inputs(circuit *CompiledCircuit, commons *gnarkmpc.SrsCommons) error {
+ if err := validateCompiledCircuit(circuit); err != nil {
+ return err
+ }
+ if commons == nil {
+ return errors.New("sealed Phase 1 commons are required")
+ }
+ if err := validateDomainN(circuit.Binding.DomainSize); err != nil {
+ return fmt.Errorf("compiled circuit domain: %w", err)
+ }
+ if err := validateCommonsDomain(commons, circuit.Binding.DomainSize); err != nil {
+ return fmt.Errorf("sealed Phase 1 commons: %w", err)
+ }
+ return nil
+}
+
+func requireSamePhase2Structure(previous, next *gnarkmpc.Phase2) error {
+ expected, err := DerivePhase2Shape(previous)
+ if err != nil {
+ return err
+ }
+ return requirePhase2Structure(expected, next)
+}
+
+func requirePhase2Structure(expected Phase2Shape, actual *gnarkmpc.Phase2) error {
+ got, err := DerivePhase2Shape(actual)
+ if err != nil {
+ return err
+ }
+ // Contributions have a 32-byte challenge while genesis has none. The
+ // remaining fields must be exactly invariant across the phase.
+ gotChallengeLength := got.ChallengeLength
+ got.ChallengeLength = expected.ChallengeLength
+ if got.Commitments != expected.Commitments ||
+ got.PKK != expected.PKK ||
+ got.Z != expected.Z ||
+ len(got.SigmaCKK) != len(expected.SigmaCKK) {
+ return fmt.Errorf("got %+v, want structure %+v (challenge length %d)", got, expected, gotChallengeLength)
+ }
+ for i := range got.SigmaCKK {
+ if got.SigmaCKK[i] != expected.SigmaCKK[i] {
+ return fmt.Errorf(
+ "SigmaCKK[%d] length %d, want %d",
+ i,
+ got.SigmaCKK[i],
+ expected.SigmaCKK[i],
+ )
+ }
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/plutus_evidence_script_test.go b/internal/mpcceremony/plutus_evidence_script_test.go
new file mode 100644
index 00000000..67625a5b
--- /dev/null
+++ b/internal/mpcceremony/plutus_evidence_script_test.go
@@ -0,0 +1,145 @@
+package mpcceremony
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "proof-tool/internal/prover"
+)
+
+func TestPublicFinalizationEvidenceScriptWithDynamicPlutusVerifier(t *testing.T) {
+ verifier := os.Getenv("MPC_TEST_PLUTUS_VERIFIER_BIN")
+ if verifier == "" {
+ t.Skip("set MPC_TEST_PLUTUS_VERIFIER_BIN to exercise the dynamic Plutus executable")
+ }
+
+ root := filepath.Clean(filepath.Join("..", ".."))
+ testdata := filepath.Join(root, "contracts", "ownership-verifier", "testdata")
+ readHex := func(name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(testdata, name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := hex.DecodeString(strings.TrimSpace(string(data)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return decoded
+ }
+ cardanoVK := readHex("ownership-destination-vk.hex")
+ cardanoProof := readHex("ownership-destination-proof.hex")
+ publicInputDigest := readHex("ownership-destination-pub.hex")
+ credential, err := hex.DecodeString("19e07fbcc7577359d6c51f1e49cf1b0bf4c943b48ba4e4905a8702e4")
+ if err != nil {
+ t.Fatal(err)
+ }
+ destination, err := hex.DecodeString(
+ "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111" +
+ "0000000000000000000000000000000000000000000000000000000000",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cardanoVK) != prover.CardanoVKCommitmentLen ||
+ len(cardanoProof) != prover.CardanoProofCommitmentLen {
+ t.Fatal("repository Cardano fixture has unexpected length")
+ }
+
+ ceremonyID := NewDigest([]byte("dynamic Plutus verifier integration")).SHA256
+ cardanoVKRef := ArtifactRef{Name: CardanoVKBytesFile, Digest: NewDigest(cardanoVK)}
+ evidence := PublicFinalizationEvidence{
+ Schema: PublicEvidenceSchema,
+ CeremonyID: ceremonyID,
+ Fixture: PublicEvidenceFixture,
+ CredentialHex: hex.EncodeToString(credential),
+ DestinationHex: hex.EncodeToString(destination),
+ PublicInputDigestHex: hex.EncodeToString(publicInputDigest),
+ CardanoProofHex: hex.EncodeToString(cardanoProof),
+ CardanoProofFormat: expectedCardanoBSB22,
+ CardanoProofRawDigest: NewDigest(cardanoProof),
+ CardanoVerifyingKey: cardanoVKRef,
+ }
+ evidenceBytes, err := MarshalCanonical(evidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ evidenceRef := ArtifactRef{Name: PublicEvidenceFile, Digest: NewDigest(evidenceBytes)}
+ report := VerificationReport{
+ Schema: VerificationReportSchema,
+ CeremonyID: ceremonyID,
+ Fixture: PublicEvidenceFixture,
+ NativeProofVerified: true,
+ WrongCredentialRejected: true,
+ WrongDestinationRejected: true,
+ WrongDigestRejected: true,
+ WrongProofRejected: true,
+ WrongVKRejected: true,
+ ProofTruncationRejected: true,
+ ProofAppendRejected: true,
+ CardanoProofFormat: expectedCardanoBSB22,
+ CardanoProofBytes: len(cardanoProof),
+ CardanoProofRawDigest: NewDigest(cardanoProof),
+ CardanoVKFormat: expectedCardanoBSB22,
+ CardanoVKBytes: len(cardanoVK),
+ CardanoVKRawDigest: NewDigest(cardanoVK),
+ PublicEvidence: evidenceRef,
+ CheckedAt: time.Date(2026, time.July, 23, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano),
+ }
+ reportBytes, err := MarshalCanonical(report)
+ if err != nil {
+ t.Fatal(err)
+ }
+ candidateBytes, err := json.Marshal(struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"`
+ VerificationReport ArtifactRef `json:"verification_report"`
+ PublicEvidence ArtifactRef `json:"public_finalization_evidence"`
+ }{
+ Schema: CandidateMetadataSchema,
+ CeremonyID: ceremonyID,
+ CardanoVerifyingKey: cardanoVKRef,
+ VerificationReport: ArtifactRef{Name: VerificationReportFile, Digest: NewDigest(reportBytes)},
+ PublicEvidence: evidenceRef,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dir := t.TempDir()
+ write := func(name string, data []byte) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(CandidateMetadataFile, candidateBytes)
+ write(VerificationReportFile, reportBytes)
+ write(PublicEvidenceFile, evidenceBytes)
+ write(CardanoVKBytesFile, cardanoVK)
+ write(CardanoVKHexFile, []byte(hex.EncodeToString(cardanoVK)+"\n"))
+
+ script := filepath.Join(root, "scripts", "verify-mpc-final-plutus-evidence.sh")
+ command := exec.Command(script, dir, verifier)
+ output, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("public Plutus evidence script failed: %v\n%s", err, output)
+ }
+ var result struct {
+ PositiveVerified bool `json:"positive_verified"`
+ RejectedNegatives []string `json:"rejected_negatives"`
+ }
+ if err := json.Unmarshal(output, &result); err != nil {
+ t.Fatalf("parse script output: %v\n%s", err, output)
+ }
+ if !result.PositiveVerified || len(result.RejectedNegatives) != 9 {
+ t.Fatalf("incomplete dynamic Plutus evidence: %+v", result)
+ }
+}
diff --git a/internal/mpcceremony/preflight.go b/internal/mpcceremony/preflight.go
new file mode 100644
index 00000000..be23f252
--- /dev/null
+++ b/internal/mpcceremony/preflight.go
@@ -0,0 +1,517 @@
+package mpcceremony
+
+import (
+ "bufio"
+ "crypto/sha256"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "hash"
+ "io"
+ "math"
+ "math/bits"
+
+ "golang.org/x/crypto/blake2b"
+)
+
+const (
+ // BLS12-381's scalar field has 2-adicity 32. Ceremonies in this package
+ // must use a power-of-two FFT domain no larger than that.
+ MaxDomainN uint64 = 1 << 32
+
+ // gnark assigns a one-byte domain-separation tag to every Phase 2
+ // commitment. More than 255 commitments aliases those tags.
+ MaxPhase2Commitments uint16 = 255
+
+ // MaxArtifactSize is an intentional fail-closed operational limit. Raising
+ // it requires reviewing memory, disk and ceremony host requirements.
+ MaxArtifactSize int64 = 16 << 30
+
+ g1CompressedSize = uint64(48)
+ g2CompressedSize = uint64(96)
+ preflightBuffer = 1 << 20
+)
+
+var (
+ ErrInvalidShape = errors.New("invalid MPC artifact shape")
+ ErrArtifactTooLarge = errors.New("MPC artifact exceeds size limit")
+ ErrNonCanonicalPoint = errors.New("non-canonical compressed BLS12-381 point")
+ ErrTrailingData = errors.New("trailing data after MPC artifact")
+)
+
+// Phase1Shape is the public, allocation-safe shape needed to inspect a Phase 1
+// transcript before handing it to gnark.
+type Phase1Shape struct {
+ DomainN uint64
+ ChallengeLength uint8
+}
+
+// CommonsShape is the public, allocation-safe shape of the sealed Phase 1
+// common reference string.
+type CommonsShape struct {
+ DomainN uint64
+}
+
+// Phase2Shape is derived from a locally compiled, frozen R1CS. Never derive it
+// from an untrusted Phase 2 artifact.
+type Phase2Shape struct {
+ Commitments uint16
+ PKK uint32
+ Z uint32
+ SigmaCKK []uint32
+ ChallengeLength uint8
+}
+
+// ArtifactDigest describes exactly the bytes inspected by a preflight or
+// strict reader. Challenge is copied and may safely be retained by the caller.
+type ArtifactDigest struct {
+ Size int64
+ SHA256 [sha256.Size]byte
+ BLAKE2b256 [blake2b.Size256]byte
+ Challenge []byte
+}
+
+func (s Phase1Shape) Validate() error {
+ if err := validateDomain(s.DomainN); err != nil {
+ return err
+ }
+ _, err := ExpectedPhase1Size(s)
+ return err
+}
+
+func (s CommonsShape) Validate() error {
+ if err := validateDomain(s.DomainN); err != nil {
+ return err
+ }
+ _, err := ExpectedCommonsSize(s)
+ return err
+}
+
+func (s Phase2Shape) Validate() error {
+ if s.Commitments > MaxPhase2Commitments {
+ return fmt.Errorf("%w: Phase 2 commitments %d exceed %d", ErrInvalidShape, s.Commitments, MaxPhase2Commitments)
+ }
+ if len(s.SigmaCKK) != int(s.Commitments) {
+ return fmt.Errorf("%w: %d SigmaCKK lengths for %d commitments", ErrInvalidShape, len(s.SigmaCKK), s.Commitments)
+ }
+ _, err := ExpectedPhase2Size(s)
+ return err
+}
+
+func validateDomain(n uint64) error {
+ if n < 2 || n > MaxDomainN || n&(n-1) != 0 {
+ return fmt.Errorf("%w: domain %d must be a power of two in [2,%d]", ErrInvalidShape, n, MaxDomainN)
+ }
+ return nil
+}
+
+// ExpectedCommonsSize returns the exact compressed gnark encoding size.
+func ExpectedCommonsSize(s CommonsShape) (int64, error) {
+ if err := validateDomain(s.DomainN); err != nil {
+ return 0, err
+ }
+
+ // uint64 N, G2 Beta, (2N-2) G1 Tau, (N-1) G2 Tau,
+ // N G1 BetaTau, N G1 AlphaTau = 288N - 88.
+ nBytes, err := checkedMul(s.DomainN, 288)
+ if err != nil {
+ return 0, err
+ }
+ nBytes, err = checkedSub(nBytes, 88)
+ if err != nil {
+ return 0, err
+ }
+ return checkedArtifactSize(nBytes)
+}
+
+// ExpectedPhase1Size returns the exact compressed gnark encoding size.
+func ExpectedPhase1Size(s Phase1Shape) (int64, error) {
+ commons, err := ExpectedCommonsSize(CommonsShape{DomainN: s.DomainN})
+ if err != nil {
+ return 0, err
+ }
+
+ // Three UpdateProofs (G1+G2), commons, one-byte challenge length.
+ nBytes, err := checkedAdd(uint64(commons), 3*(g1CompressedSize+g2CompressedSize))
+ if err != nil {
+ return 0, err
+ }
+ nBytes, err = checkedAdd(nBytes, 1+uint64(s.ChallengeLength))
+ if err != nil {
+ return 0, err
+ }
+ return checkedArtifactSize(nBytes)
+}
+
+// ExpectedPhase2Size returns the exact compressed gnark encoding size.
+func ExpectedPhase2Size(s Phase2Shape) (int64, error) {
+ if s.Commitments > MaxPhase2Commitments {
+ return 0, fmt.Errorf("%w: Phase 2 commitments %d exceed %d", ErrInvalidShape, s.Commitments, MaxPhase2Commitments)
+ }
+ if len(s.SigmaCKK) != int(s.Commitments) {
+ return 0, fmt.Errorf("%w: %d SigmaCKK lengths for %d commitments", ErrInvalidShape, len(s.SigmaCKK), s.Commitments)
+ }
+
+ totalPoints := uint64(s.PKK) + uint64(s.Z)
+ for _, n := range s.SigmaCKK {
+ var err error
+ totalPoints, err = checkedAdd(totalPoints, uint64(n))
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ // Fixed bytes are:
+ // commitments u16, Delta G1, PKK/Z length prefixes, Delta G2,
+ // Delta UpdateProof, challenge prefix = 299.
+ // Per commitment:
+ // SigmaCKK length prefix, Sigma G2, Sigma UpdateProof = 244.
+ nBytes, err := checkedMul(totalPoints, g1CompressedSize)
+ if err != nil {
+ return 0, err
+ }
+ nBytes, err = checkedAdd(nBytes, 299+uint64(s.ChallengeLength))
+ if err != nil {
+ return 0, err
+ }
+ perCommitment, err := checkedMul(uint64(s.Commitments), 244)
+ if err != nil {
+ return 0, err
+ }
+ nBytes, err = checkedAdd(nBytes, perCommitment)
+ if err != nil {
+ return 0, err
+ }
+ return checkedArtifactSize(nBytes)
+}
+
+func checkedArtifactSize(n uint64) (int64, error) {
+ if n > uint64(math.MaxInt64) {
+ return 0, fmt.Errorf("%w: byte length overflows int64", ErrInvalidShape)
+ }
+ if n > uint64(MaxArtifactSize) {
+ return 0, fmt.Errorf("%w: %d > %d bytes", ErrArtifactTooLarge, n, MaxArtifactSize)
+ }
+ return int64(n), nil
+}
+
+func checkedAdd(a, b uint64) (uint64, error) {
+ sum, carry := bits.Add64(a, b, 0)
+ if carry != 0 {
+ return 0, fmt.Errorf("%w: integer addition overflow", ErrInvalidShape)
+ }
+ return sum, nil
+}
+
+func checkedMul(a, b uint64) (uint64, error) {
+ hi, lo := bits.Mul64(a, b)
+ if hi != 0 {
+ return 0, fmt.Errorf("%w: integer multiplication overflow", ErrInvalidShape)
+ }
+ return lo, nil
+}
+
+func checkedSub(a, b uint64) (uint64, error) {
+ if b > a {
+ return 0, fmt.Errorf("%w: integer subtraction underflow", ErrInvalidShape)
+ }
+ return a - b, nil
+}
+
+// PreflightPhase1 scans the complete canonical encoding without allocating any
+// attacker-declared vector. The supplied shape must come from trusted local
+// ceremony state.
+func PreflightPhase1(r io.Reader, s Phase1Shape) (ArtifactDigest, error) {
+ expected, err := ExpectedPhase1Size(s)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ sc, err := newPreflightScanner(r, expected)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+
+ for range 3 {
+ if err := sc.pointG1(); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 1 update proof G1: %w", err)
+ }
+ if err := sc.pointG2(); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 1 update proof G2: %w", err)
+ }
+ }
+
+ n, err := sc.uint64()
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 1 domain: %w", err)
+ }
+ if n != s.DomainN {
+ return ArtifactDigest{}, fmt.Errorf("%w: Phase 1 domain %d, expected %d", ErrInvalidShape, n, s.DomainN)
+ }
+ if err := scanCommonsBody(sc, n); err != nil {
+ return ArtifactDigest{}, err
+ }
+ challenge, err := sc.challenge(s.ChallengeLength)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ return sc.finish(challenge)
+}
+
+// PreflightCommons scans a complete canonical SrsCommons encoding.
+func PreflightCommons(r io.Reader, s CommonsShape) (ArtifactDigest, error) {
+ expected, err := ExpectedCommonsSize(s)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ sc, err := newPreflightScanner(r, expected)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+
+ n, err := sc.uint64()
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("SRS commons domain: %w", err)
+ }
+ if n != s.DomainN {
+ return ArtifactDigest{}, fmt.Errorf("%w: SRS commons domain %d, expected %d", ErrInvalidShape, n, s.DomainN)
+ }
+ if err := scanCommonsBody(sc, n); err != nil {
+ return ArtifactDigest{}, err
+ }
+ return sc.finish(nil)
+}
+
+func scanCommonsBody(sc *preflightScanner, n uint64) error {
+ if err := sc.pointG2(); err != nil {
+ return fmt.Errorf("SRS commons G2 Beta: %w", err)
+ }
+ if err := sc.pointsG1(2*n - 2); err != nil {
+ return fmt.Errorf("SRS commons G1 Tau: %w", err)
+ }
+ if err := sc.pointsG2(n - 1); err != nil {
+ return fmt.Errorf("SRS commons G2 Tau: %w", err)
+ }
+ if err := sc.pointsG1(n); err != nil {
+ return fmt.Errorf("SRS commons G1 BetaTau: %w", err)
+ }
+ if err := sc.pointsG1(n); err != nil {
+ return fmt.Errorf("SRS commons G1 AlphaTau: %w", err)
+ }
+ return nil
+}
+
+// PreflightPhase2 scans the complete canonical encoding and checks every
+// attacker-controlled vector length before native gnark decoding.
+func PreflightPhase2(r io.Reader, s Phase2Shape) (ArtifactDigest, error) {
+ expected, err := ExpectedPhase2Size(s)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ sc, err := newPreflightScanner(r, expected)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+
+ commitments, err := sc.uint16()
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 2 commitments: %w", err)
+ }
+ if commitments != s.Commitments {
+ return ArtifactDigest{}, fmt.Errorf("%w: Phase 2 commitments %d, expected %d", ErrInvalidShape, commitments, s.Commitments)
+ }
+ if err := sc.pointG1(); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 2 G1 Delta: %w", err)
+ }
+ if err := sc.vectorG1("Phase 2 PKK", s.PKK); err != nil {
+ return ArtifactDigest{}, err
+ }
+ if err := sc.vectorG1("Phase 2 Z", s.Z); err != nil {
+ return ArtifactDigest{}, err
+ }
+ if err := sc.pointG2(); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 2 G2 Delta: %w", err)
+ }
+ for i, want := range s.SigmaCKK {
+ if err := sc.vectorG1(fmt.Sprintf("Phase 2 SigmaCKK[%d]", i), want); err != nil {
+ return ArtifactDigest{}, err
+ }
+ }
+ if err := sc.pointsG2(uint64(s.Commitments)); err != nil {
+ return ArtifactDigest{}, fmt.Errorf("Phase 2 G2 Sigma: %w", err)
+ }
+ if err := sc.updateProof("Phase 2 Delta proof"); err != nil {
+ return ArtifactDigest{}, err
+ }
+ for i := uint16(0); i < s.Commitments; i++ {
+ if err := sc.updateProof(fmt.Sprintf("Phase 2 Sigma proof[%d]", i)); err != nil {
+ return ArtifactDigest{}, err
+ }
+ }
+ challenge, err := sc.challenge(s.ChallengeLength)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ return sc.finish(challenge)
+}
+
+type preflightScanner struct {
+ r *bufio.Reader
+ expected int64
+ n int64
+ sha hash.Hash
+ blake hash.Hash
+}
+
+func newPreflightScanner(r io.Reader, expected int64) (*preflightScanner, error) {
+ if r == nil {
+ return nil, errors.New("nil MPC artifact reader")
+ }
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return nil, fmt.Errorf("initialize BLAKE2b-256: %w", err)
+ }
+ tee := io.TeeReader(r, io.MultiWriter(sha, blake))
+ return &preflightScanner{
+ r: bufio.NewReaderSize(tee, preflightBuffer),
+ expected: expected,
+ sha: sha,
+ blake: blake,
+ }, nil
+}
+
+func (s *preflightScanner) readFull(p []byte) error {
+ n, err := io.ReadFull(s.r, p)
+ s.n += int64(n)
+ if err != nil {
+ return err
+ }
+ if s.n > s.expected {
+ return fmt.Errorf("%w: artifact exceeds expected %d bytes", ErrTrailingData, s.expected)
+ }
+ return nil
+}
+
+func (s *preflightScanner) uint16() (uint16, error) {
+ var b [2]byte
+ if err := s.readFull(b[:]); err != nil {
+ return 0, err
+ }
+ return binary.BigEndian.Uint16(b[:]), nil
+}
+
+func (s *preflightScanner) uint32() (uint32, error) {
+ var b [4]byte
+ if err := s.readFull(b[:]); err != nil {
+ return 0, err
+ }
+ return binary.BigEndian.Uint32(b[:]), nil
+}
+
+func (s *preflightScanner) uint64() (uint64, error) {
+ var b [8]byte
+ if err := s.readFull(b[:]); err != nil {
+ return 0, err
+ }
+ return binary.BigEndian.Uint64(b[:]), nil
+}
+
+func (s *preflightScanner) pointG1() error {
+ return s.point(g1CompressedSize)
+}
+
+func (s *preflightScanner) pointG2() error {
+ return s.point(g2CompressedSize)
+}
+
+func (s *preflightScanner) point(size uint64) error {
+ var encoded [g2CompressedSize]byte
+ if err := s.readFull(encoded[:size]); err != nil {
+ return err
+ }
+ switch encoded[0] & 0xe0 {
+ case 0x80, 0xa0, 0xc0:
+ return nil
+ default:
+ return ErrNonCanonicalPoint
+ }
+}
+
+func (s *preflightScanner) pointsG1(n uint64) error {
+ for i := uint64(0); i < n; i++ {
+ if err := s.pointG1(); err != nil {
+ return fmt.Errorf("point %d: %w", i, err)
+ }
+ }
+ return nil
+}
+
+func (s *preflightScanner) pointsG2(n uint64) error {
+ for i := uint64(0); i < n; i++ {
+ if err := s.pointG2(); err != nil {
+ return fmt.Errorf("point %d: %w", i, err)
+ }
+ }
+ return nil
+}
+
+func (s *preflightScanner) vectorG1(name string, expected uint32) error {
+ n, err := s.uint32()
+ if err != nil {
+ return fmt.Errorf("%s length: %w", name, err)
+ }
+ if n != expected {
+ return fmt.Errorf("%w: %s length %d, expected %d", ErrInvalidShape, name, n, expected)
+ }
+ if err := s.pointsG1(uint64(n)); err != nil {
+ return fmt.Errorf("%s: %w", name, err)
+ }
+ return nil
+}
+
+func (s *preflightScanner) updateProof(name string) error {
+ if err := s.pointG1(); err != nil {
+ return fmt.Errorf("%s G1: %w", name, err)
+ }
+ if err := s.pointG2(); err != nil {
+ return fmt.Errorf("%s G2: %w", name, err)
+ }
+ return nil
+}
+
+func (s *preflightScanner) challenge(expected uint8) ([]byte, error) {
+ var length [1]byte
+ if err := s.readFull(length[:]); err != nil {
+ return nil, fmt.Errorf("challenge length: %w", err)
+ }
+ if length[0] != expected {
+ return nil, fmt.Errorf("%w: challenge length %d, expected %d", ErrInvalidShape, length[0], expected)
+ }
+ challenge := make([]byte, int(expected))
+ if err := s.readFull(challenge); err != nil {
+ return nil, fmt.Errorf("challenge: %w", err)
+ }
+ return challenge, nil
+}
+
+func (s *preflightScanner) finish(challenge []byte) (ArtifactDigest, error) {
+ if s.n != s.expected {
+ return ArtifactDigest{}, fmt.Errorf("%w: read %d bytes, expected %d", ErrInvalidShape, s.n, s.expected)
+ }
+ var extra [1]byte
+ n, err := io.ReadFull(s.r, extra[:])
+ s.n += int64(n)
+ if err == nil || n != 0 {
+ return ArtifactDigest{}, ErrTrailingData
+ }
+ if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+ return ArtifactDigest{}, fmt.Errorf("check artifact EOF: %w", err)
+ }
+
+ var digest ArtifactDigest
+ digest.Size = s.expected
+ copy(digest.SHA256[:], s.sha.Sum(nil))
+ copy(digest.BLAKE2b256[:], s.blake.Sum(nil))
+ digest.Challenge = append([]byte(nil), challenge...)
+ return digest, nil
+}
diff --git a/internal/mpcceremony/preflight_test.go b/internal/mpcceremony/preflight_test.go
new file mode 100644
index 00000000..763ec8f4
--- /dev/null
+++ b/internal/mpcceremony/preflight_test.go
@@ -0,0 +1,257 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "math"
+ "testing"
+
+ curve "github.com/consensys/gnark-crypto/ecc/bls12-381"
+ cryptompc "github.com/consensys/gnark-crypto/ecc/bls12-381/mpcsetup"
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+func TestExpectedArtifactSizes(t *testing.T) {
+ t.Parallel()
+
+ phase1Initial := Phase1Shape{DomainN: 2}
+ if got, err := ExpectedPhase1Size(phase1Initial); err != nil || got != 921 {
+ t.Fatalf("initial Phase 1 size = %d, %v; want 921", got, err)
+ }
+ phase1Contribution := Phase1Shape{DomainN: 2, ChallengeLength: 32}
+ if got, err := ExpectedPhase1Size(phase1Contribution); err != nil || got != 953 {
+ t.Fatalf("contributed Phase 1 size = %d, %v; want 953", got, err)
+ }
+ if got, err := ExpectedCommonsSize(CommonsShape{DomainN: 2}); err != nil || got != 488 {
+ t.Fatalf("commons size = %d, %v; want 488", got, err)
+ }
+
+ phase2 := Phase2Shape{
+ Commitments: 1,
+ PKK: 2,
+ Z: 1,
+ SigmaCKK: []uint32{1},
+ ChallengeLength: 32,
+ }
+ if got, err := ExpectedPhase2Size(phase2); err != nil || got != 767 {
+ t.Fatalf("Phase 2 size = %d, %v; want 767", got, err)
+ }
+}
+
+func TestShapeValidationRejectsUnsafeBounds(t *testing.T) {
+ t.Parallel()
+
+ for _, n := range []uint64{0, 1, 3, MaxDomainN + 1} {
+ if err := (Phase1Shape{DomainN: n}).Validate(); !errors.Is(err, ErrInvalidShape) {
+ t.Errorf("domain %d error = %v; want ErrInvalidShape", n, err)
+ }
+ }
+ if err := (Phase2Shape{
+ Commitments: 256,
+ SigmaCKK: make([]uint32, 256),
+ }).Validate(); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("256 commitments error = %v; want ErrInvalidShape", err)
+ }
+ if err := (Phase2Shape{Commitments: 1}).Validate(); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("missing SigmaCKK error = %v; want ErrInvalidShape", err)
+ }
+ if _, err := ExpectedPhase2Size(Phase2Shape{
+ Commitments: 1,
+ PKK: math.MaxUint32,
+ SigmaCKK: []uint32{math.MaxUint32},
+ }); !errors.Is(err, ErrArtifactTooLarge) {
+ t.Fatalf("oversize Phase 2 error = %v; want ErrArtifactTooLarge", err)
+ }
+}
+
+func TestPreflightPhase1CanonicalAndHostileDomain(t *testing.T) {
+ t.Parallel()
+
+ initial := gnarkmpc.NewPhase1(2)
+ encoded := writeNative(t, initial)
+ shape := Phase1Shape{DomainN: 2}
+
+ digest, err := PreflightPhase1(bytes.NewReader(encoded), shape)
+ if err != nil {
+ t.Fatalf("preflight canonical Phase 1: %v", err)
+ }
+ if digest.Size != int64(len(encoded)) || len(digest.Challenge) != 0 {
+ t.Fatalf("unexpected digest: %+v", digest)
+ }
+
+ for name, domain := range map[string]uint64{
+ "zero": 0,
+ "one": 1,
+ "non-power": 3,
+ "huge": math.MaxUint64,
+ "wrong-valid": 4,
+ } {
+ t.Run(name, func(t *testing.T) {
+ mutated := bytes.Clone(encoded)
+ // Three UpdateProofs, each one compressed G1 and G2.
+ binary.BigEndian.PutUint64(mutated[3*(48+96):], domain)
+ if _, err := PreflightPhase1(bytes.NewReader(mutated), shape); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("error = %v; want ErrInvalidShape", err)
+ }
+ })
+ }
+}
+
+func TestPreflightRejectsNonCanonicalTrailingAndTruncatedPhase1(t *testing.T) {
+ t.Parallel()
+
+ initial := gnarkmpc.NewPhase1(2)
+ encoded := writeNative(t, initial)
+ shape := Phase1Shape{DomainN: 2}
+
+ uncompressed := bytes.Clone(encoded)
+ uncompressed[0] = 0x40
+ if _, err := PreflightPhase1(bytes.NewReader(uncompressed), shape); !errors.Is(err, ErrNonCanonicalPoint) {
+ t.Fatalf("uncompressed point error = %v; want ErrNonCanonicalPoint", err)
+ }
+
+ withTrailing := append(bytes.Clone(encoded), 0)
+ if _, err := PreflightPhase1(bytes.NewReader(withTrailing), shape); !errors.Is(err, ErrTrailingData) {
+ t.Fatalf("trailing error = %v; want ErrTrailingData", err)
+ }
+
+ for _, cut := range []int{0, 1, 143, 432, len(encoded) - 1} {
+ if _, err := PreflightPhase1(bytes.NewReader(encoded[:cut]), shape); err == nil {
+ t.Fatalf("truncation at %d unexpectedly accepted", cut)
+ }
+ }
+}
+
+func TestPreflightPhase1ContributionChallenge(t *testing.T) {
+ t.Parallel()
+
+ contribution := gnarkmpc.NewPhase1(2)
+ contribution.Contribute()
+ encoded := writeNative(t, contribution)
+ shape := Phase1Shape{DomainN: 2, ChallengeLength: 32}
+ digest, err := PreflightPhase1(bytes.NewReader(encoded), shape)
+ if err != nil {
+ t.Fatalf("preflight contribution: %v", err)
+ }
+ if !bytes.Equal(digest.Challenge, contribution.Challenge) {
+ t.Fatal("preflight challenge differs from native contribution")
+ }
+
+ wrongShape := shape
+ wrongShape.ChallengeLength = 0
+ if _, err := PreflightPhase1(bytes.NewReader(encoded), wrongShape); err == nil {
+ t.Fatal("contribution accepted as zero-challenge genesis")
+ }
+}
+
+func TestPreflightCommons(t *testing.T) {
+ t.Parallel()
+
+ phase1 := gnarkmpc.NewPhase1(2)
+ commons := phase1.Seal([]byte("test beacon"))
+ encoded := writeNative(t, &commons)
+
+ if _, err := PreflightCommons(bytes.NewReader(encoded), CommonsShape{DomainN: 2}); err != nil {
+ t.Fatalf("preflight commons: %v", err)
+ }
+ mutated := bytes.Clone(encoded)
+ binary.BigEndian.PutUint64(mutated, math.MaxUint64)
+ if _, err := PreflightCommons(bytes.NewReader(mutated), CommonsShape{DomainN: 2}); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("hostile commons domain error = %v; want ErrInvalidShape", err)
+ }
+}
+
+func TestPreflightPhase2ChecksEveryLengthPrefix(t *testing.T) {
+ t.Parallel()
+
+ phase2, shape := smallPhase2(32)
+ encoded := writeNative(t, phase2)
+ if got := int64(len(encoded)); got != 767 {
+ t.Fatalf("encoded Phase 2 size = %d; want 767", got)
+ }
+ if _, err := PreflightPhase2(bytes.NewReader(encoded), shape); err != nil {
+ t.Fatalf("preflight Phase 2: %v", err)
+ }
+
+ mutateUint16 := func(offset int, value uint16) []byte {
+ out := bytes.Clone(encoded)
+ binary.BigEndian.PutUint16(out[offset:], value)
+ return out
+ }
+ mutateUint32 := func(offset int, value uint32) []byte {
+ out := bytes.Clone(encoded)
+ binary.BigEndian.PutUint32(out[offset:], value)
+ return out
+ }
+
+ // Layout before vector payloads:
+ // commitments(2), DeltaG1(48), PKK length(4), PKK(2*48),
+ // Z length(4), Z(48), DeltaG2(96), SigmaCKK length(4).
+ pkkOffset := 2 + 48
+ zOffset := pkkOffset + 4 + 2*48
+ sigmaCKKOffset := zOffset + 4 + 48 + 96
+ cases := map[string][]byte{
+ "commitments": mutateUint16(0, math.MaxUint16),
+ "PKK": mutateUint32(pkkOffset, math.MaxUint32),
+ "Z": mutateUint32(zOffset, math.MaxUint32),
+ "SigmaCKK": mutateUint32(sigmaCKKOffset, math.MaxUint32),
+ }
+ for name, input := range cases {
+ t.Run(name, func(t *testing.T) {
+ if _, err := PreflightPhase2(bytes.NewReader(input), shape); !errors.Is(err, ErrInvalidShape) {
+ t.Fatalf("error = %v; want ErrInvalidShape", err)
+ }
+ })
+ }
+}
+
+func TestPreflightPhase2RejectsTrailingAndUncompressed(t *testing.T) {
+ t.Parallel()
+
+ phase2, shape := smallPhase2(32)
+ encoded := writeNative(t, phase2)
+
+ uncompressed := bytes.Clone(encoded)
+ uncompressed[2] = 0x40
+ if _, err := PreflightPhase2(bytes.NewReader(uncompressed), shape); !errors.Is(err, ErrNonCanonicalPoint) {
+ t.Fatalf("uncompressed error = %v; want ErrNonCanonicalPoint", err)
+ }
+ if _, err := PreflightPhase2(bytes.NewReader(append(encoded, 0)), shape); !errors.Is(err, ErrTrailingData) {
+ t.Fatalf("trailing error = %v; want ErrTrailingData", err)
+ }
+ if _, err := PreflightPhase2(bytes.NewReader(encoded[:len(encoded)-1]), shape); err == nil {
+ t.Fatal("truncated challenge unexpectedly accepted")
+ }
+}
+
+func writeNative(t *testing.T, artifact io.WriterTo) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ n, err := artifact.WriteTo(&buf)
+ if err != nil {
+ t.Fatalf("write native artifact: %v", err)
+ }
+ if n != int64(buf.Len()) {
+ t.Fatalf("native count = %d, buffer = %d", n, buf.Len())
+ }
+ return buf.Bytes()
+}
+
+func smallPhase2(challengeLength int) (*gnarkmpc.Phase2, Phase2Shape) {
+ p := new(gnarkmpc.Phase2)
+ p.Parameters.G1.PKK = make([]curve.G1Affine, 2)
+ p.Parameters.G1.Z = make([]curve.G1Affine, 1)
+ p.Parameters.G1.SigmaCKK = [][]curve.G1Affine{make([]curve.G1Affine, 1)}
+ p.Parameters.G2.Sigma = make([]curve.G2Affine, 1)
+ p.Sigmas = make([]cryptompc.UpdateProof, 1)
+ p.Challenge = make([]byte, challengeLength)
+ return p, Phase2Shape{
+ Commitments: 1,
+ PKK: 2,
+ Z: 1,
+ SigmaCKK: []uint32{1},
+ ChallengeLength: uint8(challengeLength),
+ }
+}
diff --git a/internal/mpcceremony/publication.go b/internal/mpcceremony/publication.go
new file mode 100644
index 00000000..f7bf6946
--- /dev/null
+++ b/internal/mpcceremony/publication.go
@@ -0,0 +1,552 @@
+package mpcceremony
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "golang.org/x/crypto/blake2b"
+)
+
+type publicationCommitState string
+
+const (
+ publicationNotCommitted publicationCommitState = "not-committed"
+ publicationCommitted publicationCommitState = "committed"
+)
+
+// publicationError records whether an error happened before an authoritative
+// destination was created or after exact committed bytes were observed there.
+// Callers must never roll back a publicationCommitted destination.
+type publicationError struct {
+ state publicationCommitState
+ op string
+ err error
+}
+
+func (e *publicationError) Error() string {
+ return fmt.Sprintf("%s publication %s: %v", e.state, e.op, e.err)
+}
+
+func (e *publicationError) Unwrap() error {
+ return e.err
+}
+
+func publicationWasCommitted(err error) bool {
+ var publicationErr *publicationError
+ return errors.As(err, &publicationErr) &&
+ publicationErr.state == publicationCommitted
+}
+
+type publicationOps struct {
+ link func(string, string) error
+ renameDirectory func(string, string) error
+ remove func(string) error
+ removeAll func(string) error
+ syncDirectory func(string) error
+}
+
+var defaultPublicationOps = publicationOps{
+ link: os.Link,
+ renameDirectory: renameDirectoryNoReplace,
+ remove: os.Remove,
+ removeAll: os.RemoveAll,
+ syncDirectory: syncDirectory,
+}
+
+// createRecoveryStagingDir creates a fresh same-parent staging directory even
+// when destination already exists. The publication step later accepts that
+// destination only if its complete tree is byte-for-byte identical. This is
+// what makes a crash after rename but before parent fsync safely retryable.
+func createRecoveryStagingDir(destination string) (string, error) {
+ if strings.TrimSpace(destination) == "" {
+ return "", errors.New("publication destination is required")
+ }
+ parent := filepath.Dir(destination)
+ parentInfo, err := os.Lstat(parent)
+ if err != nil {
+ return "", err
+ }
+ if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("publication destination parent is not a real directory")
+ }
+ staging, err := os.MkdirTemp(parent, "."+filepath.Base(destination)+".partial-*")
+ if err != nil {
+ return "", err
+ }
+ if err := os.Chmod(staging, 0o700); err != nil {
+ _ = os.Remove(staging)
+ return "", err
+ }
+ return staging, nil
+}
+
+type publicationFile struct {
+ mode fs.FileMode
+ digest Digest
+}
+
+func publicationMode(mode fs.FileMode) fs.FileMode {
+ return mode & (fs.ModePerm | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky)
+}
+
+func inspectPublicationFile(path string) (publicationFile, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return publicationFile{}, err
+ }
+ if !linkInfo.Mode().IsRegular() || linkInfo.Mode()&os.ModeSymlink != 0 {
+ return publicationFile{}, fmt.Errorf("%q is not a real regular file", path)
+ }
+ if linkInfo.Size() < 0 || linkInfo.Size() > MaxArtifactSize {
+ return publicationFile{}, fmt.Errorf(
+ "%q size %d is outside [0,%d]",
+ path,
+ linkInfo.Size(),
+ MaxArtifactSize,
+ )
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return publicationFile{}, err
+ }
+ defer file.Close()
+ openInfo, err := file.Stat()
+ if err != nil {
+ return publicationFile{}, err
+ }
+ if !openInfo.Mode().IsRegular() ||
+ !os.SameFile(linkInfo, openInfo) ||
+ openInfo.Size() != linkInfo.Size() {
+ return publicationFile{}, fmt.Errorf("%q changed while being opened", path)
+ }
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return publicationFile{}, err
+ }
+ n, err := io.Copy(io.MultiWriter(sha, blake), file)
+ if err != nil {
+ return publicationFile{}, err
+ }
+ if n != openInfo.Size() {
+ return publicationFile{}, fmt.Errorf("%q changed size while hashing", path)
+ }
+ finalInfo, err := file.Stat()
+ if err != nil {
+ return publicationFile{}, err
+ }
+ if !finalInfo.Mode().IsRegular() ||
+ !os.SameFile(openInfo, finalInfo) ||
+ finalInfo.Size() != openInfo.Size() {
+ return publicationFile{}, fmt.Errorf("%q changed while being hashed", path)
+ }
+ return publicationFile{
+ mode: publicationMode(openInfo.Mode()),
+ digest: Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake.Sum(nil)),
+ Size: n,
+ },
+ }, nil
+}
+
+func equalPublicationFile(actual, expected publicationFile) bool {
+ return actual.mode == expected.mode && actual.digest == expected.digest
+}
+
+// publishFileNoReplace publishes a synced temporary file with a same-directory
+// hard link while preserving the public no-replace contract: any destination
+// that existed when the call began yields fs.ErrExist.
+func publishFileNoReplace(tempPath, destination string) error {
+ return publishFileWithOps(
+ tempPath,
+ destination,
+ false,
+ defaultPublicationOps,
+ )
+}
+
+// publishFileNoReplaceOrExact is the recovery variant. It accepts an exact
+// existing destination but never replaces a different one.
+func publishFileNoReplaceOrExact(tempPath, destination string) error {
+ return publishFileNoReplaceOrExactWithOps(
+ tempPath,
+ destination,
+ defaultPublicationOps,
+ )
+}
+
+func publishFileNoReplaceOrExactWithOps(
+ tempPath, destination string,
+ ops publicationOps,
+) error {
+ return publishFileWithOps(tempPath, destination, true, ops)
+}
+
+func publishFileWithOps(
+ tempPath, destination string,
+ acceptExactExisting bool,
+ ops publicationOps,
+) error {
+ expected, err := inspectPublicationFile(tempPath)
+ if err != nil {
+ return &publicationError{publicationNotCommitted, "inspect temporary file", err}
+ }
+ tempInfo, err := os.Lstat(tempPath)
+ if err != nil {
+ return &publicationError{publicationNotCommitted, "inspect temporary file identity", err}
+ }
+ parent := filepath.Dir(destination)
+ destinationExisted := false
+ if _, statErr := os.Lstat(destination); statErr == nil {
+ destinationExisted = true
+ actual, inspectErr := inspectPublicationFile(destination)
+ if inspectErr != nil ||
+ !acceptExactExisting ||
+ !equalPublicationFile(actual, expected) {
+ return &publicationError{
+ publicationNotCommitted,
+ "conflicts with an existing destination",
+ errors.Join(
+ fmt.Errorf("%w: %s", fs.ErrExist, destination),
+ inspectErr,
+ ),
+ }
+ }
+ } else if !errors.Is(statErr, fs.ErrNotExist) {
+ return &publicationError{
+ publicationNotCommitted,
+ "inspect destination",
+ statErr,
+ }
+ }
+
+ if !destinationExisted {
+ if linkErr := ops.link(tempPath, destination); linkErr != nil {
+ destinationInfo, destinationStatErr := os.Lstat(destination)
+ if destinationStatErr != nil ||
+ !destinationInfo.Mode().IsRegular() ||
+ !os.SameFile(tempInfo, destinationInfo) {
+ return &publicationError{
+ publicationNotCommitted,
+ "link without replacement",
+ linkErr,
+ }
+ }
+ }
+ destinationInfo, statErr := os.Lstat(destination)
+ if statErr != nil ||
+ !destinationInfo.Mode().IsRegular() ||
+ !os.SameFile(tempInfo, destinationInfo) {
+ if statErr == nil {
+ statErr = errors.New("destination does not identify the published temporary file")
+ }
+ return &publicationError{
+ publicationCommitted,
+ "validate destination identity",
+ statErr,
+ }
+ }
+ }
+
+ actual, err := inspectPublicationFile(destination)
+ if err != nil || !equalPublicationFile(actual, expected) {
+ if err == nil {
+ err = errors.New("destination bytes or permissions differ from validated temporary file")
+ }
+ return &publicationError{publicationCommitted, "revalidate destination", err}
+ }
+ if err := ops.remove(tempPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return &publicationError{publicationCommitted, "remove temporary link", err}
+ }
+ if err := syncPublicationDirectoryWithRecovery(
+ parent,
+ func() error {
+ actual, err := inspectPublicationFile(destination)
+ if err != nil {
+ return err
+ }
+ if !equalPublicationFile(actual, expected) {
+ return errors.New("destination changed after publication")
+ }
+ return nil
+ },
+ ops,
+ ); err != nil {
+ return err
+ }
+ return nil
+}
+
+type publicationTreeEntry struct {
+ name string
+ mode fs.FileMode
+ digest Digest
+ isDir bool
+}
+
+func inspectPublicationTree(root string) ([]publicationTreeEntry, os.FileInfo, error) {
+ rootInfo, err := os.Lstat(root)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return nil, nil, errors.New("publication root is not a real directory")
+ }
+ const maxPublicationEntries = 100_000
+ entries := make([]publicationTreeEntry, 0, 32)
+ err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if len(entries) >= maxPublicationEntries {
+ return fmt.Errorf("publication tree exceeds %d entries", maxPublicationEntries)
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ name := filepath.ToSlash(relative)
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("publication entry %q is a symbolic link", name)
+ }
+ switch {
+ case entry.IsDir():
+ entries = append(entries, publicationTreeEntry{
+ name: name,
+ mode: publicationMode(info.Mode()),
+ isDir: true,
+ })
+ case info.Mode().IsRegular():
+ file, err := inspectPublicationFile(path)
+ if err != nil {
+ return err
+ }
+ entries = append(entries, publicationTreeEntry{
+ name: name,
+ mode: file.mode,
+ digest: file.digest,
+ })
+ default:
+ return fmt.Errorf("publication entry %q is not a regular file or directory", name)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ finalRootInfo, err := os.Lstat(root)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !os.SameFile(rootInfo, finalRootInfo) {
+ return nil, nil, errors.New("publication root changed while being inspected")
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].name < entries[j].name
+ })
+ return entries, rootInfo, nil
+}
+
+func equalPublicationTrees(actual, expected []publicationTreeEntry) bool {
+ if len(actual) != len(expected) {
+ return false
+ }
+ for index := range expected {
+ if actual[index] != expected[index] {
+ return false
+ }
+ }
+ return true
+}
+
+// publishDirectoryNoReplaceOrExact atomically renames a complete same-parent
+// staging tree. Exact existing trees are accepted only as idempotent recovery;
+// conflicting trees are never replaced.
+func publishDirectoryNoReplaceOrExact(stagingDir, destination string) error {
+ return publishDirectoryNoReplaceOrExactGuardedWithOps(
+ stagingDir,
+ destination,
+ nil,
+ defaultPublicationOps,
+ )
+}
+
+// publishDirectoryNoReplaceOrExactGuarded is the time-sensitive recovery
+// variant. guard is evaluated only for a new destination, after both trees
+// have been inspected and immediately before the no-replace rename. Exact
+// existing destinations bypass the guard so a committed publication can be
+// recovered after its original deadline.
+func publishDirectoryNoReplaceOrExactGuarded(
+ stagingDir, destination string,
+ guard func() error,
+) error {
+ return publishDirectoryNoReplaceOrExactGuardedWithOps(
+ stagingDir,
+ destination,
+ guard,
+ defaultPublicationOps,
+ )
+}
+
+func publishDirectoryNoReplaceOrExactWithOps(
+ stagingDir, destination string,
+ ops publicationOps,
+) error {
+ return publishDirectoryNoReplaceOrExactGuardedWithOps(
+ stagingDir,
+ destination,
+ nil,
+ ops,
+ )
+}
+
+func publishDirectoryNoReplaceOrExactGuardedWithOps(
+ stagingDir, destination string,
+ guard func() error,
+ ops publicationOps,
+) error {
+ expected, stagingInfo, err := inspectPublicationTree(stagingDir)
+ if err != nil {
+ return &publicationError{publicationNotCommitted, "inspect staging tree", err}
+ }
+ if len(expected) <= 1 {
+ return &publicationError{
+ publicationNotCommitted,
+ "inspect staging tree",
+ errors.New("refusing to publish an empty staging directory"),
+ }
+ }
+ parent := filepath.Dir(destination)
+ destinationExisted := false
+ if _, statErr := os.Lstat(destination); statErr == nil {
+ destinationExisted = true
+ actual, _, inspectErr := inspectPublicationTree(destination)
+ if inspectErr != nil || !equalPublicationTrees(actual, expected) {
+ return &publicationError{
+ publicationNotCommitted,
+ "conflicts with an existing destination",
+ errors.Join(
+ fmt.Errorf("%w: %s", fs.ErrExist, destination),
+ inspectErr,
+ ),
+ }
+ }
+ } else if !errors.Is(statErr, fs.ErrNotExist) {
+ return &publicationError{publicationNotCommitted, "inspect destination", statErr}
+ }
+
+ if !destinationExisted {
+ if guard != nil {
+ if guardErr := guard(); guardErr != nil {
+ return &publicationError{
+ publicationNotCommitted,
+ "run pre-commit guard",
+ guardErr,
+ }
+ }
+ }
+ if renameErr := ops.renameDirectory(stagingDir, destination); renameErr != nil {
+ destinationInfo, destinationStatErr := os.Lstat(destination)
+ if destinationStatErr != nil ||
+ !destinationInfo.IsDir() ||
+ !os.SameFile(stagingInfo, destinationInfo) {
+ return &publicationError{
+ publicationNotCommitted,
+ "rename without replacement",
+ renameErr,
+ }
+ }
+ }
+ destinationInfo, statErr := os.Lstat(destination)
+ if statErr != nil ||
+ !destinationInfo.IsDir() ||
+ !os.SameFile(stagingInfo, destinationInfo) {
+ if statErr == nil {
+ statErr = errors.New("destination does not identify the renamed staging directory")
+ }
+ return &publicationError{
+ publicationCommitted,
+ "validate destination identity",
+ statErr,
+ }
+ }
+ }
+
+ validateDestination := func() error {
+ actual, _, err := inspectPublicationTree(destination)
+ if err != nil {
+ return err
+ }
+ if !equalPublicationTrees(actual, expected) {
+ return errors.New("destination tree changed after publication")
+ }
+ return nil
+ }
+ if err := validateDestination(); err != nil {
+ return &publicationError{publicationCommitted, "revalidate destination tree", err}
+ }
+ if destinationExisted {
+ currentStagingInfo, err := os.Lstat(stagingDir)
+ if err != nil {
+ return &publicationError{publicationCommitted, "inspect recovered staging tree", err}
+ }
+ if !os.SameFile(stagingInfo, currentStagingInfo) {
+ return &publicationError{
+ publicationCommitted,
+ "inspect recovered staging tree",
+ errors.New("staging tree changed before cleanup"),
+ }
+ }
+ if err := ops.removeAll(stagingDir); err != nil {
+ return &publicationError{publicationCommitted, "remove exact retry staging tree", err}
+ }
+ }
+ if err := syncPublicationDirectoryWithRecovery(
+ parent,
+ validateDestination,
+ ops,
+ ); err != nil {
+ return err
+ }
+ return nil
+}
+
+func syncPublicationDirectoryWithRecovery(
+ parent string,
+ validate func() error,
+ ops publicationOps,
+) error {
+ if err := ops.syncDirectory(parent); err == nil {
+ return nil
+ } else {
+ firstSyncErr := err
+ if validationErr := validate(); validationErr != nil {
+ return &publicationError{
+ publicationCommitted,
+ "recover after parent sync failure",
+ errors.Join(firstSyncErr, validationErr),
+ }
+ }
+ if retryErr := ops.syncDirectory(parent); retryErr != nil {
+ return &publicationError{
+ publicationCommitted,
+ "retry parent directory sync",
+ errors.Join(firstSyncErr, retryErr),
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/mpcceremony/publication_recovery_test.go b/internal/mpcceremony/publication_recovery_test.go
new file mode 100644
index 00000000..b80c1d03
--- /dev/null
+++ b/internal/mpcceremony/publication_recovery_test.go
@@ -0,0 +1,389 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDirectoryPublicationFaultRecovery(t *testing.T) {
+ t.Run("guard runs immediately before rename", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ guarded := false
+ ops := defaultPublicationOps
+ ops.renameDirectory = func(source, target string) error {
+ if !guarded {
+ t.Fatal("rename called before publication guard")
+ }
+ return renameDirectoryNoReplace(source, target)
+ }
+
+ if err := publishDirectoryNoReplaceOrExactGuardedWithOps(
+ staging,
+ destination,
+ func() error {
+ guarded = true
+ return nil
+ },
+ ops,
+ ); err != nil {
+ t.Fatalf("guarded publication: %v", err)
+ }
+ assertPublicationFile(t, filepath.Join(destination, "artifact.bin"), "candidate")
+ })
+
+ t.Run("guard rejection leaves publication uncommitted", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ injected := errors.New("injected expired deadline")
+ renameCalls := 0
+ ops := defaultPublicationOps
+ ops.renameDirectory = func(source, target string) error {
+ renameCalls++
+ return renameDirectoryNoReplace(source, target)
+ }
+
+ err := publishDirectoryNoReplaceOrExactGuardedWithOps(
+ staging,
+ destination,
+ func() error { return injected },
+ ops,
+ )
+ requirePublicationState(t, err, publicationNotCommitted)
+ if !errors.Is(err, injected) {
+ t.Fatalf("error = %v, want injected guard failure", err)
+ }
+ if renameCalls != 0 {
+ t.Fatalf("rename calls = %d, want 0", renameCalls)
+ }
+ assertPublicationFile(t, filepath.Join(staging, "artifact.bin"), "candidate")
+ if _, statErr := os.Lstat(destination); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("destination exists after guard failure: %v", statErr)
+ }
+ })
+
+ t.Run("exact retry bypasses guard", func(t *testing.T) {
+ parent := t.TempDir()
+ destination := makePublicationTestTree(t, parent, "release", "candidate")
+ staging := makePublicationTestTree(t, parent, "retry", "candidate")
+ guardCalls := 0
+
+ if err := publishDirectoryNoReplaceOrExactGuardedWithOps(
+ staging,
+ destination,
+ func() error {
+ guardCalls++
+ return errors.New("guard must not run")
+ },
+ defaultPublicationOps,
+ ); err != nil {
+ t.Fatalf("exact guarded retry: %v", err)
+ }
+ if guardCalls != 0 {
+ t.Fatalf("guard calls = %d, want 0", guardCalls)
+ }
+ if _, statErr := os.Lstat(staging); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("exact retry staging was not removed: %v", statErr)
+ }
+ })
+
+ t.Run("pre-rename failure is not committed", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ injected := errors.New("injected pre-rename failure")
+ syncCalls := 0
+ ops := defaultPublicationOps
+ ops.renameDirectory = func(_, _ string) error { return injected }
+ ops.syncDirectory = func(string) error {
+ syncCalls++
+ return nil
+ }
+
+ err := publishDirectoryNoReplaceOrExactWithOps(staging, destination, ops)
+ requirePublicationState(t, err, publicationNotCommitted)
+ if !errors.Is(err, injected) {
+ t.Fatalf("error = %v, want injected rename failure", err)
+ }
+ if syncCalls != 0 {
+ t.Fatalf("parent sync calls = %d, want 0", syncCalls)
+ }
+ assertPublicationFile(t, filepath.Join(staging, "artifact.bin"), "candidate")
+ if _, statErr := os.Lstat(destination); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("destination exists after pre-rename failure: %v", statErr)
+ }
+ })
+
+ t.Run("post-rename parent sync failure is recovered", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ syncCalls := 0
+ ops := defaultPublicationOps
+ ops.syncDirectory = func(path string) error {
+ syncCalls++
+ if syncCalls == 1 {
+ return errors.New("injected parent sync failure")
+ }
+ return syncDirectory(path)
+ }
+
+ if err := publishDirectoryNoReplaceOrExactWithOps(staging, destination, ops); err != nil {
+ t.Fatalf("recover post-rename failure: %v", err)
+ }
+ if syncCalls != 2 {
+ t.Fatalf("parent sync calls = %d, want 2", syncCalls)
+ }
+ if _, err := os.Lstat(staging); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("renamed staging still exists: %v", err)
+ }
+ assertPublicationFile(t, filepath.Join(destination, "artifact.bin"), "candidate")
+ })
+
+ t.Run("ambiguous rename error recovers by inode and content", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ ops := defaultPublicationOps
+ ops.renameDirectory = func(source, target string) error {
+ if err := renameDirectoryNoReplace(source, target); err != nil {
+ return err
+ }
+ return errors.New("injected error after successful rename")
+ }
+
+ if err := publishDirectoryNoReplaceOrExactWithOps(staging, destination, ops); err != nil {
+ t.Fatalf("recover ambiguous rename result: %v", err)
+ }
+ assertPublicationFile(t, filepath.Join(destination, "artifact.bin"), "candidate")
+ })
+
+ t.Run("persistent parent sync failure reports committed and retries", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := filepath.Join(parent, "release")
+ ops := defaultPublicationOps
+ ops.syncDirectory = func(string) error {
+ return errors.New("injected persistent parent sync failure")
+ }
+
+ err := publishDirectoryNoReplaceOrExactWithOps(staging, destination, ops)
+ requirePublicationState(t, err, publicationCommitted)
+ assertPublicationFile(t, filepath.Join(destination, "artifact.bin"), "candidate")
+ if _, statErr := os.Lstat(staging); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("successful rename left staging path: %v", statErr)
+ }
+
+ retry := makePublicationTestTree(t, parent, "retry", "candidate")
+ destinationInfo, statErr := os.Lstat(destination)
+ if statErr != nil {
+ t.Fatal(statErr)
+ }
+ if err := publishDirectoryNoReplaceOrExact(retry, destination); err != nil {
+ t.Fatalf("exact committed retry: %v", err)
+ }
+ finalInfo, statErr := os.Lstat(destination)
+ if statErr != nil {
+ t.Fatal(statErr)
+ }
+ if !os.SameFile(destinationInfo, finalInfo) {
+ t.Fatal("exact retry replaced the committed destination directory")
+ }
+ if _, statErr := os.Lstat(retry); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("exact retry staging was not removed: %v", statErr)
+ }
+ })
+
+ t.Run("conflicting retry is untouched", func(t *testing.T) {
+ parent := t.TempDir()
+ staging := makePublicationTestTree(t, parent, "staging", "candidate")
+ destination := makePublicationTestTree(t, parent, "release", "different")
+
+ err := publishDirectoryNoReplaceOrExact(staging, destination)
+ requirePublicationState(t, err, publicationNotCommitted)
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("conflict error = %v, want fs.ErrExist", err)
+ }
+ assertPublicationFile(t, filepath.Join(staging, "artifact.bin"), "candidate")
+ assertPublicationFile(t, filepath.Join(destination, "artifact.bin"), "different")
+ })
+}
+
+func TestFilePublicationFaultRecovery(t *testing.T) {
+ t.Run("pre-link failure is not committed", func(t *testing.T) {
+ parent := t.TempDir()
+ temporary := makePublicationTestFile(t, parent, ".artifact.partial-test", "candidate")
+ destination := filepath.Join(parent, "artifact.bin")
+ injected := errors.New("injected pre-link failure")
+ ops := defaultPublicationOps
+ ops.link = func(_, _ string) error { return injected }
+
+ err := publishFileNoReplaceOrExactWithOps(temporary, destination, ops)
+ requirePublicationState(t, err, publicationNotCommitted)
+ if !errors.Is(err, injected) {
+ t.Fatalf("error = %v, want injected link failure", err)
+ }
+ assertPublicationFile(t, temporary, "candidate")
+ if _, statErr := os.Lstat(destination); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("destination exists after pre-link failure: %v", statErr)
+ }
+ })
+
+ t.Run("post-link parent sync failure is recovered", func(t *testing.T) {
+ parent := t.TempDir()
+ temporary := makePublicationTestFile(t, parent, ".artifact.partial-test", "candidate")
+ destination := filepath.Join(parent, "artifact.bin")
+ syncCalls := 0
+ ops := defaultPublicationOps
+ ops.syncDirectory = func(path string) error {
+ syncCalls++
+ if syncCalls == 1 {
+ return errors.New("injected parent sync failure")
+ }
+ return syncDirectory(path)
+ }
+
+ if err := publishFileNoReplaceOrExactWithOps(temporary, destination, ops); err != nil {
+ t.Fatalf("recover post-link failure: %v", err)
+ }
+ if syncCalls != 2 {
+ t.Fatalf("parent sync calls = %d, want 2", syncCalls)
+ }
+ assertPublicationFile(t, destination, "candidate")
+ if _, statErr := os.Lstat(temporary); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("temporary link still exists: %v", statErr)
+ }
+ })
+
+ t.Run("ambiguous link error recovers by inode and content", func(t *testing.T) {
+ parent := t.TempDir()
+ temporary := makePublicationTestFile(t, parent, ".artifact.partial-test", "candidate")
+ destination := filepath.Join(parent, "artifact.bin")
+ ops := defaultPublicationOps
+ ops.link = func(source, target string) error {
+ if err := os.Link(source, target); err != nil {
+ return err
+ }
+ return errors.New("injected error after successful link")
+ }
+
+ if err := publishFileNoReplaceOrExactWithOps(temporary, destination, ops); err != nil {
+ t.Fatalf("recover ambiguous link result: %v", err)
+ }
+ assertPublicationFile(t, destination, "candidate")
+ })
+
+ t.Run("persistent sync failure preserves exact committed bytes", func(t *testing.T) {
+ parent := t.TempDir()
+ temporary := makePublicationTestFile(t, parent, ".artifact.partial-test", "candidate")
+ destination := filepath.Join(parent, "artifact.bin")
+ ops := defaultPublicationOps
+ ops.syncDirectory = func(string) error {
+ return errors.New("injected persistent parent sync failure")
+ }
+
+ err := publishFileNoReplaceOrExactWithOps(temporary, destination, ops)
+ requirePublicationState(t, err, publicationCommitted)
+ assertPublicationFile(t, destination, "candidate")
+
+ retry := makePublicationTestFile(t, parent, ".artifact.partial-retry", "candidate")
+ destinationInfo, statErr := os.Lstat(destination)
+ if statErr != nil {
+ t.Fatal(statErr)
+ }
+ if err := publishFileNoReplaceOrExact(retry, destination); err != nil {
+ t.Fatalf("exact file retry: %v", err)
+ }
+ finalInfo, statErr := os.Lstat(destination)
+ if statErr != nil {
+ t.Fatal(statErr)
+ }
+ if !os.SameFile(destinationInfo, finalInfo) {
+ t.Fatal("exact file retry replaced the committed destination")
+ }
+ })
+
+ t.Run("conflicting retry is untouched", func(t *testing.T) {
+ parent := t.TempDir()
+ temporary := makePublicationTestFile(t, parent, ".artifact.partial-test", "candidate")
+ destination := makePublicationTestFile(t, parent, "artifact.bin", "different")
+
+ err := publishFileNoReplaceOrExact(temporary, destination)
+ requirePublicationState(t, err, publicationNotCommitted)
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("conflict error = %v, want fs.ErrExist", err)
+ }
+ assertPublicationFile(t, temporary, "candidate")
+ assertPublicationFile(t, destination, "different")
+ })
+}
+
+func makePublicationTestTree(t *testing.T, parent, name, contents string) string {
+ t.Helper()
+ root := filepath.Join(parent, name)
+ if err := os.Mkdir(root, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ nested := filepath.Join(root, "evidence")
+ if err := os.Mkdir(nested, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ makePublicationTestFile(t, root, "artifact.bin", contents)
+ makePublicationTestFile(t, nested, "receipt.json", `{"accepted":true}`)
+ return root
+}
+
+func makePublicationTestFile(t *testing.T, dir, name, contents string) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Sync(); err != nil {
+ file.Close()
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func requirePublicationState(
+ t *testing.T,
+ err error,
+ expected publicationCommitState,
+) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("publication unexpectedly succeeded; want state %s", expected)
+ }
+ var publicationErr *publicationError
+ if !errors.As(err, &publicationErr) {
+ t.Fatalf("error %T %v is not a publicationError", err, err)
+ }
+ if publicationErr.state != expected {
+ t.Fatalf("publication state = %s, want %s: %v", publicationErr.state, expected, err)
+ }
+}
+
+func assertPublicationFile(t *testing.T, path, expected string) {
+ t.Helper()
+ actual, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, []byte(expected)) {
+ t.Fatalf("%s = %q, want %q", path, actual, expected)
+ }
+}
diff --git a/internal/mpcceremony/publication_retry_test.go b/internal/mpcceremony/publication_retry_test.go
new file mode 100644
index 00000000..5d7c396d
--- /dev/null
+++ b/internal/mpcceremony/publication_retry_test.go
@@ -0,0 +1,171 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+)
+
+func TestSignedRecordPublicationResumesExactPrefixes(t *testing.T) {
+ t.Parallel()
+
+ record := adversarialAttestation(t)
+ privateKey := adversarialPrivateKey(0x52)
+ recordBytes, signatureBytes, err := SignRecord(record, record.ParticipantKeyID, privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("signature only", func(t *testing.T) {
+ dir := t.TempDir()
+ recordPath := filepath.Join(dir, "record.json")
+ signaturePath := filepath.Join(dir, "record.sig")
+ if err := writeBytesNoReplace(signaturePath, signatureBytes, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeSignedRecordNoReplace(
+ recordPath,
+ signaturePath,
+ record,
+ record.ParticipantKeyID,
+ privateKey,
+ ); err != nil {
+ t.Fatalf("resume signature-only publication: %v", err)
+ }
+ assertExactFile(t, recordPath, recordBytes)
+ assertExactFile(t, signaturePath, signatureBytes)
+ if err := writeSignedRecordNoReplace(
+ recordPath,
+ signaturePath,
+ record,
+ record.ParticipantKeyID,
+ privateKey,
+ ); err != nil {
+ t.Fatalf("idempotent exact retry: %v", err)
+ }
+ })
+
+ t.Run("mismatched record", func(t *testing.T) {
+ dir := t.TempDir()
+ recordPath := filepath.Join(dir, "record.json")
+ signaturePath := filepath.Join(dir, "record.sig")
+ mismatch := []byte("different authoritative bytes")
+ if err := writeBytesNoReplace(recordPath, mismatch, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ err := writeSignedRecordNoReplace(
+ recordPath,
+ signaturePath,
+ record,
+ record.ParticipantKeyID,
+ privateKey,
+ )
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("mismatched retry error = %v, want fs.ErrExist", err)
+ }
+ assertExactFile(t, recordPath, mismatch)
+ if _, statErr := os.Lstat(signaturePath); !errors.Is(statErr, fs.ErrNotExist) {
+ t.Fatalf("mismatched preflight published signature: %v", statErr)
+ }
+ })
+}
+
+func TestNativePublicationResumesOnlyExactArtifact(t *testing.T) {
+ t.Parallel()
+
+ phase1 := gnarkmpc.NewPhase1(adversarialTinyDomain)
+ shape := Phase1Shape{DomainN: adversarialTinyDomain}
+ expected, err := writerDigest(phase1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "phase1.bin")
+ if _, err := writePhase1FileNoReplaceOrExact(path, phase1, shape, expected); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writePhase1FileNoReplaceOrExact(path, phase1, shape, expected); err != nil {
+ t.Fatalf("exact native retry: %v", err)
+ }
+ wrong := NewDigest([]byte("different artifact"))
+ if _, err := writePhase1FileNoReplaceOrExact(path, phase1, shape, wrong); !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("mismatched native retry error = %v, want fs.ErrExist", err)
+ }
+}
+
+func TestMakeOrResumePrivateDirRejectsUnsafeExistingPath(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ path := filepath.Join(parent, "resume")
+ created, err := makeOrResumePrivateDir(path)
+ if err != nil || !created {
+ t.Fatalf("create private retry dir = %v, created %v", err, created)
+ }
+ created, err = makeOrResumePrivateDir(path)
+ if err != nil || created {
+ t.Fatalf("resume private retry dir = %v, created %v", err, created)
+ }
+ if err := os.WriteFile(filepath.Join(path, "unexpected"), []byte("stray"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := requireDirectoryEntriesSubset(path, []string{"expected"}); err == nil {
+ t.Fatal("retry directory accepted an unexpected entry")
+ }
+
+ cleanupDir := filepath.Join(parent, "cleanup")
+ if err := os.Mkdir(cleanupDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ temporary := filepath.Join(cleanupDir, ".expected.partial-interrupted")
+ if err := os.WriteFile(temporary, []byte("unpublished"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := requireDirectoryEntriesSubset(cleanupDir, []string{"expected"}); err != nil {
+ t.Fatalf("clean interrupted temporary: %v", err)
+ }
+ if _, err := os.Lstat(temporary); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("interrupted temporary remains: %v", err)
+ }
+
+ filePath := filepath.Join(parent, "not-a-dir")
+ if err := os.WriteFile(filePath, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := makeOrResumePrivateDir(filePath); err == nil {
+ t.Fatal("regular file accepted as retry directory")
+ }
+}
+
+func TestMkdirAllPrivateDurableCreatesPrivateHierarchy(t *testing.T) {
+ t.Parallel()
+
+ path := filepath.Join(t.TempDir(), "phase1", "contributions", "0001")
+ if err := mkdirAllPrivateDurable(path); err != nil {
+ t.Fatal(err)
+ }
+ for current := path; filepath.Base(current) != "phase1"; current = filepath.Dir(current) {
+ info, err := os.Lstat(current)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
+ t.Fatalf("created directory %s mode = %v", current, info.Mode())
+ }
+ }
+}
+
+func assertExactFile(t *testing.T, path string, expected []byte) {
+ t.Helper()
+ actual, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, expected) {
+ t.Fatalf("%s differs from expected bytes", path)
+ }
+}
diff --git a/internal/mpcceremony/r1cs.go b/internal/mpcceremony/r1cs.go
new file mode 100644
index 00000000..96f50d05
--- /dev/null
+++ b/internal/mpcceremony/r1cs.go
@@ -0,0 +1,448 @@
+package mpcceremony
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ "github.com/consensys/gnark/backend/groth16"
+ "github.com/consensys/gnark/constraint"
+ bls12381cs "github.com/consensys/gnark/constraint/bls12-381"
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/keyprofile"
+ "proof-tool/internal/prover"
+)
+
+const destinationV2CommitmentCount = 1
+
+// CompiledCircuit couples the concrete gnark BLS12-381 R1CS used by the MPC
+// engine with the exact public binding that must appear in the signed ceremony
+// definition.
+type CompiledCircuit struct {
+ R1CS *bls12381cs.R1CS
+ Binding CircuitBinding
+
+ validated bool
+}
+
+// CompileDestinationV2 compiles the repository's production destination-v2
+// profile and derives its exact serialized R1CS identity and Phase 2 shape.
+func CompileDestinationV2() (*CompiledCircuit, error) {
+ profile, err := keyprofile.ForKeyVersion(prover.DefaultDestinationKeyVersion)
+ if err != nil {
+ return nil, fmt.Errorf("resolve destination-v2 circuit profile: %w", err)
+ }
+ if profile.KeyVersion != KeyVersionDestinationV2 || profile.CircuitID != CircuitIDDestinationV2 {
+ return nil, fmt.Errorf(
+ "destination-v2 profile identity is key_version=%q circuit_id=%q",
+ profile.KeyVersion,
+ profile.CircuitID,
+ )
+ }
+ compiled, err := profile.Compile()
+ if err != nil {
+ return nil, fmt.Errorf("compile destination-v2 circuit: %w", err)
+ }
+ return bindDestinationV2R1CS(compiled)
+}
+
+// BindDestinationV2R1CS validates a loaded or independently compiled
+// constraint system using the same curve, shape, and digest rules as
+// CompileDestinationV2. Callers must still compare the resulting Binding to
+// the signed ceremony definition before accepting externally supplied bytes.
+func BindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircuit, error) {
+ return bindDestinationV2R1CS(compiled)
+}
+
+// ValidateCircuitBinding requires every field of the runtime circuit binding
+// to match the signed expected binding. This includes both hashes and the exact
+// native serialization size, not only circuit labels or counts.
+func ValidateCircuitBinding(circuit *CompiledCircuit, expected CircuitBinding) error {
+ if err := validateCompiledCircuit(circuit); err != nil {
+ return err
+ }
+ if err := expected.Validate(); err != nil {
+ return fmt.Errorf("expected circuit binding: %w", err)
+ }
+ if err := circuit.Binding.Validate(); err != nil {
+ return fmt.Errorf("compiled circuit binding: %w", err)
+ }
+ if !equalCircuitBinding(circuit.Binding, expected) {
+ return fmt.Errorf("compiled circuit binding does not match signed ceremony definition")
+ }
+ return nil
+}
+
+// ReadR1CSFile authenticates an exact-size frozen native gnark constraint
+// system against a signed destination-v2 binding before decoding it. The
+// digest check intentionally precedes native decoding, whose vector lengths
+// are not safe to accept from an unauthenticated file.
+func ReadR1CSFile(path string, expected CircuitBinding) (*CompiledCircuit, error) {
+ if err := expected.Validate(); err != nil {
+ return nil, fmt.Errorf("expected circuit binding: %w", err)
+ }
+ if expected.R1CS.Digest.Size > MaxArtifactSize {
+ return nil, fmt.Errorf(
+ "frozen R1CS is %d bytes, exceeds limit %d",
+ expected.R1CS.Digest.Size,
+ MaxArtifactSize,
+ )
+ }
+ file, digest, err := preflightR1CSFile(path, expected.R1CS.Digest)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ ccs := groth16.NewCS(ecc.BLS12_381)
+ native, ok := ccs.(*bls12381cs.R1CS)
+ if !ok {
+ return nil, fmt.Errorf("new BLS12-381 constraint system type is %T", ccs)
+ }
+ if err := nativeReadExact(
+ io.NewSectionReader(file, 0, digest.Size),
+ digest.Size,
+ native,
+ ); err != nil {
+ return nil, fmt.Errorf("decode frozen R1CS %q: %w", path, err)
+ }
+ compiled, err := bindDestinationV2R1CS(native)
+ if err != nil {
+ return nil, fmt.Errorf("validate frozen R1CS %q: %w", path, err)
+ }
+ if err := ValidateCircuitBinding(compiled, expected); err != nil {
+ return nil, fmt.Errorf("frozen R1CS %q: %w", path, err)
+ }
+ return compiled, nil
+}
+
+// WriteR1CSFileNoReplace writes the exact native gnark R1CS, syncs it, reads
+// it back through ReadR1CSFile, and atomically publishes it without replacing
+// any existing path.
+func WriteR1CSFileNoReplace(path string, circuit *CompiledCircuit) (Digest, error) {
+ if err := validateCompiledCircuit(circuit); err != nil {
+ return Digest{}, err
+ }
+ if filepath.Base(path) != circuit.Binding.R1CS.Name {
+ return Digest{}, fmt.Errorf(
+ "frozen R1CS output name %q, want %q",
+ filepath.Base(path),
+ circuit.Binding.R1CS.Name,
+ )
+ }
+
+ _, err := atomicWriteNoReplace(
+ path,
+ circuit.Binding.R1CS.Digest.Size,
+ circuit.R1CS.WriteTo,
+ func(tempPath string) (ArtifactDigest, error) {
+ if _, err := ReadR1CSFile(tempPath, circuit.Binding); err != nil {
+ return ArtifactDigest{}, err
+ }
+ return artifactDigestFromModel(circuit.Binding.R1CS.Digest)
+ },
+ )
+ if err != nil {
+ return Digest{}, err
+ }
+ return circuit.Binding.R1CS.Digest, nil
+}
+
+func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircuit, error) {
+ if compiled == nil {
+ return nil, errors.New("constraint system is required")
+ }
+ native, ok := compiled.(*bls12381cs.R1CS)
+ if !ok {
+ return nil, fmt.Errorf("constraint system type is %T, want *bls12-381.R1CS", compiled)
+ }
+ if native.Field().Cmp(ecc.BLS12_381.ScalarField()) != 0 {
+ return nil, errors.New("constraint system scalar field is not BLS12-381")
+ }
+ if native.GetNbConstraints() <= 0 {
+ return nil, fmt.Errorf("constraint system has %d constraints", native.GetNbConstraints())
+ }
+
+ constraints := uint64(native.GetNbConstraints())
+ domainN := ecc.NextPowerOfTwo(constraints)
+ if err := validateDomain(domainN); err != nil {
+ return nil, fmt.Errorf("constraint system domain: %w", err)
+ }
+
+ internal, secret, public := native.GetNbVariables()
+ if internal <= 0 || secret <= 0 || public <= 0 {
+ return nil, fmt.Errorf(
+ "constraint system variable counts must be positive: internal=%d secret=%d public=%d",
+ internal,
+ secret,
+ public,
+ )
+ }
+ commitments, err := groth16Commitments(native)
+ if err != nil {
+ return nil, err
+ }
+ if len(commitments) != destinationV2CommitmentCount {
+ return nil, fmt.Errorf(
+ "destination-v2 constraint system has %d commitments, want %d",
+ len(commitments),
+ destinationV2CommitmentCount,
+ )
+ }
+
+ digest, err := digestR1CS(native)
+ if err != nil {
+ return nil, err
+ }
+ phase2Shape, err := phase2ShapeFromR1CS(native, domainN, commitments)
+ if err != nil {
+ return nil, err
+ }
+ binding := CircuitBinding{
+ KeyVersion: KeyVersionDestinationV2,
+ CircuitID: CircuitIDDestinationV2,
+ Curve: CurveBLS12381,
+ Backend: BackendGroth16,
+ R1CS: ArtifactRef{Name: prover.DestinationConstraintSystemFile, Digest: digest},
+ Constraints: constraints,
+ InternalVariables: uint64(internal),
+ SecretVariables: uint64(secret),
+ PublicVariables: uint64(public),
+ DomainSize: domainN,
+ Phase2Shape: phase2Shape,
+ }
+ if err := binding.Validate(); err != nil {
+ return nil, fmt.Errorf("derived destination-v2 circuit binding: %w", err)
+ }
+ return &CompiledCircuit{R1CS: native, Binding: binding, validated: true}, nil
+}
+
+func validateCompiledCircuit(circuit *CompiledCircuit) error {
+ if circuit == nil || circuit.R1CS == nil {
+ return errors.New("compiled BLS12-381 circuit is required")
+ }
+ if !circuit.validated {
+ return errors.New("compiled circuit was not created by an exact MPC circuit binder")
+ }
+ if err := circuit.Binding.Validate(); err != nil {
+ return fmt.Errorf("compiled circuit binding: %w", err)
+ }
+ if circuit.R1CS.Field().Cmp(ecc.BLS12_381.ScalarField()) != 0 {
+ return errors.New("compiled circuit scalar field is not BLS12-381")
+ }
+ if uint64(circuit.R1CS.GetNbConstraints()) != circuit.Binding.Constraints {
+ return fmt.Errorf(
+ "compiled circuit constraints %d, binding pins %d",
+ circuit.R1CS.GetNbConstraints(),
+ circuit.Binding.Constraints,
+ )
+ }
+ internal, secret, public := circuit.R1CS.GetNbVariables()
+ if uint64(internal) != circuit.Binding.InternalVariables ||
+ uint64(secret) != circuit.Binding.SecretVariables ||
+ uint64(public) != circuit.Binding.PublicVariables {
+ return errors.New("compiled circuit variable counts do not match binding")
+ }
+ if got := ecc.NextPowerOfTwo(uint64(circuit.R1CS.GetNbConstraints())); got != circuit.Binding.DomainSize {
+ return fmt.Errorf("compiled circuit domain %d, binding pins %d", got, circuit.Binding.DomainSize)
+ }
+ commitments, err := groth16Commitments(circuit.R1CS)
+ if err != nil {
+ return err
+ }
+ shape, err := phase2ShapeFromR1CS(circuit.R1CS, circuit.Binding.DomainSize, commitments)
+ if err != nil {
+ return err
+ }
+ if !equalPhase2Shape(shape, circuit.Binding.Phase2Shape) {
+ return errors.New("compiled circuit Phase 2 shape does not match binding")
+ }
+ return nil
+}
+
+func preflightR1CSFile(path string, expected Digest) (*os.File, ArtifactDigest, error) {
+ if err := expected.Validate(); err != nil {
+ return nil, ArtifactDigest{}, fmt.Errorf("expected R1CS digest: %w", err)
+ }
+ file, err := openRegularExact(path, expected.Size)
+ if err != nil {
+ return nil, ArtifactDigest{}, err
+ }
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ file.Close()
+ return nil, ArtifactDigest{}, fmt.Errorf("initialize BLAKE2b-256: %w", err)
+ }
+ size, err := io.Copy(
+ io.MultiWriter(sha, blake),
+ io.NewSectionReader(file, 0, expected.Size),
+ )
+ if err != nil {
+ file.Close()
+ return nil, ArtifactDigest{}, fmt.Errorf("digest frozen R1CS %q: %w", path, err)
+ }
+ var digest ArtifactDigest
+ digest.Size = size
+ copy(digest.SHA256[:], sha.Sum(nil))
+ copy(digest.BLAKE2b256[:], blake.Sum(nil))
+ if got := modelDigestFromArtifact(digest); got != expected {
+ file.Close()
+ return nil, ArtifactDigest{}, fmt.Errorf("frozen R1CS %q digest does not match signed binding", path)
+ }
+ return file, digest, nil
+}
+
+func modelDigestFromArtifact(digest ArtifactDigest) Digest {
+ return Digest{
+ SHA256: "sha256:" + hex.EncodeToString(digest.SHA256[:]),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(digest.BLAKE2b256[:]),
+ Size: digest.Size,
+ }
+}
+
+func artifactDigestFromModel(digest Digest) (ArtifactDigest, error) {
+ if err := digest.Validate(); err != nil {
+ return ArtifactDigest{}, err
+ }
+ shaBytes, err := hex.DecodeString(strings.TrimPrefix(digest.SHA256, "sha256:"))
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ blakeBytes, err := hex.DecodeString(strings.TrimPrefix(digest.Blake2b256, "blake2b256:"))
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ var result ArtifactDigest
+ result.Size = digest.Size
+ copy(result.SHA256[:], shaBytes)
+ copy(result.BLAKE2b256[:], blakeBytes)
+ return result, nil
+}
+
+func groth16Commitments(r1cs *bls12381cs.R1CS) (constraint.Groth16Commitments, error) {
+ commitments, ok := r1cs.CommitmentInfo.(constraint.Groth16Commitments)
+ if !ok {
+ return nil, fmt.Errorf(
+ "constraint system commitment metadata type is %T, want constraint.Groth16Commitments",
+ r1cs.CommitmentInfo,
+ )
+ }
+ return commitments, nil
+}
+
+// phase2ShapeFromR1CS mirrors the length-only part of gnark v0.15.0
+// mpcsetup.Phase2.Initialize. It avoids evaluating the full K=21 QAP merely
+// to establish allocation-safe transcript bounds in the ceremony definition.
+// engine_test checks this result against DerivePhase2Shape on an initialized
+// committed circuit.
+func phase2ShapeFromR1CS(
+ r1cs *bls12381cs.R1CS,
+ domainN uint64,
+ commitments constraint.Groth16Commitments,
+) (Phase2Shape, error) {
+ if domainN == 0 || domainN > math.MaxUint32+1 {
+ return Phase2Shape{}, fmt.Errorf("Phase 2 domain %d cannot be represented", domainN)
+ }
+ if len(commitments) > int(MaxPhase2Commitments) {
+ return Phase2Shape{}, fmt.Errorf(
+ "Phase 2 commitment count %d exceeds %d",
+ len(commitments),
+ MaxPhase2Commitments,
+ )
+ }
+
+ internal, secret, _ := r1cs.GetNbVariables()
+ committed := 0
+ shape := Phase2Shape{
+ Commitments: uint16(len(commitments)),
+ Z: uint32(domainN - 1),
+ SigmaCKK: make([]uint32, len(commitments)),
+ ChallengeLength: 0,
+ }
+ for i := range commitments {
+ count := len(commitments[i].PrivateCommitted)
+ if count > math.MaxUint32 {
+ return Phase2Shape{}, fmt.Errorf("commitment %d private committed count %d exceeds uint32", i, count)
+ }
+ if committed > math.MaxInt-count {
+ return Phase2Shape{}, errors.New("total private committed count overflows int")
+ }
+ committed += count
+ shape.SigmaCKK[i] = uint32(count)
+ }
+ pkk := internal + secret - committed - len(commitments)
+ if pkk < 0 || pkk > math.MaxUint32 {
+ return Phase2Shape{}, fmt.Errorf("derived Phase 2 PKK length %d is invalid", pkk)
+ }
+ shape.PKK = uint32(pkk)
+ if err := shape.Validate(); err != nil {
+ return Phase2Shape{}, fmt.Errorf("derived Phase 2 shape: %w", err)
+ }
+ return shape, nil
+}
+
+func digestR1CS(r1cs *bls12381cs.R1CS) (Digest, error) {
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return Digest{}, fmt.Errorf("initialize BLAKE2b-256: %w", err)
+ }
+ size, err := writeToWithPanicBoundary(
+ "constraint-system encoder",
+ r1cs,
+ io.MultiWriter(sha, blake),
+ )
+ if err != nil {
+ return Digest{}, fmt.Errorf("serialize constraint system: %w", err)
+ }
+ digest := Digest{
+ SHA256: "sha256:" + hex.EncodeToString(sha.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blake.Sum(nil)),
+ Size: size,
+ }
+ if err := digest.Validate(); err != nil {
+ return Digest{}, fmt.Errorf("constraint system digest: %w", err)
+ }
+ return digest, nil
+}
+
+func equalCircuitBinding(left, right CircuitBinding) bool {
+ if left.KeyVersion != right.KeyVersion ||
+ left.CircuitID != right.CircuitID ||
+ left.Curve != right.Curve ||
+ left.Backend != right.Backend ||
+ left.R1CS != right.R1CS ||
+ left.Constraints != right.Constraints ||
+ left.InternalVariables != right.InternalVariables ||
+ left.SecretVariables != right.SecretVariables ||
+ left.PublicVariables != right.PublicVariables ||
+ left.DomainSize != right.DomainSize {
+ return false
+ }
+ return equalPhase2Shape(left.Phase2Shape, right.Phase2Shape)
+}
+
+func equalPhase2Shape(left, right Phase2Shape) bool {
+ if left.Commitments != right.Commitments ||
+ left.PKK != right.PKK ||
+ left.Z != right.Z ||
+ left.ChallengeLength != right.ChallengeLength ||
+ len(left.SigmaCKK) != len(right.SigmaCKK) {
+ return false
+ }
+ for i := range left.SigmaCKK {
+ if left.SigmaCKK[i] != right.SigmaCKK[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/mpcceremony/rename_noreplace_linux.go b/internal/mpcceremony/rename_noreplace_linux.go
new file mode 100644
index 00000000..28893d1d
--- /dev/null
+++ b/internal/mpcceremony/rename_noreplace_linux.go
@@ -0,0 +1,15 @@
+//go:build linux
+
+package mpcceremony
+
+import "golang.org/x/sys/unix"
+
+func renameDirectoryNoReplace(source, destination string) error {
+ return unix.Renameat2(
+ unix.AT_FDCWD,
+ source,
+ unix.AT_FDCWD,
+ destination,
+ unix.RENAME_NOREPLACE,
+ )
+}
diff --git a/internal/mpcceremony/rename_noreplace_other.go b/internal/mpcceremony/rename_noreplace_other.go
new file mode 100644
index 00000000..65237319
--- /dev/null
+++ b/internal/mpcceremony/rename_noreplace_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package mpcceremony
+
+import "os"
+
+// Production release binaries are Linux-only. This fallback preserves local
+// development portability but cannot provide Linux renameat2 no-replace
+// semantics against a hostile concurrent creator of an empty destination.
+func renameDirectoryNoReplace(source, destination string) error {
+ return os.Rename(source, destination)
+}
diff --git a/internal/mpcceremony/software.go b/internal/mpcceremony/software.go
new file mode 100644
index 00000000..df39e165
--- /dev/null
+++ b/internal/mpcceremony/software.go
@@ -0,0 +1,518 @@
+package mpcceremony
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/prover"
+)
+
+const (
+ gnarkModulePath = "github.com/consensys/gnark"
+ gnarkCryptoModulePath = "github.com/consensys/gnark-crypto"
+ drandModulePath = "github.com/drand/drand/v2"
+ gitRevisionHexLength = 40
+)
+
+type runningSoftwareSource struct {
+ executable func() (string, error)
+ readBuildInfo func() (*debug.BuildInfo, bool)
+ runtimeVersion func() string
+}
+
+func productionSoftwareSource() runningSoftwareSource {
+ return runningSoftwareSource{
+ executable: productionExecutablePath,
+ readBuildInfo: debug.ReadBuildInfo,
+ runtimeVersion: runtime.Version,
+ }
+}
+
+func productionExecutablePath() (string, error) {
+ if runtime.GOOS != ProductionGOOS {
+ return "", fmt.Errorf(
+ "production executable identity requires %s /proc/self/exe, running on %s",
+ ProductionGOOS,
+ runtime.GOOS,
+ )
+ }
+ return "/proc/self/exe", nil
+}
+
+// RunningSoftwareBinding derives the software identity from the process that
+// is actually running. It intentionally ignores ceremony JSON: production
+// initialization must put this value into the signed definition rather than
+// accepting operator-supplied software metadata.
+//
+// Production binaries must be built from a clean Git checkout with VCS build
+// information enabled. The exact executable bytes and linked gnark module
+// versions are included in the returned binding.
+func RunningSoftwareBinding(proofToolVersion string) (SoftwareBinding, error) {
+ return RunningSoftwareBindingForMode(proofToolVersion, ModeProduction)
+}
+
+// RunningSoftwareBindingForMode derives the running process identity under
+// the selected ceremony policy. Production requires vcs.modified=false.
+// Rehearsal permits either clean or dirty builds and records the exact flag.
+func RunningSoftwareBindingForMode(proofToolVersion, mode string) (SoftwareBinding, error) {
+ return runningSoftwareBinding(proofToolVersion, mode, productionSoftwareSource())
+}
+
+// VerifyRunningSoftware fails unless every field in expected describes the
+// exact clean binary that is currently running.
+func VerifyRunningSoftware(expected SoftwareBinding) error {
+ return VerifyRunningSoftwareForMode(expected, ModeProduction)
+}
+
+// VerifyRunningSoftwareForMode verifies the current process under the selected
+// ceremony policy, including an exact match of the VCS modified flag.
+func VerifyRunningSoftwareForMode(expected SoftwareBinding, mode string) error {
+ return verifyRunningSoftware(expected, mode, productionSoftwareSource())
+}
+
+func runningSoftwareBinding(
+ proofToolVersion string,
+ mode string,
+ source runningSoftwareSource,
+) (SoftwareBinding, error) {
+ if err := validateSoftwareMode(mode); err != nil {
+ return SoftwareBinding{}, err
+ }
+ if proofToolVersion != prover.ProofToolVersion {
+ return SoftwareBinding{}, fmt.Errorf(
+ "proof tool version %q, want compiled version %q",
+ proofToolVersion,
+ prover.ProofToolVersion,
+ )
+ }
+ if source.executable == nil || source.readBuildInfo == nil || source.runtimeVersion == nil {
+ return SoftwareBinding{}, errors.New("running software source is incomplete")
+ }
+
+ executablePath, err := source.executable()
+ if err != nil {
+ return SoftwareBinding{}, fmt.Errorf("resolve running executable: %w", err)
+ }
+ toolBinary, err := digestRunningExecutable(executablePath)
+ if err != nil {
+ return SoftwareBinding{}, fmt.Errorf("digest running executable: %w", err)
+ }
+
+ buildInfo, ok := source.readBuildInfo()
+ if !ok || buildInfo == nil {
+ return SoftwareBinding{}, errors.New("running executable has no Go build information")
+ }
+ goVersion := source.runtimeVersion()
+ if strings.TrimSpace(goVersion) == "" {
+ return SoftwareBinding{}, errors.New("running Go version is empty")
+ }
+ if buildInfo.GoVersion != goVersion {
+ return SoftwareBinding{}, fmt.Errorf(
+ "linked Go version %q does not match runtime version %q",
+ buildInfo.GoVersion,
+ goVersion,
+ )
+ }
+ goOS, err := uniqueBuildSetting(buildInfo, "GOOS")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ goArch, err := uniqueBuildSetting(buildInfo, "GOARCH")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ goAMD64 := ""
+ if goArch == ProductionGOARCH {
+ goAMD64, err = uniqueBuildSetting(buildInfo, "GOAMD64")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ }
+ compiler, err := uniqueBuildSetting(buildInfo, "-compiler")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ buildMode, err := uniqueBuildSetting(buildInfo, "-buildmode")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ cgoEnabled, err := booleanBuildSetting(buildInfo, "CGO_ENABLED", false)
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ trimPath, err := booleanBuildSetting(buildInfo, "-trimpath", false)
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if mode == ModeProduction {
+ if err := validateProductionBuildProfile(
+ goVersion,
+ goOS,
+ goArch,
+ goAMD64,
+ compiler,
+ buildMode,
+ cgoEnabled,
+ trimPath,
+ ); err != nil {
+ return SoftwareBinding{}, err
+ }
+ }
+
+ vcs, err := uniqueBuildSetting(buildInfo, "vcs")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if vcs != "git" {
+ return SoftwareBinding{}, fmt.Errorf("linked VCS is %q, want %q", vcs, "git")
+ }
+ sourceCommit, err := uniqueBuildSetting(buildInfo, "vcs.revision")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if err := validateCleanGitRevision(sourceCommit); err != nil {
+ return SoftwareBinding{}, fmt.Errorf("vcs.revision: %w", err)
+ }
+ sourceModified, err := uniqueBuildSetting(buildInfo, "vcs.modified")
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ var sourceDirty bool
+ switch sourceModified {
+ case "false":
+ case "true":
+ sourceDirty = true
+ default:
+ return SoftwareBinding{}, fmt.Errorf(
+ "vcs.modified is %q, want %q or %q",
+ sourceModified,
+ "false",
+ "true",
+ )
+ }
+ if mode == ModeProduction && sourceDirty {
+ return SoftwareBinding{}, fmt.Errorf(
+ "vcs.modified is %q; production ceremony binaries must be built from a clean checkout",
+ sourceModified,
+ )
+ }
+
+ gnarkVersion, err := linkedModuleVersion(buildInfo, gnarkModulePath)
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if gnarkVersion != GnarkVersion {
+ return SoftwareBinding{}, fmt.Errorf(
+ "linked %s version %q, want %q",
+ gnarkModulePath,
+ gnarkVersion,
+ GnarkVersion,
+ )
+ }
+ gnarkCryptoVersion, err := linkedModuleVersion(buildInfo, gnarkCryptoModulePath)
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if gnarkCryptoVersion != GnarkCryptoVersion {
+ return SoftwareBinding{}, fmt.Errorf(
+ "linked %s version %q, want %q",
+ gnarkCryptoModulePath,
+ gnarkCryptoVersion,
+ GnarkCryptoVersion,
+ )
+ }
+ drandVersion, err := linkedModuleVersion(buildInfo, drandModulePath)
+ if err != nil {
+ return SoftwareBinding{}, err
+ }
+ if drandVersion != DrandVersion {
+ return SoftwareBinding{}, fmt.Errorf(
+ "linked %s version %q, want %q",
+ drandModulePath,
+ drandVersion,
+ DrandVersion,
+ )
+ }
+
+ binding := SoftwareBinding{
+ ProofToolVersion: proofToolVersion,
+ GnarkVersion: gnarkVersion,
+ GnarkCryptoVersion: gnarkCryptoVersion,
+ DrandVersion: drandVersion,
+ GoVersion: goVersion,
+ GoOS: goOS,
+ GoArch: goArch,
+ GoAMD64: goAMD64,
+ Compiler: compiler,
+ BuildMode: buildMode,
+ CGOEnabled: cgoEnabled,
+ TrimPath: trimPath,
+ SourceCommit: sourceCommit,
+ SourceDirty: sourceDirty,
+ ToolBinary: toolBinary,
+ }
+ if err := binding.Validate(); err != nil {
+ return SoftwareBinding{}, fmt.Errorf("derived software binding: %w", err)
+ }
+ return binding, nil
+}
+
+func verifyRunningSoftware(
+ expected SoftwareBinding,
+ mode string,
+ source runningSoftwareSource,
+) error {
+ if err := validateSoftwareMode(mode); err != nil {
+ return err
+ }
+ if err := expected.Validate(); err != nil {
+ return fmt.Errorf("expected software binding: %w", err)
+ }
+ if mode == ModeProduction && expected.SourceDirty {
+ return errors.New("expected software binding records a dirty source checkout")
+ }
+ if err := validateCleanGitRevision(expected.SourceCommit); err != nil {
+ return fmt.Errorf("expected source_commit: %w", err)
+ }
+
+ actual, err := runningSoftwareBinding(expected.ProofToolVersion, mode, source)
+ if err != nil {
+ return fmt.Errorf("derive running software binding: %w", err)
+ }
+ switch {
+ case expected.ProofToolVersion != actual.ProofToolVersion:
+ return softwareMismatch("proof_tool_version", expected.ProofToolVersion, actual.ProofToolVersion)
+ case expected.GnarkVersion != actual.GnarkVersion:
+ return softwareMismatch("gnark_version", expected.GnarkVersion, actual.GnarkVersion)
+ case expected.GnarkCryptoVersion != actual.GnarkCryptoVersion:
+ return softwareMismatch("gnark_crypto_version", expected.GnarkCryptoVersion, actual.GnarkCryptoVersion)
+ case expected.DrandVersion != actual.DrandVersion:
+ return softwareMismatch("drand_version", expected.DrandVersion, actual.DrandVersion)
+ case expected.GoVersion != actual.GoVersion:
+ return softwareMismatch("go_version", expected.GoVersion, actual.GoVersion)
+ case expected.GoOS != actual.GoOS:
+ return softwareMismatch("goos", expected.GoOS, actual.GoOS)
+ case expected.GoArch != actual.GoArch:
+ return softwareMismatch("goarch", expected.GoArch, actual.GoArch)
+ case expected.GoAMD64 != actual.GoAMD64:
+ return softwareMismatch("goamd64", expected.GoAMD64, actual.GoAMD64)
+ case expected.Compiler != actual.Compiler:
+ return softwareMismatch("compiler", expected.Compiler, actual.Compiler)
+ case expected.BuildMode != actual.BuildMode:
+ return softwareMismatch("build_mode", expected.BuildMode, actual.BuildMode)
+ case expected.CGOEnabled != actual.CGOEnabled:
+ return softwareMismatch("cgo_enabled", expected.CGOEnabled, actual.CGOEnabled)
+ case expected.TrimPath != actual.TrimPath:
+ return softwareMismatch("trimpath", expected.TrimPath, actual.TrimPath)
+ case expected.SourceCommit != actual.SourceCommit:
+ return softwareMismatch("source_commit", expected.SourceCommit, actual.SourceCommit)
+ case expected.SourceDirty != actual.SourceDirty:
+ return softwareMismatch("source_dirty", expected.SourceDirty, actual.SourceDirty)
+ case expected.ToolBinary.SHA256 != actual.ToolBinary.SHA256:
+ return softwareMismatch("tool_binary.sha256", expected.ToolBinary.SHA256, actual.ToolBinary.SHA256)
+ case expected.ToolBinary.Blake2b256 != actual.ToolBinary.Blake2b256:
+ return softwareMismatch(
+ "tool_binary.blake2b256",
+ expected.ToolBinary.Blake2b256,
+ actual.ToolBinary.Blake2b256,
+ )
+ case expected.ToolBinary.Size != actual.ToolBinary.Size:
+ return softwareMismatch("tool_binary.size", expected.ToolBinary.Size, actual.ToolBinary.Size)
+ default:
+ return nil
+ }
+}
+
+func digestRunningExecutable(path string) (Digest, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return Digest{}, err
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return Digest{}, err
+ }
+ if !info.Mode().IsRegular() {
+ return Digest{}, fmt.Errorf("%q is not a regular file", path)
+ }
+ if info.Size() <= 0 {
+ return Digest{}, fmt.Errorf("%q is empty", path)
+ }
+
+ shaHash := sha256.New()
+ blakeHash, err := blake2b.New256(nil)
+ if err != nil {
+ return Digest{}, fmt.Errorf("create BLAKE2b-256 hasher: %w", err)
+ }
+ size, err := io.Copy(io.MultiWriter(shaHash, blakeHash), file)
+ if err != nil {
+ return Digest{}, err
+ }
+ if size != info.Size() {
+ return Digest{}, fmt.Errorf(
+ "%q changed size while hashing: read %d bytes, stat reported %d",
+ path,
+ size,
+ info.Size(),
+ )
+ }
+
+ result := Digest{
+ SHA256: "sha256:" + hex.EncodeToString(shaHash.Sum(nil)),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(blakeHash.Sum(nil)),
+ Size: size,
+ }
+ if err := result.Validate(); err != nil {
+ return Digest{}, err
+ }
+ return result, nil
+}
+
+func uniqueBuildSetting(info *debug.BuildInfo, key string) (string, error) {
+ var value string
+ found := false
+ for _, setting := range info.Settings {
+ if setting.Key != key {
+ continue
+ }
+ if found {
+ return "", fmt.Errorf("running executable has duplicate %s build settings", key)
+ }
+ value = setting.Value
+ found = true
+ }
+ if !found {
+ return "", fmt.Errorf("running executable is missing %s build setting", key)
+ }
+ return value, nil
+}
+
+func optionalBuildSetting(info *debug.BuildInfo, key string) (string, bool, error) {
+ var value string
+ found := false
+ for _, setting := range info.Settings {
+ if setting.Key != key {
+ continue
+ }
+ if found {
+ return "", false, fmt.Errorf("running executable has duplicate %s build settings", key)
+ }
+ value = setting.Value
+ found = true
+ }
+ return value, found, nil
+}
+
+func booleanBuildSetting(info *debug.BuildInfo, key string, absentValue bool) (bool, error) {
+ value, found, err := optionalBuildSetting(info, key)
+ if err != nil {
+ return false, err
+ }
+ if !found {
+ return absentValue, nil
+ }
+ parsed, err := strconv.ParseBool(value)
+ if err != nil {
+ return false, fmt.Errorf("running executable has invalid %s build setting %q", key, value)
+ }
+ return parsed, nil
+}
+
+func validateProductionBuildProfile(
+ goVersion string,
+ goOS string,
+ goArch string,
+ goAMD64 string,
+ compiler string,
+ buildMode string,
+ cgoEnabled bool,
+ trimPath bool,
+) error {
+ switch {
+ case goVersion != ProductionGoVersion:
+ return softwareMismatch("go_version", ProductionGoVersion, goVersion)
+ case goOS != ProductionGOOS:
+ return softwareMismatch("goos", ProductionGOOS, goOS)
+ case goArch != ProductionGOARCH:
+ return softwareMismatch("goarch", ProductionGOARCH, goArch)
+ case goAMD64 != ProductionGOAMD64:
+ return softwareMismatch("goamd64", ProductionGOAMD64, goAMD64)
+ case compiler != ProductionCompiler:
+ return softwareMismatch("compiler", ProductionCompiler, compiler)
+ case buildMode != ProductionBuildMode:
+ return softwareMismatch("build_mode", ProductionBuildMode, buildMode)
+ case cgoEnabled:
+ return errors.New("running software cgo_enabled mismatch: production requires false")
+ case !trimPath:
+ return errors.New("running software trimpath mismatch: production requires true")
+ default:
+ return nil
+ }
+}
+
+func linkedModuleVersion(info *debug.BuildInfo, modulePath string) (string, error) {
+ var matched *debug.Module
+ for _, module := range info.Deps {
+ if module == nil || module.Path != modulePath {
+ continue
+ }
+ if matched != nil {
+ return "", fmt.Errorf("running executable contains duplicate %s modules", modulePath)
+ }
+ matched = module
+ }
+ if matched == nil {
+ return "", fmt.Errorf("running executable is missing linked module %s", modulePath)
+ }
+ if matched.Replace != nil {
+ return "", fmt.Errorf(
+ "running executable uses a replacement for %s; production requires the exact published module",
+ modulePath,
+ )
+ }
+ if strings.TrimSpace(matched.Version) == "" {
+ return "", fmt.Errorf("running executable has no version for linked module %s", modulePath)
+ }
+ return matched.Version, nil
+}
+
+func validateCleanGitRevision(revision string) error {
+ if len(revision) != gitRevisionHexLength {
+ return fmt.Errorf("must be exactly 40 lowercase hexadecimal characters, got %d", len(revision))
+ }
+ if revision != strings.ToLower(revision) {
+ return errors.New("must use lowercase hexadecimal")
+ }
+ if _, err := hex.DecodeString(revision); err != nil {
+ return errors.New("must be exactly 40 lowercase hexadecimal characters")
+ }
+ if strings.Trim(revision, "0") == "" {
+ return errors.New("must identify a real Git commit, not the all-zero object ID")
+ }
+ return nil
+}
+
+func softwareMismatch(field string, expected, actual any) error {
+ return fmt.Errorf("running software %s mismatch: expected %v, got %v", field, expected, actual)
+}
+
+func validateSoftwareMode(mode string) error {
+ switch mode {
+ case ModeProduction, ModeRehearsal:
+ return nil
+ default:
+ return fmt.Errorf("software binding mode %q, want %q or %q", mode, ModeProduction, ModeRehearsal)
+ }
+}
diff --git a/internal/mpcceremony/software_test.go b/internal/mpcceremony/software_test.go
new file mode 100644
index 00000000..f5ba0259
--- /dev/null
+++ b/internal/mpcceremony/software_test.go
@@ -0,0 +1,489 @@
+package mpcceremony
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "testing"
+
+ "proof-tool/internal/prover"
+)
+
+const testSourceCommit = "0123456789abcdef0123456789abcdef01234567"
+
+func TestRunningSoftwareBindingDerivesExactProcessIdentity(t *testing.T) {
+ executable := []byte("exact test ceremony executable")
+ source := newTestSoftwareSource(t, executable, testBuildInfo())
+
+ binding, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source)
+ if err != nil {
+ t.Fatalf("derive running binding: %v", err)
+ }
+ if binding.ProofToolVersion != prover.ProofToolVersion {
+ t.Fatalf("proof tool version = %q", binding.ProofToolVersion)
+ }
+ if binding.GnarkVersion != GnarkVersion {
+ t.Fatalf("gnark version = %q", binding.GnarkVersion)
+ }
+ if binding.GnarkCryptoVersion != GnarkCryptoVersion {
+ t.Fatalf("gnark-crypto version = %q", binding.GnarkCryptoVersion)
+ }
+ if binding.DrandVersion != DrandVersion {
+ t.Fatalf("drand version = %q", binding.DrandVersion)
+ }
+ if binding.GoVersion != ProductionGoVersion {
+ t.Fatalf("Go version = %q", binding.GoVersion)
+ }
+ if binding.GoOS != ProductionGOOS ||
+ binding.GoArch != ProductionGOARCH ||
+ binding.GoAMD64 != ProductionGOAMD64 ||
+ binding.Compiler != ProductionCompiler ||
+ binding.BuildMode != ProductionBuildMode ||
+ binding.CGOEnabled ||
+ !binding.TrimPath {
+ t.Fatalf("unexpected production build profile: %#v", binding)
+ }
+ if binding.SourceCommit != testSourceCommit || binding.SourceDirty {
+ t.Fatalf("source identity = %q, dirty %t", binding.SourceCommit, binding.SourceDirty)
+ }
+ if want := NewDigest(executable); binding.ToolBinary != want {
+ t.Fatalf("binary digest = %#v, want %#v", binding.ToolBinary, want)
+ }
+ if err := verifyRunningSoftware(binding, ModeProduction, source); err != nil {
+ t.Fatalf("verify exact running binding: %v", err)
+ }
+}
+
+func TestVerifyRunningSoftwareRejectsEveryBindingMismatch(t *testing.T) {
+ source := newTestSoftwareSource(t, []byte("ceremony executable"), testBuildInfo())
+ exact, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source)
+ if err != nil {
+ t.Fatalf("derive exact binding: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*SoftwareBinding)
+ }{
+ {
+ name: "proof tool version",
+ mutate: func(binding *SoftwareBinding) {
+ binding.ProofToolVersion = "9.9.9"
+ },
+ },
+ {
+ name: "gnark version",
+ mutate: func(binding *SoftwareBinding) {
+ binding.GnarkVersion = "v0.14.0"
+ },
+ },
+ {
+ name: "gnark-crypto version",
+ mutate: func(binding *SoftwareBinding) {
+ binding.GnarkCryptoVersion = "v0.19.0"
+ },
+ },
+ {
+ name: "drand version",
+ mutate: func(binding *SoftwareBinding) {
+ binding.DrandVersion = "v2.1.5"
+ },
+ },
+ {
+ name: "Go version",
+ mutate: func(binding *SoftwareBinding) {
+ binding.GoVersion = "go-other"
+ },
+ },
+ {
+ name: "source commit",
+ mutate: func(binding *SoftwareBinding) {
+ binding.SourceCommit = strings.Repeat("a", 40)
+ },
+ },
+ {
+ name: "dirty source",
+ mutate: func(binding *SoftwareBinding) {
+ binding.SourceDirty = true
+ },
+ },
+ {
+ name: "SHA-256",
+ mutate: func(binding *SoftwareBinding) {
+ binding.ToolBinary.SHA256 = NewDigest([]byte("other executable")).SHA256
+ },
+ },
+ {
+ name: "BLAKE2b-256",
+ mutate: func(binding *SoftwareBinding) {
+ binding.ToolBinary.Blake2b256 = NewDigest([]byte("other executable")).Blake2b256
+ },
+ },
+ {
+ name: "size",
+ mutate: func(binding *SoftwareBinding) {
+ binding.ToolBinary.Size++
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ changed := exact
+ test.mutate(&changed)
+ if err := verifyRunningSoftware(changed, ModeProduction, source); err == nil {
+ t.Fatal("expected mismatch rejection")
+ }
+ })
+ }
+}
+
+func TestRunningSoftwareBindingRejectsUnverifiableBuilds(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*debug.BuildInfo)
+ }{
+ {
+ name: "missing VCS revision",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings = append(info.Settings[:1], info.Settings[2:]...)
+ },
+ },
+ {
+ name: "duplicate VCS revision",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings = append(
+ info.Settings,
+ debug.BuildSetting{Key: "vcs.revision", Value: testSourceCommit},
+ )
+ },
+ },
+ {
+ name: "uppercase VCS revision",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings[1].Value = strings.ToUpper(testSourceCommit)
+ },
+ },
+ {
+ name: "short VCS revision",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings[1].Value = testSourceCommit[:39]
+ },
+ },
+ {
+ name: "zero VCS revision",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings[1].Value = strings.Repeat("0", 40)
+ },
+ },
+ {
+ name: "dirty checkout",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings[2].Value = "true"
+ },
+ },
+ {
+ name: "missing modified flag",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings = info.Settings[:2]
+ },
+ },
+ {
+ name: "non-Git VCS",
+ mutate: func(info *debug.BuildInfo) {
+ info.Settings[0].Value = "other"
+ },
+ },
+ {
+ name: "wrong gnark version",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps[0].Version = "v0.14.0"
+ },
+ },
+ {
+ name: "replaced gnark",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps[0].Replace = &debug.Module{
+ Path: "../gnark",
+ Version: GnarkVersion,
+ }
+ },
+ },
+ {
+ name: "missing gnark-crypto",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps = info.Deps[:1]
+ },
+ },
+ {
+ name: "duplicate gnark-crypto",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps = append(info.Deps, &debug.Module{
+ Path: gnarkCryptoModulePath,
+ Version: GnarkCryptoVersion,
+ })
+ },
+ },
+ {
+ name: "wrong drand version",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps[2].Version = "v2.1.5"
+ },
+ },
+ {
+ name: "replaced drand",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps[2].Replace = &debug.Module{
+ Path: "../drand",
+ Version: DrandVersion,
+ }
+ },
+ },
+ {
+ name: "missing drand",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps = info.Deps[:2]
+ },
+ },
+ {
+ name: "duplicate drand",
+ mutate: func(info *debug.BuildInfo) {
+ info.Deps = append(info.Deps, &debug.Module{
+ Path: drandModulePath,
+ Version: DrandVersion,
+ })
+ },
+ },
+ {
+ name: "linked Go version mismatch",
+ mutate: func(info *debug.BuildInfo) {
+ info.GoVersion = "go-other-version"
+ },
+ },
+ {
+ name: "unapproved Go version",
+ mutate: func(info *debug.BuildInfo) {
+ info.GoVersion = "go1.26.6"
+ },
+ },
+ {
+ name: "wrong operating system",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "GOOS", "darwin")
+ },
+ },
+ {
+ name: "wrong architecture",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "GOARCH", "arm64")
+ },
+ },
+ {
+ name: "wrong amd64 level",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "GOAMD64", "v3")
+ },
+ },
+ {
+ name: "CGO enabled",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "CGO_ENABLED", "1")
+ },
+ },
+ {
+ name: "trimpath disabled",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "-trimpath", "false")
+ },
+ },
+ {
+ name: "wrong compiler",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "-compiler", "gccgo")
+ },
+ },
+ {
+ name: "wrong build mode",
+ mutate: func(info *debug.BuildInfo) {
+ setTestBuildSetting(info, "-buildmode", "pie")
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ info := testBuildInfo()
+ test.mutate(info)
+ source := newTestSoftwareSource(t, []byte("ceremony executable"), info)
+ if _, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source); err == nil {
+ t.Fatal("expected unverifiable build rejection")
+ }
+ })
+ }
+}
+
+func TestRehearsalBindingRecordsAndVerifiesDirtyBuild(t *testing.T) {
+ info := testBuildInfo()
+ info.Settings[2].Value = "true"
+ source := newTestSoftwareSource(t, []byte("dirty rehearsal executable"), info)
+
+ binding, err := runningSoftwareBinding(prover.ProofToolVersion, ModeRehearsal, source)
+ if err != nil {
+ t.Fatalf("derive rehearsal binding: %v", err)
+ }
+ if !binding.SourceDirty {
+ t.Fatal("dirty rehearsal build was recorded as clean")
+ }
+ if err := verifyRunningSoftware(binding, ModeRehearsal, source); err != nil {
+ t.Fatalf("verify dirty rehearsal binding: %v", err)
+ }
+
+ claimedClean := binding
+ claimedClean.SourceDirty = false
+ if err := verifyRunningSoftware(claimedClean, ModeRehearsal, source); err == nil {
+ t.Fatal("dirty rehearsal build matched a clean software claim")
+ }
+ if _, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source); err == nil {
+ t.Fatal("dirty build was accepted for production")
+ }
+}
+
+func TestRunningSoftwareBindingRejectsWrongCompiledVersionAndBadExecutable(t *testing.T) {
+ source := newTestSoftwareSource(t, []byte("ceremony executable"), testBuildInfo())
+ if _, err := runningSoftwareBinding(prover.ProofToolVersion, "unknown", source); err == nil {
+ t.Fatal("expected ceremony mode rejection")
+ }
+ if _, err := runningSoftwareBinding("operator-supplied", ModeProduction, source); err == nil {
+ t.Fatal("expected proof tool version rejection")
+ }
+
+ source.executable = func() (string, error) {
+ return "", errors.New("unavailable")
+ }
+ if _, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source); err == nil {
+ t.Fatal("expected executable resolution rejection")
+ }
+
+ emptyPath := filepath.Join(t.TempDir(), "empty")
+ if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ source.executable = func() (string, error) {
+ return emptyPath, nil
+ }
+ if _, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, source); err == nil {
+ t.Fatal("expected empty executable rejection")
+ }
+}
+
+func TestProductionExecutableIdentityUsesKernelHeldLinuxImage(t *testing.T) {
+ if runtime.GOOS != ProductionGOOS {
+ t.Skip("production executable identity is Linux-only")
+ }
+ path, err := productionExecutablePath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/proc/self/exe" {
+ t.Fatalf("production executable path = %q", path)
+ }
+
+ original := []byte("original executable image")
+ replacement := []byte("same-uid path replacement")
+ directory := t.TempDir()
+ diskPath := filepath.Join(directory, "mpc-ceremony")
+ if err := os.WriteFile(diskPath, original, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ file, err := os.Open(diskPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ if err := os.Rename(diskPath, diskPath+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(diskPath, replacement, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ kernelHeldPath := "/proc/self/fd/" + strconv.FormatUint(uint64(file.Fd()), 10)
+ actual, err := digestRunningExecutable(kernelHeldPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if expected := NewDigest(original); actual != expected {
+ t.Fatalf("kernel-held executable digest = %#v, want original %#v", actual, expected)
+ }
+ if actual == NewDigest(replacement) {
+ t.Fatal("kernel-held executable digest followed the replaced pathname")
+ }
+}
+
+func testBuildInfo() *debug.BuildInfo {
+ return &debug.BuildInfo{
+ GoVersion: ProductionGoVersion,
+ Settings: []debug.BuildSetting{
+ {Key: "vcs", Value: "git"},
+ {Key: "vcs.revision", Value: testSourceCommit},
+ {Key: "vcs.modified", Value: "false"},
+ {Key: "-buildmode", Value: ProductionBuildMode},
+ {Key: "-compiler", Value: ProductionCompiler},
+ {Key: "-trimpath", Value: "true"},
+ {Key: "CGO_ENABLED", Value: "0"},
+ {Key: "GOARCH", Value: ProductionGOARCH},
+ {Key: "GOOS", Value: ProductionGOOS},
+ {Key: "GOAMD64", Value: ProductionGOAMD64},
+ },
+ Deps: []*debug.Module{
+ {
+ Path: gnarkModulePath,
+ Version: GnarkVersion,
+ },
+ {
+ Path: gnarkCryptoModulePath,
+ Version: GnarkCryptoVersion,
+ },
+ {
+ Path: drandModulePath,
+ Version: DrandVersion,
+ },
+ },
+ }
+}
+
+func newTestSoftwareSource(
+ t *testing.T,
+ executable []byte,
+ info *debug.BuildInfo,
+) runningSoftwareSource {
+ t.Helper()
+ executablePath := filepath.Join(t.TempDir(), "mpc-ceremony")
+ if err := os.WriteFile(executablePath, executable, 0o700); err != nil {
+ t.Fatalf("write test executable: %v", err)
+ }
+ return runningSoftwareSource{
+ executable: func() (string, error) {
+ return executablePath, nil
+ },
+ readBuildInfo: func() (*debug.BuildInfo, bool) {
+ return info, true
+ },
+ runtimeVersion: func() string {
+ return info.GoVersion
+ },
+ }
+}
+
+func setTestBuildSetting(info *debug.BuildInfo, key, value string) {
+ for index := range info.Settings {
+ if info.Settings[index].Key == key {
+ info.Settings[index].Value = value
+ return
+ }
+ }
+ info.Settings = append(info.Settings, debug.BuildSetting{Key: key, Value: value})
+}
diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go
new file mode 100644
index 00000000..90c5f63c
--- /dev/null
+++ b/internal/mpcceremony/testdata/workflowhelper/main.go
@@ -0,0 +1,844 @@
+package main
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math/big"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/consensys/gnark-crypto/ecc"
+ "github.com/consensys/gnark/backend/groth16"
+ cs "github.com/consensys/gnark/constraint/bls12-381"
+ "github.com/consensys/gnark/frontend"
+ "github.com/consensys/gnark/frontend/cs/r1cs"
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/mpcceremony"
+ "proof-tool/internal/prover"
+)
+
+const (
+ quicknetRound42 = `{"round":42,"randomness":"8ada64bae5c6c0f5540a6a13af56e663240edfbd2c76ac6a8f27671eb7259ce3","signature":"95a9f9f5b231b7714de1553105d8ffdf3dcda24cfdb1e689319bccf79a9c8ce430a91b811fbfaf763900bc998b5d686a"}`
+ quicknetRound43 = `{"round":43,"randomness":"c8f7c61c7024f8b45ffbf5be58b1f112a26be93c26f5461af9f8522233705dbb","signature":"a96a579010b3d2261959104b29b5b46685b3a6b6f6aae7304ef72d44fe7d44e667bcdd500935d7deb58a2b4d89419ea6"}`
+)
+
+type tinyCommittedCircuit struct {
+ Public frontend.Variable `gnark:",public"`
+ Secret frontend.Variable
+}
+
+func (c *tinyCommittedCircuit) Define(api frontend.API) error {
+ committer, ok := api.(frontend.Committer)
+ if !ok {
+ return errors.New("compiler does not implement frontend.Committer")
+ }
+ commitment, err := committer.Commit(c.Secret)
+ if err != nil {
+ return err
+ }
+ api.AssertIsDifferent(commitment, 0)
+ api.AssertIsEqual(c.Public, c.Secret)
+ return nil
+}
+
+func main() {
+ if len(os.Args) != 2 && len(os.Args) != 3 {
+ fmt.Fprintln(os.Stderr, "usage: workflowhelper OUTPUT_ROOT [OPERATIONAL_EVIDENCE_HELPER]")
+ os.Exit(2)
+ }
+ operationalEvidenceHelper := ""
+ if len(os.Args) == 3 {
+ operationalEvidenceHelper = os.Args[2]
+ }
+ if err := run(os.Args[1], operationalEvidenceHelper); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(outputRoot, operationalEvidenceHelper string) error {
+ compiled, err := frontend.Compile(
+ ecc.BLS12_381.ScalarField(),
+ r1cs.NewBuilder,
+ &tinyCommittedCircuit{},
+ )
+ if err != nil {
+ return fmt.Errorf("compile tiny circuit: %w", err)
+ }
+ native, ok := compiled.(*cs.R1CS)
+ if !ok {
+ return fmt.Errorf("compiled circuit type %T, want *bls12-381.R1CS", compiled)
+ }
+ circuit, err := mpcceremony.BindDestinationV2R1CS(native)
+ if err != nil {
+ return fmt.Errorf("bind tiny circuit: %w", err)
+ }
+ software, err := mpcceremony.RunningSoftwareBindingForMode(
+ prover.ProofToolVersion,
+ mpcceremony.ModeRehearsal,
+ )
+ if err != nil {
+ return fmt.Errorf("bind helper executable: %w", err)
+ }
+
+ if err := os.Mkdir(outputRoot, 0o700); err != nil {
+ return err
+ }
+ keyDir := filepath.Join(outputRoot, "identity-keys")
+ candidateRoot := filepath.Join(outputRoot, "candidates")
+ for _, dir := range []string{keyDir, candidateRoot} {
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ return err
+ }
+ }
+
+ privateKey := func(fill byte) ed25519.PrivateKey {
+ return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{fill}, ed25519.SeedSize))
+ }
+ coordinatorPrivate := privateKey(0x81)
+ releasePrivate := privateKey(0x82)
+ auditor1Private := privateKey(0x83)
+ auditor2Private := privateKey(0x84)
+ participant1Private := privateKey(0x91)
+ participant2Private := privateKey(0x92)
+ identity := func(id string, key ed25519.PrivateKey) (mpcceremony.Identity, error) {
+ return mpcceremony.NewIdentity(
+ id,
+ "Integration "+id,
+ id+"-key",
+ key.Public().(ed25519.PublicKey),
+ )
+ }
+ coordinator, err := identity("coordinator", coordinatorPrivate)
+ if err != nil {
+ return err
+ }
+ releaseSigner, err := identity("release-signer", releasePrivate)
+ if err != nil {
+ return err
+ }
+ auditor1, err := identity("auditor-01", auditor1Private)
+ if err != nil {
+ return err
+ }
+ auditor2, err := identity("auditor-02", auditor2Private)
+ if err != nil {
+ return err
+ }
+ participant1, err := identity("participant-01", participant1Private)
+ if err != nil {
+ return err
+ }
+ participant2, err := identity("participant-02", participant2Private)
+ if err != nil {
+ return err
+ }
+ writePrivateKey := func(name string, key ed25519.PrivateKey) (string, error) {
+ path := filepath.Join(keyDir, name+".ed25519.private.hex")
+ err := os.WriteFile(path, []byte(hex.EncodeToString(key.Seed())+"\n"), 0o600)
+ return path, err
+ }
+ coordinatorKeyPath, err := writePrivateKey("coordinator", coordinatorPrivate)
+ if err != nil {
+ return err
+ }
+ participant1KeyPath, err := writePrivateKey("participant-01", participant1Private)
+ if err != nil {
+ return err
+ }
+ participant2KeyPath, err := writePrivateKey("participant-02", participant2Private)
+ if err != nil {
+ return err
+ }
+ releaseKeyPath, err := writePrivateKey("release-signer", releasePrivate)
+ if err != nil {
+ return err
+ }
+ auditor1KeyPath, err := writePrivateKey("auditor-01", auditor1Private)
+ if err != nil {
+ return err
+ }
+ auditor2KeyPath, err := writePrivateKey("auditor-02", auditor2Private)
+ if err != nil {
+ return err
+ }
+ for _, external := range []struct {
+ name string
+ fill byte
+ }{
+ {name: "witness-01", fill: 0xa1},
+ {name: "witness-02", fill: 0xa2},
+ {name: "mirror-01", fill: 0xb1},
+ {name: "mirror-02", fill: 0xb2},
+ } {
+ if _, err := writePrivateKey(external.name, privateKey(external.fill)); err != nil {
+ return err
+ }
+ }
+ trustedCoordinatorPath := filepath.Join(keyDir, "trusted-coordinator.ed25519.public.hex")
+ if err := os.WriteFile(
+ trustedCoordinatorPath,
+ []byte(hex.EncodeToString(coordinatorPrivate.Public().(ed25519.PublicKey))+"\n"),
+ 0o600,
+ ); err != nil {
+ return err
+ }
+
+ ceremonyRoot := filepath.Join(outputRoot, "ceremony")
+ initialized, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{
+ RootDir: ceremonyRoot,
+ Circuit: circuit,
+ Definition: mpcceremony.DefinitionOptions{
+ Mode: mpcceremony.ModeRehearsal,
+ CreatedAt: "2023-08-23T15:00:00Z",
+ SessionNonceHex: "abababababababababababababababababababababababababababababababab",
+ Software: software,
+ Coordinator: coordinator,
+ ReleaseSigner: releaseSigner,
+ Auditors: []mpcceremony.Identity{auditor1, auditor2},
+ Roster: []mpcceremony.Participant{
+ {Identity: participant1},
+ {Identity: participant2},
+ },
+ Phase1Policy: mpcceremony.PhasePolicy{
+ Participants: []string{"participant-01", "participant-02"},
+ Minimum: 2,
+ },
+ Phase2Policy: mpcceremony.PhasePolicy{
+ Participants: []string{"participant-01", "participant-02"},
+ Minimum: 2,
+ },
+ 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: 1,
+ FutureRoundRequired: true,
+ },
+ },
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ })
+ if err != nil {
+ return fmt.Errorf("initialize ceremony: %w", err)
+ }
+ trust := mpcceremony.TrustPaths{
+ DefinitionPath: initialized.DefinitionPath,
+ DefinitionSignaturePath: initialized.DefinitionSignaturePath,
+ CoordinatorPublicKeyPath: trustedCoordinatorPath,
+ }
+ trusted, err := mpcceremony.LoadSignedDefinition(trust)
+ if err != nil {
+ return err
+ }
+ writeHistoricalClose := func(
+ phase mpcceremony.Phase,
+ chain mpcceremony.Chain,
+ round uint64,
+ closedAt string,
+ ) (mpcceremony.ClosePhaseFilesResult, error) {
+ roundTime, err := mpcceremony.QuicknetRoundTime(round)
+ if err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ headID, _ := chain.HeadRecordID()
+ headPayload, _ := chain.HeadPayload()
+ participants, _ := chain.ParticipantIDs()
+ closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: phase,
+ PhaseID: chain.PhaseID,
+ FinalIndex: uint8(len(chain.Records)),
+ FinalPayload: headPayload,
+ ChainHeadID: headID,
+ AcceptedParticipants: participants,
+ BeaconProvider: trusted.Definition.BeaconPolicy.Provider,
+ BeaconNetwork: trusted.Definition.BeaconPolicy.Network,
+ BeaconRound: round,
+ BeaconNotBefore: roundTime.Format(time.RFC3339Nano),
+ ClosedAt: closedAt,
+ CoordinatorID: trusted.Definition.Coordinator.ID,
+ CoordinatorKeyID: trusted.Definition.Coordinator.KeyID,
+ })
+ if err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ if err := mpcceremony.ValidateClose(trusted.Definition, chain, closeRecord); err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ recordBytes, signatureBytes, err := mpcceremony.SignRecord(
+ closeRecord,
+ trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ )
+ if err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ closeDir := filepath.Join(ceremonyRoot, string(phase), "closure")
+ if err := os.Mkdir(closeDir, 0o700); err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ recordPath := filepath.Join(closeDir, "record.json")
+ signaturePath := filepath.Join(closeDir, "record.sig")
+ if err := os.WriteFile(signaturePath, signatureBytes, 0o600); err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ if err := os.WriteFile(recordPath, recordBytes, 0o600); err != nil {
+ return mpcceremony.ClosePhaseFilesResult{}, err
+ }
+ return mpcceremony.ClosePhaseFilesResult{
+ Close: closeRecord,
+ ClosePath: recordPath,
+ SignaturePath: signaturePath,
+ }, nil
+ }
+ environment := mpcceremony.ContributionEnvironment{
+ OS: "linux",
+ Architecture: "amd64",
+ EntropySource: "operating-system-csprng",
+ SwapDisabled: true,
+ CrashDumpsDisabled: true,
+ TelemetryDisabled: true,
+ EphemeralEnvironment: true,
+ EphemeralDestructionRequired: true,
+ }
+ participantKeyPaths := []string{participant1KeyPath, participant2KeyPath}
+ contributeAndAccept := func(
+ phase mpcceremony.Phase,
+ index int,
+ chainPaths mpcceremony.PhaseTranscriptPaths,
+ phase1SealPath string,
+ phase1SealSignaturePath string,
+ contributedAt string,
+ destroyedAt string,
+ acceptedAt string,
+ ) (mpcceremony.PhaseTranscriptPaths, error) {
+ participantID := fmt.Sprintf("participant-%02d", index)
+ candidateDir := filepath.Join(
+ candidateRoot,
+ fmt.Sprintf("%s-%s", phase, participantID),
+ )
+ if _, err := mpcceremony.CreateContributionCandidate(
+ mpcceremony.ContributionFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: phase,
+ Transcript: chainPaths,
+ Phase1SealPath: phase1SealPath,
+ Phase1SealSignaturePath: phase1SealSignaturePath,
+ ParticipantID: participantID,
+ ParticipantPrivateKeyPath: participantKeyPaths[index-1],
+ Environment: environment,
+ ContributedAt: contributedAt,
+ CandidateDir: candidateDir,
+ },
+ ); err != nil {
+ return mpcceremony.PhaseTranscriptPaths{}, err
+ }
+ if _, err := mpcceremony.CreateErasureAttestationFiles(
+ mpcceremony.CreateErasureAttestationFilesOptions{
+ Trust: trust,
+ ParticipantID: participantID,
+ ParticipantPrivateKeyPath: participantKeyPaths[index-1],
+ CandidateDir: candidateDir,
+ DestroyedAt: destroyedAt,
+ },
+ ); err != nil {
+ return mpcceremony.PhaseTranscriptPaths{}, err
+ }
+ accepted, err := mpcceremony.VerifyAndAcceptContribution(
+ mpcceremony.AcceptContributionFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: phase,
+ Transcript: chainPaths,
+ Phase1SealPath: phase1SealPath,
+ Phase1SealSignaturePath: phase1SealSignaturePath,
+ CandidateDir: candidateDir,
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ AcceptedAt: acceptedAt,
+ },
+ )
+ if err != nil {
+ return mpcceremony.PhaseTranscriptPaths{}, err
+ }
+ return mpcceremony.PhaseTranscriptPaths{
+ RootDir: ceremonyRoot,
+ ChainPath: accepted.ChainPath,
+ ChainSignaturePath: accepted.ChainSignaturePath,
+ }, nil
+ }
+
+ phase1Paths := mpcceremony.PhaseTranscriptPaths{
+ RootDir: ceremonyRoot,
+ ChainPath: initialized.Phase1ChainPath,
+ ChainSignaturePath: initialized.Phase1ChainSignaturePath,
+ }
+ phase1Paths, err = contributeAndAccept(
+ mpcceremony.Phase1,
+ 1,
+ phase1Paths,
+ "",
+ "",
+ "2023-08-23T15:01:00Z",
+ "2023-08-23T15:01:01Z",
+ "2023-08-23T15:02:00Z",
+ )
+ if err != nil {
+ return fmt.Errorf("Phase 1 participant 1: %w", err)
+ }
+ phase1Paths, err = contributeAndAccept(
+ mpcceremony.Phase1,
+ 2,
+ phase1Paths,
+ "",
+ "",
+ "2023-08-23T15:03:00Z",
+ "2023-08-23T15:03:01Z",
+ "2023-08-23T15:04:00Z",
+ )
+ if err != nil {
+ return fmt.Errorf("Phase 1 participant 2: %w", err)
+ }
+ phase1Chain, err := mpcceremony.LoadReplayPhase1Files(trusted, circuit, phase1Paths)
+ if err != nil {
+ return fmt.Errorf("replay Phase 1 before historical test closure: %w", err)
+ }
+ // Historical drand fixtures exercise downstream replay and beacon binding.
+ // Production closure timing is tested inside package mpcceremony and is
+ // never bypassed by the participant-facing CLI.
+ phase1Close, err := writeHistoricalClose(
+ mpcceremony.Phase1,
+ phase1Chain,
+ 42,
+ "2023-08-23T15:05:00Z",
+ )
+ if err != nil {
+ return fmt.Errorf("close Phase 1: %w", err)
+ }
+ round42Path := filepath.Join(outputRoot, "quicknet-round-42.json")
+ if err := os.WriteFile(round42Path, []byte(quicknetRound42), 0o600); err != nil {
+ return err
+ }
+ phase1Beacon, err := mpcceremony.RecordBeaconFiles(mpcceremony.RecordBeaconFilesOptions{
+ Trust: trust,
+ TranscriptRoot: ceremonyRoot,
+ Phase: mpcceremony.Phase1,
+ ClosePath: phase1Close.ClosePath,
+ CloseSignaturePath: phase1Close.SignaturePath,
+ RawResponsePath: round42Path,
+ PublishedAt: "2023-08-23T15:11:30Z",
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ })
+ if err != nil {
+ return fmt.Errorf("record Phase 1 beacon: %w", err)
+ }
+ phase1Seal, err := mpcceremony.SealPhase1Files(mpcceremony.SealPhase1FilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ TranscriptRoot: ceremonyRoot,
+ ClosePath: phase1Close.ClosePath,
+ CloseSignaturePath: phase1Close.SignaturePath,
+ BeaconPath: phase1Beacon.BeaconPath,
+ BeaconSignaturePath: phase1Beacon.SignaturePath,
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ OutputDir: filepath.Join(ceremonyRoot, "phase1", "sealed"),
+ })
+ if err != nil {
+ return fmt.Errorf("seal Phase 1: %w", err)
+ }
+
+ phase2Initialized, err := mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ TranscriptRoot: ceremonyRoot,
+ Phase1SealPath: phase1Seal.SealPath,
+ Phase1SealSignaturePath: phase1Seal.SignaturePath,
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ OutputDir: filepath.Join(ceremonyRoot, "phase2"),
+ })
+ if err != nil {
+ return fmt.Errorf("initialize Phase 2: %w", err)
+ }
+ phase2Paths := mpcceremony.PhaseTranscriptPaths{
+ RootDir: ceremonyRoot,
+ ChainPath: phase2Initialized.ChainPath,
+ ChainSignaturePath: phase2Initialized.ChainSignaturePath,
+ }
+ phase2Paths, err = contributeAndAccept(
+ mpcceremony.Phase2,
+ 1,
+ phase2Paths,
+ phase1Seal.SealPath,
+ phase1Seal.SignaturePath,
+ "2023-08-23T15:11:30.1Z",
+ "2023-08-23T15:11:30.2Z",
+ "2023-08-23T15:11:30.3Z",
+ )
+ if err != nil {
+ return fmt.Errorf("Phase 2 participant 1: %w", err)
+ }
+ phase2Paths, err = contributeAndAccept(
+ mpcceremony.Phase2,
+ 2,
+ phase2Paths,
+ phase1Seal.SealPath,
+ phase1Seal.SignaturePath,
+ "2023-08-23T15:11:30.4Z",
+ "2023-08-23T15:11:30.5Z",
+ "2023-08-23T15:11:30.6Z",
+ )
+ if err != nil {
+ return fmt.Errorf("Phase 2 participant 2: %w", err)
+ }
+ _, err = mpcceremony.ClosePhaseFiles(mpcceremony.ClosePhaseFilesOptions{
+ Trust: trust,
+ Circuit: circuit,
+ Phase: mpcceremony.Phase2,
+ Transcript: phase2Paths,
+ Phase1SealPath: phase1Seal.SealPath,
+ Phase1SealSignaturePath: phase1Seal.SignaturePath,
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ BeaconRound: 42,
+ })
+ if err == nil || !strings.Contains(err.Error(), "reuses the authenticated phase1 beacon round") {
+ return fmt.Errorf("reused Phase 1 beacon round error = %v, want distinct-round rejection", err)
+ }
+ for _, path := range []string{
+ filepath.Join(ceremonyRoot, "phase2", "closure"),
+ } {
+ if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) {
+ return fmt.Errorf("rejected reused-round close published %q: %v", path, statErr)
+ }
+ }
+ commons, _, err := mpcceremony.ReadCommonsFile(
+ phase1Seal.CommonsPath,
+ mpcceremony.CommonsShape{DomainN: circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ return fmt.Errorf("read sealed commons for historical Phase 2 closure: %w", err)
+ }
+ phase2Chain, err := mpcceremony.LoadReplayPhase2Files(
+ trusted,
+ circuit,
+ commons,
+ phase1Seal.Seal,
+ phase2Paths,
+ )
+ if err != nil {
+ return fmt.Errorf("replay Phase 2 before historical test closure: %w", err)
+ }
+ phase2Close, err := writeHistoricalClose(
+ mpcceremony.Phase2,
+ phase2Chain,
+ 43,
+ "2023-08-23T15:11:30.7Z",
+ )
+ if err != nil {
+ return fmt.Errorf("close Phase 2: %w", err)
+ }
+ round43Path := filepath.Join(outputRoot, "quicknet-round-43.json")
+ if err := os.WriteFile(round43Path, []byte(quicknetRound43), 0o600); err != nil {
+ return err
+ }
+ phase2Beacon, err := mpcceremony.RecordBeaconFiles(mpcceremony.RecordBeaconFilesOptions{
+ Trust: trust,
+ TranscriptRoot: ceremonyRoot,
+ Phase: mpcceremony.Phase2,
+ ClosePath: phase2Close.ClosePath,
+ CloseSignaturePath: phase2Close.SignaturePath,
+ RawResponsePath: round43Path,
+ PublishedAt: "2023-08-23T15:11:33Z",
+ CoordinatorPrivateKeyPath: coordinatorKeyPath,
+ })
+ if err != nil {
+ return fmt.Errorf("record Phase 2 beacon: %w", err)
+ }
+ if operationalEvidenceHelper == "" {
+ return nil
+ }
+
+ replay := mpcceremony.ReplayPaths{
+ TranscriptRoot: ceremonyRoot,
+ CoordinatorPublicKeyHex: hex.EncodeToString(coordinatorPrivate.Public().(ed25519.PublicKey)),
+ DefinitionPath: initialized.DefinitionPath,
+ DefinitionSignaturePath: initialized.DefinitionSignaturePath,
+ Phase1ChainPath: phase1Paths.ChainPath,
+ Phase1ChainSignaturePath: phase1Paths.ChainSignaturePath,
+ Phase1ClosePath: phase1Close.ClosePath,
+ Phase1CloseSignaturePath: phase1Close.SignaturePath,
+ Phase1BeaconPath: phase1Beacon.BeaconPath,
+ Phase1BeaconSignaturePath: phase1Beacon.SignaturePath,
+ Phase1SealPath: phase1Seal.SealPath,
+ Phase1SealSignaturePath: phase1Seal.SignaturePath,
+ Phase2ChainPath: phase2Paths.ChainPath,
+ Phase2ChainSignaturePath: phase2Paths.ChainSignaturePath,
+ Phase2ClosePath: phase2Close.ClosePath,
+ Phase2CloseSignaturePath: phase2Close.SignaturePath,
+ Phase2BeaconPath: phase2Beacon.BeaconPath,
+ Phase2BeaconSignaturePath: phase2Beacon.SignaturePath,
+ }
+ preliminaryDir := filepath.Join(outputRoot, "preliminary")
+ if _, err := mpcceremony.PrepareFinalization(mpcceremony.PrepareFinalizationOptions{
+ Replay: replay,
+ Circuit: circuit,
+ OutDir: preliminaryDir,
+ CoordinatorSigningKey: coordinatorKeyPath,
+ PreparedAt: mustUTC("2023-08-23T15:11:34Z"),
+ }); err != nil {
+ return fmt.Errorf("prepare finalization: %w", err)
+ }
+ if _, err := mpcceremony.VerifyPreliminaryFinalKeys(
+ preliminaryDir,
+ replay.CoordinatorPublicKeyHex,
+ ); err != nil {
+ return fmt.Errorf("verify preliminary final keys: %w", err)
+ }
+ publicEvidencePath := filepath.Join(outputRoot, "public-finalization-evidence.json")
+ if err := writeTinyPublicEvidence(
+ publicEvidencePath,
+ initialized.Definition.CeremonyID,
+ circuit,
+ preliminaryDir,
+ ); err != nil {
+ return fmt.Errorf("generate separate public evidence: %w", err)
+ }
+ candidateDir := filepath.Join(outputRoot, "candidate")
+ if _, err := mpcceremony.Finalize(mpcceremony.FinalizeOptions{
+ Replay: replay,
+ Circuit: circuit,
+ OutDir: candidateDir,
+ CoordinatorSigningKey: coordinatorKeyPath,
+ PublicEvidencePath: publicEvidencePath,
+ FinalizedAt: mustUTC("2023-08-23T15:11:35Z"),
+ }); err != nil {
+ return fmt.Errorf("complete finalization: %w", err)
+ }
+
+ auditDir := filepath.Join(outputRoot, "audits")
+ if err := os.Mkdir(auditDir, 0o700); err != nil {
+ return err
+ }
+ audits := make([]mpcceremony.AuditArtifact, 0, 2)
+ for index, input := range []struct {
+ id string
+ keyPath string
+ at string
+ }{
+ {id: auditor1.ID, keyPath: auditor1KeyPath, at: "2023-08-23T15:11:36Z"},
+ {id: auditor2.ID, keyPath: auditor2KeyPath, at: "2023-08-23T15:11:37Z"},
+ } {
+ recordPath := filepath.Join(auditDir, fmt.Sprintf("audit-%02d.json", index+1))
+ signaturePath := filepath.Join(auditDir, fmt.Sprintf("audit-%02d.sig", index+1))
+ if _, err := mpcceremony.Audit(mpcceremony.AuditOptions{
+ Replay: replay,
+ Circuit: circuit,
+ CandidateDir: candidateDir,
+ AuditorID: input.id,
+ AuditorSigningKey: input.keyPath,
+ OutPath: recordPath,
+ SignatureOutPath: signaturePath,
+ AuditedAt: mustUTC(input.at),
+ }); err != nil {
+ return fmt.Errorf("audit %s: %w", input.id, err)
+ }
+ audits = append(audits, mpcceremony.AuditArtifact{
+ RecordPath: recordPath,
+ SignaturePath: signaturePath,
+ LogicalName: fmt.Sprintf("audit-%02d", index+1),
+ })
+ }
+
+ phase1Relays, err := writeRelayFixture(
+ outputRoot,
+ "phase1-relays",
+ []byte(quicknetRound42),
+ "2023-08-23T15:11:30Z",
+ )
+ if err != nil {
+ return err
+ }
+ phase2Relays, err := writeRelayFixture(
+ outputRoot,
+ "phase2-relays",
+ []byte(quicknetRound43),
+ "2023-08-23T15:11:33Z",
+ )
+ if err != nil {
+ return err
+ }
+ operationalCommand := exec.Command(
+ operationalEvidenceHelper,
+ "--transcript-root", ceremonyRoot,
+ "--keys-dir", keyDir,
+ "--coordinator-public-key-file", trustedCoordinatorPath,
+ "--phase1-relays", phase1Relays,
+ "--phase2-relays", phase2Relays,
+ "--assembled-at", "2023-08-23T15:11:35Z",
+ "--out-dir", filepath.Join(ceremonyRoot, "operational"),
+ )
+ if output, err := operationalCommand.CombinedOutput(); err != nil {
+ return fmt.Errorf("generate operational evidence: %w\n%s", err, output)
+ }
+
+ releaseDir := filepath.Join(outputRoot, "release")
+ if _, err := mpcceremony.SignRelease(mpcceremony.SignReleaseOptions{
+ DefinitionPath: initialized.DefinitionPath,
+ DefinitionSignaturePath: initialized.DefinitionSignaturePath,
+ CoordinatorPublicKeyHex: replay.CoordinatorPublicKeyHex,
+ CandidateDir: candidateDir,
+ ReleaseDir: releaseDir,
+ Audits: audits,
+ OperationalEvidenceRoot: ceremonyRoot,
+ OperationalBundlePath: filepath.Join(ceremonyRoot, mpcceremony.OperationalEvidenceBundleFile),
+ OperationalSignaturePath: filepath.Join(ceremonyRoot, mpcceremony.OperationalEvidenceSignatureFile),
+ ReleaseSigningKey: releaseKeyPath,
+ SignatureKeyID: releaseSigner.KeyID,
+ ReleasedAt: mustUTC("2023-08-23T15:11:38Z"),
+ }); err != nil {
+ return fmt.Errorf("sign release: %w", err)
+ }
+ if _, err := mpcceremony.VerifyRelease(mpcceremony.VerifyReleaseOptions{
+ DefinitionPath: initialized.DefinitionPath,
+ DefinitionSignaturePath: initialized.DefinitionSignaturePath,
+ CoordinatorPublicKeyHex: replay.CoordinatorPublicKeyHex,
+ KeysDir: releaseDir,
+ TrustedPublicKeyHex: releaseSigner.Ed25519PublicKeyHex,
+ ExpectedSignatureKeyID: releaseSigner.KeyID,
+ RequireProvingKey: true,
+ }); err != nil {
+ return fmt.Errorf("verify signed release: %w", err)
+ }
+ return nil
+}
+
+func mustUTC(value string) time.Time {
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ panic(err)
+ }
+ return parsed.UTC()
+}
+
+func writeTinyPublicEvidence(
+ path string,
+ ceremonyID string,
+ circuit *mpcceremony.CompiledCircuit,
+ preliminaryDir string,
+) error {
+ credential, err := hex.DecodeString(mpcceremony.GoldenPublicCredentialHex)
+ if err != nil {
+ return err
+ }
+ destination, err := hex.DecodeString(mpcceremony.GoldenPublicDestinationHex)
+ if err != nil {
+ return err
+ }
+ preimage := append([]byte(mpcceremony.DestinationPublicDomain), credential...)
+ preimage = append(preimage, destination...)
+ digest := blake2b.Sum256(preimage)
+ reversed := bytes.Clone(digest[:])
+ for left, right := 0, len(reversed)-1; left < right; left, right = left+1, right-1 {
+ reversed[left], reversed[right] = reversed[right], reversed[left]
+ }
+ scalar := new(big.Int).SetBytes(reversed)
+ scalar.Mod(scalar, ecc.BLS12_381.ScalarField())
+ assignment := &tinyCommittedCircuit{Public: scalar, Secret: scalar}
+ fullWitness, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField())
+ if err != nil {
+ return err
+ }
+ pk, err := prover.LoadPK(filepath.Join(preliminaryDir, mpcceremony.NativeProvingKeyFile))
+ if err != nil {
+ return err
+ }
+ proof, err := groth16.Prove(circuit.R1CS, pk, fullWitness)
+ if err != nil {
+ return err
+ }
+ cardanoProof, format, err := prover.SerializeCardanoProof(proof)
+ if err != nil {
+ return err
+ }
+ cardanoVK, err := os.ReadFile(filepath.Join(preliminaryDir, mpcceremony.CardanoVKBytesFile))
+ if err != nil {
+ return err
+ }
+ evidence := mpcceremony.PublicFinalizationEvidence{
+ Schema: mpcceremony.PublicEvidenceSchema,
+ CeremonyID: ceremonyID,
+ Fixture: mpcceremony.PublicEvidenceFixture,
+ CredentialHex: hex.EncodeToString(credential),
+ DestinationHex: hex.EncodeToString(destination),
+ PublicInputDigestHex: hex.EncodeToString(digest[:]),
+ CardanoProofHex: hex.EncodeToString(cardanoProof),
+ CardanoProofFormat: format,
+ CardanoProofRawDigest: mpcceremony.NewDigest(cardanoProof),
+ CardanoVerifyingKey: mpcceremony.ArtifactRef{
+ Name: mpcceremony.CardanoVKBytesFile,
+ Digest: mpcceremony.NewDigest(cardanoVK),
+ },
+ }
+ canonical, err := mpcceremony.MarshalCanonical(evidence)
+ if err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(canonical); err != nil {
+ _ = file.Close()
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ _ = file.Close()
+ return err
+ }
+ return file.Close()
+}
+
+func writeRelayFixture(root, name string, raw []byte, retrievedAt string) (string, error) {
+ dir := filepath.Join(root, name)
+ if err := os.Mkdir(dir, 0o700); err != nil {
+ return "", err
+ }
+ rows := []string{
+ "relay_id\toperator_id\tendpoint_sha256\tretrieved_at\tfilename",
+ }
+ for index := 1; index <= 3; index++ {
+ filename := fmt.Sprintf("relay-%02d.json", index)
+ if err := os.WriteFile(filepath.Join(dir, filename), raw, 0o600); err != nil {
+ return "", err
+ }
+ endpoint := "sha256:" + strings.Repeat(fmt.Sprintf("%x", index), 64)
+ rows = append(rows, fmt.Sprintf(
+ "relay-%02d\toperator-%02d\t%s\t%s\t%s",
+ index,
+ index,
+ endpoint,
+ retrievedAt,
+ filename,
+ ))
+ }
+ if err := os.WriteFile(
+ filepath.Join(dir, "relays.tsv"),
+ []byte(strings.Join(rows, "\n")+"\n"),
+ 0o600,
+ ); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go
new file mode 100644
index 00000000..72e071a6
--- /dev/null
+++ b/internal/mpcceremony/workflow.go
@@ -0,0 +1,3108 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ gnarkmpc "github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup"
+ "golang.org/x/crypto/blake2b"
+
+ "proof-tool/internal/keybundle"
+)
+
+const (
+ maxSignedRecordBytes = 16 << 20
+ verificationSchema = "proof-tool-mpc-contribution-verification-v2"
+ directTransitionVerification = "direct-transition-from-authenticated-head-v1"
+ closePublicationSafetyMargin = 2 * time.Second
+ closeRecordFilename = "record.json"
+ closeSignatureFilename = "record.sig"
+ closePublicationDirectoryName = "closure"
+)
+
+// DefaultSignaturePath returns the fixed detached-signature sidecar path used
+// by workflow records. It does not search a directory or select a latest file.
+func DefaultSignaturePath(recordPath string) string {
+ ext := filepath.Ext(recordPath)
+ if ext == "" {
+ return recordPath + ".sig"
+ }
+ return strings.TrimSuffix(recordPath, ext) + ".sig"
+}
+
+// TrustPaths are all explicit. CoordinatorPublicKeyPath is an out-of-band
+// trust anchor; a public-key copy inside the ceremony directory is not enough.
+type TrustPaths struct {
+ DefinitionPath string
+ DefinitionSignaturePath string
+ CoordinatorPublicKeyPath string
+}
+
+// TrustedCeremony is an authenticated ceremony definition coupled to its
+// externally supplied coordinator trust anchor.
+type TrustedCeremony struct {
+ Definition CeremonyDefinition
+ CoordinatorPublicKey ed25519.PublicKey
+}
+
+// InitParticipants is the fixed-field, canonical enrollment input accepted by
+// the coordinator init command. It contains public signing identities only.
+type InitParticipants struct {
+ Coordinator Identity `json:"coordinator"`
+ ReleaseSigner Identity `json:"release_signer"`
+ Auditors []Identity `json:"auditors"`
+ Roster []Participant `json:"roster"`
+}
+
+func (p InitParticipants) Validate() error {
+ if err := p.Coordinator.Validate(); err != nil {
+ return fmt.Errorf("coordinator: %w", err)
+ }
+ if err := p.ReleaseSigner.Validate(); err != nil {
+ return fmt.Errorf("release_signer: %w", err)
+ }
+ if len(p.Auditors) < 2 {
+ return errors.New("at least two independent auditors are required")
+ }
+ if len(p.Roster) == 0 || len(p.Roster) > MaxParticipants {
+ return fmt.Errorf("roster must contain between 1 and %d participants", MaxParticipants)
+ }
+
+ identityIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster))
+ keyIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster))
+ publicKeyFingerprints := make(map[string]string, 2+len(p.Auditors)+len(p.Roster))
+ add := func(identity Identity, role string) error {
+ if previous, exists := identityIDs[identity.ID]; exists {
+ return fmt.Errorf("%s identity %q duplicates %s", role, identity.ID, previous)
+ }
+ if previous, exists := keyIDs[identity.KeyID]; exists {
+ return fmt.Errorf("%s key %q duplicates %s", role, identity.KeyID, previous)
+ }
+ if previous, exists := publicKeyFingerprints[identity.PublicKeyFingerprint]; exists {
+ return fmt.Errorf("%s public key duplicates %s", role, previous)
+ }
+ identityIDs[identity.ID] = role
+ keyIDs[identity.KeyID] = role
+ publicKeyFingerprints[identity.PublicKeyFingerprint] = role
+ return nil
+ }
+ if err := add(p.Coordinator, "coordinator"); err != nil {
+ return err
+ }
+ if err := add(p.ReleaseSigner, "release signer"); err != nil {
+ return err
+ }
+ for index, auditor := range p.Auditors {
+ if err := auditor.Validate(); err != nil {
+ return fmt.Errorf("auditor %d: %w", index, err)
+ }
+ if err := add(auditor, "auditor"); err != nil {
+ return err
+ }
+ }
+ for index, participant := range p.Roster {
+ if err := participant.Validate(); err != nil {
+ return fmt.Errorf("roster participant %d: %w", index, err)
+ }
+ if err := add(participant.Identity, "participant"); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// InitPolicy is the fixed-field, canonical policy input accepted by init.
+// Cross-checking policy participant IDs against InitParticipants happens when
+// the ceremony definition is assembled and validated.
+type InitPolicy struct {
+ Phase1Policy PhasePolicy `json:"phase1_policy"`
+ Phase2Policy PhasePolicy `json:"phase2_policy"`
+ BeaconPolicy BeaconPolicy `json:"beacon_policy"`
+}
+
+func (p InitPolicy) Validate() error {
+ if err := validateUnboundPhasePolicy(p.Phase1Policy); err != nil {
+ return fmt.Errorf("phase1_policy: %w", err)
+ }
+ if err := validateUnboundPhasePolicy(p.Phase2Policy); err != nil {
+ return fmt.Errorf("phase2_policy: %w", err)
+ }
+ if err := p.BeaconPolicy.Validate(); err != nil {
+ return fmt.Errorf("beacon_policy: %w", err)
+ }
+ return nil
+}
+
+// LoadInitParticipants reads exact canonical enrollment JSON from a regular
+// file. Unknown/duplicate fields, trailing bytes, and non-canonical encodings
+// are rejected.
+func LoadInitParticipants(path string) (InitParticipants, error) {
+ var result InitParticipants
+ if err := loadCanonicalInput(path, &result); err != nil {
+ return InitParticipants{}, fmt.Errorf("load init participants: %w", err)
+ }
+ return result, nil
+}
+
+// LoadInitPolicy reads exact canonical initialization policy JSON.
+func LoadInitPolicy(path string) (InitPolicy, error) {
+ var result InitPolicy
+ if err := loadCanonicalInput(path, &result); err != nil {
+ return InitPolicy{}, fmt.Errorf("load init policy: %w", err)
+ }
+ return result, nil
+}
+
+// LoadContributionEnvironment reads the one canonical contribution preflight
+// record used by the protocol model; there is no parallel CLI-only schema.
+func LoadContributionEnvironment(path string) (ContributionEnvironment, error) {
+ var result ContributionEnvironment
+ if err := loadCanonicalInput(path, &result); err != nil {
+ return ContributionEnvironment{}, fmt.Errorf("load contribution environment: %w", err)
+ }
+ return result, nil
+}
+
+// LoadSignedDefinition authenticates exact canonical definition bytes before
+// they are used to resolve any transcript path or allocation shape.
+func LoadSignedDefinition(paths TrustPaths) (*TrustedCeremony, error) {
+ if strings.TrimSpace(paths.DefinitionPath) == "" ||
+ strings.TrimSpace(paths.DefinitionSignaturePath) == "" ||
+ strings.TrimSpace(paths.CoordinatorPublicKeyPath) == "" {
+ return nil, errors.New("definition, definition signature, and external coordinator public-key paths are required")
+ }
+ definitionBytes, err := readRegularBounded(paths.DefinitionPath, maxSignedRecordBytes)
+ if err != nil {
+ return nil, err
+ }
+ signatureBytes, err := readRegularBounded(paths.DefinitionSignaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return nil, err
+ }
+ publicKey, err := loadExternalPublicKey(paths.CoordinatorPublicKeyPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var signature DetachedSignature
+ if err := UnmarshalCanonical(signatureBytes, &signature); err != nil {
+ return nil, fmt.Errorf("ceremony definition signature: %w", err)
+ }
+ // The signer key ID is authenticated here but is not trusted to identify a
+ // protocol role. That binding is checked against the definition only after
+ // the external trust anchor has authenticated the exact definition bytes.
+ if err := VerifyExact(definitionBytes, signature, signature.KeyID, publicKey); err != nil {
+ return nil, fmt.Errorf("verify ceremony definition signature: %w", err)
+ }
+ var definition CeremonyDefinition
+ if err := UnmarshalCanonical(definitionBytes, &definition); err != nil {
+ return nil, fmt.Errorf("ceremony definition: %w", err)
+ }
+ if signature.KeyID != definition.Coordinator.KeyID {
+ return nil, fmt.Errorf(
+ "ceremony definition signature key_id %q, want coordinator key %q",
+ signature.KeyID,
+ definition.Coordinator.KeyID,
+ )
+ }
+ identityKey, err := identityPublicKey(definition.Coordinator)
+ if err != nil {
+ return nil, fmt.Errorf("coordinator identity: %w", err)
+ }
+ if !bytes.Equal(identityKey, publicKey) {
+ return nil, errors.New("external coordinator public key does not match the signed coordinator identity")
+ }
+ return &TrustedCeremony{
+ Definition: definition,
+ CoordinatorPublicKey: bytes.Clone(publicKey),
+ }, nil
+}
+
+func loadOperationalCeremony(paths TrustPaths) (*TrustedCeremony, error) {
+ trusted, err := LoadSignedDefinition(paths)
+ if err != nil {
+ return nil, err
+ }
+ if err := VerifyRunningSoftwareForMode(
+ trusted.Definition.Software,
+ trusted.Definition.Mode,
+ ); err != nil {
+ return nil, fmt.Errorf("running software does not match signed ceremony definition: %w", err)
+ }
+ return trusted, nil
+}
+
+type InitFilesOptions struct {
+ RootDir string
+ Circuit *CompiledCircuit
+ Definition DefinitionOptions
+ CoordinatorPrivateKeyPath string
+}
+
+type InitFilesResult struct {
+ Definition CeremonyDefinition
+ Phase1Chain Chain
+ DefinitionPath string
+ DefinitionSignaturePath string
+ CoordinatorPublicKeyPath string
+ R1CSPath string
+ Phase1GenesisPath string
+ Phase1ChainPath string
+ Phase1ChainSignaturePath string
+}
+
+// InitializeCeremonyFiles creates a fresh ceremony root and deterministic
+// Phase 1 genesis. The root must not already exist.
+func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, err error) {
+ if options.Circuit == nil || options.Circuit.R1CS == nil {
+ return result, errors.New("compiled circuit is required")
+ }
+ if strings.TrimSpace(options.RootDir) == "" {
+ return result, errors.New("fresh ceremony root is required")
+ }
+ if err := options.Circuit.Binding.Validate(); err != nil {
+ return result, fmt.Errorf("compiled circuit binding: %w", err)
+ }
+ privateKey, publicKey, err := loadMatchingPrivateKey(
+ options.CoordinatorPrivateKeyPath,
+ options.Definition.Coordinator,
+ )
+ if err != nil {
+ return result, fmt.Errorf("coordinator signing key: %w", err)
+ }
+ if err := os.Mkdir(options.RootDir, 0o700); err != nil {
+ return result, fmt.Errorf("create fresh ceremony root: %w", err)
+ }
+ createdRoot := true
+ defer func() {
+ if err != nil && createdRoot && !publicationWasCommitted(err) {
+ _ = os.RemoveAll(options.RootDir)
+ }
+ }()
+ phase1Dir := filepath.Join(options.RootDir, "phase1")
+ if err := os.Mkdir(phase1Dir, 0o700); err != nil {
+ return result, fmt.Errorf("create Phase 1 directory: %w", err)
+ }
+
+ result.DefinitionPath = filepath.Join(options.RootDir, "ceremony.json")
+ result.DefinitionSignaturePath = filepath.Join(options.RootDir, "ceremony.sig")
+ result.CoordinatorPublicKeyPath = filepath.Join(options.RootDir, "coordinator-public-key.hex")
+ result.R1CSPath, err = resolveArtifactPath(options.RootDir, options.Circuit.Binding.R1CS.Name)
+ if err != nil {
+ return result, err
+ }
+ result.Phase1GenesisPath = filepath.Join(phase1Dir, "genesis.bin")
+ result.Phase1ChainPath = filepath.Join(phase1Dir, "chain-0000.json")
+ result.Phase1ChainSignaturePath = filepath.Join(phase1Dir, "chain-0000.sig")
+
+ if _, err := writeWriterToNoReplace(
+ result.R1CSPath,
+ options.Circuit.R1CS,
+ options.Circuit.Binding.R1CS.Digest,
+ ); err != nil {
+ return result, fmt.Errorf("write frozen R1CS: %w", err)
+ }
+
+ genesis := gnarkmpc.NewPhase1(options.Circuit.Binding.DomainSize)
+ genesisShape := Phase1Shape{DomainN: options.Circuit.Binding.DomainSize}
+ genesisDigest, err := WritePhase1FileNoReplace(result.Phase1GenesisPath, genesis, genesisShape)
+ if err != nil {
+ return result, fmt.Errorf("write Phase 1 genesis: %w", err)
+ }
+ genesisRef := ArtifactRef{
+ Name: "phase1/genesis.bin",
+ Digest: modelDigest(genesisDigest),
+ }
+
+ definitionOptions := options.Definition
+ definitionOptions.Circuit = options.Circuit.Binding
+ definitionOptions.Phase1Genesis = genesisRef
+ definition, err := NewCeremonyDefinition(definitionOptions)
+ if err != nil {
+ return result, fmt.Errorf("create ceremony definition: %w", err)
+ }
+ phaseID, err := ComputePhaseID(definition.CeremonyID, Phase1, genesisRef, "")
+ if err != nil {
+ return result, fmt.Errorf("compute Phase 1 ID: %w", err)
+ }
+ chain, err := NewChain(definition.CeremonyID, Phase1, phaseID, genesisRef)
+ if err != nil {
+ return result, fmt.Errorf("create Phase 1 chain: %w", err)
+ }
+
+ if err := writeSignedRecordNoReplace(
+ result.DefinitionPath,
+ result.DefinitionSignaturePath,
+ definition,
+ definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, fmt.Errorf("write signed ceremony definition: %w", err)
+ }
+ if err := writeSignedRecordNoReplace(
+ result.Phase1ChainPath,
+ result.Phase1ChainSignaturePath,
+ chain,
+ definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, fmt.Errorf("write signed Phase 1 genesis chain: %w", err)
+ }
+ if err := writeBytesNoReplace(
+ result.CoordinatorPublicKeyPath,
+ []byte(hex.EncodeToString(publicKey)+"\n"),
+ 0o600,
+ ); err != nil {
+ return result, fmt.Errorf("write coordinator public-key copy: %w", err)
+ }
+ result.Definition = definition
+ result.Phase1Chain = chain
+ createdRoot = false
+ return result, nil
+}
+
+type PhaseTranscriptPaths struct {
+ RootDir string
+ ChainPath string
+ ChainSignaturePath string
+}
+
+// LoadSignedChain verifies the exact coordinator-signed chain at paths.
+func LoadSignedChain(trusted *TrustedCeremony, paths PhaseTranscriptPaths) (Chain, error) {
+ if err := validateTrustedCeremony(trusted); err != nil {
+ return Chain{}, 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")
+ }
+ if _, err := logicalPathWithin(paths.RootDir, paths.ChainPath); err != nil {
+ return Chain{}, fmt.Errorf("chain path: %w", err)
+ }
+ if _, err := logicalPathWithin(paths.RootDir, paths.ChainSignaturePath); err != nil {
+ return Chain{}, fmt.Errorf("chain signature path: %w", err)
+ }
+ var chain Chain
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ paths.ChainPath,
+ paths.ChainSignaturePath,
+ &chain,
+ ); err != nil {
+ return Chain{}, 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, nil
+}
+
+// LoadReplayPhase1Files strictly reads all accepted evidence and replays every
+// native Phase 1 transition while retaining at most the states needed by gnark.
+func loadVerifiedPhase1Files(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ paths PhaseTranscriptPaths,
+) (Chain, error) {
+ if err := validateWorkflowCircuit(trusted, circuit); err != nil {
+ return Chain{}, err
+ }
+ chain, err := LoadSignedChain(trusted, paths)
+ if err != nil {
+ return Chain{}, err
+ }
+ if chain.Phase != Phase1 {
+ return Chain{}, fmt.Errorf("chain phase is %q, want phase1", chain.Phase)
+ }
+ if chain.Genesis != trusted.Definition.Phase1Genesis {
+ return Chain{}, errors.New("Phase 1 chain genesis differs from signed ceremony definition")
+ }
+ expectedPhaseID, err := ComputePhaseID(
+ trusted.Definition.CeremonyID,
+ Phase1,
+ chain.Genesis,
+ "",
+ )
+ if err != nil {
+ return Chain{}, err
+ }
+ if chain.PhaseID != expectedPhaseID {
+ return Chain{}, errors.New("Phase 1 chain ID does not bind the signed genesis")
+ }
+ if err := verifyChainFiles(trusted, paths.RootDir, chain, circuit.Binding.Phase2Shape); err != nil {
+ return Chain{}, err
+ }
+ return chain, nil
+}
+
+func LoadReplayPhase1Files(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ paths PhaseTranscriptPaths,
+) (Chain, error) {
+ chain, _, err := loadReplayPhase1FilesState(trusted, circuit, paths)
+ return chain, err
+}
+
+func loadReplayPhase1FilesState(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ paths PhaseTranscriptPaths,
+) (Chain, *gnarkmpc.Phase1, error) {
+ chain, err := loadVerifiedPhase1Files(trusted, circuit, paths)
+ if err != nil {
+ return Chain{}, nil, err
+ }
+ loader := phase1FileLoader(paths.RootDir, chain, circuit.Binding.DomainSize)
+ head, err := replayPhase1State(circuit.Binding.DomainSize, len(chain.Records), loader)
+ if err != nil {
+ return Chain{}, nil, err
+ }
+ return chain, head, nil
+}
+
+// LoadReplayPhase2Files performs the equivalent strict complete-chain replay
+// for Phase 2.
+func loadVerifiedPhase2Files(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ phase1Seal SealRecord,
+ paths PhaseTranscriptPaths,
+) (Chain, error) {
+ if err := validateWorkflowCircuit(trusted, circuit); err != nil {
+ return Chain{}, err
+ }
+ if commons == nil {
+ return Chain{}, errors.New("sealed Phase 1 commons are required")
+ }
+ chain, err := LoadSignedChain(trusted, paths)
+ if err != nil {
+ return Chain{}, err
+ }
+ if chain.Phase != Phase2 {
+ return Chain{}, fmt.Errorf("chain phase is %q, want phase2", chain.Phase)
+ }
+ if phase1Seal.CeremonyID != trusted.Definition.CeremonyID || phase1Seal.Phase != Phase1 {
+ return Chain{}, errors.New("Phase 2 chain requires the signed Phase 1 seal")
+ }
+ expectedPhaseID, err := ComputePhaseID(
+ trusted.Definition.CeremonyID,
+ Phase2,
+ chain.Genesis,
+ phase1Seal.SealID,
+ )
+ if err != nil {
+ return Chain{}, err
+ }
+ if chain.PhaseID != expectedPhaseID {
+ return Chain{}, errors.New("Phase 2 chain ID does not bind the signed Phase 1 seal")
+ }
+ deterministicGenesis, deterministicShape, err := InitializePhase2(circuit, commons)
+ if err != nil {
+ return Chain{}, fmt.Errorf("recompute deterministic Phase 2 genesis: %w", err)
+ }
+ if !equalPhase2Shape(deterministicShape, circuit.Binding.Phase2Shape) {
+ return Chain{}, errors.New("deterministic Phase 2 genesis shape differs from signed circuit binding")
+ }
+ expectedGenesisSize, err := ExpectedPhase2Size(deterministicShape)
+ if err != nil {
+ return Chain{}, err
+ }
+ genesisHash := newDualHash()
+ written, err := writeToWithPanicBoundary(
+ "deterministic Phase 2 genesis encoder",
+ deterministicGenesis,
+ genesisHash,
+ )
+ if err != nil {
+ return Chain{}, fmt.Errorf("hash deterministic Phase 2 genesis: %w", err)
+ }
+ if written != expectedGenesisSize {
+ return Chain{}, fmt.Errorf("deterministic Phase 2 genesis wrote %d bytes, expected %d", written, expectedGenesisSize)
+ }
+ if modelDigest(genesisHash.digest(written, nil)) != chain.Genesis.Digest {
+ return Chain{}, errors.New("Phase 2 chain genesis is not the deterministic circuit/commons initialization")
+ }
+ if err := verifyChainFiles(trusted, paths.RootDir, chain, circuit.Binding.Phase2Shape); err != nil {
+ return Chain{}, err
+ }
+ return chain, nil
+}
+
+func LoadReplayPhase2Files(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ commons *gnarkmpc.SrsCommons,
+ phase1Seal SealRecord,
+ paths PhaseTranscriptPaths,
+) (Chain, error) {
+ chain, err := loadVerifiedPhase2Files(trusted, circuit, commons, phase1Seal, paths)
+ if err != nil {
+ return Chain{}, err
+ }
+ loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape))
+ if err := ReplayPhase2Loaded(circuit, commons, len(chain.Records), loader); err != nil {
+ return Chain{}, err
+ }
+ return chain, nil
+}
+
+type ContributionFilesOptions struct {
+ Trust TrustPaths
+ Circuit *CompiledCircuit
+ Phase Phase
+ Transcript PhaseTranscriptPaths
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ ParticipantID string
+ ParticipantPrivateKeyPath string
+ Environment ContributionEnvironment
+ ContributedAt string
+ CandidateDir string
+}
+
+type ContributionFilesResult struct {
+ Attestation ContributionAttestation
+ OutputPayloadPath string
+ AttestationPath string
+ AttestationSignaturePath string
+}
+
+// CreateContributionCandidate replays the entire accepted chain before
+// sampling contribution randomness. It never modifies the authoritative
+// transcript and writes only to a fresh candidate directory.
+func CreateContributionCandidate(options ContributionFilesOptions) (result ContributionFilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil {
+ return result, err
+ }
+ if err := options.Phase.Validate(); err != nil {
+ return result, err
+ }
+ if strings.TrimSpace(options.CandidateDir) == "" {
+ return result, errors.New("fresh candidate directory is required")
+ }
+ participant, ok := trusted.Definition.ParticipantByID(options.ParticipantID)
+ if !ok {
+ return result, fmt.Errorf("participant %q is not in the signed roster", options.ParticipantID)
+ }
+ privateKey, _, err := loadMatchingPrivateKey(options.ParticipantPrivateKeyPath, participant.Identity)
+ if err != nil {
+ return result, fmt.Errorf("participant signing key: %w", err)
+ }
+ if err := options.Environment.Validate(); err != nil {
+ return result, fmt.Errorf("contribution environment: %w", err)
+ }
+ if err := validateTimestamp("contributed_at", options.ContributedAt); err != nil {
+ return result, err
+ }
+
+ var chain Chain
+ var generateContributionWriter func() (func(string) (ArtifactDigest, error), error)
+ switch options.Phase {
+ case Phase1:
+ chain, err = loadVerifiedPhase1Files(trusted, options.Circuit, options.Transcript)
+ if err != nil {
+ return result, err
+ }
+ generateContributionWriter = func() (func(string) (ArtifactDigest, error), error) {
+ contribution, contributeErr := ContributePhase1Loaded(
+ options.Circuit.Binding.DomainSize,
+ len(chain.Records),
+ phase1FileLoader(options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize),
+ )
+ if contributeErr != nil {
+ return nil, contributeErr
+ }
+ shape := Phase1Shape{DomainN: options.Circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize}
+ return func(path string) (ArtifactDigest, error) {
+ return WritePhase1FileNoReplace(path, contribution, shape)
+ }, nil
+ }
+ case Phase2:
+ commons, phase1Seal, _, loadErr := loadPhase1CommonsForPhase2(trusted, options.Circuit, options.Transcript.RootDir, options.Phase1SealPath, options.Phase1SealSignaturePath)
+ if loadErr != nil {
+ return result, loadErr
+ }
+ chain, err = loadVerifiedPhase2Files(trusted, options.Circuit, commons, phase1Seal, options.Transcript)
+ if err != nil {
+ return result, err
+ }
+ generateContributionWriter = func() (func(string) (ArtifactDigest, error), error) {
+ contribution, contributeErr := ContributePhase2Loaded(
+ options.Circuit,
+ commons,
+ len(chain.Records),
+ phase2FileLoader(options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape)),
+ )
+ if contributeErr != nil {
+ return nil, contributeErr
+ }
+ shape := contributionPhase2Shape(options.Circuit.Binding.Phase2Shape)
+ return func(path string) (ArtifactDigest, error) {
+ return WritePhase2FileNoReplace(path, contribution, shape)
+ }, nil
+ }
+ }
+
+ policy, _ := trusted.Definition.PolicyForPhase(options.Phase)
+ index := len(chain.Records) + 1
+ if index > len(policy.Participants) || policy.Participants[index-1] != options.ParticipantID {
+ return result, fmt.Errorf("participant %q is not scheduled at contribution index %d", options.ParticipantID, index)
+ }
+ if _, statErr := os.Lstat(options.CandidateDir); statErr == nil {
+ return result, fmt.Errorf("fresh candidate directory already exists: %w", fs.ErrExist)
+ } else if !errors.Is(statErr, fs.ErrNotExist) {
+ return result, fmt.Errorf("inspect fresh candidate directory: %w", statErr)
+ }
+
+ // Replay and MPC entropy sampling start only after all deterministic
+ // schedule and destination preflights have succeeded. The directory itself
+ // is created afterward so termination during replay does not strand an
+ // empty candidate path; the ceremony root must remain access-controlled to
+ // exclude a racing creator between this preflight and mkdir.
+ contributionWriter, err := generateContributionWriter()
+ if err != nil {
+ return result, err
+ }
+ if err := os.Mkdir(options.CandidateDir, 0o700); err != nil {
+ return result, fmt.Errorf("create fresh candidate directory: %w", err)
+ }
+ createdCandidate := true
+ defer func() {
+ if err != nil && createdCandidate && !publicationWasCommitted(err) {
+ _ = os.RemoveAll(options.CandidateDir)
+ }
+ }()
+ result.OutputPayloadPath = filepath.Join(options.CandidateDir, "contribution.bin")
+ result.AttestationPath = filepath.Join(options.CandidateDir, "attestation.json")
+ result.AttestationSignaturePath = filepath.Join(options.CandidateDir, "attestation.sig")
+
+ outputDigest, err := contributionWriter(result.OutputPayloadPath)
+ if err != nil {
+ return result, err
+ }
+ previousPayload, _ := chain.HeadPayload()
+ if err := requireChallengeMatchesDigest(outputDigest.Challenge, previousPayload.Digest); err != nil {
+ return result, fmt.Errorf("generated contribution challenge: %w", err)
+ }
+ previousRecordID, _ := chain.HeadRecordID()
+ names := contributionLogicalNames(options.Phase, index)
+ outputRef := ArtifactRef{Name: names.Payload, Digest: modelDigest(outputDigest)}
+ attestation, err := NewContributionAttestation(ContributionAttestation{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: options.Phase,
+ PhaseID: chain.PhaseID,
+ Index: uint8(index),
+ ParticipantID: participant.Identity.ID,
+ ParticipantKeyID: participant.Identity.KeyID,
+ PreviousPayload: previousPayload,
+ OutputPayload: outputRef,
+ PreviousAcceptanceID: previousRecordID,
+ ToolBinary: trusted.Definition.Software.ToolBinary,
+ SourceCommit: trusted.Definition.Software.SourceCommit,
+ GnarkVersion: trusted.Definition.Software.GnarkVersion,
+ GnarkCryptoVersion: trusted.Definition.Software.GnarkCryptoVersion,
+ DrandVersion: trusted.Definition.Software.DrandVersion,
+ Environment: options.Environment,
+ ContributedAt: options.ContributedAt,
+ })
+ if err != nil {
+ return result, err
+ }
+ if err := writeSignedRecordNoReplace(
+ result.AttestationPath,
+ result.AttestationSignaturePath,
+ attestation,
+ participant.Identity.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+ result.Attestation = attestation
+ createdCandidate = false
+ return result, nil
+}
+
+type CreateErasureAttestationFilesOptions struct {
+ Trust TrustPaths
+ ParticipantID string
+ ParticipantPrivateKeyPath string
+ CandidateDir string
+ DestroyedAt string
+}
+
+type CreateErasureAttestationFilesResult struct {
+ Erasure ErasureAttestation
+ ErasurePath string
+ SignaturePath string
+}
+
+// CreateErasureAttestationFiles authenticates the participant's contribution
+// attestation and records the required post-contribution destruction evidence.
+// It does not touch ceremony wallet material or modify the contribution.
+func CreateErasureAttestationFiles(
+ options CreateErasureAttestationFilesOptions,
+) (result CreateErasureAttestationFilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ participant, ok := trusted.Definition.ParticipantByID(options.ParticipantID)
+ if !ok {
+ return result, fmt.Errorf("participant %q is not in the signed roster", options.ParticipantID)
+ }
+ privateKey, publicKey, err := loadMatchingPrivateKey(
+ options.ParticipantPrivateKeyPath,
+ participant.Identity,
+ )
+ if err != nil {
+ return result, fmt.Errorf("participant signing key: %w", err)
+ }
+ attestationPath := filepath.Join(options.CandidateDir, "attestation.json")
+ signaturePath := filepath.Join(options.CandidateDir, "attestation.sig")
+ attestationBytes, err := readRegularBounded(attestationPath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ signatureBytes, err := readRegularBounded(signaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ var attestation ContributionAttestation
+ if err := VerifySignedRecord(
+ attestationBytes,
+ signatureBytes,
+ &attestation,
+ participant.Identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return result, fmt.Errorf("verify contribution attestation: %w", err)
+ }
+ if attestation.ParticipantID != participant.Identity.ID ||
+ attestation.ParticipantKeyID != participant.Identity.KeyID ||
+ attestation.CeremonyID != trusted.Definition.CeremonyID {
+ return result, errors.New("contribution attestation does not match participant or ceremony")
+ }
+ erasure, err := NewErasureAttestation(ErasureAttestation{
+ CeremonyID: attestation.CeremonyID,
+ Phase: attestation.Phase,
+ PhaseID: attestation.PhaseID,
+ Index: attestation.Index,
+ ParticipantID: attestation.ParticipantID,
+ ParticipantKeyID: attestation.ParticipantKeyID,
+ ContributionAttestationID: attestation.AttestationID,
+ OutputPayload: attestation.OutputPayload,
+ DestroyedAt: options.DestroyedAt,
+ ProcessTerminated: true,
+ EphemeralStorageDestroyed: true,
+ NoBackupRetained: true,
+ })
+ if err != nil {
+ return result, err
+ }
+ if err := ValidateErasureForContribution(attestation, erasure); err != nil {
+ return result, err
+ }
+ result.ErasurePath = filepath.Join(options.CandidateDir, "erasure.json")
+ result.SignaturePath = filepath.Join(options.CandidateDir, "erasure.sig")
+ if err := writeSignedRecordNoReplace(
+ result.ErasurePath,
+ result.SignaturePath,
+ erasure,
+ participant.Identity.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+ result.Erasure = erasure
+ return result, nil
+}
+
+// RecordErasureFiles is a participant-CLI-friendly alias.
+type RecordErasureFilesOptions = CreateErasureAttestationFilesOptions
+type RecordErasureFilesResult = CreateErasureAttestationFilesResult
+
+func RecordErasureFiles(options RecordErasureFilesOptions) (RecordErasureFilesResult, error) {
+ return CreateErasureAttestationFiles(options)
+}
+
+type AcceptContributionFilesOptions struct {
+ Trust TrustPaths
+ Circuit *CompiledCircuit
+ Phase Phase
+ Transcript PhaseTranscriptPaths
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ CandidateDir string
+ CoordinatorPrivateKeyPath string
+ AcceptedAt string
+}
+
+type AcceptContributionFilesResult struct {
+ Record ChainRecord
+ Chain Chain
+ AcceptedPayloadPath string
+ AcceptedAttestationPath string
+ AcceptedAttestationSignaturePath string
+ AcceptedErasurePath string
+ AcceptedErasureSignaturePath string
+ VerificationPath string
+ ChainPath string
+ ChainSignaturePath string
+}
+
+// VerifyAndAcceptContribution verifies a candidate independently, publishes
+// immutable evidence, and writes a new signed chain document last. The input
+// chain is never overwritten.
+func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result AcceptContributionFilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil {
+ return result, err
+ }
+ if err := options.Phase.Validate(); err != nil {
+ return result, err
+ }
+ if err := validateTimestamp("accepted_at", options.AcceptedAt); err != nil {
+ return result, err
+ }
+ coordinatorPrivate, _, err := loadMatchingPrivateKey(
+ options.CoordinatorPrivateKeyPath,
+ trusted.Definition.Coordinator,
+ )
+ if err != nil {
+ return result, fmt.Errorf("coordinator signing key: %w", err)
+ }
+
+ var chain Chain
+ var candidateDigest ArtifactDigest
+ var candidateChallenge []byte
+ var phase1Candidate *gnarkmpc.Phase1
+ var phase2Candidate *gnarkmpc.Phase2
+ var phase2Commons *gnarkmpc.SrsCommons
+ var phase1Seal SealRecord
+ switch options.Phase {
+ case Phase1:
+ chain, err = loadVerifiedPhase1Files(trusted, options.Circuit, options.Transcript)
+ if err != nil {
+ return result, err
+ }
+ case Phase2:
+ phase2Commons, phase1Seal, _, err = loadAuthenticatedPhase1CommonsForCoordinator(
+ trusted,
+ options.Circuit,
+ options.Transcript.RootDir,
+ options.Phase1SealPath,
+ options.Phase1SealSignaturePath,
+ )
+ loadErr := err
+ if loadErr != nil {
+ return result, loadErr
+ }
+ chain, err = loadVerifiedPhase2Files(trusted, options.Circuit, phase2Commons, phase1Seal, options.Transcript)
+ if err != nil {
+ return result, err
+ }
+ }
+ index := len(chain.Records) + 1
+ names := contributionLogicalNames(options.Phase, index)
+ candidatePayloadPath := filepath.Join(options.CandidateDir, "contribution.bin")
+ candidateAttestationPath := filepath.Join(options.CandidateDir, "attestation.json")
+ candidateSignaturePath := filepath.Join(options.CandidateDir, "attestation.sig")
+ candidateErasurePath := filepath.Join(options.CandidateDir, "erasure.json")
+ candidateErasureSignaturePath := filepath.Join(options.CandidateDir, "erasure.sig")
+
+ attestationBytes, err := readRegularBounded(candidateAttestationPath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ var attestation ContributionAttestation
+ if err := UnmarshalCanonical(attestationBytes, &attestation); err != nil {
+ return result, fmt.Errorf("candidate attestation: %w", err)
+ }
+ participant, ok := trusted.Definition.ParticipantByID(attestation.ParticipantID)
+ if !ok {
+ return result, fmt.Errorf("candidate participant %q is not in roster", attestation.ParticipantID)
+ }
+ participantKey, err := identityPublicKey(participant.Identity)
+ if err != nil {
+ return result, err
+ }
+ attestationSignatureBytes, err := readRegularBounded(candidateSignaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ if err := VerifySignedRecord(
+ attestationBytes,
+ attestationSignatureBytes,
+ &attestation,
+ participant.Identity.KeyID,
+ participantKey,
+ ); err != nil {
+ return result, fmt.Errorf("verify candidate attestation: %w", err)
+ }
+ if attestation.Index != uint8(index) || attestation.Phase != options.Phase ||
+ attestation.PhaseID != chain.PhaseID || attestation.CeremonyID != trusted.Definition.CeremonyID ||
+ attestation.OutputPayload.Name != names.Payload {
+ return result, errors.New("candidate attestation does not match the expected chain position")
+ }
+ previousPayload, _ := chain.HeadPayload()
+ previousRecordID, _ := chain.HeadRecordID()
+ if attestation.PreviousPayload != previousPayload || attestation.PreviousAcceptanceID != previousRecordID {
+ return result, errors.New("candidate attestation is not based on the accepted chain head")
+ }
+ erasureBytes, err := readRegularBounded(candidateErasurePath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ erasureSignatureBytes, err := readRegularBounded(candidateErasureSignaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return result, err
+ }
+ var erasure ErasureAttestation
+ if err := VerifySignedRecord(
+ erasureBytes,
+ erasureSignatureBytes,
+ &erasure,
+ participant.Identity.KeyID,
+ participantKey,
+ ); err != nil {
+ return result, fmt.Errorf("verify candidate erasure attestation: %w", err)
+ }
+ if err := ValidateErasureForContribution(attestation, erasure); err != nil {
+ return result, fmt.Errorf("candidate erasure attestation: %w", err)
+ }
+
+ switch options.Phase {
+ case Phase1:
+ candidate, digest, readErr := ReadPhase1File(
+ candidatePayloadPath,
+ Phase1Shape{DomainN: options.Circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize},
+ )
+ if readErr != nil {
+ return result, readErr
+ }
+ phase1Candidate = candidate
+ candidateDigest, candidateChallenge = digest, digest.Challenge
+ var previous *gnarkmpc.Phase1
+ if index == 1 {
+ previous, _, err = InitializePhase1(options.Circuit.Binding.DomainSize)
+ } else {
+ previous, err = phase1FileLoader(
+ options.Transcript.RootDir,
+ chain,
+ options.Circuit.Binding.DomainSize,
+ )(index - 2)
+ }
+ if err != nil {
+ return result, fmt.Errorf("load authenticated Phase 1 head: %w", err)
+ }
+ if err := verifyPhase1Transition(
+ options.Circuit.Binding.DomainSize,
+ previous,
+ candidate,
+ ); err != nil {
+ return result, fmt.Errorf("verify candidate Phase 1 transition: %w", err)
+ }
+ case Phase2:
+ candidate, digest, readErr := ReadPhase2File(candidatePayloadPath, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape))
+ if readErr != nil {
+ return result, readErr
+ }
+ phase2Candidate = candidate
+ candidateDigest, candidateChallenge = digest, digest.Challenge
+ var previous *gnarkmpc.Phase2
+ if index == 1 {
+ previous, _, err = InitializePhase2(options.Circuit, phase2Commons)
+ } else {
+ previous, err = phase2FileLoader(
+ options.Transcript.RootDir,
+ chain,
+ contributionPhase2Shape(options.Circuit.Binding.Phase2Shape),
+ )(index - 2)
+ }
+ if err != nil {
+ return result, fmt.Errorf("load authenticated Phase 2 head: %w", err)
+ }
+ if err := verifyPhase2Transition(previous, candidate); err != nil {
+ return result, fmt.Errorf("verify candidate Phase 2 transition: %w", err)
+ }
+ }
+ if modelDigest(candidateDigest) != attestation.OutputPayload.Digest {
+ return result, errors.New("candidate contribution digest does not match attestation")
+ }
+ if err := requireChallengeMatchesDigest(candidateChallenge, previousPayload.Digest); err != nil {
+ return result, err
+ }
+
+ attestationRef := ArtifactRef{Name: names.Attestation, Digest: digestBytes(attestationBytes)}
+ attestationSignatureRef := ArtifactRef{Name: names.AttestationSignature, Digest: digestBytes(attestationSignatureBytes)}
+ erasureRef := ArtifactRef{Name: names.Erasure, Digest: digestBytes(erasureBytes)}
+ erasureSignatureRef := ArtifactRef{Name: names.ErasureSignature, Digest: digestBytes(erasureSignatureBytes)}
+ verification := ContributionVerification{
+ Schema: verificationSchema,
+ VerificationMode: directTransitionVerification,
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: options.Phase,
+ PhaseID: chain.PhaseID,
+ Index: uint8(index),
+ ParticipantID: attestation.ParticipantID,
+ PreviousPayload: previousPayload,
+ OutputPayload: attestation.OutputPayload,
+ AttestationID: attestation.AttestationID,
+ ErasureID: erasure.ErasureID,
+ PreviousRecordID: previousRecordID,
+ CoordinatorID: trusted.Definition.Coordinator.ID,
+ CoordinatorKeyID: trusted.Definition.Coordinator.KeyID,
+ Passed: true,
+ VerifiedAt: options.AcceptedAt,
+ }
+ verificationBytes, err := MarshalCanonical(verification)
+ if err != nil {
+ return result, err
+ }
+ verificationRef := ArtifactRef{Name: names.Verification, Digest: digestBytes(verificationBytes)}
+ record, err := NewChainRecord(ChainRecord{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: options.Phase,
+ PhaseID: chain.PhaseID,
+ Index: uint8(index),
+ ParticipantID: attestation.ParticipantID,
+ PreviousPayload: previousPayload,
+ OutputPayload: attestation.OutputPayload,
+ AttestationID: attestation.AttestationID,
+ Attestation: attestationRef,
+ AttestationSignature: attestationSignatureRef,
+ ErasureID: erasure.ErasureID,
+ Erasure: erasureRef,
+ ErasureSignature: erasureSignatureRef,
+ Verification: verificationRef,
+ PreviousRecordID: previousRecordID,
+ CoordinatorID: trusted.Definition.Coordinator.ID,
+ CoordinatorKeyID: trusted.Definition.Coordinator.KeyID,
+ AcceptedAt: options.AcceptedAt,
+ })
+ if err != nil {
+ return result, err
+ }
+ if err := ValidateAttestationAcceptance(trusted.Definition, chain, attestation, erasure, record); err != nil {
+ return result, err
+ }
+ nextChain := chain
+ if err := nextChain.Append(record); err != nil {
+ return result, err
+ }
+
+ result.AcceptedPayloadPath, err = resolveArtifactPath(options.Transcript.RootDir, names.Payload)
+ if err != nil {
+ return result, err
+ }
+ result.AcceptedAttestationPath, err = resolveArtifactPath(options.Transcript.RootDir, names.Attestation)
+ if err != nil {
+ return result, err
+ }
+ result.AcceptedAttestationSignaturePath, err = resolveArtifactPath(options.Transcript.RootDir, names.AttestationSignature)
+ if err != nil {
+ return result, err
+ }
+ result.AcceptedErasurePath, err = resolveArtifactPath(options.Transcript.RootDir, names.Erasure)
+ if err != nil {
+ return result, err
+ }
+ result.AcceptedErasureSignaturePath, err = resolveArtifactPath(options.Transcript.RootDir, names.ErasureSignature)
+ if err != nil {
+ return result, err
+ }
+ result.VerificationPath, err = resolveArtifactPath(options.Transcript.RootDir, names.Verification)
+ if err != nil {
+ return result, err
+ }
+ contributionDir := filepath.Dir(result.AcceptedPayloadPath)
+ phaseDir := filepath.Join(options.Transcript.RootDir, string(options.Phase))
+ if err := mkdirAllPrivateDurable(contributionDir); err != nil {
+ return result, err
+ }
+ if err := requirePrivateRealDirectory(contributionDir); err != nil {
+ return result, err
+ }
+ if err := requireDirectoryEntriesSubset(contributionDir, []string{
+ filepath.Base(result.AcceptedPayloadPath),
+ filepath.Base(result.AcceptedAttestationPath),
+ filepath.Base(result.AcceptedAttestationSignaturePath),
+ filepath.Base(result.AcceptedErasurePath),
+ filepath.Base(result.AcceptedErasureSignaturePath),
+ filepath.Base(result.VerificationPath),
+ }); err != nil {
+ return result, err
+ }
+ result.ChainPath = filepath.Join(phaseDir, fmt.Sprintf("chain-%04d.json", index))
+ result.ChainSignaturePath = filepath.Join(phaseDir, fmt.Sprintf("chain-%04d.sig", index))
+ chainBytes, chainSignatureBytes, err := SignRecord(
+ nextChain,
+ trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ )
+ if err != nil {
+ return result, err
+ }
+
+ // Check every possible retry artifact before publishing any new bytes.
+ // A complete or partial byte-identical prefix is resumable; a mismatch
+ // aborts without modifying the existing transcript.
+ switch options.Phase {
+ case Phase1:
+ if err := requireAbsentOrExactPhase1(
+ result.AcceptedPayloadPath,
+ Phase1Shape{DomainN: options.Circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize},
+ attestation.OutputPayload.Digest,
+ ); err != nil {
+ return result, err
+ }
+ case Phase2:
+ if err := requireAbsentOrExactPhase2(
+ result.AcceptedPayloadPath,
+ contributionPhase2Shape(options.Circuit.Binding.Phase2Shape),
+ attestation.OutputPayload.Digest,
+ ); err != nil {
+ return result, err
+ }
+ }
+ for _, item := range []struct {
+ path string
+ data []byte
+ }{
+ {result.AcceptedAttestationPath, attestationBytes},
+ {result.AcceptedAttestationSignaturePath, attestationSignatureBytes},
+ {result.AcceptedErasurePath, erasureBytes},
+ {result.AcceptedErasureSignaturePath, erasureSignatureBytes},
+ {result.VerificationPath, verificationBytes},
+ {result.ChainSignaturePath, chainSignatureBytes},
+ {result.ChainPath, chainBytes},
+ } {
+ if err := requireAbsentOrExact(item.path, item.data, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ }
+
+ switch options.Phase {
+ case Phase1:
+ if _, err := writePhase1FileNoReplaceOrExact(
+ result.AcceptedPayloadPath,
+ phase1Candidate,
+ Phase1Shape{DomainN: options.Circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize},
+ attestation.OutputPayload.Digest,
+ ); err != nil {
+ return result, err
+ }
+ case Phase2:
+ if _, err := writePhase2FileNoReplaceOrExact(
+ result.AcceptedPayloadPath,
+ phase2Candidate,
+ contributionPhase2Shape(options.Circuit.Binding.Phase2Shape),
+ attestation.OutputPayload.Digest,
+ ); err != nil {
+ return result, err
+ }
+ }
+ if err := writeBytesNoReplaceOrExact(result.AcceptedAttestationPath, attestationBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := writeBytesNoReplaceOrExact(result.AcceptedAttestationSignaturePath, attestationSignatureBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := writeBytesNoReplaceOrExact(result.AcceptedErasurePath, erasureBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := writeBytesNoReplaceOrExact(result.AcceptedErasureSignaturePath, erasureSignatureBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := writeBytesNoReplaceOrExact(result.VerificationPath, verificationBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := writeSignedRecordNoReplace(
+ result.ChainPath,
+ result.ChainSignaturePath,
+ nextChain,
+ trusted.Definition.Coordinator.KeyID,
+ coordinatorPrivate,
+ ); err != nil {
+ return result, err
+ }
+ result.Record = record
+ result.Chain = nextChain
+ return result, nil
+}
+
+// ContributionVerification is coordinator evidence referenced from each
+// accepted chain record.
+type ContributionVerification struct {
+ Schema string `json:"schema"`
+ VerificationMode string `json:"verification_mode"`
+ CeremonyID string `json:"ceremony_id"`
+ Phase Phase `json:"phase"`
+ PhaseID string `json:"phase_id"`
+ Index uint8 `json:"index"`
+ ParticipantID string `json:"participant_id"`
+ PreviousPayload ArtifactRef `json:"previous_payload"`
+ OutputPayload ArtifactRef `json:"output_payload"`
+ AttestationID string `json:"attestation_id"`
+ ErasureID string `json:"erasure_id"`
+ PreviousRecordID string `json:"previous_record_id"`
+ CoordinatorID string `json:"coordinator_id"`
+ CoordinatorKeyID string `json:"coordinator_key_id"`
+ Passed bool `json:"passed"`
+ VerifiedAt string `json:"verified_at"`
+}
+
+func (v ContributionVerification) Validate() error {
+ if v.Schema != verificationSchema {
+ return fmt.Errorf("verification schema %q, want %q", v.Schema, verificationSchema)
+ }
+ if v.VerificationMode != directTransitionVerification {
+ return fmt.Errorf(
+ "verification mode %q, want %q",
+ v.VerificationMode,
+ directTransitionVerification,
+ )
+ }
+ if err := validateRecordScope(v.CeremonyID, v.Phase, v.PhaseID); err != nil {
+ return err
+ }
+ if v.Index == 0 || v.Index > MaxParticipants {
+ return fmt.Errorf("verification index %d is invalid", v.Index)
+ }
+ if err := validateID("participant_id", v.ParticipantID); err != nil {
+ return err
+ }
+ if err := v.PreviousPayload.Validate(); err != nil {
+ return err
+ }
+ if err := v.OutputPayload.Validate(); err != nil {
+ return err
+ }
+ if err := validateHashID("attestation_id", v.AttestationID); err != nil {
+ return err
+ }
+ if err := validateHashID("erasure_id", v.ErasureID); err != nil {
+ return err
+ }
+ if err := validateHashID("previous_record_id", v.PreviousRecordID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_id", v.CoordinatorID); err != nil {
+ return err
+ }
+ if err := validateID("coordinator_key_id", v.CoordinatorKeyID); err != nil {
+ return err
+ }
+ if !v.Passed {
+ return errors.New("accepted contribution verification must pass")
+ }
+ return validateTimestamp("verified_at", v.VerifiedAt)
+}
+
+func validateContributionVerification(record ChainRecord, verification ContributionVerification) error {
+ if err := verification.Validate(); err != nil {
+ return err
+ }
+ if verification.CeremonyID != record.CeremonyID ||
+ verification.Phase != record.Phase ||
+ verification.PhaseID != record.PhaseID ||
+ verification.Index != record.Index ||
+ verification.ParticipantID != record.ParticipantID ||
+ verification.PreviousPayload != record.PreviousPayload ||
+ verification.OutputPayload != record.OutputPayload ||
+ verification.AttestationID != record.AttestationID ||
+ verification.ErasureID != record.ErasureID ||
+ verification.PreviousRecordID != record.PreviousRecordID ||
+ verification.CoordinatorID != record.CoordinatorID ||
+ verification.CoordinatorKeyID != record.CoordinatorKeyID ||
+ verification.VerifiedAt != record.AcceptedAt {
+ return errors.New("verification evidence does not match accepted chain record")
+ }
+ return nil
+}
+
+type ClosePhaseFilesOptions struct {
+ Trust TrustPaths
+ Circuit *CompiledCircuit
+ Phase Phase
+ Transcript PhaseTranscriptPaths
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ CoordinatorPrivateKeyPath string
+ BeaconRound uint64
+}
+
+type ClosePhaseFilesResult struct {
+ Close CloseRecord
+ ClosePath string
+ SignaturePath string
+}
+
+// ClosePhaseFiles replays the exact signed chain and publishes a signed
+// closure as one atomic directory at the fixed per-phase path. Closure time is
+// sampled only after replay, and the future-round lead is checked again
+// immediately before the directory is committed.
+func ClosePhaseFiles(options ClosePhaseFilesOptions) (ClosePhaseFilesResult, error) {
+ return closePhaseFiles(options, time.Now)
+}
+
+func closePhaseFiles(
+ options ClosePhaseFilesOptions,
+ now func() time.Time,
+) (ClosePhaseFilesResult, error) {
+ if now == nil {
+ return ClosePhaseFilesResult{}, errors.New("closure clock is required")
+ }
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return ClosePhaseFilesResult{}, err
+ }
+ return closePhaseFilesAuthenticated(options, trusted, now)
+}
+
+func closePhaseFilesAuthenticated(
+ options ClosePhaseFilesOptions,
+ trusted *TrustedCeremony,
+ now func() time.Time,
+) (ClosePhaseFilesResult, error) {
+ var result ClosePhaseFilesResult
+ if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil {
+ return result, err
+ }
+ var chain Chain
+ var err error
+ 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(
+ trusted,
+ options.Circuit,
+ options.Transcript.RootDir,
+ options.Phase1SealPath,
+ options.Phase1SealSignaturePath,
+ )
+ if err == nil && options.BeaconRound == phase1Close.BeaconRound {
+ err = fmt.Errorf(
+ "phase2 beacon round %d reuses the authenticated phase1 beacon round; a distinct round is required",
+ options.BeaconRound,
+ )
+ }
+ if err == nil {
+ chain, err = LoadReplayPhase2Files(trusted, options.Circuit, commons, phase1Seal, options.Transcript)
+ }
+ default:
+ err = fmt.Errorf("unsupported phase %q", options.Phase)
+ }
+ if err != nil {
+ return result, err
+ }
+ return publishReplayedPhaseClose(options, trusted, chain, now)
+}
+
+func publishReplayedPhaseClose(
+ options ClosePhaseFilesOptions,
+ trusted *TrustedCeremony,
+ chain Chain,
+ now func() time.Time,
+) (ClosePhaseFilesResult, error) {
+ var result ClosePhaseFilesResult
+ privateKey, _, err := loadMatchingPrivateKey(options.CoordinatorPrivateKeyPath, trusted.Definition.Coordinator)
+ if err != nil {
+ return result, err
+ }
+ headID, _ := chain.HeadRecordID()
+ headPayload, _ := chain.HeadPayload()
+ participants, _ := chain.ParticipantIDs()
+ phaseDir := filepath.Join(options.Transcript.RootDir, string(options.Phase))
+ closeDir := filepath.Join(phaseDir, closePublicationDirectoryName)
+ result.ClosePath = filepath.Join(closeDir, closeRecordFilename)
+ result.SignaturePath = filepath.Join(closeDir, closeSignatureFilename)
+
+ if _, statErr := os.Lstat(closeDir); statErr == nil {
+ if err := requirePrivateRealDirectory(closeDir); err != nil {
+ return result, fmt.Errorf("validate existing atomic phase closure directory: %w", err)
+ }
+ if err := requireDirectoryEntriesSubset(closeDir, []string{
+ closeRecordFilename,
+ closeSignatureFilename,
+ }); err != nil {
+ return result, fmt.Errorf("validate existing atomic phase closure members: %w", err)
+ }
+ var existing CloseRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ result.ClosePath,
+ result.SignaturePath,
+ &existing,
+ ); err != nil {
+ return result, fmt.Errorf("load existing atomic phase closure: %w", err)
+ }
+ if existing.BeaconRound != options.BeaconRound {
+ return result, fmt.Errorf(
+ "existing phase closure commits beacon round %d, not requested round %d",
+ existing.BeaconRound,
+ options.BeaconRound,
+ )
+ }
+ if err := ValidateClose(trusted.Definition, chain, existing); err != nil {
+ return result, fmt.Errorf("validate existing atomic phase closure: %w", err)
+ }
+ // A previous attempt may have completed the no-replace rename but
+ // returned after a persistent parent-directory fsync failure. Re-sync
+ // the phase directory before reporting an exact committed retry as
+ // successful.
+ if err := syncDirectory(phaseDir); err != nil {
+ return result, fmt.Errorf("recover existing phase closure durability: %w", err)
+ }
+ result.Close = existing
+ return result, nil
+ } else if !errors.Is(statErr, fs.ErrNotExist) {
+ 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")
+ }
+ closeRecord, err := NewCloseRecord(CloseRecord{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: options.Phase,
+ PhaseID: chain.PhaseID,
+ FinalIndex: uint8(len(chain.Records)),
+ FinalPayload: headPayload,
+ ChainHeadID: headID,
+ AcceptedParticipants: participants,
+ BeaconProvider: trusted.Definition.BeaconPolicy.Provider,
+ BeaconNetwork: trusted.Definition.BeaconPolicy.Network,
+ BeaconRound: options.BeaconRound,
+ BeaconNotBefore: roundTime.Format(time.RFC3339Nano),
+ ClosedAt: closedAt.Format(time.RFC3339Nano),
+ CoordinatorID: trusted.Definition.Coordinator.ID,
+ CoordinatorKeyID: trusted.Definition.Coordinator.KeyID,
+ })
+ if err != nil {
+ return result, err
+ }
+ if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil {
+ return result, err
+ }
+
+ stagingDir, err := os.MkdirTemp(phaseDir, ".closure.staging-")
+ if err != nil {
+ return result, fmt.Errorf("create phase closure staging directory: %w", err)
+ }
+ defer func() {
+ _ = os.RemoveAll(stagingDir)
+ }()
+ stagedRecord := filepath.Join(stagingDir, closeRecordFilename)
+ stagedSignature := filepath.Join(stagingDir, closeSignatureFilename)
+ if err := writeSignedRecordNoReplace(
+ stagedRecord,
+ stagedSignature,
+ closeRecord,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+
+ if err := publishDirectoryNoReplaceOrExactGuarded(
+ stagingDir,
+ closeDir,
+ func() error {
+ return validateCloseCommitTime(
+ closedAt,
+ now().UTC(),
+ roundTime,
+ trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds,
+ )
+ },
+ ); err != nil {
+ return result, fmt.Errorf("atomically publish phase closure: %w", err)
+ }
+ result.Close = closeRecord
+ return result, nil
+}
+
+func validateCloseCommitTime(
+ closedAt time.Time,
+ commitTime time.Time,
+ roundTime time.Time,
+ minimumWitnessLeadSeconds uint32,
+) error {
+ if closedAt.IsZero() {
+ return errors.New("closure clock returned the zero time")
+ }
+ if commitTime.IsZero() {
+ return errors.New("closure clock returned the zero time before publication")
+ }
+ if commitTime.Before(closedAt) {
+ return errors.New("closure clock moved backwards before publication")
+ }
+ minimumLead := time.Duration(minimumWitnessLeadSeconds) * time.Second
+ 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)",
+ roundTime.Sub(commitTime),
+ requiredLead,
+ minimumLead,
+ closePublicationSafetyMargin,
+ )
+ }
+ return nil
+}
+
+type RecordBeaconFilesOptions struct {
+ Trust TrustPaths
+ TranscriptRoot string
+ Phase Phase
+ ClosePath string
+ CloseSignaturePath string
+ RawResponsePath string
+ PublishedAt string
+ CoordinatorPrivateKeyPath string
+}
+
+type RecordBeaconFilesResult struct {
+ Beacon BeaconRecord
+ RawResponsePath string
+ BeaconPath string
+ SignaturePath string
+}
+
+// RecordBeaconFiles strictly verifies a local drand response against the
+// definition-pinned quicknet key and scheme, derives its randomness from the
+// verified signature, and preserves the raw response for independent replay.
+func RecordBeaconFiles(options RecordBeaconFilesOptions) (result RecordBeaconFilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ if err := options.Phase.Validate(); err != nil {
+ return result, err
+ }
+ if strings.TrimSpace(options.TranscriptRoot) == "" {
+ return result, errors.New("transcript root is required")
+ }
+ var closeRecord CloseRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ options.ClosePath,
+ options.CloseSignaturePath,
+ &closeRecord,
+ ); err != nil {
+ return result, fmt.Errorf("load signed close record: %w", err)
+ }
+ if closeRecord.Phase != options.Phase {
+ return result, fmt.Errorf("close record phase is %q, want %q", closeRecord.Phase, options.Phase)
+ }
+ if closeRecord.CeremonyID != trusted.Definition.CeremonyID {
+ return result, errors.New("close record ceremony does not match signed definition")
+ }
+ privateKey, _, err := loadMatchingPrivateKey(
+ options.CoordinatorPrivateKeyPath,
+ trusted.Definition.Coordinator,
+ )
+ if err != nil {
+ return result, fmt.Errorf("coordinator signing key: %w", err)
+ }
+ rawResponse, err := readRegularBounded(options.RawResponsePath, maxDrandResponseBytes)
+ if err != nil {
+ return result, fmt.Errorf("read raw beacon response: %w", err)
+ }
+ if len(rawResponse) == 0 {
+ return result, errors.New("raw beacon response must not be empty")
+ }
+ randomnessHex, err := VerifyDrandBeaconResponse(
+ trusted.Definition.BeaconPolicy,
+ closeRecord.BeaconRound,
+ rawResponse,
+ )
+ if err != nil {
+ return result, fmt.Errorf("verify raw drand response: %w", err)
+ }
+
+ evidenceDir := filepath.Join(options.TranscriptRoot, string(options.Phase), "beacon")
+ createdEvidence, err := makeOrResumePrivateDir(evidenceDir)
+ if err != nil {
+ return result, fmt.Errorf("create or resume beacon evidence directory: %w", err)
+ }
+ defer func() {
+ if err != nil && createdEvidence && !publicationWasCommitted(err) {
+ _ = os.RemoveAll(evidenceDir)
+ _ = syncDirectory(filepath.Dir(evidenceDir))
+ }
+ }()
+ if err := requireDirectoryEntriesSubset(evidenceDir, []string{
+ "raw-response.bin",
+ "record.json",
+ "record.sig",
+ }); err != nil {
+ return result, err
+ }
+ result.RawResponsePath = filepath.Join(evidenceDir, "raw-response.bin")
+ result.BeaconPath = filepath.Join(evidenceDir, "record.json")
+ result.SignaturePath = filepath.Join(evidenceDir, "record.sig")
+ rawName, err := logicalPathWithin(options.TranscriptRoot, result.RawResponsePath)
+ if err != nil {
+ return result, err
+ }
+ rawRef := ArtifactRef{Name: rawName, Digest: digestBytes(rawResponse)}
+ beacon, err := NewBeaconRecord(BeaconRecord{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: options.Phase,
+ PhaseID: closeRecord.PhaseID,
+ CloseID: closeRecord.CloseID,
+ Provider: closeRecord.BeaconProvider,
+ Network: closeRecord.BeaconNetwork,
+ Round: closeRecord.BeaconRound,
+ PublishedAt: options.PublishedAt,
+ RawResponse: rawRef,
+ RandomnessHex: randomnessHex,
+ })
+ if err != nil {
+ return result, fmt.Errorf("create beacon record: %w", err)
+ }
+ if err := ValidateBeacon(trusted.Definition, closeRecord, beacon); err != nil {
+ return result, fmt.Errorf("validate beacon record: %w", err)
+ }
+ beaconBytes, beaconSignatureBytes, err := SignRecord(
+ beacon,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ )
+ if err != nil {
+ return result, err
+ }
+ for _, item := range []struct {
+ path string
+ data []byte
+ maximum int64
+ }{
+ {result.RawResponsePath, rawResponse, maxDrandResponseBytes},
+ {result.SignaturePath, beaconSignatureBytes, maxSignedRecordBytes},
+ {result.BeaconPath, beaconBytes, maxSignedRecordBytes},
+ } {
+ if err := requireAbsentOrExact(item.path, item.data, item.maximum); err != nil {
+ return result, err
+ }
+ }
+ if err := writeBytesNoReplaceOrExact(
+ result.RawResponsePath,
+ rawResponse,
+ 0o600,
+ maxDrandResponseBytes,
+ ); err != nil {
+ return result, fmt.Errorf("write raw beacon response: %w", err)
+ }
+ if err := writeSignedRecordNoReplace(
+ result.BeaconPath,
+ result.SignaturePath,
+ beacon,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+ result.Beacon = beacon
+ createdEvidence = false
+ return result, nil
+}
+
+type SealPhase1FilesOptions struct {
+ Trust TrustPaths
+ Circuit *CompiledCircuit
+ TranscriptRoot string
+ ClosePath string
+ CloseSignaturePath string
+ BeaconPath string
+ BeaconSignaturePath string
+ CoordinatorPrivateKeyPath string
+ OutputDir string
+}
+
+type SealPhase1FilesResult struct {
+ Seal SealRecord
+ CommonsPath string
+ SealPath string
+ SignaturePath string
+}
+
+// SealPhase1Files verifies the signed closure and future beacon, replays Phase
+// 1 from immutable files, and publishes native commons plus a signed seal.
+func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil {
+ return result, err
+ }
+ var closeRecord CloseRecord
+ if err := loadCoordinatorSignedRecord(trusted, options.ClosePath, options.CloseSignaturePath, &closeRecord); err != nil {
+ return result, err
+ }
+ var beacon BeaconRecord
+ if err := loadCoordinatorSignedRecord(trusted, options.BeaconPath, options.BeaconSignaturePath, &beacon); err != nil {
+ return result, err
+ }
+ if closeRecord.Phase != Phase1 {
+ return result, errors.New("Phase 1 seal received a non-Phase-1 close record")
+ }
+ chainPath := filepath.Join(options.TranscriptRoot, "phase1", fmt.Sprintf("chain-%04d.json", closeRecord.FinalIndex))
+ chainPaths := PhaseTranscriptPaths{
+ RootDir: options.TranscriptRoot,
+ ChainPath: chainPath,
+ ChainSignaturePath: DefaultSignaturePath(chainPath),
+ }
+ chain, replayedHead, err := loadReplayPhase1FilesState(trusted, options.Circuit, chainPaths)
+ if err != nil {
+ return result, err
+ }
+ if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil {
+ return result, err
+ }
+ if err := ValidateBeacon(trusted.Definition, closeRecord, beacon); err != nil {
+ return result, err
+ }
+ if err := VerifyBeaconRecordFiles(trusted, options.TranscriptRoot, closeRecord, beacon); err != nil {
+ return result, fmt.Errorf("verify archived Phase 1 beacon response: %w", err)
+ }
+ challenge, err := hex.DecodeString(beacon.ChallengeHex)
+ if err != nil || len(challenge) != contributionChallengeSize {
+ return result, fmt.Errorf("Phase 1 beacon challenge must be exactly %d bytes", contributionChallengeSize)
+ }
+ privateKey, _, err := loadMatchingPrivateKey(options.CoordinatorPrivateKeyPath, trusted.Definition.Coordinator)
+ if err != nil {
+ return result, err
+ }
+ commons, err := sealReplayedPhase1Head(
+ options.Circuit.Binding.DomainSize,
+ challenge,
+ replayedHead,
+ )
+ if err != nil {
+ return result, err
+ }
+ createdOutput, err := makeOrResumePrivateDir(options.OutputDir)
+ if err != nil {
+ return result, fmt.Errorf("create or resume Phase 1 seal directory: %w", err)
+ }
+ defer func() {
+ if err != nil && createdOutput && !publicationWasCommitted(err) {
+ _ = os.RemoveAll(options.OutputDir)
+ _ = syncDirectory(filepath.Dir(options.OutputDir))
+ }
+ }()
+ if err := requireDirectoryEntriesSubset(options.OutputDir, []string{
+ "commons.bin",
+ "seal.json",
+ "seal.sig",
+ }); err != nil {
+ return result, err
+ }
+ result.CommonsPath = filepath.Join(options.OutputDir, "commons.bin")
+ result.SealPath = filepath.Join(options.OutputDir, "seal.json")
+ result.SignaturePath = filepath.Join(options.OutputDir, "seal.sig")
+ expectedCommons, err := writerDigest(commons)
+ if err != nil {
+ return result, err
+ }
+ commonsName, err := logicalPathWithin(options.TranscriptRoot, result.CommonsPath)
+ if err != nil {
+ return result, err
+ }
+ commonsRef := ArtifactRef{Name: commonsName, Digest: expectedCommons}
+ seal, err := NewSealRecord(SealRecord{
+ CeremonyID: trusted.Definition.CeremonyID,
+ Phase: Phase1,
+ PhaseID: closeRecord.PhaseID,
+ CloseID: closeRecord.CloseID,
+ BeaconID: beacon.BeaconID,
+ FinalPayload: closeRecord.FinalPayload,
+ Outputs: []ArtifactRef{commonsRef},
+ SealedAt: beacon.PublishedAt,
+ })
+ if err != nil {
+ return result, err
+ }
+ if err := ValidateSeal(closeRecord, beacon, seal); err != nil {
+ return result, err
+ }
+ sealBytes, sealSignatureBytes, err := SignRecord(
+ seal,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ )
+ if err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExactCommons(
+ result.CommonsPath,
+ CommonsShape{DomainN: options.Circuit.Binding.DomainSize},
+ expectedCommons,
+ ); err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExact(result.SignaturePath, sealSignatureBytes, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExact(result.SealPath, sealBytes, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if _, err := writeCommonsFileNoReplaceOrExact(
+ result.CommonsPath,
+ commons,
+ CommonsShape{DomainN: options.Circuit.Binding.DomainSize},
+ expectedCommons,
+ ); err != nil {
+ return result, err
+ }
+ if err := writeSignedRecordNoReplace(
+ result.SealPath,
+ result.SignaturePath,
+ seal,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+ result.Seal = seal
+ createdOutput = false
+ return result, nil
+}
+
+type InitPhase2FilesOptions struct {
+ Trust TrustPaths
+ Circuit *CompiledCircuit
+ TranscriptRoot string
+ Phase1SealPath string
+ Phase1SealSignaturePath string
+ CoordinatorPrivateKeyPath string
+ OutputDir string
+}
+
+type InitPhase2FilesResult struct {
+ Chain Chain
+ GenesisPath string
+ ChainPath string
+ ChainSignaturePath string
+}
+
+// InitializePhase2Files binds signed Phase 1 commons to the exact local R1CS
+// and publishes deterministic Phase 2 genesis and its signed chain.
+func InitializePhase2Files(options InitPhase2FilesOptions) (result InitPhase2FilesResult, err error) {
+ trusted, err := loadOperationalCeremony(options.Trust)
+ if err != nil {
+ return result, err
+ }
+ if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil {
+ return result, err
+ }
+ commons, phase1Seal, _, err := loadPhase1CommonsForPhase2(
+ trusted,
+ options.Circuit,
+ options.TranscriptRoot,
+ options.Phase1SealPath,
+ options.Phase1SealSignaturePath,
+ )
+ if err != nil {
+ return result, err
+ }
+ privateKey, _, err := loadMatchingPrivateKey(options.CoordinatorPrivateKeyPath, trusted.Definition.Coordinator)
+ if err != nil {
+ return result, err
+ }
+ initial, shape, err := InitializePhase2(options.Circuit, commons)
+ if err != nil {
+ return result, err
+ }
+ if !equalPhase2Shape(shape, options.Circuit.Binding.Phase2Shape) {
+ return result, errors.New("initialized Phase 2 shape differs from signed circuit binding")
+ }
+ createdOutput, err := makeOrResumePrivateDir(options.OutputDir)
+ if err != nil {
+ return result, fmt.Errorf("create or resume Phase 2 directory: %w", err)
+ }
+ defer func() {
+ if err != nil && createdOutput && !publicationWasCommitted(err) {
+ _ = os.RemoveAll(options.OutputDir)
+ _ = syncDirectory(filepath.Dir(options.OutputDir))
+ }
+ }()
+ if err := requireDirectoryEntriesSubset(options.OutputDir, []string{
+ "genesis.bin",
+ "chain-0000.json",
+ "chain-0000.sig",
+ }); err != nil {
+ return result, err
+ }
+ result.GenesisPath = filepath.Join(options.OutputDir, "genesis.bin")
+ result.ChainPath = filepath.Join(options.OutputDir, "chain-0000.json")
+ result.ChainSignaturePath = filepath.Join(options.OutputDir, "chain-0000.sig")
+ expectedGenesis, err := writerDigest(initial)
+ if err != nil {
+ return result, err
+ }
+ genesisName, err := logicalPathWithin(options.TranscriptRoot, result.GenesisPath)
+ if err != nil {
+ return result, err
+ }
+ genesisRef := ArtifactRef{Name: genesisName, Digest: expectedGenesis}
+ phaseID, err := ComputePhaseID(
+ trusted.Definition.CeremonyID,
+ Phase2,
+ genesisRef,
+ phase1Seal.SealID,
+ )
+ if err != nil {
+ return result, err
+ }
+ chain, err := NewChain(trusted.Definition.CeremonyID, Phase2, phaseID, genesisRef)
+ if err != nil {
+ return result, err
+ }
+ chainBytes, chainSignatureBytes, err := SignRecord(
+ chain,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ )
+ if err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExactPhase2(result.GenesisPath, shape, expectedGenesis); err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExact(result.ChainSignaturePath, chainSignatureBytes, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if err := requireAbsentOrExact(result.ChainPath, chainBytes, maxSignedRecordBytes); err != nil {
+ return result, err
+ }
+ if _, err := writePhase2FileNoReplaceOrExact(
+ result.GenesisPath,
+ initial,
+ shape,
+ expectedGenesis,
+ ); err != nil {
+ return result, err
+ }
+ if err := writeSignedRecordNoReplace(
+ result.ChainPath,
+ result.ChainSignaturePath,
+ chain,
+ trusted.Definition.Coordinator.KeyID,
+ privateKey,
+ ); err != nil {
+ return result, err
+ }
+ result.Chain = chain
+ createdOutput = false
+ return result, nil
+}
+
+type contributionNames struct {
+ Payload string
+ Attestation string
+ AttestationSignature string
+ Erasure string
+ ErasureSignature string
+ Verification string
+}
+
+func contributionLogicalNames(phase Phase, index int) contributionNames {
+ base := fmt.Sprintf("%s/contributions/%04d", phase, index)
+ return contributionNames{
+ Payload: base + "/contribution.bin",
+ Attestation: base + "/attestation.json",
+ AttestationSignature: base + "/attestation.sig",
+ Erasure: base + "/erasure.json",
+ ErasureSignature: base + "/erasure.sig",
+ Verification: base + "/verification.json",
+ }
+}
+
+func validateTrustedCeremony(trusted *TrustedCeremony) error {
+ if trusted == nil {
+ return errors.New("trusted ceremony is required")
+ }
+ if err := trusted.Definition.Validate(); err != nil {
+ return err
+ }
+ if len(trusted.CoordinatorPublicKey) != ed25519.PublicKeySize {
+ return errors.New("trusted coordinator public key is invalid")
+ }
+ return nil
+}
+
+func validateWorkflowCircuit(trusted *TrustedCeremony, circuit *CompiledCircuit) error {
+ if err := validateTrustedCeremony(trusted); err != nil {
+ return err
+ }
+ return ValidateCircuitBinding(circuit, trusted.Definition.Circuit)
+}
+
+func validateUnboundPhasePolicy(policy PhasePolicy) error {
+ if len(policy.Participants) == 0 {
+ return errors.New("phase participants must not be empty")
+ }
+ if len(policy.Participants) > MaxParticipants {
+ return fmt.Errorf("phase participants exceed maximum %d", MaxParticipants)
+ }
+ if policy.Minimum == 0 || int(policy.Minimum) > len(policy.Participants) {
+ return fmt.Errorf("phase minimum %d must be between 1 and %d", policy.Minimum, len(policy.Participants))
+ }
+ seen := make(map[string]struct{}, len(policy.Participants))
+ for index, participantID := range policy.Participants {
+ if err := validateID("phase participant", participantID); err != nil {
+ return fmt.Errorf("phase participant %d: %w", index, err)
+ }
+ if _, duplicate := seen[participantID]; duplicate {
+ return fmt.Errorf("phase participant %q is duplicated", participantID)
+ }
+ seen[participantID] = struct{}{}
+ }
+ return nil
+}
+
+func loadCanonicalInput(path string, destination any) error {
+ if strings.TrimSpace(path) == "" {
+ return errors.New("canonical input path is required")
+ }
+ data, err := readRegularBounded(path, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ return UnmarshalCanonical(data, destination)
+}
+
+func loadCoordinatorSignedRecord(trusted *TrustedCeremony, recordPath, signaturePath string, destination any) error {
+ if strings.TrimSpace(recordPath) == "" || strings.TrimSpace(signaturePath) == "" {
+ return errors.New("record and signature paths are required")
+ }
+ recordBytes, err := readRegularBounded(recordPath, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signatureBytes, err := readRegularBounded(signaturePath, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ return VerifySignedRecord(
+ recordBytes,
+ signatureBytes,
+ destination,
+ trusted.Definition.Coordinator.KeyID,
+ trusted.CoordinatorPublicKey,
+ )
+}
+
+func loadExternalPublicKey(path string) (ed25519.PublicKey, error) {
+ raw, err := readRegularBounded(path, 4096)
+ if err != nil {
+ return nil, err
+ }
+ return keybundle.DecodePublicKeyHex(strings.TrimSpace(string(raw)))
+}
+
+func identityPublicKey(identity Identity) (ed25519.PublicKey, error) {
+ raw, err := keybundle.DecodePublicKeyHex(identity.Ed25519PublicKeyHex)
+ if err != nil {
+ return nil, err
+ }
+ if identity.PublicKeyFingerprint != taggedSHA256(raw) {
+ return nil, errors.New("identity public-key fingerprint mismatch")
+ }
+ return raw, nil
+}
+
+func loadMatchingPrivateKey(path string, identity Identity) (ed25519.PrivateKey, ed25519.PublicKey, error) {
+ privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ expected, err := identityPublicKey(identity)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !bytes.Equal(publicKey, expected) {
+ return nil, nil, fmt.Errorf("private key does not match identity %q", identity.ID)
+ }
+ return privateKey, publicKey, nil
+}
+
+func readRegularBounded(path string, max int64) ([]byte, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, fmt.Errorf("inspect %q: %w", path, err)
+ }
+ if linkInfo.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("%q must not be a symbolic link", path)
+ }
+ if !linkInfo.Mode().IsRegular() {
+ return nil, fmt.Errorf("%q is not a regular file", path)
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %q: %w", path, err)
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat %q: %w", path, err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("%q is not a regular file", path)
+ }
+ if !os.SameFile(linkInfo, info) {
+ return nil, fmt.Errorf("%q changed while being opened", path)
+ }
+ if info.Size() <= 0 || info.Size() > max {
+ return nil, fmt.Errorf("%q size %d is outside [1,%d]", path, info.Size(), max)
+ }
+ data := make([]byte, info.Size())
+ if _, err := io.ReadFull(f, data); err != nil {
+ return nil, fmt.Errorf("read %q: %w", path, err)
+ }
+ var extra [1]byte
+ if n, err := f.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) {
+ return nil, fmt.Errorf("%q changed while being read", path)
+ }
+ return data, nil
+}
+
+func writeSignedRecordNoReplace(recordPath, signaturePath string, record any, keyID string, privateKey ed25519.PrivateKey) error {
+ recordBytes, signatureBytes, err := SignRecord(record, keyID, privateKey)
+ if err != nil {
+ return err
+ }
+ // Preflight both destinations before publishing either one. Existing
+ // byte-identical artifacts are an interrupted publication that this call
+ // may safely resume; any mismatch is preserved and rejected.
+ if err := requireAbsentOrExact(signaturePath, signatureBytes, maxSignedRecordBytes); err != nil {
+ return err
+ }
+ if err := requireAbsentOrExact(recordPath, recordBytes, maxSignedRecordBytes); err != nil {
+ return err
+ }
+ // Publish the detached signature first and the authenticated record last.
+ // A failure can therefore never expose a newly published record without
+ // its signature. Retrying completes an exact signature-only prefix.
+ if err := writeBytesNoReplaceOrExact(signaturePath, signatureBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return err
+ }
+ if err := writeBytesNoReplaceOrExact(recordPath, recordBytes, 0o600, maxSignedRecordBytes); err != nil {
+ return err
+ }
+ return nil
+}
+
+func requireAbsentOrExact(path string, expected []byte, maximum int64) error {
+ if strings.TrimSpace(path) == "" {
+ return errors.New("output path is required")
+ }
+ if _, err := os.Lstat(path); errors.Is(err, fs.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ actual, err := readRegularBounded(path, maximum)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(actual, expected) {
+ return fmt.Errorf("existing output %q differs from the exact retry artifact: %w", path, fs.ErrExist)
+ }
+ return nil
+}
+
+func writeBytesNoReplaceOrExact(path string, data []byte, mode fs.FileMode, maximum int64) error {
+ if err := writeBytesNoReplace(path, data, mode); err == nil {
+ return nil
+ } else if !errors.Is(err, fs.ErrExist) {
+ return err
+ }
+ return requireAbsentOrExact(path, data, maximum)
+}
+
+func writePhase1FileNoReplaceOrExact(
+ path string,
+ artifact *gnarkmpc.Phase1,
+ shape Phase1Shape,
+ expected Digest,
+) (ArtifactDigest, error) {
+ digest, err := WritePhase1FileNoReplace(path, artifact, shape)
+ if err == nil {
+ if modelDigest(digest) != expected {
+ return ArtifactDigest{}, errors.New("published Phase 1 artifact differs from expected digest")
+ }
+ return digest, nil
+ }
+ if !errors.Is(err, fs.ErrExist) {
+ return ArtifactDigest{}, err
+ }
+ if err := requireAbsentOrExactPhase1(path, shape, expected); err != nil {
+ return ArtifactDigest{}, err
+ }
+ _, digest, err = ReadPhase1File(path, shape)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("existing Phase 1 retry artifact: %w", err)
+ }
+ return digest, nil
+}
+
+func requireAbsentOrExactPhase1(path string, shape Phase1Shape, expected Digest) error {
+ if _, err := os.Lstat(path); errors.Is(err, fs.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ _, digest, err := ReadPhase1File(path, shape)
+ if err != nil {
+ return fmt.Errorf("existing Phase 1 retry artifact: %w", err)
+ }
+ if modelDigest(digest) != expected {
+ return fmt.Errorf("existing Phase 1 retry artifact differs from expected digest: %w", fs.ErrExist)
+ }
+ return nil
+}
+
+func writePhase2FileNoReplaceOrExact(
+ path string,
+ artifact *gnarkmpc.Phase2,
+ shape Phase2Shape,
+ expected Digest,
+) (ArtifactDigest, error) {
+ digest, err := WritePhase2FileNoReplace(path, artifact, shape)
+ if err == nil {
+ if modelDigest(digest) != expected {
+ return ArtifactDigest{}, errors.New("published Phase 2 artifact differs from expected digest")
+ }
+ return digest, nil
+ }
+ if !errors.Is(err, fs.ErrExist) {
+ return ArtifactDigest{}, err
+ }
+ if err := requireAbsentOrExactPhase2(path, shape, expected); err != nil {
+ return ArtifactDigest{}, err
+ }
+ _, digest, err = ReadPhase2File(path, shape)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("existing Phase 2 retry artifact: %w", err)
+ }
+ return digest, nil
+}
+
+func requireAbsentOrExactPhase2(path string, shape Phase2Shape, expected Digest) error {
+ if _, err := os.Lstat(path); errors.Is(err, fs.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ _, digest, err := ReadPhase2File(path, shape)
+ if err != nil {
+ return fmt.Errorf("existing Phase 2 retry artifact: %w", err)
+ }
+ if modelDigest(digest) != expected {
+ return fmt.Errorf("existing Phase 2 retry artifact differs from expected digest: %w", fs.ErrExist)
+ }
+ return nil
+}
+
+func writeCommonsFileNoReplaceOrExact(
+ path string,
+ artifact *gnarkmpc.SrsCommons,
+ shape CommonsShape,
+ expected Digest,
+) (ArtifactDigest, error) {
+ digest, err := WriteCommonsFileNoReplace(path, artifact, shape)
+ if err == nil {
+ if modelDigest(digest) != expected {
+ return ArtifactDigest{}, errors.New("published commons artifact differs from expected digest")
+ }
+ return digest, nil
+ }
+ if !errors.Is(err, fs.ErrExist) {
+ return ArtifactDigest{}, err
+ }
+ if err := requireAbsentOrExactCommons(path, shape, expected); err != nil {
+ return ArtifactDigest{}, err
+ }
+ _, digest, err = ReadCommonsFile(path, shape)
+ if err != nil {
+ return ArtifactDigest{}, fmt.Errorf("existing commons retry artifact: %w", err)
+ }
+ return digest, nil
+}
+
+func requireAbsentOrExactCommons(path string, shape CommonsShape, expected Digest) error {
+ if _, err := os.Lstat(path); errors.Is(err, fs.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ _, digest, err := ReadCommonsFile(path, shape)
+ if err != nil {
+ return fmt.Errorf("existing commons retry artifact: %w", err)
+ }
+ if modelDigest(digest) != expected {
+ return fmt.Errorf("existing commons retry artifact differs from expected digest: %w", fs.ErrExist)
+ }
+ return nil
+}
+
+func makeOrResumePrivateDir(path string) (created bool, err error) {
+ if strings.TrimSpace(path) == "" {
+ return false, errors.New("output directory is required")
+ }
+ if err := os.Mkdir(path, 0o700); err == nil {
+ if err := syncDirectory(filepath.Dir(path)); err != nil {
+ _ = os.Remove(path)
+ _ = syncDirectory(filepath.Dir(path))
+ return false, err
+ }
+ return true, nil
+ } else if !errors.Is(err, fs.ErrExist) {
+ return false, err
+ }
+ return false, requirePrivateRealDirectory(path)
+}
+
+func requirePrivateRealDirectory(path string) error {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("retry output %q is not a real directory", path)
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ return fmt.Errorf("retry output directory %q has group/world permissions", path)
+ }
+ return nil
+}
+
+func requireDirectoryEntriesSubset(dir string, allowed []string) error {
+ allowedSet := make(map[string]struct{}, len(allowed))
+ for _, name := range allowed {
+ if filepath.Base(name) != name || name == "." || name == "" {
+ return fmt.Errorf("invalid allowed retry entry %q", name)
+ }
+ allowedSet[name] = struct{}{}
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+ removedTemporary := false
+ for _, entry := range entries {
+ if _, ok := allowedSet[entry.Name()]; ok {
+ continue
+ }
+ temporary := false
+ for name := range allowedSet {
+ if strings.HasPrefix(entry.Name(), "."+name+".partial-") {
+ temporary = true
+ break
+ }
+ }
+ if !temporary {
+ return fmt.Errorf("retry directory %q contains unexpected entry %q", dir, entry.Name())
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("retry temporary entry %q is not a regular file", entry.Name())
+ }
+ if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
+ return fmt.Errorf("remove unpublished retry temporary %q: %w", entry.Name(), err)
+ }
+ removedTemporary = true
+ }
+ if removedTemporary {
+ return syncDirectory(dir)
+ }
+ return nil
+}
+
+func mkdirAllPrivateDurable(path string) error {
+ if strings.TrimSpace(path) == "" {
+ return errors.New("directory path is required")
+ }
+ info, err := os.Lstat(path)
+ if err == nil {
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("directory path %q is not a real directory", path)
+ }
+ return nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ parent := filepath.Dir(path)
+ if parent == path {
+ return fmt.Errorf("cannot create directory root %q", path)
+ }
+ if err := mkdirAllPrivateDurable(parent); err != nil {
+ return err
+ }
+ if err := os.Mkdir(path, 0o700); err != nil {
+ if !errors.Is(err, fs.ErrExist) {
+ return err
+ }
+ if info, statErr := os.Lstat(path); statErr != nil ||
+ !info.IsDir() ||
+ info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("concurrent directory creation for %q was not a real directory", path)
+ }
+ return nil
+ }
+ if err := syncDirectory(parent); err != nil {
+ _ = os.Remove(path)
+ _ = syncDirectory(parent)
+ return err
+ }
+ return nil
+}
+
+func writeWriterToNoReplace(path string, src io.WriterTo, expected Digest) (ArtifactDigest, error) {
+ if err := expected.Validate(); err != nil {
+ return ArtifactDigest{}, err
+ }
+ return atomicWriteNoReplace(
+ path,
+ expected.Size,
+ src.WriteTo,
+ func(tempPath string) (ArtifactDigest, error) {
+ digest, err := digestRegularFile(tempPath, expected.Size)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ if modelDigest(digest) != expected {
+ return ArtifactDigest{}, errors.New("native artifact digest differs from expected binding")
+ }
+ return digest, nil
+ },
+ )
+}
+
+func digestRegularFile(path string, expectedSize int64) (ArtifactDigest, error) {
+ f, err := openRegularExact(path, expectedSize)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ defer f.Close()
+ dataHash := newDualHash()
+ n, err := io.Copy(dataHash, f)
+ if err != nil {
+ return ArtifactDigest{}, err
+ }
+ if n != expectedSize {
+ return ArtifactDigest{}, fmt.Errorf("hashed %d bytes, expected %d", n, expectedSize)
+ }
+ return dataHash.digest(n, nil), nil
+}
+
+type dualHash struct {
+ sha hashWriter
+ blake hashWriter
+}
+
+type hashWriter interface {
+ io.Writer
+ Sum([]byte) []byte
+}
+
+func newDualHash() *dualHash {
+ blake, _ := blake2bNew256()
+ return &dualHash{sha: sha256.New(), blake: blake}
+}
+
+func (h *dualHash) Write(p []byte) (int, error) {
+ n, err := h.sha.Write(p)
+ if err != nil {
+ return n, err
+ }
+ n2, err := h.blake.Write(p)
+ if err != nil {
+ return n2, err
+ }
+ if n2 != n {
+ return n2, io.ErrShortWrite
+ }
+ return n, nil
+}
+
+func (h *dualHash) digest(size int64, challenge []byte) ArtifactDigest {
+ var result ArtifactDigest
+ result.Size = size
+ copy(result.SHA256[:], h.sha.Sum(nil))
+ copy(result.BLAKE2b256[:], h.blake.Sum(nil))
+ result.Challenge = bytes.Clone(challenge)
+ return result
+}
+
+// Kept behind a helper so the impossible nil-key initialization error has one
+// audited handling point.
+func blake2bNew256() (hashWriter, error) {
+ return blake2b.New256(nil)
+}
+
+func artifactDigestBytes(data []byte) ArtifactDigest {
+ sha := sha256.Sum256(data)
+ blake := blake2b.Sum256(data)
+ return ArtifactDigest{
+ Size: int64(len(data)),
+ SHA256: sha,
+ BLAKE2b256: blake,
+ }
+}
+
+func digestBytes(data []byte) Digest {
+ return modelDigest(artifactDigestBytes(data))
+}
+
+func modelDigest(d ArtifactDigest) Digest {
+ return Digest{
+ SHA256: "sha256:" + hex.EncodeToString(d.SHA256[:]),
+ Blake2b256: "blake2b256:" + hex.EncodeToString(d.BLAKE2b256[:]),
+ Size: d.Size,
+ }
+}
+
+func resolveArtifactPath(root, name string) (string, error) {
+ if err := validateArtifactName(name); err != nil {
+ return "", err
+ }
+ rootAbs, err := filepath.Abs(root)
+ if err != nil {
+ return "", err
+ }
+ pathAbs, err := filepath.Abs(filepath.Join(rootAbs, filepath.FromSlash(name)))
+ if err != nil {
+ return "", err
+ }
+ rel, err := filepath.Rel(rootAbs, pathAbs)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return "", fmt.Errorf("artifact %q escapes transcript root", name)
+ }
+ if err := rejectSymlinkComponents(pathAbs); err != nil {
+ return "", fmt.Errorf("artifact %q path: %w", name, err)
+ }
+ return pathAbs, nil
+}
+
+func logicalPathWithin(root, path string) (string, error) {
+ rootAbs, err := filepath.Abs(root)
+ if err != nil {
+ return "", err
+ }
+ pathAbs, err := filepath.Abs(path)
+ if err != nil {
+ return "", err
+ }
+ rel, err := filepath.Rel(rootAbs, pathAbs)
+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return "", errors.New("output path must be inside the transcript root")
+ }
+ if err := rejectSymlinkComponents(pathAbs); err != nil {
+ return "", fmt.Errorf("output path: %w", err)
+ }
+ name := filepath.ToSlash(rel)
+ if err := validateArtifactName(name); err != nil {
+ return "", err
+ }
+ return name, nil
+}
+
+// rejectSymlinkComponents is the portable transcript path defense. It rejects
+// every existing symlink component before an open. It does not claim the
+// race-free guarantee of Linux openat2 with RESOLVE_NO_SYMLINKS.
+func rejectSymlinkComponents(path string) error {
+ pathAbs, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ volume := filepath.VolumeName(pathAbs)
+ remainder := strings.TrimPrefix(pathAbs, volume)
+ remainder = strings.TrimLeft(remainder, string(filepath.Separator))
+ current := volume + string(filepath.Separator)
+ parts := strings.Split(remainder, string(filepath.Separator))
+ for index, part := range parts {
+ if part == "" || part == "." {
+ continue
+ }
+ current = filepath.Join(current, part)
+ info, err := os.Lstat(current)
+ if errors.Is(err, fs.ErrNotExist) {
+ // Missing descendants cannot currently redirect resolution.
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("symbolic-link component %q is forbidden", current)
+ }
+ if index < len(parts)-1 && !info.IsDir() {
+ return fmt.Errorf("path component %q is not a directory", current)
+ }
+ }
+ return nil
+}
+
+func verifyArtifactBytes(root string, ref ArtifactRef, max int64) ([]byte, error) {
+ path, err := resolveArtifactPath(root, ref.Name)
+ if err != nil {
+ return nil, err
+ }
+ data, err := readRegularBounded(path, max)
+ if err != nil {
+ return nil, err
+ }
+ if digestBytes(data) != ref.Digest {
+ return nil, fmt.Errorf("artifact %q digest mismatch", ref.Name)
+ }
+ return data, nil
+}
+
+func verifyChainFiles(trusted *TrustedCeremony, root string, chain Chain, basePhase2Shape Phase2Shape) error {
+ if chain.Phase == Phase1 {
+ path, err := resolveArtifactPath(root, chain.Genesis.Name)
+ if err != nil {
+ return err
+ }
+ _, digest, err := ReadPhase1File(path, Phase1Shape{DomainN: trusted.Definition.Circuit.DomainSize})
+ if err != nil {
+ return err
+ }
+ if modelDigest(digest) != chain.Genesis.Digest {
+ return errors.New("Phase 1 genesis digest mismatch")
+ }
+ } else {
+ path, err := resolveArtifactPath(root, chain.Genesis.Name)
+ if err != nil {
+ return err
+ }
+ shape := basePhase2Shape
+ shape.ChallengeLength = 0
+ _, digest, err := ReadPhase2File(path, shape)
+ if err != nil {
+ return err
+ }
+ if modelDigest(digest) != chain.Genesis.Digest {
+ return errors.New("Phase 2 genesis digest mismatch")
+ }
+ }
+
+ prefix := chain
+ prefix.Records = nil
+ previous := chain.Genesis
+ for i, record := range chain.Records {
+ path, err := resolveArtifactPath(root, record.OutputPayload.Name)
+ if err != nil {
+ return err
+ }
+ var nativeDigest ArtifactDigest
+ switch chain.Phase {
+ case Phase1:
+ _, nativeDigest, err = ReadPhase1File(path, Phase1Shape{
+ DomainN: trusted.Definition.Circuit.DomainSize,
+ ChallengeLength: contributionChallengeSize,
+ })
+ case Phase2:
+ shape := contributionPhase2Shape(basePhase2Shape)
+ _, nativeDigest, err = ReadPhase2File(path, shape)
+ }
+ if err != nil {
+ return fmt.Errorf("record %d native payload: %w", i, err)
+ }
+ if modelDigest(nativeDigest) != record.OutputPayload.Digest {
+ return fmt.Errorf("record %d output payload digest mismatch", i)
+ }
+ if err := requireChallengeMatchesDigest(nativeDigest.Challenge, previous.Digest); err != nil {
+ return fmt.Errorf("record %d: %w", i, err)
+ }
+ attestationBytes, err := verifyArtifactBytes(root, record.Attestation, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ signatureBytes, err := verifyArtifactBytes(root, record.AttestationSignature, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var attestation ContributionAttestation
+ participant, ok := trusted.Definition.ParticipantByID(record.ParticipantID)
+ if !ok {
+ return fmt.Errorf("record %d participant missing", i)
+ }
+ publicKey, err := identityPublicKey(participant.Identity)
+ if err != nil {
+ return err
+ }
+ if err := VerifySignedRecord(
+ attestationBytes,
+ signatureBytes,
+ &attestation,
+ participant.Identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return fmt.Errorf("record %d participant attestation: %w", i, err)
+ }
+ erasureBytes, err := verifyArtifactBytes(root, record.Erasure, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ erasureSignatureBytes, err := verifyArtifactBytes(root, record.ErasureSignature, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var erasure ErasureAttestation
+ if err := VerifySignedRecord(
+ erasureBytes,
+ erasureSignatureBytes,
+ &erasure,
+ participant.Identity.KeyID,
+ publicKey,
+ ); err != nil {
+ return fmt.Errorf("record %d erasure attestation: %w", i, err)
+ }
+ if erasure.ErasureID != record.ErasureID {
+ return fmt.Errorf("record %d erasure ID mismatch", i)
+ }
+ if err := ValidateErasureForContribution(attestation, erasure); err != nil {
+ return fmt.Errorf("record %d erasure binding: %w", i, err)
+ }
+ verificationBytes, err := verifyArtifactBytes(root, record.Verification, maxSignedRecordBytes)
+ if err != nil {
+ return err
+ }
+ var verification ContributionVerification
+ if err := UnmarshalCanonical(verificationBytes, &verification); err != nil {
+ return fmt.Errorf("record %d verification: %w", i, err)
+ }
+ if err := validateContributionVerification(record, verification); err != nil {
+ return fmt.Errorf("record %d verification: %w", i, err)
+ }
+ if err := ValidateAttestationAcceptance(trusted.Definition, prefix, attestation, erasure, record); err != nil {
+ return fmt.Errorf("record %d acceptance: %w", i, err)
+ }
+ if err := prefix.Append(record); err != nil {
+ return err
+ }
+ previous = record.OutputPayload
+ }
+ return nil
+}
+
+func phase1FileLoader(root string, chain Chain, domainN uint64) 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)
+ }
+ path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name)
+ if err != nil {
+ return nil, err
+ }
+ artifact, _, err := ReadPhase1File(path, Phase1Shape{DomainN: domainN, ChallengeLength: contributionChallengeSize})
+ return artifact, err
+ }
+}
+
+func phase2FileLoader(root string, chain Chain, shape Phase2Shape) 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)
+ }
+ path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name)
+ if err != nil {
+ return nil, err
+ }
+ artifact, _, err := ReadPhase2File(path, shape)
+ return artifact, err
+ }
+}
+
+func contributionPhase2Shape(base Phase2Shape) Phase2Shape {
+ shape := base
+ shape.SigmaCKK = slices.Clone(base.SigmaCKK)
+ shape.ChallengeLength = contributionChallengeSize
+ return shape
+}
+
+func requireChallengeMatchesDigest(challenge []byte, digest Digest) error {
+ if !strings.HasPrefix(digest.SHA256, "sha256:") {
+ return errors.New("previous payload SHA-256 is not tagged")
+ }
+ raw, err := hex.DecodeString(strings.TrimPrefix(digest.SHA256, "sha256:"))
+ if err != nil || len(raw) != sha256.Size {
+ return errors.New("previous payload SHA-256 is invalid")
+ }
+ if !bytes.Equal(challenge, raw) {
+ return errors.New("contribution challenge does not equal previous payload SHA-256")
+ }
+ return nil
+}
+
+// loadAuthenticatedPhase1CommonsForCoordinator is restricted to coordinator
+// acceptance of a candidate Phase 2 edge. It authenticates the already
+// published seal and commons, while loadVerifiedPhase2Files binds those
+// commons to the coordinator-signed Phase 2 genesis. Participant contribution,
+// initialization, closure, finalization, and audit paths must instead call
+// loadPhase1CommonsForPhase2, which independently replays Phase 1 and derives
+// the commons before any secret randomness is sampled.
+func loadAuthenticatedPhase1CommonsForCoordinator(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ transcriptRoot, sealPath, sealSignaturePath string,
+) (*gnarkmpc.SrsCommons, SealRecord, CloseRecord, error) {
+ if strings.TrimSpace(sealPath) == "" || strings.TrimSpace(sealSignaturePath) == "" {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("signed Phase 1 seal paths are required")
+ }
+ var seal SealRecord
+ if err := loadCoordinatorSignedRecord(trusted, sealPath, sealSignaturePath, &seal); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ if seal.CeremonyID != trusted.Definition.CeremonyID || seal.Phase != Phase1 {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("Phase 1 seal ceremony or phase mismatch")
+ }
+ closePath := filepath.Join(
+ transcriptRoot,
+ string(Phase1),
+ closePublicationDirectoryName,
+ closeRecordFilename,
+ )
+ closeSignaturePath := filepath.Join(
+ transcriptRoot,
+ string(Phase1),
+ closePublicationDirectoryName,
+ closeSignatureFilename,
+ )
+ var closeRecord CloseRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ closePath,
+ closeSignaturePath,
+ &closeRecord,
+ ); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("load closed Phase 1 for coordinator acceptance: %w", err)
+ }
+ beaconPath := filepath.Join(transcriptRoot, string(Phase1), "beacon", "record.json")
+ beaconSignaturePath := filepath.Join(transcriptRoot, string(Phase1), "beacon", "record.sig")
+ var beacon BeaconRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ beaconPath,
+ beaconSignaturePath,
+ &beacon,
+ ); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("load Phase 1 beacon for coordinator acceptance: %w", err)
+ }
+ if err := VerifyBeaconRecordFiles(trusted, transcriptRoot, closeRecord, beacon); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("verify Phase 1 beacon for coordinator acceptance: %w", err)
+ }
+ if err := ValidateSeal(closeRecord, beacon, seal); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("validate Phase 1 seal for coordinator acceptance: %w", err)
+ }
+ commonsRef, err := phase1CommonsOutput(seal)
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ path, err := resolveArtifactPath(transcriptRoot, commonsRef.Name)
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ commons, digest, err := ReadCommonsFile(
+ path,
+ CommonsShape{DomainN: circuit.Binding.DomainSize},
+ )
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ if modelDigest(digest) != commonsRef.Digest {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("Phase 1 commons digest does not match signed seal")
+ }
+ return commons, seal, closeRecord, nil
+}
+
+func phase1CommonsOutput(seal SealRecord) (ArtifactRef, error) {
+ var commonsRef *ArtifactRef
+ for i := range seal.Outputs {
+ if strings.HasSuffix(seal.Outputs[i].Name, "/commons.bin") ||
+ seal.Outputs[i].Name == "commons.bin" {
+ if commonsRef != nil {
+ return ArtifactRef{}, errors.New("Phase 1 seal contains multiple commons outputs")
+ }
+ commonsRef = &seal.Outputs[i]
+ }
+ }
+ if commonsRef == nil {
+ return ArtifactRef{}, errors.New("Phase 1 seal does not contain commons.bin")
+ }
+ return *commonsRef, nil
+}
+
+func loadPhase1CommonsForPhase2(
+ trusted *TrustedCeremony,
+ circuit *CompiledCircuit,
+ transcriptRoot, sealPath, sealSignaturePath string,
+) (*gnarkmpc.SrsCommons, SealRecord, CloseRecord, error) {
+ if strings.TrimSpace(sealPath) == "" || strings.TrimSpace(sealSignaturePath) == "" {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("signed Phase 1 seal paths are required")
+ }
+ var seal SealRecord
+ if err := loadCoordinatorSignedRecord(trusted, sealPath, sealSignaturePath, &seal); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ if seal.CeremonyID != trusted.Definition.CeremonyID || seal.Phase != Phase1 {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("Phase 1 seal ceremony or phase mismatch")
+ }
+
+ // A coordinator signature makes the seal attributable, but it does not
+ // make the sealed state valid. Every Phase 2 operation independently
+ // verifies the complete closed Phase 1 transcript before it accepts the
+ // derived commons. This prevents a malicious or mistaken coordinator from
+ // inducing Phase 2 participants to contribute against an invalid Phase 1
+ // state and deferring discovery until finalization.
+ closePath := filepath.Join(
+ transcriptRoot,
+ string(Phase1),
+ closePublicationDirectoryName,
+ closeRecordFilename,
+ )
+ closeSignaturePath := filepath.Join(
+ transcriptRoot,
+ string(Phase1),
+ closePublicationDirectoryName,
+ closeSignatureFilename,
+ )
+ var closeRecord CloseRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ closePath,
+ closeSignaturePath,
+ &closeRecord,
+ ); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("load closed Phase 1 for Phase 2: %w", err)
+ }
+ if closeRecord.CeremonyID != trusted.Definition.CeremonyID ||
+ closeRecord.Phase != Phase1 {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("Phase 1 closure ceremony or phase mismatch")
+ }
+ chainPath := filepath.Join(
+ transcriptRoot,
+ string(Phase1),
+ fmt.Sprintf("chain-%04d.json", closeRecord.FinalIndex),
+ )
+ chain, replayedHead, err := loadReplayPhase1FilesState(
+ trusted,
+ circuit,
+ PhaseTranscriptPaths{
+ RootDir: transcriptRoot,
+ ChainPath: chainPath,
+ ChainSignaturePath: DefaultSignaturePath(chainPath),
+ },
+ )
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("replay closed Phase 1 for Phase 2: %w", err)
+ }
+ if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("validate closed Phase 1 for Phase 2: %w", err)
+ }
+ beaconPath := filepath.Join(transcriptRoot, string(Phase1), "beacon", "record.json")
+ beaconSignaturePath := filepath.Join(transcriptRoot, string(Phase1), "beacon", "record.sig")
+ var beacon BeaconRecord
+ if err := loadCoordinatorSignedRecord(
+ trusted,
+ beaconPath,
+ beaconSignaturePath,
+ &beacon,
+ ); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("load Phase 1 beacon for Phase 2: %w", err)
+ }
+ if err := VerifyBeaconRecordFiles(trusted, transcriptRoot, closeRecord, beacon); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("verify Phase 1 beacon for Phase 2: %w", err)
+ }
+ if err := ValidateSeal(closeRecord, beacon, seal); err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf("validate Phase 1 seal for Phase 2: %w", err)
+ }
+ challenge, err := hex.DecodeString(beacon.ChallengeHex)
+ if err != nil || len(challenge) != contributionChallengeSize {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf(
+ "Phase 1 beacon challenge must be exactly %d bytes",
+ contributionChallengeSize,
+ )
+ }
+ derivedCommons, err := sealReplayedPhase1Head(
+ circuit.Binding.DomainSize,
+ challenge,
+ replayedHead,
+ )
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf(
+ "derive Phase 1 commons from authenticated chain and beacon: %w",
+ err,
+ )
+ }
+ derivedDigest, err := writerDigest(derivedCommons)
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, fmt.Errorf(
+ "digest derived Phase 1 commons: %w",
+ err,
+ )
+ }
+
+ commonsRef, err := phase1CommonsOutput(seal)
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ path, err := resolveArtifactPath(transcriptRoot, commonsRef.Name)
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ _, digest, err := ReadCommonsFile(path, CommonsShape{DomainN: circuit.Binding.DomainSize})
+ if err != nil {
+ return nil, SealRecord{}, CloseRecord{}, err
+ }
+ if modelDigest(digest) != commonsRef.Digest {
+ return nil, SealRecord{}, CloseRecord{}, errors.New("Phase 1 commons digest does not match signed seal")
+ }
+ if derivedDigest != commonsRef.Digest {
+ return nil, SealRecord{}, CloseRecord{}, errors.New(
+ "Phase 1 commons were not derived from the authenticated accepted chain and beacon",
+ )
+ }
+ return derivedCommons, seal, closeRecord, nil
+}
diff --git a/internal/mpcceremony/workflow_test.go b/internal/mpcceremony/workflow_test.go
new file mode 100644
index 00000000..47def260
--- /dev/null
+++ b/internal/mpcceremony/workflow_test.go
@@ -0,0 +1,132 @@
+package mpcceremony
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestResolveArtifactPathRejectsSymlinkComponent(t *testing.T) {
+ root := t.TempDir()
+ outside := t.TempDir()
+ if err := os.WriteFile(filepath.Join(outside, "genesis.bin"), []byte("outside"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(root, "phase1")); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ if _, err := resolveArtifactPath(root, "phase1/genesis.bin"); err == nil ||
+ !strings.Contains(err.Error(), "symbolic-link") {
+ t.Fatalf("resolve through symlink error = %v, want symbolic-link rejection", err)
+ }
+}
+
+func TestReadRegularBoundedRejectsSymlinkLeaf(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(root, "target.json")
+ if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(root, "link.json")
+ if err := os.Symlink(target, link); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ if _, err := readRegularBounded(link, 1024); err == nil ||
+ !strings.Contains(err.Error(), "symbolic link") {
+ t.Fatalf("read symlink error = %v, want symbolic-link rejection", err)
+ }
+}
+
+func TestCanonicalInitInputsRejectUnknownAndNonCanonicalJSON(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "environment.json")
+ valid := ContributionEnvironment{
+ OS: "linux",
+ Architecture: "amd64",
+ EntropySource: "operating-system-csprng",
+ SwapDisabled: true,
+ CrashDumpsDisabled: true,
+ TelemetryDisabled: true,
+ EphemeralEnvironment: true,
+ EphemeralDestructionRequired: true,
+ }
+ data, err := MarshalCanonical(valid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadContributionEnvironment(path); err != nil {
+ t.Fatalf("load canonical environment: %v", err)
+ }
+ if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadContributionEnvironment(path); err == nil {
+ t.Fatal("non-canonical JSON with trailing newline was accepted")
+ }
+}
+
+func TestLoadSignedDefinitionAuthenticatesBeforeSemanticParsing(t *testing.T) {
+ definition := adversarialDefinition(t)
+ definitionBytes, err := MarshalCanonical(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)
+ }
+
+ // This remains canonical JSON but is semantically invalid. Because its
+ // detached signature covers the original bytes, authentication must fail
+ // before the invalid mode is interpreted.
+ tamperedDefinition := bytes.Replace(
+ definitionBytes,
+ []byte(`"mode":"production"`),
+ []byte(`"mode":"invalid-mode"`),
+ 1,
+ )
+ if bytes.Equal(tamperedDefinition, definitionBytes) {
+ t.Fatal("test did not tamper definition")
+ }
+
+ 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, tamperedDefinition, 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 {
+ t.Fatal("tampered definition unexpectedly accepted")
+ }
+ if !strings.Contains(err.Error(), "signed-data digest mismatch") {
+ t.Fatalf("LoadSignedDefinition() error = %v, want authentication failure before semantic parsing", err)
+ }
+ if strings.Contains(err.Error(), "mode") {
+ t.Fatalf("LoadSignedDefinition() parsed unauthenticated mode before verification: %v", err)
+ }
+}
diff --git a/internal/msmengine/async_contract.go b/internal/msmengine/async_contract.go
index 3ef0a515..5ed45af3 100644
--- a/internal/msmengine/async_contract.go
+++ b/internal/msmengine/async_contract.go
@@ -9,31 +9,92 @@ import (
const asyncQueueShardMultiplier = 16
-// A terminated Web Worker does not guarantee an error event or reply. Bound
-// every section wait so a silently dead worker fails closed instead of leaving
-// the proof parked forever. This remains below the fault-suite's external
-// deadline while leaving ample headroom over measured single-shard work.
+// A terminated Web Worker does not guarantee an error event or reply. The
+// inactivity lease therefore remains bounded, but authenticated worker
+// progress may renew it. The absolute deadline prevents a faulty worker from
+// extending a request forever. Healthy proofs finish before either timer fires.
//
//nolint:unused // used by sharded_js.go under the js && wasm build tags, which golangci-lint does not analyze
-const asyncWorkerReplyTimeout = 5 * time.Minute
+const (
+ asyncWorkerInactivityTimeout = 5 * time.Minute
+ asyncWorkerAbsoluteTimeout = 20 * time.Minute
+ asyncShardMaxAttempts = 3
+)
var errAsyncWaitCancelled = errors.New("asynchronous section MSM cancelled")
func waitForAsyncResult[T any](results <-chan T, cancel <-chan struct{}, timeout time.Duration) (T, error) {
+ return waitForAsyncResultWithProgress(results, cancel, timeout, timeout, nil)
+}
+
+// waitForAsyncResultWithProgress waits without polling on the healthy path.
+// When the inactivity timer fires, it samples progress exactly once. Strictly
+// increasing progress renews the lease; otherwise the request fails closed.
+// This keeps normal proof execution to the same one-timer/select shape while
+// allowing a slow authenticated chunk stream to continue.
+func waitForAsyncResultWithProgress[T any](
+ results <-chan T,
+ cancel <-chan struct{},
+ inactivityTimeout time.Duration,
+ absoluteTimeout time.Duration,
+ progress func() uint64,
+) (T, error) {
var zero T
- if timeout <= 0 {
- return zero, fmt.Errorf("async worker reply timeout must be positive")
+ if inactivityTimeout <= 0 {
+ return zero, fmt.Errorf("async worker inactivity timeout must be positive")
}
- timer := time.NewTimer(timeout)
+ if absoluteTimeout <= 0 {
+ return zero, fmt.Errorf("async worker absolute timeout must be positive")
+ }
+ started := time.Now()
+ var lastProgress uint64
+ timer := time.NewTimer(minDuration(inactivityTimeout, absoluteTimeout))
defer timer.Stop()
- select {
- case result := <-results:
- return result, nil
- case <-cancel:
- return zero, errAsyncWaitCancelled
- case <-timer.C:
- return zero, fmt.Errorf("worker reply timed out after %s", timeout)
+ for {
+ select {
+ case result := <-results:
+ return result, nil
+ case <-cancel:
+ return zero, errAsyncWaitCancelled
+ case <-timer.C:
+ elapsed := time.Since(started)
+ if elapsed >= absoluteTimeout {
+ return zero, fmt.Errorf("worker reply exceeded absolute deadline %s", absoluteTimeout)
+ }
+ currentProgress := lastProgress
+ if progress != nil {
+ currentProgress = progress()
+ }
+ if currentProgress <= lastProgress {
+ return zero, fmt.Errorf("worker reply made no observed progress within %s", inactivityTimeout)
+ }
+ lastProgress = currentProgress
+ remaining := absoluteTimeout - elapsed
+ timer.Reset(minDuration(inactivityTimeout, remaining))
+ }
+ }
+}
+
+func minDuration(a, b time.Duration) time.Duration {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func asyncRetryBackoff(attempt, workerSlot int) time.Duration {
+ if attempt < 2 {
+ attempt = 2
+ }
+ shift := attempt - 2
+ if shift > 3 {
+ shift = 3
}
+ base := 250 * time.Millisecond * time.Duration(1< 500*time.Millisecond {
+ t.Fatalf("absolute watchdog took %s, want bounded completion", elapsed)
+ }
+}
+
+func TestAsyncRetryBackoffIsBoundedAndStaggered(t *testing.T) {
+ first := asyncRetryBackoff(2, 0)
+ second := asyncRetryBackoff(3, 0)
+ otherWorker := asyncRetryBackoff(2, 1)
+ if first < 250*time.Millisecond || second <= first {
+ t.Fatalf("retry backoff did not increase: first=%s second=%s", first, second)
+ }
+ if otherWorker == first {
+ t.Fatalf("worker jitter did not stagger retries: both=%s", first)
+ }
+ if got := asyncRetryBackoff(99, 99); got > 3*time.Second {
+ t.Fatalf("retry backoff %s exceeds bounded maximum", got)
+ }
+}
diff --git a/internal/msmengine/async_js.go b/internal/msmengine/async_js.go
index 4fa880b9..be974bd8 100644
--- a/internal/msmengine/async_js.go
+++ b/internal/msmengine/async_js.go
@@ -39,6 +39,7 @@ type asyncShardTask struct {
r [2]int
requestID int
affinityWorker int
+ attempt int
}
type sectionScheduler struct {
@@ -58,7 +59,7 @@ func (s *shardedMSM) scheduler() *sectionScheduler {
if s.async == nil {
s.async = §ionScheduler{
owner: s,
- busy: make([]bool, len(s.pool.workers)),
+ busy: make([]bool, s.pool.count()),
handles: make(map[*asyncSectionHandle]struct{}),
cancel: make(chan struct{}),
}
@@ -121,7 +122,7 @@ func (s *shardedMSM) dispatchSection(g2 bool, plan *PKSectionPlan, section strin
}
q.queue = append(q.queue, asyncShardTask{
handle: h, index: i, r: r, requestID: q.nextRequestID,
- affinityWorker: affinityWorker,
+ affinityWorker: affinityWorker, attempt: 1,
})
}
EmitTrace("measure", "async-msm-queue", map[string]any{
@@ -196,7 +197,11 @@ func (s *sectionScheduler) pumpLocked() {
func (s *sectionScheduler) run(workerSlot int, task asyncShardTask) {
h := task.handle
- w := s.owner.pool.workers[workerSlot]
+ w := s.owner.pool.worker(workerSlot)
+ if w == nil {
+ s.fail(workerSlot, failClosed("worker-terminated", errors.New("worker slot is unavailable")))
+ return
+ }
totalStart := time.Now()
scalarStart := time.Now()
scsBuf := marshalScalars(h.scalars[task.r[0]:task.r[1]])
@@ -217,6 +222,10 @@ func (s *sectionScheduler) run(workerSlot int, task asyncShardTask) {
"worker_turnaround_ms": workerMS, "worker_compute_ms": reply.computeMS,
"total_ms": elapsedMS(totalStart), "error": errorString(reply.err), "async_dispatch": true,
}
+ if task.attempt > 1 {
+ fields["attempt"] = task.attempt
+ fields["attempt_max"] = asyncShardMaxAttempts
+ }
addTraceFields(fields, reply.timings)
addByteTraceFields(fields, reply.bytes)
group, op := "g1", "DispatchG1Section"
@@ -225,6 +234,9 @@ func (s *sectionScheduler) run(workerSlot int, task asyncShardTask) {
}
emitShardTrace(op, group, task.index, w.id, task.r, h.n, fields)
if reply.err != nil {
+ if s.retry(workerSlot, w, task, reply.err) {
+ return
+ }
s.fail(workerSlot, reply.err)
return
}
@@ -250,6 +262,56 @@ func (s *sectionScheduler) run(workerSlot int, task asyncShardTask) {
s.complete(workerSlot, task)
}
+func (s *sectionScheduler) retry(workerSlot int, failedWorker *worker, task asyncShardTask, cause error) bool {
+ retryable, replace, retryAfter := sectionWorkerRetry(cause)
+ if !retryable || task.attempt >= asyncShardMaxAttempts {
+ return false
+ }
+ s.mu.Lock()
+ if s.terminal != nil {
+ s.mu.Unlock()
+ return true
+ }
+ s.mu.Unlock()
+ if replace {
+ if _, err := s.owner.pool.replace(workerSlot, failedWorker); err != nil {
+ s.fail(workerSlot, failClosed("worker-terminated", fmt.Errorf("replace worker slot %d: %w", workerSlot, err)))
+ return true
+ }
+ }
+ task.attempt++
+ s.mu.Lock()
+ if s.terminal != nil {
+ s.mu.Unlock()
+ return true
+ }
+ s.nextRequestID++
+ task.requestID = s.nextRequestID
+ s.mu.Unlock()
+ delay := asyncRetryBackoff(task.attempt, workerSlot)
+ if retryAfter > delay {
+ delay = retryAfter
+ }
+ EmitTrace("measure", "msm-shard-retry", map[string]any{
+ "section": task.handle.section, "shard_index": task.index,
+ "worker_id": failedWorker.id, "attempt": task.attempt,
+ "attempt_max": asyncShardMaxAttempts, "replace_worker": replace,
+ "backoff_ms": float64(delay) / float64(time.Millisecond),
+ "error": cause.Error(),
+ })
+ go func() {
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-s.cancel:
+ return
+ case <-timer.C:
+ s.run(workerSlot, task)
+ }
+ }()
+ return true
+}
+
func (s *sectionScheduler) complete(workerSlot int, task asyncShardTask) {
h := task.handle
h.mu.Lock()
diff --git a/internal/msmengine/engine.go b/internal/msmengine/engine.go
index 778ce247..7ff7155c 100644
--- a/internal/msmengine/engine.go
+++ b/internal/msmengine/engine.go
@@ -10,9 +10,11 @@
package msmengine
import (
+ "errors"
"fmt"
"strings"
"sync"
+ "time"
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
"github.com/consensys/gnark-crypto/ecc/bls12-381/fr"
@@ -38,6 +40,20 @@ func failClosed(class string, err error) error {
return &FailClosedError{Class: class, Err: err}
}
+// sectionWorkerFailure carries retry policy separately from the public
+// fail-closed class. Only explicitly allow-listed transport failures can set
+// Retryable; message text never grants retry authority.
+type sectionWorkerFailure struct {
+ Code string
+ Retryable bool
+ Replace bool
+ RetryAfter time.Duration
+ Err error
+}
+
+func (e *sectionWorkerFailure) Error() string { return e.Err.Error() }
+func (e *sectionWorkerFailure) Unwrap() error { return e.Err }
+
func classifySectionWorkerError(err error) error {
message := strings.ToLower(err.Error())
switch {
@@ -58,6 +74,46 @@ func classifySectionWorkerError(err error) error {
}
}
+func classifyTypedSectionWorkerError(code string, advertisedRetryable bool, retryAfter time.Duration, err error) error {
+ if err == nil {
+ return nil
+ }
+ class := "sharded-worker-error"
+ retryable := false
+ replace := false
+ switch code {
+ case "chunk-integrity":
+ class = "chunk-digest-mismatch"
+ case "chunk-fetch-network", "chunk-fetch-http":
+ class = "range-fetch-aborted"
+ retryable = advertisedRetryable
+ case "worker-terminated", "worker-initialization":
+ class = "worker-terminated"
+ retryable = advertisedRetryable
+ replace = true
+ default:
+ // Unknown or compute/protocol failures remain terminal even if a Worker
+ // advertises them as retryable.
+ retryAfter = 0
+ }
+ if retryAfter < 0 || retryAfter > 30*time.Second {
+ retryAfter = 0
+ }
+ failure := §ionWorkerFailure{
+ Code: code, Retryable: retryable, Replace: replace,
+ RetryAfter: retryAfter, Err: err,
+ }
+ return failClosed(class, failure)
+}
+
+func sectionWorkerRetry(err error) (retryable, replace bool, retryAfter time.Duration) {
+ var failure *sectionWorkerFailure
+ if !errors.As(err, &failure) {
+ return false, false, 0
+ }
+ return failure.Retryable, failure.Replace, failure.RetryAfter
+}
+
func workerReplyIntegrityError(requested, received int) error {
return failClosed(
"worker-reply-integrity",
diff --git a/internal/msmengine/hfft_js.go b/internal/msmengine/hfft_js.go
index efde9dd8..1c6bb3bd 100644
--- a/internal/msmengine/hfft_js.go
+++ b/internal/msmengine/hfft_js.go
@@ -144,11 +144,7 @@ func terminateWorkers(workers []*worker) {
if w == nil {
continue
}
- if !w.js.IsUndefined() {
- w.js.Call("terminate")
- }
- w.onMsg.Release()
- w.onErr.Release()
+ w.stop()
}
}
diff --git a/internal/msmengine/selector_test.go b/internal/msmengine/selector_test.go
index b3740ea9..9e5bd460 100644
--- a/internal/msmengine/selector_test.go
+++ b/internal/msmengine/selector_test.go
@@ -149,6 +149,35 @@ func TestClassifySectionWorkerError(t *testing.T) {
}
}
+func TestTypedSectionWorkerRetryPolicyIsAllowListed(t *testing.T) {
+ tests := []struct {
+ code string
+ advertisedRetryable bool
+ class string
+ retryable bool
+ replace bool
+ }{
+ {"chunk-fetch-network", true, "range-fetch-aborted", true, false},
+ {"chunk-fetch-http", true, "range-fetch-aborted", true, false},
+ {"chunk-fetch-http", false, "range-fetch-aborted", false, false},
+ {"worker-terminated", true, "worker-terminated", true, true},
+ {"chunk-integrity", true, "chunk-digest-mismatch", false, false},
+ {"worker-compute", true, "sharded-worker-error", false, false},
+ {"future-unknown-code", true, "sharded-worker-error", false, false},
+ }
+ for _, tc := range tests {
+ err := classifyTypedSectionWorkerError(tc.code, tc.advertisedRetryable, 0, errors.New("worker failure"))
+ var failClosedErr *FailClosedError
+ if !errors.As(err, &failClosedErr) || failClosedErr.Class != tc.class {
+ t.Fatalf("%s class = %v, want %s", tc.code, err, tc.class)
+ }
+ retryable, replace, _ := sectionWorkerRetry(err)
+ if retryable != tc.retryable || replace != tc.replace {
+ t.Fatalf("%s retry policy = (%t,%t), want (%t,%t)", tc.code, retryable, replace, tc.retryable, tc.replace)
+ }
+ }
+}
+
func TestWorkerResultIntegrityErrorsDoNotDemote(t *testing.T) {
for _, primaryErr := range []error{
workerReplyIntegrityError(7, 3),
diff --git a/internal/msmengine/sharded_js.go b/internal/msmengine/sharded_js.go
index 34667fe6..fc724624 100644
--- a/internal/msmengine/sharded_js.go
+++ b/internal/msmengine/sharded_js.go
@@ -421,27 +421,48 @@ const rangeFetchConcurrency = 4
// worker is one Web Worker plus a channel keyed by request id for its replies.
type worker struct {
- js js.Value // the Worker object
- onMsg js.Func
- onErr js.Func
- id int
- mu sync.Mutex
- replies chan workerReply
+ js js.Value // the Worker object
+ onMsg js.Func
+ onErr js.Func
+ id int
+ mu sync.Mutex
+ stopOnce sync.Once
+ replies chan workerReply
+ progressSAB js.Value
+ progressView js.Value
+ atomics js.Value
}
type workerReply struct {
- id int
- partial []byte
- err error
- computeMS float64
- timings map[string]any
- bytes map[string]any
+ id int
+ partial []byte
+ err error
+ errorCode string
+ retryable bool
+ retryAfterMS int
+ computeMS float64
+ timings map[string]any
+ bytes map[string]any
+}
+
+type sectionTaskExecution struct {
+ worker *worker
+ reply workerReply
+ scalarBytes int
+ scalarMarshalMS float64
+ sabCopyMS float64
+ queueWaitMS float64
+ workerMS float64
+ totalMS float64
+ attempt int
}
// workerPool owns the Web Workers and round-robins shards across them.
type workerPool struct {
+ mu sync.RWMutex
workers []*worker
- closeOnce sync.Once
+ workerURL string
+ closed bool
}
// shardedMSM dispatches each MSM across a workerPool. It satisfies MSMEngine.
@@ -501,7 +522,7 @@ func NewShardedWithOptions(workerURL string, cap int, opts Options) (*shardedMSM
if prefetchWindow > 4 {
prefetchWindow = 4
}
- pool := &workerPool{}
+ pool := &workerPool{workerURL: workerURL}
for i := 0; i < n; i++ {
w, err := newWorker(g, workerURL, i)
if err != nil {
@@ -534,10 +555,13 @@ func NewShardedWithOptions(workerURL string, cap int, opts Options) (*shardedMSM
func newWorker(g js.Value, workerURL string, id int) (*worker, error) {
jsWorker := g.Get("Worker").New(workerURL)
w := &worker{
- js: jsWorker,
- id: id,
- replies: make(chan workerReply, 1),
+ js: jsWorker,
+ id: id,
+ replies: make(chan workerReply, 1),
+ progressSAB: g.Get("SharedArrayBuffer").New(8),
+ atomics: g.Get("Atomics"),
}
+ w.progressView = g.Get("Int32Array").New(w.progressSAB)
w.onMsg = js.FuncOf(func(this js.Value, args []js.Value) any {
data := args[0].Get("data")
if typ := data.Get("type"); !typ.IsUndefined() {
@@ -546,15 +570,36 @@ func newWorker(g js.Value, workerURL string, id int) (*worker, error) {
return nil
case "init-error":
select {
- case w.replies <- workerReply{err: errors.New(data.Get("error").String())}:
+ case w.replies <- workerReply{
+ err: errors.New(data.Get("error").String()), errorCode: "worker-initialization",
+ retryable: true,
+ }:
default:
}
return nil
}
}
if errv := data.Get("error"); !errv.IsUndefined() && !errv.IsNull() {
+ errorCode := ""
+ if value := data.Get("error_code"); value.Type() == js.TypeString {
+ errorCode = value.String()
+ }
+ retryable := false
+ if value := data.Get("retryable"); value.Type() == js.TypeBoolean {
+ retryable = value.Bool()
+ }
+ retryAfterMS := 0
+ if value := data.Get("retry_after_ms"); value.Type() == js.TypeNumber {
+ candidate := value.Int()
+ if candidate > 0 && candidate <= 30_000 {
+ retryAfterMS = candidate
+ }
+ }
select {
- case w.replies <- workerReply{id: data.Get("id").Int(), err: errors.New(errv.String())}:
+ case w.replies <- workerReply{
+ id: data.Get("id").Int(), err: errors.New(errv.String()),
+ errorCode: errorCode, retryable: retryable, retryAfterMS: retryAfterMS,
+ }:
default:
}
return nil
@@ -580,7 +625,9 @@ func newWorker(g js.Value, workerURL string, id int) (*worker, error) {
}
}
select {
- case w.replies <- workerReply{err: errors.New(msg)}:
+ case w.replies <- workerReply{
+ err: errors.New(msg), errorCode: "worker-terminated", retryable: true,
+ }:
default:
}
return nil
@@ -641,8 +688,64 @@ func (s *shardedMSM) Close() error {
}
func (p *workerPool) close() {
- p.closeOnce.Do(func() {
- terminateWorkers(p.workers)
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ return
+ }
+ p.closed = true
+ workers := append([]*worker(nil), p.workers...)
+ p.workers = nil
+ p.mu.Unlock()
+ terminateWorkers(workers)
+}
+
+func (p *workerPool) worker(slot int) *worker {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ if p.closed || slot < 0 || slot >= len(p.workers) {
+ return nil
+ }
+ return p.workers[slot]
+}
+
+func (p *workerPool) count() int {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ return len(p.workers)
+}
+
+func (p *workerPool) replace(slot int, expected *worker) (*worker, error) {
+ replacement, err := newWorker(js.Global(), p.workerURL, expected.id)
+ if err != nil {
+ return nil, err
+ }
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ replacement.stop()
+ return nil, errors.New("worker pool is closed")
+ }
+ if slot < 0 || slot >= len(p.workers) || p.workers[slot] != expected {
+ p.mu.Unlock()
+ replacement.stop()
+ return nil, errors.New("worker slot changed before replacement")
+ }
+ p.workers[slot] = replacement
+ p.mu.Unlock()
+ expected.stop()
+ return replacement, nil
+}
+
+func (w *worker) stop() {
+ w.stopOnce.Do(func() {
+ if !w.js.IsUndefined() {
+ w.js.Set("onmessage", js.Null())
+ w.js.Set("onerror", js.Null())
+ w.js.Call("terminate")
+ }
+ w.onMsg.Release()
+ w.onErr.Release()
})
}
@@ -678,7 +781,7 @@ func (s *shardedMSM) MSMG1(dst *bls12381.G1Jac, points []bls12381.G1Affine, scal
ptsBuf := marshalG1Points(points[r[0]:r[1]])
scsBuf := marshalScalars(scalars[r[0]:r[1]])
marshalMS := elapsedMS(marshalStart)
- w := s.pool.workers[i%len(s.pool.workers)]
+ w := s.pool.worker(i % s.pool.count())
queueStart := time.Now()
w.mu.Lock()
queueMS := elapsedMS(queueStart)
@@ -765,7 +868,7 @@ func (s *shardedMSM) MSMG2(dst *bls12381.G2Jac, points []bls12381.G2Affine, scal
ptsBuf := marshalG2Points(points[r[0]:r[1]])
scsBuf := marshalScalars(scalars[r[0]:r[1]])
marshalMS := elapsedMS(marshalStart)
- w := s.pool.workers[i%len(s.pool.workers)]
+ w := s.pool.worker(i % s.pool.count())
queueStart := time.Now()
w.mu.Lock()
queueMS := elapsedMS(queueStart)
@@ -857,7 +960,7 @@ func (s *shardedMSM) MSMG1Ranged(dst *bls12381.G1Jac, n int, fetch FetchG1, scal
i, r := i, r
go func() {
totalStart := time.Now()
- w := s.pool.workers[i%len(s.pool.workers)]
+ w := s.pool.worker(i % s.pool.count())
queueStart := time.Now()
w.mu.Lock()
queueMS := elapsedMS(queueStart)
@@ -978,7 +1081,7 @@ func (s *shardedMSM) MSMG2Ranged(dst *bls12381.G2Jac, n int, fetch FetchG2, scal
i, r := i, r
go func() {
totalStart := time.Now()
- w := s.pool.workers[i%len(s.pool.workers)]
+ w := s.pool.worker(i % s.pool.count())
queueStart := time.Now()
w.mu.Lock()
queueMS := elapsedMS(queueStart)
@@ -1094,40 +1197,34 @@ func (s *shardedMSM) MSMG1Section(dst *bls12381.G1Jac, plan *PKSectionPlan, sect
ch := make(chan g1res, len(ranges))
launch := func(workerSlot, idx int) {
r := ranges[idx]
- w := s.pool.workers[workerSlot]
go func() {
- totalStart := time.Now()
- scalarStart := time.Now()
- scsBuf := marshalScalars(scalars[r[0]:r[1]])
- scalarMS := elapsedMS(scalarStart)
- queueStart := time.Now()
- w.mu.Lock()
- queueMS := elapsedMS(queueStart)
- sabStart := time.Now()
- scsSab := newSAB(scsBuf)
- zeroBytes(scsBuf)
- sabMS := elapsedMS(sabStart)
- workerStart := time.Now()
- reply := w.postSectionAndWaitLocked(idx, false, string(planJSON), section, r, scsSab, s.pinnedDecode, s.optW7, s.chunkPrefetchWindow)
- zeroSAB(scsSab)
- workerMS := elapsedMS(workerStart)
- w.mu.Unlock()
+ execution := s.executeSectionTask(workerSlot, idx, false, string(planJSON), section, r, scalars[r[0]:r[1]])
+ w := execution.worker
+ reply := execution.reply
+ workerID := workerSlot
+ if w != nil {
+ workerID = w.id
+ }
fields := map[string]any{
"section": section,
"worker_owned_fetch": true,
"point_bytes_from_main": 0,
- "scalar_bytes": len(scsBuf),
- "scalar_marshal_ms": scalarMS,
- "sab_copy_ms": sabMS,
- "queue_wait_ms": queueMS,
- "worker_turnaround_ms": workerMS,
+ "scalar_bytes": execution.scalarBytes,
+ "scalar_marshal_ms": execution.scalarMarshalMS,
+ "sab_copy_ms": execution.sabCopyMS,
+ "queue_wait_ms": execution.queueWaitMS,
+ "worker_turnaround_ms": execution.workerMS,
"worker_compute_ms": reply.computeMS,
- "total_ms": elapsedMS(totalStart),
+ "total_ms": execution.totalMS,
"error": errorString(reply.err),
}
+ if execution.attempt > 1 {
+ fields["attempt"] = execution.attempt
+ fields["attempt_max"] = asyncShardMaxAttempts
+ }
addTraceFields(fields, reply.timings)
addByteTraceFields(fields, reply.bytes)
- emitShardTrace("MSMG1Section", "g1", idx, w.id, r, n, fields)
+ emitShardTrace("MSMG1Section", "g1", idx, workerID, r, n, fields)
if reply.err != nil {
ch <- g1res{idx: idx, workerSlot: workerSlot, r: r, err: reply.err}
return
@@ -1144,8 +1241,8 @@ func (s *shardedMSM) MSMG1Section(dst *bls12381.G1Jac, plan *PKSectionPlan, sect
var affinityNext []int
resultsExpected := len(ranges)
if s.optW7 {
- affinity = newContiguousShardAffinity(len(ranges), len(s.pool.workers))
- affinityNext = make([]int, len(s.pool.workers))
+ affinity = newContiguousShardAffinity(len(ranges), s.pool.count())
+ affinityNext = make([]int, s.pool.count())
resultsExpected = 0
for workerSlot, shards := range affinity.byWorker {
if len(shards) == 0 {
@@ -1156,7 +1253,7 @@ func (s *shardedMSM) MSMG1Section(dst *bls12381.G1Jac, plan *PKSectionPlan, sect
resultsExpected++
}
} else {
- initial := len(s.pool.workers)
+ initial := s.pool.count()
if initial > len(ranges) {
initial = len(ranges)
}
@@ -1237,40 +1334,34 @@ func (s *shardedMSM) MSMG2Section(dst *bls12381.G2Jac, plan *PKSectionPlan, sect
ch := make(chan g2res, len(ranges))
launch := func(workerSlot, idx int) {
r := ranges[idx]
- w := s.pool.workers[workerSlot]
go func() {
- totalStart := time.Now()
- scalarStart := time.Now()
- scsBuf := marshalScalars(scalars[r[0]:r[1]])
- scalarMS := elapsedMS(scalarStart)
- queueStart := time.Now()
- w.mu.Lock()
- queueMS := elapsedMS(queueStart)
- sabStart := time.Now()
- scsSab := newSAB(scsBuf)
- zeroBytes(scsBuf)
- sabMS := elapsedMS(sabStart)
- workerStart := time.Now()
- reply := w.postSectionAndWaitLocked(idx, true, string(planJSON), section, r, scsSab, s.pinnedDecode, s.optW7, s.chunkPrefetchWindow)
- zeroSAB(scsSab)
- workerMS := elapsedMS(workerStart)
- w.mu.Unlock()
+ execution := s.executeSectionTask(workerSlot, idx, true, string(planJSON), section, r, scalars[r[0]:r[1]])
+ w := execution.worker
+ reply := execution.reply
+ workerID := workerSlot
+ if w != nil {
+ workerID = w.id
+ }
fields := map[string]any{
"section": section,
"worker_owned_fetch": true,
"point_bytes_from_main": 0,
- "scalar_bytes": len(scsBuf),
- "scalar_marshal_ms": scalarMS,
- "sab_copy_ms": sabMS,
- "queue_wait_ms": queueMS,
- "worker_turnaround_ms": workerMS,
+ "scalar_bytes": execution.scalarBytes,
+ "scalar_marshal_ms": execution.scalarMarshalMS,
+ "sab_copy_ms": execution.sabCopyMS,
+ "queue_wait_ms": execution.queueWaitMS,
+ "worker_turnaround_ms": execution.workerMS,
"worker_compute_ms": reply.computeMS,
- "total_ms": elapsedMS(totalStart),
+ "total_ms": execution.totalMS,
"error": errorString(reply.err),
}
+ if execution.attempt > 1 {
+ fields["attempt"] = execution.attempt
+ fields["attempt_max"] = asyncShardMaxAttempts
+ }
addTraceFields(fields, reply.timings)
addByteTraceFields(fields, reply.bytes)
- emitShardTrace("MSMG2Section", "g2", idx, w.id, r, n, fields)
+ emitShardTrace("MSMG2Section", "g2", idx, workerID, r, n, fields)
if reply.err != nil {
ch <- g2res{idx: idx, workerSlot: workerSlot, r: r, err: reply.err}
return
@@ -1287,8 +1378,8 @@ func (s *shardedMSM) MSMG2Section(dst *bls12381.G2Jac, plan *PKSectionPlan, sect
var affinityNext []int
resultsExpected := len(ranges)
if s.optW7 {
- affinity = newContiguousShardAffinity(len(ranges), len(s.pool.workers))
- affinityNext = make([]int, len(s.pool.workers))
+ affinity = newContiguousShardAffinity(len(ranges), s.pool.count())
+ affinityNext = make([]int, s.pool.count())
resultsExpected = 0
for workerSlot, shards := range affinity.byWorker {
if len(shards) == 0 {
@@ -1299,7 +1390,7 @@ func (s *shardedMSM) MSMG2Section(dst *bls12381.G2Jac, plan *PKSectionPlan, sect
resultsExpected++
}
} else {
- initial := len(s.pool.workers)
+ initial := s.pool.count()
if initial > len(ranges) {
initial = len(ranges)
}
@@ -1352,6 +1443,71 @@ func (s *shardedMSM) MSMG2Section(dst *bls12381.G2Jac, plan *PKSectionPlan, sect
return nil
}
+// executeSectionTask gives the pre-W1 synchronous Basis section the same
+// bounded recovery policy as the W1 asynchronous scheduler. The successful
+// path still performs exactly one marshal, SAB copy, post, and wait.
+func (s *shardedMSM) executeSectionTask(workerSlot, requestID int, g2 bool, planJSON, section string, r [2]int, scalars []fr.Element) sectionTaskExecution {
+ totalStart := time.Now()
+ var execution sectionTaskExecution
+ for attempt := 1; attempt <= asyncShardMaxAttempts; attempt++ {
+ w := s.pool.worker(workerSlot)
+ execution.worker = w
+ execution.attempt = attempt
+ if w == nil {
+ execution.reply.err = failClosed("worker-terminated", errors.New("worker slot is unavailable"))
+ break
+ }
+ scalarStart := time.Now()
+ scsBuf := marshalScalars(scalars)
+ execution.scalarBytes = len(scsBuf)
+ execution.scalarMarshalMS = elapsedMS(scalarStart)
+ queueStart := time.Now()
+ w.mu.Lock()
+ execution.queueWaitMS = elapsedMS(queueStart)
+ sabStart := time.Now()
+ scsSab := newSAB(scsBuf)
+ zeroBytes(scsBuf)
+ execution.sabCopyMS = elapsedMS(sabStart)
+ workerStart := time.Now()
+ execution.reply = w.postSectionAndWaitLocked(
+ requestID, g2, planJSON, section, r, scsSab,
+ s.pinnedDecode, s.optW7, s.chunkPrefetchWindow,
+ )
+ zeroSAB(scsSab)
+ execution.workerMS = elapsedMS(workerStart)
+ w.mu.Unlock()
+ if execution.reply.err == nil {
+ break
+ }
+ retryable, replace, retryAfter := sectionWorkerRetry(execution.reply.err)
+ if !retryable || attempt >= asyncShardMaxAttempts {
+ break
+ }
+ if replace {
+ replacement, err := s.pool.replace(workerSlot, w)
+ if err != nil {
+ execution.reply.err = failClosed("worker-terminated", fmt.Errorf("replace worker slot %d: %w", workerSlot, err))
+ break
+ }
+ execution.worker = replacement
+ }
+ delay := asyncRetryBackoff(attempt+1, workerSlot)
+ if retryAfter > delay {
+ delay = retryAfter
+ }
+ EmitTrace("measure", "msm-shard-retry", map[string]any{
+ "section": section, "shard_index": requestID,
+ "worker_id": w.id, "attempt": attempt + 1,
+ "attempt_max": asyncShardMaxAttempts, "replace_worker": replace,
+ "backoff_ms": float64(delay) / float64(time.Millisecond),
+ "error": execution.reply.err.Error(),
+ })
+ time.Sleep(delay)
+ }
+ execution.totalMS = elapsedMS(totalStart)
+ return execution
+}
+
// dispatch posts one shard to this worker over a SharedArrayBuffer and blocks
// until the worker posts back its partial (or an error). Blocking parks the Go
// goroutine; the JS event loop runs the worker's onmessage, which feeds the
@@ -1396,6 +1552,7 @@ func (w *worker) postSectionAndWaitLocked(id int, g2 bool, planJSON string, sect
}
func (w *worker) postSectionAndWaitLockedCancelable(id int, g2 bool, planJSON string, section string, r [2]int, scsSab js.Value, pinnedDecode, optW7 bool, chunkPrefetchWindow int, cancel <-chan struct{}) workerReply {
+ w.resetProgress(id)
msg := js.Global().Get("Object").New()
msg.Set("type", "msm-section-range")
msg.Set("id", id)
@@ -1408,16 +1565,34 @@ func (w *worker) postSectionAndWaitLockedCancelable(id int, g2 bool, planJSON st
msg.Set("pinnedDecode", pinnedDecode)
msg.Set("optW7", optW7)
msg.Set("chunkPrefetchWindow", chunkPrefetchWindow)
+ msg.Set("progress", w.progressSAB)
w.js.Call("postMessage", msg)
- reply, waitErr := waitForAsyncResult(w.replies, cancel, asyncWorkerReplyTimeout)
+ reply, waitErr := waitForAsyncResultWithProgress(
+ w.replies,
+ cancel,
+ asyncWorkerInactivityTimeout,
+ asyncWorkerAbsoluteTimeout,
+ func() uint64 { return w.readProgress(id) },
+ )
if waitErr != nil {
if errors.Is(waitErr, errAsyncWaitCancelled) {
return workerReply{err: failClosed("async-msm-cancelled", waitErr)}
}
- return workerReply{err: failClosed("worker-terminated", waitErr)}
+ return workerReply{err: classifyTypedSectionWorkerError(
+ "worker-terminated", true, 0, waitErr,
+ )}
}
if reply.err != nil {
- reply.err = classifySectionWorkerError(reply.err)
+ if reply.errorCode == "" {
+ reply.err = classifySectionWorkerError(reply.err)
+ } else {
+ reply.err = classifyTypedSectionWorkerError(
+ reply.errorCode,
+ reply.retryable,
+ time.Duration(reply.retryAfterMS)*time.Millisecond,
+ reply.err,
+ )
+ }
return reply
}
if reply.id != id {
@@ -1431,6 +1606,22 @@ func (w *worker) postSectionAndWaitLockedCancelable(id int, g2 bool, planJSON st
return reply
}
+func (w *worker) resetProgress(id int) {
+ w.atomics.Call("store", w.progressView, 1, 0)
+ w.atomics.Call("store", w.progressView, 0, id)
+}
+
+func (w *worker) readProgress(id int) uint64 {
+ if generation := w.atomics.Call("load", w.progressView, 0).Int(); generation != id {
+ return 0
+ }
+ value := w.atomics.Call("load", w.progressView, 1).Int()
+ if value <= 0 {
+ return 0
+ }
+ return uint64(value)
+}
+
// newSAB copies b into a freshly allocated SharedArrayBuffer-backed Uint8Array
// and returns the SharedArrayBuffer (shared, so no transfer list needed).
func newSAB(b []byte) js.Value {
diff --git a/internal/proofassets/chunk_manifest.go b/internal/proofassets/chunk_manifest.go
index 78641f78..543fba88 100644
--- a/internal/proofassets/chunk_manifest.go
+++ b/internal/proofassets/chunk_manifest.go
@@ -18,6 +18,7 @@ import (
"golang.org/x/crypto/blake2b"
"proof-tool/internal/artifact"
+ "proof-tool/internal/strictjson"
)
const (
@@ -57,6 +58,11 @@ type ChunkCoherence struct {
ProofToolVersion string `json:"proof_tool_version,omitempty"`
CardanoVKFormat string `json:"cardano_vk_format,omitempty"`
CardanoVKBlake2b256 string `json:"cardano_vk_blake2b256,omitempty"`
+ MPCCeremonyID string `json:"mpc_ceremony_id,omitempty"`
+ MPCCandidateID string `json:"mpc_candidate_id,omitempty"`
+ ProductionDecisionID string `json:"production_decision_id,omitempty"`
+ MPCReleaseID string `json:"mpc_release_id,omitempty"`
+ ReleaseManifestSHA256 string `json:"release_manifest_sha256,omitempty"`
DeploymentID string `json:"deployment_id"`
DeploymentSourceCommit string `json:"deployment_source_commit,omitempty"`
}
@@ -128,6 +134,7 @@ type CompressedAssetPin struct {
type ReclaimDeploymentManifest struct {
Schema string `json:"schema"`
DeploymentID string `json:"deployment_id"`
+ Network string `json:"network"`
SourceCommit string `json:"source_commit"`
ReclaimGlobal struct {
VerifierVKHash string `json:"verifier_vk_hash"`
@@ -141,7 +148,15 @@ type ReclaimDeploymentManifest struct {
DestinationAddressEncoding string `json:"destination_address_encoding"`
VKHash string `json:"vk_hash"`
CardanoVKBlake2b256 string `json:"cardano_vk_blake2b256"`
+ SetupTranscriptHash string `json:"setup_transcript_hash"`
+ MPCCeremonyID string `json:"mpc_ceremony_id"`
+ MPCCandidateID string `json:"mpc_candidate_id"`
} `json:"proof"`
+ Planning struct {
+ ProductionDecisionID string `json:"production_decision_id"`
+ MPCReleaseID string `json:"mpc_release_id"`
+ ReleaseManifestSHA256 string `json:"release_manifest_sha256"`
+ } `json:"planning"`
}
type ChunkManifestOptions struct {
@@ -204,6 +219,20 @@ func GenerateChunkManifest(opts ChunkManifestOptions) (*ChunkManifest, error) {
if err := ValidateReclaimDeployment(opts.Deployment, opts.KeyManifest, opts.CardanoVKBlake2b256); err != nil {
return nil, err
}
+ if err := validateMainnetReleaseManifestDigest(opts.Deployment, opts.KeyManifestDigest); err != nil {
+ return nil, err
+ }
+ assets := opts.Assets
+ if assets == nil {
+ assets = map[string]AssetPin{}
+ }
+ if err := ValidateKeyManifestAssetDigests(
+ opts.KeyManifest,
+ fileDigestFromAssetPin(assets["ownership.vk"]),
+ fileDigestFromAssetPin(assets["ownership-destination.ccs"]),
+ ); err != nil {
+ return nil, err
+ }
idx, err := BuildPKIndex(opts.ProvingKeyPath)
if err != nil {
@@ -227,10 +256,6 @@ func GenerateChunkManifest(opts ChunkManifestOptions) (*ChunkManifest, error) {
if err != nil {
return nil, err
}
- assets := opts.Assets
- if assets == nil {
- assets = map[string]AssetPin{}
- }
return &ChunkManifest{
Schema: ChunkManifestSchema,
Release: opts.Release,
@@ -255,6 +280,11 @@ func GenerateChunkManifest(opts ChunkManifestOptions) (*ChunkManifest, error) {
ProofToolVersion: opts.KeyManifest.ProofToolVersion,
CardanoVKFormat: opts.CardanoVKFormat,
CardanoVKBlake2b256: opts.CardanoVKBlake2b256,
+ MPCCeremonyID: opts.Deployment.Proof.MPCCeremonyID,
+ MPCCandidateID: opts.Deployment.Proof.MPCCandidateID,
+ ProductionDecisionID: opts.Deployment.Planning.ProductionDecisionID,
+ MPCReleaseID: opts.Deployment.Planning.MPCReleaseID,
+ ReleaseManifestSHA256: opts.Deployment.Planning.ReleaseManifestSHA256,
DeploymentID: opts.Deployment.DeploymentID,
DeploymentSourceCommit: opts.Deployment.SourceCommit,
},
@@ -406,6 +436,13 @@ func ValidateChunkManifest(m *ChunkManifest, expected ChunkManifestExpectations)
if err := validateAgainstKeyManifest(m, expected.KeyManifest); err != nil {
return err
}
+ if err := ValidateKeyManifestAssetDigests(
+ expected.KeyManifest,
+ fileDigestFromAssetPin(m.Assets["ownership.vk"]),
+ fileDigestFromAssetPin(m.Assets["ownership-destination.ccs"]),
+ ); err != nil {
+ return err
+ }
}
if expected.KeyManifestDigest.Size != 0 || expected.KeyManifestDigest.SHA256 != "" || expected.KeyManifestDigest.Blake2b256 != "" {
if m.Coherence.KeyManifestSHA256 != expected.KeyManifestDigest.SHA256 {
@@ -422,6 +459,21 @@ func ValidateChunkManifest(m *ChunkManifest, expected ChunkManifestExpectations)
if m.Coherence.DeploymentSourceCommit != expected.Deployment.SourceCommit {
return fmt.Errorf("deployment source commit mismatch: manifest %q, expected %q", m.Coherence.DeploymentSourceCommit, expected.Deployment.SourceCommit)
}
+ for _, check := range []struct {
+ name string
+ got string
+ want string
+ }{
+ {"mpc ceremony id", m.Coherence.MPCCeremonyID, expected.Deployment.Proof.MPCCeremonyID},
+ {"mpc candidate id", m.Coherence.MPCCandidateID, expected.Deployment.Proof.MPCCandidateID},
+ {"production decision id", m.Coherence.ProductionDecisionID, expected.Deployment.Planning.ProductionDecisionID},
+ {"mpc release id", m.Coherence.MPCReleaseID, expected.Deployment.Planning.MPCReleaseID},
+ {"release manifest sha256", m.Coherence.ReleaseManifestSHA256, expected.Deployment.Planning.ReleaseManifestSHA256},
+ } {
+ if check.got != check.want {
+ return fmt.Errorf("%s mismatch: manifest %q, expected %q", check.name, check.got, check.want)
+ }
+ }
}
if expected.CardanoVKFormat != "" && m.Coherence.CardanoVKFormat != expected.CardanoVKFormat {
return fmt.Errorf("cardano vk format mismatch: manifest %q, expected %q", m.Coherence.CardanoVKFormat, expected.CardanoVKFormat)
@@ -460,7 +512,7 @@ func ReadChunkManifest(path string) (*ChunkManifest, error) {
return nil, fmt.Errorf("read chunk manifest %s: %w", path, err)
}
var m ChunkManifest
- if err := json.Unmarshal(raw, &m); err != nil {
+ if err := strictjson.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("parse chunk manifest %s: %w", path, err)
}
return &m, nil
@@ -494,7 +546,7 @@ func ReadReclaimDeployment(path string) (*ReclaimDeploymentManifest, error) {
return nil, fmt.Errorf("read deployment manifest %s: %w", path, err)
}
var m ReclaimDeploymentManifest
- if err := json.Unmarshal(raw, &m); err != nil {
+ if err := strictjson.UnmarshalProjection(raw, &m); err != nil {
return nil, fmt.Errorf("parse deployment manifest %s: %w", path, err)
}
if m.Schema != ReclaimDeploymentSchema {
@@ -519,18 +571,26 @@ func ValidateReclaimDeployment(deployment *ReclaimDeploymentManifest, manifest *
if deployment.Proof.VKHash != manifest.VKHash {
return fmt.Errorf("deployment proof.vk_hash %q, want %q", deployment.Proof.VKHash, manifest.VKHash)
}
- if deployment.ReclaimGlobal.VerifierVKHash != manifest.VKHash {
- return fmt.Errorf("deployment reclaim_global.verifier_vk_hash %q, want %q", deployment.ReclaimGlobal.VerifierVKHash, manifest.VKHash)
- }
if deployment.Proof.CircuitID != manifest.CircuitID {
return fmt.Errorf("deployment proof.circuit_id %q, want %q", deployment.Proof.CircuitID, manifest.CircuitID)
}
if deployment.Proof.KeyVersion != manifest.KeyVersion {
return fmt.Errorf("deployment proof.key_version %q, want %q", deployment.Proof.KeyVersion, manifest.KeyVersion)
}
+ if deployment.Proof.SetupTranscriptHash != "" &&
+ deployment.Proof.SetupTranscriptHash != manifest.SetupTranscriptHash {
+ return fmt.Errorf(
+ "deployment proof.setup_transcript_hash %q, want %q",
+ deployment.Proof.SetupTranscriptHash,
+ manifest.SetupTranscriptHash,
+ )
+ }
if cardanoVKBlake2b256 != "" && deployment.Proof.CardanoVKBlake2b256 != cardanoVKBlake2b256 {
return fmt.Errorf("deployment proof.cardano_vk_blake2b256 %q, want %q", deployment.Proof.CardanoVKBlake2b256, cardanoVKBlake2b256)
}
+ if deployment.ReclaimGlobal.VerifierVKHash != deployment.Proof.CardanoVKBlake2b256 {
+ return fmt.Errorf("deployment reclaim_global.verifier_vk_hash %q, want Cardano VK hash %q", deployment.ReclaimGlobal.VerifierVKHash, deployment.Proof.CardanoVKBlake2b256)
+ }
const statementBoundV2 = "full-proof-plus-public-input-digest-v2"
if deployment.ReclaimGlobal.ProofSlotEncoding != statementBoundV2 {
return fmt.Errorf("deployment reclaim_global.proof_slot_encoding %q, want %q", deployment.ReclaimGlobal.ProofSlotEncoding, statementBoundV2)
@@ -541,9 +601,60 @@ func ValidateReclaimDeployment(deployment *ReclaimDeploymentManifest, manifest *
if deployment.ReclaimGlobal.BatchTranscriptVKHash != deployment.Proof.CardanoVKBlake2b256 {
return fmt.Errorf("deployment reclaim_global.batch_transcript_vk_hash %q, want %q", deployment.ReclaimGlobal.BatchTranscriptVKHash, deployment.Proof.CardanoVKBlake2b256)
}
+ if isMainnetDeployment(deployment) {
+ if deployment.Proof.SetupTranscriptHash == "" {
+ return errors.New("mainnet deployment requires proof.setup_transcript_hash")
+ }
+ for _, identity := range []struct {
+ label string
+ value string
+ }{
+ {"proof.mpc_ceremony_id", deployment.Proof.MPCCeremonyID},
+ {"proof.mpc_candidate_id", deployment.Proof.MPCCandidateID},
+ {"planning.production_decision_id", deployment.Planning.ProductionDecisionID},
+ {"planning.mpc_release_id", deployment.Planning.MPCReleaseID},
+ } {
+ if !isSHA256ID(identity.value) {
+ return fmt.Errorf("mainnet deployment %s must be an exact sha256 identity", identity.label)
+ }
+ }
+ if err := validateDigest("sha256", deployment.Planning.ReleaseManifestSHA256); err != nil {
+ return fmt.Errorf("mainnet deployment planning.release_manifest_sha256: %w", err)
+ }
+ }
+ return nil
+}
+
+func isMainnetDeployment(deployment *ReclaimDeploymentManifest) bool {
+ return deployment != nil &&
+ (deployment.Network == "Mainnet" || strings.HasPrefix(deployment.DeploymentID, "mainnet:"))
+}
+
+func validateMainnetReleaseManifestDigest(
+ deployment *ReclaimDeploymentManifest,
+ keyManifestDigest FileDigest,
+) error {
+ if !isMainnetDeployment(deployment) {
+ return nil
+ }
+ if deployment.Planning.ReleaseManifestSHA256 != keyManifestDigest.SHA256 {
+ return fmt.Errorf(
+ "mainnet deployment release_manifest_sha256 %q, want exact signed key manifest %q",
+ deployment.Planning.ReleaseManifestSHA256,
+ keyManifestDigest.SHA256,
+ )
+ }
return nil
}
+func isSHA256ID(value string) bool {
+ if value != strings.ToLower(value) || !strings.HasPrefix(value, "sha256:") {
+ return false
+ }
+ raw, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
+ return err == nil && len(raw) == 32
+}
+
func SignDetached(raw []byte, privateKey ed25519.PrivateKey) string {
return hex.EncodeToString(ed25519.Sign(privateKey, raw))
}
@@ -593,6 +704,54 @@ func checkKeyManifestPK(manifest *artifact.KeyManifest, digest FileDigest) error
return nil
}
+// ValidateKeyManifestAssetDigests binds the browser/runtime VK and CCS assets
+// to the already signed key manifest before a chunk manifest can be generated
+// or signed. This is intentionally a generator-side check; a downstream web
+// verifier must not be the first component to discover that release assets
+// describe a different circuit or verifying key.
+func ValidateKeyManifestAssetDigests(manifest *artifact.KeyManifest, vk, ccs FileDigest) error {
+ if manifest == nil {
+ return errors.New("key manifest is required")
+ }
+ if manifest.VKHash == "" || manifest.VerifyingKeySHA256 == "" || manifest.VerifyingKeySize <= 0 {
+ return errors.New("key manifest must include vk_hash, verifying key sha256, and verifying key size")
+ }
+ if vk.Size <= 0 || vk.SHA256 == "" || vk.Blake2b256 == "" {
+ return errors.New("ownership.vk asset pin with size, sha256, and blake2b256 is required")
+ }
+ if vk.Size != manifest.VerifyingKeySize {
+ return fmt.Errorf("ownership.vk size mismatch: manifest %d, asset %d", manifest.VerifyingKeySize, vk.Size)
+ }
+ if vk.SHA256 != manifest.VerifyingKeySHA256 {
+ return fmt.Errorf("ownership.vk sha256 mismatch: manifest %s, asset %s", manifest.VerifyingKeySHA256, vk.SHA256)
+ }
+ if vk.Blake2b256 != manifest.VKHash {
+ return fmt.Errorf("ownership.vk blake2b256 mismatch: manifest %s, asset %s", manifest.VKHash, vk.Blake2b256)
+ }
+ if manifest.ConstraintSystemHash == "" {
+ return errors.New("key manifest must include constraint_system_hash")
+ }
+ if ccs.Size <= 0 || ccs.SHA256 == "" || ccs.Blake2b256 == "" {
+ return errors.New("ownership-destination.ccs asset pin with size, sha256, and blake2b256 is required")
+ }
+ if ccs.Blake2b256 != manifest.ConstraintSystemHash {
+ return fmt.Errorf(
+ "ownership-destination.ccs blake2b256 mismatch: manifest %s, asset %s",
+ manifest.ConstraintSystemHash,
+ ccs.Blake2b256,
+ )
+ }
+ return nil
+}
+
+func fileDigestFromAssetPin(pin AssetPin) FileDigest {
+ return FileDigest{
+ Size: pin.Size,
+ SHA256: pin.SHA256,
+ Blake2b256: pin.Blake2b256,
+ }
+}
+
func validateAgainstKeyManifest(m *ChunkManifest, manifest *artifact.KeyManifest) error {
c := m.Coherence
checks := []struct {
diff --git a/internal/proofassets/chunk_manifest_test.go b/internal/proofassets/chunk_manifest_test.go
index be02b193..7f9bf7a6 100644
--- a/internal/proofassets/chunk_manifest_test.go
+++ b/internal/proofassets/chunk_manifest_test.go
@@ -49,6 +49,7 @@ func TestGenerateChunkManifestAndTamperGuards(t *testing.T) {
}
cardanoVKHash := prefixedHash("55")
deployment := testDeploymentManifest(keyManifest, cardanoVKHash)
+ deployment.Planning.ReleaseManifestSHA256 = keyManifestDigest.SHA256
outDir := filepath.Join(t.TempDir(), "assets")
manifest, err := GenerateChunkManifest(ChunkManifestOptions{
@@ -69,8 +70,14 @@ func TestGenerateChunkManifestAndTamperGuards(t *testing.T) {
"ownership.vk": {
Path: "ownership.vk",
Size: 784,
+ SHA256: shaPrefixedHash("22"),
+ Blake2b256: prefixedHash("11"),
+ },
+ "ownership-destination.ccs": {
+ Path: "ownership-destination.ccs",
+ Size: 1024,
SHA256: shaPrefixedHash("66"),
- Blake2b256: prefixedHash("77"),
+ Blake2b256: prefixedHash("33"),
},
},
})
@@ -133,6 +140,24 @@ func TestGenerateChunkManifestAndTamperGuards(t *testing.T) {
t.Fatalf("expected vk_hash failure, got %v", err)
}
})
+ t.Run("vk asset digest tamper fails", func(t *testing.T) {
+ tampered := cloneChunkManifest(t, manifest)
+ pin := tampered.Assets["ownership.vk"]
+ pin.Blake2b256 = prefixedHash("99")
+ tampered.Assets["ownership.vk"] = pin
+ if err := ValidateChunkManifest(tampered, expect); err == nil || !strings.Contains(err.Error(), "ownership.vk blake2b256") {
+ t.Fatalf("expected ownership.vk coherence failure, got %v", err)
+ }
+ })
+ t.Run("ccs asset digest tamper fails", func(t *testing.T) {
+ tampered := cloneChunkManifest(t, manifest)
+ pin := tampered.Assets["ownership-destination.ccs"]
+ pin.Blake2b256 = prefixedHash("99")
+ tampered.Assets["ownership-destination.ccs"] = pin
+ if err := ValidateChunkManifest(tampered, expect); err == nil || !strings.Contains(err.Error(), "ownership-destination.ccs blake2b256") {
+ t.Fatalf("expected CCS coherence failure, got %v", err)
+ }
+ })
t.Run("deployment id tamper fails", func(t *testing.T) {
tampered := cloneChunkManifest(t, manifest)
tampered.Coherence.DeploymentID = "mainnet:wrong"
@@ -140,13 +165,36 @@ func TestGenerateChunkManifestAndTamperGuards(t *testing.T) {
t.Fatalf("expected deployment id failure, got %v", err)
}
})
+ t.Run("MPC provenance tamper fails", func(t *testing.T) {
+ tampered := cloneChunkManifest(t, manifest)
+ tampered.Coherence.MPCCandidateID = "sha256:" + strings.Repeat("99", 32)
+ if err := ValidateChunkManifest(tampered, expect); err == nil || !strings.Contains(err.Error(), "mpc candidate id") {
+ t.Fatalf("expected MPC candidate coherence failure, got %v", err)
+ }
+ })
+ t.Run("release manifest provenance tamper fails", func(t *testing.T) {
+ tampered := cloneChunkManifest(t, manifest)
+ tampered.Coherence.ReleaseManifestSHA256 = shaPrefixedHash("99")
+ if err := ValidateChunkManifest(tampered, expect); err == nil || !strings.Contains(err.Error(), "release manifest sha256") {
+ t.Fatalf("expected release manifest coherence failure, got %v", err)
+ }
+ })
+ t.Run("deployment cannot substitute a different signed key manifest", func(t *testing.T) {
+ tampered := *deployment
+ tampered.Planning.ReleaseManifestSHA256 = shaPrefixedHash("99")
+ if err := validateMainnetReleaseManifestDigest(&tampered, keyManifestDigest); err == nil ||
+ !strings.Contains(err.Error(), "exact signed key manifest") {
+ t.Fatalf("expected exact signed release manifest failure, got %v", err)
+ }
+ })
}
func TestValidateReclaimDeploymentStatementBoundV2(t *testing.T) {
keyManifest := &artifact.KeyManifest{
- VKHash: prefixedHash("11"),
- CircuitID: "root-ownership-destination-v1/bls12-381/groth16",
- KeyVersion: "ownership-destination-v1",
+ VKHash: prefixedHash("11"),
+ CircuitID: "root-ownership-destination-v1/bls12-381/groth16",
+ KeyVersion: "ownership-destination-v1",
+ SetupTranscriptHash: prefixedHash("44"),
}
cardanoVKHash := prefixedHash("55")
deployment := testDeploymentManifest(keyManifest, cardanoVKHash)
@@ -156,6 +204,18 @@ func TestValidateReclaimDeploymentStatementBoundV2(t *testing.T) {
t.Fatalf("expected statement-bound V2 deployment to validate: %v", err)
}
+ deployment.Proof.SetupTranscriptHash = prefixedHash("99")
+ if err := ValidateReclaimDeployment(deployment, keyManifest, cardanoVKHash); err == nil || !strings.Contains(err.Error(), "setup_transcript_hash") {
+ t.Fatalf("expected setup transcript mismatch to fail, got %v", err)
+ }
+ deployment.Proof.SetupTranscriptHash = keyManifest.SetupTranscriptHash
+
+ deployment.ReclaimGlobal.VerifierVKHash = keyManifest.VKHash
+ if err := ValidateReclaimDeployment(deployment, keyManifest, cardanoVKHash); err == nil || !strings.Contains(err.Error(), "Cardano VK hash") {
+ t.Fatalf("expected native VK hash in on-chain verifier field to fail, got %v", err)
+ }
+ deployment.ReclaimGlobal.VerifierVKHash = cardanoVKHash
+
deployment.ReclaimGlobal.BatchTranscriptVKHash = ""
if err := ValidateReclaimDeployment(deployment, keyManifest, cardanoVKHash); err == nil || !strings.Contains(err.Error(), "batch_transcript_vk_hash") {
t.Fatalf("expected missing V2 batch transcript hash failure, got %v", err)
@@ -171,8 +231,9 @@ func testDeploymentManifest(keyManifest *artifact.KeyManifest, cardanoVKHash str
var deployment ReclaimDeploymentManifest
deployment.Schema = ReclaimDeploymentSchema
deployment.DeploymentID = "mainnet:" + strings.Repeat("b", 56) + ":" + strings.Repeat("a", 40)
+ deployment.Network = "Mainnet"
deployment.SourceCommit = strings.Repeat("a", 40)
- deployment.ReclaimGlobal.VerifierVKHash = keyManifest.VKHash
+ deployment.ReclaimGlobal.VerifierVKHash = cardanoVKHash
deployment.ReclaimGlobal.ProofProfile = "single-destination"
deployment.ReclaimGlobal.ProofSlotEncoding = "full-proof-plus-public-input-digest-v2"
deployment.ReclaimGlobal.BatchTranscriptVKHash = cardanoVKHash
@@ -181,6 +242,12 @@ func testDeploymentManifest(keyManifest *artifact.KeyManifest, cardanoVKHash str
deployment.Proof.DestinationAddressEncoding = "destination-address-v1"
deployment.Proof.VKHash = keyManifest.VKHash
deployment.Proof.CardanoVKBlake2b256 = cardanoVKHash
+ deployment.Proof.SetupTranscriptHash = keyManifest.SetupTranscriptHash
+ deployment.Proof.MPCCeremonyID = "sha256:" + strings.Repeat("66", 32)
+ deployment.Proof.MPCCandidateID = "sha256:" + strings.Repeat("77", 32)
+ deployment.Planning.ProductionDecisionID = "sha256:" + strings.Repeat("88", 32)
+ deployment.Planning.MPCReleaseID = "sha256:" + strings.Repeat("99", 32)
+ deployment.Planning.ReleaseManifestSHA256 = shaPrefixedHash("aa")
return &deployment
}
diff --git a/internal/proofassets/pkindex.go b/internal/proofassets/pkindex.go
index 4a3a3f8f..06dc7115 100644
--- a/internal/proofassets/pkindex.go
+++ b/internal/proofassets/pkindex.go
@@ -7,6 +7,8 @@ import (
"io"
"math"
"os"
+
+ "proof-tool/internal/strictjson"
)
const (
@@ -184,7 +186,7 @@ func ReadPKIndex(path string) (*PKIndex, error) {
return nil, fmt.Errorf("read index %s: %w", path, err)
}
var idx PKIndex
- if err := json.Unmarshal(raw, &idx); err != nil {
+ if err := strictjson.Unmarshal(raw, &idx); err != nil {
return nil, fmt.Errorf("parse index %s: %w", path, err)
}
if err := ValidatePKIndex(&idx); err != nil {
diff --git a/internal/strictjson/strictjson.go b/internal/strictjson/strictjson.go
new file mode 100644
index 00000000..5bba44f7
--- /dev/null
+++ b/internal/strictjson/strictjson.go
@@ -0,0 +1,135 @@
+// Package strictjson provides fail-closed JSON decoding for signed and
+// security-sensitive artifacts that are not required to use the ceremony's
+// compact canonical encoding.
+package strictjson
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+)
+
+const (
+ maxDepth = 64
+ maxObjectKeys = 100_000
+)
+
+// Unmarshal rejects duplicate object keys, unknown struct fields, and trailing
+// JSON values. Whitespace before or after the one value remains valid.
+func Unmarshal(data []byte, destination any) error {
+ if destination == nil {
+ return errors.New("JSON destination is nil")
+ }
+ if err := scanOneValue(data); err != nil {
+ return err
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ return decodeOne(decoder, destination)
+}
+
+// UnmarshalProjection provides duplicate/trailing-value protection when a
+// caller intentionally decodes only a documented projection of a larger JSON
+// schema. Unknown fields are allowed; use Unmarshal for complete schemas.
+func UnmarshalProjection(data []byte, destination any) error {
+ if destination == nil {
+ return errors.New("JSON destination is nil")
+ }
+ if err := scanOneValue(data); err != nil {
+ return err
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ return decodeOne(decoder, destination)
+}
+
+func decodeOne(decoder *json.Decoder, destination any) error {
+ if err := decoder.Decode(destination); err != nil {
+ return fmt.Errorf("decode strict JSON: %w", err)
+ }
+ return requireEOF(decoder)
+}
+
+func scanOneValue(data []byte) error {
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.UseNumber()
+ keyCount := 0
+ if err := scanValue(decoder, 0, &keyCount); err != nil {
+ return err
+ }
+ return requireEOF(decoder)
+}
+
+func requireEOF(decoder *json.Decoder) error {
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("invalid trailing JSON: %w", err)
+ }
+ return fmt.Errorf("unexpected trailing JSON token %v", token)
+}
+
+func scanValue(decoder *json.Decoder, depth int, keyCount *int) error {
+ if depth > maxDepth {
+ return fmt.Errorf("JSON nesting exceeds maximum depth %d", maxDepth)
+ }
+ token, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ delim, isDelimiter := token.(json.Delim)
+ if !isDelimiter {
+ return nil
+ }
+ switch delim {
+ case '{':
+ seen := make(map[string]struct{})
+ for decoder.More() {
+ keyToken, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON object key: %w", err)
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return errors.New("JSON object key is not a string")
+ }
+ if _, duplicate := seen[key]; duplicate {
+ return fmt.Errorf("duplicate JSON object key %q", key)
+ }
+ seen[key] = struct{}{}
+ *keyCount++
+ if *keyCount > maxObjectKeys {
+ return fmt.Errorf("JSON object key count exceeds maximum %d", maxObjectKeys)
+ }
+ if err := scanValue(decoder, depth+1, keyCount); err != nil {
+ return err
+ }
+ }
+ end, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON object end: %w", err)
+ }
+ if end != json.Delim('}') {
+ return errors.New("invalid JSON object delimiter")
+ }
+ case '[':
+ for decoder.More() {
+ if err := scanValue(decoder, depth+1, keyCount); err != nil {
+ return err
+ }
+ }
+ end, err := decoder.Token()
+ if err != nil {
+ return fmt.Errorf("invalid JSON array end: %w", err)
+ }
+ if end != json.Delim(']') {
+ return errors.New("invalid JSON array delimiter")
+ }
+ default:
+ return fmt.Errorf("unexpected JSON delimiter %q", delim)
+ }
+ return nil
+}
diff --git a/internal/strictjson/strictjson_test.go b/internal/strictjson/strictjson_test.go
new file mode 100644
index 00000000..4a1a3355
--- /dev/null
+++ b/internal/strictjson/strictjson_test.go
@@ -0,0 +1,58 @@
+package strictjson
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestUnmarshalRejectsAmbiguousOrTrailingJSON(t *testing.T) {
+ type record struct {
+ Name string `json:"name"`
+ }
+ for name, input := range map[string]string{
+ "duplicate": `{"name":"first","name":"second"}`,
+ "unknown": `{"name":"first","extra":true}`,
+ "trailing": `{"name":"first"}{"name":"second"}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ var got record
+ if err := Unmarshal([]byte(input), &got); err == nil {
+ t.Fatalf("accepted %s JSON", name)
+ }
+ })
+ }
+ var got record
+ if err := Unmarshal([]byte(" \n{\"name\":\"ok\"}\n"), &got); err != nil {
+ t.Fatalf("valid strict JSON failed: %v", err)
+ }
+ if got.Name != "ok" {
+ t.Fatalf("decoded name %q", got.Name)
+ }
+}
+
+func TestUnmarshalProjectionAllowsUnknownButRejectsDuplicate(t *testing.T) {
+ type projection struct {
+ Name string `json:"name"`
+ }
+ var got projection
+ if err := UnmarshalProjection([]byte(`{"name":"ok","documented_elsewhere":true}`), &got); err != nil {
+ t.Fatalf("projection decode failed: %v", err)
+ }
+ if got.Name != "ok" {
+ t.Fatalf("decoded name %q", got.Name)
+ }
+ if err := UnmarshalProjection([]byte(`{"name":"first","name":"second"}`), &got); err == nil {
+ t.Fatal("projection accepted duplicate key")
+ }
+}
+
+func TestUnmarshalRejectsExcessiveDepth(t *testing.T) {
+ type record struct {
+ Value any `json:"value"`
+ }
+ input := `{"value":` + strings.Repeat(`[`, maxDepth+1) + `null` + strings.Repeat(`]`, maxDepth+1) + `}`
+ var got record
+ if err := Unmarshal([]byte(input), &got); err == nil || !strings.Contains(err.Error(), "maximum depth") {
+ t.Fatalf("expected depth rejection, got %v", err)
+ }
+}
diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh
new file mode 100755
index 00000000..24258cf4
--- /dev/null
+++ b/scripts/build-mpc-ceremony-release.sh
@@ -0,0 +1,538 @@
+#!/usr/bin/env -S -u SHELLOPTS -u BASHOPTS BASH_ENV=/dev/null ENV=/dev/null /bin/bash
+# Builds the participant-facing MPC ceremony binary from an exact clean Git
+# state and records the inputs needed for independent byte-for-byte rebuilds.
+#
+# Production usage requires a verified signed tag:
+# scripts/build-mpc-ceremony-release.sh \
+# --mode production \
+# --signed-tag vX.Y.Z \
+# --tag-signer-fingerprint "$APPROVED_GPG_FINGERPRINT" \
+# --build-signing-key /offline/build-signing-key \
+# --out-dir /fresh/output
+#
+# Rehearsals deliberately record that no signed-tag gate was applied:
+# scripts/build-mpc-ceremony-release.sh \
+# --mode rehearsal --out-dir /fresh/output
+set -euo pipefail
+
+# Release builds use a closed, non-hooked command/config environment. The Go
+# build invocations below additionally set every build-affecting Go variable.
+unset CDPATH GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
+unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG_COUNT
+export BASH_ENV=/dev/null
+export ENV=/dev/null
+export GIT_CONFIG_GLOBAL=/dev/null
+export GIT_CONFIG_NOSYSTEM=1
+
+usage() {
+ echo "usage: $0 --mode production|rehearsal --out-dir DIR [--signed-tag TAG --tag-signer-fingerprint HEX] [--build-signing-key KEY]" >&2
+ exit 2
+}
+
+MODE=
+OUT_DIR=
+SIGNED_TAG=
+TAG_SIGNER_FINGERPRINT=
+BUILD_SIGNING_KEY=
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --mode)
+ [[ $# -ge 2 ]] || usage
+ MODE=$2
+ shift 2
+ ;;
+ --out-dir)
+ [[ $# -ge 2 ]] || usage
+ OUT_DIR=$2
+ shift 2
+ ;;
+ --signed-tag)
+ [[ $# -ge 2 ]] || usage
+ SIGNED_TAG=$2
+ shift 2
+ ;;
+ --tag-signer-fingerprint)
+ [[ $# -ge 2 ]] || usage
+ TAG_SIGNER_FINGERPRINT=$2
+ shift 2
+ ;;
+ --build-signing-key)
+ [[ $# -ge 2 ]] || usage
+ BUILD_SIGNING_KEY=$2
+ shift 2
+ ;;
+ *)
+ usage
+ ;;
+ esac
+done
+
+if [[ "$MODE" != "production" && "$MODE" != "rehearsal" ]]; then
+ usage
+fi
+if [[ -z "$OUT_DIR" ]]; then
+ usage
+fi
+if [[ "$MODE" == "production" &&
+ ( -z "$SIGNED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -z "$BUILD_SIGNING_KEY" ) ]]; then
+ echo "FAIL: production builds require --signed-tag, --tag-signer-fingerprint, and --build-signing-key" >&2
+ exit 1
+fi
+if [[ "$MODE" == "rehearsal" &&
+ ( -n "$SIGNED_TAG" || -n "$TAG_SIGNER_FINGERPRINT" || -n "$BUILD_SIGNING_KEY" ) ]]; then
+ echo "FAIL: rehearsal builds must not supply production tag or build-signing identity" >&2
+ exit 1
+fi
+if [[ -n "$SIGNED_TAG" && -z "$TAG_SIGNER_FINGERPRINT" ]] ||
+ [[ -z "$SIGNED_TAG" && -n "$TAG_SIGNER_FINGERPRINT" ]]; then
+ echo "FAIL: --signed-tag and --tag-signer-fingerprint must be supplied together" >&2
+ exit 1
+fi
+if [[ -n "$TAG_SIGNER_FINGERPRINT" ]]; then
+ TAG_SIGNER_FINGERPRINT=${TAG_SIGNER_FINGERPRINT^^}
+ if [[ ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ ]]; then
+ echo "FAIL: tag signer fingerprint must be exactly 40 or 64 hexadecimal characters" >&2
+ exit 1
+ fi
+fi
+
+if [[ -n "$BUILD_SIGNING_KEY" ]]; then
+ BUILD_SIGNING_KEY_DIR=$(realpath -e -- "$(dirname -- "$BUILD_SIGNING_KEY")")
+ BUILD_SIGNING_KEY="$BUILD_SIGNING_KEY_DIR/$(basename -- "$BUILD_SIGNING_KEY")"
+ if [[ ! -f "$BUILD_SIGNING_KEY" || -L "$BUILD_SIGNING_KEY" ]]; then
+ echo "FAIL: build signing key must be a non-symlink regular file" >&2
+ exit 1
+ fi
+fi
+OUT_PARENT=$(realpath -e -- "$(dirname -- "$OUT_DIR")")
+OUT_DIR="$OUT_PARENT/$(basename -- "$OUT_DIR")"
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel)
+cd "$REPO_ROOT"
+
+if ! git diff --quiet --ignore-submodules -- ||
+ ! git diff --cached --quiet --ignore-submodules -- ||
+ [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
+ echo "FAIL: release builds require a clean Git checkout with no untracked source files" >&2
+ exit 1
+fi
+
+SOURCE_COMMIT=$(git rev-parse --verify HEAD)
+if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "FAIL: HEAD is not an exact 40-character Git commit" >&2
+ exit 1
+fi
+
+TAG_STATUS=not-required-for-rehearsal
+TAG_OBJECT=none
+if [[ -n "$SIGNED_TAG" ]]; then
+ if [[ "$SIGNED_TAG" == -* ]] || ! git check-ref-format "refs/tags/$SIGNED_TAG"; then
+ echo "FAIL: invalid signed tag name: $SIGNED_TAG" >&2
+ exit 1
+ fi
+ TAG_COMMIT=$(git rev-parse --verify "$SIGNED_TAG^{commit}")
+ if [[ "$TAG_COMMIT" != "$SOURCE_COMMIT" ]]; then
+ echo "FAIL: signed tag $SIGNED_TAG resolves to $TAG_COMMIT, not HEAD $SOURCE_COMMIT" >&2
+ exit 1
+ fi
+ TAG_OBJECT=$(git rev-parse --verify "$SIGNED_TAG^{tag}")
+ VERIFY_TAG_OUTPUT=
+ if ! VERIFY_TAG_OUTPUT=$(git verify-tag --raw "$SIGNED_TAG" 2>&1); then
+ printf '%s\n' "$VERIFY_TAG_OUTPUT" >&2
+ echo "FAIL: signed tag verification failed: $SIGNED_TAG" >&2
+ exit 1
+ fi
+ mapfile -t VALID_TAG_FINGERPRINTS < <(
+ printf '%s\n' "$VERIFY_TAG_OUTPUT" |
+ sed -n 's/^\[GNUPG:\] VALIDSIG \([0-9A-Fa-f]*\) .*/\U\1/p'
+ )
+ if [[ "${#VALID_TAG_FINGERPRINTS[@]}" -ne 1 ||
+ "${VALID_TAG_FINGERPRINTS[0]}" != "$TAG_SIGNER_FINGERPRINT" ]]; then
+ echo "FAIL: signed tag fingerprint does not match the approved fingerprint" >&2
+ exit 1
+ fi
+ TAG_STATUS=verified
+fi
+
+ACTIVE_GOROOT=$(env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=auto \
+ go env GOROOT)
+GO_BIN="$ACTIVE_GOROOT/bin/go"
+if [[ ! -x "$GO_BIN" || -L "$GO_BIN" ]]; then
+ echo "FAIL: resolved Go executable must be a non-symlink executable file: $GO_BIN" >&2
+ 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
+ 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)
+GO_HOST_ARCH=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOHOSTARCH)
+if [[ "$GO_HOST_OS" != "linux" || "$GO_HOST_ARCH" != "amd64" ]]; then
+ echo "FAIL: release build host toolchain must be linux/amd64, found $GO_HOST_OS/$GO_HOST_ARCH" >&2
+ exit 1
+fi
+if [[ -n "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOEXPERIMENT)" ]]; then
+ 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
+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
+ local expected=$2
+ local actual
+ actual=$(sha256sum "$path")
+ actual=${actual%% *}
+ if [[ "$actual" != "$expected" ]]; then
+ echo "FAIL: toolchain digest mismatch for $path: $actual, want $expected" >&2
+ exit 1
+ fi
+}
+verify_tool_hash "$GO_BIN" "$EXPECTED_GO_SHA256"
+verify_tool_hash "$GO_TOOL_DIR/compile" "$EXPECTED_COMPILE_SHA256"
+verify_tool_hash "$GO_TOOL_DIR/link" "$EXPECTED_LINK_SHA256"
+verify_tool_hash "$GO_TOOL_DIR/asm" "$EXPECTED_ASM_SHA256"
+if rg -n '^[[:space:]]*replace([[:space:]]|$)' go.mod >/dev/null; then
+ echo "FAIL: release build forbids Go module replace directives" >&2
+ exit 1
+fi
+if [[ ! -d vendor ]]; then
+ echo "FAIL: vendor/ is absent; create it only with scripts/bootstrap-vendor.sh" >&2
+ exit 1
+fi
+if [[ -n "$(find vendor -type l -print -quit)" ]]; then
+ echo "FAIL: release build forbids symbolic links in vendor/" >&2
+ exit 1
+fi
+
+if [[ -e "$OUT_DIR" || -L "$OUT_DIR" ]]; then
+ echo "FAIL: output directory already exists: $OUT_DIR" >&2
+ exit 1
+fi
+if [[ ! -d "$OUT_PARENT" || -L "$OUT_PARENT" ]]; then
+ echo "FAIL: output parent must be an existing real directory: $OUT_PARENT" >&2
+ exit 1
+fi
+
+umask 077
+CANONICAL_ROOT="/tmp/proof-tool-mpc-release-build-$SOURCE_COMMIT"
+CANONICAL_SOURCE="$CANONICAL_ROOT/source"
+if [[ -e "$CANONICAL_ROOT" || -L "$CANONICAL_ROOT" ]]; then
+ echo "FAIL: canonical clean-build path already exists: $CANONICAL_ROOT" >&2
+ exit 1
+fi
+mkdir -m 0700 "$CANONICAL_ROOT"
+STAGING=
+cleanup() {
+ if [[ -n "$STAGING" ]]; then
+ rm -rf -- "$STAGING"
+ fi
+ rm -rf -- "$CANONICAL_ROOT"
+}
+trap cleanup EXIT
+
+git clone --quiet --no-hardlinks --no-checkout "$REPO_ROOT" "$CANONICAL_SOURCE"
+git -C "$CANONICAL_SOURCE" checkout --quiet --detach "$SOURCE_COMMIT"
+cp -a "$REPO_ROOT/vendor" "$CANONICAL_SOURCE/vendor"
+
+STAGING=$(mktemp -d "$OUT_PARENT/.mpc-ceremony-release.partial.XXXXXXXX")
+cd "$CANONICAL_SOURCE"
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ PATH="$(dirname "$GO_BIN"):$PATH" \
+ bash scripts/check-vendor-drift.sh
+
+SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$SOURCE_COMMIT")
+BUILD_FLAGS="-mod=vendor -trimpath -buildvcs=true -ldflags=-buildid="
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOFLAGS= \
+ SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
+ TZ=UTC \
+ LC_ALL=C \
+ "$GO_BIN" build \
+ -mod=vendor \
+ -trimpath \
+ -buildvcs=true \
+ -ldflags=-buildid= \
+ -o "$STAGING/mpc-ceremony" \
+ ./cmd/mpc-ceremony
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOFLAGS= \
+ SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
+ TZ=UTC \
+ LC_ALL=C \
+ "$GO_BIN" build \
+ -mod=vendor \
+ -trimpath \
+ -buildvcs=true \
+ -ldflags=-buildid= \
+ -o "$STAGING/mpc-finalization-evidence" \
+ ./scripts/mpc-finalization-evidence
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/hash-blake2b \
+ -go-version "$GO_VERSION" \
+ -build-flags "$BUILD_FLAGS" \
+ "$STAGING/mpc-ceremony" >"$STAGING/binary-manifest.json"
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/hash-blake2b \
+ -go-version "$GO_VERSION" \
+ -build-flags "$BUILD_FLAGS" \
+ "$STAGING/mpc-finalization-evidence" >"$STAGING/finalization-evidence-binary-manifest.json"
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/generate-go-sbom \
+ --binary "$STAGING/mpc-ceremony" \
+ --name mpc-ceremony \
+ --source-root "$CANONICAL_SOURCE" >"$STAGING/sbom.cdx.json"
+
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/generate-go-sbom \
+ --binary "$STAGING/mpc-finalization-evidence" \
+ --name mpc-finalization-evidence \
+ --source-root "$CANONICAL_SOURCE" >"$STAGING/finalization-evidence-sbom.cdx.json"
+
+(
+ cd "$CANONICAL_SOURCE"
+ git ls-files -z |
+ LC_ALL=C sort -z |
+ xargs -0 sha256sum >"$STAGING/source-checksums.sha256"
+ find vendor -type f -print0 |
+ LC_ALL=C sort -z |
+ xargs -0 sha256sum >"$STAGING/vendor-checksums.sha256"
+)
+
+(
+ cd "$STAGING"
+ sha256sum mpc-ceremony mpc-finalization-evidence >checksums.sha256
+ b2sum -l 256 mpc-ceremony mpc-finalization-evidence >checksums.blake2b256
+ env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOTOOLCHAIN=local \
+ GOAMD64=v1 \
+ "$GO_BIN" version -m ./mpc-ceremony >go-build-info.txt
+ env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOTOOLCHAIN=local \
+ GOAMD64=v1 \
+ "$GO_BIN" version -m ./mpc-finalization-evidence >finalization-evidence-go-build-info.txt
+)
+printf '%s\n' "$SOURCE_COMMIT" >"$STAGING/source-commit.txt"
+printf '%s\n' "$SOURCE_DATE_EPOCH" >"$STAGING/source-date-epoch.txt"
+printf '%s\n' "$MODE" >"$STAGING/build-mode.txt"
+printf '%s\n' "${SIGNED_TAG:-none}" >"$STAGING/signed-tag.txt"
+printf '%s\n' "$TAG_STATUS" >"$STAGING/signed-tag-status.txt"
+printf '%s\n' "$TAG_OBJECT" >"$STAGING/signed-tag-object.txt"
+printf '%s\n' "${TAG_SIGNER_FINGERPRINT:-none}" >"$STAGING/signed-tag-signer-fingerprint.txt"
+cat >"$STAGING/toolchain-checksums.sha256" <"$STAGING/build-package-manifest.json"
+(
+ cd "$STAGING"
+ sha256sum build-package-manifest.json >build-package-manifest.sha256
+)
+if [[ -n "$BUILD_SIGNING_KEY" ]]; then
+ env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/sign-ed25519-file \
+ --input "$STAGING/build-package-manifest.json" \
+ --private-key "$BUILD_SIGNING_KEY" \
+ --signature-out "$STAGING/build-package-manifest.sig" \
+ --public-key-out "$STAGING/build-package-manifest-public-key.hex"
+fi
+
+chmod 0555 "$STAGING/mpc-ceremony" "$STAGING/mpc-finalization-evidence"
+chmod 0444 \
+ "$STAGING"/*.txt \
+ "$STAGING"/*.json \
+ "$STAGING"/build-package-manifest.sha256 \
+ "$STAGING"/checksums.* \
+ "$STAGING"/*-checksums.sha256
+if [[ -n "$BUILD_SIGNING_KEY" ]]; then
+ chmod 0444 \
+ "$STAGING"/build-package-manifest.sig \
+ "$STAGING"/build-package-manifest-public-key.hex
+fi
+touch -d "@$SOURCE_DATE_EPOCH" "$STAGING"/*
+env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="$CANONICAL_ROOT/go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run ./scripts/rename-directory-noreplace "$STAGING" "$OUT_DIR"
+STAGING=
+rm -rf -- "$CANONICAL_ROOT"
+CANONICAL_ROOT=
+trap - EXIT
+
+echo "OK: built $OUT_DIR/mpc-ceremony from $SOURCE_COMMIT ($MODE)"
diff --git a/scripts/check-mpc-k21-capacity.sh b/scripts/check-mpc-k21-capacity.sh
new file mode 100755
index 00000000..e3be20a0
--- /dev/null
+++ b/scripts/check-mpc-k21-capacity.sh
@@ -0,0 +1,325 @@
+#!/usr/bin/env bash
+# Refuses to start an exact K=21 rehearsal unless the selected local
+# filesystem and current process limits meet explicit, measurable floors.
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "usage: $0 EXISTING_WORK_DIRECTORY" >&2
+ exit 2
+fi
+
+WORK_DIR=$1
+MIN_FREE_BYTES=${MPC_K21_MIN_FREE_BYTES:-$((100 * 1024 * 1024 * 1024))}
+MIN_AVAILABLE_MEMORY_BYTES=${MPC_K21_MIN_AVAILABLE_MEMORY_BYTES:-$((16 * 1024 * 1024 * 1024))}
+MIN_FREE_INODES=${MPC_K21_MIN_FREE_INODES:-100000}
+MIN_OPEN_FILES=${MPC_K21_MIN_OPEN_FILES:-4096}
+MIN_FILE_SIZE_LIMIT_BYTES=${MPC_K21_MIN_FILE_SIZE_LIMIT_BYTES:-$((16 * 1024 * 1024 * 1024))}
+IO_PROBE_BYTES=${MPC_K21_IO_PROBE_BYTES:-$((256 * 1024 * 1024))}
+IO_DIRECT=${MPC_K21_IO_DIRECT:-1}
+MIN_WRITE_BYTES_PER_SECOND=${MPC_K21_MIN_WRITE_BYTES_PER_SECOND:-$((20 * 1024 * 1024))}
+MIN_READ_BYTES_PER_SECOND=${MPC_K21_MIN_READ_BYTES_PER_SECOND:-$((20 * 1024 * 1024))}
+REQUIRE_SWAP_DISABLED=${MPC_K21_REQUIRE_SWAP_DISABLED:-0}
+REQUIRE_QUOTA_VISIBILITY=${MPC_K21_REQUIRE_QUOTA_VISIBILITY:-0}
+
+require_nonnegative_integer() {
+ local name=$1
+ local value=$2
+ if [[ ! "$value" =~ ^[0-9]+$ ]]; then
+ echo "FAIL: $name must be a non-negative integer" >&2
+ exit 1
+ fi
+}
+
+for setting in \
+ "MPC_K21_MIN_FREE_BYTES:$MIN_FREE_BYTES" \
+ "MPC_K21_MIN_AVAILABLE_MEMORY_BYTES:$MIN_AVAILABLE_MEMORY_BYTES" \
+ "MPC_K21_MIN_FREE_INODES:$MIN_FREE_INODES" \
+ "MPC_K21_MIN_OPEN_FILES:$MIN_OPEN_FILES" \
+ "MPC_K21_MIN_FILE_SIZE_LIMIT_BYTES:$MIN_FILE_SIZE_LIMIT_BYTES" \
+ "MPC_K21_IO_PROBE_BYTES:$IO_PROBE_BYTES" \
+ "MPC_K21_IO_DIRECT:$IO_DIRECT" \
+ "MPC_K21_MIN_WRITE_BYTES_PER_SECOND:$MIN_WRITE_BYTES_PER_SECOND" \
+ "MPC_K21_MIN_READ_BYTES_PER_SECOND:$MIN_READ_BYTES_PER_SECOND" \
+ "MPC_K21_REQUIRE_SWAP_DISABLED:$REQUIRE_SWAP_DISABLED" \
+ "MPC_K21_REQUIRE_QUOTA_VISIBILITY:$REQUIRE_QUOTA_VISIBILITY"; do
+ require_nonnegative_integer "${setting%%:*}" "${setting#*:}"
+done
+if [[ "$REQUIRE_SWAP_DISABLED" != 0 && "$REQUIRE_SWAP_DISABLED" != 1 ]]; then
+ echo "FAIL: MPC_K21_REQUIRE_SWAP_DISABLED must be 0 or 1" >&2
+ exit 1
+fi
+if [[ "$REQUIRE_QUOTA_VISIBILITY" != 0 && "$REQUIRE_QUOTA_VISIBILITY" != 1 ]]; then
+ echo "FAIL: MPC_K21_REQUIRE_QUOTA_VISIBILITY must be 0 or 1" >&2
+ exit 1
+fi
+if [[ "$IO_DIRECT" != 0 && "$IO_DIRECT" != 1 ]]; then
+ echo "FAIL: MPC_K21_IO_DIRECT must be 0 or 1" >&2
+ exit 1
+fi
+if (( IO_PROBE_BYTES < 1048576 ||
+ IO_PROBE_BYTES > 8 * 1024 * 1024 * 1024 ||
+ IO_PROBE_BYTES % 1048576 != 0 )); then
+ echo "FAIL: MPC_K21_IO_PROBE_BYTES must be a multiple of 1 MiB between 1 MiB and 8 GiB" >&2
+ exit 1
+fi
+
+if [[ ! -d "$WORK_DIR" || -L "$WORK_DIR" ]]; then
+ echo "FAIL: work directory must be an existing real directory: $WORK_DIR" >&2
+ exit 1
+fi
+
+FS_TYPE=$(stat -f -c %T "$WORK_DIR")
+case "$FS_TYPE" in
+ ext2/ext3 | xfs | btrfs | zfs)
+ ;;
+ *)
+ echo "FAIL: unqualified filesystem type $FS_TYPE for $WORK_DIR" >&2
+ echo "Use a dedicated local ext4, XFS, Btrfs, or ZFS volume." >&2
+ exit 1
+ ;;
+esac
+
+FREE_BYTES=$(df -B1 --output=avail "$WORK_DIR" | tail -n 1 | tr -d ' ')
+require_nonnegative_integer free_bytes "$FREE_BYTES"
+if (( FREE_BYTES < MIN_FREE_BYTES )); then
+ echo "FAIL: $WORK_DIR has $FREE_BYTES free bytes; configured floor is $MIN_FREE_BYTES" >&2
+ exit 1
+fi
+
+FREE_INODES=$(df --output=iavail "$WORK_DIR" | tail -n 1 | tr -d ' ')
+require_nonnegative_integer free_inodes "$FREE_INODES"
+if (( FREE_INODES < MIN_FREE_INODES )); then
+ echo "FAIL: $WORK_DIR has $FREE_INODES free inodes; configured floor is $MIN_FREE_INODES" >&2
+ exit 1
+fi
+
+AVAILABLE_MEMORY_KIB=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
+require_nonnegative_integer MemAvailable_kib "$AVAILABLE_MEMORY_KIB"
+HOST_AVAILABLE_MEMORY_BYTES=$((AVAILABLE_MEMORY_KIB * 1024))
+
+CGROUP_VERSION=none
+CGROUP_MEMORY_MAX=max
+CGROUP_MEMORY_CURRENT=0
+CGROUP_MEMORY_REMAINING=$HOST_AVAILABLE_MEMORY_BYTES
+CGROUP_MEMORY_HIGH=max
+CGROUP_SWAP_MAX=unknown
+CGROUP_SWAP_CURRENT=0
+if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then
+ CGROUP_VERSION=2
+ CGROUP_RELATIVE=$(awk -F: '$1 == "0" {print $3}' /proc/self/cgroup)
+ if [[ -z "$CGROUP_RELATIVE" || "$CGROUP_RELATIVE" == *..* ]]; then
+ echo "FAIL: could not resolve the current cgroup v2 path" >&2
+ exit 1
+ fi
+ CGROUP_PATH="/sys/fs/cgroup$CGROUP_RELATIVE"
+ for controller_file in memory.max memory.current memory.high memory.swap.max memory.swap.current; do
+ if [[ ! -f "$CGROUP_PATH/$controller_file" ]]; then
+ echo "FAIL: current cgroup lacks $controller_file" >&2
+ exit 1
+ fi
+ done
+ CGROUP_MEMORY_MAX=$(tr -d '\n' <"$CGROUP_PATH/memory.max")
+ CGROUP_MEMORY_CURRENT=$(tr -d '\n' <"$CGROUP_PATH/memory.current")
+ CGROUP_MEMORY_HIGH=$(tr -d '\n' <"$CGROUP_PATH/memory.high")
+ CGROUP_SWAP_MAX=$(tr -d '\n' <"$CGROUP_PATH/memory.swap.max")
+ CGROUP_SWAP_CURRENT=$(tr -d '\n' <"$CGROUP_PATH/memory.swap.current")
+ require_nonnegative_integer cgroup_memory_current "$CGROUP_MEMORY_CURRENT"
+ require_nonnegative_integer cgroup_swap_current "$CGROUP_SWAP_CURRENT"
+ if [[ "$CGROUP_MEMORY_MAX" != max ]]; then
+ require_nonnegative_integer cgroup_memory_max "$CGROUP_MEMORY_MAX"
+ if (( CGROUP_MEMORY_CURRENT >= CGROUP_MEMORY_MAX )); then
+ CGROUP_MEMORY_REMAINING=0
+ else
+ CGROUP_MEMORY_REMAINING=$((CGROUP_MEMORY_MAX - CGROUP_MEMORY_CURRENT))
+ fi
+ fi
+elif [[ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]]; then
+ CGROUP_VERSION=1
+ CGROUP_RELATIVE=$(
+ awk -F: '$2 ~ /(^|,)memory(,|$)/ {print $3}' /proc/self/cgroup
+ )
+ if [[ -z "$CGROUP_RELATIVE" || "$CGROUP_RELATIVE" == *..* ]]; then
+ echo "FAIL: could not resolve the current cgroup v1 memory path" >&2
+ exit 1
+ fi
+ CGROUP_PATH="/sys/fs/cgroup/memory$CGROUP_RELATIVE"
+ for controller_file in memory.limit_in_bytes memory.usage_in_bytes; do
+ if [[ ! -f "$CGROUP_PATH/$controller_file" ]]; then
+ echo "FAIL: current cgroup lacks $controller_file" >&2
+ exit 1
+ fi
+ done
+ CGROUP_MEMORY_MAX=$(tr -d '\n' <"$CGROUP_PATH/memory.limit_in_bytes")
+ CGROUP_MEMORY_CURRENT=$(tr -d '\n' <"$CGROUP_PATH/memory.usage_in_bytes")
+ require_nonnegative_integer cgroup_memory_max "$CGROUP_MEMORY_MAX"
+ require_nonnegative_integer cgroup_memory_current "$CGROUP_MEMORY_CURRENT"
+ if (( CGROUP_MEMORY_CURRENT >= CGROUP_MEMORY_MAX )); then
+ CGROUP_MEMORY_REMAINING=0
+ else
+ CGROUP_MEMORY_REMAINING=$((CGROUP_MEMORY_MAX - CGROUP_MEMORY_CURRENT))
+ fi
+ if [[ -f "$CGROUP_PATH/memory.memsw.limit_in_bytes" &&
+ -f "$CGROUP_PATH/memory.memsw.usage_in_bytes" ]]; then
+ CGROUP_MEMSW_MAX=$(tr -d '\n' <"$CGROUP_PATH/memory.memsw.limit_in_bytes")
+ CGROUP_MEMSW_CURRENT=$(tr -d '\n' <"$CGROUP_PATH/memory.memsw.usage_in_bytes")
+ require_nonnegative_integer cgroup_memsw_max "$CGROUP_MEMSW_MAX"
+ require_nonnegative_integer cgroup_memsw_current "$CGROUP_MEMSW_CURRENT"
+ if (( CGROUP_MEMSW_MAX > CGROUP_MEMORY_MAX )); then
+ CGROUP_SWAP_MAX=$((CGROUP_MEMSW_MAX - CGROUP_MEMORY_MAX))
+ else
+ CGROUP_SWAP_MAX=0
+ fi
+ if (( CGROUP_MEMSW_CURRENT > CGROUP_MEMORY_CURRENT )); then
+ CGROUP_SWAP_CURRENT=$((CGROUP_MEMSW_CURRENT - CGROUP_MEMORY_CURRENT))
+ else
+ CGROUP_SWAP_CURRENT=0
+ fi
+ fi
+fi
+
+EFFECTIVE_AVAILABLE_MEMORY_BYTES=$HOST_AVAILABLE_MEMORY_BYTES
+if (( CGROUP_MEMORY_REMAINING < EFFECTIVE_AVAILABLE_MEMORY_BYTES )); then
+ EFFECTIVE_AVAILABLE_MEMORY_BYTES=$CGROUP_MEMORY_REMAINING
+fi
+if [[ "$CGROUP_MEMORY_HIGH" != max ]]; then
+ require_nonnegative_integer cgroup_memory_high "$CGROUP_MEMORY_HIGH"
+ if (( CGROUP_MEMORY_CURRENT >= CGROUP_MEMORY_HIGH )); then
+ CGROUP_MEMORY_HIGH_REMAINING=0
+ else
+ CGROUP_MEMORY_HIGH_REMAINING=$((CGROUP_MEMORY_HIGH - CGROUP_MEMORY_CURRENT))
+ fi
+ if (( CGROUP_MEMORY_HIGH_REMAINING < EFFECTIVE_AVAILABLE_MEMORY_BYTES )); then
+ EFFECTIVE_AVAILABLE_MEMORY_BYTES=$CGROUP_MEMORY_HIGH_REMAINING
+ fi
+fi
+if (( EFFECTIVE_AVAILABLE_MEMORY_BYTES < MIN_AVAILABLE_MEMORY_BYTES )); then
+ echo "FAIL: effective available memory is $EFFECTIVE_AVAILABLE_MEMORY_BYTES bytes; configured floor is $MIN_AVAILABLE_MEMORY_BYTES" >&2
+ exit 1
+fi
+
+HOST_SWAP_ACTIVE_BYTES=$(
+ awk 'NR > 1 {total += $4} END {printf "%.0f", total * 1024}' /proc/swaps
+)
+HOST_SWAP_CONFIGURED_BYTES=$(
+ awk 'NR > 1 {total += $3} END {printf "%.0f", total * 1024}' /proc/swaps
+)
+require_nonnegative_integer host_swap_active_bytes "$HOST_SWAP_ACTIVE_BYTES"
+require_nonnegative_integer host_swap_configured_bytes "$HOST_SWAP_CONFIGURED_BYTES"
+if (( REQUIRE_SWAP_DISABLED == 1 )); then
+ if (( HOST_SWAP_CONFIGURED_BYTES != 0 || HOST_SWAP_ACTIVE_BYTES != 0 ||
+ CGROUP_SWAP_CURRENT != 0 )); then
+ echo "FAIL: configured or active swap is forbidden by MPC_K21_REQUIRE_SWAP_DISABLED=1" >&2
+ exit 1
+ fi
+fi
+
+OPEN_FILES_LIMIT=$(ulimit -Sn)
+require_nonnegative_integer open_files_soft_limit "$OPEN_FILES_LIMIT"
+if (( OPEN_FILES_LIMIT < MIN_OPEN_FILES )); then
+ echo "FAIL: open-file soft limit is $OPEN_FILES_LIMIT; configured floor is $MIN_OPEN_FILES" >&2
+ exit 1
+fi
+FILE_SIZE_LIMIT_BLOCKS=$(ulimit -Sf)
+if [[ "$FILE_SIZE_LIMIT_BLOCKS" == unlimited ]]; then
+ FILE_SIZE_LIMIT_BYTES=unlimited
+elif [[ "$FILE_SIZE_LIMIT_BLOCKS" =~ ^[0-9]+$ ]]; then
+ FILE_SIZE_LIMIT_BYTES=$((FILE_SIZE_LIMIT_BLOCKS * 512))
+ if (( FILE_SIZE_LIMIT_BYTES < MIN_FILE_SIZE_LIMIT_BYTES )); then
+ echo "FAIL: file-size soft limit is $FILE_SIZE_LIMIT_BYTES bytes; configured floor is $MIN_FILE_SIZE_LIMIT_BYTES" >&2
+ exit 1
+ fi
+else
+ echo "FAIL: could not parse file-size soft limit" >&2
+ exit 1
+fi
+
+MOUNT_OPTIONS=unknown
+if command -v findmnt >/dev/null 2>&1; then
+ MOUNT_OPTIONS=$(findmnt -T "$WORK_DIR" -n -o OPTIONS | tr -d '\n')
+fi
+QUOTA_VISIBILITY=none
+if [[ "$MOUNT_OPTIONS" == *quota* ]]; then
+ QUOTA_VISIBILITY=mount-options
+fi
+if command -v quota >/dev/null 2>&1; then
+ if QUOTA_OUTPUT=$(timeout 5 quota -s 2>&1); then
+ QUOTA_VISIBILITY=quota-command
+ else
+ QUOTA_OUTPUT=unavailable
+ fi
+else
+ QUOTA_OUTPUT=not-installed
+fi
+QUOTA_OUTPUT=${QUOTA_OUTPUT//$'\n'/;}
+QUOTA_OUTPUT=${QUOTA_OUTPUT//$'\t'/ }
+if (( REQUIRE_QUOTA_VISIBILITY == 1 )) && [[ "$QUOTA_VISIBILITY" == none ]]; then
+ echo "FAIL: quota visibility is required but no quota signal is available" >&2
+ exit 1
+fi
+
+PROBE_DIR=$(mktemp -d "$WORK_DIR/.mpc-capacity-probe.XXXXXXXX")
+cleanup() {
+ rm -rf -- "$PROBE_DIR"
+}
+trap cleanup EXIT
+
+printf 'mpc-capacity-source\n' >"$PROBE_DIR/source"
+printf 'mpc-capacity-target\n' >"$PROBE_DIR/collision-target"
+cp "$PROBE_DIR/source" "$PROBE_DIR/collision-source"
+if mv -Tn "$PROBE_DIR/collision-source" "$PROBE_DIR/collision-target" 2>/dev/null; then
+ :
+fi
+if [[ ! -f "$PROBE_DIR/collision-source" ]] ||
+ [[ "$(tr -d '\n' <"$PROBE_DIR/collision-target")" != mpc-capacity-target ]]; then
+ echo "FAIL: no-clobber rename collision probe overwrote an existing target" >&2
+ exit 1
+fi
+ln "$PROBE_DIR/source" "$PROBE_DIR/published"
+if ln "$PROBE_DIR/source" "$PROBE_DIR/published" 2>/dev/null; then
+ echo "FAIL: no-replace hard-link publication accepted a collision" >&2
+ exit 1
+fi
+cmp "$PROBE_DIR/source" "$PROBE_DIR/published"
+sync -f "$PROBE_DIR/source"
+sync -f "$PROBE_DIR"
+
+IO_FILE="$PROBE_DIR/sustained-io.bin"
+IO_COUNT=$((IO_PROBE_BYTES / 1048576))
+WRITE_FLAGS=(conv=fsync)
+READ_FLAGS=()
+if (( IO_DIRECT == 1 )); then
+ WRITE_FLAGS+=(oflag=direct)
+ READ_FLAGS+=(iflag=direct)
+fi
+WRITE_START_NS=$(date +%s%N)
+dd if=/dev/zero of="$IO_FILE" bs=1048576 count="$IO_COUNT" \
+ "${WRITE_FLAGS[@]}" status=none
+WRITE_END_NS=$(date +%s%N)
+READ_START_NS=$(date +%s%N)
+dd if="$IO_FILE" of=/dev/null bs=1048576 "${READ_FLAGS[@]}" status=none
+READ_END_NS=$(date +%s%N)
+WRITE_ELAPSED_NS=$((WRITE_END_NS - WRITE_START_NS))
+READ_ELAPSED_NS=$((READ_END_NS - READ_START_NS))
+if (( WRITE_ELAPSED_NS <= 0 || READ_ELAPSED_NS <= 0 )); then
+ echo "FAIL: sustained I/O timer did not advance" >&2
+ exit 1
+fi
+WRITE_BYTES_PER_SECOND=$((IO_PROBE_BYTES * 1000000000 / WRITE_ELAPSED_NS))
+READ_BYTES_PER_SECOND=$((IO_PROBE_BYTES * 1000000000 / READ_ELAPSED_NS))
+if (( WRITE_BYTES_PER_SECOND < MIN_WRITE_BYTES_PER_SECOND )); then
+ echo "FAIL: measured fsync write rate is $WRITE_BYTES_PER_SECOND B/s; configured floor is $MIN_WRITE_BYTES_PER_SECOND" >&2
+ exit 1
+fi
+if (( READ_BYTES_PER_SECOND < MIN_READ_BYTES_PER_SECOND )); then
+ echo "FAIL: measured read rate is $READ_BYTES_PER_SECOND B/s; configured floor is $MIN_READ_BYTES_PER_SECOND" >&2
+ exit 1
+fi
+
+echo "OK: K=21 work volume passed resource, I/O, and publication probes"
+echo "filesystem=$FS_TYPE free_bytes=$FREE_BYTES free_inodes=$FREE_INODES"
+echo "host_available_memory_bytes=$HOST_AVAILABLE_MEMORY_BYTES effective_available_memory_bytes=$EFFECTIVE_AVAILABLE_MEMORY_BYTES"
+echo "cgroup_version=$CGROUP_VERSION cgroup_memory_max=$CGROUP_MEMORY_MAX cgroup_memory_current=$CGROUP_MEMORY_CURRENT cgroup_memory_high=$CGROUP_MEMORY_HIGH"
+echo "host_swap_configured_bytes=$HOST_SWAP_CONFIGURED_BYTES host_swap_active_bytes=$HOST_SWAP_ACTIVE_BYTES cgroup_swap_max=$CGROUP_SWAP_MAX cgroup_swap_current=$CGROUP_SWAP_CURRENT swap_disabled_required=$REQUIRE_SWAP_DISABLED"
+echo "open_files_soft_limit=$OPEN_FILES_LIMIT file_size_soft_limit_bytes=$FILE_SIZE_LIMIT_BYTES"
+echo "io_probe_bytes=$IO_PROBE_BYTES io_direct=$IO_DIRECT fsync_write_bytes_per_second=$WRITE_BYTES_PER_SECOND read_bytes_per_second=$READ_BYTES_PER_SECOND"
+echo "mount_options=$MOUNT_OPTIONS quota_visibility=$QUOTA_VISIBILITY quota_command=$QUOTA_OUTPUT"
diff --git a/scripts/generate-go-sbom/main.go b/scripts/generate-go-sbom/main.go
new file mode 100644
index 00000000..c74e846e
--- /dev/null
+++ b/scripts/generate-go-sbom/main.go
@@ -0,0 +1,379 @@
+// Command generate-go-sbom emits a deterministic CycloneDX 1.5 SBOM from the
+// Go build information embedded in an already-built executable. It performs no
+// network access and records the exact linked module versions and module sums.
+package main
+
+import (
+ "bufio"
+ "crypto/sha256"
+ "debug/buildinfo"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime/debug"
+ "sort"
+ "strings"
+)
+
+var gnarkPatchPaths = []string{
+ "experiments/wasm-prover/patches/prove-stream.patch",
+ "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",
+}
+
+type bom struct {
+ BOMFormat string `json:"bomFormat"`
+ SpecVersion string `json:"specVersion"`
+ Version int `json:"version"`
+ Metadata metadata `json:"metadata"`
+ Components []component `json:"components"`
+}
+
+type metadata struct {
+ Tools tools `json:"tools"`
+ Component component `json:"component"`
+ Properties []property `json:"properties"`
+}
+
+type tools struct {
+ Components []component `json:"components"`
+}
+
+type component struct {
+ Type string `json:"type"`
+ Group string `json:"group,omitempty"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+ BOMRef string `json:"bom-ref,omitempty"`
+ PURL string `json:"purl,omitempty"`
+ Hashes []hash `json:"hashes,omitempty"`
+ Properties []property `json:"properties,omitempty"`
+}
+
+type hash struct {
+ Algorithm string `json:"alg"`
+ Content string `json:"content"`
+}
+
+type property struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+func main() {
+ binaryPath := flag.String("binary", "", "path to the built Go executable")
+ componentName := flag.String("name", "mpc-ceremony", "application component name")
+ sourceRoot := flag.String("source-root", "", "exact source root containing go.sum, vendor, and reviewed patches")
+ flag.Parse()
+ if *binaryPath == "" || *sourceRoot == "" || flag.NArg() != 0 {
+ fmt.Fprintln(os.Stderr, "usage: generate-go-sbom --binary FILE --source-root DIR [--name NAME]")
+ os.Exit(2)
+ }
+ sourceInfo, err := os.Lstat(*sourceRoot)
+ if err != nil || !sourceInfo.IsDir() || sourceInfo.Mode()&os.ModeSymlink != 0 {
+ fatal(errors.New("source root must be a real directory"))
+ }
+ moduleSums, err := readModuleSums(filepath.Join(*sourceRoot, "go.sum"))
+ if err != nil {
+ fatal(err)
+ }
+ gnarkPatchProperties, err := patchProperties(*sourceRoot)
+ if err != nil {
+ fatal(err)
+ }
+
+ info, err := buildinfo.ReadFile(*binaryPath)
+ if err != nil {
+ fatal(err)
+ }
+ revision, err := uniqueSetting(info.Settings, "vcs.revision")
+ if err != nil {
+ fatal(err)
+ }
+ modified, err := uniqueSetting(info.Settings, "vcs.modified")
+ if err != nil {
+ fatal(err)
+ }
+ if modified != "false" {
+ fatal(fmt.Errorf("vcs.modified is %q, want false", modified))
+ }
+ if len(revision) != 40 {
+ fatal(errors.New("vcs.revision is not an exact 40-character commit"))
+ }
+
+ components := make([]component, 0, len(info.Deps))
+ seen := make(map[string]struct{}, len(info.Deps))
+ for _, dependency := range info.Deps {
+ if dependency == nil {
+ fatal(errors.New("build information contains a nil dependency"))
+ }
+ module := dependency
+ properties := make([]property, 0, 2)
+ if dependency.Replace != nil {
+ module = dependency.Replace
+ properties = append(properties, property{
+ Name: "proof-tool:golang:replaces",
+ Value: dependency.Path + "@" + dependency.Version,
+ })
+ }
+ if module.Path == "" || module.Version == "" {
+ fatal(fmt.Errorf("dependency %q has an incomplete module identity", dependency.Path))
+ }
+ key := module.Path + "@" + module.Version
+ if _, duplicate := seen[key]; duplicate {
+ fatal(fmt.Errorf("duplicate linked dependency %q", key))
+ }
+ seen[key] = struct{}{}
+ upstreamSum, ok := moduleSums[key]
+ if !ok {
+ fatal(fmt.Errorf("linked dependency %q has no exact module content sum in go.sum", key))
+ }
+ if module.Sum != "" && module.Sum != upstreamSum {
+ fatal(fmt.Errorf("linked dependency %q build-info sum differs from go.sum", key))
+ }
+ properties = append(properties, property{
+ Name: "proof-tool:golang:module-sum",
+ Value: upstreamSum,
+ })
+ vendoredDigest, err := vendoredTreeDigest(*sourceRoot, module.Path)
+ if err != nil {
+ fatal(fmt.Errorf("linked dependency %q vendored tree: %w", key, err))
+ }
+ properties = append(properties, property{
+ Name: "proof-tool:golang:vendored-tree-sha256",
+ Value: vendoredDigest,
+ })
+ if module.Path == "github.com/consensys/gnark" {
+ properties = append(properties, gnarkPatchProperties...)
+ }
+ components = append(components, component{
+ Type: "library",
+ Name: module.Path,
+ Version: module.Version,
+ BOMRef: "pkg:golang/" + module.Path + "@" + module.Version,
+ PURL: "pkg:golang/" + module.Path + "@" + module.Version,
+ Hashes: moduleHashes(upstreamSum),
+ Properties: properties,
+ })
+ }
+ sort.Slice(components, func(i, j int) bool {
+ if components[i].Name == components[j].Name {
+ return components[i].Version < components[j].Version
+ }
+ return components[i].Name < components[j].Name
+ })
+
+ result := bom{
+ BOMFormat: "CycloneDX",
+ SpecVersion: "1.5",
+ Version: 1,
+ Metadata: metadata{
+ Tools: tools{Components: []component{{
+ Type: "application",
+ Name: "proof-tool/scripts/generate-go-sbom",
+ Version: revision,
+ }}},
+ Component: component{
+ Type: "application",
+ Name: *componentName,
+ Version: revision,
+ BOMRef: "pkg:golang/proof-tool/" + *componentName + "@" + revision,
+ PURL: "pkg:golang/proof-tool/" + *componentName + "@" + revision,
+ },
+ Properties: []property{
+ {Name: "proof-tool:go-version", Value: info.GoVersion},
+ {Name: "proof-tool:source-commit", Value: revision},
+ {Name: "proof-tool:vcs-modified", Value: modified},
+ },
+ },
+ Components: components,
+ }
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetEscapeHTML(false)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(result); err != nil {
+ fatal(err)
+ }
+}
+
+func uniqueSetting(settings []debug.BuildSetting, key string) (string, error) {
+ var value string
+ found := false
+ for _, setting := range settings {
+ if setting.Key != key {
+ continue
+ }
+ if found {
+ return "", fmt.Errorf("build setting %q is duplicated", key)
+ }
+ value = setting.Value
+ found = true
+ }
+ if !found || strings.TrimSpace(value) == "" {
+ return "", fmt.Errorf("build setting %q is missing", key)
+ }
+ return value, nil
+}
+
+func moduleHashes(sum string) []hash {
+ if !strings.HasPrefix(sum, "h1:") {
+ return nil
+ }
+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(sum, "h1:"))
+ if err != nil || len(decoded) != sha256.Size {
+ fatal(fmt.Errorf("invalid Go module sum %q", sum))
+ }
+ return []hash{{
+ Algorithm: "SHA-256",
+ Content: hex.EncodeToString(decoded),
+ }}
+}
+
+func readModuleSums(path string) (map[string]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ result := make(map[string]string)
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) != 3 || strings.HasSuffix(fields[1], "/go.mod") {
+ continue
+ }
+ if !strings.HasPrefix(fields[2], "h1:") {
+ continue
+ }
+ key := fields[0] + "@" + fields[1]
+ if existing, ok := result[key]; ok && existing != fields[2] {
+ return nil, fmt.Errorf("go.sum has conflicting content sums for %q", key)
+ }
+ result[key] = fields[2]
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ if len(result) == 0 {
+ return nil, errors.New("go.sum contains no module content sums")
+ }
+ return result, nil
+}
+
+func vendoredTreeDigest(sourceRoot, modulePath string) (string, error) {
+ moduleRoot := filepath.Join(sourceRoot, "vendor", filepath.FromSlash(modulePath))
+ rootInfo, err := os.Lstat(moduleRoot)
+ if err != nil {
+ return "", err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("vendored module root is not a real directory")
+ }
+ var paths []string
+ err = filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if path == moduleRoot {
+ return nil
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("vendored module contains symbolic link %q", path)
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("vendored module contains non-regular file %q", path)
+ }
+ paths = append(paths, path)
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ if len(paths) == 0 {
+ return "", errors.New("vendored module tree contains no files")
+ }
+ sort.Strings(paths)
+ digest := sha256.New()
+ _, _ = io.WriteString(digest, "proof-tool/vendored-module-tree/v1\x00")
+ for _, path := range paths {
+ relative, err := filepath.Rel(moduleRoot, path)
+ if err != nil {
+ return "", err
+ }
+ relative = filepath.ToSlash(relative)
+ info, err := os.Lstat(path)
+ if err != nil {
+ return "", err
+ }
+ if !info.Mode().IsRegular() {
+ return "", fmt.Errorf("vendored module file %q changed type", path)
+ }
+ if _, err := fmt.Fprintf(digest, "%d:%s:%d:", len(relative), relative, info.Size()); err != nil {
+ return "", err
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ n, copyErr := io.Copy(digest, file)
+ closeErr := file.Close()
+ if copyErr != nil {
+ return "", copyErr
+ }
+ if closeErr != nil {
+ return "", closeErr
+ }
+ if n != info.Size() {
+ return "", fmt.Errorf("vendored module file %q changed size", path)
+ }
+ _, _ = digest.Write([]byte{0})
+ }
+ return "sha256:" + hex.EncodeToString(digest.Sum(nil)), nil
+}
+
+func patchProperties(sourceRoot string) ([]property, error) {
+ result := make([]property, 0, len(gnarkPatchPaths))
+ for _, relative := range gnarkPatchPaths {
+ path := filepath.Join(sourceRoot, filepath.FromSlash(relative))
+ info, err := os.Lstat(path)
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || info.Size() <= 0 {
+ return nil, fmt.Errorf("reviewed patch %q is not a non-empty regular file", relative)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ sum := sha256.Sum256(data)
+ result = append(result, property{
+ Name: "proof-tool:vendored-patch:" + filepath.Base(relative) + ":sha256",
+ Value: "sha256:" + hex.EncodeToString(sum[:]),
+ })
+ }
+ return result, nil
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
diff --git a/scripts/mpc-finalization-evidence/main.go b/scripts/mpc-finalization-evidence/main.go
new file mode 100644
index 00000000..cde64685
--- /dev/null
+++ b/scripts/mpc-finalization-evidence/main.go
@@ -0,0 +1,182 @@
+// Command mpc-finalization-evidence is the coordinator-local public golden
+// evidence generator for rehearsal and production. It is deliberately
+// separate from cmd/mpc-ceremony and accepts no wallet, seed, master-XPrv, or
+// derivation-path input.
+package main
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "proof-tool/internal/circuit/ownership"
+ "proof-tool/internal/circuit/ownershipdest"
+ "proof-tool/internal/mpcceremony"
+ "proof-tool/internal/prover"
+)
+
+const resultSchema = "proof-tool-mpc-public-evidence-generation-result-v1"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ os.Exit(1)
+ }
+}
+
+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")
+ coordinatorPublicKeyFile := fs.String("coordinator-public-key-file", "", "out-of-band trusted coordinator Ed25519 public key file")
+ ceremonyID := fs.String("ceremony-id", "", "exact ceremony id from ceremony.json")
+ out := fs.String("out", "", "canonical public evidence JSON path (fresh or exact completed retry)")
+ if err := fs.Parse(os.Args[1:]); err != nil {
+ return err
+ }
+ if *keysDir == "" || *coordinatorPublicKeyFile == "" || *ceremonyID == "" || *out == "" {
+ return errors.New("--keys-dir, --coordinator-public-key-file, --ceremony-id, and --out are required")
+ }
+ info, err := os.Lstat(*keysDir)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return errors.New("keys-dir must be a real directory, not a symlink")
+ }
+ keyInfo, err := os.Lstat(*coordinatorPublicKeyFile)
+ if err != nil {
+ return err
+ }
+ if !keyInfo.Mode().IsRegular() || keyInfo.Mode()&os.ModeSymlink != 0 {
+ return errors.New("coordinator public key must be a regular file, not a symlink")
+ }
+ publicKeyHex, err := os.ReadFile(*coordinatorPublicKeyFile)
+ if err != nil {
+ return err
+ }
+ preliminary, err := mpcceremony.VerifyPreliminaryFinalKeys(*keysDir, string(publicKeyHex))
+ if err != nil {
+ return err
+ }
+ if preliminary.CeremonyID != *ceremonyID {
+ return errors.New("preliminary key ceremony id differs from --ceremony-id")
+ }
+
+ // 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",
+ )
+ if err != nil {
+ return err
+ }
+ destination, err := ownershipdest.DecodeDestinationAddressV1Hex(
+ "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111000000" +
+ "0000000000000000000000000000000000000000000000000000",
+ )
+ if err != nil {
+ return err
+ }
+ path := ownership.Path{Account: 3, Role: 2, Index: 0}
+ credential, err := ownership.DeriveCredential(master, path)
+ if err != nil {
+ return err
+ }
+ publicInputDigest, err := ownershipdest.PublicInputDigestForCredentialDestination(credential[:], destination)
+ if err != nil {
+ return err
+ }
+ publicInput, err := ownershipdest.PublicInputForCredentialDestination(credential[:], destination)
+ if err != nil {
+ return err
+ }
+ assignment, err := ownershipdest.Assignment(master, path, destination, publicInput)
+ if err != nil {
+ return err
+ }
+ ccs, err := mpcceremony.ReadR1CSFile(
+ filepath.Join(*keysDir, preliminary.ConstraintSystem.Name),
+ preliminary.Circuit,
+ )
+ if err != nil {
+ return err
+ }
+ pk, err := prover.LoadPK(filepath.Join(*keysDir, mpcceremony.NativeProvingKeyFile))
+ if err != nil {
+ return err
+ }
+ vk, err := prover.LoadVK(filepath.Join(*keysDir, mpcceremony.NativeVerifyingKeyFile))
+ if err != nil {
+ return err
+ }
+ cardanoVK, formatVK, err := prover.SerializeCardanoVK(vk)
+ if err != nil {
+ return err
+ }
+ if formatVK != "groth16-bls12-381-bsb22" ||
+ mpcceremony.NewDigest(cardanoVK) != preliminary.CardanoVerifyingKey.Digest {
+ return errors.New("native preliminary VK differs from signed Cardano VK")
+ }
+ proof, err := prover.Prove(ccs.R1CS, pk, assignment)
+ if err != nil {
+ return err
+ }
+ if err := prover.VerifyProof(vk, proof, &ownershipdest.Circuit{Pub: publicInput}); err != nil {
+ return fmt.Errorf("preliminary PK/VK proof coherence: %w", err)
+ }
+ cardanoProof, format, err := prover.SerializeCardanoProof(proof)
+ if err != nil {
+ return err
+ }
+ if format != "groth16-bls12-381-bsb22" || len(cardanoProof) != prover.CardanoProofCommitmentLen {
+ return errors.New("generated proof is not the exact Cardano BSB22 encoding")
+ }
+ storedCardanoVK, err := os.ReadFile(filepath.Join(*keysDir, mpcceremony.CardanoVKBytesFile))
+ if err != nil {
+ return err
+ }
+ if len(storedCardanoVK) != prover.CardanoVKCommitmentLen ||
+ !bytes.Equal(storedCardanoVK, cardanoVK) {
+ return errors.New("preliminary Cardano VK has unexpected length")
+ }
+ evidence := mpcceremony.PublicFinalizationEvidence{
+ Schema: mpcceremony.PublicEvidenceSchema,
+ CeremonyID: *ceremonyID,
+ Fixture: mpcceremony.PublicEvidenceFixture,
+ CredentialHex: hex.EncodeToString(credential[:]),
+ DestinationHex: hex.EncodeToString(destination),
+ PublicInputDigestHex: hex.EncodeToString(publicInputDigest),
+ CardanoProofHex: hex.EncodeToString(cardanoProof),
+ CardanoProofFormat: format,
+ CardanoProofRawDigest: mpcceremony.NewDigest(cardanoProof),
+ CardanoVerifyingKey: mpcceremony.ArtifactRef{
+ Name: mpcceremony.CardanoVKBytesFile,
+ Digest: mpcceremony.NewDigest(storedCardanoVK),
+ },
+ }
+ data, err := mpcceremony.MarshalCanonical(evidence)
+ if err != nil {
+ return err
+ }
+ if err := publishCompletedFile(*out, data, 0o600); err != nil {
+ return err
+ }
+ result := struct {
+ Schema string `json:"schema"`
+ CeremonyID string `json:"ceremony_id"`
+ PublicEvidence mpcceremony.Digest `json:"public_evidence_digest"`
+ }{
+ Schema: resultSchema,
+ CeremonyID: *ceremonyID,
+ PublicEvidence: mpcceremony.NewDigest(data),
+ }
+ return json.NewEncoder(os.Stdout).Encode(result)
+}
diff --git a/scripts/mpc-finalization-evidence/publication.go b/scripts/mpc-finalization-evidence/publication.go
new file mode 100644
index 00000000..0c9331b4
--- /dev/null
+++ b/scripts/mpc-finalization-evidence/publication.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+// publishCompletedFile writes and syncs a same-parent temporary file before
+// publishing it with a hard link. A complete exact destination is accepted so
+// a retry can recover from termination after the link but before the caller
+// received success. A different destination is never replaced.
+func publishCompletedFile(destination string, data []byte, mode fs.FileMode) error {
+ if destination == "" {
+ return errors.New("publication destination is required")
+ }
+ parent := filepath.Dir(destination)
+ parentInfo, err := os.Lstat(parent)
+ if err != nil {
+ return fmt.Errorf("inspect publication parent: %w", err)
+ }
+ if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
+ return errors.New("publication parent must be a real directory")
+ }
+
+ temporary, err := os.CreateTemp(parent, "."+filepath.Base(destination)+".partial-*")
+ if err != nil {
+ return fmt.Errorf("create publication staging file: %w", err)
+ }
+ temporaryPath := temporary.Name()
+ keepTemporary := true
+ defer func() {
+ if keepTemporary {
+ _ = temporary.Close()
+ _ = os.Remove(temporaryPath)
+ }
+ }()
+ if err := temporary.Chmod(mode); err != nil {
+ return fmt.Errorf("set publication staging permissions: %w", err)
+ }
+ if _, err := temporary.Write(data); err != nil {
+ return fmt.Errorf("write publication staging file: %w", err)
+ }
+ if err := temporary.Sync(); err != nil {
+ return fmt.Errorf("sync publication staging file: %w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close publication staging file: %w", err)
+ }
+
+ exact, err := completedFileIsExact(destination, data, mode)
+ if err != nil {
+ return err
+ }
+ if !exact {
+ if err := os.Link(temporaryPath, destination); err != nil {
+ exact, inspectErr := completedFileIsExact(destination, data, mode)
+ if inspectErr != nil {
+ return errors.Join(
+ fmt.Errorf("publish completed output without replacement: %w", err),
+ inspectErr,
+ )
+ }
+ if !exact {
+ return fmt.Errorf("publish completed output without replacement: %w", err)
+ }
+ }
+ exact, err = completedFileIsExact(destination, data, mode)
+ if err != nil {
+ return fmt.Errorf("validate published output: %w", err)
+ }
+ if !exact {
+ return errors.New("published output differs from completed staging file")
+ }
+ }
+
+ if err := os.Remove(temporaryPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("remove publication staging link: %w", err)
+ }
+ keepTemporary = false
+ if err := syncOutputDirectory(parent); err != nil {
+ return err
+ }
+ return nil
+}
+
+func completedFileIsExact(path string, expected []byte, mode fs.FileMode) (bool, error) {
+ linkInfo, err := os.Lstat(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("inspect completed output: %w", err)
+ }
+ if !linkInfo.Mode().IsRegular() || linkInfo.Mode()&os.ModeSymlink != 0 {
+ return false, fmt.Errorf("completed output conflicts with unsafe path: %w", fs.ErrExist)
+ }
+ if linkInfo.Mode().Perm() != mode.Perm() || linkInfo.Size() != int64(len(expected)) {
+ return false, fmt.Errorf("completed output conflicts with different bytes or permissions: %w", fs.ErrExist)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return false, fmt.Errorf("open completed output: %w", err)
+ }
+ defer file.Close()
+ openInfo, err := file.Stat()
+ if err != nil {
+ return false, fmt.Errorf("inspect open completed output: %w", err)
+ }
+ if !openInfo.Mode().IsRegular() || !os.SameFile(linkInfo, openInfo) ||
+ openInfo.Size() != int64(len(expected)) {
+ return false, fmt.Errorf("completed output changed while being opened: %w", fs.ErrExist)
+ }
+ actual := make([]byte, len(expected))
+ if _, err := io.ReadFull(file, actual); err != nil && len(expected) != 0 {
+ return false, fmt.Errorf("read completed output: %w", err)
+ }
+ finalInfo, err := file.Stat()
+ if err != nil {
+ return false, fmt.Errorf("reinspect completed output: %w", err)
+ }
+ if !os.SameFile(openInfo, finalInfo) || finalInfo.Size() != openInfo.Size() ||
+ !bytes.Equal(actual, expected) {
+ return false, fmt.Errorf("completed output conflicts with different bytes: %w", fs.ErrExist)
+ }
+ return true, nil
+}
+
+func syncOutputDirectory(path string) error {
+ directory, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open publication parent for sync: %w", err)
+ }
+ defer directory.Close()
+ if err := directory.Sync(); err != nil {
+ return fmt.Errorf("sync publication parent: %w", err)
+ }
+ return nil
+}
diff --git a/scripts/mpc-finalization-evidence/publication_test.go b/scripts/mpc-finalization-evidence/publication_test.go
new file mode 100644
index 00000000..0de85432
--- /dev/null
+++ b/scripts/mpc-finalization-evidence/publication_test.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPublishCompletedFileRecoversOnlyExactOutput(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ destination := filepath.Join(parent, "evidence.json")
+ data := []byte("{\"complete\":true}\n")
+ if err := publishCompletedFile(destination, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := publishCompletedFile(destination, data, 0o600); err != nil {
+ t.Fatalf("exact retry failed: %v", err)
+ }
+ actual, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, data) {
+ t.Fatal("published output differs")
+ }
+
+ err = publishCompletedFile(destination, []byte("{\"complete\":false}\n"), 0o600)
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("conflicting retry error = %v, want fs.ErrExist", err)
+ }
+ actual, err = os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, data) {
+ t.Fatal("conflicting retry changed authoritative output")
+ }
+}
+
+func TestPublishCompletedFileIgnoresInterruptedStagingFile(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ stale := filepath.Join(parent, ".evidence.json.partial-interrupted")
+ if err := os.WriteFile(stale, []byte("partial"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ destination := filepath.Join(parent, "evidence.json")
+ data := []byte("complete")
+ if err := publishCompletedFile(destination, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ actual, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, data) {
+ t.Fatal("interrupted staging file affected publication")
+ }
+}
diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go
new file mode 100644
index 00000000..fda685d2
--- /dev/null
+++ b/scripts/mpc-rehearsal-config/main.go
@@ -0,0 +1,268 @@
+// Command mpc-rehearsal-config 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 main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "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 main() {
+ outDir := flag.String("out-dir", "", "fresh output directory")
+ participantCount := flag.Int("participants", 3, "number of rehearsal participants (3-20)")
+ beaconWitnessLead := flag.Uint(
+ "beacon-witness-lead-seconds",
+ 300,
+ "signed rehearsal witness/round lead in seconds (minimum 60)",
+ )
+ flag.Parse()
+ if *outDir == "" || flag.NArg() != 0 {
+ fmt.Fprintln(os.Stderr, "usage: mpc-rehearsal-config --out-dir FRESH_DIR [--participants 3]")
+ os.Exit(2)
+ }
+ if *beaconWitnessLead > uint(^uint32(0)) {
+ fmt.Fprintln(os.Stderr, "beacon witness lead exceeds uint32")
+ os.Exit(2)
+ }
+ if err := generate(*outDir, *participantCount, uint32(*beaconWitnessLead)); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ 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
+}
diff --git a/scripts/mpc-rehearsal-config/main_test.go b/scripts/mpc-rehearsal-config/main_test.go
new file mode 100644
index 00000000..c7db51d2
--- /dev/null
+++ b/scripts/mpc-rehearsal-config/main_test.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/mpcceremony"
+)
+
+func TestGenerateCreatesValidatedCanonicalRehearsalInputs(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "rehearsal")
+ if err := generate(root, 3, 300); err != nil {
+ t.Fatal(err)
+ }
+ participants, err := mpcceremony.LoadInitParticipants(
+ filepath.Join(root, "config", "participants.json"),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(participants.Roster) != 3 || len(participants.Auditors) != 2 {
+ t.Fatal("unexpected generated rehearsal roster")
+ }
+ policy, err := mpcceremony.LoadInitPolicy(filepath.Join(root, "config", "policy.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if policy.Phase1Policy.Minimum != 3 || policy.Phase2Policy.Minimum != 3 {
+ t.Fatal("rehearsal policy did not require every generated participant")
+ }
+ if policy.BeaconPolicy.MinimumWitnessLeadSeconds != 300 {
+ t.Fatal("rehearsal policy did not bind the requested beacon witness lead")
+ }
+ if _, err := mpcceremony.LoadContributionEnvironment(
+ filepath.Join(root, "config", "environment.json"),
+ ); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{
+ "coordinator",
+ "release-signer",
+ "auditor-01",
+ "auditor-02",
+ "witness-01",
+ "witness-02",
+ "mirror-01",
+ "mirror-02",
+ "participant-01",
+ "participant-02",
+ "participant-03",
+ } {
+ path := filepath.Join(root, "keys", id+".ed25519.private.hex")
+ if _, _, err := keybundle.LoadExistingPrivateKey(path); err != nil {
+ t.Fatalf("load generated %s key: %v", id, err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ t.Fatalf("generated %s key has group/world permissions", id)
+ }
+ }
+}
+
+func TestGenerateRejectsUnsafeParticipantCounts(t *testing.T) {
+ for _, count := range []int{0, 2, 21} {
+ if err := generate(filepath.Join(t.TempDir(), "rehearsal"), count, 300); err == nil {
+ t.Fatalf("accepted participant count %d", count)
+ }
+ }
+}
+
+func TestGenerateRejectsShortBeaconWitnessLead(t *testing.T) {
+ if err := generate(filepath.Join(t.TempDir(), "rehearsal"), 3, 59); err == nil {
+ t.Fatal("accepted a rehearsal beacon witness lead below 60 seconds")
+ }
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/main.go b/scripts/mpc-rehearsal-operational-evidence/main.go
new file mode 100644
index 00000000..d900f337
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/main.go
@@ -0,0 +1,951 @@
+// Command mpc-rehearsal-operational-evidence builds complete, signed
+// operational evidence for the same-host K=21 rehearsal. It is not a
+// production enrollment or witnessing tool.
+package main
+
+import (
+ "crypto/ed25519"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "proof-tool/internal/keybundle"
+ "proof-tool/internal/mpcceremony"
+)
+
+const resultSchema = "proof-tool-mpc-rehearsal-operational-evidence-result-v1"
+
+type signer struct {
+ identity mpcceremony.Identity
+ key ed25519.PrivateKey
+ role mpcceremony.EnrollmentRole
+ index uint16
+}
+
+type relayInput struct {
+ relayID string
+ operatorID string
+ endpointSHA256 string
+ retrievedAt string
+ filename string
+ raw []byte
+}
+
+type phaseResult struct {
+ evidence mpcceremony.PhaseOperationalEvidence
+ close mpcceremony.AuthenticatedCloseEvidence
+}
+
+func main() {
+ transcriptRoot := flag.String("transcript-root", "", "exact rehearsal transcript/evidence root")
+ keysDir := flag.String("keys-dir", "", "rehearsal-only identity key directory")
+ coordinatorPublicKey := flag.String(
+ "coordinator-public-key-file",
+ "",
+ "out-of-band coordinator public key",
+ )
+ phase1Relays := flag.String("phase1-relays", "", "Phase 1 explicit relay input directory")
+ phase2Relays := flag.String("phase2-relays", "", "Phase 2 explicit relay input directory")
+ assembledAt := flag.String("assembled-at", "", "bundle assembly time in RFC3339 UTC")
+ outDir := flag.String(
+ "out-dir",
+ "",
+ "transcript-root/operational directory (fresh or exact completed retry)",
+ )
+ flag.Parse()
+ if flag.NArg() != 0 || *transcriptRoot == "" || *keysDir == "" ||
+ *coordinatorPublicKey == "" || *phase1Relays == "" || *phase2Relays == "" ||
+ *assembledAt == "" || *outDir == "" {
+ fmt.Fprintln(
+ os.Stderr,
+ "usage: mpc-rehearsal-operational-evidence --transcript-root DIR --keys-dir DIR "+
+ "--coordinator-public-key-file FILE --phase1-relays DIR --phase2-relays DIR "+
+ "--assembled-at RFC3339_UTC --out-dir OPERATIONAL_DIR",
+ )
+ os.Exit(2)
+ }
+ result, err := build(
+ *transcriptRoot,
+ *keysDir,
+ *coordinatorPublicKey,
+ *phase1Relays,
+ *phase2Relays,
+ *assembledAt,
+ *outDir,
+ )
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func build(
+ transcriptRoot,
+ keysDir,
+ coordinatorPublicKey,
+ phase1RelayDir,
+ phase2RelayDir,
+ assembledAt,
+ outDir string,
+) (map[string]any, error) {
+ transcriptRoot, err := realDirectory(transcriptRoot)
+ if err != nil {
+ return nil, fmt.Errorf("transcript root: %w", err)
+ }
+ keysDir, err = realDirectory(keysDir)
+ if err != nil {
+ return nil, fmt.Errorf("keys directory: %w", err)
+ }
+ outParent, err := realDirectory(filepath.Dir(outDir))
+ if err != nil {
+ return nil, fmt.Errorf("output parent: %w", err)
+ }
+ outDir = filepath.Join(outParent, filepath.Base(outDir))
+ if outParent != transcriptRoot || filepath.Base(outDir) != "operational" {
+ return nil, errors.New("output must be the fresh transcript-root/operational directory")
+ }
+ if _, err := time.Parse(time.RFC3339, assembledAt); err != nil {
+ return nil, fmt.Errorf("assembled-at: %w", err)
+ }
+
+ trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{
+ DefinitionPath: filepath.Join(transcriptRoot, "ceremony.json"),
+ DefinitionSignaturePath: filepath.Join(transcriptRoot, "ceremony.sig"),
+ CoordinatorPublicKeyPath: coordinatorPublicKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ definition := trusted.Definition
+ definitionBytes, err := readRegular(filepath.Join(transcriptRoot, "ceremony.json"), 16<<20)
+ if err != nil {
+ return nil, err
+ }
+ signers, err := loadSigners(definition, keysDir)
+ if err != nil {
+ return nil, err
+ }
+ coordinator := signers[definition.Coordinator.ID]
+
+ stagingDir, err := createCompletedOutputStaging(outDir)
+ if err != nil {
+ return nil, fmt.Errorf("create operational evidence staging root: %w", err)
+ }
+ defer func() {
+ _ = os.RemoveAll(stagingDir)
+ }()
+
+ enrollments := make([]mpcceremony.SignedArtifactRefs, 0, len(signers))
+ signerIDs := make([]string, 0, len(signers))
+ for id := range signers {
+ signerIDs = append(signerIDs, id)
+ }
+ slices.Sort(signerIDs)
+ createdAt, err := parseTimestamp("definition created_at", definition.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ for _, id := range signerIDs {
+ item := signers[id]
+ disclosureName := "operational/disclosures/" + id + ".json"
+ disclosureBytes := []byte(
+ fmt.Sprintf(
+ "{\"schema\":\"proof-tool-mpc-rehearsal-independence-disclosure-v1\",\"identity_id\":%q,\"same_host\":true}\n",
+ id,
+ ),
+ )
+ if err := writeRelative(stagingDir, disclosureName, disclosureBytes); err != nil {
+ return nil, err
+ }
+ record, err := mpcceremony.NewEnrollmentRecord(
+ definition,
+ definitionBytes,
+ item.identity,
+ item.role,
+ item.index,
+ mpcceremony.ArtifactRef{
+ Name: disclosureName,
+ Digest: mpcceremony.NewDigest(disclosureBytes),
+ },
+ createdAt.Add(time.Second).Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return nil, err
+ }
+ pair, err := writeSignedPair(
+ stagingDir,
+ "operational/enrollments/"+id,
+ record,
+ item.identity.KeyID,
+ item.key,
+ )
+ if err != nil {
+ return nil, err
+ }
+ enrollments = append(enrollments, pair)
+ }
+
+ phase1, err := buildPhase(
+ transcriptRoot,
+ stagingDir,
+ definition,
+ signers,
+ mpcceremony.Phase1,
+ phase1RelayDir,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("phase1: %w", err)
+ }
+ phase2, err := buildPhase(
+ transcriptRoot,
+ stagingDir,
+ definition,
+ signers,
+ mpcceremony.Phase2,
+ phase2RelayDir,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("phase2: %w", err)
+ }
+ bundle := mpcceremony.OperationalEvidenceBundle{
+ Schema: mpcceremony.OperationalEvidenceBundleSchema,
+ CeremonyID: definition.CeremonyID,
+ Enrollments: enrollments,
+ Phase1: phase1.evidence,
+ Phase2: phase2.evidence,
+ CoordinatorID: definition.Coordinator.ID,
+ CoordinatorKeyID: definition.Coordinator.KeyID,
+ AssembledAt: assembledAt,
+ }
+ bundleBytes, bundleSignature, err := mpcceremony.SignRecord(
+ bundle,
+ definition.Coordinator.KeyID,
+ coordinator.key,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if err := writeRelative(
+ stagingDir,
+ "operational/evidence-bundle.json",
+ bundleBytes,
+ ); err != nil {
+ return nil, err
+ }
+ if err := writeRelative(
+ stagingDir,
+ "operational/evidence-bundle.sig",
+ bundleSignature,
+ ); err != nil {
+ return nil, err
+ }
+ verificationRoot, err := createOperationalVerificationRoot(transcriptRoot, stagingDir)
+ if err != nil {
+ return nil, fmt.Errorf("create operational evidence verification root: %w", err)
+ }
+ defer func() {
+ _ = os.RemoveAll(verificationRoot)
+ }()
+ var verified mpcceremony.VerifiedOperationalEvidence
+ if err := verifyThenPublishCompletedDirectory(stagingDir, outDir, func() error {
+ var verifyErr error
+ verified, verifyErr = mpcceremony.VerifyOperationalEvidenceBundle(
+ mpcceremony.VerifyOperationalEvidenceOptions{
+ Definition: definition,
+ CoordinatorPublicKey: trusted.CoordinatorPublicKey,
+ EvidenceRoot: verificationRoot,
+ BundleBytes: bundleBytes,
+ BundleSignatureBytes: bundleSignature,
+ Phase1Close: phase1.close,
+ Phase2Close: phase2.close,
+ },
+ )
+ return verifyErr
+ }); err != nil {
+ return nil, fmt.Errorf("complete operational evidence: %w", err)
+ }
+ return map[string]any{
+ "schema": resultSchema,
+ "ok": true,
+ "ceremony_id": definition.CeremonyID,
+ "bundle_sha256": verified.BundleDigest.SHA256,
+ "referenced_artifacts": len(verified.ReferencedArtifacts),
+ "out": outDir,
+ }, nil
+}
+
+func buildPhase(
+ root,
+ stagingDir string,
+ definition mpcceremony.CeremonyDefinition,
+ signers map[string]signer,
+ phase mpcceremony.Phase,
+ relayDir string,
+) (phaseResult, error) {
+ policy, err := definition.PolicyForPhase(phase)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ sequence := fmt.Sprintf("%04d", len(policy.Participants))
+ phaseName := string(phase)
+ chainName := phaseName + "/chain-" + sequence + ".json"
+ chainSignatureName := phaseName + "/chain-" + sequence + ".sig"
+ closeName := phaseName + "/closure/record.json"
+ closeSignatureName := phaseName + "/closure/record.sig"
+ chainBytes, err := readRegular(filepath.Join(root, filepath.FromSlash(chainName)), 16<<20)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ chainSignatureBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(chainSignatureName)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ closeBytes, err := readRegular(filepath.Join(root, filepath.FromSlash(closeName)), 16<<20)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ closeSignatureBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(closeSignatureName)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ var chain mpcceremony.Chain
+ if err := mpcceremony.UnmarshalCanonical(chainBytes, &chain); err != nil {
+ return phaseResult{}, err
+ }
+ var closeRecord mpcceremony.CloseRecord
+ if err := mpcceremony.UnmarshalCanonical(closeBytes, &closeRecord); err != nil {
+ return phaseResult{}, err
+ }
+ coordinator := signers[definition.Coordinator.ID]
+ heads := make([]mpcceremony.AcceptedHeadOperationalEvidence, len(chain.Records))
+ for index, chainRecord := range chain.Records {
+ participant := signers[chainRecord.ParticipantID]
+ prefixSequence := fmt.Sprintf("%04d", index+1)
+ prefixName := phaseName + "/chain-" + prefixSequence + ".json"
+ prefixSignatureName := phaseName + "/chain-" + prefixSequence + ".sig"
+ prefixBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(prefixName)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ prefixSignatureBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(prefixSignatureName)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ prefixPair := mpcceremony.SignedArtifactRefs{
+ Record: mpcceremony.ArtifactRef{
+ Name: prefixName,
+ Digest: mpcceremony.NewDigest(prefixBytes),
+ },
+ Signature: mpcceremony.ArtifactRef{
+ Name: prefixSignatureName,
+ Digest: mpcceremony.NewDigest(prefixSignatureBytes),
+ },
+ }
+ attestationBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(chainRecord.Attestation.Name)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ var attestation mpcceremony.ContributionAttestation
+ if err := mpcceremony.UnmarshalCanonical(attestationBytes, &attestation); err != nil {
+ return phaseResult{}, err
+ }
+ erasureBytes, err := readRegular(
+ filepath.Join(root, filepath.FromSlash(chainRecord.Erasure.Name)),
+ 16<<20,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ var erasure mpcceremony.ErasureAttestation
+ if err := mpcceremony.UnmarshalCanonical(erasureBytes, &erasure); err != nil {
+ return phaseResult{}, err
+ }
+ contributedAt, err := parseTimestamp("contributed_at", attestation.ContributedAt)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ destroyedAt, err := parseTimestamp("destroyed_at", erasure.DestroyedAt)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ acceptedAt, err := parseTimestamp("accepted_at", chainRecord.AcceptedAt)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ predecessorAcceptedAt, err := parseTimestamp("definition created_at", definition.CreatedAt)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ if index > 0 {
+ predecessorAcceptedAt, err = parseTimestamp(
+ "predecessor accepted_at",
+ chain.Records[index-1].AcceptedAt,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ }
+ outboundCreatedAt, outboundReceivedAt, err := twoInteriorTimestamps(
+ predecessorAcceptedAt,
+ contributedAt,
+ )
+ if err != nil {
+ return phaseResult{}, fmt.Errorf("accepted head %d outbound custody: %w", index+1, err)
+ }
+ returnLowerBound := contributedAt
+ if destroyedAt.After(returnLowerBound) {
+ returnLowerBound = destroyedAt
+ }
+ returnCreatedAt, returnReceivedAt, err := twoInteriorTimestamps(
+ returnLowerBound,
+ acceptedAt,
+ )
+ if err != nil {
+ return phaseResult{}, fmt.Errorf("accepted head %d returned custody: %w", index+1, err)
+ }
+
+ outboundFiles := []mpcceremony.ArtifactRef{chainRecord.PreviousPayload}
+ outbound, err := mpcceremony.NewTransferHandoff(
+ definition,
+ phase,
+ uint8(index+1),
+ chainRecord.PreviousRecordID,
+ outboundFiles,
+ coordinator.identity,
+ participant.identity,
+ outboundCreatedAt,
+ contributedAt.Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ outboundBytes, err := mpcceremony.MarshalCanonical(outbound)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ stem := fmt.Sprintf("operational/%s/heads/%04d", phaseName, index+1)
+ outboundPair, err := writeSignedPair(
+ stagingDir,
+ stem+"/outbound-handoff",
+ outbound,
+ coordinator.identity.KeyID,
+ coordinator.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ outboundReceipt, err := mpcceremony.NewTransferReceipt(
+ outbound,
+ outboundBytes,
+ mpcceremony.ReceiptReceiver,
+ outboundReceivedAt,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ outboundReceiptPair, err := writeSignedPair(
+ stagingDir,
+ stem+"/outbound-receipt",
+ outboundReceipt,
+ participant.identity.KeyID,
+ participant.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+
+ returnFiles := []mpcceremony.ArtifactRef{
+ chainRecord.OutputPayload,
+ chainRecord.Attestation,
+ chainRecord.AttestationSignature,
+ chainRecord.Erasure,
+ chainRecord.ErasureSignature,
+ }
+ slices.SortFunc(returnFiles, func(a, b mpcceremony.ArtifactRef) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ returnHandoff, err := mpcceremony.NewTransferHandoff(
+ definition,
+ phase,
+ uint8(index+1),
+ chainRecord.PreviousRecordID,
+ returnFiles,
+ participant.identity,
+ coordinator.identity,
+ returnCreatedAt,
+ acceptedAt.Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ returnHandoffBytes, err := mpcceremony.MarshalCanonical(returnHandoff)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ returnHandoffPair, err := writeSignedPair(
+ stagingDir,
+ stem+"/return-handoff",
+ returnHandoff,
+ participant.identity.KeyID,
+ participant.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ returnReceipt, err := mpcceremony.NewTransferReceipt(
+ returnHandoff,
+ returnHandoffBytes,
+ mpcceremony.ReceiptReceiver,
+ returnReceivedAt,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ returnReceiptPair, err := writeSignedPair(
+ stagingDir,
+ stem+"/return-receipt",
+ returnReceipt,
+ coordinator.identity.KeyID,
+ coordinator.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ mirrorFiles := append([]mpcceremony.ArtifactRef(nil), returnFiles...)
+ mirrorFiles = append(
+ mirrorFiles,
+ chainRecord.Verification,
+ prefixPair.Record,
+ prefixPair.Signature,
+ )
+ slices.SortFunc(mirrorFiles, func(a, b mpcceremony.ArtifactRef) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ mirrorPairs := make([]mpcceremony.SignedArtifactRefs, 0, 2)
+ for _, mirrorID := range []string{"mirror-01", "mirror-02"} {
+ mirror := signers[mirrorID]
+ record, err := mpcceremony.NewImmutableMirrorReceipt(
+ definition.CeremonyID,
+ phase,
+ uint8(index+1),
+ chainRecord.RecordID,
+ mirrorFiles,
+ mirror.identity,
+ mpcceremony.NewDigest(
+ []byte("same-host-rehearsal-mirror:"+phaseName+":"+sequence+":"+mirrorID),
+ ).SHA256,
+ acceptedAt.Add(time.Second).Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ pair, err := writeSignedPair(
+ stagingDir,
+ stem+"/mirrors/"+mirrorID,
+ record,
+ mirror.identity.KeyID,
+ mirror.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ mirrorPairs = append(mirrorPairs, pair)
+ }
+ heads[index] = mpcceremony.AcceptedHeadOperationalEvidence{
+ Index: uint8(index + 1),
+ PredecessorHeadID: chainRecord.PreviousRecordID,
+ AcceptedHeadID: chainRecord.RecordID,
+ OutboundHandoff: outboundPair,
+ OutboundReceipt: outboundReceiptPair,
+ ReturnHandoff: returnHandoffPair,
+ ReturnReceipt: returnReceiptPair,
+ AcceptedChainPrefix: prefixPair,
+ MirrorReceipts: mirrorPairs,
+ }
+ }
+
+ witnessPairs := make([]mpcceremony.SignedArtifactRefs, 0, 2)
+ closedAt, err := parseTimestamp("closed_at", closeRecord.ClosedAt)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ for _, witnessID := range []string{"witness-01", "witness-02"} {
+ witness := signers[witnessID]
+ record, err := mpcceremony.NewPublicWitnessReceipt(
+ definition,
+ closeRecord,
+ closeBytes,
+ witness.identity,
+ closeName,
+ mpcceremony.NewDigest(
+ []byte("same-host-rehearsal-witness:"+phaseName+":"+witnessID),
+ ).SHA256,
+ closedAt.Add(time.Second).Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ pair, err := writeSignedPair(
+ stagingDir,
+ "operational/"+phaseName+"/witnesses/"+witnessID,
+ record,
+ witness.identity.KeyID,
+ witness.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ witnessPairs = append(witnessPairs, pair)
+ }
+
+ relays, err := loadRelayInputs(relayDir)
+ if err != nil {
+ return phaseResult{}, err
+ }
+ rawResponses := make(map[string][]byte, len(relays))
+ rawRefs := make([]mpcceremony.ArtifactRef, len(relays))
+ observations := make([]mpcceremony.RelayObservation, len(relays))
+ var latestRetrieved time.Time
+ for index, relay := range relays {
+ name := "operational/" + phaseName + "/beacon/raw/" + relay.relayID + ".json"
+ if err := writeRelative(stagingDir, name, relay.raw); err != nil {
+ return phaseResult{}, err
+ }
+ randomness, err := mpcceremony.VerifyDrandBeaconResponse(
+ definition.BeaconPolicy,
+ closeRecord.BeaconRound,
+ relay.raw,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ rawRefs[index] = mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest(relay.raw)}
+ observations[index] = mpcceremony.RelayObservation{
+ RelayID: relay.relayID,
+ OperatorID: relay.operatorID,
+ EndpointSHA256: relay.endpointSHA256,
+ RawResponse: rawRefs[index],
+ RetrievedAt: relay.retrievedAt,
+ VerifiedRandomness: randomness,
+ }
+ retrieved, _ := time.Parse(time.RFC3339, relay.retrievedAt)
+ if retrieved.After(latestRetrieved) {
+ latestRetrieved = retrieved
+ }
+ rawResponses[relay.relayID] = relay.raw
+ }
+ beaconEvidence, err := mpcceremony.NewMultiRelayBeaconEvidence(
+ definition,
+ closeRecord,
+ observations,
+ rawResponses,
+ latestRetrieved.Add(time.Second).Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ beaconPair, err := writeSignedPair(
+ stagingDir,
+ "operational/"+phaseName+"/beacon/evidence",
+ beaconEvidence,
+ coordinator.identity.KeyID,
+ coordinator.key,
+ )
+ if err != nil {
+ return phaseResult{}, err
+ }
+ return phaseResult{
+ evidence: mpcceremony.PhaseOperationalEvidence{
+ Phase: phase,
+ AcceptedChain: mpcceremony.SignedArtifactRefs{
+ Record: mpcceremony.ArtifactRef{Name: chainName, Digest: mpcceremony.NewDigest(chainBytes)},
+ Signature: mpcceremony.ArtifactRef{Name: chainSignatureName, Digest: mpcceremony.NewDigest(chainSignatureBytes)},
+ },
+ Close: mpcceremony.SignedArtifactRefs{
+ Record: mpcceremony.ArtifactRef{Name: closeName, Digest: mpcceremony.NewDigest(closeBytes)},
+ Signature: mpcceremony.ArtifactRef{Name: closeSignatureName, Digest: mpcceremony.NewDigest(closeSignatureBytes)},
+ },
+ AcceptedHeads: heads,
+ PublicWitnessQuorum: 2,
+ PublicWitnessReceipts: witnessPairs,
+ MultiRelayBeaconEvidence: beaconPair,
+ RawBeaconResponses: rawRefs,
+ },
+ close: mpcceremony.AuthenticatedCloseEvidence{
+ Record: closeRecord,
+ RecordBytes: closeBytes,
+ SignatureBytes: closeSignatureBytes,
+ },
+ }, nil
+}
+
+func loadSigners(
+ definition mpcceremony.CeremonyDefinition,
+ keysDir string,
+) (map[string]signer, error) {
+ result := make(map[string]signer)
+ add := func(identity mpcceremony.Identity, role mpcceremony.EnrollmentRole, index uint16) error {
+ key, publicKey, err := keybundle.LoadExistingPrivateKey(
+ filepath.Join(keysDir, identity.ID+".ed25519.private.hex"),
+ )
+ if err != nil {
+ return err
+ }
+ if identity.Ed25519PublicKeyHex != fmt.Sprintf("%x", publicKey) {
+ return fmt.Errorf("private key does not match identity %q", identity.ID)
+ }
+ result[identity.ID] = signer{identity: identity, key: key, role: role, index: index}
+ return nil
+ }
+ if err := add(definition.Coordinator, mpcceremony.EnrollmentCoordinator, 1); err != nil {
+ return nil, err
+ }
+ if err := add(definition.ReleaseSigner, mpcceremony.EnrollmentReleaseSigner, 1); err != nil {
+ return nil, err
+ }
+ for index, identity := range definition.Auditors {
+ if err := add(identity, mpcceremony.EnrollmentAuditor, uint16(index+1)); err != nil {
+ return nil, err
+ }
+ }
+ for index, participant := range definition.Roster {
+ if err := add(participant.Identity, mpcceremony.EnrollmentParticipant, uint16(index+1)); err != nil {
+ return nil, err
+ }
+ }
+ for index, external := range []struct {
+ id, display string
+ role mpcceremony.EnrollmentRole
+ }{
+ {"witness-01", "Local Rehearsal Public Witness 01", mpcceremony.EnrollmentPublicWitness},
+ {"witness-02", "Local Rehearsal Public Witness 02", mpcceremony.EnrollmentPublicWitness},
+ {"mirror-01", "Local Rehearsal Mirror Operator 01", mpcceremony.EnrollmentMirrorOperator},
+ {"mirror-02", "Local Rehearsal Mirror Operator 02", mpcceremony.EnrollmentMirrorOperator},
+ } {
+ key, publicKey, err := keybundle.LoadExistingPrivateKey(
+ filepath.Join(keysDir, external.id+".ed25519.private.hex"),
+ )
+ if err != nil {
+ return nil, err
+ }
+ identity, err := mpcceremony.NewIdentity(
+ external.id,
+ external.display,
+ external.id+"-key",
+ publicKey,
+ )
+ if err != nil {
+ return nil, err
+ }
+ roleIndex := uint16(index%2 + 1)
+ result[external.id] = signer{
+ identity: identity,
+ key: key,
+ role: external.role,
+ index: roleIndex,
+ }
+ }
+ return result, nil
+}
+
+func loadRelayInputs(directory string) ([]relayInput, error) {
+ directory, err := realDirectory(directory)
+ if err != nil {
+ return nil, err
+ }
+ manifest, err := readRegular(filepath.Join(directory, "relays.tsv"), 64<<10)
+ if err != nil {
+ return nil, err
+ }
+ reader := csv.NewReader(strings.NewReader(string(manifest)))
+ reader.Comma = '\t'
+ reader.FieldsPerRecord = 5
+ reader.ReuseRecord = false
+ rows, err := reader.ReadAll()
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) < 4 || len(rows) > 17 ||
+ !slices.Equal(rows[0], []string{
+ "relay_id",
+ "operator_id",
+ "endpoint_sha256",
+ "retrieved_at",
+ "filename",
+ }) {
+ return nil, errors.New("relays.tsv must have the exact header and 3-16 observations")
+ }
+ safeName := regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*\.json$`)
+ safeID := regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`)
+ result := make([]relayInput, 0, len(rows)-1)
+ for _, row := range rows[1:] {
+ if !safeID.MatchString(row[0]) || strings.Contains(row[0], "..") ||
+ !safeName.MatchString(row[4]) {
+ return nil, fmt.Errorf("unsafe relay identifier or filename %q/%q", row[0], row[4])
+ }
+ if _, err := time.Parse(time.RFC3339, row[3]); err != nil {
+ return nil, fmt.Errorf("relay retrieved_at: %w", err)
+ }
+ raw, err := readRegular(filepath.Join(directory, row[4]), 64<<10)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, relayInput{
+ relayID: row[0],
+ operatorID: row[1],
+ endpointSHA256: row[2],
+ retrievedAt: row[3],
+ filename: row[4],
+ raw: raw,
+ })
+ }
+ slices.SortFunc(result, func(a, b relayInput) int {
+ return strings.Compare(a.relayID, b.relayID)
+ })
+ return result, nil
+}
+
+func twoInteriorTimestamps(lower, upper time.Time) (string, string, error) {
+ if !upper.After(lower) {
+ return "", "", errors.New("authenticated interval is empty")
+ }
+ span := upper.Sub(lower)
+ if span < 3*time.Nanosecond {
+ return "", "", errors.New("authenticated interval has fewer than two distinct interior instants")
+ }
+ first := lower.Add(span / 3)
+ second := lower.Add((2 * span) / 3)
+ if !first.After(lower) || !second.After(first) || !upper.After(second) {
+ return "", "", errors.New("authenticated interval cannot represent strict custody ordering")
+ }
+ return first.Format(time.RFC3339Nano), second.Format(time.RFC3339Nano), nil
+}
+
+func parseTimestamp(label, value string) (time.Time, error) {
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("%s: %w", label, err)
+ }
+ return parsed, nil
+}
+
+func writeSignedPair(
+ stagingRoot,
+ stem string,
+ record any,
+ keyID string,
+ key ed25519.PrivateKey,
+) (mpcceremony.SignedArtifactRefs, error) {
+ recordBytes, signatureBytes, err := mpcceremony.SignRecord(record, keyID, key)
+ if err != nil {
+ return mpcceremony.SignedArtifactRefs{}, err
+ }
+ recordName := stem + ".json"
+ signatureName := stem + ".sig"
+ if err := writeRelative(stagingRoot, recordName, recordBytes); err != nil {
+ return mpcceremony.SignedArtifactRefs{}, err
+ }
+ if err := writeRelative(stagingRoot, signatureName, signatureBytes); err != nil {
+ return mpcceremony.SignedArtifactRefs{}, err
+ }
+ return mpcceremony.SignedArtifactRefs{
+ Record: mpcceremony.ArtifactRef{
+ Name: recordName,
+ Digest: mpcceremony.NewDigest(recordBytes),
+ },
+ Signature: mpcceremony.ArtifactRef{
+ Name: signatureName,
+ Digest: mpcceremony.NewDigest(signatureBytes),
+ },
+ }, nil
+}
+
+func writeRelative(stagingRoot, name string, data []byte) error {
+ if name == "" || strings.Contains(name, `\`) || filepath.IsAbs(name) {
+ return errors.New("unsafe evidence artifact name")
+ }
+ clean := filepath.Clean(filepath.FromSlash(name))
+ if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return errors.New("unsafe evidence artifact traversal")
+ }
+ operationalPrefix := "operational" + string(filepath.Separator)
+ if !strings.HasPrefix(clean, operationalPrefix) ||
+ strings.TrimPrefix(clean, operationalPrefix) == "" {
+ return errors.New("evidence artifact must be beneath operational/")
+ }
+ path := filepath.Join(stagingRoot, strings.TrimPrefix(clean, operationalPrefix))
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ 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 readRegular(path string, maximum int64) ([]byte, error) {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maximum {
+ return nil, fmt.Errorf("unsafe or out-of-bounds regular file: %s", path)
+ }
+ return os.ReadFile(path)
+}
+
+func realDirectory(path string) (string, error) {
+ info, err := os.Lstat(path)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("path is not a real directory")
+ }
+ return filepath.Abs(path)
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/main_test.go b/scripts/mpc-rehearsal-operational-evidence/main_test.go
new file mode 100644
index 00000000..21822b1c
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/main_test.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTwoInteriorTimestampsSupportsSubsecondAuthenticatedGap(t *testing.T) {
+ lower := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
+ upper := lower.Add(100 * time.Millisecond)
+ firstText, secondText, err := twoInteriorTimestamps(lower, upper)
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := time.Parse(time.RFC3339Nano, firstText)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := time.Parse(time.RFC3339Nano, secondText)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !first.After(lower) || !second.After(first) || !upper.After(second) {
+ t.Fatalf("timestamps are not strictly interior: %s, %s", firstText, secondText)
+ }
+}
+
+func TestTwoInteriorTimestampsRejectsUnrepresentableGap(t *testing.T) {
+ lower := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
+ if _, _, err := twoInteriorTimestamps(lower, lower.Add(2*time.Nanosecond)); err == nil {
+ t.Fatal("two-nanosecond interval unexpectedly represented two strict interior instants")
+ }
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/publication.go b/scripts/mpc-rehearsal-operational-evidence/publication.go
new file mode 100644
index 00000000..0ba1e3c3
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/publication.go
@@ -0,0 +1,382 @@
+package main
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const maxCompletedOutputEntries = 100_000
+
+type completedOutputEntry struct {
+ name string
+ mode fs.FileMode
+ size int64
+ sha256 [sha256.Size]byte
+ isDir bool
+}
+
+func createCompletedOutputStaging(destination string) (string, error) {
+ if strings.TrimSpace(destination) == "" {
+ return "", errors.New("completed output destination is required")
+ }
+ parent := filepath.Dir(destination)
+ parentInfo, err := os.Lstat(parent)
+ if err != nil {
+ return "", err
+ }
+ if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("completed output parent must be a real directory")
+ }
+ staging, err := os.MkdirTemp(parent, "."+filepath.Base(destination)+".partial-*")
+ if err != nil {
+ return "", err
+ }
+ if err := os.Chmod(staging, 0o700); err != nil {
+ _ = os.Remove(staging)
+ return "", err
+ }
+ return staging, nil
+}
+
+// createOperationalVerificationRoot builds a private hard-linked view of the
+// immutable phase transcript plus the unpublished operational tree. This lets
+// the full bundle verifier run before the authoritative operational directory
+// exists without copying multi-gigabyte MPC artifacts.
+func createOperationalVerificationRoot(transcriptRoot, operationalStaging string) (string, error) {
+ rootInfo, err := os.Lstat(transcriptRoot)
+ if err != nil {
+ return "", err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("transcript root must be a real directory")
+ }
+ shadow, err := os.MkdirTemp(transcriptRoot, ".operational.verify-*")
+ if err != nil {
+ return "", err
+ }
+ remove := true
+ defer func() {
+ if remove {
+ _ = os.RemoveAll(shadow)
+ }
+ }()
+ if err := os.Chmod(shadow, 0o700); err != nil {
+ return "", err
+ }
+ for _, phase := range []string{"phase1", "phase2"} {
+ if err := hardlinkCompletedOutputTree(
+ filepath.Join(transcriptRoot, phase),
+ filepath.Join(shadow, phase),
+ ); err != nil {
+ return "", fmt.Errorf("materialize %s verification view: %w", phase, err)
+ }
+ }
+ if err := hardlinkCompletedOutputTree(
+ operationalStaging,
+ filepath.Join(shadow, "operational"),
+ ); err != nil {
+ return "", fmt.Errorf("materialize operational verification view: %w", err)
+ }
+ remove = false
+ return shadow, nil
+}
+
+func hardlinkCompletedOutputTree(source, destination string) error {
+ sourceInfo, err := os.Lstat(source)
+ if err != nil {
+ return err
+ }
+ if !sourceInfo.IsDir() || sourceInfo.Mode()&os.ModeSymlink != 0 {
+ return errors.New("verification source must be a real directory")
+ }
+ if err := os.Mkdir(destination, sourceInfo.Mode().Perm()); err != nil {
+ return err
+ }
+ entries := 0
+ return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ entries++
+ if entries > maxCompletedOutputEntries {
+ return fmt.Errorf("verification source exceeds %d entries", maxCompletedOutputEntries)
+ }
+ relative, err := filepath.Rel(source, path)
+ if err != nil {
+ return err
+ }
+ if relative == "." {
+ return nil
+ }
+ target := filepath.Join(destination, relative)
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ switch {
+ case info.Mode()&os.ModeSymlink != 0:
+ return fmt.Errorf("%q is a symlink", path)
+ case info.IsDir():
+ if err := os.Mkdir(target, info.Mode().Perm()); err != nil {
+ return err
+ }
+ case info.Mode().IsRegular():
+ if err := os.Link(path, target); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("%q is not a regular file or directory", path)
+ }
+ return nil
+ })
+}
+
+func verifyThenPublishCompletedDirectory(
+ staging,
+ destination string,
+ verify func() error,
+) error {
+ if verify == nil {
+ return errors.New("completed output verifier is required")
+ }
+ if err := verify(); err != nil {
+ return fmt.Errorf("verify completed staging tree: %w", err)
+ }
+ return publishCompletedDirectory(staging, destination)
+}
+
+// publishCompletedDirectory atomically renames a complete same-parent staging
+// tree without replacing an existing destination. An exact complete
+// destination is accepted so retry can recover from termination after rename
+// but before the caller observed success.
+func publishCompletedDirectory(staging, destination string) error {
+ stagingParent, err := filepath.Abs(filepath.Dir(staging))
+ if err != nil {
+ return fmt.Errorf("resolve completed staging parent: %w", err)
+ }
+ destinationParent, err := filepath.Abs(filepath.Dir(destination))
+ if err != nil {
+ return fmt.Errorf("resolve completed destination parent: %w", err)
+ }
+ if stagingParent != destinationParent {
+ return errors.New("completed staging and destination must have the same parent")
+ }
+ parentInfo, err := os.Lstat(destinationParent)
+ if err != nil {
+ return fmt.Errorf("inspect completed output parent: %w", err)
+ }
+ if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
+ return errors.New("completed output parent must be a real directory")
+ }
+
+ expected, stagingInfo, err := inspectCompletedOutputTree(staging)
+ if err != nil {
+ return fmt.Errorf("inspect completed staging tree: %w", err)
+ }
+ if len(expected) <= 1 {
+ return errors.New("refusing to publish an empty completed output tree")
+ }
+ if err := syncCompletedOutputDirectories(staging); err != nil {
+ return err
+ }
+
+ parent := destinationParent
+ destinationExisted := false
+ if _, err := os.Lstat(destination); err == nil {
+ destinationExisted = true
+ if err := requireExactCompletedOutputTree(destination, expected); err != nil {
+ return err
+ }
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("inspect completed output destination: %w", err)
+ }
+
+ if !destinationExisted {
+ if err := renameCompletedDirectoryNoReplace(staging, destination); err != nil {
+ if exactErr := requireExactCompletedOutputTree(destination, expected); exactErr != nil {
+ return errors.Join(
+ fmt.Errorf("rename completed output without replacement: %w", err),
+ exactErr,
+ )
+ }
+ destinationExisted = true
+ } else {
+ destinationInfo, statErr := os.Lstat(destination)
+ if statErr != nil || !destinationInfo.IsDir() ||
+ destinationInfo.Mode()&os.ModeSymlink != 0 ||
+ !os.SameFile(stagingInfo, destinationInfo) {
+ if statErr == nil {
+ statErr = errors.New("destination does not identify renamed staging directory")
+ }
+ return fmt.Errorf("validate renamed completed output: %w", statErr)
+ }
+ }
+ }
+ if err := requireExactCompletedOutputTree(destination, expected); err != nil {
+ return fmt.Errorf("revalidate completed output destination: %w", err)
+ }
+ if destinationExisted {
+ if currentInfo, err := os.Lstat(staging); err == nil {
+ if !os.SameFile(stagingInfo, currentInfo) {
+ return errors.New("completed staging tree changed before recovery cleanup")
+ }
+ if err := os.RemoveAll(staging); err != nil {
+ return fmt.Errorf("remove exact retry staging tree: %w", err)
+ }
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("inspect exact retry staging tree: %w", err)
+ }
+ }
+ if err := syncCompletedOutputDirectory(parent); err != nil {
+ return err
+ }
+ return nil
+}
+
+func requireExactCompletedOutputTree(path string, expected []completedOutputEntry) error {
+ actual, _, err := inspectCompletedOutputTree(path)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ return fmt.Errorf("completed output conflicts with unsafe destination: %w", errors.Join(fs.ErrExist, err))
+ }
+ if len(actual) != len(expected) {
+ return fmt.Errorf("completed output conflicts with a different tree: %w", fs.ErrExist)
+ }
+ for index := range expected {
+ if actual[index] != expected[index] {
+ return fmt.Errorf("completed output conflicts at %q: %w", expected[index].name, fs.ErrExist)
+ }
+ }
+ return nil
+}
+
+func inspectCompletedOutputTree(root string) ([]completedOutputEntry, os.FileInfo, error) {
+ rootInfo, err := os.Lstat(root)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return nil, nil, errors.New("completed output root is not a real directory")
+ }
+ entries := make([]completedOutputEntry, 0, 32)
+ err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if len(entries) >= maxCompletedOutputEntries {
+ return fmt.Errorf("completed output exceeds %d entries", maxCompletedOutputEntries)
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ item := completedOutputEntry{
+ name: filepath.ToSlash(relative),
+ mode: info.Mode() & (fs.ModePerm | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky),
+ isDir: info.IsDir(),
+ }
+ switch {
+ case info.Mode()&os.ModeSymlink != 0:
+ return fmt.Errorf("%q is a symlink", path)
+ case info.IsDir():
+ case info.Mode().IsRegular():
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ openInfo, err := file.Stat()
+ if err != nil {
+ _ = file.Close()
+ return err
+ }
+ if !openInfo.Mode().IsRegular() || !os.SameFile(info, openInfo) {
+ _ = file.Close()
+ return fmt.Errorf("%q changed while being opened", path)
+ }
+ hasher := sha256.New()
+ size, err := io.Copy(hasher, file)
+ if err != nil {
+ _ = file.Close()
+ return err
+ }
+ finalInfo, statErr := file.Stat()
+ closeErr := file.Close()
+ if statErr != nil {
+ return statErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if !os.SameFile(openInfo, finalInfo) || finalInfo.Size() != size {
+ return fmt.Errorf("%q changed while being hashed", path)
+ }
+ item.size = size
+ copy(item.sha256[:], hasher.Sum(nil))
+ default:
+ return fmt.Errorf("%q is not a regular file or directory", path)
+ }
+ entries = append(entries, item)
+ return nil
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].name < entries[j].name
+ })
+ return entries, rootInfo, nil
+}
+
+func syncCompletedOutputDirectories(root string) error {
+ directories := make([]string, 0, 16)
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("completed output directory %q is a symlink", path)
+ }
+ if entry.IsDir() {
+ directories = append(directories, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ sort.Slice(directories, func(i, j int) bool {
+ return strings.Count(directories[i], string(filepath.Separator)) >
+ strings.Count(directories[j], string(filepath.Separator))
+ })
+ for _, directory := range directories {
+ if err := syncCompletedOutputDirectory(directory); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func syncCompletedOutputDirectory(path string) error {
+ directory, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open completed output directory for sync: %w", err)
+ }
+ defer directory.Close()
+ if err := directory.Sync(); err != nil {
+ return fmt.Errorf("sync completed output directory: %w", err)
+ }
+ return nil
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/publication_test.go b/scripts/mpc-rehearsal-operational-evidence/publication_test.go
new file mode 100644
index 00000000..9d679e46
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/publication_test.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPublishCompletedDirectoryRecoversOnlyExactTree(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ destination := filepath.Join(parent, "operational")
+ first := completedOutputTree(t, destination, []byte("complete"))
+ if err := publishCompletedDirectory(first, destination); err != nil {
+ t.Fatal(err)
+ }
+ retry := completedOutputTree(t, destination, []byte("complete"))
+ if err := publishCompletedDirectory(retry, destination); err != nil {
+ t.Fatalf("exact retry failed: %v", err)
+ }
+ if _, err := os.Lstat(retry); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("exact retry staging remains: %v", err)
+ }
+
+ conflict := completedOutputTree(t, destination, []byte("different"))
+ err := publishCompletedDirectory(conflict, destination)
+ if !errors.Is(err, fs.ErrExist) {
+ t.Fatalf("conflicting retry error = %v, want fs.ErrExist", err)
+ }
+ actual, err := os.ReadFile(filepath.Join(destination, "nested", "artifact.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, []byte("complete")) {
+ t.Fatal("conflicting retry changed authoritative output")
+ }
+}
+
+func TestPublishCompletedDirectoryIgnoresInterruptedStagingTree(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ destination := filepath.Join(parent, "operational")
+ stale := filepath.Join(parent, ".operational.partial-interrupted")
+ if err := os.Mkdir(stale, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(stale, "partial"), []byte("partial"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ staging := completedOutputTree(t, destination, []byte("complete"))
+ if err := publishCompletedDirectory(staging, destination); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(stale); err != nil {
+ t.Fatalf("publication unexpectedly changed prior interrupted staging tree: %v", err)
+ }
+}
+
+func TestOperationalVerificationRootUsesLogicalLayoutWithoutCopying(t *testing.T) {
+ t.Parallel()
+
+ transcript := t.TempDir()
+ for _, phase := range []string{"phase1", "phase2"} {
+ if err := os.Mkdir(filepath.Join(transcript, phase), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(transcript, phase, "artifact.bin"),
+ []byte(phase),
+ 0o600,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ }
+ destination := filepath.Join(transcript, "operational")
+ staging := completedOutputTree(t, destination, []byte("complete"))
+ shadow, err := createOperationalVerificationRoot(transcript, staging)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(shadow) })
+
+ operationalPath := filepath.Join(shadow, "operational", "nested", "artifact.json")
+ actual, err := os.ReadFile(operationalPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(actual, []byte("complete")) {
+ t.Fatal("shadow operational artifact differs")
+ }
+ if _, err := os.Lstat(filepath.Join(shadow, "operational", "operational")); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("shadow view nested operational twice: %v", err)
+ }
+ sourceInfo, err := os.Lstat(filepath.Join(transcript, "phase1", "artifact.bin"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ shadowInfo, err := os.Lstat(filepath.Join(shadow, "phase1", "artifact.bin"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !os.SameFile(sourceInfo, shadowInfo) {
+ t.Fatal("phase artifact was copied instead of hard-linked")
+ }
+}
+
+func TestVerificationFailureLeavesCompletedDestinationAbsent(t *testing.T) {
+ t.Parallel()
+
+ parent := t.TempDir()
+ destination := filepath.Join(parent, "operational")
+ staging := completedOutputTree(t, destination, []byte("complete"))
+ sentinel := errors.New("invalid generated bundle")
+ err := verifyThenPublishCompletedDirectory(staging, destination, func() error {
+ return sentinel
+ })
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("verification error = %v, want sentinel", err)
+ }
+ if _, err := os.Lstat(destination); !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("failed verification published destination: %v", err)
+ }
+ if _, err := os.Lstat(filepath.Join(staging, "nested", "artifact.json")); err != nil {
+ t.Fatalf("failed verification changed completed staging tree: %v", err)
+ }
+}
+
+func completedOutputTree(t *testing.T, destination string, data []byte) string {
+ t.Helper()
+ staging, err := createCompletedOutputStaging(destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nested := filepath.Join(staging, "nested")
+ if err := os.Mkdir(nested, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(nested, "artifact.json")
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.Write(data); err != nil {
+ _ = file.Close()
+ t.Fatal(err)
+ }
+ if err := file.Sync(); err != nil {
+ _ = file.Close()
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return staging
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_linux.go b/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_linux.go
new file mode 100644
index 00000000..2907de83
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_linux.go
@@ -0,0 +1,15 @@
+//go:build linux
+
+package main
+
+import "golang.org/x/sys/unix"
+
+func renameCompletedDirectoryNoReplace(source, destination string) error {
+ return unix.Renameat2(
+ unix.AT_FDCWD,
+ source,
+ unix.AT_FDCWD,
+ destination,
+ unix.RENAME_NOREPLACE,
+ )
+}
diff --git a/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_other.go b/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_other.go
new file mode 100644
index 00000000..a780e16f
--- /dev/null
+++ b/scripts/mpc-rehearsal-operational-evidence/rename_noreplace_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+package main
+
+import "os"
+
+// Production release binaries are Linux-only. This fallback preserves local
+// development portability but does not provide Linux renameat2 semantics.
+func renameCompletedDirectoryNoReplace(source, destination string) error {
+ return os.Rename(source, destination)
+}
diff --git a/scripts/package-mpc-public-evidence.sh b/scripts/package-mpc-public-evidence.sh
new file mode 100755
index 00000000..382b2cdf
--- /dev/null
+++ b/scripts/package-mpc-public-evidence.sh
@@ -0,0 +1,536 @@
+#!/usr/bin/env bash
+# Builds or verifies a fail-closed, content-hashed public MPC evidence tree.
+# Private control keys and files outside the explicit allowlist are never copied.
+set -euo pipefail
+umask 077
+export LC_ALL=C
+
+usage() {
+ cat >&2 <<'EOF'
+usage:
+ package-mpc-public-evidence.sh create REHEARSAL_ROOT FRESH_PACKAGE_DIR
+ package-mpc-public-evidence.sh verify PACKAGE_DIR
+ package-mpc-public-evidence.sh self-test
+
+The package is a directory. SHA256SUMS covers every other regular file in it;
+the SHA-256 of SHA256SUMS is printed separately for external witnessing.
+EOF
+ exit 2
+}
+
+[[ $# -ge 1 ]] || usage
+MODE=$1
+shift
+
+is_allowed_source_path() {
+ local path=$1
+ case "$path" in
+ control/participant-count.txt | control/config/participants.json \
+ | control/config/policy.json \
+ | control/public-finalization-evidence.json)
+ return 0
+ ;;
+ transcript/ceremony.json | transcript/ceremony.sig \
+ | transcript/coordinator-public-key.hex \
+ | transcript/ownership-destination.ccs)
+ return 0
+ ;;
+ candidate/candidate.json | candidate/candidate.sig.json \
+ | candidate/verification-report.json \
+ | candidate/ownership.pk | candidate/ownership.vk \
+ | candidate/ownership-destination.ccs \
+ | candidate/public-finalization-evidence.json \
+ | candidate/cardano-vk.bin | candidate/cardano-vk.hex \
+ | candidate/cardano-vk-format.txt \
+ | candidate/candidate-checksums.sha256 \
+ | candidate/phase2-seal.json | candidate/phase2-seal.sig.json)
+ return 0
+ ;;
+ preliminary-final-keys/ownership-destination.ccs \
+ | preliminary-final-keys/ownership.pk \
+ | preliminary-final-keys/ownership.vk \
+ | preliminary-final-keys/cardano-vk.bin \
+ | preliminary-final-keys/cardano-vk.hex \
+ | preliminary-final-keys/cardano-vk-format.txt \
+ | preliminary-final-keys/preliminary-final-keys.json \
+ | preliminary-final-keys/preliminary-final-keys.sig.json \
+ | preliminary-final-keys/preliminary-checksums.sha256)
+ return 0
+ ;;
+ release/ownership-destination.ccs | release/ownership.pk \
+ | release/ownership.vk | release/cardano-vk.bin \
+ | release/cardano-vk.hex | release/cardano-vk-format.txt \
+ | release/verification-report.json \
+ | release/public-finalization-evidence.json \
+ | release/phase2-seal.json \
+ | release/phase2-seal.sig.json | release/candidate.json \
+ | release/candidate.sig.json | release/candidate-checksums.sha256 \
+ | release/setup-transcript.json | release/manifest.json \
+ | release/manifest.sig | release/manifest-public-key.hex \
+ | release/checksums.sha256)
+ return 0
+ ;;
+ measurements/artifact-sizes.tsv \
+ | measurements/command-resources.tsv)
+ return 0
+ ;;
+ state/binary.sha256 | state/config-generator-mode.txt \
+ | state/config-generator.sha256 | state/created-epoch.txt \
+ | state/beacon-lead-seconds.txt \
+ | state/finalization-evidence-generator-mode.txt \
+ | state/finalization-evidence-generator.sha256 \
+ | state/operational-evidence-generator-mode.txt \
+ | state/operational-evidence-generator.sha256 \
+ | state/phase1-relay-ids.txt | state/phase1-relays.sha256 \
+ | state/phase2-relay-ids.txt | state/phase2-relays.sha256)
+ return 0
+ ;;
+ esac
+
+ if [[ "$path" =~ ^transcript/phase[12]/(genesis\.bin|chain-[0-9]{4}\.(json|sig)|closure/record\.(json|sig))$ ||
+ "$path" =~ ^transcript/phase[12]/contributions/[0-9]{4}/(contribution\.bin|attestation\.(json|sig)|erasure\.(json|sig)|verification\.json)$ ||
+ "$path" =~ ^transcript/phase[12]/beacon/(raw-response\.bin|record\.(json|sig))$ ||
+ "$path" =~ ^transcript/phase1/sealed/(commons\.bin|seal\.(json|sig))$ ||
+ "$path" =~ ^(transcript|release)/operational/evidence-bundle\.(json|sig)$ ||
+ "$path" =~ ^(transcript|release)/operational/(disclosures/[a-z0-9._:-]+\.json|enrollments/[a-z0-9._:-]+\.(json|sig))$ ||
+ "$path" =~ ^(transcript|release)/operational/phase[12]/heads/[0-9]{4}/(outbound-handoff|outbound-receipt|return-handoff|return-receipt)\.(json|sig)$ ||
+ "$path" =~ ^(transcript|release)/operational/phase[12]/heads/[0-9]{4}/mirrors/[a-z0-9._:-]+\.(json|sig)$ ||
+ "$path" =~ ^(transcript|release)/operational/phase[12]/witnesses/[a-z0-9._:-]+\.(json|sig)$ ||
+ "$path" =~ ^(transcript|release)/operational/phase[12]/beacon/(evidence\.(json|sig)|raw/[a-z0-9._:-]+\.json)$ ||
+ "$path" =~ ^release/phase[12]/(genesis\.bin|chain-[0-9]{4}\.(json|sig)|closure/record\.(json|sig))$ ||
+ "$path" =~ ^release/phase[12]/contributions/[0-9]{4}/(contribution\.bin|attestation\.(json|sig)|erasure\.(json|sig)|verification\.json)$ ||
+ "$path" =~ ^audits/auditor-[0-9]{2}\.(json|sig)$ ||
+ "$path" =~ ^release/audits/[0-9]{4}\.(json|sig)$ ||
+ "$path" =~ ^state/(prepare|phase1-contributions|phase1|phase1-beacon|phase2-contributions|phase2|finish)\.(complete|steps\.sha256)$ ||
+ "$path" =~ ^state/phase[12]-(round|round-epoch|closed-epoch|published-at|published-epoch)\.txt$ ||
+ "$path" =~ ^state/steps/[a-z0-9-]+\.(epoch|complete|artifacts\.sha256)$ ]]; then
+ return 0
+ fi
+ return 1
+}
+
+reject_private_name() {
+ local path=$1
+ local lowered=${path,,}
+ case "$lowered" in
+ *private* | *secret* | *wallet* | *mnemonic* | *seed* | *xprv*)
+ echo "FAIL: private-material name is forbidden in a public package: $path" >&2
+ return 1
+ ;;
+ esac
+}
+
+verify_public_text() {
+ local package=$1
+ local file
+ local relative
+ while IFS= read -r -d '' file; do
+ relative=${file#"$package/"}
+ case "$relative" in
+ *.json | *.sig | *.hex | *.txt | *.tsv | *.sha256)
+ ;;
+ *)
+ continue
+ ;;
+ esac
+ if grep -E -i -n \
+ '(Command being timed:|--(participant|coordinator|release)-signing-key|[a-z0-9._-]+\.private\.hex)' \
+ "$file" >/dev/null 2>&1; then
+ echo "FAIL: public package contains a command line or private-key path: $relative" >&2
+ return 1
+ fi
+ if grep -E -i -n \
+ '(^|[[:space:]"'"'"'=:(])/[a-z0-9._~-]|(^|[[:space:]"'"'"'=:(])[A-Za-z]:\\|file://|(^|[[:space:]"'"'"'=:(])\\\\[a-z0-9._~-]' \
+ "$file" >/dev/null 2>&1; then
+ echo "FAIL: public package contains an absolute host path: $relative" >&2
+ return 1
+ fi
+ done < <(find "$package" -type f ! -name SHA256SUMS -print0)
+}
+
+write_public_resource_summary() {
+ local source=$1
+ local output=$2
+ mkdir -p "$(dirname "$output")"
+ node - "$source" "$output" <<'NODE'
+const fs = require("node:fs");
+const crypto = require("node:crypto");
+const path = require("node:path");
+const source = process.argv[2];
+const output = process.argv[3];
+const measurements = path.join(source, "measurements");
+const steps = path.join(source, "state", "steps");
+const suffix = ".time.txt";
+const namePattern = /^([a-z0-9-]+)\.attempt-([0-9]{4})\.time\.txt$/;
+const fields = [
+ ["user_seconds", "User time (seconds)", /^[0-9]+(?:\.[0-9]+)?$/],
+ ["system_seconds", "System time (seconds)", /^[0-9]+(?:\.[0-9]+)?$/],
+ ["elapsed_wall", "Elapsed (wall clock) time (h:mm:ss or m:ss)", /^(?:[0-9]+:){1,2}[0-9]+(?:\.[0-9]+)?$/],
+ ["max_rss_kib", "Maximum resident set size (kbytes)", /^[0-9]+$/],
+ ["filesystem_inputs", "File system inputs", /^[0-9]+$/],
+ ["filesystem_outputs", "File system outputs", /^[0-9]+$/],
+ ["exit_status", "Exit status", /^[0-9]+$/],
+];
+const names = [];
+if (fs.existsSync(steps)) {
+ for (const markerName of fs.readdirSync(steps).filter((name) => name.endsWith(".complete")).sort()) {
+ const label = markerName.slice(0, -".complete".length);
+ if (!/^[a-z0-9-]+$/.test(label)) throw new Error(`unexpected step marker: ${markerName}`);
+ const marker = fs.readFileSync(path.join(steps, markerName), "utf8").trimEnd();
+ const match = marker.match(/^([0-9a-f]{64}) measurements\/([a-z0-9-]+\.attempt-[0-9]{4})\.output\.json$/);
+ if (!match || !match[2].startsWith(`${label}.attempt-`)) {
+ throw new Error(`malformed completed-step marker: ${markerName}`);
+ }
+ const resultPath = path.join(measurements, `${match[2]}.output.json`);
+ const actualResultHash = crypto
+ .createHash("sha256")
+ .update(fs.readFileSync(resultPath))
+ .digest("hex");
+ if (actualResultHash !== match[1]) {
+ throw new Error(`completed-step result digest mismatch: ${markerName}`);
+ }
+ names.push(`${match[2]}${suffix}`);
+ }
+}
+const rows = [];
+for (const name of names) {
+ const match = name.match(namePattern);
+ if (!match) {
+ throw new Error(`unexpected timing filename: ${name}`);
+ }
+ const values = new Map();
+ for (const line of fs.readFileSync(path.join(measurements, name), "utf8").split(/\r?\n/)) {
+ for (const [, label] of fields) {
+ const prefix = `\t${label}: `;
+ if (line.startsWith(prefix)) {
+ if (values.has(label)) throw new Error(`duplicate ${label} in ${name}`);
+ values.set(label, line.slice(prefix.length));
+ }
+ }
+ }
+ const row = [match[1], match[2]];
+ for (const [, label, pattern] of fields) {
+ const value = values.get(label);
+ if (typeof value !== "string" || !pattern.test(value)) {
+ throw new Error(`missing or malformed ${label} in ${name}`);
+ }
+ row.push(value);
+ }
+ rows.push(row.join("\t"));
+}
+const header = ["label", "attempt", ...fields.map(([key]) => key)].join("\t");
+fs.writeFileSync(output, `${header}\n${rows.length ? `${rows.join("\n")}\n` : ""}`, {
+ encoding: "utf8",
+ mode: 0o400,
+ flag: "wx",
+});
+NODE
+ chmod 0444 "$output"
+}
+
+verify_public_resource_summary() {
+ local summary=$1
+ node - "$summary" <<'NODE'
+const fs = require("node:fs");
+const path = process.argv[2];
+const lines = fs.readFileSync(path, "utf8").split("\n");
+if (lines.pop() !== "") throw new Error("resource summary lacks a final newline");
+const expectedHeader =
+ "label\tattempt\tuser_seconds\tsystem_seconds\telapsed_wall\tmax_rss_kib\tfilesystem_inputs\tfilesystem_outputs\texit_status";
+if (lines.shift() !== expectedHeader) throw new Error("resource summary header is invalid");
+const rowPattern =
+ /^[a-z0-9-]+\t[0-9]{4}\t[0-9]+(?:\.[0-9]+)?\t[0-9]+(?:\.[0-9]+)?\t(?:[0-9]+:){1,2}[0-9]+(?:\.[0-9]+)?\t[0-9]+\t[0-9]+\t[0-9]+\t[0-9]+$/;
+let previous = "";
+for (const row of lines) {
+ if (!rowPattern.test(row) || (previous && row <= previous)) {
+ throw new Error("resource summary row is malformed, duplicated, or unsorted");
+ }
+ previous = row;
+}
+NODE
+}
+
+verify_package() {
+ local package=$1
+ if [[ ! -d "$package" || -L "$package" ]]; then
+ echo "FAIL: package must be an existing real directory: $package" >&2
+ return 1
+ fi
+ package=$(cd "$package" && pwd)
+ local unsafe
+ unsafe=$(find "$package" ! -type d ! -type f -print -quit)
+ if [[ -n "$unsafe" ]]; then
+ echo "FAIL: package contains a symlink or special file: $unsafe" >&2
+ return 1
+ fi
+ if [[ ! -f "$package/SHA256SUMS" || -L "$package/SHA256SUMS" ]]; then
+ echo "FAIL: package SHA256SUMS is absent or unsafe" >&2
+ return 1
+ fi
+ if [[ ! -f "$package/PUBLIC-PACKAGE-FORMAT.txt" ||
+ -L "$package/PUBLIC-PACKAGE-FORMAT.txt" ]]; then
+ echo "FAIL: public package format marker is absent, unsafe, or unsupported" >&2
+ return 1
+ fi
+ local format_value
+ format_value=$(tr '\n' ' ' <"$package/PUBLIC-PACKAGE-FORMAT.txt")
+ if [[ "$format_value" != "proof-tools MPC public evidence package format 2 SHA256SUMS covers every other regular file in this directory. " ]]; then
+ echo "FAIL: public package format marker is absent, unsafe, or unsupported" >&2
+ return 1
+ fi
+ if [[ ! -f "$package/measurements/command-resources.tsv" ||
+ -L "$package/measurements/command-resources.tsv" ]]; then
+ echo "FAIL: numeric command resource summary is absent or unsafe" >&2
+ return 1
+ fi
+ if ! verify_public_resource_summary \
+ "$package/measurements/command-resources.tsv"; then
+ echo "FAIL: numeric command resource summary is malformed" >&2
+ return 1
+ fi
+
+ local listed
+ local digest
+ local path
+ local previous=
+ local count=0
+ while IFS=$'\t' read -r digest path; do
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ||
+ -z "$path" || "$path" == /* || "$path" == *'..'* ||
+ "$path" == *$'\t'* || "$path" == *$'\n'* ]]; then
+ echo "FAIL: malformed or unsafe SHA256SUMS entry" >&2
+ return 1
+ fi
+ if [[ "$path" != PUBLIC-PACKAGE-FORMAT.txt ]] &&
+ ! is_allowed_source_path "$path"; then
+ echo "FAIL: package path is outside the explicit public allowlist: $path" >&2
+ return 1
+ fi
+ if [[ -n "$previous" && "$path" < "$previous" ]]; then
+ echo "FAIL: SHA256SUMS is not bytewise path-sorted" >&2
+ return 1
+ fi
+ if [[ "$path" == "$previous" ]]; then
+ echo "FAIL: duplicate SHA256SUMS path: $path" >&2
+ return 1
+ fi
+ reject_private_name "$path"
+ if [[ ! -f "$package/$path" || -L "$package/$path" ]]; then
+ echo "FAIL: listed package file is absent or unsafe: $path" >&2
+ return 1
+ fi
+ listed=$(sha256sum "$package/$path" | cut -d ' ' -f 1)
+ if [[ "$listed" != "$digest" ]]; then
+ echo "FAIL: package digest mismatch: $path" >&2
+ return 1
+ fi
+ previous=$path
+ ((count += 1))
+ done <"$package/SHA256SUMS"
+
+ local actual_list
+ actual_list=$(mktemp)
+ local manifest_list
+ manifest_list=$(mktemp)
+ find "$package" -type f ! -name SHA256SUMS -printf '%P\n' |
+ LC_ALL=C sort >"$actual_list"
+ cut -f 2 "$package/SHA256SUMS" >"$manifest_list"
+ if ! cmp -s "$actual_list" "$manifest_list"; then
+ rm -f -- "$actual_list" "$manifest_list"
+ echo "FAIL: SHA256SUMS does not cover the exact package file set" >&2
+ return 1
+ fi
+ rm -f -- "$actual_list" "$manifest_list"
+ if (( count == 0 )); then
+ echo "FAIL: public evidence package is empty" >&2
+ return 1
+ fi
+ if grep -I -R -E -i -n \
+ --exclude=SHA256SUMS \
+ '(BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY|master xprv|mnemonic phrase|seed phrase)' \
+ "$package" >/dev/null; then
+ echo "FAIL: public package contains a private-material marker" >&2
+ return 1
+ fi
+ verify_public_text "$package"
+ local package_file
+ while IFS= read -r -d '' package_file; do
+ local package_relative=${package_file#"$package/"}
+ if [[ "$package_relative" != release/manifest.sig ]] &&
+ grep -E -x '[0-9a-f]{128}' "$package_file" >/dev/null 2>&1; then
+ echo "FAIL: public package contains an Ed25519 private-key-shaped value: $package_relative" >&2
+ return 1
+ fi
+ done < <(find "$package" -type f ! -name SHA256SUMS -print0)
+ echo "OK: public evidence package verified"
+ echo "package=$package files=$count manifest_sha256=$(sha256sum "$package/SHA256SUMS" | cut -d ' ' -f 1)"
+}
+
+case "$MODE" in
+ create)
+ [[ $# -eq 2 ]] || usage
+ SOURCE=$1
+ DESTINATION=$2
+ if [[ ! -d "$SOURCE" || -L "$SOURCE" ]]; then
+ echo "FAIL: rehearsal root must be an existing real directory: $SOURCE" >&2
+ exit 1
+ fi
+ if [[ -e "$DESTINATION" || -L "$DESTINATION" ]]; then
+ echo "FAIL: destination must be fresh: $DESTINATION" >&2
+ exit 1
+ fi
+ DESTINATION_PARENT=$(dirname "$DESTINATION")
+ if [[ ! -d "$DESTINATION_PARENT" || -L "$DESTINATION_PARENT" ]]; then
+ echo "FAIL: destination parent must be an existing real directory" >&2
+ exit 1
+ fi
+ SOURCE=$(cd "$SOURCE" && pwd)
+ DESTINATION_PARENT=$(cd "$DESTINATION_PARENT" && pwd)
+ DESTINATION="$DESTINATION_PARENT/$(basename "$DESTINATION")"
+ if [[ "$DESTINATION_PARENT" == "$SOURCE" || "$DESTINATION_PARENT" == "$SOURCE/"* ]]; then
+ echo "FAIL: public package destination must be outside the rehearsal root" >&2
+ exit 1
+ fi
+ unsafe_entry=$(find "$SOURCE" ! -type d ! -type f -print -quit)
+ if [[ -n "$unsafe_entry" ]]; then
+ echo "FAIL: source tree contains a symlink or special file: $unsafe_entry" >&2
+ exit 1
+ fi
+ for required in \
+ transcript/ceremony.json \
+ transcript/ceremony.sig \
+ transcript/coordinator-public-key.hex \
+ transcript/ownership-destination.ccs; do
+ if [[ ! -f "$SOURCE/$required" ]]; then
+ echo "FAIL: required public ceremony artifact is absent: $required" >&2
+ exit 1
+ fi
+ done
+
+ PACKAGE_TMP=$(mktemp -d "$DESTINATION_PARENT/.mpc-public-evidence.XXXXXXXX")
+ cleanup() {
+ if [[ -n "${PACKAGE_TMP:-}" && -d "$PACKAGE_TMP" ]]; then
+ rm -rf -- "$PACKAGE_TMP"
+ fi
+ }
+ trap cleanup EXIT
+ COPIED=0
+ while IFS= read -r -d '' source_path; do
+ relative=${source_path#"$SOURCE/"}
+ if ! is_allowed_source_path "$relative"; then
+ continue
+ fi
+ reject_private_name "$relative"
+ if [[ ! -f "$source_path" || -L "$source_path" ]]; then
+ echo "FAIL: selected source is not a safe regular file: $relative" >&2
+ exit 1
+ fi
+ install -D -m 0444 "$source_path" "$PACKAGE_TMP/$relative"
+ ((COPIED += 1))
+ done < <(find "$SOURCE" -type f -print0 | LC_ALL=C sort -z)
+ write_public_resource_summary \
+ "$SOURCE" \
+ "$PACKAGE_TMP/measurements/command-resources.tsv"
+ ((COPIED += 1))
+ if (( COPIED == 0 )); then
+ echo "FAIL: allowlist selected no public evidence" >&2
+ exit 1
+ fi
+ printf '%s\n' \
+ 'proof-tools MPC public evidence package format 2' \
+ 'SHA256SUMS covers every other regular file in this directory.' \
+ >"$PACKAGE_TMP/PUBLIC-PACKAGE-FORMAT.txt"
+ chmod 0444 "$PACKAGE_TMP/PUBLIC-PACKAGE-FORMAT.txt"
+ (
+ cd "$PACKAGE_TMP"
+ find . -type f ! -name SHA256SUMS -printf '%P\n' |
+ LC_ALL=C sort |
+ while IFS= read -r path; do
+ printf '%s\t%s\n' "$(sha256sum "$path" | cut -d ' ' -f 1)" "$path"
+ done >SHA256SUMS
+ chmod 0444 SHA256SUMS
+ sync -f SHA256SUMS
+ sync -f .
+ )
+ verify_package "$PACKAGE_TMP" >/dev/null
+ if ! mv -Tn "$PACKAGE_TMP" "$DESTINATION" || [[ -d "$PACKAGE_TMP" ]]; then
+ echo "FAIL: package publication collided with an existing path" >&2
+ exit 1
+ fi
+ PACKAGE_TMP=
+ sync -f "$DESTINATION_PARENT"
+ verify_package "$DESTINATION"
+ ;;
+ verify)
+ [[ $# -eq 1 ]] || usage
+ verify_package "$1"
+ ;;
+ self-test)
+ [[ $# -eq 0 ]] || usage
+ SELF_TEST_ROOT=$(mktemp -d)
+ cleanup_self_test() {
+ rm -rf -- "$SELF_TEST_ROOT"
+ }
+ trap cleanup_self_test EXIT
+ SOURCE="$SELF_TEST_ROOT/source"
+ PACKAGE="$SELF_TEST_ROOT/package"
+ mkdir -p "$SOURCE/transcript" "$SOURCE/measurements" "$SOURCE/state/steps"
+ printf '{}\n' >"$SOURCE/transcript/ceremony.json"
+ printf 'signature\n' >"$SOURCE/transcript/ceremony.sig"
+ printf '00\n' >"$SOURCE/transcript/coordinator-public-key.hex"
+ printf 'r1cs\n' >"$SOURCE/transcript/ownership-destination.ccs"
+ cat >"$SOURCE/measurements/demo.attempt-0001.time.txt" <<'EOF'
+ Command being timed: "/host/mpc phase1 contribute --participant-signing-key /home/operator/participant.private.hex"
+ User time (seconds): 1.25
+ System time (seconds): 0.50
+ Elapsed (wall clock) time (h:mm:ss or m:ss): 0:02.00
+ Maximum resident set size (kbytes): 12345
+ File system inputs: 4
+ File system outputs: 8
+ Exit status: 0
+EOF
+ printf '{"outputs":{"candidate":"/home/operator/rehearsal/candidate"}}\n' \
+ >"$SOURCE/measurements/demo.attempt-0001.output.json"
+ SELF_RESULT_HASH=$(
+ sha256sum "$SOURCE/measurements/demo.attempt-0001.output.json" |
+ cut -d ' ' -f 1
+ )
+ printf '%s measurements/demo.attempt-0001.output.json\n' "$SELF_RESULT_HASH" \
+ >"$SOURCE/state/steps/demo.complete"
+ "$0" create "$SOURCE" "$PACKAGE" >/dev/null
+ "$0" verify "$PACKAGE" >/dev/null
+ if find "$PACKAGE" -type f \
+ \( -name '*.time.txt' -o -name '*.output.json' \) -print -quit |
+ grep -q .; then
+ echo "FAIL: self-test packaged a raw command measurement" >&2
+ exit 1
+ fi
+ if grep -R -E \
+ '(Command being timed:|participant-signing-key|private\.hex|/home/operator)' \
+ "$PACKAGE" >/dev/null; then
+ echo "FAIL: self-test package leaked private host context" >&2
+ exit 1
+ fi
+ grep -F $'demo\t0001\t1.25\t0.50\t0:02.00\t12345\t4\t8\t0' \
+ "$PACKAGE/measurements/command-resources.tsv" >/dev/null
+ chmod 0644 "$PACKAGE/transcript/ceremony.json" "$PACKAGE/SHA256SUMS"
+ printf '{"path":"/home/operator/rehearsal"}\n' \
+ >"$PACKAGE/transcript/ceremony.json"
+ (
+ cd "$PACKAGE"
+ find . -type f ! -name SHA256SUMS -printf '%P\n' |
+ LC_ALL=C sort |
+ while IFS= read -r path; do
+ printf '%s\t%s\n' "$(sha256sum "$path" | cut -d ' ' -f 1)" "$path"
+ done >SHA256SUMS
+ )
+ if "$0" verify "$PACKAGE" >/dev/null 2>&1; then
+ echo "FAIL: self-test verifier accepted an absolute host path" >&2
+ exit 1
+ fi
+ echo "OK: public evidence package self-test passed"
+ ;;
+ *)
+ usage
+ ;;
+esac
diff --git a/scripts/rename-directory-noreplace/main.go b/scripts/rename-directory-noreplace/main.go
new file mode 100644
index 00000000..ca9d5970
--- /dev/null
+++ b/scripts/rename-directory-noreplace/main.go
@@ -0,0 +1,57 @@
+// Command rename-directory-noreplace atomically publishes one Linux directory
+// without ever replacing a concurrently created destination.
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+func main() {
+ if len(os.Args) != 3 {
+ fatal(errors.New("usage: rename-directory-noreplace SOURCE DESTINATION"))
+ }
+ source, destination := os.Args[1], os.Args[2]
+ info, err := os.Lstat(source)
+ if err != nil {
+ fatal(err)
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ fatal(errors.New("source must be a real directory"))
+ }
+ if _, err := os.Lstat(destination); err == nil {
+ fatal(fmt.Errorf("destination already exists: %w", fs.ErrExist))
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ fatal(err)
+ }
+ if err := unix.Renameat2(
+ unix.AT_FDCWD,
+ source,
+ unix.AT_FDCWD,
+ destination,
+ unix.RENAME_NOREPLACE,
+ ); err != nil {
+ fatal(err)
+ }
+ parent, err := os.OpenFile(filepath.Dir(destination), os.O_RDONLY|unix.O_DIRECTORY, 0)
+ if err != nil {
+ fatal(err)
+ }
+ if err := parent.Sync(); err != nil {
+ _ = parent.Close()
+ fatal(err)
+ }
+ if err := parent.Close(); err != nil {
+ fatal(err)
+ }
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
diff --git a/scripts/run-mpc-k21-local-rehearsal.sh b/scripts/run-mpc-k21-local-rehearsal.sh
new file mode 100755
index 00000000..21509e00
--- /dev/null
+++ b/scripts/run-mpc-k21-local-rehearsal.sh
@@ -0,0 +1,2191 @@
+#!/usr/bin/env bash
+# Runs a staged, exact ownership-destination-v2 K=21 ceremony through the
+# participant-facing CLI using same-host rehearsal identities.
+#
+# This is resource/coherence evidence, not participant-independence evidence.
+# It never fetches a beacon. The operator must close each phase on a future
+# round, publicly witness the closure, wait for that round, obtain the exact
+# raw response independently, and resume with the next stage.
+set -euo pipefail
+umask 077
+export LC_ALL=C
+
+usage() {
+ cat >&2 <<'EOF'
+usage:
+ run-mpc-k21-local-rehearsal.sh prepare FRESH_ROOT MPC_BINARY [PARTICIPANTS]
+ run-mpc-k21-local-rehearsal.sh phase1-contribute ROOT MPC_BINARY
+ run-mpc-k21-local-rehearsal.sh phase1-close ROOT MPC_BINARY FUTURE_QUICKNET_ROUND
+ run-mpc-k21-local-rehearsal.sh phase1-beacon ROOT MPC_BINARY RAW_RESPONSE PUBLISHED_AT_UTC
+ run-mpc-k21-local-rehearsal.sh phase2-contribute ROOT MPC_BINARY
+ run-mpc-k21-local-rehearsal.sh phase2-close ROOT MPC_BINARY FUTURE_QUICKNET_ROUND
+ run-mpc-k21-local-rehearsal.sh finish ROOT MPC_BINARY RAW_RESPONSE PUBLISHED_AT_UTC PHASE1_RELAY_DIR PHASE2_RELAY_DIR
+ run-mpc-k21-local-rehearsal.sh inspect ROOT MPC_BINARY
+ run-mpc-k21-local-rehearsal.sh self-test-state
+ run-mpc-k21-local-rehearsal.sh self-test-close-recovery
+
+This local harness requires 3-20 identities and a qualified work volume. It
+does not create production enrollment, fetch network data, or prove that
+same-host identities are independent participants or auditors.
+EOF
+ exit 2
+}
+
+[[ $# -ge 1 ]] || usage
+STAGE=$1
+shift
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
+QUICKNET_GENESIS=1692803367
+QUICKNET_PERIOD=3
+DEFAULT_REHEARSAL_BEACON_LEAD_SECONDS=300
+HARD_MIN_REHEARSAL_BEACON_LEAD_SECONDS=60
+
+timestamp() {
+ date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ
+}
+
+max_epoch() {
+ local left=$1
+ local right=$2
+ if (( left > right )); then
+ echo "$left"
+ else
+ echo "$right"
+ fi
+}
+
+round_epoch() {
+ local round=$1
+ if [[ ! "$round" =~ ^[1-9][0-9]*$ ]]; then
+ echo "FAIL: Quicknet round must be a positive integer" >&2
+ exit 1
+ fi
+ echo $((QUICKNET_GENESIS + (round - 1) * QUICKNET_PERIOD))
+}
+
+closure_epoch() {
+ local path=$1
+ local expected_round=$2
+ local expected_round_epoch=$3
+ local minimum_lead=$4
+ node - "$path" "$expected_round" "$expected_round_epoch" "$minimum_lead" <<'NODE'
+const fs = require("node:fs");
+const path = process.argv[2];
+const expectedRound = Number(process.argv[3]);
+const expectedRoundEpoch = Number(process.argv[4]);
+const minimumLead = Number(process.argv[5]);
+let close;
+try {
+ close = JSON.parse(fs.readFileSync(path, "utf8"));
+} catch (error) {
+ process.stderr.write(`FAIL: cannot parse published closure: ${error.message}\n`);
+ process.exit(1);
+}
+const closedMillis = Date.parse(close.closed_at);
+const notBeforeMillis = Date.parse(close.beacon_not_before);
+if (!Number.isSafeInteger(expectedRound) ||
+ !Number.isSafeInteger(expectedRoundEpoch) ||
+ !Number.isSafeInteger(minimumLead) ||
+ close.beacon_round !== expectedRound ||
+ !Number.isFinite(closedMillis) ||
+ notBeforeMillis !== expectedRoundEpoch * 1000 ||
+ expectedRoundEpoch * 1000 - closedMillis < (minimumLead + 2) * 1000) {
+ process.stderr.write("FAIL: published closure does not bind the requested future round and lead\n");
+ process.exit(1);
+}
+process.stdout.write(`${Math.floor(closedMillis / 1000)}\n`);
+NODE
+}
+
+finalization_evidence_source_hash() {
+ (
+ cd "$REPO_ROOT/scripts/mpc-finalization-evidence"
+ find . -maxdepth 1 -type f -print0 |
+ LC_ALL=C sort -z |
+ xargs -0 sha256sum |
+ sha256sum |
+ cut -d ' ' -f 1
+ )
+}
+
+default_finalization_evidence_binary() {
+ local candidate
+ candidate="$(dirname "$MPC_BIN")/mpc-finalization-evidence"
+ if [[ -f "$candidate" && ! -L "$candidate" && -x "$candidate" ]]; then
+ printf '%s\n' "$candidate"
+ fi
+}
+
+record_finalization_evidence_generator() {
+ local generator=${MPC_FINALIZATION_EVIDENCE_BIN:-}
+ if [[ -z "$generator" ]]; then
+ generator=$(default_finalization_evidence_binary)
+ fi
+ if [[ -n "$generator" ]]; then
+ if [[ ! -f "$generator" ||
+ -L "$generator" ||
+ ! -x "$generator" ]]; then
+ echo "FAIL: MPC_FINALIZATION_EVIDENCE_BIN must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ write_state "$STATE_DIR/finalization-evidence-generator-mode.txt" "prebuilt-binary"
+ write_state \
+ "$STATE_DIR/finalization-evidence-generator.sha256" \
+ "$(sha256sum "$generator" | cut -d ' ' -f 1)"
+ else
+ write_state "$STATE_DIR/finalization-evidence-generator-mode.txt" "go-run-source"
+ write_state \
+ "$STATE_DIR/finalization-evidence-generator.sha256" \
+ "$(finalization_evidence_source_hash)"
+ fi
+}
+
+resolve_finalization_evidence_generator() {
+ local mode
+ local expected_hash
+ local actual_hash
+ local generator
+ for state_path in \
+ "$STATE_DIR/finalization-evidence-generator-mode.txt" \
+ "$STATE_DIR/finalization-evidence-generator.sha256"; do
+ if [[ ! -f "$state_path" || -L "$state_path" ]]; then
+ echo "FAIL: finalization evidence generator binding is absent or unsafe" >&2
+ exit 1
+ fi
+ done
+ mode=$(tr -d '\n' <"$STATE_DIR/finalization-evidence-generator-mode.txt")
+ expected_hash=$(tr -d '\n' <"$STATE_DIR/finalization-evidence-generator.sha256")
+ case "$mode" in
+ prebuilt-binary)
+ generator=${MPC_FINALIZATION_EVIDENCE_BIN:-}
+ if [[ -z "$generator" ]]; then
+ generator=$(default_finalization_evidence_binary)
+ fi
+ if [[ -z "$generator" ||
+ ! -f "$generator" ||
+ -L "$generator" ||
+ ! -x "$generator" ]]; then
+ echo "FAIL: prepared rehearsal requires the bound MPC_FINALIZATION_EVIDENCE_BIN" >&2
+ exit 1
+ fi
+ actual_hash=$(sha256sum "$generator" | cut -d ' ' -f 1)
+ if [[ "$actual_hash" != "$expected_hash" ]]; then
+ echo "FAIL: finalization evidence generator changed after prepare" >&2
+ exit 1
+ fi
+ FINALIZATION_EVIDENCE_COMMAND=("$generator")
+ FINALIZATION_GOCACHE=
+ ;;
+ go-run-source)
+ if [[ -n "${MPC_FINALIZATION_EVIDENCE_BIN:-}" ]]; then
+ echo "FAIL: rehearsal prepared for source generator but a prebuilt helper was supplied" >&2
+ exit 1
+ fi
+ actual_hash=$(finalization_evidence_source_hash)
+ if [[ "$actual_hash" != "$expected_hash" ]]; then
+ echo "FAIL: finalization evidence generator source changed after prepare" >&2
+ exit 1
+ fi
+ FINALIZATION_GOCACHE="$ROOT/.mpc-finalization-evidence-go-cache"
+ FINALIZATION_EVIDENCE_COMMAND=(
+ env
+ "GOCACHE=$FINALIZATION_GOCACHE"
+ GOWORK=off
+ GOFLAGS=-mod=vendor
+ go run "$REPO_ROOT/scripts/mpc-finalization-evidence"
+ )
+ ;;
+ *)
+ echo "FAIL: unknown finalization evidence generator mode: $mode" >&2
+ exit 1
+ ;;
+ esac
+}
+
+record_operational_evidence_generator() {
+ local tools_dir="$CONTROL/tools"
+ local generator="$tools_dir/mpc-rehearsal-operational-evidence"
+ if [[ -e "$tools_dir" || -L "$tools_dir" ]]; then
+ if [[ ! -d "$tools_dir" || -L "$tools_dir" ]]; then
+ echo "FAIL: rehearsal helper directory is unsafe" >&2
+ exit 1
+ fi
+ else
+ mkdir -m 0700 "$tools_dir"
+ fi
+ if [[ -e "$generator" || -L "$generator" ]]; then
+ echo "FAIL: rehearsal operational-evidence helper path must be fresh" >&2
+ exit 1
+ fi
+ if [[ -n "${MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN:-}" ]]; then
+ if [[ ! -f "$MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN" ||
+ -L "$MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN" ||
+ ! -x "$MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN" ]]; then
+ echo "FAIL: MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ cp -- "$MPC_REHEARSAL_OPERATIONAL_EVIDENCE_BIN" "$generator"
+ write_state "$STATE_DIR/operational-evidence-generator-mode.txt" "provided-binary-copy"
+ else
+ local cache="$ROOT/.mpc-rehearsal-operational-evidence-go-cache"
+ (
+ cd "$REPO_ROOT"
+ env \
+ GOCACHE="$cache" \
+ GOWORK=off \
+ GOFLAGS=-mod=vendor \
+ go build -buildvcs=false -trimpath \
+ -o "$generator" \
+ ./scripts/mpc-rehearsal-operational-evidence
+ )
+ rm -rf -- "$cache"
+ write_state "$STATE_DIR/operational-evidence-generator-mode.txt" "prepare-built-binary"
+ fi
+ chmod 0500 "$generator"
+ sync -f "$generator"
+ write_state \
+ "$STATE_DIR/operational-evidence-generator.sha256" \
+ "$(sha256sum "$generator" | cut -d ' ' -f 1)"
+}
+
+resolve_operational_evidence_generator() {
+ local generator="$CONTROL/tools/mpc-rehearsal-operational-evidence"
+ local expected_hash
+ local actual_hash
+ for state_path in \
+ "$STATE_DIR/operational-evidence-generator-mode.txt" \
+ "$STATE_DIR/operational-evidence-generator.sha256"; do
+ if [[ ! -f "$state_path" || -L "$state_path" ]]; then
+ echo "FAIL: operational evidence generator binding is absent or unsafe" >&2
+ exit 1
+ fi
+ done
+ if [[ ! -f "$generator" || -L "$generator" || ! -x "$generator" ]]; then
+ echo "FAIL: bound operational evidence generator is absent or unsafe" >&2
+ exit 1
+ fi
+ expected_hash=$(tr -d '\n' <"$STATE_DIR/operational-evidence-generator.sha256")
+ actual_hash=$(sha256sum "$generator" | cut -d ' ' -f 1)
+ if [[ ! "$expected_hash" =~ ^[0-9a-f]{64}$ || "$actual_hash" != "$expected_hash" ]]; then
+ echo "FAIL: operational evidence generator changed after prepare" >&2
+ exit 1
+ fi
+ OPERATIONAL_EVIDENCE_COMMAND=("$generator")
+}
+
+write_state() {
+ local path=$1
+ local value=$2
+ if [[ -e "$path" || -L "$path" ]]; then
+ if [[ -L "$path" || ! -f "$path" ]]; then
+ echo "FAIL: state path is not a regular file: $path" >&2
+ exit 1
+ fi
+ local existing
+ existing=$(tr -d '\n' <"$path")
+ if [[ "$existing" != "$value" ]]; then
+ echo "FAIL: existing state differs from exact retry value: $path" >&2
+ exit 1
+ fi
+ return
+ fi
+ printf '%s\n' "$value" >"$path"
+ sync -f "$path"
+}
+
+step_epoch() {
+ local label=$1
+ local minimum=$2
+ local path="$STATE_DIR/steps/$label.epoch"
+ if [[ -f "$path" && ! -L "$path" ]]; then
+ local existing
+ existing=$(tr -d '\n' <"$path")
+ if [[ ! "$existing" =~ ^[0-9]+$ || "$existing" -lt "$minimum" ]]; then
+ echo "FAIL: invalid persisted timestamp for $label" >&2
+ exit 1
+ fi
+ echo "$existing"
+ return
+ fi
+ local selected
+ selected=$(max_epoch "$(date +%s)" "$minimum")
+ write_state "$path" "$selected"
+ echo "$selected"
+}
+
+require_marker() {
+ local name=$1
+ if [[ ! -f "$STATE_DIR/$name.complete" || -L "$STATE_DIR/$name.complete" ]]; then
+ echo "FAIL: required completed stage is missing: $name" >&2
+ exit 1
+ fi
+ verify_stage_manifest "$name"
+}
+
+complete_stage() {
+ local name=$1
+ write_state "$STATE_DIR/$name.complete" "$(timestamp "$(date +%s)")"
+ write_stage_manifest "$name"
+ sync -f "$STATE_DIR"
+}
+
+load_common() {
+ if [[ $# -lt 2 || $# -gt 3 ]]; then
+ usage
+ fi
+ ROOT=$1
+ MPC_BIN=$2
+ LOAD_MODE=${3:-mutable}
+ if [[ ! -d "$ROOT" || -L "$ROOT" ]]; then
+ echo "FAIL: rehearsal root must be an existing real directory: $ROOT" >&2
+ exit 1
+ fi
+ if [[ ! -f "$MPC_BIN" || -L "$MPC_BIN" || ! -x "$MPC_BIN" ]]; then
+ echo "FAIL: MPC binary must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ ROOT=$(cd "$ROOT" && pwd)
+ MPC_BIN=$(cd "$(dirname "$MPC_BIN")" && pwd)/$(basename "$MPC_BIN")
+ CONTROL="$ROOT/control"
+ CONFIG="$CONTROL/config"
+ KEYS="$CONTROL/keys"
+ TRANSCRIPT="$ROOT/transcript"
+ CANDIDATES="$ROOT/candidates"
+ MEASUREMENTS="$ROOT/measurements"
+ STATE_DIR="$ROOT/state"
+ AUDITS="$ROOT/audits"
+ PRELIMINARY_KEYS="$ROOT/preliminary-final-keys"
+ FINAL_CANDIDATE="$ROOT/candidate"
+ RELEASE_DIR="$ROOT/release"
+ PUBLIC_FINALIZATION_EVIDENCE="$CONTROL/public-finalization-evidence.json"
+ for dir in "$CONTROL" "$CONFIG" "$KEYS" "$TRANSCRIPT" "$MEASUREMENTS" "$STATE_DIR" "$STATE_DIR/steps"; do
+ if [[ ! -d "$dir" || -L "$dir" ]]; then
+ echo "FAIL: expected rehearsal directory is absent or unsafe: $dir" >&2
+ exit 1
+ fi
+ done
+ local unsafe_entry
+ unsafe_entry=$(find "$ROOT" ! -type d ! -type f -print -quit)
+ if [[ -n "$unsafe_entry" ]]; then
+ echo "FAIL: rehearsal tree contains a symlink or special file: $unsafe_entry" >&2
+ exit 1
+ fi
+ if [[ "$LOAD_MODE" != "read-only" ]]; then
+ "$SCRIPT_DIR/check-mpc-k21-capacity.sh" "$ROOT"
+ fi
+ if [[ ! -f "$STATE_DIR/binary.sha256" || -L "$STATE_DIR/binary.sha256" ]]; then
+ echo "FAIL: prepared binary hash is absent or unsafe" >&2
+ exit 1
+ fi
+ local expected_binary_hash
+ expected_binary_hash=$(tr -d '\n' <"$STATE_DIR/binary.sha256")
+ local actual_binary_hash
+ actual_binary_hash=$(sha256sum "$MPC_BIN" | cut -d ' ' -f 1)
+ if [[ "$actual_binary_hash" != "$expected_binary_hash" ]]; then
+ echo "FAIL: ceremony binary changed after prepare" >&2
+ exit 1
+ fi
+ PARTICIPANT_COUNT=$(tr -d '\n' <"$CONTROL/participant-count.txt")
+ if [[ ! "$PARTICIPANT_COUNT" =~ ^([3-9]|1[0-9]|20)$ ]]; then
+ echo "FAIL: invalid rehearsal participant count" >&2
+ exit 1
+ fi
+ if [[ ! -f "$STATE_DIR/beacon-lead-seconds.txt" ||
+ -L "$STATE_DIR/beacon-lead-seconds.txt" ]]; then
+ echo "FAIL: prepared rehearsal beacon lead is absent or unsafe" >&2
+ exit 1
+ fi
+ MIN_BEACON_LEAD_SECONDS=$(tr -d '\n' <"$STATE_DIR/beacon-lead-seconds.txt")
+ if [[ ! "$MIN_BEACON_LEAD_SECONDS" =~ ^[0-9]+$ ||
+ "$MIN_BEACON_LEAD_SECONDS" -lt "$HARD_MIN_REHEARSAL_BEACON_LEAD_SECONDS" ]]; then
+ echo "FAIL: invalid prepared rehearsal beacon lead" >&2
+ exit 1
+ fi
+ CEREMONY="$TRANSCRIPT/ceremony.json"
+ CEREMONY_SIGNATURE="$TRANSCRIPT/ceremony.sig"
+ COORDINATOR_PUBLIC_KEY="$KEYS/coordinator.ed25519.public.hex"
+ COORDINATOR_PRIVATE_KEY="$KEYS/coordinator.ed25519.private.hex"
+ PHASE1_SEAL="$TRANSCRIPT/phase1/sealed/seal.json"
+ PHASE1_SEAL_SIGNATURE="$TRANSCRIPT/phase1/sealed/seal.sig"
+ resolve_finalization_evidence_generator
+ resolve_operational_evidence_generator
+ if [[ "$LOAD_MODE" == "read-only" ]]; then
+ revalidate_completed_steps no
+ revalidate_stage_markers no
+ else
+ revalidate_completed_steps yes
+ revalidate_stage_markers yes
+ fi
+}
+
+relay_ids() {
+ local phase=$1
+ local path="$STATE_DIR/$phase-relay-ids.txt"
+ if [[ ! -f "$path" || -L "$path" ]]; then
+ echo "FAIL: bound relay identifiers are absent or unsafe for $phase" >&2
+ return 1
+ fi
+ local value
+ value=$(tr -d '\n' <"$path")
+ if [[ ! "$value" =~ ^[a-z0-9._:-]+(,[a-z0-9._:-]+){2,15}$ ||
+ "$value" == *..* ]]; then
+ echo "FAIL: bound relay identifiers are malformed for $phase" >&2
+ return 1
+ fi
+ tr ',' '\n' <<<"$value"
+}
+
+record_relay_inputs() {
+ local phase=$1
+ local directory=$2
+ if [[ ! -d "$directory" || -L "$directory" ]]; then
+ echo "FAIL: $phase relay input must be a real directory" >&2
+ return 1
+ fi
+ directory=$(cd "$directory" && pwd)
+ local manifest="$directory/relays.tsv"
+ if [[ ! -f "$manifest" || -L "$manifest" ]]; then
+ echo "FAIL: $phase relay input lacks a regular relays.tsv" >&2
+ return 1
+ fi
+ local ids
+ ids=$(
+ node - "$manifest" <<'NODE'
+const fs = require("node:fs");
+const rows = fs.readFileSync(process.argv[2], "utf8").split("\n");
+if (rows.at(-1) === "") rows.pop();
+const header = "relay_id\toperator_id\tendpoint_sha256\tretrieved_at\tfilename";
+if (rows.length < 4 || rows.length > 17 || rows[0] !== header) {
+ throw new Error("relays.tsv must have the exact header and 3-16 observations");
+}
+const idPattern = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
+const filenamePattern = /^[a-z0-9][a-z0-9._-]*\.json$/;
+const ids = [];
+for (const row of rows.slice(1)) {
+ const fields = row.split("\t");
+ if (fields.length !== 5 || !idPattern.test(fields[0]) ||
+ fields[0].includes("..") ||
+ !filenamePattern.test(fields[4])) {
+ throw new Error("relays.tsv contains an unsafe row");
+ }
+ ids.push(fields[0]);
+}
+ids.sort();
+if (new Set(ids).size !== ids.length) {
+ throw new Error("relays.tsv contains duplicate relay identifiers");
+}
+process.stdout.write(ids.join(","));
+NODE
+ )
+ local latest_epoch=0
+ local row=0
+ local relay_id
+ local operator_id
+ local endpoint_sha256
+ local retrieved_at
+ local filename
+ local retrieved_epoch
+ while IFS=$'\t' read -r relay_id operator_id endpoint_sha256 retrieved_at filename; do
+ ((row += 1))
+ if (( row == 1 )); then
+ continue
+ fi
+ if ! retrieved_epoch=$(date -u -d "$retrieved_at" +%s); then
+ echo "FAIL: $phase relays.tsv contains an invalid retrieved_at" >&2
+ return 1
+ fi
+ if (( retrieved_epoch > latest_epoch )); then
+ latest_epoch=$retrieved_epoch
+ fi
+ done <"$manifest"
+ write_state "$STATE_DIR/$phase-relay-ids.txt" "$ids"
+ write_state \
+ "$STATE_DIR/$phase-relays.sha256" \
+ "$(sha256sum "$manifest" | cut -d ' ' -f 1)"
+ printf -v "${phase^^}_RELAY_LATEST_EPOCH" '%s' "$latest_epoch"
+}
+
+operational_generated_paths() {
+ local prefix=$1
+ local phase
+ local index
+ local sequence
+ local identity
+ local relay
+ local relay_values
+ printf '%s\0' \
+ "$prefix/operational/evidence-bundle.json" \
+ "$prefix/operational/evidence-bundle.sig"
+ for identity in \
+ coordinator release-signer auditor-01 auditor-02 \
+ witness-01 witness-02 mirror-01 mirror-02; do
+ printf '%s\0' \
+ "$prefix/operational/disclosures/$identity.json" \
+ "$prefix/operational/enrollments/$identity.json" \
+ "$prefix/operational/enrollments/$identity.sig"
+ done
+ for index in $(seq 1 "$PARTICIPANT_COUNT"); do
+ printf -v identity 'participant-%02d' "$index"
+ printf '%s\0' \
+ "$prefix/operational/disclosures/$identity.json" \
+ "$prefix/operational/enrollments/$identity.json" \
+ "$prefix/operational/enrollments/$identity.sig"
+ done
+ for phase in phase1 phase2; do
+ for index in $(seq 1 "$PARTICIPANT_COUNT"); do
+ printf -v sequence '%04d' "$index"
+ printf '%s\0' \
+ "$prefix/operational/$phase/heads/$sequence/outbound-handoff.json" \
+ "$prefix/operational/$phase/heads/$sequence/outbound-handoff.sig" \
+ "$prefix/operational/$phase/heads/$sequence/outbound-receipt.json" \
+ "$prefix/operational/$phase/heads/$sequence/outbound-receipt.sig" \
+ "$prefix/operational/$phase/heads/$sequence/return-handoff.json" \
+ "$prefix/operational/$phase/heads/$sequence/return-handoff.sig" \
+ "$prefix/operational/$phase/heads/$sequence/return-receipt.json" \
+ "$prefix/operational/$phase/heads/$sequence/return-receipt.sig" \
+ "$prefix/operational/$phase/heads/$sequence/mirrors/mirror-01.json" \
+ "$prefix/operational/$phase/heads/$sequence/mirrors/mirror-01.sig" \
+ "$prefix/operational/$phase/heads/$sequence/mirrors/mirror-02.json" \
+ "$prefix/operational/$phase/heads/$sequence/mirrors/mirror-02.sig"
+ done
+ printf '%s\0' \
+ "$prefix/operational/$phase/witnesses/witness-01.json" \
+ "$prefix/operational/$phase/witnesses/witness-01.sig" \
+ "$prefix/operational/$phase/witnesses/witness-02.json" \
+ "$prefix/operational/$phase/witnesses/witness-02.sig" \
+ "$prefix/operational/$phase/beacon/evidence.json" \
+ "$prefix/operational/$phase/beacon/evidence.sig"
+ if ! relay_values=$(relay_ids "$phase"); then
+ return 1
+ fi
+ while IFS= read -r relay; do
+ printf '%s\0' "$prefix/operational/$phase/beacon/raw/$relay.json"
+ done <<<"$relay_values"
+ done
+}
+
+operational_release_transcript_paths() {
+ local phase
+ local index
+ local sequence
+ for phase in phase1 phase2; do
+ printf '%s\0' \
+ "$RELEASE_DIR/$phase/closure/record.json" \
+ "$RELEASE_DIR/$phase/closure/record.sig" \
+ "$RELEASE_DIR/$phase/genesis.bin"
+ for index in $(seq 1 "$PARTICIPANT_COUNT"); do
+ printf -v sequence '%04d' "$index"
+ printf '%s\0' \
+ "$RELEASE_DIR/$phase/chain-$sequence.json" \
+ "$RELEASE_DIR/$phase/chain-$sequence.sig" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/contribution.bin" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/attestation.json" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/attestation.sig" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/erasure.json" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/erasure.sig" \
+ "$RELEASE_DIR/$phase/contributions/$sequence/verification.json"
+ done
+ done
+}
+
+artifact_paths() {
+ local label=$1
+ local phase
+ local sequence
+ local index
+ local participant_id
+ local candidate
+ local contribution
+ case "$label" in
+ init)
+ printf '%s\0' \
+ "$TRANSCRIPT/ceremony.json" \
+ "$TRANSCRIPT/ceremony.sig" \
+ "$TRANSCRIPT/coordinator-public-key.hex" \
+ "$TRANSCRIPT/ownership-destination.ccs" \
+ "$TRANSCRIPT/phase1/genesis.bin" \
+ "$TRANSCRIPT/phase1/chain-0000.json" \
+ "$TRANSCRIPT/phase1/chain-0000.sig"
+ ;;
+ phase1-[0-9][0-9][0-9][0-9]-contribute | phase2-[0-9][0-9][0-9][0-9]-contribute)
+ phase=${label%%-*}
+ sequence=${label#"$phase-"}
+ sequence=${sequence%%-*}
+ index=$((10#$sequence))
+ printf -v participant_id 'participant-%02d' "$index"
+ candidate="$CANDIDATES/$phase-$participant_id"
+ printf '%s\0' \
+ "$candidate/contribution.bin" \
+ "$candidate/attestation.json" \
+ "$candidate/attestation.sig"
+ ;;
+ phase1-[0-9][0-9][0-9][0-9]-erasure | phase2-[0-9][0-9][0-9][0-9]-erasure)
+ phase=${label%%-*}
+ sequence=${label#"$phase-"}
+ sequence=${sequence%%-*}
+ index=$((10#$sequence))
+ printf -v participant_id 'participant-%02d' "$index"
+ candidate="$CANDIDATES/$phase-$participant_id"
+ printf '%s\0' "$candidate/erasure.json" "$candidate/erasure.sig"
+ ;;
+ phase1-[0-9][0-9][0-9][0-9]-verify | phase2-[0-9][0-9][0-9][0-9]-verify)
+ phase=${label%%-*}
+ sequence=${label#"$phase-"}
+ sequence=${sequence%%-*}
+ contribution="$TRANSCRIPT/$phase/contributions/$sequence"
+ printf '%s\0' \
+ "$contribution/contribution.bin" \
+ "$contribution/attestation.json" \
+ "$contribution/attestation.sig" \
+ "$contribution/erasure.json" \
+ "$contribution/erasure.sig" \
+ "$contribution/verification.json" \
+ "$TRANSCRIPT/$phase/chain-$sequence.json" \
+ "$TRANSCRIPT/$phase/chain-$sequence.sig"
+ ;;
+ phase1-close | phase2-close)
+ phase=${label%-close}
+ printf '%s\0' "$TRANSCRIPT/$phase/closure/record.json" "$TRANSCRIPT/$phase/closure/record.sig"
+ ;;
+ phase1-beacon | phase2-beacon)
+ phase=${label%-beacon}
+ printf '%s\0' \
+ "$TRANSCRIPT/$phase/beacon/raw-response.bin" \
+ "$TRANSCRIPT/$phase/beacon/record.json" \
+ "$TRANSCRIPT/$phase/beacon/record.sig"
+ ;;
+ phase1-seal)
+ printf '%s\0' \
+ "$TRANSCRIPT/phase1/sealed/commons.bin" \
+ "$TRANSCRIPT/phase1/sealed/seal.json" \
+ "$TRANSCRIPT/phase1/sealed/seal.sig"
+ ;;
+ phase2-init)
+ printf '%s\0' \
+ "$TRANSCRIPT/phase2/genesis.bin" \
+ "$TRANSCRIPT/phase2/chain-0000.json" \
+ "$TRANSCRIPT/phase2/chain-0000.sig"
+ ;;
+ finalize-prepare)
+ printf '%s\0' \
+ "$PRELIMINARY_KEYS/ownership-destination.ccs" \
+ "$PRELIMINARY_KEYS/ownership.pk" \
+ "$PRELIMINARY_KEYS/ownership.vk" \
+ "$PRELIMINARY_KEYS/cardano-vk.bin" \
+ "$PRELIMINARY_KEYS/cardano-vk.hex" \
+ "$PRELIMINARY_KEYS/cardano-vk-format.txt" \
+ "$PRELIMINARY_KEYS/preliminary-final-keys.json" \
+ "$PRELIMINARY_KEYS/preliminary-final-keys.sig.json" \
+ "$PRELIMINARY_KEYS/preliminary-checksums.sha256"
+ ;;
+ public-evidence-generate)
+ printf '%s\0' "$PUBLIC_FINALIZATION_EVIDENCE"
+ ;;
+ operational-evidence-generate)
+ operational_generated_paths "$TRANSCRIPT"
+ ;;
+ finalize-complete)
+ printf '%s\0' \
+ "$FINAL_CANDIDATE/candidate.json" \
+ "$FINAL_CANDIDATE/candidate.sig.json" \
+ "$FINAL_CANDIDATE/verification-report.json" \
+ "$FINAL_CANDIDATE/public-finalization-evidence.json" \
+ "$FINAL_CANDIDATE/ownership.pk" \
+ "$FINAL_CANDIDATE/ownership.vk" \
+ "$FINAL_CANDIDATE/ownership-destination.ccs" \
+ "$FINAL_CANDIDATE/cardano-vk.bin" \
+ "$FINAL_CANDIDATE/cardano-vk.hex" \
+ "$FINAL_CANDIDATE/cardano-vk-format.txt" \
+ "$FINAL_CANDIDATE/candidate-checksums.sha256" \
+ "$FINAL_CANDIDATE/phase2-seal.json" \
+ "$FINAL_CANDIDATE/phase2-seal.sig.json"
+ ;;
+ audit-01 | audit-02)
+ printf '%s\0' \
+ "$AUDITS/${label/audit-/auditor-}.json" \
+ "$AUDITS/${label/audit-/auditor-}.sig"
+ ;;
+ release-sign)
+ printf '%s\0' \
+ "$RELEASE_DIR/ownership-destination.ccs" \
+ "$RELEASE_DIR/ownership.pk" \
+ "$RELEASE_DIR/ownership.vk" \
+ "$RELEASE_DIR/cardano-vk.bin" \
+ "$RELEASE_DIR/cardano-vk.hex" \
+ "$RELEASE_DIR/cardano-vk-format.txt" \
+ "$RELEASE_DIR/verification-report.json" \
+ "$RELEASE_DIR/public-finalization-evidence.json" \
+ "$RELEASE_DIR/phase2-seal.json" \
+ "$RELEASE_DIR/phase2-seal.sig.json" \
+ "$RELEASE_DIR/candidate.json" \
+ "$RELEASE_DIR/candidate.sig.json" \
+ "$RELEASE_DIR/candidate-checksums.sha256" \
+ "$RELEASE_DIR/setup-transcript.json" \
+ "$RELEASE_DIR/manifest.json" \
+ "$RELEASE_DIR/manifest.sig" \
+ "$RELEASE_DIR/manifest-public-key.hex" \
+ "$RELEASE_DIR/audits/0001.json" \
+ "$RELEASE_DIR/audits/0001.sig" \
+ "$RELEASE_DIR/audits/0002.json" \
+ "$RELEASE_DIR/audits/0002.sig" \
+ "$RELEASE_DIR/checksums.sha256"
+ operational_generated_paths "$RELEASE_DIR"
+ operational_release_transcript_paths
+ ;;
+ final-plutus-evidence | operational-evidence-verify | release-verify)
+ ;;
+ *)
+ echo "FAIL: no artifact allowlist exists for step $label" >&2
+ return 1
+ ;;
+ esac
+}
+
+load_expected_artifacts() {
+ local label=$1
+ local temporary
+ temporary=$(mktemp)
+ if ! artifact_paths "$label" >"$temporary"; then
+ rm -f -- "$temporary"
+ return 1
+ fi
+ EXPECTED_ARTIFACTS=()
+ mapfile -d '' EXPECTED_ARTIFACTS <"$temporary"
+ rm -f -- "$temporary"
+}
+
+validate_artifact_path() {
+ local path=$1
+ if [[ "$path" != "$ROOT/"* || ! -f "$path" || -L "$path" ]]; then
+ echo "FAIL: generated artifact is absent, outside the root, or unsafe: $path" >&2
+ return 1
+ fi
+}
+
+validate_exact_artifact_tree() {
+ local label=$1
+ local tree
+ case "$label" in
+ operational-evidence-generate)
+ tree="$TRANSCRIPT/operational"
+ ;;
+ release-sign)
+ tree="$RELEASE_DIR"
+ ;;
+ *)
+ return
+ ;;
+ esac
+ if [[ ! -d "$tree" || -L "$tree" ]]; then
+ echo "FAIL: generated artifact tree is absent or unsafe for $label" >&2
+ return 1
+ fi
+ local expected_file
+ local actual_file
+ expected_file=$(mktemp "$STATE_DIR/steps/.$label.expected.XXXXXXXX")
+ actual_file=$(mktemp "$STATE_DIR/steps/.$label.actual.XXXXXXXX")
+ local artifact
+ for artifact in "${EXPECTED_ARTIFACTS[@]}"; do
+ printf '%s\n' "${artifact#"$tree/"}"
+ done | LC_ALL=C sort >"$expected_file"
+ find "$tree" -type f -printf '%P\n' | LC_ALL=C sort >"$actual_file"
+ if ! cmp -s "$expected_file" "$actual_file"; then
+ rm -f -- "$expected_file" "$actual_file"
+ echo "FAIL: $label generated a file set outside its exact allowlist" >&2
+ return 1
+ fi
+ rm -f -- "$expected_file" "$actual_file"
+}
+
+write_artifact_manifest() {
+ local label=$1
+ local manifest="$STATE_DIR/steps/$label.artifacts.sha256"
+ if [[ -e "$manifest" || -L "$manifest" ]]; then
+ echo "FAIL: artifact marker already exists for $label" >&2
+ return 1
+ fi
+ local artifact
+ local relative
+ local digest
+ for artifact in "${EXPECTED_ARTIFACTS[@]}"; do
+ validate_artifact_path "$artifact"
+ done
+ validate_exact_artifact_tree "$label"
+ local temporary
+ temporary=$(mktemp "$STATE_DIR/steps/.$label.artifacts.XXXXXXXX")
+ for artifact in "${EXPECTED_ARTIFACTS[@]}"; do
+ relative=${artifact#"$ROOT/"}
+ if [[ "$relative" == *$'\t'* || "$relative" == *$'\n'* ]]; then
+ echo "FAIL: generated artifact path contains a control character" >&2
+ rm -f -- "$temporary"
+ return 1
+ fi
+ digest=$(sha256sum "$artifact" | cut -d ' ' -f 1)
+ printf '%s\t%s\n' "$digest" "$relative" >>"$temporary"
+ done
+ chmod 0600 "$temporary"
+ sync -f "$temporary"
+ if ! ln "$temporary" "$manifest"; then
+ rm -f -- "$temporary"
+ echo "FAIL: artifact marker publication collided for $label" >&2
+ return 1
+ fi
+ rm -f -- "$temporary"
+ sync -f "$STATE_DIR/steps"
+}
+
+verify_artifact_manifest() {
+ local label=$1
+ local manifest="$STATE_DIR/steps/$label.artifacts.sha256"
+ if [[ ! -f "$manifest" || -L "$manifest" ]]; then
+ echo "FAIL: completed step lacks an artifact hash marker: $label" >&2
+ return 1
+ fi
+ local position=0
+ local digest
+ local relative
+ local expected
+ local actual
+ while IFS=$'\t' read -r digest relative; do
+ if (( position >= ${#EXPECTED_ARTIFACTS[@]} )); then
+ echo "FAIL: artifact marker has unexpected entries for $label" >&2
+ return 1
+ fi
+ expected=${EXPECTED_ARTIFACTS[$position]#"$ROOT/"}
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ || "$relative" != "$expected" ]]; then
+ echo "FAIL: artifact marker is malformed or names an unexpected path for $label" >&2
+ return 1
+ fi
+ validate_artifact_path "$ROOT/$relative"
+ actual=$(sha256sum "$ROOT/$relative" | cut -d ' ' -f 1)
+ if [[ "$actual" != "$digest" ]]; then
+ echo "FAIL: completed-step artifact changed for $label: $relative" >&2
+ return 1
+ fi
+ ((position += 1))
+ done <"$manifest"
+ if (( position != ${#EXPECTED_ARTIFACTS[@]} )); then
+ echo "FAIL: artifact marker is incomplete for $label" >&2
+ return 1
+ fi
+ validate_exact_artifact_tree "$label"
+}
+
+validate_output_marker() {
+ local label=$1
+ local marker="$STATE_DIR/steps/$label.complete"
+ if [[ ! -f "$marker" || -L "$marker" ]]; then
+ echo "FAIL: completed-step output marker is absent or unsafe: $label" >&2
+ return 1
+ fi
+ local line
+ local digest
+ local output_relative
+ local output
+ local actual
+ line=$(tr -d '\n' <"$marker")
+ digest=${line%% *}
+ output_relative=${line#* }
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ||
+ "$output_relative" != "measurements/$label.attempt-"[0-9][0-9][0-9][0-9]".output.json" ]]; then
+ echo "FAIL: completed-step output marker is malformed for $label" >&2
+ return 1
+ fi
+ output="$ROOT/$output_relative"
+ if [[ ! -f "$output" || -L "$output" ]]; then
+ echo "FAIL: completed-step output marker is malformed for $label" >&2
+ return 1
+ fi
+ actual=$(sha256sum "$output" | cut -d ' ' -f 1)
+ if [[ "$actual" != "$digest" ]]; then
+ echo "FAIL: completed-step output changed for $label" >&2
+ return 1
+ fi
+ validate_success_json "$label" "$output"
+}
+
+validate_success_json() {
+ local label=$1
+ local output=$2
+ node - "$label" "$output" "$CEREMONY" <<'NODE'
+const fs = require("node:fs");
+const label = process.argv[2];
+const path = process.argv[3];
+const ceremonyPath = process.argv[4];
+let value;
+try {
+ value = JSON.parse(fs.readFileSync(path, "utf8"));
+} catch (error) {
+ process.stderr.write(`FAIL: invalid JSON result for ${label}: ${error.message}\n`);
+ process.exit(1);
+}
+let expectedCeremonyID = "";
+if (fs.existsSync(ceremonyPath)) {
+ const ceremony = JSON.parse(fs.readFileSync(ceremonyPath, "utf8"));
+ if (!/^sha256:[0-9a-f]{64}$/.test(ceremony.ceremony_id)) {
+ process.stderr.write("FAIL: ceremony result trust anchor has an invalid ceremony_id\n");
+ process.exit(1);
+ }
+ expectedCeremonyID = ceremony.ceremony_id;
+}
+if (label === "final-plutus-evidence") {
+ if (value.schema !== "proof-tool-mpc-plutus-finalization-verification-v1" ||
+ value.positive_verified !== true ||
+ !Array.isArray(value.rejected_negatives) ||
+ value.rejected_negatives.length !== 9) {
+ process.stderr.write(`FAIL: unsuccessful Plutus evidence result for ${label}\n`);
+ process.exit(1);
+ }
+} else if (label === "public-evidence-generate") {
+ if (value.schema !== "proof-tool-mpc-public-evidence-generation-result-v1" ||
+ !/^sha256:[0-9a-f]{64}$/.test(value.ceremony_id) ||
+ value.ceremony_id !== expectedCeremonyID ||
+ typeof value.public_evidence_digest !== "object" ||
+ value.public_evidence_digest === null ||
+ !/^sha256:[0-9a-f]{64}$/.test(value.public_evidence_digest.sha256) ||
+ !/^blake2b256:[0-9a-f]{64}$/.test(value.public_evidence_digest.blake2b256) ||
+ !Number.isSafeInteger(value.public_evidence_digest.size) ||
+ value.public_evidence_digest.size < 1) {
+ process.stderr.write(`FAIL: unsuccessful public-evidence generator result for ${label}\n`);
+ process.exit(1);
+ }
+} else if (label === "operational-evidence-generate") {
+ if (value.schema !== "proof-tool-mpc-rehearsal-operational-evidence-result-v1" ||
+ value.ok !== true ||
+ !/^sha256:[0-9a-f]{64}$/.test(value.ceremony_id) ||
+ value.ceremony_id !== expectedCeremonyID ||
+ !/^sha256:[0-9a-f]{64}$/.test(value.bundle_sha256) ||
+ !Number.isSafeInteger(value.referenced_artifacts) ||
+ value.referenced_artifacts < 1) {
+ process.stderr.write(`FAIL: unsuccessful operational-evidence generator result for ${label}\n`);
+ process.exit(1);
+ }
+} else if (value.schema !== "proof-tool-mpc-command-result-v1" || value.ok !== true) {
+ process.stderr.write(`FAIL: unsuccessful ceremony command result for ${label}\n`);
+ process.exit(1);
+} else {
+ let expected;
+ if (label === "init") expected = "init";
+ else if (/^phase[12]-[0-9]{4}-contribute$/.test(label)) {
+ expected = `${label.slice(0, 6)} contribute`;
+ } else if (/^phase[12]-[0-9]{4}-erasure$/.test(label)) {
+ expected = `${label.slice(0, 6)} attest-erasure`;
+ } else if (/^phase[12]-[0-9]{4}-verify$/.test(label)) {
+ expected = `${label.slice(0, 6)} verify`;
+ } else if (/^phase[12]-close$/.test(label)) expected = `${label.slice(0, 6)} close`;
+ else if (/^phase[12]-beacon$/.test(label)) expected = `${label.slice(0, 6)} beacon`;
+ else if (label === "phase1-seal") expected = "phase1 seal";
+ else if (label === "phase2-init") expected = "phase2 init";
+ else if (label === "finalize-prepare") expected = "finalize prepare";
+ else if (label === "finalize-complete") expected = "finalize complete";
+ else if (label === "operational-evidence-verify") expected = "ops verify";
+ else if (/^audit-[0-9]{2}$/.test(label)) expected = "audit";
+ else if (label === "release-sign") expected = "release sign";
+ else if (label === "release-verify") expected = "release verify";
+ if (!expected || value.command !== expected) {
+ process.stderr.write(`FAIL: command result for ${label} names ${JSON.stringify(value.command)}, expected ${JSON.stringify(expected)}\n`);
+ process.exit(1);
+ }
+}
+NODE
+}
+
+revalidate_completed_steps() {
+ local allow_upgrade=$1
+ local marker
+ local label
+ while IFS= read -r -d '' marker; do
+ label=$(basename "$marker" .complete)
+ load_expected_artifacts "$label"
+ validate_output_marker "$label"
+ if [[ ! -e "$STATE_DIR/steps/$label.artifacts.sha256" ]]; then
+ if [[ "$allow_upgrade" != "yes" ]]; then
+ echo "FAIL: completed step $label predates artifact-bound resume markers" >&2
+ return 1
+ fi
+ write_artifact_manifest "$label"
+ echo "UPGRADE: bound completed step $label to its generated artifacts"
+ fi
+ verify_artifact_manifest "$label"
+ if step_requires_runner_epoch "$label"; then
+ local epoch_path="$STATE_DIR/steps/$label.epoch"
+ local epoch_value
+ if [[ ! -f "$epoch_path" || -L "$epoch_path" ]]; then
+ echo "FAIL: completed step lacks its persisted runner epoch: $label" >&2
+ return 1
+ fi
+ epoch_value=$(tr -d '\n' <"$epoch_path")
+ if [[ ! "$epoch_value" =~ ^[0-9]+$ ]]; then
+ echo "FAIL: completed step has a malformed runner epoch: $label" >&2
+ return 1
+ fi
+ fi
+ done < <(
+ find "$STATE_DIR/steps" -maxdepth 1 -type f -name '*.complete' -print0 |
+ LC_ALL=C sort -z
+ )
+}
+
+step_requires_runner_epoch() {
+ local label=$1
+ [[ "$label" =~ ^phase[12]-[0-9]{4}-(contribute|erasure|verify)$ ||
+ "$label" =~ ^finalize-(prepare|complete)$ ||
+ "$label" == operational-evidence-generate ||
+ "$label" =~ ^audit-[0-9]{2}$ ||
+ "$label" == release-sign ]]
+}
+
+verify_stage_manifest() {
+ local name=$1
+ local manifest="$STATE_DIR/$name.steps.sha256"
+ if [[ ! -f "$manifest" || -L "$manifest" ]]; then
+ echo "FAIL: completed stage lacks a step-set manifest: $name" >&2
+ return 1
+ fi
+ local digest
+ local relative
+ local previous=
+ local actual
+ local saw_stage_complete=0
+ local step_complete_count=0
+ local step_artifact_count=0
+ local invariant_count=0
+ declare -A completed_steps=()
+ declare -A epoch_steps=()
+ while IFS=$'\t' read -r digest relative; do
+ if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ||
+ ( "$relative" != "state/$name.complete" &&
+ ! "$relative" =~ ^state/steps/[a-z0-9-]+\.(complete|epoch|artifacts\.sha256)$ &&
+ ( "$name" != prepare ||
+ ! "$relative" =~ ^(state/(binary\.sha256|created-epoch\.txt|config-generator-mode\.txt|config-generator\.sha256|finalization-evidence-generator-mode\.txt|finalization-evidence-generator\.sha256|operational-evidence-generator-mode\.txt|operational-evidence-generator\.sha256|beacon-lead-seconds\.txt)|control/(participant-count\.txt|config/(participants|policy)\.json)|measurements/prepare-capacity\.txt)$ ) &&
+ ( "$name" != phase1 ||
+ ! "$relative" =~ ^state/phase1-(round|round-epoch|closed-epoch)\.txt$ ) &&
+ ( "$name" != phase1-beacon ||
+ ! "$relative" =~ ^state/phase1-(published-at|published-epoch)\.txt$ ) &&
+ ( "$name" != phase2 ||
+ ! "$relative" =~ ^state/phase2-(round|round-epoch|closed-epoch)\.txt$ ) &&
+ ( "$name" != finish ||
+ ! "$relative" =~ ^(measurements/(artifact-sizes\.tsv|retained-directory-sizes\.txt|final-filesystem-capacity\.txt)|state/phase[12]-relay-ids\.txt|state/phase[12]-relays\.sha256|state/phase2-(published-at|published-epoch)\.txt)$ ) ) ||
+ "$relative" == "$previous" ||
+ ( -n "$previous" && "$relative" < "$previous" ) ]]; then
+ echo "FAIL: stage step-set manifest is malformed: $name" >&2
+ return 1
+ fi
+ if [[ ! -f "$ROOT/$relative" || -L "$ROOT/$relative" ]]; then
+ echo "FAIL: stage $name lost a completed-step marker: $relative" >&2
+ return 1
+ fi
+ actual=$(sha256sum "$ROOT/$relative" | cut -d ' ' -f 1)
+ if [[ "$actual" != "$digest" ]]; then
+ echo "FAIL: stage $name completed-step marker changed: $relative" >&2
+ return 1
+ fi
+ if [[ "$relative" == "state/$name.complete" ]]; then
+ saw_stage_complete=1
+ elif [[ "$relative" == state/steps/*.complete ]]; then
+ ((step_complete_count += 1))
+ completed_steps["${relative#state/steps/}"]=1
+ elif [[ "$relative" == state/steps/*.artifacts.sha256 ]]; then
+ ((step_artifact_count += 1))
+ elif [[ "$relative" == state/steps/*.epoch ]]; then
+ local epoch
+ epoch=$(tr -d '\n' <"$ROOT/$relative")
+ if [[ ! "$epoch" =~ ^[0-9]+$ ]]; then
+ echo "FAIL: stage $name contains a malformed runner epoch: $relative" >&2
+ return 1
+ fi
+ epoch_steps["${relative#state/steps/}"]=1
+ else
+ ((invariant_count += 1))
+ fi
+ previous=$relative
+ done <"$manifest"
+ local epoch_name
+ for epoch_name in "${!epoch_steps[@]}"; do
+ if [[ -z "${completed_steps[${epoch_name%.epoch}.complete]:-}" ]]; then
+ echo "FAIL: stage $name binds an epoch without its completed step: $epoch_name" >&2
+ return 1
+ fi
+ done
+ local complete_name
+ local complete_label
+ for complete_name in "${!completed_steps[@]}"; do
+ complete_label=${complete_name%.complete}
+ if step_requires_runner_epoch "$complete_label" &&
+ [[ -z "${epoch_steps[$complete_label.epoch]:-}" ]]; then
+ echo "FAIL: stage $name omits the required runner epoch for $complete_label" >&2
+ return 1
+ fi
+ done
+ if (( saw_stage_complete != 1 || step_complete_count == 0 ||
+ step_complete_count != step_artifact_count )) ||
+ { [[ "$name" == prepare ]] && (( invariant_count != 13 )); } ||
+ { [[ "$name" == phase1 ]] && (( invariant_count != 3 )); } ||
+ { [[ "$name" == phase1-beacon ]] && (( invariant_count != 2 )); } ||
+ { [[ "$name" == phase2 ]] && (( invariant_count != 3 )); } ||
+ { [[ "$name" == finish ]] && (( invariant_count != 9 )); } ||
+ { [[ "$name" != prepare && "$name" != phase1 &&
+ "$name" != phase1-beacon && "$name" != phase2 &&
+ "$name" != finish ]] && (( invariant_count != 0 )); }; then
+ echo "FAIL: stage $name step-set manifest is incomplete or unpaired" >&2
+ return 1
+ fi
+}
+
+write_stage_manifest() {
+ local name=$1
+ local manifest="$STATE_DIR/$name.steps.sha256"
+ if [[ -e "$manifest" || -L "$manifest" ]]; then
+ verify_stage_manifest "$name"
+ return
+ fi
+ local temporary
+ temporary=$(mktemp "$STATE_DIR/.$name.steps.XXXXXXXX")
+ (
+ cd "$ROOT"
+ {
+ printf 'state/%s.complete\n' "$name"
+ if [[ "$name" == prepare ]]; then
+ printf '%s\n' \
+ state/binary.sha256 \
+ state/created-epoch.txt \
+ state/config-generator-mode.txt \
+ state/config-generator.sha256 \
+ state/finalization-evidence-generator-mode.txt \
+ state/finalization-evidence-generator.sha256 \
+ state/operational-evidence-generator-mode.txt \
+ state/operational-evidence-generator.sha256 \
+ state/beacon-lead-seconds.txt \
+ control/participant-count.txt \
+ control/config/participants.json \
+ control/config/policy.json \
+ measurements/prepare-capacity.txt
+ elif [[ "$name" == phase1 ]]; then
+ printf '%s\n' \
+ state/phase1-round.txt \
+ state/phase1-round-epoch.txt \
+ state/phase1-closed-epoch.txt
+ elif [[ "$name" == phase1-beacon ]]; then
+ printf '%s\n' \
+ state/phase1-published-at.txt \
+ state/phase1-published-epoch.txt
+ elif [[ "$name" == phase2 ]]; then
+ printf '%s\n' \
+ state/phase2-round.txt \
+ state/phase2-round-epoch.txt \
+ state/phase2-closed-epoch.txt
+ elif [[ "$name" == finish ]]; then
+ printf '%s\n' \
+ measurements/artifact-sizes.tsv \
+ measurements/retained-directory-sizes.txt \
+ measurements/final-filesystem-capacity.txt \
+ state/phase1-relay-ids.txt \
+ state/phase1-relays.sha256 \
+ state/phase2-relay-ids.txt \
+ state/phase2-relays.sha256 \
+ state/phase2-published-at.txt \
+ state/phase2-published-epoch.txt
+ fi
+ find state/steps -maxdepth 1 -type f \
+ \( -name '*.complete' -o -name '*.epoch' -o -name '*.artifacts.sha256' \) \
+ -printf '%p\n'
+ } |
+ LC_ALL=C sort |
+ while IFS= read -r relative; do
+ printf '%s\t%s\n' "$(sha256sum "$relative" | cut -d ' ' -f 1)" "$relative"
+ done
+ ) >"$temporary"
+ chmod 0600 "$temporary"
+ sync -f "$temporary"
+ if ! ln "$temporary" "$manifest"; then
+ rm -f -- "$temporary"
+ echo "FAIL: stage step-set marker publication collided for $name" >&2
+ return 1
+ fi
+ rm -f -- "$temporary"
+ sync -f "$STATE_DIR"
+ verify_stage_manifest "$name"
+}
+
+revalidate_stage_markers() {
+ local allow_upgrade=$1
+ local marker
+ local name
+ for name in prepare phase1-contributions phase1 phase1-beacon phase2-contributions phase2 finish; do
+ marker="$STATE_DIR/$name.complete"
+ if [[ ! -e "$marker" && ! -L "$marker" ]]; then
+ continue
+ fi
+ if [[ ! -f "$marker" || -L "$marker" ]]; then
+ echo "FAIL: completed-stage marker is unsafe: $name" >&2
+ return 1
+ fi
+ if [[ ! -e "$STATE_DIR/$name.steps.sha256" ]]; then
+ if [[ "$allow_upgrade" != yes ]]; then
+ echo "FAIL: completed stage $name predates step-set manifests" >&2
+ return 1
+ fi
+ write_stage_manifest "$name"
+ echo "UPGRADE: bound completed stage $name to its command markers"
+ fi
+ verify_stage_manifest "$name"
+ done
+}
+
+recover_orphaned_success_marker() {
+ local label=$1
+ local artifact_marker="$STATE_DIR/steps/$label.artifacts.sha256"
+ local step_marker="$STATE_DIR/steps/$label.complete"
+ if [[ ! -f "$artifact_marker" || -L "$artifact_marker" || -e "$step_marker" || -L "$step_marker" ]]; then
+ return
+ fi
+ verify_artifact_manifest "$label"
+ local outputs=()
+ mapfile -d '' outputs < <(
+ find "$MEASUREMENTS" -maxdepth 1 -type f \
+ -name "$label.attempt-????.output.json" -print0 |
+ LC_ALL=C sort -z
+ )
+ if (( ${#outputs[@]} == 0 )); then
+ echo "FAIL: artifact marker without a captured result for $label" >&2
+ return 1
+ fi
+ local output=${outputs[-1]}
+ if [[ ! -s "$output" || -L "$output" ]]; then
+ echo "FAIL: orphaned successful result is empty or unsafe for $label" >&2
+ return 1
+ fi
+ local output_hash
+ output_hash=$(sha256sum "$output" | cut -d ' ' -f 1)
+ write_state "$step_marker" "$output_hash ${output#"$ROOT/"}"
+ validate_output_marker "$label"
+ echo "RECOVER: published the success marker for already-hashed artifacts from $label"
+}
+
+recover_unmarked_success() {
+ local label=$1
+ local step_marker="$STATE_DIR/steps/$label.complete"
+ local artifact_marker="$STATE_DIR/steps/$label.artifacts.sha256"
+ if [[ -e "$step_marker" || -L "$step_marker" || -e "$artifact_marker" || -L "$artifact_marker" ]]; then
+ return
+ fi
+ local outputs=()
+ mapfile -d '' outputs < <(
+ find "$MEASUREMENTS" -maxdepth 1 -type f \
+ -name "$label.attempt-????.output.json" -print0 |
+ LC_ALL=C sort -z
+ )
+ if (( ${#outputs[@]} == 0 )); then
+ return
+ fi
+ local output=${outputs[-1]}
+ if [[ ! -s "$output" || -L "$output" ]] ||
+ ! validate_success_json "$label" "$output" >/dev/null 2>&1; then
+ return
+ fi
+ local artifact
+ for artifact in "${EXPECTED_ARTIFACTS[@]}"; do
+ validate_artifact_path "$artifact"
+ done
+ write_artifact_manifest "$label"
+ local output_hash
+ output_hash=$(sha256sum "$output" | cut -d ' ' -f 1)
+ write_state "$step_marker" "$output_hash ${output#"$ROOT/"}"
+ validate_output_marker "$label"
+ echo "RECOVER: adopted complete signed artifacts and a successful captured result from $label"
+}
+
+run_measured_json_step() {
+ local label=$1
+ shift
+ load_expected_artifacts "$label"
+ local step_marker="$STATE_DIR/steps/$label.complete"
+ recover_unmarked_success "$label"
+ recover_orphaned_success_marker "$label"
+ if [[ -f "$step_marker" && ! -L "$step_marker" ]]; then
+ validate_output_marker "$label"
+ verify_artifact_manifest "$label"
+ echo "SKIP: verified completed step $label"
+ return
+ fi
+ local attempt=1
+ local attempt_label
+ local timing
+ local output
+ while true; do
+ printf -v attempt_label '%s.attempt-%04d' "$label" "$attempt"
+ timing="$MEASUREMENTS/$attempt_label.time.txt"
+ output="$MEASUREMENTS/$attempt_label.output.json"
+ if [[ ! -e "$timing" && ! -L "$timing" && ! -e "$output" && ! -L "$output" ]]; then
+ break
+ fi
+ ((attempt += 1))
+ done
+ env LC_ALL=C TZ=UTC \
+ /usr/bin/time -v -o "$timing" \
+ "$@" >"$output"
+ sync -f "$timing"
+ sync -f "$output"
+ validate_success_json "$label" "$output"
+ write_artifact_manifest "$label"
+ local output_hash
+ output_hash=$(sha256sum "$output" | cut -d ' ' -f 1)
+ write_state "$step_marker" "$output_hash ${output#"$ROOT/"}"
+}
+
+run_step() {
+ local label=$1
+ shift
+ run_measured_json_step "$label" "$MPC_BIN" --format json "$@"
+}
+
+common_trust_flags() {
+ COMMON_TRUST_FLAGS=(
+ --ceremony "$CEREMONY"
+ --ceremony-signature "$CEREMONY_SIGNATURE"
+ --coordinator-public-key-file "$COORDINATOR_PUBLIC_KEY"
+ )
+}
+
+replay_flags() {
+ local count=$PARTICIPANT_COUNT
+ printf -v final_chain 'chain-%04d' "$count"
+ REPLAY_FLAGS=(
+ --transcript-root "$TRANSCRIPT"
+ --phase1-chain "$TRANSCRIPT/phase1/$final_chain.json"
+ --phase1-chain-signature "$TRANSCRIPT/phase1/$final_chain.sig"
+ --phase1-close "$TRANSCRIPT/phase1/closure/record.json"
+ --phase1-close-signature "$TRANSCRIPT/phase1/closure/record.sig"
+ --phase1-beacon "$TRANSCRIPT/phase1/beacon/record.json"
+ --phase1-beacon-signature "$TRANSCRIPT/phase1/beacon/record.sig"
+ --phase1-seal "$PHASE1_SEAL"
+ --phase1-seal-signature "$PHASE1_SEAL_SIGNATURE"
+ --phase2-chain "$TRANSCRIPT/phase2/$final_chain.json"
+ --phase2-chain-signature "$TRANSCRIPT/phase2/$final_chain.sig"
+ --phase2-close "$TRANSCRIPT/phase2/closure/record.json"
+ --phase2-close-signature "$TRANSCRIPT/phase2/closure/record.sig"
+ --phase2-beacon "$TRANSCRIPT/phase2/beacon/record.json"
+ --phase2-beacon-signature "$TRANSCRIPT/phase2/beacon/record.sig"
+ )
+}
+
+run_close_stage() {
+ local phase=$1
+ local round=$2
+ local phase_title
+ local round_epoch_value
+ local closed_epoch
+ local sequence
+ local chain
+ local chain_signature
+ local phase_flags=()
+
+ case "$phase" in
+ phase1)
+ phase_title="Phase 1"
+ ;;
+ phase2)
+ phase_title="Phase 2"
+ phase_flags=(
+ --phase1-seal "$PHASE1_SEAL"
+ --phase1-seal-signature "$PHASE1_SEAL_SIGNATURE"
+ )
+ ;;
+ *)
+ echo "FAIL: unsupported close phase: $phase" >&2
+ return 1
+ ;;
+ esac
+
+ require_marker "$phase-contributions" || return 1
+ if [[ -e "$STATE_DIR/$phase.complete" ]]; then
+ echo "FAIL: $phase_title close stage is already complete" >&2
+ return 1
+ fi
+ if [[ "$phase" == phase2 ]]; then
+ if [[ ! -f "$STATE_DIR/phase1-round.txt" ||
+ -L "$STATE_DIR/phase1-round.txt" ]]; then
+ echo "FAIL: Phase 1 beacon round state is absent or unsafe" >&2
+ return 1
+ fi
+ local phase1_round
+ phase1_round=$(tr -d '\n' <"$STATE_DIR/phase1-round.txt")
+ if [[ "$round" == "$phase1_round" ]]; then
+ echo "FAIL: Phase 2 must use a distinct beacon round" >&2
+ return 1
+ fi
+ fi
+ common_trust_flags
+ round_epoch_value=$(round_epoch "$round") || return 1
+ if [[ ! -e "$TRANSCRIPT/$phase/closure" &&
+ ! -L "$TRANSCRIPT/$phase/closure" ]] &&
+ (( round_epoch_value < $(date +%s) + MIN_BEACON_LEAD_SECONDS + 2 )); then
+ echo "FAIL: select a $phase_title beacon round leaving the signed lead plus publication margin after the close replay" >&2
+ return 1
+ fi
+ printf -v sequence '%04d' "$PARTICIPANT_COUNT"
+ chain="$TRANSCRIPT/$phase/chain-$sequence.json"
+ chain_signature="$TRANSCRIPT/$phase/chain-$sequence.sig"
+ run_step "$phase-close" \
+ "$phase" close \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ "${phase_flags[@]}" \
+ --transcript-dir "$TRANSCRIPT" \
+ --chain "$chain" \
+ --chain-signature "$chain_signature" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --beacon-round "$round" ||
+ return 1
+ if ! closed_epoch=$(
+ closure_epoch \
+ "$TRANSCRIPT/$phase/closure/record.json" \
+ "$round" \
+ "$round_epoch_value" \
+ "$MIN_BEACON_LEAD_SECONDS"
+ ); then
+ return 1
+ fi
+ write_state "$STATE_DIR/$phase-round.txt" "$round" || return 1
+ write_state "$STATE_DIR/$phase-round-epoch.txt" "$round_epoch_value" ||
+ return 1
+ write_state "$STATE_DIR/$phase-closed-epoch.txt" "$closed_epoch" ||
+ return 1
+ complete_stage "$phase" || return 1
+ if [[ "$phase" == phase2 ]]; then
+ echo "OK: $phase_title closed on distinct future Quicknet round $round"
+ else
+ echo "OK: $phase_title closed on future Quicknet round $round"
+ fi
+ echo "Publish and independently timestamp the closure before waiting for the round."
+}
+
+case "$STAGE" in
+ self-test-close-recovery)
+ if [[ $# -ne 0 ]]; then
+ usage
+ fi
+ SELF_TEST_ROOT=$(mktemp -d)
+ cleanup_close_recovery_self_test() {
+ rm -rf -- "$SELF_TEST_ROOT"
+ }
+ trap cleanup_close_recovery_self_test EXIT
+
+ initialize_close_recovery_fixture() {
+ local name=$1
+ local phase=$2
+ local round=$3
+ local record_mode=$4
+ local round_epoch_value
+ local closed_epoch
+ ROOT="$SELF_TEST_ROOT/$name"
+ STATE_DIR="$ROOT/state"
+ MEASUREMENTS="$ROOT/measurements"
+ TRANSCRIPT="$ROOT/transcript"
+ KEYS="$ROOT/control/keys"
+ MPC_BIN=/bin/false
+ PARTICIPANT_COUNT=3
+ MIN_BEACON_LEAD_SECONDS=60
+ CEREMONY="$TRANSCRIPT/ceremony.json"
+ CEREMONY_SIGNATURE="$TRANSCRIPT/ceremony.sig"
+ COORDINATOR_PUBLIC_KEY="$KEYS/coordinator.ed25519.public.hex"
+ COORDINATOR_PRIVATE_KEY="$KEYS/coordinator.ed25519.private.hex"
+ PHASE1_SEAL="$TRANSCRIPT/phase1/sealed/seal.json"
+ PHASE1_SEAL_SIGNATURE="$TRANSCRIPT/phase1/sealed/seal.sig"
+ mkdir -p \
+ "$STATE_DIR/steps" \
+ "$MEASUREMENTS" \
+ "$TRANSCRIPT/$phase/closure" \
+ "$KEYS"
+ printf 'fixture\n' >"$STATE_DIR/steps/fixture.complete"
+ printf 'fixture\n' >"$STATE_DIR/steps/fixture.artifacts.sha256"
+ complete_stage "$phase-contributions"
+ round_epoch_value=$(round_epoch "$round")
+ closed_epoch=$((round_epoch_value - MIN_BEACON_LEAD_SECONDS - 2))
+ if [[ "$record_mode" == valid ]]; then
+ printf \
+ '{"beacon_round":%s,"beacon_not_before":"%s","closed_at":"%s"}\n' \
+ "$round" \
+ "$(timestamp "$round_epoch_value")" \
+ "$(timestamp "$closed_epoch")" \
+ >"$TRANSCRIPT/$phase/closure/record.json"
+ else
+ printf '{malformed closure\n' \
+ >"$TRANSCRIPT/$phase/closure/record.json"
+ fi
+ printf 'fixture signature\n' \
+ >"$TRANSCRIPT/$phase/closure/record.sig"
+ printf \
+ '{"schema":"proof-tool-mpc-command-result-v1","ok":true,"command":"%s close"}\n' \
+ "$phase" \
+ >"$MEASUREMENTS/$phase-close.attempt-0001.output.json"
+ }
+
+ initialize_close_recovery_fixture exact phase1 2 valid
+ run_close_stage phase1 2 >"$SELF_TEST_ROOT/exact.out"
+ [[ -f "$STATE_DIR/steps/phase1-close.complete" ]]
+ [[ -f "$STATE_DIR/steps/phase1-close.artifacts.sha256" ]]
+ [[ -f "$STATE_DIR/phase1.complete" ]]
+ [[ "$(tr -d '\n' <"$STATE_DIR/phase1-round.txt")" == 2 ]]
+ grep -F "RECOVER: adopted complete signed artifacts" \
+ "$SELF_TEST_ROOT/exact.out" >/dev/null
+ verify_stage_manifest phase1
+
+ initialize_close_recovery_fixture different-round phase1 2 valid
+ if run_close_stage phase1 3 \
+ >"$SELF_TEST_ROOT/different-round.out" \
+ 2>"$SELF_TEST_ROOT/different-round.err"; then
+ echo "FAIL: close recovery accepted a different requested round" >&2
+ exit 1
+ fi
+ grep -F "published closure does not bind the requested future round and lead" \
+ "$SELF_TEST_ROOT/different-round.err" >/dev/null
+ [[ ! -e "$STATE_DIR/phase1.complete" ]]
+ [[ ! -e "$STATE_DIR/phase1-round.txt" ]]
+
+ initialize_close_recovery_fixture malformed phase1 2 malformed
+ if run_close_stage phase1 2 \
+ >"$SELF_TEST_ROOT/malformed.out" \
+ 2>"$SELF_TEST_ROOT/malformed.err"; then
+ echo "FAIL: close recovery accepted a malformed closure" >&2
+ exit 1
+ fi
+ grep -F "cannot parse published closure" \
+ "$SELF_TEST_ROOT/malformed.err" >/dev/null
+ [[ ! -e "$STATE_DIR/phase1.complete" ]]
+ [[ ! -e "$STATE_DIR/phase1-round.txt" ]]
+
+ initialize_close_recovery_fixture reused phase2 2 valid
+ write_state "$STATE_DIR/phase1-round.txt" 2
+ if run_close_stage phase2 2 \
+ >"$SELF_TEST_ROOT/reused.out" \
+ 2>"$SELF_TEST_ROOT/reused.err"; then
+ echo "FAIL: Phase 2 close recovery accepted the Phase 1 round" >&2
+ exit 1
+ fi
+ grep -F "Phase 2 must use a distinct beacon round" \
+ "$SELF_TEST_ROOT/reused.err" >/dev/null
+ [[ ! -e "$STATE_DIR/phase2.complete" ]]
+ [[ ! -e "$STATE_DIR/steps/phase2-close.complete" ]]
+ echo "OK: rehearsal close-stage recovery self-test passed"
+ ;;
+
+ self-test-state)
+ if [[ $# -ne 0 ]]; then
+ usage
+ fi
+ SELF_TEST_ROOT=$(mktemp -d)
+ cleanup_self_test() {
+ rm -rf -- "$SELF_TEST_ROOT"
+ }
+ trap cleanup_self_test EXIT
+ ROOT="$SELF_TEST_ROOT"
+ STATE_DIR="$ROOT/state"
+ mkdir -p "$STATE_DIR/steps"
+ printf '2026-07-24T00:00:00Z\n' >"$STATE_DIR/phase1.complete"
+ printf '123\n' >"$STATE_DIR/phase1-round.txt"
+ printf '1692803733\n' >"$STATE_DIR/phase1-round-epoch.txt"
+ printf '1692803600\n' >"$STATE_DIR/phase1-closed-epoch.txt"
+ printf '%064d measurements/phase1-0001-contribute.attempt-0001.output.json\n' 0 \
+ >"$STATE_DIR/steps/phase1-0001-contribute.complete"
+ printf '%064d\ttranscript/demo.json\n' 0 \
+ >"$STATE_DIR/steps/phase1-0001-contribute.artifacts.sha256"
+ printf '1692803500\n' \
+ >"$STATE_DIR/steps/phase1-0001-contribute.epoch"
+ write_stage_manifest phase1
+ verify_stage_manifest phase1
+ grep -F $'\tstate/steps/phase1-0001-contribute.epoch' \
+ "$STATE_DIR/phase1.steps.sha256" >/dev/null
+ grep -F $'\tstate/phase1-round.txt' \
+ "$STATE_DIR/phase1.steps.sha256" >/dev/null
+ printf '1692803501\n' >"$STATE_DIR/steps/phase1-0001-contribute.epoch"
+ if verify_stage_manifest phase1 >/dev/null 2>&1; then
+ echo "FAIL: state self-test accepted a changed runner epoch" >&2
+ exit 1
+ fi
+ printf '1692803500\n' >"$STATE_DIR/steps/phase1-0001-contribute.epoch"
+ printf '124\n' >"$STATE_DIR/phase1-round.txt"
+ if verify_stage_manifest phase1 >/dev/null 2>&1; then
+ echo "FAIL: state self-test accepted a changed beacon round" >&2
+ exit 1
+ fi
+ echo "OK: rehearsal stage-state binding self-test passed"
+ ;;
+
+ inspect)
+ if [[ $# -ne 2 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ load_common "$ROOT_ARG" "$MPC_ARG" read-only
+ orphan_marker=$(
+ find "$STATE_DIR/steps" -maxdepth 1 -type f -name '*.artifacts.sha256' \
+ -print |
+ while IFS= read -r artifact_marker; do
+ label=$(basename "$artifact_marker" .artifacts.sha256)
+ if [[ ! -f "$STATE_DIR/steps/$label.complete" ]]; then
+ printf '%s\n' "$artifact_marker"
+ break
+ fi
+ done
+ )
+ if [[ -n "$orphan_marker" ]]; then
+ echo "FAIL: read-only inspection found an unpublished success marker: $orphan_marker" >&2
+ exit 1
+ fi
+ unmarked_result=$(
+ find "$MEASUREMENTS" -maxdepth 1 -type f \
+ -name '*.attempt-????.output.json' -print |
+ LC_ALL=C sort |
+ while IFS= read -r result_path; do
+ result_name=$(basename "$result_path")
+ result_label=${result_name%.attempt-????.output.json}
+ if [[ ! -f "$STATE_DIR/steps/$result_label.complete" ]] &&
+ validate_success_json "$result_label" "$result_path" >/dev/null 2>&1; then
+ printf '%s\n' "$result_path"
+ break
+ fi
+ done
+ )
+ if [[ -n "$unmarked_result" ]]; then
+ echo "FAIL: read-only inspection found a successful result awaiting exact recovery: $unmarked_result" >&2
+ exit 1
+ fi
+ echo "OK: all completed command outputs and generated artifacts match their resume markers"
+ echo "root=$ROOT participant_count=$PARTICIPANT_COUNT binary_sha256=$(tr -d '\n' <"$STATE_DIR/binary.sha256")"
+ for stage_name in prepare phase1-contributions phase1 phase1-beacon phase2-contributions phase2 finish; do
+ if [[ -f "$STATE_DIR/$stage_name.complete" && ! -L "$STATE_DIR/$stage_name.complete" ]]; then
+ echo "stage=$stage_name status=complete completed_at=$(tr -d '\n' <"$STATE_DIR/$stage_name.complete")"
+ else
+ echo "stage=$stage_name status=pending"
+ fi
+ done
+ echo "NOTE: inspect is read-only; it does not run the capacity probe or any ceremony command."
+ ;;
+
+ prepare)
+ if [[ $# -lt 2 || $# -gt 3 ]]; then
+ usage
+ fi
+ ROOT=$1
+ MPC_BIN=$2
+ PARTICIPANT_COUNT=${3:-3}
+ if [[ -e "$ROOT" || -L "$ROOT" ]]; then
+ echo "FAIL: prepare requires a fresh root: $ROOT" >&2
+ exit 1
+ fi
+ if [[ ! -f "$MPC_BIN" || -L "$MPC_BIN" || ! -x "$MPC_BIN" ]]; then
+ echo "FAIL: MPC binary must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ PARENT=$(dirname "$ROOT")
+ CAPACITY_TEMP=$(mktemp "$PARENT/.mpc-k21-capacity.XXXXXXXX")
+ if ! "$SCRIPT_DIR/check-mpc-k21-capacity.sh" "$PARENT" >"$CAPACITY_TEMP"; then
+ rm -f -- "$CAPACITY_TEMP"
+ exit 1
+ fi
+ sed -n '1,200p' "$CAPACITY_TEMP"
+ mkdir -m 0700 "$ROOT"
+ ROOT=$(cd "$ROOT" && pwd)
+ MPC_BIN=$(cd "$(dirname "$MPC_BIN")" && pwd)/$(basename "$MPC_BIN")
+ CONTROL="$ROOT/control"
+ TRANSCRIPT="$ROOT/transcript"
+ MEASUREMENTS="$ROOT/measurements"
+ STATE_DIR="$ROOT/state"
+ mkdir -m 0700 "$MEASUREMENTS" "$STATE_DIR"
+ mkdir -m 0700 "$STATE_DIR/steps"
+ chmod 0600 "$CAPACITY_TEMP"
+ mv -T "$CAPACITY_TEMP" "$MEASUREMENTS/prepare-capacity.txt"
+ sync -f "$MEASUREMENTS/prepare-capacity.txt"
+ REHEARSAL_BEACON_LEAD_SECONDS=${MPC_REHEARSAL_BEACON_LEAD_SECONDS:-$DEFAULT_REHEARSAL_BEACON_LEAD_SECONDS}
+ if [[ ! "$REHEARSAL_BEACON_LEAD_SECONDS" =~ ^[0-9]+$ ||
+ "$REHEARSAL_BEACON_LEAD_SECONDS" -lt "$HARD_MIN_REHEARSAL_BEACON_LEAD_SECONDS" ]]; then
+ echo "FAIL: MPC_REHEARSAL_BEACON_LEAD_SECONDS must be an integer of at least $HARD_MIN_REHEARSAL_BEACON_LEAD_SECONDS" >&2
+ exit 1
+ fi
+ if [[ -n "${MPC_REHEARSAL_CONFIG_BIN:-}" ]]; then
+ if [[ ! -f "$MPC_REHEARSAL_CONFIG_BIN" ||
+ -L "$MPC_REHEARSAL_CONFIG_BIN" ||
+ ! -x "$MPC_REHEARSAL_CONFIG_BIN" ]]; then
+ echo "FAIL: MPC_REHEARSAL_CONFIG_BIN must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ "$MPC_REHEARSAL_CONFIG_BIN" \
+ --out-dir "$CONTROL" \
+ --participants "$PARTICIPANT_COUNT" \
+ --beacon-witness-lead-seconds "$REHEARSAL_BEACON_LEAD_SECONDS"
+ write_state "$STATE_DIR/config-generator-mode.txt" "prebuilt-binary"
+ write_state \
+ "$STATE_DIR/config-generator.sha256" \
+ "$(sha256sum "$MPC_REHEARSAL_CONFIG_BIN" | cut -d ' ' -f 1)"
+ else
+ CONFIG_GOCACHE="$ROOT/.mpc-rehearsal-config-go-cache"
+ (
+ cd "$REPO_ROOT"
+ env \
+ GOCACHE="$CONFIG_GOCACHE" \
+ GOWORK=off \
+ GOFLAGS=-mod=vendor \
+ go run ./scripts/mpc-rehearsal-config \
+ --out-dir "$CONTROL" \
+ --participants "$PARTICIPANT_COUNT" \
+ --beacon-witness-lead-seconds "$REHEARSAL_BEACON_LEAD_SECONDS"
+ )
+ rm -rf -- "$CONFIG_GOCACHE"
+ CONFIG_SOURCE_HASH=$(
+ cd "$REPO_ROOT/scripts/mpc-rehearsal-config"
+ find . -maxdepth 1 -type f -print0 |
+ LC_ALL=C sort -z |
+ xargs -0 sha256sum |
+ sha256sum |
+ cut -d ' ' -f 1
+ )
+ write_state "$STATE_DIR/config-generator-mode.txt" "go-run-source"
+ write_state "$STATE_DIR/config-generator.sha256" "$CONFIG_SOURCE_HASH"
+ fi
+ CONFIG="$CONTROL/config"
+ KEYS="$CONTROL/keys"
+ MPC_HASH=$(sha256sum "$MPC_BIN" | cut -d ' ' -f 1)
+ CREATED_EPOCH=$(date +%s)
+ write_state "$STATE_DIR/binary.sha256" "$MPC_HASH"
+ write_state "$STATE_DIR/created-epoch.txt" "$CREATED_EPOCH"
+ write_state "$STATE_DIR/beacon-lead-seconds.txt" "$REHEARSAL_BEACON_LEAD_SECONDS"
+ record_finalization_evidence_generator
+ record_operational_evidence_generator
+ CEREMONY="$TRANSCRIPT/ceremony.json"
+ CEREMONY_SIGNATURE="$TRANSCRIPT/ceremony.sig"
+ COORDINATOR_PUBLIC_KEY="$KEYS/coordinator.ed25519.public.hex"
+ COORDINATOR_PRIVATE_KEY="$KEYS/coordinator.ed25519.private.hex"
+ run_step init \
+ init \
+ --mode rehearsal \
+ --created-at "$(timestamp "$CREATED_EPOCH")" \
+ --key-version ownership-destination-v2 \
+ --participants "$CONFIG/participants.json" \
+ --policy "$CONFIG/policy.json" \
+ --coordinator-key-id coordinator-key \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --out-dir "$TRANSCRIPT"
+ complete_stage prepare
+ echo "OK: prepared exact K=21 local rehearsal at $ROOT"
+ echo "WARNING: same-host identities are not independent ceremony participants."
+ ;;
+
+ phase1-contribute)
+ if [[ $# -ne 2 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ require_marker prepare
+ if [[ -e "$STATE_DIR/phase1-contributions.complete" ]]; then
+ echo "FAIL: Phase 1 contribution stage is already complete" >&2
+ exit 1
+ fi
+ common_trust_flags
+ CREATED_EPOCH=$(tr -d '\n' <"$STATE_DIR/created-epoch.txt")
+ LAST_EPOCH=$CREATED_EPOCH
+ if [[ ! -e "$CANDIDATES" && ! -L "$CANDIDATES" ]]; then
+ mkdir -m 0700 "$CANDIDATES"
+ elif [[ ! -d "$CANDIDATES" || -L "$CANDIDATES" ]]; then
+ echo "FAIL: candidate root is unsafe" >&2
+ exit 1
+ fi
+ CHAIN="$TRANSCRIPT/phase1/chain-0000.json"
+ CHAIN_SIGNATURE="$TRANSCRIPT/phase1/chain-0000.sig"
+ for index in $(seq 1 "$PARTICIPANT_COUNT"); do
+ printf -v participant_id 'participant-%02d' "$index"
+ printf -v sequence '%04d' "$index"
+ candidate="$CANDIDATES/phase1-$participant_id"
+ contributed_epoch=$(step_epoch "phase1-$sequence-contribute" "$((LAST_EPOCH + 10))")
+ run_step "phase1-$sequence-contribute" \
+ phase1 contribute \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --transcript-dir "$TRANSCRIPT" \
+ --chain "$CHAIN" \
+ --chain-signature "$CHAIN_SIGNATURE" \
+ --participant-id "$participant_id" \
+ --participant-signing-key "$KEYS/$participant_id.ed25519.private.hex" \
+ --environment "$CONFIG/environment.json" \
+ --contributed-at "$(timestamp "$contributed_epoch")" \
+ --out-dir "$candidate"
+ LAST_EPOCH=$contributed_epoch
+ destroyed_epoch=$(step_epoch "phase1-$sequence-erasure" "$((LAST_EPOCH + 10))")
+ run_step "phase1-$sequence-erasure" \
+ phase1 attest-erasure \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --participant-id "$participant_id" \
+ --participant-signing-key "$KEYS/$participant_id.ed25519.private.hex" \
+ --candidate-dir "$candidate" \
+ --destroyed-at "$(timestamp "$destroyed_epoch")"
+ LAST_EPOCH=$destroyed_epoch
+ accepted_epoch=$(step_epoch "phase1-$sequence-verify" "$((LAST_EPOCH + 10))")
+ run_step "phase1-$sequence-verify" \
+ phase1 verify \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --transcript-dir "$TRANSCRIPT" \
+ --chain "$CHAIN" \
+ --chain-signature "$CHAIN_SIGNATURE" \
+ --candidate-dir "$candidate" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --accepted-at "$(timestamp "$accepted_epoch")"
+ LAST_EPOCH=$accepted_epoch
+ CHAIN="$TRANSCRIPT/phase1/chain-$sequence.json"
+ CHAIN_SIGNATURE="$TRANSCRIPT/phase1/chain-$sequence.sig"
+ done
+ complete_stage phase1-contributions
+ echo "OK: completed all Phase 1 contributions; select the future beacon round now."
+ ;;
+
+ phase1-close)
+ if [[ $# -ne 3 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ ROUND=$3
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ run_close_stage phase1 "$ROUND"
+ ;;
+
+ phase1-beacon)
+ if [[ $# -ne 4 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ RAW_RESPONSE=$3
+ PUBLISHED_AT=$4
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ require_marker phase1
+ if [[ -e "$STATE_DIR/phase1-beacon.complete" ]]; then
+ echo "FAIL: Phase 1 beacon stage is already complete" >&2
+ exit 1
+ fi
+ if [[ ! -f "$RAW_RESPONSE" || -L "$RAW_RESPONSE" ]]; then
+ echo "FAIL: raw response must be a regular file, not a symlink" >&2
+ exit 1
+ fi
+ PUBLISHED_EPOCH=$(date -u -d "$PUBLISHED_AT" +%s)
+ if (( PUBLISHED_EPOCH > $(date +%s) + 5 )); then
+ echo "FAIL: Phase 1 published-at is more than five seconds in the future" >&2
+ exit 1
+ fi
+ ROUND_EPOCH=$(tr -d '\n' <"$STATE_DIR/phase1-round-epoch.txt")
+ if (( PUBLISHED_EPOCH < ROUND_EPOCH )); then
+ echo "FAIL: Phase 1 publication time predates the committed round" >&2
+ exit 1
+ fi
+ write_state "$STATE_DIR/phase1-published-at.txt" "$PUBLISHED_AT"
+ common_trust_flags
+ run_step phase1-beacon \
+ phase1 beacon \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --closure "$TRANSCRIPT/phase1/closure/record.json" \
+ --closure-signature "$TRANSCRIPT/phase1/closure/record.sig" \
+ --raw-response "$RAW_RESPONSE" \
+ --published-at "$PUBLISHED_AT" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --transcript-dir "$TRANSCRIPT"
+ run_step phase1-seal \
+ phase1 seal \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --transcript-dir "$TRANSCRIPT" \
+ --closure "$TRANSCRIPT/phase1/closure/record.json" \
+ --closure-signature "$TRANSCRIPT/phase1/closure/record.sig" \
+ --beacon "$TRANSCRIPT/phase1/beacon/record.json" \
+ --beacon-signature "$TRANSCRIPT/phase1/beacon/record.sig" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --out-dir "$TRANSCRIPT/phase1/sealed"
+ run_step phase2-init \
+ phase2 init \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --phase1-transcript-dir "$TRANSCRIPT" \
+ --phase1-seal "$PHASE1_SEAL" \
+ --phase1-seal-signature "$PHASE1_SEAL_SIGNATURE" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --out-dir "$TRANSCRIPT/phase2"
+ write_state "$STATE_DIR/phase1-published-epoch.txt" "$PUBLISHED_EPOCH"
+ complete_stage phase1-beacon
+ echo "OK: verified Phase 1 beacon, sealed Phase 1, and initialized Phase 2"
+ ;;
+
+ phase2-contribute)
+ if [[ $# -ne 2 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ require_marker phase1-beacon
+ if [[ -e "$STATE_DIR/phase2-contributions.complete" ]]; then
+ echo "FAIL: Phase 2 contribution stage is already complete" >&2
+ exit 1
+ fi
+ common_trust_flags
+ PUBLISHED_EPOCH=$(tr -d '\n' <"$STATE_DIR/phase1-published-epoch.txt")
+ LAST_EPOCH=$PUBLISHED_EPOCH
+ CHAIN="$TRANSCRIPT/phase2/chain-0000.json"
+ CHAIN_SIGNATURE="$TRANSCRIPT/phase2/chain-0000.sig"
+ for index in $(seq 1 "$PARTICIPANT_COUNT"); do
+ printf -v participant_id 'participant-%02d' "$index"
+ printf -v sequence '%04d' "$index"
+ candidate="$CANDIDATES/phase2-$participant_id"
+ contributed_epoch=$(step_epoch "phase2-$sequence-contribute" "$((LAST_EPOCH + 10))")
+ run_step "phase2-$sequence-contribute" \
+ phase2 contribute \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --phase1-seal "$PHASE1_SEAL" \
+ --phase1-seal-signature "$PHASE1_SEAL_SIGNATURE" \
+ --transcript-dir "$TRANSCRIPT" \
+ --chain "$CHAIN" \
+ --chain-signature "$CHAIN_SIGNATURE" \
+ --participant-id "$participant_id" \
+ --participant-signing-key "$KEYS/$participant_id.ed25519.private.hex" \
+ --environment "$CONFIG/environment.json" \
+ --contributed-at "$(timestamp "$contributed_epoch")" \
+ --out-dir "$candidate"
+ LAST_EPOCH=$contributed_epoch
+ destroyed_epoch=$(step_epoch "phase2-$sequence-erasure" "$((LAST_EPOCH + 10))")
+ run_step "phase2-$sequence-erasure" \
+ phase2 attest-erasure \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --participant-id "$participant_id" \
+ --participant-signing-key "$KEYS/$participant_id.ed25519.private.hex" \
+ --candidate-dir "$candidate" \
+ --destroyed-at "$(timestamp "$destroyed_epoch")"
+ LAST_EPOCH=$destroyed_epoch
+ accepted_epoch=$(step_epoch "phase2-$sequence-verify" "$((LAST_EPOCH + 10))")
+ run_step "phase2-$sequence-verify" \
+ phase2 verify \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --phase1-seal "$PHASE1_SEAL" \
+ --phase1-seal-signature "$PHASE1_SEAL_SIGNATURE" \
+ --transcript-dir "$TRANSCRIPT" \
+ --chain "$CHAIN" \
+ --chain-signature "$CHAIN_SIGNATURE" \
+ --candidate-dir "$candidate" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --accepted-at "$(timestamp "$accepted_epoch")"
+ LAST_EPOCH=$accepted_epoch
+ CHAIN="$TRANSCRIPT/phase2/chain-$sequence.json"
+ CHAIN_SIGNATURE="$TRANSCRIPT/phase2/chain-$sequence.sig"
+ done
+ complete_stage phase2-contributions
+ echo "OK: completed all Phase 2 contributions; select the distinct future beacon round now."
+ ;;
+
+ phase2-close)
+ if [[ $# -ne 3 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ ROUND=$3
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ run_close_stage phase2 "$ROUND"
+ ;;
+
+ finish)
+ if [[ $# -ne 6 ]]; then
+ usage
+ fi
+ ROOT_ARG=$1
+ MPC_ARG=$2
+ RAW_RESPONSE=$3
+ PUBLISHED_AT=$4
+ PHASE1_RELAY_DIR=$5
+ PHASE2_RELAY_DIR=$6
+ load_common "$ROOT_ARG" "$MPC_ARG"
+ require_marker phase2
+ if [[ -e "$STATE_DIR/finish.complete" ]]; then
+ echo "FAIL: finish stage is already complete" >&2
+ exit 1
+ fi
+ if [[ ! -f "$RAW_RESPONSE" || -L "$RAW_RESPONSE" ]]; then
+ echo "FAIL: raw response must be a regular file, not a symlink" >&2
+ exit 1
+ fi
+ record_relay_inputs phase1 "$PHASE1_RELAY_DIR"
+ record_relay_inputs phase2 "$PHASE2_RELAY_DIR"
+ PHASE1_RELAY_DIR=$(cd "$PHASE1_RELAY_DIR" && pwd)
+ PHASE2_RELAY_DIR=$(cd "$PHASE2_RELAY_DIR" && pwd)
+ PUBLISHED_EPOCH=$(date -u -d "$PUBLISHED_AT" +%s)
+ if (( PUBLISHED_EPOCH > $(date +%s) + 5 )); then
+ echo "FAIL: Phase 2 published-at is more than five seconds in the future" >&2
+ exit 1
+ fi
+ ROUND_EPOCH=$(tr -d '\n' <"$STATE_DIR/phase2-round-epoch.txt")
+ if (( PUBLISHED_EPOCH < ROUND_EPOCH )); then
+ echo "FAIL: Phase 2 publication time predates the committed round" >&2
+ exit 1
+ fi
+ write_state "$STATE_DIR/phase2-published-at.txt" "$PUBLISHED_AT"
+ common_trust_flags
+ replay_flags
+ run_step phase2-beacon \
+ phase2 beacon \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --closure "$TRANSCRIPT/phase2/closure/record.json" \
+ --closure-signature "$TRANSCRIPT/phase2/closure/record.sig" \
+ --raw-response "$RAW_RESPONSE" \
+ --published-at "$PUBLISHED_AT" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --transcript-dir "$TRANSCRIPT"
+ PREPARED_EPOCH=$(step_epoch finalize-prepare "$((PUBLISHED_EPOCH + 1))")
+ run_step finalize-prepare \
+ finalize prepare \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ "${REPLAY_FLAGS[@]}" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --prepared-at "$(timestamp "$PREPARED_EPOCH")" \
+ --out-dir "$PRELIMINARY_KEYS"
+ CEREMONY_ID=$(
+ node - "$CEREMONY" <<'NODE'
+const fs = require("node:fs");
+const definition = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
+if (typeof definition.ceremony_id !== "string" ||
+ !/^sha256:[0-9a-f]{64}$/.test(definition.ceremony_id)) {
+ throw new Error("ceremony.json has an invalid ceremony_id");
+}
+process.stdout.write(definition.ceremony_id);
+NODE
+ )
+ resolve_finalization_evidence_generator
+ run_measured_json_step public-evidence-generate \
+ "${FINALIZATION_EVIDENCE_COMMAND[@]}" \
+ --keys-dir "$PRELIMINARY_KEYS" \
+ --ceremony-id "$CEREMONY_ID" \
+ --coordinator-public-key-file "$COORDINATOR_PUBLIC_KEY" \
+ --out "$PUBLIC_FINALIZATION_EVIDENCE"
+ if [[ -n "$FINALIZATION_GOCACHE" && -d "$FINALIZATION_GOCACHE" ]]; then
+ rm -rf -- "$FINALIZATION_GOCACHE"
+ fi
+ FINALIZED_EPOCH=$(step_epoch finalize-complete "$((PREPARED_EPOCH + 1))")
+ run_step finalize-complete \
+ finalize complete \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ "${REPLAY_FLAGS[@]}" \
+ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \
+ --public-evidence "$PUBLIC_FINALIZATION_EVIDENCE" \
+ --finalized-at "$(timestamp "$FINALIZED_EPOCH")" \
+ --out-dir "$FINAL_CANDIDATE"
+ PLUTUS_EVIDENCE_ARGS=("$FINAL_CANDIDATE")
+ if [[ -n "${MPC_PLUTUS_VERIFIER_BIN:-}" ]]; then
+ if [[ ! -f "$MPC_PLUTUS_VERIFIER_BIN" ||
+ -L "$MPC_PLUTUS_VERIFIER_BIN" ||
+ ! -x "$MPC_PLUTUS_VERIFIER_BIN" ]]; then
+ echo "FAIL: MPC_PLUTUS_VERIFIER_BIN must be an executable regular file, not a symlink" >&2
+ exit 1
+ fi
+ PLUTUS_EVIDENCE_ARGS+=("$MPC_PLUTUS_VERIFIER_BIN")
+ fi
+ run_measured_json_step final-plutus-evidence \
+ "$SCRIPT_DIR/verify-mpc-final-plutus-evidence.sh" \
+ "${PLUTUS_EVIDENCE_ARGS[@]}"
+ LATEST_RELAY_EPOCH=$(max_epoch "$PHASE1_RELAY_LATEST_EPOCH" "$PHASE2_RELAY_LATEST_EPOCH")
+ OPERATIONAL_EVIDENCE_MINIMUM=$(max_epoch \
+ "$((FINALIZED_EPOCH + 1))" \
+ "$((LATEST_RELAY_EPOCH + 2))")
+ OPERATIONAL_EVIDENCE_EPOCH=$(
+ step_epoch operational-evidence-generate "$OPERATIONAL_EVIDENCE_MINIMUM"
+ )
+ run_measured_json_step operational-evidence-generate \
+ "${OPERATIONAL_EVIDENCE_COMMAND[@]}" \
+ --transcript-root "$TRANSCRIPT" \
+ --keys-dir "$KEYS" \
+ --coordinator-public-key-file "$COORDINATOR_PUBLIC_KEY" \
+ --phase1-relays "$PHASE1_RELAY_DIR" \
+ --phase2-relays "$PHASE2_RELAY_DIR" \
+ --assembled-at "$(timestamp "$OPERATIONAL_EVIDENCE_EPOCH")" \
+ --out-dir "$TRANSCRIPT/operational"
+ run_step operational-evidence-verify \
+ ops verify \
+ --record-type evidence-bundle \
+ --record "$TRANSCRIPT/operational/evidence-bundle.json" \
+ --signature "$TRANSCRIPT/operational/evidence-bundle.sig" \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --signer-public-key-file "$COORDINATOR_PUBLIC_KEY" \
+ --evidence-root "$TRANSCRIPT"
+ if [[ ! -e "$AUDITS" && ! -L "$AUDITS" ]]; then
+ mkdir -m 0700 "$AUDITS"
+ elif [[ ! -d "$AUDITS" || -L "$AUDITS" ]]; then
+ echo "FAIL: audit root is unsafe" >&2
+ exit 1
+ fi
+ AUDIT1_EPOCH=$(step_epoch audit-01 "$((OPERATIONAL_EVIDENCE_EPOCH + 1))")
+ run_step audit-01 \
+ audit \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ "${REPLAY_FLAGS[@]}" \
+ --candidate-bundle "$FINAL_CANDIDATE" \
+ --auditor-id auditor-01 \
+ --auditor-signing-key "$KEYS/auditor-01.ed25519.private.hex" \
+ --audited-at "$(timestamp "$AUDIT1_EPOCH")" \
+ --out "$AUDITS/auditor-01.json" \
+ --audit-signature "$AUDITS/auditor-01.sig"
+ AUDIT2_EPOCH=$(step_epoch audit-02 "$((AUDIT1_EPOCH + 1))")
+ run_step audit-02 \
+ audit \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ "${REPLAY_FLAGS[@]}" \
+ --candidate-bundle "$FINAL_CANDIDATE" \
+ --auditor-id auditor-02 \
+ --auditor-signing-key "$KEYS/auditor-02.ed25519.private.hex" \
+ --audited-at "$(timestamp "$AUDIT2_EPOCH")" \
+ --out "$AUDITS/auditor-02.json" \
+ --audit-signature "$AUDITS/auditor-02.sig"
+ RELEASED_EPOCH=$(step_epoch release-sign "$((AUDIT2_EPOCH + 1))")
+ run_step release-sign \
+ release sign \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --candidate-bundle "$FINAL_CANDIDATE" \
+ --audit-report "$AUDITS/auditor-01.json" \
+ --audit-signature "$AUDITS/auditor-01.sig" \
+ --audit-report "$AUDITS/auditor-02.json" \
+ --audit-signature "$AUDITS/auditor-02.sig" \
+ --operational-evidence-root "$TRANSCRIPT" \
+ --operational-bundle "$TRANSCRIPT/operational/evidence-bundle.json" \
+ --operational-bundle-signature "$TRANSCRIPT/operational/evidence-bundle.sig" \
+ --release-signing-key "$KEYS/release-signer.ed25519.private.hex" \
+ --signature-key-id release-signer-key \
+ --released-at "$(timestamp "$RELEASED_EPOCH")" \
+ --release-dir "$RELEASE_DIR"
+ run_step release-verify \
+ release verify \
+ "${COMMON_TRUST_FLAGS[@]}" \
+ --keys-dir "$RELEASE_DIR" \
+ --manifest-public-key-file "$KEYS/release-signer.ed25519.public.hex" \
+ --signature-key-id release-signer-key
+ (
+ cd "$ROOT"
+ find . -type f \
+ ! -path './control/keys/*.private.hex' \
+ -printf '%s\t%P\n' |
+ LC_ALL=C sort -k2,2 >"$MEASUREMENTS/artifact-sizes.tsv"
+ )
+ du -sb \
+ "$CONTROL" \
+ "$TRANSCRIPT" \
+ "$CANDIDATES" \
+ "$PRELIMINARY_KEYS" \
+ "$FINAL_CANDIDATE" \
+ "$AUDITS" \
+ "$RELEASE_DIR" \
+ >"$MEASUREMENTS/retained-directory-sizes.txt"
+ df -B1 "$ROOT" >"$MEASUREMENTS/final-filesystem-capacity.txt"
+ sync -f "$MEASUREMENTS/artifact-sizes.tsv"
+ sync -f "$MEASUREMENTS/retained-directory-sizes.txt"
+ sync -f "$MEASUREMENTS/final-filesystem-capacity.txt"
+ complete_stage finish
+ echo "OK: exact K=21 local rehearsal finalized, audited twice, signed, and verified"
+ echo "WARNING: this same-host run does not satisfy independent participant, auditor, or witness gates."
+ ;;
+
+ *)
+ usage
+ ;;
+esac
diff --git a/scripts/sign-ed25519-file/main.go b/scripts/sign-ed25519-file/main.go
new file mode 100644
index 00000000..f98437e4
--- /dev/null
+++ b/scripts/sign-ed25519-file/main.go
@@ -0,0 +1,117 @@
+// Command sign-ed25519-file creates a deterministic detached Ed25519 signature
+// for an exact regular file using an existing private key. It never creates a
+// key and rejects permissive secret-key modes.
+package main
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "runtime"
+ "strings"
+)
+
+func main() {
+ input := flag.String("input", "", "exact file to sign")
+ privateKeyPath := flag.String("private-key", "", "existing Ed25519 seed or private key in hex")
+ signatureOut := flag.String("signature-out", "", "fresh detached signature output")
+ publicKeyOut := flag.String("public-key-out", "", "fresh public-key output")
+ flag.Parse()
+ if flag.NArg() != 0 || *input == "" || *privateKeyPath == "" ||
+ *signatureOut == "" || *publicKeyOut == "" || *signatureOut == *publicKeyOut {
+ fatal(errors.New("usage: sign-ed25519-file --input FILE --private-key KEY --signature-out FILE --public-key-out FILE"))
+ }
+ data, err := readRegular(*input, false)
+ if err != nil {
+ fatal(err)
+ }
+ keyHex, err := readRegular(*privateKeyPath, true)
+ if err != nil {
+ fatal(err)
+ }
+ defer clear(keyHex)
+ raw, err := hex.DecodeString(strings.TrimSpace(string(keyHex)))
+ if err != nil {
+ fatal(err)
+ }
+ defer clear(raw)
+ var privateKey ed25519.PrivateKey
+ switch len(raw) {
+ case ed25519.SeedSize:
+ privateKey = ed25519.NewKeyFromSeed(raw)
+ case ed25519.PrivateKeySize:
+ privateKey = ed25519.NewKeyFromSeed(raw[:ed25519.SeedSize])
+ if !bytes.Equal(raw, privateKey) {
+ fatal(errors.New("Ed25519 private-key public half does not match its seed"))
+ }
+ default:
+ fatal(fmt.Errorf("Ed25519 key is %d bytes, want %d-byte seed or %d-byte private key", len(raw), ed25519.SeedSize, ed25519.PrivateKeySize))
+ }
+ defer clear(privateKey)
+ publicKey := privateKey.Public().(ed25519.PublicKey)
+ signature := ed25519.Sign(privateKey, data)
+ writeFresh(*signatureOut, []byte(hex.EncodeToString(signature)+"\n"))
+ writeFresh(*publicKeyOut, []byte(hex.EncodeToString(publicKey)+"\n"))
+}
+
+func readRegular(path string, secret bool) ([]byte, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, err
+ }
+ if !linkInfo.Mode().IsRegular() || linkInfo.Size() <= 0 || linkInfo.Size() > 64<<20 {
+ return nil, errors.New("input must be a bounded non-empty regular file")
+ }
+ if secret && runtime.GOOS != "windows" && linkInfo.Mode().Perm()&0o077 != 0 {
+ return nil, errors.New("private key has group/world permission bits")
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !info.Mode().IsRegular() || !os.SameFile(linkInfo, info) {
+ return nil, errors.New("input changed while being opened")
+ }
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) != info.Size() {
+ return nil, errors.New("input changed while being read")
+ }
+ return data, nil
+}
+
+func writeFresh(path string, data []byte) {
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fs.FileMode(0o600))
+ if err != nil {
+ fatal(err)
+ }
+ if _, err := file.Write(data); err != nil {
+ _ = file.Close()
+ fatal(err)
+ }
+ if err := file.Sync(); err != nil {
+ _ = file.Close()
+ fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ fatal(err)
+ }
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
diff --git a/scripts/test-all.sh b/scripts/test-all.sh
index f0400b93..294b5463 100755
--- a/scripts/test-all.sh
+++ b/scripts/test-all.sh
@@ -53,6 +53,10 @@ fi
run_step "go build" go build ./...
run_step "go vet" go vet ./...
+run_step "MPC rehearsal state self-test" \
+ bash scripts/run-mpc-k21-local-rehearsal.sh self-test-state
+run_step "MPC rehearsal close recovery self-test" \
+ bash scripts/run-mpc-k21-local-rehearsal.sh self-test-close-recovery
if command -v golangci-lint >/dev/null 2>&1; then
run_step "golangci-lint" golangci-lint run ./...
@@ -62,9 +66,9 @@ fi
if [[ "$FAST" == "1" ]]; then
run_step "go test (fast: skips circuit compile suites)" \
- go test ./cmd/... ./internal/prover/... ./internal/verifier/... ./internal/helper/... \
+ go test -short ./cmd/... ./internal/prover/... ./internal/verifier/... ./internal/helper/... \
./internal/msmengine/... ./internal/streampk/... ./internal/streamprove/... \
- ./internal/proofassets/... ./internal/batchtranscript/...
+ ./internal/proofassets/... ./internal/batchtranscript/... ./internal/mpcceremony/...
else
# PROOF_TOOL_RUN_FULL_PROOF=1 un-gates the real Groth16 ownership /
# multi / destination round-trip integration tests (positive + tamper
diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go
new file mode 100644
index 00000000..9c640d40
--- /dev/null
+++ b/scripts/verify-mpc-build-metadata/main.go
@@ -0,0 +1,924 @@
+// Command verify-mpc-build-metadata semantically verifies one reproducible MPC
+// build package. It is intentionally stricter than a directory diff: every
+// build-profile, tag, SBOM, and root-manifest field must agree with caller
+// supplied production identity.
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "debug/buildinfo"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime/debug"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+
+ "golang.org/x/crypto/blake2b"
+)
+
+const (
+ productionGoVersion = "go1.26.5"
+ expectedBuildFlags = "-mod=vendor\x00-trimpath\x00-buildvcs=true\x00-ldflags=-buildid="
+)
+
+var (
+ lowerCommitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
+ fingerprintPattern = regexp.MustCompile(`^([0-9A-F]{40}|[0-9A-F]{64})$`)
+ lowerSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
+ rootFileNames = []string{
+ "binary-manifest.json",
+ "build-mode.txt",
+ "checksums.blake2b256",
+ "checksums.sha256",
+ "go-build-info.txt",
+ "finalization-evidence-binary-manifest.json",
+ "finalization-evidence-go-build-info.txt",
+ "finalization-evidence-sbom.cdx.json",
+ "mpc-finalization-evidence",
+ "mpc-ceremony",
+ "sbom.cdx.json",
+ "signed-tag-object.txt",
+ "signed-tag-signer-fingerprint.txt",
+ "signed-tag-status.txt",
+ "signed-tag.txt",
+ "source-checksums.sha256",
+ "source-commit.txt",
+ "source-date-epoch.txt",
+ "toolchain-checksums.sha256",
+ "vendor-checksums.sha256",
+ }
+ gnarkPatchNames = []string{
+ "prove-stream.patch",
+ "domain-read-no-precompute.patch",
+ "release-ccs-after-solve.patch",
+ "dispatch-before-fft.patch",
+ "computeh-scoped-coset-tables.patch",
+ "uints-constant-fold.patch",
+ "computeh-parallel-transforms.patch",
+ }
+)
+
+type digestEntry struct {
+ Filename string `json:"filename"`
+ SizeBytes int64 `json:"size_bytes"`
+ SHA256 string `json:"sha256"`
+ Blake2b256 string `json:"blake2b256"`
+}
+
+type digestManifest struct {
+ GoVersion string `json:"go_version"`
+ BuildFlags []string `json:"build_flags"`
+ Files []digestEntry `json:"files"`
+}
+
+type property struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type componentHash struct {
+ Algorithm string `json:"alg"`
+ Content string `json:"content"`
+}
+
+type component struct {
+ Type string `json:"type"`
+ Group string `json:"group,omitempty"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ BOMRef string `json:"bom-ref,omitempty"`
+ PURL string `json:"purl,omitempty"`
+ Hashes []componentHash `json:"hashes"`
+ Properties []property `json:"properties"`
+}
+
+type sbom struct {
+ BOMFormat string `json:"bomFormat"`
+ SpecVersion string `json:"specVersion"`
+ Version int `json:"version"`
+ Metadata struct {
+ Tools struct {
+ Components []component `json:"components"`
+ } `json:"tools"`
+ Component component `json:"component"`
+ Properties []property `json:"properties"`
+ } `json:"metadata"`
+ Components []component `json:"components"`
+}
+
+func main() {
+ dir := flag.String("dir", "", "build package directory")
+ mode := flag.String("mode", "", "expected build mode")
+ commit := flag.String("commit", "", "expected lowercase 40-character source commit")
+ tag := flag.String("tag", "", "expected signed production tag or none")
+ fingerprint := flag.String("tag-signer-fingerprint", "", "expected uppercase tag signer fingerprint or none")
+ sourceRoot := flag.String("source-root", "", "exact clean source checkout used to independently verify source and SBOM identities")
+ trustedBuildPublicKey := flag.String("trusted-build-public-key-file", "", "out-of-band trusted Ed25519 build public key or none")
+ flag.Parse()
+ if flag.NArg() != 0 || *dir == "" || (*mode != "production" && *mode != "rehearsal") ||
+ !lowerCommitPattern.MatchString(*commit) || *tag == "" || *fingerprint == "" ||
+ *sourceRoot == "" || *trustedBuildPublicKey == "" {
+ fatal(errors.New("usage: verify-mpc-build-metadata --dir DIR --mode production|rehearsal --commit COMMIT --tag TAG|none --tag-signer-fingerprint HEX|none --source-root DIR --trusted-build-public-key-file FILE|none"))
+ }
+ if (*mode == "production" && *trustedBuildPublicKey == "none") ||
+ (*mode == "rehearsal" && *trustedBuildPublicKey != "none") {
+ fatal(errors.New("production requires an out-of-band trusted build public key; rehearsal requires none"))
+ }
+ if err := verifyPlainIdentity(*dir, *mode, *commit, *tag, *fingerprint); err != nil {
+ fatal(err)
+ }
+ if err := verifySourceCheckout(*sourceRoot, *dir, *commit); err != nil {
+ fatal(err)
+ }
+ ceremonyManifest, err := readDigestManifest(filepath.Join(*dir, "binary-manifest.json"))
+ if err != nil {
+ fatal(err)
+ }
+ if err := verifyBinaryManifest(*dir, ceremonyManifest, "mpc-ceremony"); err != nil {
+ fatal(err)
+ }
+ evidenceManifest, err := readDigestManifest(
+ filepath.Join(*dir, "finalization-evidence-binary-manifest.json"),
+ )
+ if err != nil {
+ fatal(err)
+ }
+ if err := verifyBinaryManifest(*dir, evidenceManifest, "mpc-finalization-evidence"); err != nil {
+ fatal(err)
+ }
+ if err := verifyBinaryChecksums(*dir, ceremonyManifest.Files[0], evidenceManifest.Files[0]); err != nil {
+ fatal(err)
+ }
+ if err := verifyBuildInfo(filepath.Join(*dir, "mpc-ceremony"), *commit); err != nil {
+ fatal(err)
+ }
+ if err := verifyBuildInfo(filepath.Join(*dir, "mpc-finalization-evidence"), *commit); err != nil {
+ fatal(err)
+ }
+ if err := verifySBOM(
+ filepath.Join(*dir, "sbom.cdx.json"),
+ *sourceRoot,
+ *commit,
+ "mpc-ceremony",
+ ); err != nil {
+ fatal(err)
+ }
+ if err := verifySBOM(
+ filepath.Join(*dir, "finalization-evidence-sbom.cdx.json"),
+ *sourceRoot,
+ *commit,
+ "mpc-finalization-evidence",
+ ); err != nil {
+ fatal(err)
+ }
+ root, err := readDigestManifest(filepath.Join(*dir, "build-package-manifest.json"))
+ if err != nil {
+ fatal(err)
+ }
+ if err := verifyRootManifest(*dir, root); err != nil {
+ fatal(err)
+ }
+ if err := verifyBuildSignature(*dir, *mode, *trustedBuildPublicKey); err != nil {
+ fatal(err)
+ }
+}
+
+func verifyPlainIdentity(dir, mode, commit, tag, fingerprint string) error {
+ values := map[string]string{
+ "build-mode.txt": mode,
+ "source-commit.txt": commit,
+ "signed-tag.txt": tag,
+ "signed-tag-signer-fingerprint.txt": fingerprint,
+ }
+ for name, expected := range values {
+ actual, err := readOneLine(filepath.Join(dir, name))
+ if err != nil {
+ return err
+ }
+ if actual != expected {
+ return fmt.Errorf("%s is %q, want %q", name, actual, expected)
+ }
+ }
+ status, err := readOneLine(filepath.Join(dir, "signed-tag-status.txt"))
+ if err != nil {
+ return err
+ }
+ tagObject, err := readOneLine(filepath.Join(dir, "signed-tag-object.txt"))
+ if err != nil {
+ return err
+ }
+ if mode == "production" {
+ if tag == "none" || !fingerprintPattern.MatchString(fingerprint) ||
+ status != "verified" || !lowerCommitPattern.MatchString(tagObject) {
+ return errors.New("production package does not contain an exact verified signed-tag identity")
+ }
+ } else if tag != "none" || fingerprint != "none" ||
+ status != "not-required-for-rehearsal" || tagObject != "none" {
+ return errors.New("untagged rehearsal package contains inconsistent signed-tag identity")
+ }
+ epoch, err := readOneLine(filepath.Join(dir, "source-date-epoch.txt"))
+ if err != nil {
+ return err
+ }
+ parsedEpoch, err := strconv.ParseInt(epoch, 10, 64)
+ if err != nil || parsedEpoch <= 0 {
+ return errors.New("source-date-epoch.txt is not a positive Unix timestamp")
+ }
+ return verifyToolchainChecksums(filepath.Join(dir, "toolchain-checksums.sha256"))
+}
+
+func verifyToolchainChecksums(path string) error {
+ const expected = "" +
+ "8da5fd321795754b994c64e3eb8a5a14ff47bd285559a7e876f3c79abafc67f9 go\n" +
+ "10c67b9de41c1e546b9bf416ceef410e5e3dd87a76d129b08b74a9570db9c463 compile\n" +
+ "e58a36e6550a32ed7175cd6e2a1824dc66c034d1e3539ebeac8af719a9150d5d link\n" +
+ "0c9a07447aba3ed1df7a0a3e85f6e003d9bf312d2936dfc4b79e3d81e8ca7636 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 nil
+}
+
+func verifyBinaryManifest(dir string, manifest digestManifest, binaryName string) error {
+ if manifest.GoVersion != productionGoVersion ||
+ strings.Join(manifest.BuildFlags, "\x00") != expectedBuildFlags ||
+ len(manifest.Files) != 1 ||
+ manifest.Files[0].Filename != binaryName {
+ return fmt.Errorf("binary manifest for %s does not describe the exact production build profile", binaryName)
+ }
+ return verifyDigestEntry(dir, manifest.Files[0])
+}
+
+func verifyBinaryChecksums(dir string, entries ...digestEntry) error {
+ var shaLines []string
+ var blakeLines []string
+ for _, entry := range entries {
+ shaLines = append(shaLines, entry.SHA256+" "+entry.Filename)
+ blakeLines = append(blakeLines, entry.Blake2b256+" "+entry.Filename)
+ }
+ expectedSHA := strings.Join(shaLines, "\n") + "\n"
+ expectedBlake := strings.Join(blakeLines, "\n") + "\n"
+ actualSHA, err := os.ReadFile(filepath.Join(dir, "checksums.sha256"))
+ if err != nil {
+ return err
+ }
+ actualBlake, err := os.ReadFile(filepath.Join(dir, "checksums.blake2b256"))
+ if err != nil {
+ return err
+ }
+ if string(actualSHA) != expectedSHA || string(actualBlake) != expectedBlake {
+ return errors.New("binary checksum files do not exactly match both binary manifests")
+ }
+ return nil
+}
+
+func verifyBuildInfo(path, commit string) error {
+ info, err := buildinfo.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if info.GoVersion != productionGoVersion {
+ return fmt.Errorf("binary Go version is %q, want %q", info.GoVersion, productionGoVersion)
+ }
+ expected := map[string]string{
+ "-buildmode": "exe",
+ "-compiler": "gc",
+ "-trimpath": "true",
+ "CGO_ENABLED": "0",
+ "GOARCH": "amd64",
+ "GOOS": "linux",
+ "GOAMD64": "v1",
+ "vcs": "git",
+ "vcs.modified": "false",
+ "vcs.revision": commit,
+ }
+ for key, value := range expected {
+ actual, err := uniqueSetting(info, key)
+ if err != nil {
+ return err
+ }
+ if actual != value {
+ return fmt.Errorf("binary build setting %s is %q, want %q", key, actual, value)
+ }
+ }
+ return nil
+}
+
+func verifySBOM(path, sourceRoot, commit, binaryName string) error {
+ var value sbom
+ if err := readStrictJSON(path, &value); err != nil {
+ return err
+ }
+ if value.BOMFormat != "CycloneDX" || value.SpecVersion != "1.5" || value.Version != 1 {
+ return errors.New("SBOM is not exact CycloneDX 1.5")
+ }
+ if len(value.Metadata.Tools.Components) != 1 ||
+ !exactSBOMComponent(
+ value.Metadata.Tools.Components[0],
+ "application",
+ "proof-tool/scripts/generate-go-sbom",
+ commit,
+ "",
+ ) {
+ return errors.New("SBOM generator identity is not exact")
+ }
+ if !exactSBOMComponent(
+ value.Metadata.Component,
+ "application",
+ binaryName,
+ commit,
+ "pkg:golang/proof-tool/"+binaryName+"@"+commit,
+ ) {
+ return errors.New("SBOM application identity is not exact")
+ }
+ requiredMetadata := map[string]string{
+ "proof-tool:go-version": productionGoVersion,
+ "proof-tool:source-commit": commit,
+ "proof-tool:vcs-modified": "false",
+ }
+ if len(value.Metadata.Properties) != len(requiredMetadata) {
+ return errors.New("SBOM contains an unexpected metadata property set")
+ }
+ for name, expected := range requiredMetadata {
+ actual, err := uniqueProperty(value.Metadata.Properties, name)
+ if err != nil || actual != expected {
+ return fmt.Errorf("SBOM metadata property %s does not equal %q", name, expected)
+ }
+ }
+ info, err := buildinfo.ReadFile(filepath.Join(filepath.Dir(path), binaryName))
+ if err != nil {
+ return err
+ }
+ linked := make(map[string]string, len(info.Deps))
+ for _, dependency := range info.Deps {
+ if dependency == nil || dependency.Replace != nil {
+ return errors.New("binary contains an invalid or replaced dependency")
+ }
+ linked[dependency.Path] = dependency.Version
+ }
+ if len(value.Components) != len(linked) {
+ return errors.New("SBOM component set does not equal linked module set")
+ }
+ moduleSums, err := readModuleSums(filepath.Join(sourceRoot, "go.sum"))
+ if err != nil {
+ return err
+ }
+ patchDigests, err := gnarkPatchDigests(sourceRoot)
+ if err != nil {
+ return err
+ }
+ seen := make(map[string]struct{}, len(value.Components))
+ for _, component := range value.Components {
+ expectedVersion, linkedComponent := linked[component.Name]
+ expectedPURL := "pkg:golang/" + component.Name + "@" + component.Version
+ if !linkedComponent || component.Type != "library" || component.Group != "" ||
+ expectedVersion != component.Version || component.BOMRef != expectedPURL ||
+ component.PURL != expectedPURL {
+ return fmt.Errorf("SBOM component %s@%s is not an exact linked module", component.Name, component.Version)
+ }
+ if _, duplicate := seen[component.Name]; duplicate {
+ return fmt.Errorf("SBOM component %q is duplicated", component.Name)
+ }
+ seen[component.Name] = struct{}{}
+ moduleSum, err := uniqueProperty(component.Properties, "proof-tool:golang:module-sum")
+ if err != nil {
+ return err
+ }
+ expectedModuleSum, ok := moduleSums[component.Name+"@"+component.Version]
+ if !ok || moduleSum != expectedModuleSum {
+ return fmt.Errorf("SBOM component %q module sum does not equal go.sum", component.Name)
+ }
+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(moduleSum, "h1:"))
+ if !strings.HasPrefix(moduleSum, "h1:") || err != nil || len(decoded) != sha256.Size {
+ return fmt.Errorf("SBOM component %q has invalid module sum", component.Name)
+ }
+ if len(component.Hashes) != 1 ||
+ component.Hashes[0].Algorithm != "SHA-256" ||
+ component.Hashes[0].Content != hex.EncodeToString(decoded) {
+ return fmt.Errorf("SBOM component %q hash does not equal module sum", component.Name)
+ }
+ vendored, err := uniqueProperty(component.Properties, "proof-tool:golang:vendored-tree-sha256")
+ if err != nil {
+ return err
+ }
+ expectedVendored, err := vendoredTreeDigest(sourceRoot, component.Name)
+ if err != nil || vendored != expectedVendored {
+ return fmt.Errorf("SBOM component %q vendored-tree digest does not match the exact source checkout", component.Name)
+ }
+ expectedPropertyCount := 2
+ if component.Name == "github.com/consensys/gnark" {
+ expectedPropertyCount += len(gnarkPatchNames)
+ for _, patch := range gnarkPatchNames {
+ name := "proof-tool:vendored-patch:" + patch + ":sha256"
+ digest, err := uniqueProperty(component.Properties, name)
+ if err != nil || digest != patchDigests[patch] {
+ return fmt.Errorf("SBOM gnark patch digest %q does not match the exact source checkout", patch)
+ }
+ }
+ }
+ if len(component.Properties) != expectedPropertyCount {
+ return fmt.Errorf("SBOM component %q contains an unexpected property set", component.Name)
+ }
+ }
+ return nil
+}
+
+func exactSBOMComponent(component component, componentType, name, version, purl string) bool {
+ return component.Type == componentType &&
+ component.Group == "" &&
+ component.Name == name &&
+ component.Version == version &&
+ component.BOMRef == purl &&
+ component.PURL == purl &&
+ len(component.Hashes) == 0 &&
+ len(component.Properties) == 0
+}
+
+func verifyRootManifest(dir string, manifest digestManifest) error {
+ if manifest.GoVersion != productionGoVersion ||
+ strings.Join(manifest.BuildFlags, "\x00") != expectedBuildFlags ||
+ len(manifest.Files) != len(rootFileNames) {
+ return errors.New("build-package-manifest.json has invalid build identity or entry count")
+ }
+ names := make([]string, 0, len(manifest.Files))
+ for _, entry := range manifest.Files {
+ names = append(names, entry.Filename)
+ if err := verifyDigestEntry(dir, entry); err != nil {
+ return err
+ }
+ }
+ if !slices.Equal(names, rootFileNames) {
+ return errors.New("build-package-manifest.json has an unexpected or reordered entry set")
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "build-package-manifest.json"))
+ if err != nil {
+ return err
+ }
+ sum := sha256.Sum256(data)
+ checksum, err := readOneLine(filepath.Join(dir, "build-package-manifest.sha256"))
+ if err != nil {
+ return err
+ }
+ expected := hex.EncodeToString(sum[:]) + " build-package-manifest.json"
+ if checksum != expected {
+ return errors.New("build-package-manifest.sha256 does not match exact root manifest bytes")
+ }
+ return nil
+}
+
+func verifyBuildSignature(dir, mode, trustedPublicKeyPath string) error {
+ signaturePath := filepath.Join(dir, "build-package-manifest.sig")
+ bundledKeyPath := filepath.Join(dir, "build-package-manifest-public-key.hex")
+ if mode == "rehearsal" {
+ for _, path := range []string{signaturePath, bundledKeyPath} {
+ if _, err := os.Lstat(path); err == nil {
+ return fmt.Errorf("rehearsal package unexpectedly contains %s", filepath.Base(path))
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ }
+ return nil
+ }
+ trusted, err := readHexLine(trustedPublicKeyPath, ed25519.PublicKeySize)
+ if err != nil {
+ return fmt.Errorf("trusted build public key: %w", err)
+ }
+ bundled, err := readHexLine(bundledKeyPath, ed25519.PublicKeySize)
+ if err != nil {
+ return fmt.Errorf("bundled build public key: %w", err)
+ }
+ if !bytes.Equal(trusted, bundled) {
+ return errors.New("bundled build public key does not equal the out-of-band trusted build public key")
+ }
+ signature, err := readHexLine(signaturePath, ed25519.SignatureSize)
+ if err != nil {
+ return fmt.Errorf("build-package signature: %w", err)
+ }
+ manifest, err := os.ReadFile(filepath.Join(dir, "build-package-manifest.json"))
+ if err != nil {
+ return err
+ }
+ if !ed25519.Verify(ed25519.PublicKey(trusted), manifest, signature) {
+ return errors.New("build-package manifest signature is invalid")
+ }
+ return nil
+}
+
+func verifySourceCheckout(sourceRoot, packageDir, commit string) error {
+ rootInfo, err := os.Lstat(sourceRoot)
+ if err != nil {
+ return err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return errors.New("source root must be a real directory")
+ }
+ head, err := gitOutput(sourceRoot, "rev-parse", "--verify", "HEAD")
+ if err != nil {
+ return err
+ }
+ if string(head) != commit+"\n" {
+ return errors.New("source root HEAD does not equal the expected release commit")
+ }
+ for _, args := range [][]string{
+ {"diff", "--quiet", "--ignore-submodules", "--"},
+ {"diff", "--cached", "--quiet", "--ignore-submodules", "--"},
+ } {
+ command := exec.Command("git", append([]string{"-C", sourceRoot}, args...)...)
+ if output, err := command.CombinedOutput(); err != nil {
+ return fmt.Errorf("source checkout has tracked modifications: %w: %s", err, output)
+ }
+ }
+ untracked, err := gitOutput(sourceRoot, "ls-files", "--others", "--exclude-standard", "-z")
+ if err != nil {
+ return err
+ }
+ if len(untracked) != 0 {
+ return errors.New("source checkout contains non-ignored untracked files")
+ }
+ trackedRaw, err := gitOutput(sourceRoot, "ls-files", "-z")
+ if err != nil {
+ return err
+ }
+ tracked := splitNULPaths(trackedRaw)
+ sort.Strings(tracked)
+ if err := verifyChecksumInventory(
+ filepath.Join(packageDir, "source-checksums.sha256"),
+ sourceRoot,
+ tracked,
+ ); err != nil {
+ return fmt.Errorf("source checksum inventory: %w", err)
+ }
+ vendorFiles, err := regularTreeFiles(filepath.Join(sourceRoot, "vendor"), sourceRoot)
+ if err != nil {
+ return err
+ }
+ if err := verifyChecksumInventory(
+ filepath.Join(packageDir, "vendor-checksums.sha256"),
+ sourceRoot,
+ vendorFiles,
+ ); err != nil {
+ return fmt.Errorf("vendor checksum inventory: %w", err)
+ }
+ return nil
+}
+
+func gitOutput(sourceRoot string, args ...string) ([]byte, error) {
+ commandArgs := append([]string{"-C", sourceRoot}, args...)
+ output, err := exec.Command("git", commandArgs...).Output()
+ if err != nil {
+ return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
+ }
+ return output, nil
+}
+
+func splitNULPaths(raw []byte) []string {
+ if len(raw) == 0 {
+ return nil
+ }
+ parts := bytes.Split(raw, []byte{0})
+ if len(parts[len(parts)-1]) == 0 {
+ parts = parts[:len(parts)-1]
+ }
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ result = append(result, string(part))
+ }
+ return result
+}
+
+func regularTreeFiles(root, relativeTo string) ([]string, error) {
+ rootInfo, err := os.Lstat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return nil, errors.New("tree root must be a real directory")
+ }
+ var result []string
+ err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if path == root {
+ return nil
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("tree contains symbolic link %q", path)
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("tree contains non-regular file %q", path)
+ }
+ relative, err := filepath.Rel(relativeTo, path)
+ if err != nil {
+ return err
+ }
+ result = append(result, filepath.ToSlash(relative))
+ return nil
+ })
+ sort.Strings(result)
+ return result, err
+}
+
+func verifyChecksumInventory(manifestPath, sourceRoot string, expectedNames []string) error {
+ file, _, err := openRegular(manifestPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 4096), 1<<20)
+ var actualNames []string
+ for scanner.Scan() {
+ line := scanner.Text()
+ if len(line) < 67 || line[64:66] != " " ||
+ !lowerSHA256Pattern.MatchString(line[:64]) {
+ return errors.New("checksum inventory contains a malformed line")
+ }
+ name := line[66:]
+ if !fs.ValidPath(name) || name == "." {
+ return fmt.Errorf("checksum inventory contains unsafe path %q", name)
+ }
+ dataFile, _, err := openRegular(filepath.Join(sourceRoot, filepath.FromSlash(name)))
+ if err != nil {
+ return err
+ }
+ digest := sha256.New()
+ _, copyErr := io.Copy(digest, dataFile)
+ closeErr := dataFile.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if hex.EncodeToString(digest.Sum(nil)) != line[:64] {
+ return fmt.Errorf("checksum mismatch for %q", name)
+ }
+ actualNames = append(actualNames, name)
+ }
+ if err := scanner.Err(); err != nil {
+ return err
+ }
+ if !slices.Equal(actualNames, expectedNames) {
+ return errors.New("checksum inventory does not contain the exact expected ordered file set")
+ }
+ return nil
+}
+
+func readModuleSums(path string) (map[string]string, error) {
+ file, _, err := openRegular(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ result := make(map[string]string)
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) != 3 || strings.HasSuffix(fields[1], "/go.mod") {
+ continue
+ }
+ if !strings.HasPrefix(fields[2], "h1:") {
+ continue
+ }
+ key := fields[0] + "@" + fields[1]
+ if existing, duplicate := result[key]; duplicate && existing != fields[2] {
+ return nil, fmt.Errorf("go.sum contains conflicting sums for %q", key)
+ }
+ result[key] = fields[2]
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func vendoredTreeDigest(sourceRoot, modulePath string) (string, error) {
+ moduleRoot := filepath.Join(sourceRoot, "vendor", filepath.FromSlash(modulePath))
+ paths, err := regularTreeFiles(moduleRoot, moduleRoot)
+ if err != nil {
+ return "", err
+ }
+ if len(paths) == 0 {
+ return "", errors.New("vendored module tree contains no files")
+ }
+ digest := sha256.New()
+ _, _ = io.WriteString(digest, "proof-tool/vendored-module-tree/v1\x00")
+ for _, relative := range paths {
+ path := filepath.Join(moduleRoot, filepath.FromSlash(relative))
+ file, info, err := openRegular(path)
+ if err != nil {
+ return "", err
+ }
+ if _, err := fmt.Fprintf(digest, "%d:%s:%d:", len(relative), relative, info.Size()); err != nil {
+ file.Close()
+ return "", err
+ }
+ n, copyErr := io.Copy(digest, file)
+ closeErr := file.Close()
+ if copyErr != nil {
+ return "", copyErr
+ }
+ if closeErr != nil {
+ return "", closeErr
+ }
+ if n != info.Size() {
+ return "", fmt.Errorf("vendored file %q changed while hashing", path)
+ }
+ _, _ = digest.Write([]byte{0})
+ }
+ return "sha256:" + hex.EncodeToString(digest.Sum(nil)), nil
+}
+
+func gnarkPatchDigests(sourceRoot string) (map[string]string, error) {
+ result := make(map[string]string, len(gnarkPatchNames))
+ for _, name := range gnarkPatchNames {
+ path := filepath.Join(sourceRoot, "experiments", "wasm-prover", "patches", name)
+ file, _, err := openRegular(path)
+ if err != nil {
+ return nil, err
+ }
+ digest := sha256.New()
+ _, copyErr := io.Copy(digest, file)
+ closeErr := file.Close()
+ if copyErr != nil {
+ return nil, copyErr
+ }
+ if closeErr != nil {
+ return nil, closeErr
+ }
+ result[name] = "sha256:" + hex.EncodeToString(digest.Sum(nil))
+ }
+ return result, nil
+}
+
+func readHexLine(path string, expectedBytes int) ([]byte, error) {
+ line, err := readOneLine(path)
+ if err != nil {
+ return nil, err
+ }
+ if len(line) != expectedBytes*2 {
+ return nil, fmt.Errorf("%s has invalid hexadecimal length", path)
+ }
+ decoded, err := hex.DecodeString(line)
+ if err != nil {
+ return nil, err
+ }
+ return decoded, nil
+}
+
+func verifyDigestEntry(dir string, expected digestEntry) error {
+ if expected.Filename != filepath.Base(expected.Filename) || expected.SizeBytes <= 0 ||
+ !lowerSHA256Pattern.MatchString(expected.SHA256) ||
+ !lowerSHA256Pattern.MatchString(expected.Blake2b256) {
+ return fmt.Errorf("invalid root-manifest entry %q", expected.Filename)
+ }
+ path := filepath.Join(dir, expected.Filename)
+ file, info, err := openRegular(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ sha := sha256.New()
+ blake, err := blake2b.New256(nil)
+ if err != nil {
+ return err
+ }
+ n, err := io.Copy(io.MultiWriter(sha, blake), file)
+ if err != nil {
+ return err
+ }
+ if n != info.Size() || n != expected.SizeBytes ||
+ hex.EncodeToString(sha.Sum(nil)) != expected.SHA256 ||
+ hex.EncodeToString(blake.Sum(nil)) != expected.Blake2b256 {
+ return fmt.Errorf("root-manifest digest mismatch for %q", expected.Filename)
+ }
+ return nil
+}
+
+func readDigestManifest(path string) (digestManifest, error) {
+ var value digestManifest
+ err := readStrictJSON(path, &value)
+ return value, err
+}
+
+func readStrictJSON(path string, destination any) error {
+ file, _, err := openRegular(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ decoder := json.NewDecoder(file)
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(destination); err != nil {
+ return err
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return errors.New("JSON artifact contains trailing data")
+ }
+ return nil
+}
+
+func readOneLine(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ if len(data) < 2 || data[len(data)-1] != '\n' || strings.Count(string(data), "\n") != 1 {
+ return "", fmt.Errorf("%s must contain exactly one non-empty newline-terminated line", path)
+ }
+ return strings.TrimSuffix(string(data), "\n"), nil
+}
+
+func openRegular(path string) (*os.File, fs.FileInfo, error) {
+ linkInfo, err := os.Lstat(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !linkInfo.Mode().IsRegular() {
+ return nil, nil, fmt.Errorf("%s is not a non-symlink regular file", path)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ info, err := file.Stat()
+ if err != nil {
+ file.Close()
+ return nil, nil, err
+ }
+ if !info.Mode().IsRegular() || !os.SameFile(linkInfo, info) {
+ file.Close()
+ return nil, nil, fmt.Errorf("%s changed while being opened", path)
+ }
+ return file, info, nil
+}
+
+func uniqueSetting(info *debug.BuildInfo, key string) (string, error) {
+ var value string
+ found := false
+ for _, setting := range info.Settings {
+ if setting.Key != key {
+ continue
+ }
+ if found {
+ return "", fmt.Errorf("binary build setting %q is duplicated", key)
+ }
+ value = setting.Value
+ found = true
+ }
+ if !found {
+ return "", fmt.Errorf("binary build setting %q is absent", key)
+ }
+ return value, nil
+}
+
+func uniqueProperty(properties []property, name string) (string, error) {
+ var value string
+ found := false
+ for _, property := range properties {
+ if property.Name != name {
+ continue
+ }
+ if found {
+ return "", fmt.Errorf("property %q is duplicated", name)
+ }
+ value = property.Value
+ found = true
+ }
+ if !found {
+ return "", fmt.Errorf("property %q is absent", name)
+ }
+ return value, nil
+}
+
+func fatal(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+}
diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh
new file mode 100755
index 00000000..7dbf6d8d
--- /dev/null
+++ b/scripts/verify-mpc-ceremony-reproducible.sh
@@ -0,0 +1,286 @@
+#!/usr/bin/env -S -u SHELLOPTS -u BASHOPTS BASH_ENV=/dev/null ENV=/dev/null /bin/bash
+# Semantically verifies and then compares release-build directories produced
+# independently by build-mpc-ceremony-release.sh.
+set -euo pipefail
+
+unset CDPATH GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
+unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG_COUNT
+export BASH_ENV=/dev/null
+export ENV=/dev/null
+export GIT_CONFIG_GLOBAL=/dev/null
+export GIT_CONFIG_NOSYSTEM=1
+
+usage() {
+ echo "usage: $0 --mode production|rehearsal --expected-commit COMMIT --expected-tag TAG|none --tag-signer-fingerprint HEX|none --trusted-build-public-key-file FILE|none BUILD_DIR_A BUILD_DIR_B" >&2
+ exit 2
+}
+
+MODE=
+EXPECTED_COMMIT=
+EXPECTED_TAG=
+TAG_SIGNER_FINGERPRINT=
+TRUSTED_BUILD_PUBLIC_KEY_FILE=
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --mode)
+ [[ $# -ge 2 ]] || usage
+ MODE=$2
+ shift 2
+ ;;
+ --expected-commit)
+ [[ $# -ge 2 ]] || usage
+ EXPECTED_COMMIT=$2
+ shift 2
+ ;;
+ --expected-tag)
+ [[ $# -ge 2 ]] || usage
+ EXPECTED_TAG=$2
+ shift 2
+ ;;
+ --tag-signer-fingerprint)
+ [[ $# -ge 2 ]] || usage
+ TAG_SIGNER_FINGERPRINT=${2^^}
+ shift 2
+ ;;
+ --trusted-build-public-key-file)
+ [[ $# -ge 2 ]] || usage
+ TRUSTED_BUILD_PUBLIC_KEY_FILE=$2
+ shift 2
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ usage
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+if [[ $# -ne 2 || ( "$MODE" != "production" && "$MODE" != "rehearsal" ) ||
+ ! "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ||
+ -z "$EXPECTED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" ||
+ -z "$TRUSTED_BUILD_PUBLIC_KEY_FILE" ]]; then
+ usage
+fi
+if [[ "$MODE" == "production" ]]; then
+ if [[ "$EXPECTED_TAG" == "none" ||
+ ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ ||
+ "$TRUSTED_BUILD_PUBLIC_KEY_FILE" == "none" ]]; then
+ usage
+ fi
+ TRUSTED_BUILD_PUBLIC_KEY_DIR=$(realpath -e -- "$(dirname -- "$TRUSTED_BUILD_PUBLIC_KEY_FILE")")
+ TRUSTED_BUILD_PUBLIC_KEY_FILE="$TRUSTED_BUILD_PUBLIC_KEY_DIR/$(basename -- "$TRUSTED_BUILD_PUBLIC_KEY_FILE")"
+ if [[ ! -f "$TRUSTED_BUILD_PUBLIC_KEY_FILE" || -L "$TRUSTED_BUILD_PUBLIC_KEY_FILE" ]]; then
+ echo "FAIL: trusted build public key must be a non-symlink regular file" >&2
+ exit 1
+ fi
+elif [[ "$EXPECTED_TAG" != "none" || "$TAG_SIGNER_FINGERPRINT" != "NONE" ||
+ "$TRUSTED_BUILD_PUBLIC_KEY_FILE" != "none" ]]; then
+ usage
+else
+ TAG_SIGNER_FINGERPRINT=none
+fi
+
+BUILD_A=$1
+BUILD_B=$2
+EXPECTED_FILES=(
+ binary-manifest.json
+ build-mode.txt
+ build-package-manifest.json
+ build-package-manifest.sha256
+ checksums.blake2b256
+ checksums.sha256
+ finalization-evidence-binary-manifest.json
+ finalization-evidence-go-build-info.txt
+ finalization-evidence-sbom.cdx.json
+ go-build-info.txt
+ mpc-finalization-evidence
+ mpc-ceremony
+ sbom.cdx.json
+ signed-tag-object.txt
+ signed-tag-signer-fingerprint.txt
+ signed-tag-status.txt
+ signed-tag.txt
+ source-checksums.sha256
+ source-commit.txt
+ source-date-epoch.txt
+ toolchain-checksums.sha256
+ vendor-checksums.sha256
+)
+if [[ "$MODE" == "production" ]]; then
+ EXPECTED_FILES+=(
+ build-package-manifest-public-key.hex
+ build-package-manifest.sig
+ )
+fi
+mapfile -t EXPECTED_FILES < <(printf '%s\n' "${EXPECTED_FILES[@]}" | LC_ALL=C sort)
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel)
+if [[ "$MODE" == "production" ]]; then
+ if [[ "$EXPECTED_TAG" == -* ]] ||
+ ! git -C "$REPO_ROOT" check-ref-format "refs/tags/$EXPECTED_TAG"; then
+ echo "FAIL: invalid expected production tag: $EXPECTED_TAG" >&2
+ exit 1
+ fi
+ VERIFIED_TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{commit}")
+ if [[ "$VERIFIED_TAG_COMMIT" != "$EXPECTED_COMMIT" ]]; then
+ echo "FAIL: expected production tag does not resolve to the expected commit" >&2
+ exit 1
+ fi
+ VERIFIED_TAG_OBJECT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{tag}")
+ VERIFY_TAG_OUTPUT=
+ if ! VERIFY_TAG_OUTPUT=$(git -C "$REPO_ROOT" verify-tag --raw "$EXPECTED_TAG" 2>&1); then
+ printf '%s\n' "$VERIFY_TAG_OUTPUT" >&2
+ echo "FAIL: independent production tag verification failed" >&2
+ exit 1
+ fi
+ mapfile -t VERIFIED_TAG_FINGERPRINTS < <(
+ printf '%s\n' "$VERIFY_TAG_OUTPUT" |
+ sed -n 's/^\[GNUPG:\] VALIDSIG \([0-9A-Fa-f]*\) .*/\U\1/p'
+ )
+ if [[ "${#VERIFIED_TAG_FINGERPRINTS[@]}" -ne 1 ||
+ "${VERIFIED_TAG_FINGERPRINTS[0]}" != "$TAG_SIGNER_FINGERPRINT" ]]; then
+ echo "FAIL: independent tag verification did not use the approved signer fingerprint" >&2
+ exit 1
+ fi
+fi
+ACTIVE_GOROOT=$(env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=auto \
+ 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
+ exit 1
+fi
+
+for dir in "$BUILD_A" "$BUILD_B"; do
+ if [[ ! -d "$dir" || -L "$dir" ]]; then
+ echo "FAIL: build path must be a real directory: $dir" >&2
+ exit 1
+ fi
+ if [[ -n "$(find "$dir" -type l -print -quit)" ]]; then
+ echo "FAIL: build directory contains a symbolic link: $dir" >&2
+ exit 1
+ fi
+ mapfile -t actual_entries < <(
+ cd "$dir"
+ find . -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort
+ )
+ if [[ "${actual_entries[*]}" != "${EXPECTED_FILES[*]}" ]]; then
+ echo "FAIL: build directory has an unexpected entry set: $dir" >&2
+ printf 'expected: %s\n' "${EXPECTED_FILES[*]}" >&2
+ printf 'actual: %s\n' "${actual_entries[*]}" >&2
+ exit 1
+ fi
+ for name in "${EXPECTED_FILES[@]}"; do
+ if [[ ! -f "$dir/$name" || -L "$dir/$name" ]]; then
+ echo "FAIL: build entry must be a non-symlink regular file: $dir/$name" >&2
+ exit 1
+ fi
+ expected_mode=444
+ if [[ "$name" == "mpc-ceremony" || "$name" == "mpc-finalization-evidence" ]]; then
+ expected_mode=555
+ fi
+ actual_mode=$(stat -c %a "$dir/$name")
+ if [[ "$actual_mode" != "$expected_mode" ]]; then
+ echo "FAIL: build entry mode is $actual_mode, want $expected_mode: $dir/$name" >&2
+ exit 1
+ fi
+ done
+ if [[ ! -x "$dir/mpc-ceremony" || ! -x "$dir/mpc-finalization-evidence" ]]; then
+ echo "FAIL: both release binaries must be executable: $dir" >&2
+ exit 1
+ fi
+ if [[ "$MODE" == "production" ]]; then
+ RECORDED_TAG_OBJECT=$(<"$dir/signed-tag-object.txt")
+ if [[ "$RECORDED_TAG_OBJECT" != "$VERIFIED_TAG_OBJECT" ]]; then
+ echo "FAIL: recorded signed tag object does not equal independently verified tag object: $dir" >&2
+ exit 1
+ fi
+ fi
+ env \
+ -u GOROOT \
+ CGO_ENABLED=0 \
+ GOCACHE="${TMPDIR:-/tmp}/proof-tool-mpc-verify-go-cache" \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOTOOLCHAIN=local \
+ GOWORK=off \
+ GOOS=linux \
+ GOARCH=amd64 \
+ GOAMD64=v1 \
+ GOFLAGS=-mod=vendor \
+ "$GO_BIN" run "$REPO_ROOT/scripts/verify-mpc-build-metadata" \
+ --dir "$dir" \
+ --mode "$MODE" \
+ --commit "$EXPECTED_COMMIT" \
+ --tag "$EXPECTED_TAG" \
+ --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \
+ --source-root "$REPO_ROOT" \
+ --trusted-build-public-key-file "$TRUSTED_BUILD_PUBLIC_KEY_FILE"
+ BUILD_INFO_TMP=$(mktemp "${TMPDIR:-/tmp}/mpc-build-info.XXXXXXXX")
+ (
+ cd "$dir"
+ env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=local \
+ "$GO_BIN" version -m ./mpc-ceremony >"$BUILD_INFO_TMP"
+ )
+ if ! cmp "$BUILD_INFO_TMP" "$dir/go-build-info.txt"; then
+ rm -f -- "$BUILD_INFO_TMP"
+ echo "FAIL: saved Go build information does not exactly describe the binary: $dir" >&2
+ exit 1
+ fi
+ rm -f -- "$BUILD_INFO_TMP"
+ EVIDENCE_BUILD_INFO_TMP=$(mktemp "${TMPDIR:-/tmp}/mpc-finalization-build-info.XXXXXXXX")
+ (
+ cd "$dir"
+ env -u GOROOT \
+ CGO_ENABLED=0 \
+ GOARCH=amd64 \
+ GOENV=off \
+ GOEXPERIMENT= \
+ GOFIPS140=off \
+ GOOS=linux \
+ GOAMD64=v1 \
+ GOTOOLCHAIN=local \
+ "$GO_BIN" version -m ./mpc-finalization-evidence >"$EVIDENCE_BUILD_INFO_TMP"
+ )
+ if ! cmp "$EVIDENCE_BUILD_INFO_TMP" "$dir/finalization-evidence-go-build-info.txt"; then
+ rm -f -- "$EVIDENCE_BUILD_INFO_TMP"
+ echo "FAIL: saved Go build information does not exactly describe the finalization evidence binary: $dir" >&2
+ exit 1
+ fi
+ rm -f -- "$EVIDENCE_BUILD_INFO_TMP"
+done
+
+diff -r --no-dereference "$BUILD_A" "$BUILD_B"
+cmp "$BUILD_A/mpc-ceremony" "$BUILD_B/mpc-ceremony"
+cmp "$BUILD_A/mpc-finalization-evidence" "$BUILD_B/mpc-finalization-evidence"
+
+if [[ "$MODE" == "production" ]]; then
+ echo "OK: independent signed-tag production MPC ceremony release builds are semantically valid and byte-identical"
+else
+ echo "OK: independent MPC ceremony rehearsal builds are semantically valid and byte-identical (NOT PRODUCTION)"
+fi
+sha256sum "$BUILD_A/mpc-ceremony"
+sha256sum "$BUILD_A/mpc-finalization-evidence"
diff --git a/scripts/verify-mpc-final-plutus-evidence.sh b/scripts/verify-mpc-final-plutus-evidence.sh
new file mode 100755
index 00000000..98a0bde1
--- /dev/null
+++ b/scripts/verify-mpc-final-plutus-evidence.sh
@@ -0,0 +1,311 @@
+#!/usr/bin/env bash
+# Verifies the exact public finalization vector with the dynamic Plutus
+# destination-proof verifier. No wallet secret or derivation path is read.
+set -euo pipefail
+umask 077
+
+usage() {
+ echo "usage: verify-mpc-final-plutus-evidence.sh CANDIDATE_OR_RELEASE_DIR [VERIFY_DESTINATION_PROOF_BINARY]" >&2
+ exit 2
+}
+
+[[ $# -ge 1 && $# -le 2 ]] || usage
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
+ARTIFACT_DIR=$1
+
+if [[ ! -d "$ARTIFACT_DIR" || -L "$ARTIFACT_DIR" ]]; then
+ echo "FAIL: artifact directory must be an existing real directory" >&2
+ exit 1
+fi
+ARTIFACT_DIR=$(cd "$ARTIFACT_DIR" && pwd)
+
+for tool in b2sum node sha256sum stat; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ echo "FAIL: required tool is unavailable: $tool" >&2
+ exit 1
+ fi
+done
+
+if [[ $# -eq 2 ]]; then
+ VERIFIER_BIN=$2
+elif [[ -n "${MPC_PLUTUS_VERIFIER_BIN:-}" ]]; then
+ VERIFIER_BIN=$MPC_PLUTUS_VERIFIER_BIN
+else
+ if ! command -v cabal >/dev/null 2>&1; then
+ echo "FAIL: pass a prebuilt verify-destination-proof binary or install cabal" >&2
+ exit 1
+ fi
+ VERIFIER_BIN=$(
+ cd "$REPO_ROOT/contracts/ownership-verifier"
+ cabal list-bin exe:verify-destination-proof
+ )
+fi
+if [[ ! -f "$VERIFIER_BIN" || -L "$VERIFIER_BIN" || ! -x "$VERIFIER_BIN" ]]; then
+ echo "FAIL: Plutus verifier must be an executable regular file, not a symlink" >&2
+ exit 1
+fi
+VERIFIER_BIN=$(cd "$(dirname "$VERIFIER_BIN")" && pwd)/$(basename "$VERIFIER_BIN")
+
+CANDIDATE="$ARTIFACT_DIR/candidate.json"
+REPORT="$ARTIFACT_DIR/verification-report.json"
+EVIDENCE="$ARTIFACT_DIR/public-finalization-evidence.json"
+VK_RAW="$ARTIFACT_DIR/cardano-vk.bin"
+VK_HEX="$ARTIFACT_DIR/cardano-vk.hex"
+for path in "$CANDIDATE" "$REPORT" "$EVIDENCE" "$VK_RAW" "$VK_HEX"; do
+ if [[ ! -f "$path" || -L "$path" ]]; then
+ echo "FAIL: required artifact is not a regular file: $path" >&2
+ exit 1
+ fi
+done
+
+mapfile -d '' JSON_VALUES < <(
+ node - "$CANDIDATE" "$REPORT" "$EVIDENCE" <<'NODE'
+const fs = require("fs");
+
+function fail(message) {
+ process.stderr.write(`FAIL: ${message}\n`);
+ process.exit(1);
+}
+function readJSON(path) {
+ try {
+ return JSON.parse(fs.readFileSync(path, "utf8"));
+ } catch (error) {
+ fail(`invalid JSON in ${path}: ${error.message}`);
+ }
+}
+function requireValue(actual, expected, label) {
+ if (actual !== expected) fail(`${label} differs from ${JSON.stringify(expected)}`);
+}
+function requireHex(value, bytes, label) {
+ if (typeof value !== "string" || !new RegExp(`^[0-9a-f]{${bytes * 2}}$`).test(value)) {
+ fail(`${label} is not exactly ${bytes} lowercase hexadecimal bytes`);
+ }
+}
+function requireRef(ref, name, label) {
+ if (!ref || ref.name !== name || !ref.digest) fail(`${label} has the wrong artifact name`);
+ requireHex(ref.digest.sha256?.replace(/^sha256:/, ""), 32, `${label} SHA-256`);
+ requireHex(ref.digest.blake2b256?.replace(/^blake2b256:/, ""), 32, `${label} BLAKE2b-256`);
+ if (!Number.isSafeInteger(ref.digest.size) || ref.digest.size <= 0) fail(`${label} size is invalid`);
+}
+function same(left, right) {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+function emit(value) {
+ process.stdout.write(String(value));
+ process.stdout.write("\0");
+}
+
+const candidate = readJSON(process.argv[2]);
+const report = readJSON(process.argv[3]);
+const evidence = readJSON(process.argv[4]);
+requireValue(candidate.schema, "proof-tool-mpc-release-candidate-v2", "candidate schema");
+requireValue(report.schema, "proof-tool-mpc-verification-report-v2", "report schema");
+requireValue(evidence.schema, "proof-tool-mpc-public-finalization-evidence-v1", "evidence schema");
+requireValue(report.native_proof_verified, true, "native proof result");
+requireValue(report.wrong_credential_rejected, true, "wrong-credential result");
+requireValue(report.wrong_destination_rejected, true, "wrong-destination result");
+requireValue(report.wrong_digest_rejected, true, "wrong-digest result");
+requireValue(report.wrong_proof_rejected, true, "wrong-proof result");
+requireValue(report.wrong_vk_rejected, true, "wrong-VK result");
+requireValue(report.proof_truncation_rejected, true, "proof-truncation result");
+requireValue(report.proof_append_rejected, true, "proof-append result");
+requireValue(report.cardano_proof_format, "groth16-bls12-381-bsb22", "report proof format");
+requireValue(report.cardano_proof_bytes, 336, "report proof length");
+requireValue(report.cardano_vk_format, "groth16-bls12-381-bsb22", "report VK format");
+requireValue(report.cardano_vk_bytes, 672, "report VK length");
+requireValue(evidence.cardano_proof_format, "groth16-bls12-381-bsb22", "evidence proof format");
+requireValue(report.cardano_proof_raw_digest.size, 336, "report proof digest size");
+requireValue(evidence.cardano_proof_raw_digest.size, 336, "evidence proof digest size");
+requireValue(report.cardano_vk_raw_digest.size, 672, "report VK digest size");
+requireRef(candidate.verification_report, "verification-report.json", "candidate report reference");
+requireRef(candidate.public_finalization_evidence, "public-finalization-evidence.json", "candidate evidence reference");
+requireRef(candidate.cardano_verifying_key, "cardano-vk.bin", "candidate VK reference");
+if (!same(candidate.public_finalization_evidence, report.public_evidence)) {
+ fail("verification report does not hash-bind candidate public evidence");
+}
+if (!same(candidate.cardano_verifying_key, evidence.cardano_verifying_key)) {
+ fail("public evidence does not bind candidate Cardano VK");
+}
+if (candidate.ceremony_id !== report.ceremony_id || candidate.ceremony_id !== evidence.ceremony_id) {
+ fail("ceremony id differs across candidate, report, and evidence");
+}
+requireHex(evidence.credential_hex, 28, "credential");
+requireHex(evidence.destination_hex, 58, "destination");
+requireHex(evidence.public_input_digest_hex, 32, "public input digest");
+requireHex(evidence.cardano_proof_hex, 336, "Cardano proof");
+if (!same(report.cardano_proof_raw_digest, evidence.cardano_proof_raw_digest)) {
+ fail("report and evidence proof digests differ");
+}
+if (!same(report.cardano_vk_raw_digest, candidate.cardano_verifying_key.digest)) {
+ fail("report and candidate VK digests differ");
+}
+
+[
+ candidate.ceremony_id,
+ evidence.credential_hex,
+ evidence.destination_hex,
+ evidence.public_input_digest_hex,
+ evidence.cardano_proof_hex,
+ candidate.verification_report.digest.sha256,
+ candidate.verification_report.digest.blake2b256,
+ candidate.verification_report.digest.size,
+ candidate.public_finalization_evidence.digest.sha256,
+ candidate.public_finalization_evidence.digest.blake2b256,
+ candidate.public_finalization_evidence.digest.size,
+ candidate.cardano_verifying_key.digest.sha256,
+ candidate.cardano_verifying_key.digest.blake2b256,
+ candidate.cardano_verifying_key.digest.size,
+ report.cardano_proof_raw_digest.sha256,
+ report.cardano_proof_raw_digest.blake2b256,
+].forEach(emit);
+NODE
+)
+if [[ ${#JSON_VALUES[@]} -ne 16 ]]; then
+ echo "FAIL: JSON validator returned incomplete evidence" >&2
+ exit 1
+fi
+
+CEREMONY_ID=${JSON_VALUES[0]}
+CREDENTIAL_HEX=${JSON_VALUES[1]}
+DESTINATION_HEX=${JSON_VALUES[2]}
+PUBLIC_INPUT_DIGEST_HEX=${JSON_VALUES[3]}
+PROOF_HEX=${JSON_VALUES[4]}
+
+verify_ref() {
+ local path=$1
+ local expected_sha=$2
+ local expected_blake=$3
+ local expected_size=$4
+ local label=$5
+ local actual_sha actual_blake actual_size
+ actual_sha="sha256:$(sha256sum "$path" | cut -d ' ' -f 1)"
+ actual_blake="blake2b256:$(b2sum -l 256 "$path" | cut -d ' ' -f 1)"
+ actual_size=$(stat -c %s "$path")
+ if [[ "$actual_sha" != "$expected_sha" ||
+ "$actual_blake" != "$expected_blake" ||
+ "$actual_size" != "$expected_size" ]]; then
+ echo "FAIL: artifact digest mismatch for $label" >&2
+ exit 1
+ fi
+}
+
+verify_ref "$REPORT" "${JSON_VALUES[5]}" "${JSON_VALUES[6]}" "${JSON_VALUES[7]}" "verification report"
+verify_ref "$EVIDENCE" "${JSON_VALUES[8]}" "${JSON_VALUES[9]}" "${JSON_VALUES[10]}" "public evidence"
+verify_ref "$VK_RAW" "${JSON_VALUES[11]}" "${JSON_VALUES[12]}" "${JSON_VALUES[13]}" "Cardano VK"
+
+hex_to_binary() {
+ node -e 'process.stdout.write(Buffer.from(process.argv[1], "hex"))' "$1"
+}
+
+PROOF_SHA="sha256:$(hex_to_binary "$PROOF_HEX" | sha256sum | cut -d ' ' -f 1)"
+PROOF_BLAKE="blake2b256:$(hex_to_binary "$PROOF_HEX" | b2sum -l 256 | cut -d ' ' -f 1)"
+if [[ "$PROOF_SHA" != "${JSON_VALUES[14]}" || "$PROOF_BLAKE" != "${JSON_VALUES[15]}" ]]; then
+ echo "FAIL: report/evidence proof hashes do not bind the exact Cardano proof" >&2
+ exit 1
+fi
+
+VK_SHA="sha256:$(sha256sum "$VK_RAW" | cut -d ' ' -f 1)"
+VK_BLAKE="blake2b256:$(b2sum -l 256 "$VK_RAW" | cut -d ' ' -f 1)"
+VK_FULL_HEX=$(node -e 'process.stdout.write(require("fs").readFileSync(process.argv[1]).toString("hex"))' "$VK_RAW")
+if [[ "$VK_FULL_HEX" != "$(tr -d '[:space:]' <"$VK_HEX")" ]]; then
+ echo "FAIL: Cardano VK hex differs from exact final raw VK" >&2
+ exit 1
+fi
+
+COMPUTED_PUBLIC_INPUT_DIGEST=$(
+ {
+ printf '%s' 'ROOT-OWNERSHIP-DESTINATION-v1'
+ hex_to_binary "$CREDENTIAL_HEX"
+ hex_to_binary "$DESTINATION_HEX"
+ } | b2sum -l 256 | cut -d ' ' -f 1
+)
+if [[ "$COMPUTED_PUBLIC_INPUT_DIGEST" != "$PUBLIC_INPUT_DIGEST_HEX" ]]; then
+ echo "FAIL: public input digest does not bind the credential and destination" >&2
+ exit 1
+fi
+
+TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/proof-tool-plutus-evidence.XXXXXXXX")
+trap 'rm -rf -- "$TMP_DIR"' EXIT
+
+flip_first_byte() {
+ local value=$1
+ local byte
+ printf -v byte '%02x' "$((16#${value:0:2} ^ 1))"
+ printf '%s%s' "$byte" "${value:2}"
+}
+
+expect_reject() {
+ local label=$1
+ local vk_path=$2
+ local proof=$3
+ local credential=$4
+ local destination=$5
+ local digest=$6
+ if "$VERIFIER_BIN" "$vk_path" "$proof" "$credential" "$destination" "$digest" \
+ >"$TMP_DIR/$label.stdout" 2>"$TMP_DIR/$label.stderr"; then
+ echo "FAIL: dynamic Plutus verifier accepted negative case: $label" >&2
+ exit 1
+ fi
+}
+
+POSITIVE_OUTPUT=$(
+ "$VERIFIER_BIN" \
+ "$VK_HEX" \
+ "$PROOF_HEX" \
+ "$CREDENTIAL_HEX" \
+ "$DESTINATION_HEX" \
+ "$PUBLIC_INPUT_DIGEST_HEX"
+)
+if [[ "$POSITIVE_OUTPUT" != "ok" ]]; then
+ echo "FAIL: dynamic Plutus verifier did not return the exact positive result" >&2
+ exit 1
+fi
+
+MUTATED_VK_HEX=$(flip_first_byte "$VK_FULL_HEX")
+printf '%s\n' "$MUTATED_VK_HEX" >"$TMP_DIR/vk-mutated.hex"
+printf '%s\n' "${VK_FULL_HEX:0:${#VK_FULL_HEX}-2}" >"$TMP_DIR/vk-truncated.hex"
+printf '%s00\n' "$VK_FULL_HEX" >"$TMP_DIR/vk-appended.hex"
+
+expect_reject destination-mutated "$VK_HEX" "$PROOF_HEX" "$CREDENTIAL_HEX" "$(flip_first_byte "$DESTINATION_HEX")" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject credential-mutated "$VK_HEX" "$PROOF_HEX" "$(flip_first_byte "$CREDENTIAL_HEX")" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject digest-mutated "$VK_HEX" "$PROOF_HEX" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$(flip_first_byte "$PUBLIC_INPUT_DIGEST_HEX")"
+expect_reject proof-mutated "$VK_HEX" "$(flip_first_byte "$PROOF_HEX")" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject vk-mutated "$TMP_DIR/vk-mutated.hex" "$PROOF_HEX" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject proof-truncated "$VK_HEX" "${PROOF_HEX:0:${#PROOF_HEX}-2}" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject proof-appended "$VK_HEX" "${PROOF_HEX}00" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject vk-truncated "$TMP_DIR/vk-truncated.hex" "$PROOF_HEX" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+expect_reject vk-appended "$TMP_DIR/vk-appended.hex" "$PROOF_HEX" "$CREDENTIAL_HEX" "$DESTINATION_HEX" "$PUBLIC_INPUT_DIGEST_HEX"
+
+node - \
+ "$CEREMONY_ID" \
+ "sha256:$(sha256sum "$VERIFIER_BIN" | cut -d ' ' -f 1)" \
+ "sha256:$(sha256sum "$REPORT" | cut -d ' ' -f 1)" \
+ "sha256:$(sha256sum "$EVIDENCE" | cut -d ' ' -f 1)" \
+ "$VK_SHA" \
+ "$PROOF_SHA" <<'NODE'
+const values = process.argv.slice(2);
+process.stdout.write(JSON.stringify({
+ schema: "proof-tool-mpc-plutus-finalization-verification-v1",
+ ceremony_id: values[0],
+ verifier_sha256: values[1],
+ verification_report_sha256: values[2],
+ public_evidence_sha256: values[3],
+ cardano_vk_sha256: values[4],
+ cardano_proof_sha256: values[5],
+ positive_verified: true,
+ rejected_negatives: [
+ "destination-mutated",
+ "credential-mutated",
+ "digest-mutated",
+ "proof-mutated",
+ "vk-mutated",
+ "proof-truncated",
+ "proof-appended",
+ "vk-truncated",
+ "vk-appended",
+ ],
+}));
+process.stdout.write("\n");
+NODE