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