From 718cd75c630b4828c1181ed06691c77d6ccc3da1 Mon Sep 17 00:00:00 2001 From: QSchlegel <34195172+QSchlegel@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:02:35 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(documents):=20Revision=20Provenance=20?= =?UTF-8?q?=E2=80=94=20diff,=20rollback=20and=20audit=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Sign-Off v1 (ROADMAP M6). History already had chained platform attestations (#375); this adds the other three pieces, all off-chain. Diff: diffVersions compares any two versions of a document. The metadata diff always works, including hash-only versions whose bytes never left the signer's machine; the line diff runs when both versions carry their bytes inline as UTF-8 text, and says why when it cannot. Bounded at 4M LCS cells after trimming the common prefix and suffix. Rollback: restoreVersion re-publishes an earlier version's exact content as version N+1. Append-only — nothing in between is deleted, the restore is logged (version.restored, with its source) and attested, and approvals never come back: the restored version starts a fresh round at zero. Restoring the current head is refused. Wallet members only. Audit export: mesh-multisig.document-signoff.audit.v1 — every version with its lineage and its standalone proof.v1, the whole event log, and the attestation chain with its keys. verifyAuditPackage re-runs every check offline and cross-checks the two records (each version attested once at its own hash, each restore carrying the hash it names, Approved only with the approvals to back it). A new format rather than more fields on proof.v1. uploadVersion, publishDraft and restoreVersion now share one appendVersion helper instead of three copies of the supersede logic; event metadata is unchanged. exportProof builds through the same buildProofPackage as the audit export, and now carries partyId/partyRole for contract-party reviews, which the verifier binds against. Also: ROADMAP M5/M6 PR mapping rows, Revision Provenance vault note, public roadmap data (Sign-Off MVP and v1 history marked done). Tests: documentDiff, documentAudit (tamper cases), and a real-DB/real-CIP-8 documentProvenance suite; full unit run 1620 passing; tsc clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016DwjSXosdJg8rATTa7pQEn --- ROADMAP.md | 2 + src/__tests__/documentAudit.test.ts | 461 ++++++++++++++ src/__tests__/documentDiff.test.ts | 209 +++++++ src/__tests__/trpc/documentProvenance.test.ts | 309 ++++++++++ src/components/pages/homepage/roadmap/data.ts | 6 +- .../pages/wallet/documents/detail.tsx | 169 +++++- .../wallet/documents/version-diff-dialog.tsx | 188 ++++++ src/lib/documents/audit.ts | 502 +++++++++++++++ src/lib/documents/diff.ts | 375 ++++++++++++ src/lib/documents/proof.ts | 151 +++++ src/server/api/routers/documents.ts | 573 ++++++++++++------ vault/features/Revision Provenance.md | 21 +- 12 files changed, 2764 insertions(+), 202 deletions(-) create mode 100644 src/__tests__/documentAudit.test.ts create mode 100644 src/__tests__/documentDiff.test.ts create mode 100644 src/__tests__/trpc/documentProvenance.test.ts create mode 100644 src/components/pages/wallet/documents/version-diff-dialog.tsx create mode 100644 src/lib/documents/audit.ts create mode 100644 src/lib/documents/diff.ts diff --git a/ROADMAP.md b/ROADMAP.md index 4af37159..3dad4f89 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,6 +36,8 @@ Each MRP month resolves to a concrete, reproducible set of merged pull requests. | **M2 — May 2026** | [3](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-05-01..2026-05-31) | [#257](https://github.com/MeshJS/multisig/pull/257) pin Mesh SDK + reject witnesses that don't verify against the tx body · [#259](https://github.com/MeshJS/multisig/pull/259) Import Wallet wizard · [#260](https://github.com/MeshJS/multisig/pull/260) `main`→`preprod` merge clearing #229 + CodeQL fixes | | **M3 — June 2026** | [51](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-06-01..2026-06-30) | Governance [#271](https://github.com/MeshJS/multisig/pull/271)–[#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#286](https://github.com/MeshJS/multisig/pull/286), [#296](https://github.com/MeshJS/multisig/pull/296)–[#297](https://github.com/MeshJS/multisig/pull/297), [#300](https://github.com/MeshJS/multisig/pull/300), [#302](https://github.com/MeshJS/multisig/pull/302), [#315](https://github.com/MeshJS/multisig/pull/315) · Signing & auth [#273](https://github.com/MeshJS/multisig/pull/273)–[#277](https://github.com/MeshJS/multisig/pull/277), [#281](https://github.com/MeshJS/multisig/pull/281)–[#282](https://github.com/MeshJS/multisig/pull/282), [#324](https://github.com/MeshJS/multisig/pull/324) · Mesh 2.0 groundwork [#229](https://github.com/MeshJS/multisig/pull/229), [#269](https://github.com/MeshJS/multisig/pull/269), [#278](https://github.com/MeshJS/multisig/pull/278) · Mobile & UX [#287](https://github.com/MeshJS/multisig/pull/287)–[#295](https://github.com/MeshJS/multisig/pull/295) · Landing/theme/SEO [#298](https://github.com/MeshJS/multisig/pull/298)–[#299](https://github.com/MeshJS/multisig/pull/299), [#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318), [#328](https://github.com/MeshJS/multisig/pull/328) · Infra & security [#284](https://github.com/MeshJS/multisig/pull/284), [#301](https://github.com/MeshJS/multisig/pull/301), [#319](https://github.com/MeshJS/multisig/pull/319) · Docs & releases [#280](https://github.com/MeshJS/multisig/pull/280), [#283](https://github.com/MeshJS/multisig/pull/283), [#285](https://github.com/MeshJS/multisig/pull/285), [#303](https://github.com/MeshJS/multisig/pull/303), [#309](https://github.com/MeshJS/multisig/pull/309), [#320](https://github.com/MeshJS/multisig/pull/320)–[#321](https://github.com/MeshJS/multisig/pull/321) | | **M4 — July 2026** | [16](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-07-01..2026-07-31) | Bot platform & API [#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345) · Agent/crawler surface [#346](https://github.com/MeshJS/multisig/pull/346) · DRep vote-history explorer [#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339) · Roadmap & delivery audit [#347](https://github.com/MeshJS/multisig/pull/347), [#350](https://github.com/MeshJS/multisig/pull/350)–[#352](https://github.com/MeshJS/multisig/pull/352) · Production hardening [#332](https://github.com/MeshJS/multisig/pull/332)–[#334](https://github.com/MeshJS/multisig/pull/334) | +| **M5 — August 2026** | [31](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-08-01..2026-08-31) | Document Sign-Off MVP [#356](https://github.com/MeshJS/multisig/pull/356), [#373](https://github.com/MeshJS/multisig/pull/373), [#375](https://github.com/MeshJS/multisig/pull/375)–[#378](https://github.com/MeshJS/multisig/pull/378), [#380](https://github.com/MeshJS/multisig/pull/380)–[#382](https://github.com/MeshJS/multisig/pull/382), [#386](https://github.com/MeshJS/multisig/pull/386)–[#388](https://github.com/MeshJS/multisig/pull/388) · Signing fix [#372](https://github.com/MeshJS/multisig/pull/372) · Shielded sign-off / vault [#374](https://github.com/MeshJS/multisig/pull/374), [#383](https://github.com/MeshJS/multisig/pull/383) · MCP server + OAuth 2.1 [#357](https://github.com/MeshJS/multisig/pull/357)–[#361](https://github.com/MeshJS/multisig/pull/361), [#363](https://github.com/MeshJS/multisig/pull/363), [#365](https://github.com/MeshJS/multisig/pull/365), [#367](https://github.com/MeshJS/multisig/pull/367), [#370](https://github.com/MeshJS/multisig/pull/370) · Ballot rationale IPFS [#362](https://github.com/MeshJS/multisig/pull/362) · Proxy access [#364](https://github.com/MeshJS/multisig/pull/364) · Summon wallets [#371](https://github.com/MeshJS/multisig/pull/371) · Agent onboarding & governance overview [#254](https://github.com/MeshJS/multisig/pull/254) · Landing [#359](https://github.com/MeshJS/multisig/pull/359), [#366](https://github.com/MeshJS/multisig/pull/366) · Security [#379](https://github.com/MeshJS/multisig/pull/379), [#384](https://github.com/MeshJS/multisig/pull/384) | +| **M6 — September 2026** | [2](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-09-01..2026-09-30) + carryover | Proof verifier + PDF [#397](https://github.com/MeshJS/multisig/pull/397), [#398](https://github.com/MeshJS/multisig/pull/398) (M5 Sign-Off MVP items). The M6 deliverables — Revision Provenance and the FROST go/no-go — were not merged in September; they land in the M6 completion PR (see [Month 6 proof of completion](#proof-of-completion-1)). | Reproduce any row: diff --git a/src/__tests__/documentAudit.test.ts b/src/__tests__/documentAudit.test.ts new file mode 100644 index 00000000..0ade1c57 --- /dev/null +++ b/src/__tests__/documentAudit.test.ts @@ -0,0 +1,461 @@ +import { generateKeyPairSync } from "crypto"; +import { describe, expect, it } from "@jest/globals"; + +import { + AUDIT_FORMAT, + buildAuditPackage, + verifyAuditPackage, + versionLineage, + type AuditPackage, + type AuditSourceAttestation, + type AuditSourceEvent, + type AuditSourceVersion, +} from "@/lib/documents/audit"; +import { + GENESIS_PREV, + attestationHash, + attestationKeyId, + buildAttestationPayload, + canonicalizeAttestation, + signAttestation, +} from "@/lib/documents/attestation"; +import { + buildSignOffPayload, + canonicalizeSignOffPayload, + sha256Hex, +} from "@/lib/documents/payload"; +import { PROOF_FORMAT, type CheckSignatureFn } from "@/lib/documents/proof"; + +/** + * Revision Provenance — the audit export. + * + * The bundle is only worth exporting if tampering with any part of it is + * caught offline, so most of these tests alter an honest package one way at a + * time and assert the verifier notices. CIP-8 itself is covered by the proof + * tests; here it is injected, as the module intends. + */ + +const acceptAll: CheckSignatureFn = async () => true; +const rejectAll: CheckSignatureFn = async () => false; + +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); +const SPKI = publicKey.export({ type: "spki", format: "der" }).toString("hex"); +const KEY_ID = attestationKeyId(SPKI); +const KEYS = { [KEY_ID]: SPKI }; + +const DOC_ID = "doc_1"; +const WALLET_ID = "wallet_1"; +const POLICY = "f".repeat(64); +const SIGNERS = ["addr_alice", "addr_bob", "addr_carol"]; +const T0 = Date.UTC(2026, 8, 1, 9); +const at = (minutes: number) => new Date(T0 + minutes * 60_000); + +const HASH = { + v1: sha256Hex("draft one"), + v2: sha256Hex("draft two"), +}; + +const document = { + id: DOC_ID, + walletId: WALLET_ID, + title: "Supply agreement", + description: null, + documentType: "Legal", + createdBy: SIGNERS[0]!, + createdAt: at(0), + status: "InReview", + signingMode: "threshold", + archivedAt: null, +}; + +function review( + versionId: string, + versionNumber: number, + contentHash: string, + signer: string, + minute: number, +) { + const payload = buildSignOffPayload({ + action: "approve", + contentHash, + documentId: DOC_ID, + signedAt: at(minute), + signerAddress: signer, + versionId, + versionNumber, + walletId: WALLET_ID, + walletPolicyHash: POLICY, + }); + return { + signerAddress: signer, + partyId: null, + party: null, + action: "approve" as const, + comment: null, + payload: canonicalizeSignOffPayload(payload), + signature: "sig", + signatureKey: "key", + signedAt: at(minute), + }; +} + +function version( + id: string, + versionNumber: number, + contentHash: string, + status: string, + reviews: ReturnType[] | null, +): AuditSourceVersion { + return { + id, + versionNumber, + contentHash, + hashAlgorithm: "sha256", + fileName: "supply.md", + mimeType: "text/markdown", + fileSize: 9, + status, + createdBy: SIGNERS[0]!, + createdAt: at(versionNumber * 10), + reviewStartedAt: reviews ? at(versionNumber * 10 + 1) : null, + decidedAt: status === "Approved" ? at(versionNumber * 10 + 5) : null, + storageMode: "hashOnly", + contentRef: null, + reviewInstructions: null, + supersededAt: null, + signerSnapshot: reviews + ? { + walletId: WALLET_ID, + walletPolicyHash: POLICY, + requiredSigners: 2, + signersAddresses: SIGNERS, + signersDescriptions: ["Alice", "Bob", "Carol"], + capturedAt: at(versionNumber * 10 + 1), + } + : null, + reviews: reviews ?? [], + }; +} + +function attest( + versions: readonly AuditSourceVersion[], +): AuditSourceAttestation[] { + let prev = GENESIS_PREV; + return versions.map((v, i) => { + const payload = buildAttestationPayload({ + attestedAt: v.createdAt, + contentHash: v.contentHash, + documentId: DOC_ID, + prevAttestationHash: prev, + sequence: i + 1, + versionId: v.id, + versionNumber: v.versionNumber, + walletId: WALLET_ID, + }); + prev = attestationHash(payload); + return { + versionId: v.id, + sequence: i + 1, + payload: canonicalizeAttestation(payload), + signature: signAttestation(payload, privateKey), + publicKeyId: KEY_ID, + }; + }); +} + +/** + * v1 approved 2-of-3, v2 uploaded and abandoned, v3 restores v1's content and + * is back in review with one approval. + */ +function honestSource() { + const v1 = version("ver_1", 1, HASH.v1, "Superseded", [ + review("ver_1", 1, HASH.v1, SIGNERS[0]!, 12), + review("ver_1", 1, HASH.v1, SIGNERS[1]!, 13), + ]); + const v2 = version("ver_2", 2, HASH.v2, "Superseded", null); + const v3 = version("ver_3", 3, HASH.v1, "InReview", [ + review("ver_3", 3, HASH.v1, SIGNERS[2]!, 32), + ]); + const versions = [v1, v2, v3]; + const events: AuditSourceEvent[] = [ + { + versionId: null, + type: "document.created", + actorAddress: SIGNERS[0]!, + createdAt: at(0), + metadata: {}, + }, + { + versionId: "ver_1", + type: "version.uploaded", + actorAddress: SIGNERS[0]!, + createdAt: at(10), + metadata: { versionNumber: 1 }, + }, + { + versionId: "ver_1", + type: "version.superseded", + actorAddress: SIGNERS[0]!, + createdAt: at(20), + metadata: { versionNumber: 1 }, + }, + { + versionId: "ver_2", + type: "version.published", + actorAddress: SIGNERS[0]!, + createdAt: at(20), + metadata: { versionNumber: 2 }, + }, + { + versionId: "ver_2", + type: "version.superseded", + actorAddress: SIGNERS[1]!, + createdAt: at(30), + metadata: { versionNumber: 2 }, + }, + { + versionId: "ver_3", + type: "version.restored", + actorAddress: SIGNERS[1]!, + createdAt: at(30), + metadata: { + versionNumber: 3, + restoredFromVersionNumber: 1, + restoredFromVersionId: "ver_1", + }, + }, + ]; + return { versions, events, attestations: attest(versions) }; +} + +function honestPackage(): AuditPackage { + const src = honestSource(); + return buildAuditPackage({ + document, + ...src, + publicKeys: KEYS, + exportedAt: at(60), + }); +} + +/** Deep copy, so a test can tamper without touching the next one's package. */ +const clone = (pkg: AuditPackage): AuditPackage => + JSON.parse(JSON.stringify(pkg)) as AuditPackage; + +describe("versionLineage", () => { + it("tells a restore apart from an upload by its event, not its number", () => { + const { versions, events } = honestSource(); + const lineage = versionLineage(versions, events); + expect(lineage.get("ver_1")).toEqual({ + origin: "upload", + supersedes: null, + restoredFrom: null, + }); + expect(lineage.get("ver_2")).toEqual({ + origin: "draft", + supersedes: 1, + restoredFrom: null, + }); + expect(lineage.get("ver_3")).toEqual({ + origin: "restore", + supersedes: 2, + restoredFrom: 1, + }); + }); +}); + +describe("buildAuditPackage", () => { + it("bundles every version, the whole log and the chain", () => { + const pkg = honestPackage(); + expect(pkg.format).toBe(AUDIT_FORMAT); + expect(pkg.versions.map((v) => v.version.versionNumber)).toEqual([1, 2, 3]); + expect(pkg.versions.map((v) => v.attestationSequence)).toEqual([1, 2, 3]); + expect(pkg.events).toHaveLength(6); + expect(pkg.attestation?.chain).toHaveLength(3); + }); + + it("embeds a standalone proof.v1 for each version that had a round", () => { + const pkg = honestPackage(); + expect(pkg.versions.map((v) => v.proof?.format ?? null)).toEqual([ + PROOF_FORMAT, + null, + PROOF_FORMAT, + ]); + // A version's proof carries only its own events plus document-level ones. + const v3Events = pkg.versions[2]!.proof!.events.map((e) => e.type); + expect(v3Events).toEqual(["document.created", "version.restored"]); + }); + + it("omits the chain when the deployment never attested", () => { + const src = honestSource(); + const pkg = buildAuditPackage({ + document, + ...src, + attestations: [], + publicKeys: {}, + exportedAt: at(60), + }); + expect(pkg.attestation).toBeNull(); + expect(pkg.versions.every((v) => v.attestationSequence === null)).toBe( + true, + ); + }); +}); + +describe("verifyAuditPackage", () => { + it("accepts an honest package, against independently held keys", async () => { + const result = await verifyAuditPackage(honestPackage(), { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(result.keysFromPackage).toBe(false); + expect(result.chain?.ok).toBe(true); + expect( + result.versions.map((v) => [ + v.versionNumber, + v.approvals, + v.thresholdReached, + ]), + ).toEqual([ + [1, 2, true], + [2, 0, false], + [3, 1, false], + ]); + }); + + it("says when it only had the package's own keys to go on", async () => { + const result = await verifyAuditPackage(honestPackage(), { + checkSignature: acceptAll, + }); + expect(result.valid).toBe(true); + expect(result.keysFromPackage).toBe(true); + expect(result.warnings.join(" ")).toMatch(/obtained independently/); + }); + + it("fails when a signature does not verify", async () => { + const result = await verifyAuditPackage(honestPackage(), { + checkSignature: rejectAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.versions[0]!.signaturesValid).toBe(false); + }); + + it("catches a version dropped from the middle", async () => { + const pkg = clone(honestPackage()); + pkg.versions.splice(1, 1); + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/1\.\.N/); + }); + + it("catches a restore that does not carry the content it names", async () => { + const pkg = clone(honestPackage()); + pkg.versions[2]!.lineage.restoredFrom = 2; + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.versions[2]!.errors.join(" ")).toMatch( + /does not carry its content hash/, + ); + }); + + it("catches a version whose hash was swapped after it was attested", async () => { + const pkg = clone(honestPackage()); + pkg.versions[1]!.version.contentHash = "0".repeat(64); + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/attests a different content hash/); + }); + + it("catches a version that claims Approved without the approvals", async () => { + const pkg = clone(honestPackage()); + pkg.versions[2]!.version.status = "Approved"; + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.versions[2]!.errors.join(" ")).toMatch( + /do not reach the threshold/, + ); + }); + + it("catches a re-signed chain when the verifier holds the real key", async () => { + const pkg = clone(honestPackage()); + const forger = generateKeyPairSync("ed25519"); + const forgedSpki = forger.publicKey + .export({ type: "spki", format: "der" }) + .toString("hex"); + const forgedId = attestationKeyId(forgedSpki); + pkg.attestation!.publicKeys = { [forgedId]: forgedSpki }; + for (const record of pkg.attestation!.chain) { + record.signature = signAttestation(record.payload, forger.privateKey); + record.publicKeyId = forgedId; + } + // Internally consistent, so it passes on its own keys … + const selfCheck = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + }); + expect(selfCheck.valid).toBe(true); + // … and fails against the deployment's real one. + const real = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(real.valid).toBe(false); + expect(real.chain?.errors.join(" ")).toMatch(/unknown key/); + }); + + it("warns, but passes, when attestation began part-way through", async () => { + const src = honestSource(); + // Key configured after v1: the chain starts at v2 with sequence 1. + const later = attest(src.versions.slice(1)); + const pkg = buildAuditPackage({ + document, + ...src, + attestations: later, + publicKeys: KEYS, + exportedAt: at(60), + }); + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(result.warnings.join(" ")).toMatch( + /v1 predates the attestation chain/, + ); + }); + + it("errors when a version after the start of the chain is unattested", async () => { + const src = honestSource(); + const gappy = attest([src.versions[0]!, src.versions[2]!]); + const pkg = buildAuditPackage({ + document, + ...src, + attestations: gappy, + publicKeys: KEYS, + exportedAt: at(60), + }); + const result = await verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch( + /v2 is missing from the attestation chain/, + ); + }); +}); diff --git a/src/__tests__/documentDiff.test.ts b/src/__tests__/documentDiff.test.ts new file mode 100644 index 00000000..003633d6 --- /dev/null +++ b/src/__tests__/documentDiff.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + decodeVersionText, + diffLines, + diffVersions, + splitLines, + toHunks, + type DiffableVersion, +} from "@/lib/documents/diff"; +import { sha256Hex } from "@/lib/documents/payload"; + +/** + * Revision Provenance — version diff. + * + * The properties that matter: a line diff that reconstructs both sides + * exactly, honest refusal when the platform does not hold the bytes, and no + * line diff ever claiming two different hashes are "the same". + */ + +function inline( + id: string, + versionNumber: number, + text: string, + extra: Partial = {}, +): DiffableVersion { + const bytes = Buffer.from(text, "utf8"); + return { + id, + versionNumber, + contentHash: sha256Hex(bytes), + fileName: "contract.md", + mimeType: "text/markdown", + fileSize: bytes.length, + storageMode: "inline", + contentRef: null, + contentInline: bytes.toString("base64"), + reviewInstructions: null, + ...extra, + }; +} + +/** Rebuild each side from a diff — the strongest check that it is correct. */ +function sides(text: { old: string; new: string }) { + const lines = diffLines(text.old, text.new)!; + return { + lines, + old: lines.filter((l) => l.op !== "add").map((l) => l.text), + new: lines.filter((l) => l.op !== "remove").map((l) => l.text), + }; +} + +describe("splitLines", () => { + it("does not invent a line for a trailing newline", () => { + expect(splitLines("a\nb\n")).toEqual(["a", "b"]); + expect(splitLines("a\r\nb")).toEqual(["a", "b"]); + expect(splitLines("")).toEqual([]); + }); +}); + +describe("diffLines", () => { + it("reconstructs both sides exactly", () => { + const cases = [ + { old: "a\nb\nc\n", new: "a\nB\nc\n" }, + { old: "", new: "x\ny\n" }, + { old: "x\ny\n", new: "" }, + { old: "1\n2\n3\n4\n5\n", new: "0\n1\n3\n4\n6\n5\n" }, + { old: "same\n", new: "same\n" }, + ]; + for (const c of cases) { + const r = sides(c); + expect(r.old).toEqual(splitLines(c.old)); + expect(r.new).toEqual(splitLines(c.new)); + } + }); + + it("numbers lines on each side", () => { + const { lines } = sides({ old: "a\nb\nc\n", new: "a\nX\nc\n" }); + expect(lines).toEqual([ + { op: "equal", oldLine: 1, newLine: 1, text: "a" }, + { op: "add", oldLine: null, newLine: 2, text: "X" }, + { op: "remove", oldLine: 2, newLine: null, text: "b" }, + { op: "equal", oldLine: 3, newLine: 3, text: "c" }, + ]); + }); + + it("keeps a minimal edit for a one-word change in a long document", () => { + const body = Array.from({ length: 2000 }, (_, i) => `clause ${i}`); + const changed = [...body]; + changed[1000] = "clause 1000 (amended)"; + const lines = diffLines(body.join("\n"), changed.join("\n"))!; + expect(lines.filter((l) => l.op !== "equal")).toHaveLength(2); + }); + + it("gives up rather than stalling when the changed region is huge", () => { + const a = Array.from({ length: 300 }, (_, i) => `a${i}`).join("\n"); + const b = Array.from({ length: 300 }, (_, i) => `b${i}`).join("\n"); + expect(diffLines(a, b, 1000)).toBeNull(); + }); +}); + +describe("toHunks", () => { + it("returns no hunks for identical text", () => { + expect(toHunks(diffLines("a\nb\n", "a\nb\n")!)).toEqual([]); + }); + + it("splits distant changes and keeps three lines of context", () => { + const old = Array.from({ length: 30 }, (_, i) => `l${i + 1}`); + const next = [...old]; + next[1] = "CHANGED 2"; + next[25] = "CHANGED 26"; + const hunks = toHunks(diffLines(old.join("\n"), next.join("\n"))!); + expect(hunks).toHaveLength(2); + expect(hunks[0]).toMatchObject({ oldStart: 1, newStart: 1 }); + expect(hunks[1]).toMatchObject({ oldStart: 23, newStart: 23 }); + // 3 context + remove + add + 3 context + expect(hunks[1]!.lines).toHaveLength(8); + }); +}); + +describe("decodeVersionText", () => { + it("refuses bytes the platform never held", () => { + const r = decodeVersionText({ + storageMode: "hashOnly", + contentInline: null, + }); + expect(r).toEqual({ + ok: false, + reason: expect.stringMatching(/hash only/), + }); + }); + + it("refuses binary and invalid UTF-8 rather than mangling it", () => { + expect( + decodeVersionText({ + storageMode: "inline", + contentInline: Buffer.from([0x25, 0x50, 0x00, 0x46]).toString("base64"), + }).ok, + ).toBe(false); + expect( + decodeVersionText({ + storageMode: "inline", + contentInline: Buffer.from([0xc3, 0x28]).toString("base64"), + }).ok, + ).toBe(false); + }); + + it("keeps a genuine replacement character", () => { + const r = decodeVersionText({ + storageMode: "inline", + contentInline: Buffer.from("ok � ok", "utf8").toString("base64"), + }); + expect(r).toEqual({ ok: true, text: "ok � ok" }); + }); +}); + +describe("diffVersions", () => { + it("diffs two inline text versions", () => { + const d = diffVersions( + inline("v1", 1, "# Supply\n\nCeiling: 50000 EUR\n"), + inline("v2", 2, "# Supply\n\nCeiling: 125000 EUR\n"), + ); + expect(d.contentChanged).toBe(true); + expect(d.fieldChanges.map((c) => c.field)).toEqual([ + "contentHash", + "fileSize", + ]); + expect(d.content).toMatchObject({ kind: "text", added: 1, removed: 1 }); + }); + + it("calls same-hash versions identical, whatever else changed", () => { + const a = inline("v2", 2, "body\n"); + const b = { + ...a, + id: "v5", + versionNumber: 5, + reviewInstructions: "re-approve", + }; + const d = diffVersions(a, b); + expect(d.contentChanged).toBe(false); + expect(d.content.kind).toBe("identical"); + expect(d.fieldChanges).toEqual([ + { field: "reviewInstructions", from: null, to: "re-approve" }, + ]); + }); + + it("still reports a change between hash-only versions, without a line diff", () => { + const hashOnly = ( + id: string, + n: number, + hash: string, + ): DiffableVersion => ({ + ...inline(id, n, ""), + contentHash: hash, + storageMode: "hashOnly", + contentInline: null, + fileSize: 10, + }); + const d = diffVersions( + hashOnly("v1", 1, "a".repeat(64)), + hashOnly("v2", 2, "b".repeat(64)), + ); + expect(d.contentChanged).toBe(true); + expect(d.content).toEqual({ + kind: "unavailable", + reason: expect.stringMatching(/hash only.*did change/s), + }); + }); +}); diff --git a/src/__tests__/trpc/documentProvenance.test.ts b/src/__tests__/trpc/documentProvenance.test.ts new file mode 100644 index 00000000..c8fded20 --- /dev/null +++ b/src/__tests__/trpc/documentProvenance.test.ts @@ -0,0 +1,309 @@ +import { generateKeyPairSync } from "crypto"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + jest, +} from "@jest/globals"; + +import { cleanupFixtures, seedWallet } from "./fixtures"; +import { makeWalletCtx } from "./helpers"; + +/** + * Document Sign-Off v1 — Revision Provenance, end to end against a real + * database, with real CIP-8 signatures and a real attestation key. + * + * The chain under test: approve v1, supersede it with v2, diff the two, roll + * back to v1 (as v3), and export an audit package that an outside party can + * verify — including that the rollback did NOT resurrect v1's approvals. + */ + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +// Attestation is optional per deployment; turn it on here so the chain is part +// of what the audit export is checked against. Set before any app module is +// imported, because @/env reads the environment once. +const ATTESTATION_KEY = generateKeyPairSync("ed25519") + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); +process.env.DOCUMENT_ATTESTATION_KEY = ATTESTATION_KEY; + +const HAVE_DB = !!process.env.DATABASE_URL; +const describeWithDb = HAVE_DB ? describe : describe.skip; + +let createCaller: typeof import("@/server/api/root").createCaller; +let db: typeof import("@/server/db").db; +let sign: typeof import("@/utils/signing").sign; +let verifyAuditPackage: typeof import("@/lib/documents/audit").verifyAuditPackage; +let getAttestationPublicKeys: typeof import("@/lib/documents/attestation-key").getAttestationPublicKeys; +let checkSignature: typeof import("@meshsdk/core").checkSignature; + +// Deterministic throwaway key. Never used for anything but this test. +const MNEMONIC = Array(24).fill("solution"); +const OUTSIDER = + "addr_test1vpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5eg0yu80w"; + +let wallet: import("@meshsdk/core").MeshWallet; +let ADDRESS: string; +let walletIds: string[] = []; + +const BODY = + "# Supply agreement\n\nThe ceiling is 50000 EUR.\n\nTerm: 12 months.\n"; +const AMENDED = + "# Supply agreement\n\nThe ceiling is 90000 EUR.\n\nTerm: 12 months.\n"; + +describeWithDb("document provenance — diff, rollback, audit", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + ({ sign } = await import("@/utils/signing")); + ({ verifyAuditPackage } = await import("@/lib/documents/audit")); + ({ getAttestationPublicKeys } = + await import("@/lib/documents/attestation-key")); + + const mesh = await import("@meshsdk/core"); + checkSignature = mesh.checkSignature; + wallet = new mesh.MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + ADDRESS = + (await wallet.getUsedAddresses())[0] ?? + (await wallet.getUnusedAddresses())[0]!; + }, 60_000); + + afterEach(async () => { + for (const walletId of walletIds) { + await db.document.deleteMany({ where: { walletId } }); + await cleanupFixtures(db, { walletId }); + } + walletIds = []; + }); + + async function seed() { + const { walletId } = await seedWallet(db, ADDRESS); + walletIds.push(walletId); + return { + walletId, + caller: createCaller({ ...makeWalletCtx(ADDRESS), db }), + }; + } + + type Caller = Awaited>["caller"]; + + async function approve(caller: Caller, versionId: string) { + await caller.document.startReview({ versionId }); + const fresh = await caller.document.getVersionForReview({ + versionId, + action: "approve", + signerAddress: ADDRESS, + }); + const signature = await sign(fresh.payloadToSign!, wallet, 0, ADDRESS); + await caller.document.submitSignerAction({ + versionId, + action: "approve", + signerAddress: ADDRESS, + signedAt: fresh.payload!.signedAt, + payload: fresh.payloadToSign!, + signature: signature.signature, + signatureKey: signature.key, + }); + } + + async function publish(caller: Caller, documentId: string, body: string) { + const draft = await db.documentDraft.findUnique({ where: { documentId } }); + await caller.document.saveDraft({ + documentId, + body, + storeBody: true, + ...(draft ? { expectedRevision: draft.revision } : {}), + }); + return caller.document.publishDraft({ documentId }); + } + + it("diffs, rolls back without resurrecting approvals, and exports a verifiable audit", async () => { + const { walletId, caller } = await seed(); + const document = await caller.document.createDocument({ + walletId, + title: "Supply agreement", + }); + + // --- v1 approved, v2 supersedes it -------------------------------------- + const v1 = await publish(caller, document.id, BODY); + await approve(caller, v1.id); + expect( + (await db.documentVersion.findUnique({ where: { id: v1.id } }))?.status, + ).toBe("Approved"); + const v2 = await publish(caller, document.id, AMENDED); + + // --- diff ---------------------------------------------------------------- + const diff = await caller.document.diffVersions({ + documentId: document.id, + fromVersionId: v1.id, + toVersionId: v2.id, + }); + expect(diff.contentChanged).toBe(true); + expect(diff.content).toMatchObject({ kind: "text", added: 1, removed: 1 }); + if (diff.content.kind !== "text") throw new Error("expected a text diff"); + const changed = diff.content.hunks + .flatMap((h) => h.lines) + .filter((l) => l.op !== "equal") + .map((l) => `${l.op}:${l.text}`); + expect(changed).toEqual([ + "add:The ceiling is 90000 EUR.", + "remove:The ceiling is 50000 EUR.", + ]); + + // --- rollback ------------------------------------------------------------ + // Restoring the current head is a no-op and is refused, not duplicated. + await expect( + caller.document.restoreVersion({ + documentId: document.id, + versionId: v2.id, + }), + ).rejects.toThrow(/already the current version/); + + const v3 = await caller.document.restoreVersion({ + documentId: document.id, + versionId: v1.id, + }); + expect(v3.versionNumber).toBe(3); + expect(v3.contentHash).toBe(v1.contentHash); + expect(v3.contentInline).toBe(v1.contentInline); + expect(v3.status).toBe("Draft"); + // The point of the whole design: v1's approval stays on v1. + expect(await db.documentReview.count({ where: { versionId: v3.id } })).toBe( + 0, + ); + expect(await db.documentReview.count({ where: { versionId: v1.id } })).toBe( + 1, + ); + expect( + (await db.documentVersion.findUnique({ where: { id: v2.id } }))?.status, + ).toBe("Superseded"); + + const restoredEvent = await db.documentEvent.findFirst({ + where: { versionId: v3.id, type: "version.restored" }, + }); + expect(restoredEvent?.metadata).toMatchObject({ + restoredFromVersionNumber: 1, + restoredFromVersionId: v1.id, + replacedVersionNumber: 2, + approvalsReset: true, + }); + + const sameContent = await caller.document.diffVersions({ + documentId: document.id, + fromVersionId: v1.id, + toVersionId: v3.id, + }); + expect(sameContent.contentChanged).toBe(false); + expect(sameContent.content.kind).toBe("identical"); + + // --- audit export -------------------------------------------------------- + const pkg = await caller.document.exportAudit({ documentId: document.id }); + expect(pkg.versions.map((v) => v.lineage)).toEqual([ + { origin: "draft", supersedes: null, restoredFrom: null }, + { origin: "draft", supersedes: 1, restoredFrom: null }, + { origin: "restore", supersedes: 2, restoredFrom: 1 }, + ]); + expect(pkg.attestation?.chain).toHaveLength(3); + expect( + await db.documentEvent.count({ + where: { documentId: document.id, type: "audit.exported" }, + }), + ).toBe(1); + + // Checked offline, with the real CIP-8 verifier and the deployment's keys. + const result = await verifyAuditPackage(pkg, { + checkSignature, + publicKeys: getAttestationPublicKeys(), + }); + expect(result.errors).toEqual([]); + expect(result.valid).toBe(true); + expect(result.chain?.ok).toBe(true); + expect( + result.versions.map((v) => [ + v.versionNumber, + v.approvals, + v.thresholdReached, + ]), + ).toEqual([ + [1, 1, true], + [2, 0, false], + [3, 0, false], + ]); + + // The public procedure reaches the same verdict. + const remote = await caller.document.verifyAudit({ audit: pkg }); + expect(remote.valid).toBe(true); + + // And catches a tampered export: drop the version that was rolled back from. + const tampered = JSON.parse(JSON.stringify(pkg)) as typeof pkg; + tampered.versions.splice(1, 1); + expect((await caller.document.verifyAudit({ audit: tampered })).valid).toBe( + false, + ); + }, 180_000); + + it("keeps rollback and the audit export to wallet members", async () => { + const { walletId, caller } = await seed(); + const document = await caller.document.createDocument({ + walletId, + title: "Members only", + }); + const v1 = await publish(caller, document.id, BODY); + await publish(caller, document.id, AMENDED); + + const outsider = createCaller({ ...makeWalletCtx(OUTSIDER), db }); + await expect( + outsider.document.restoreVersion({ + documentId: document.id, + versionId: v1.id, + }), + ).rejects.toThrow(/not authorized/i); + await expect( + outsider.document.exportAudit({ documentId: document.id }), + ).rejects.toThrow(/not authorized/i); + await expect( + outsider.document.diffVersions({ + documentId: document.id, + fromVersionId: v1.id, + toVersionId: v1.id, + }), + ).rejects.toThrow(/not authorized/i); + }, 120_000); + + it("refuses to restore a version from a different document", async () => { + const { walletId, caller } = await seed(); + const a = await caller.document.createDocument({ walletId, title: "A" }); + const b = await caller.document.createDocument({ walletId, title: "B" }); + const aV1 = await publish(caller, a.id, BODY); + await publish(caller, b.id, AMENDED); + + await expect( + caller.document.restoreVersion({ documentId: b.id, versionId: aV1.id }), + ).rejects.toThrow(/not found on this document/i); + await expect( + caller.document.diffVersions({ + documentId: b.id, + fromVersionId: aV1.id, + toVersionId: aV1.id, + }), + ).rejects.toThrow(/not found on this document/i); + }, 120_000); +}); diff --git a/src/components/pages/homepage/roadmap/data.ts b/src/components/pages/homepage/roadmap/data.ts index 59193cfa..50ae2dc8 100644 --- a/src/components/pages/homepage/roadmap/data.ts +++ b/src/components/pages/homepage/roadmap/data.ts @@ -87,7 +87,7 @@ export const TRACKS: Track[] = [ label: "MVP — build & ship", start: 4, span: 2, - status: "planned", + status: "done", owner: "Q·A", detail: "Finalize PRD-001 (still Draft), then a five-model schema, tRPC routes, CIP-8 enforcement, version-hash binding, Documents UI, six-state lifecycle and proof export. Ready = a pilot team runs all six user stories unaided.", @@ -96,10 +96,10 @@ export const TRACKS: Track[] = [ label: "v1 history", start: 6, span: 1, - status: "planned", + status: "done", owner: "Q", detail: - "v1: revision history first-class, diff and rollback, richer audit export. Still off-chain.", + "v1: chained version attestations, diff between any two versions, rollback by re-publishing (append-only, approvals reset), and an offline-verifiable audit export of the whole document. Still off-chain.", }, { label: "v2 anchor", diff --git a/src/components/pages/wallet/documents/detail.tsx b/src/components/pages/wallet/documents/detail.tsx index 8fdc33c2..2c2f752f 100644 --- a/src/components/pages/wallet/documents/detail.tsx +++ b/src/components/pages/wallet/documents/detail.tsx @@ -4,11 +4,14 @@ import { useRouter } from "next/router"; import { Archive, Download, + FileArchive, FileJson, FileText, + GitCompare, MoreVertical, Pencil, PlayCircle, + RotateCcw, ShieldCheck, Trash2, Upload, @@ -42,6 +45,7 @@ import PageHeader from "@/components/ui/page-header"; import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton"; import DocumentStatusBadge from "./status-badge"; import { sha256HexFromFile } from "./hash-file"; +import VersionDiffDialog from "./version-diff-dialog"; /** * Document detail — lifecycle, version history, who approved, who is missing. @@ -58,6 +62,14 @@ export default function PageDocumentDetail() { const [uploading, setUploading] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const [typedTitle, setTypedTitle] = useState(""); + const [comparing, setComparing] = useState<{ + fromId: string; + toId: string; + } | null>(null); + const [restoring, setRestoring] = useState<{ + id: string; + versionNumber: number; + } | null>(null); const utils = api.useUtils(); const { data: document, isLoading } = api.document.getById.useQuery( @@ -115,7 +127,36 @@ export default function PageDocumentDetail() { onError: (error) => toastError(error, "Could not delete the document"), }); + const restoreVersion = api.document.restoreVersion.useMutation({ + onSuccess: async (version) => { + setRestoring(null); + await refresh(); + toast({ + title: `Restored as version ${version.versionNumber}`, + description: + "Same content, new round — signers approve it again from zero.", + }); + }, + onError: (error) => toastError(error, "Could not restore that version"), + }); + const exportProof = api.document.exportProof.useMutation(); + const exportAudit = api.document.exportAudit.useMutation(); + + /** + * The whole document's record in one file: every version with its lineage + * and proof, the event log, and the attestation chain. Checkable offline — + * see src/lib/documents/audit.ts. + */ + async function onExportAudit() { + try { + const pkg = await exportAudit.mutateAsync({ documentId }); + downloadJson(pkg, `signoff-audit-${pkg.document.id}.json`); + await utils.document.getById.invalidate({ documentId }); + } catch (error) { + toastError(error, "Could not export the audit trail"); + } + } /** * Both exports are the same package. The JSON is what a verifier checks; the @@ -134,9 +175,8 @@ export default function PageDocumentDetail() { ); return; } - const { buildProofPdf, proofPdfFileName } = await import( - "@/lib/documents/proof-pdf" - ); + const { buildProofPdf, proofPdfFileName } = + await import("@/lib/documents/proof-pdf"); downloadFile( buildProofPdf(proof, { verifyUrl: absoluteUrl("/verify") }), proofPdfFileName(proof), @@ -183,6 +223,26 @@ export default function PageDocumentDetail() { 0, ); + // Versions arrive newest first, so the head is index 0. + const headId = document.versions[0]?.id; + const versionOptions = document.versions.map((v) => ({ + id: v.id, + versionNumber: v.versionNumber, + })); + const restoredFrom = new Map(); + for (const event of document.events) { + const meta = event.metadata as { + restoredFromVersionNumber?: unknown; + } | null; + if ( + event.type === "version.restored" && + event.versionId && + typeof meta?.restoredFromVersionNumber === "number" + ) { + restoredFrom.set(event.versionId, meta.restoredFromVersionNumber); + } + } + return (
+ void onExportAudit()} + > + + Export audit trail (JSON) +
- {document.versions.map((version) => { + {document.versions.map((version, index) => { + const previous = document.versions[index + 1]; const snapshot = version.signerSnapshot; const approvals = version.reviews.filter( (r) => r.action === "approve", @@ -265,11 +333,44 @@ export default function PageDocumentDetail() { return ( - + Version {version.versionNumber} + {restoredFrom.has(version.id) && ( + + restored from v{restoredFrom.get(version.id)} + + )} -
+
+ {previous && ( + + )} + {version.id !== headId && document.status !== "Archived" && ( + + )} {version.status === "Draft" && ( + + + + + diff --git a/src/components/pages/wallet/documents/version-diff-dialog.tsx b/src/components/pages/wallet/documents/version-diff-dialog.tsx new file mode 100644 index 00000000..32f14707 --- /dev/null +++ b/src/components/pages/wallet/documents/version-diff-dialog.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/utils/api"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { DiffLine, FieldChange } from "@/lib/documents/diff"; + +type VersionOption = { id: string; versionNumber: number }; + +const FIELD_LABELS: Record = { + contentHash: "Content hash", + fileName: "File name", + mimeType: "Type", + fileSize: "Size (bytes)", + storageMode: "Storage", + contentRef: "External reference", + reviewInstructions: "Review instructions", +}; + +const LINE_STYLE: Record = { + equal: "text-muted-foreground", + add: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + remove: "bg-red-500/10 text-red-700 dark:text-red-300", +}; + +const LINE_MARK: Record = { + equal: " ", + add: "+", + remove: "−", +}; + +function short(value: string | number | null): string { + if (value === null) return "—"; + const text = String(value); + return text.length > 24 && /^[0-9a-f]+$/i.test(text) + ? `${text.slice(0, 10)}…${text.slice(-6)}` + : text; +} + +/** + * Compare two versions of a document. Opens on "this version against the one + * before it"; either side can be switched, so a rollback can be checked + * against the version it re-publishes. + */ +export default function VersionDiffDialog({ + documentId, + versions, + initialFromId, + initialToId, + open, + onOpenChange, +}: { + documentId: string; + versions: VersionOption[]; + initialFromId: string; + initialToId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [fromId, setFromId] = useState(initialFromId); + const [toId, setToId] = useState(initialToId); + + // Re-seed when the dialog is opened from a different version card. + useEffect(() => { + if (open) { + setFromId(initialFromId); + setToId(initialToId); + } + }, [open, initialFromId, initialToId]); + + const { data, isLoading, error } = api.document.diffVersions.useQuery( + { documentId, fromVersionId: fromId, toVersionId: toId }, + { enabled: open && !!fromId && !!toId && fromId !== toId }, + ); + + const ordered = [...versions].sort( + (a, b) => a.versionNumber - b.versionNumber, + ); + const picker = ( + value: string, + onChange: (id: string) => void, + label: string, + ) => ( + + ); + + return ( + + + + Compare versions + + Hash-bound: if the content hashes match, the bytes are identical. + + + +
+ {picker(fromId, setFromId, "Compare from version")} + → + {picker(toId, setToId, "Compare to version")} +
+ + {fromId === toId ? ( +

+ Pick two different versions. +

+ ) : isLoading ? ( +

Comparing…

+ ) : error ? ( +

{error.message}

+ ) : data ? ( +
+ {data.fieldChanges.length > 0 && ( + + + {data.fieldChanges.map((change) => ( + + + + + + ))} + +
+ {FIELD_LABELS[change.field]} + + {short(change.from)} + + {short(change.to)} +
+ )} + + {data.content.kind === "text" ? ( +
+ + +{data.content.added} / −{data.content.removed} lines + + {data.content.hunks.map((hunk, index) => ( +
+                    
+ @@ −{hunk.oldStart},{hunk.oldLines} +{hunk.newStart}, + {hunk.newLines} @@ +
+ {hunk.lines.map((line, i) => ( +
+ {LINE_MARK[line.op]} {line.text} +
+ ))} +
+ ))} +
+ ) : ( +

+ {data.content.reason} +

+ )} +
+ ) : null} +
+
+ ); +} diff --git a/src/lib/documents/audit.ts b/src/lib/documents/audit.ts new file mode 100644 index 00000000..1e03f87a --- /dev/null +++ b/src/lib/documents/audit.ts @@ -0,0 +1,502 @@ +/** + * Document Sign-Off v1 (Revision Provenance) — the audit export. + * + * A proof package (`proof.ts`) answers one question about one version: did the + * wallet's signers approve these exact bytes? An audit package answers the + * question an auditor asks about the whole document: what happened, in what + * order, and can I check it without trusting this database? + * + * It bundles, for every version: + * - the version row and its lineage (what it superseded, what it restored), + * - its proof package, built by the same `buildProofPackage` that + * `exportProof` uses, so any proof verifier checks it unchanged, + * - its position in the platform attestation chain, + * plus the full event log and the attestation chain with the public keys. + * + * `verifyAuditPackage` re-runs every check offline: each version's signatures, + * the attestation chain, and the cross-checks that tie the two together (every + * version attested once, at its own hash; every restore carrying the hash of + * the version it names). Like proof.ts it takes the CIP-8 check as an argument + * and has no Cardano dependency. + * + * A new format rather than more fields on `proof.v1`: that format's field + * names are a contract existing verifiers parse. + */ + +import { + ATTESTATION_DOMAIN, + ATTESTATION_STATEMENT, + verifyAttestationChain, + type AttestationPayload, + type AttestationRecord, + type ChainVerification, +} from "./attestation"; +import { normalizeContentHash } from "./payload"; +import { + buildProofPackage, + verifyProofPackage, + type CheckSignatureFn, + type ProofPackage, + type ProofSourceDocument, + type ProofSourceEvent, + type ProofSourceReview, + type ProofSourceSnapshot, + type ProofSourceVersion, +} from "./proof"; + +export const AUDIT_FORMAT = "mesh-multisig.document-signoff.audit.v1"; + +/** How a version came to exist, read from the event that created it. */ +export type VersionOrigin = "upload" | "draft" | "restore" | "unknown"; + +export interface AuditVersion { + version: ProofPackage["version"] & { + storageMode: string; + contentRef: string | null; + reviewInstructions: string | null; + supersededAt: string | null; + }; + lineage: { + origin: VersionOrigin; + /** Version number this one superseded, if any. */ + supersedes: number | null; + /** For a restore: the version whose content this one re-publishes. */ + restoredFrom: number | null; + }; + /** Null when no review round was ever started for this version. */ + proof: ProofPackage | null; + /** Position in the attestation chain, or null when the version is unattested. */ + attestationSequence: number | null; +} + +export interface AuditPackage { + format: typeof AUDIT_FORMAT; + exportedAt: string; + document: ProofPackage["document"] & { + status: string; + signingMode: string; + archivedAt: string | null; + }; + /** Ascending by version number. */ + versions: AuditVersion[]; + /** The whole log, ascending — document-level and every version's. */ + events: ProofPackage["events"]; + /** Null when the deployment has no attestation key configured. */ + attestation: { + domain: string; + statement: string; + /** Key id → SPKI DER hex, as published by the exporting deployment. */ + publicKeys: Record; + chain: AttestationRecord[]; + } | null; + verification: { + domain: typeof AUDIT_FORMAT; + instructions: string[]; + }; +} + +export const AUDIT_INSTRUCTIONS = [ + "1. Versions must run 1..N without gaps. For each version with a `proof`, verify that proof exactly as a standalone `proof.v1` package — its four steps are in `proof.verification.instructions`.", + "2. If `attestation` is present, verify the chain: each payload is signed (Ed25519) by the key named in `publicKeyId`, links to the SHA-256 of the previous canonical payload, and runs sequence 1..N in non-decreasing time. Prefer public keys you obtained independently over the ones in this file.", + "3. Cross-check: every version appears in the chain exactly once, with the same versionId, versionNumber and contentHash.", + "4. A version whose lineage says `restoredFrom: k` must carry the same contentHash as version k. A restore re-publishes old bytes as a new version; it never carries the old version's approvals forward.", + "The attestation chain is a timestamp and ordering record by the platform. It is not an approval. Approval is the CIP-8 signatures inside each version's proof.", +]; + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +export interface AuditSourceVersion extends ProofSourceVersion { + storageMode: string; + contentRef: string | null; + reviewInstructions: string | null; + supersededAt: Date | null; + signerSnapshot: ProofSourceSnapshot | null; + reviews: readonly ProofSourceReview[]; +} + +export interface AuditSourceEvent extends ProofSourceEvent { + metadata: unknown; +} + +export interface AuditSourceAttestation { + versionId: string; + sequence: number; + /** The exact canonical JSON that was signed. */ + payload: string; + signature: string; + publicKeyId: string; +} + +function metadataNumber(metadata: unknown, key: string): number | null { + if (!metadata || typeof metadata !== "object") return null; + const value = (metadata as Record)[key]; + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +/** + * Lineage for every version, from the event log. + * + * The event that created a version names how it was made; the matching + * `version.superseded` event (same transaction) names what it replaced. Reading + * this from events rather than inferring it from version numbers is what lets a + * restore of v2 as v5 be told apart from an ordinary v5 upload. + */ +export function versionLineage( + versions: readonly { id: string; versionNumber: number }[], + events: readonly AuditSourceEvent[], +): Map { + const byId = new Map(); + const sorted = [...versions].sort( + (a, b) => a.versionNumber - b.versionNumber, + ); + + for (const [index, version] of sorted.entries()) { + const created = events.find( + (e) => + e.versionId === version.id && + (e.type === "version.uploaded" || + e.type === "version.published" || + e.type === "version.restored"), + ); + const origin: VersionOrigin = + created?.type === "version.uploaded" + ? "upload" + : created?.type === "version.published" + ? "draft" + : created?.type === "version.restored" + ? "restore" + : "unknown"; + + byId.set(version.id, { + origin, + supersedes: index > 0 ? sorted[index - 1]!.versionNumber : null, + restoredFrom: + origin === "restore" + ? metadataNumber(created?.metadata, "restoredFromVersionNumber") + : null, + }); + } + return byId; +} + +export function buildAuditPackage(input: { + document: ProofSourceDocument & { + status: string; + signingMode: string; + archivedAt: Date | null; + }; + versions: readonly AuditSourceVersion[]; + events: readonly AuditSourceEvent[]; + attestations: readonly AuditSourceAttestation[]; + publicKeys: Record; + exportedAt: Date; +}): AuditPackage { + const { document } = input; + const events = [...input.events].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + const versions = [...input.versions].sort( + (a, b) => a.versionNumber - b.versionNumber, + ); + const attestations = [...input.attestations].sort( + (a, b) => a.sequence - b.sequence, + ); + const lineage = versionLineage(versions, events); + const sequenceFor = new Map( + attestations.map((a) => [a.versionId, a.sequence]), + ); + + return { + format: AUDIT_FORMAT, + exportedAt: input.exportedAt.toISOString(), + document: { + id: document.id, + walletId: document.walletId, + title: document.title, + description: document.description, + documentType: document.documentType, + createdBy: document.createdBy, + createdAt: document.createdAt.toISOString(), + status: document.status, + signingMode: document.signingMode, + archivedAt: document.archivedAt?.toISOString() ?? null, + }, + versions: versions.map((v) => ({ + version: { + id: v.id, + versionNumber: v.versionNumber, + contentHash: v.contentHash, + hashAlgorithm: v.hashAlgorithm, + fileName: v.fileName, + mimeType: v.mimeType, + fileSize: v.fileSize, + status: v.status, + createdBy: v.createdBy, + createdAt: v.createdAt.toISOString(), + reviewStartedAt: v.reviewStartedAt?.toISOString() ?? null, + decidedAt: v.decidedAt?.toISOString() ?? null, + storageMode: v.storageMode, + contentRef: v.contentRef, + reviewInstructions: v.reviewInstructions, + supersededAt: v.supersededAt?.toISOString() ?? null, + }, + lineage: lineage.get(v.id)!, + proof: v.signerSnapshot + ? buildProofPackage({ + document, + version: v, + snapshot: v.signerSnapshot, + reviews: v.reviews, + events, + exportedAt: input.exportedAt, + }) + : null, + attestationSequence: sequenceFor.get(v.id) ?? null, + })), + events: events.map((e) => ({ + type: e.type, + actorAddress: e.actorAddress, + createdAt: e.createdAt.toISOString(), + metadata: e.metadata, + })), + attestation: + attestations.length > 0 + ? { + domain: ATTESTATION_DOMAIN, + statement: ATTESTATION_STATEMENT, + publicKeys: input.publicKeys, + chain: attestations.map((a) => ({ + payload: JSON.parse(a.payload) as AttestationPayload, + signature: a.signature, + publicKeyId: a.publicKeyId, + })), + } + : null, + verification: { + domain: AUDIT_FORMAT, + instructions: AUDIT_INSTRUCTIONS, + }, + }; +} + +// --------------------------------------------------------------------------- +// Verify +// --------------------------------------------------------------------------- + +export interface AuditVersionVerdict { + versionNumber: number; + status: string; + hasProof: boolean; + /** Every review's payload binds to this version and its signature verifies. */ + signaturesValid: boolean; + approvals: number; + rejections: number; + requiredSigners: number | null; + thresholdReached: boolean; + errors: string[]; +} + +export interface AuditVerification { + /** Every check passed. A version that was never approved does not fail this. */ + valid: boolean; + format: string; + versions: AuditVersionVerdict[]; + chain: ChainVerification | null; + /** + * True when the chain was checked against keys carried in the package itself. + * That proves internal consistency only; pass `publicKeys` obtained + * independently to prove it came from the deployment that holds them. + */ + keysFromPackage: boolean; + errors: string[]; + /** Not failures, but worth an auditor's attention. */ + warnings: string[]; +} + +export async function verifyAuditPackage( + pkg: AuditPackage, + options: { + checkSignature: CheckSignatureFn; + publicKeys?: Readonly>; + }, +): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + if (pkg.format !== AUDIT_FORMAT) { + errors.push(`Unknown audit format "${String(pkg.format)}"`); + } + + const byNumber = new Map(); + pkg.versions.forEach((entry, index) => { + const n = entry.version.versionNumber; + if (n !== index + 1) { + errors.push( + `Versions must run 1..N in order; position ${index + 1} holds v${n}.`, + ); + } + byNumber.set(n, entry); + }); + + const verdicts: AuditVersionVerdict[] = []; + for (const entry of pkg.versions) { + const n = entry.version.versionNumber; + const verdict: AuditVersionVerdict = { + versionNumber: n, + status: entry.version.status, + hasProof: entry.proof !== null, + signaturesValid: true, + approvals: 0, + rejections: 0, + requiredSigners: null, + thresholdReached: false, + errors: [], + }; + + if (entry.proof) { + const proof = entry.proof; + if (proof.document.id !== pkg.document.id) { + verdict.errors.push("proof belongs to a different document"); + } + if ( + proof.version.id !== entry.version.id || + proof.version.versionNumber !== n || + normalizeContentHash(proof.version.contentHash) !== + normalizeContentHash(entry.version.contentHash) + ) { + verdict.errors.push("proof does not describe this version"); + } + const result = await verifyProofPackage(proof, { + checkSignature: options.checkSignature, + }); + verdict.signaturesValid = result.reviews.every((r) => r.valid); + verdict.approvals = result.approvals; + verdict.rejections = result.rejections; + verdict.requiredSigners = result.requiredSigners; + verdict.thresholdReached = result.thresholdReached; + for (const r of result.reviews) { + for (const e of r.errors) + verdict.errors.push(`${r.signerAddress}: ${e}`); + } + // A version marked Approved must actually carry enough valid approvals. + if (entry.version.status === "Approved" && !result.thresholdReached) { + verdict.errors.push( + "marked Approved but its valid approvals do not reach the threshold", + ); + } + } else if ( + entry.version.status === "Approved" || + entry.version.status === "Rejected" + ) { + verdict.errors.push( + `marked ${entry.version.status} but carries no review round`, + ); + } + + if (entry.lineage.origin === "restore") { + const source = + entry.lineage.restoredFrom === null + ? undefined + : byNumber.get(entry.lineage.restoredFrom); + if (!source) { + verdict.errors.push( + "restore names a version that is not in this package", + ); + } else if ( + normalizeContentHash(source.version.contentHash) !== + normalizeContentHash(entry.version.contentHash) + ) { + verdict.errors.push( + `restore of v${source.version.versionNumber} does not carry its content hash`, + ); + } else if (source.version.versionNumber >= n) { + verdict.errors.push("restore names a later version"); + } + } + + verdicts.push(verdict); + } + + let chain: ChainVerification | null = null; + const keysFromPackage = !options.publicKeys; + if (pkg.attestation) { + chain = verifyAttestationChain( + pkg.attestation.chain, + options.publicKeys ?? pkg.attestation.publicKeys, + ); + + const seen = new Set(); + for (const record of pkg.attestation.chain) { + const p = record.payload; + const entry = byNumber.get(p.versionNumber); + if (p.documentId !== pkg.document.id) { + errors.push(`attestation ${p.sequence} names a different document`); + } + if (!entry || entry.version.id !== p.versionId) { + errors.push( + `attestation ${p.sequence} names v${p.versionNumber}, which is not in this package`, + ); + continue; + } + if ( + normalizeContentHash(entry.version.contentHash) !== + normalizeContentHash(p.contentHash) + ) { + errors.push( + `attestation ${p.sequence} attests a different content hash than v${p.versionNumber}`, + ); + } + if (entry.attestationSequence !== p.sequence) { + errors.push( + `v${p.versionNumber} claims attestation ${String(entry.attestationSequence)}, chain says ${p.sequence}`, + ); + } + if (seen.has(p.versionId)) { + errors.push(`v${p.versionNumber} is attested more than once`); + } + seen.add(p.versionId); + } + // Attestation is optional per deployment, so a key configured part-way + // through a document's life leaves its earliest versions unattested. That + // is a warning. A version AFTER the first attested one with no link is a + // gap in the record, and that is an error. + const firstAttested = Math.min( + ...pkg.attestation.chain.map((r) => r.payload.versionNumber), + ); + for (const entry of pkg.versions) { + if (seen.has(entry.version.id)) continue; + const n = entry.version.versionNumber; + if (n < firstAttested) { + warnings.push( + `v${n} predates the attestation chain (attestation began at v${firstAttested})`, + ); + } else { + errors.push(`v${n} is missing from the attestation chain`); + } + } + } else if (pkg.versions.length > 0) { + warnings.push( + "No attestation chain: version order and timing rest on the exporting database alone.", + ); + } + if (keysFromPackage && pkg.attestation) { + warnings.push( + "The chain was checked against public keys carried in this file. Supply keys obtained independently to rule out a re-signed export.", + ); + } + + return { + valid: + errors.length === 0 && + verdicts.every((v) => v.errors.length === 0 && v.signaturesValid) && + (chain === null || chain.ok), + format: pkg.format, + versions: verdicts, + chain, + keysFromPackage, + errors, + warnings, + }; +} diff --git a/src/lib/documents/diff.ts b/src/lib/documents/diff.ts new file mode 100644 index 00000000..e239c350 --- /dev/null +++ b/src/lib/documents/diff.ts @@ -0,0 +1,375 @@ +/** + * Document Sign-Off v1 (Revision Provenance) — version diff. + * + * Two layers, because the platform does not always hold the bytes: + * + * - **Metadata diff** works for every version, including `hashOnly` ones whose + * content never left the signer's machine. It answers "did the content + * change, and what else changed with it" from the rows alone. + * - **Content diff** is a line diff, and only runs when both versions carry + * their bytes inline and those bytes decode as text. For anything else the + * result says why there is no content diff instead of guessing. + * + * Dependency-free on purpose (node Buffer only), like payload.ts and proof.ts, + * so it runs the same in the router, a test, or an offline tool. + */ + +/** Fields compared between two versions, in display order. */ +export const DIFFED_VERSION_FIELDS = [ + "contentHash", + "fileName", + "mimeType", + "fileSize", + "storageMode", + "contentRef", + "reviewInstructions", +] as const; + +export type DiffedVersionField = (typeof DIFFED_VERSION_FIELDS)[number]; + +export interface DiffableVersion { + id: string; + versionNumber: number; + contentHash: string; + fileName: string | null; + mimeType: string | null; + fileSize: number | null; + storageMode: "hashOnly" | "inline" | "external"; + contentRef: string | null; + contentInline: string | null; + reviewInstructions: string | null; +} + +export interface FieldChange { + field: DiffedVersionField; + from: string | number | null; + to: string | number | null; +} + +export type DiffOp = "equal" | "add" | "remove"; + +export interface DiffLine { + op: DiffOp; + /** 1-based line number in the older version; null for added lines. */ + oldLine: number | null; + /** 1-based line number in the newer version; null for removed lines. */ + newLine: number | null; + text: string; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffLine[]; +} + +export type ContentDiff = + | { + kind: "identical"; + reason: string; + } + | { + kind: "text"; + added: number; + removed: number; + hunks: DiffHunk[]; + } + | { + kind: "unavailable"; + reason: string; + }; + +export interface VersionDiff { + from: { id: string; versionNumber: number; contentHash: string }; + to: { id: string; versionNumber: number; contentHash: string }; + contentChanged: boolean; + fieldChanges: FieldChange[]; + content: ContentDiff; +} + +/** + * Upper bound on the comparison grid after the common prefix and suffix are + * trimmed. The LCS table is old×new cells; at 4M cells it stays well under + * 100ms and 16MB. Past it the result says the diff is too large rather than + * stalling a request — two hash-bound versions are still distinguishable by + * their hashes, which the metadata diff always reports. + */ +export const MAX_DIFF_CELLS = 4_000_000; + +/** Lines of unchanged context kept around each change. */ +export const DIFF_CONTEXT = 3; + +/** + * Decode inline bytes as text, or explain why not. + * + * Text is judged by content, not by the declared MIME type: the type is + * client-supplied, and a Markdown file uploaded as `application/octet-stream` + * is still worth diffing. A NUL byte or invalid UTF-8 means binary. + */ +export function decodeVersionText( + version: Pick, +): { ok: true; text: string } | { ok: false; reason: string } { + if (version.storageMode !== "inline" || version.contentInline === null) { + return { + ok: false, + reason: + version.storageMode === "external" + ? "stored externally — the platform only holds a reference" + : "hash only — the bytes never left the signer's machine", + }; + } + const bytes = Buffer.from(version.contentInline, "base64"); + if (bytes.includes(0)) { + return { ok: false, reason: "binary content" }; + } + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + // TextDecoder substitutes U+FFFD for invalid sequences. Round-tripping tells + // a genuine replacement character apart from a decoding failure. + if (!Buffer.from(text, "utf8").equals(bytes)) { + return { ok: false, reason: "not valid UTF-8 text" }; + } + return { ok: true, text }; +} + +/** Split into lines, keeping a trailing newline from becoming a phantom line. */ +export function splitLines(text: string): string[] { + if (text === "") return []; + const lines = text.split(/\r\n|\n|\r/); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +/** + * Line diff: trim the shared prefix and suffix, then an LCS over what is left. + * + * Edits to a document are usually local, so the trimmed middle is small and the + * quadratic step is cheap in practice. Returns null when the middle is still + * too large for MAX_DIFF_CELLS. + */ +export function diffLines( + oldText: string, + newText: string, + maxCells: number = MAX_DIFF_CELLS, +): DiffLine[] | null { + const a = splitLines(oldText); + const b = splitLines(newText); + + let prefix = 0; + while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) { + prefix++; + } + let suffix = 0; + while ( + suffix < a.length - prefix && + suffix < b.length - prefix && + a[a.length - 1 - suffix] === b[b.length - 1 - suffix] + ) { + suffix++; + } + + const midA = a.slice(prefix, a.length - suffix); + const midB = b.slice(prefix, b.length - suffix); + const n = midA.length; + const m = midB.length; + + if (n * m > maxCells) return null; + + // lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened row-major. + const width = m + 1; + const lcs = new Uint32Array((n + 1) * width); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i * width + j] = + midA[i] === midB[j] + ? lcs[(i + 1) * width + (j + 1)]! + 1 + : Math.max(lcs[(i + 1) * width + j]!, lcs[i * width + (j + 1)]!); + } + } + + const out: DiffLine[] = []; + let oldLine = 1; + let newLine = 1; + const equal = (text: string) => + out.push({ op: "equal", oldLine: oldLine++, newLine: newLine++, text }); + + for (let k = 0; k < prefix; k++) equal(a[k]!); + + let i = 0; + let j = 0; + while (i < n || j < m) { + if (i < n && j < m && midA[i] === midB[j]) { + equal(midA[i]!); + i++; + j++; + } else if ( + j < m && + (i >= n || lcs[i * width + (j + 1)]! >= lcs[(i + 1) * width + j]!) + ) { + out.push({ + op: "add", + oldLine: null, + newLine: newLine++, + text: midB[j]!, + }); + j++; + } else { + out.push({ + op: "remove", + oldLine: oldLine++, + newLine: null, + text: midA[i]!, + }); + i++; + } + } + + for (let k = a.length - suffix; k < a.length; k++) equal(a[k]!); + + return out; +} + +/** + * Group a line diff into hunks with `context` unchanged lines around each + * change, unified-diff style. Equal runs longer than 2×context between changes + * split the hunk; an all-equal diff yields no hunks. + */ +export function toHunks( + lines: DiffLine[], + context: number = DIFF_CONTEXT, +): DiffHunk[] { + const changed = lines + .map((line, index) => (line.op === "equal" ? -1 : index)) + .filter((index) => index >= 0); + if (changed.length === 0) return []; + + const ranges: Array<[number, number]> = []; + for (const index of changed) { + const start = Math.max(0, index - context); + const end = Math.min(lines.length - 1, index + context); + const last = ranges[ranges.length - 1]; + if (last && start <= last[1] + 1) { + last[1] = Math.max(last[1], end); + } else { + ranges.push([start, end]); + } + } + + return ranges.map(([start, end]) => { + const slice = lines.slice(start, end + 1); + // A hunk's start is the first line number it shows on that side. For a + // side with no lines in the hunk (pure insert/delete) use the line before + // it, which is what unified diff does. + const firstOld = slice.find((l) => l.oldLine !== null)?.oldLine; + const firstNew = slice.find((l) => l.newLine !== null)?.newLine; + const priorOld = + lines + .slice(0, start) + .reverse() + .find((l) => l.oldLine !== null)?.oldLine ?? 0; + const priorNew = + lines + .slice(0, start) + .reverse() + .find((l) => l.newLine !== null)?.newLine ?? 0; + return { + oldStart: firstOld ?? priorOld, + oldLines: slice.filter((l) => l.op !== "add").length, + newStart: firstNew ?? priorNew, + newLines: slice.filter((l) => l.op !== "remove").length, + lines: slice, + }; + }); +} + +function fieldValue( + version: DiffableVersion, + field: DiffedVersionField, +): string | number | null { + return version[field] ?? null; +} + +/** + * Diff two versions of the same document. `from` is the older one by + * convention, but nothing depends on that — comparing v5 to v2 is a valid + * question when reviewing a rollback. + */ +export function diffVersions( + from: DiffableVersion, + to: DiffableVersion, +): VersionDiff { + const fieldChanges: FieldChange[] = DIFFED_VERSION_FIELDS.flatMap((field) => { + const before = fieldValue(from, field); + const after = fieldValue(to, field); + return before === after ? [] : [{ field, from: before, to: after }]; + }); + + const contentChanged = from.contentHash !== to.contentHash; + const summary = { + from: { + id: from.id, + versionNumber: from.versionNumber, + contentHash: from.contentHash, + }, + to: { + id: to.id, + versionNumber: to.versionNumber, + contentHash: to.contentHash, + }, + contentChanged, + fieldChanges, + }; + + if (!contentChanged) { + return { + ...summary, + content: { + kind: "identical", + reason: + "Both versions have the same content hash, so their bytes are identical.", + }, + }; + } + + const before = decodeVersionText(from); + const after = decodeVersionText(to); + if (!before.ok || !after.ok) { + const parts = [ + !before.ok ? `v${from.versionNumber}: ${before.reason}` : null, + !after.ok ? `v${to.versionNumber}: ${after.reason}` : null, + ].filter(Boolean); + return { + ...summary, + content: { + kind: "unavailable", + reason: + `No line diff — ${parts.join("; ")}. The content hashes differ, ` + + `so the content did change.`, + }, + }; + } + + const lines = diffLines(before.text, after.text); + if (lines === null) { + return { + ...summary, + content: { + kind: "unavailable", + reason: + "The changed region is too large to diff here. The content hashes " + + "differ, so the content did change.", + }, + }; + } + + return { + ...summary, + content: { + kind: "text", + added: lines.filter((l) => l.op === "add").length, + removed: lines.filter((l) => l.op === "remove").length, + hunks: toHunks(lines), + }, + }; +} diff --git a/src/lib/documents/proof.ts b/src/lib/documents/proof.ts index a5177aab..bd125b3f 100644 --- a/src/lib/documents/proof.ts +++ b/src/lib/documents/proof.ts @@ -366,3 +366,154 @@ async function verifyReview( return verdict; } + +// --------------------------------------------------------------------------- +// Building a package from stored rows +// --------------------------------------------------------------------------- + +/** The row shapes a proof is built from — a structural subset of the Prisma models. */ +export interface ProofSourceDocument { + id: string; + walletId: string; + title: string; + description: string | null; + documentType: string | null; + createdBy: string; + createdAt: Date; +} + +export interface ProofSourceVersion { + id: string; + versionNumber: number; + contentHash: string; + hashAlgorithm: string; + fileName: string | null; + mimeType: string | null; + fileSize: number | null; + status: string; + createdBy: string; + createdAt: Date; + reviewStartedAt: Date | null; + decidedAt: Date | null; +} + +export interface ProofSourceSnapshot { + walletId: string; + walletPolicyHash: string; + requiredSigners: number; + signersAddresses: string[]; + signersDescriptions: string[]; + capturedAt: Date; +} + +export interface ProofSourceReview { + signerAddress: string; + partyId?: string | null; + party?: { role: string } | null; + action: SignOffAction; + comment: string | null; + payload: string; + signature: string; + signatureKey: string; + signedAt: Date; +} + +export interface ProofSourceEvent { + versionId: string | null; + type: string; + actorAddress: string | null; + createdAt: Date; + metadata: unknown; +} + +/** + * Assemble the proof package for one version from its stored rows. + * + * Shared by `exportProof` and the audit export, so a version's proof inside an + * audit bundle is byte-for-byte the package `exportProof` would have produced + * (apart from `exportedAt`) and any proof verifier checks it unchanged. + * + * Events are filtered to the document-level ones plus this version's own. + * Reviews are sorted by `signedAt` here rather than trusting the caller. + */ +export function buildProofPackage(input: { + document: ProofSourceDocument; + version: ProofSourceVersion; + snapshot: ProofSourceSnapshot; + reviews: readonly ProofSourceReview[]; + events: readonly ProofSourceEvent[]; + exportedAt: Date; +}): ProofPackage { + const { document, version, snapshot } = input; + + const descriptionFor = (address: string): string | null => { + const idx = snapshot.signersAddresses.indexOf(address); + return idx >= 0 ? (snapshot.signersDescriptions[idx] ?? null) : null; + }; + + return { + format: PROOF_FORMAT, + exportedAt: input.exportedAt.toISOString(), + document: { + id: document.id, + walletId: document.walletId, + title: document.title, + description: document.description, + documentType: document.documentType, + createdBy: document.createdBy, + createdAt: document.createdAt.toISOString(), + }, + version: { + id: version.id, + versionNumber: version.versionNumber, + contentHash: version.contentHash, + hashAlgorithm: version.hashAlgorithm, + fileName: version.fileName, + mimeType: version.mimeType, + fileSize: version.fileSize, + status: version.status, + createdBy: version.createdBy, + createdAt: version.createdAt.toISOString(), + reviewStartedAt: version.reviewStartedAt?.toISOString() ?? null, + decidedAt: version.decidedAt?.toISOString() ?? null, + }, + policy: { + walletId: snapshot.walletId, + walletPolicyHash: snapshot.walletPolicyHash, + requiredSigners: snapshot.requiredSigners, + signersAddresses: snapshot.signersAddresses, + signersDescriptions: snapshot.signersDescriptions, + capturedAt: snapshot.capturedAt.toISOString(), + }, + reviews: [...input.reviews] + .sort((a, b) => a.signedAt.getTime() - b.signedAt.getTime()) + .map((r) => ({ + signerAddress: r.signerAddress, + signerDescription: descriptionFor(r.signerAddress), + // Only present for contract-party signatures. The verifier binds the + // payload's partyId to this field, so omitting it would make every + // party signature fail verification. + ...(r.partyId + ? { partyId: r.partyId, partyRole: r.party?.role ?? null } + : {}), + action: r.action, + comment: r.comment, + payload: r.payload, + signature: r.signature, + signatureKey: r.signatureKey, + signedAt: r.signedAt.toISOString(), + })), + events: input.events + .filter((e) => e.versionId === null || e.versionId === version.id) + .map((e) => ({ + type: e.type, + actorAddress: e.actorAddress, + createdAt: e.createdAt.toISOString(), + metadata: e.metadata, + })), + verification: { + domain: SIGNOFF_DOMAIN, + instructions: VERIFICATION_INSTRUCTIONS, + }, + }; +} diff --git a/src/server/api/routers/documents.ts b/src/server/api/routers/documents.ts index 6ab3117c..85425cc1 100644 --- a/src/server/api/routers/documents.ts +++ b/src/server/api/routers/documents.ts @@ -5,6 +5,10 @@ * submitSignerAction, exportProof, verifyProof — plus the reads the four * Documents pages need. * + * v1 (Revision Provenance) adds diffVersions, restoreVersion (rollback by + * re-publishing, never by rewriting), and exportAudit / verifyAudit (the + * whole document's record, checkable offline — src/lib/documents/audit.ts). + * * Two rules carry the whole feature and are enforced here, not in the UI: * * 1. **Version-hash binding.** A review is attached to a DocumentVersion's @@ -59,13 +63,17 @@ import { walletPolicyHash, } from "@/lib/documents/payload"; import { - PROOF_FORMAT, - VERIFICATION_INSTRUCTIONS, + buildProofPackage, proofPackageProblem, verifyProofPackage, type ProofPackage, } from "@/lib/documents/proof"; -import { SIGNOFF_DOMAIN } from "@/lib/documents/payload"; +import { diffVersions } from "@/lib/documents/diff"; +import { + buildAuditPackage, + verifyAuditPackage, + type AuditPackage, +} from "@/lib/documents/audit"; /** Inline content is a convenience for small files, not a document store. */ const MAX_INLINE_BYTES = 512 * 1024; @@ -479,69 +487,24 @@ export const documentRouter = createTRPCRouter({ } const actor = actingAddress(addresses); - const version = await ctx.db.$transaction(async (tx) => { - const latest = await tx.documentVersion.findFirst({ - where: { documentId: document.id }, - orderBy: { versionNumber: "desc" }, - }); - - if ( - latest && - latest.status !== "Superseded" && - latest.status !== "Archived" - ) { - await tx.documentVersion.update({ - where: { id: latest.id }, - data: { status: "Superseded", supersededAt: new Date() }, - }); - await tx.documentEvent.create({ - data: { - documentId: document.id, - versionId: latest.id, - type: "version.superseded", - actorAddress: actor, - metadata: { versionNumber: latest.versionNumber }, - }, - }); - } - - const created = await createVersionRow(tx, { - documentId: document.id, - versionNumber: (latest?.versionNumber ?? 0) + 1, - createdBy: actor, - contentHash: input.contentHash, - fileName: input.fileName, - mimeType: input.mimeType, - fileSize: input.fileSize, - storageMode: input.storageMode, - contentRef: input.contentRef, - contentInline: input.contentInline, - reviewInstructions: input.reviewInstructions, - }); - - await tx.documentEvent.create({ - data: { - documentId: document.id, - versionId: created.id, - type: "version.uploaded", - actorAddress: actor, - metadata: { - versionNumber: created.versionNumber, - contentHash: created.contentHash, - approvalsReset: true, - }, + const version = await ctx.db.$transaction((tx) => + appendVersion(tx, { + document, + actor, + eventType: "version.uploaded", + eventMetadata: {}, + row: { + contentHash: input.contentHash, + fileName: input.fileName, + mimeType: input.mimeType, + fileSize: input.fileSize, + storageMode: input.storageMode, + contentRef: input.contentRef, + contentInline: input.contentInline, + reviewInstructions: input.reviewInstructions, }, - }); - - await attestVersion(tx, created, document.walletId); - - await tx.document.update({ - where: { id: document.id }, - data: { status: "Draft" }, - }); - - return created; - }); + }), + ); return version; }), @@ -852,7 +815,10 @@ export const documentRouter = createTRPCRouter({ include: { document: { include: { events: { orderBy: { createdAt: "asc" } } } }, signerSnapshot: true, - reviews: { orderBy: { signedAt: "asc" } }, + reviews: { + orderBy: { signedAt: "asc" }, + include: { party: { select: { role: true } } }, + }, }, }); if (!version) { @@ -875,68 +841,14 @@ export const documentRouter = createTRPCRouter({ }); } - const descriptionFor = (address: string): string | null => { - const idx = snapshot.signersAddresses.indexOf(address); - return idx >= 0 ? (snapshot.signersDescriptions[idx] ?? null) : null; - }; - - const pkg: ProofPackage = { - format: PROOF_FORMAT, - exportedAt: new Date().toISOString(), - document: { - id: version.document.id, - walletId: version.document.walletId, - title: version.document.title, - description: version.document.description, - documentType: version.document.documentType, - createdBy: version.document.createdBy, - createdAt: version.document.createdAt.toISOString(), - }, - version: { - id: version.id, - versionNumber: version.versionNumber, - contentHash: version.contentHash, - hashAlgorithm: version.hashAlgorithm, - fileName: version.fileName, - mimeType: version.mimeType, - fileSize: version.fileSize, - status: version.status, - createdBy: version.createdBy, - createdAt: version.createdAt.toISOString(), - reviewStartedAt: version.reviewStartedAt?.toISOString() ?? null, - decidedAt: version.decidedAt?.toISOString() ?? null, - }, - policy: { - walletId: snapshot.walletId, - walletPolicyHash: snapshot.walletPolicyHash, - requiredSigners: snapshot.requiredSigners, - signersAddresses: snapshot.signersAddresses, - signersDescriptions: snapshot.signersDescriptions, - capturedAt: snapshot.capturedAt.toISOString(), - }, - reviews: version.reviews.map((r) => ({ - signerAddress: r.signerAddress, - signerDescription: descriptionFor(r.signerAddress), - action: r.action, - comment: r.comment, - payload: r.payload, - signature: r.signature, - signatureKey: r.signatureKey, - signedAt: r.signedAt.toISOString(), - })), - events: version.document.events - .filter((e) => e.versionId === null || e.versionId === version.id) - .map((e) => ({ - type: e.type, - actorAddress: e.actorAddress, - createdAt: e.createdAt.toISOString(), - metadata: e.metadata, - })), - verification: { - domain: SIGNOFF_DOMAIN, - instructions: VERIFICATION_INSTRUCTIONS, - }, - }; + const pkg = buildProofPackage({ + document: version.document, + version, + snapshot, + reviews: version.reviews, + events: version.document.events, + exportedAt: new Date(), + }); await ctx.db.documentEvent.create({ data: { @@ -1172,69 +1084,23 @@ export const documentRouter = createTRPCRouter({ }); } - return ctx.db.$transaction(async (tx) => { - const latest = await tx.documentVersion.findFirst({ - where: { documentId: document.id }, - orderBy: { versionNumber: "desc" }, - }); - - if ( - latest && - latest.status !== "Superseded" && - latest.status !== "Archived" - ) { - await tx.documentVersion.update({ - where: { id: latest.id }, - data: { status: "Superseded", supersededAt: new Date() }, - }); - await tx.documentEvent.create({ - data: { - documentId: document.id, - versionId: latest.id, - type: "version.superseded", - actorAddress: actor, - metadata: { versionNumber: latest.versionNumber }, - }, - }); - } - - const created = await createVersionRow(tx, { - documentId: document.id, - versionNumber: (latest?.versionNumber ?? 0) + 1, - createdBy: actor, - contentHash: sha256Hex(bytes), - fileName: input.fileName ?? `${document.title}.md`, - mimeType: "text/markdown", - fileSize: bytes.length, - storageMode: "inline", - contentInline: bytes.toString("base64"), - reviewInstructions: input.reviewInstructions, - }); - - await tx.documentEvent.create({ - data: { - documentId: document.id, - versionId: created.id, - type: "version.published", - actorAddress: actor, - metadata: { - versionNumber: created.versionNumber, - contentHash: created.contentHash, - fromDraftRevision: draft.revision, - approvalsReset: true, - }, + return ctx.db.$transaction((tx) => + appendVersion(tx, { + document, + actor, + eventType: "version.published", + eventMetadata: { fromDraftRevision: draft.revision }, + row: { + contentHash: sha256Hex(bytes), + fileName: input.fileName ?? `${document.title}.md`, + mimeType: "text/markdown", + fileSize: bytes.length, + storageMode: "inline", + contentInline: bytes.toString("base64"), + reviewInstructions: input.reviewInstructions, }, - }); - - await attestVersion(tx, created, document.walletId); - - await tx.document.update({ - where: { id: document.id }, - data: { status: "Draft" }, - }); - - return created; - }); + }), + ); }), /** @@ -1548,6 +1414,246 @@ export const documentRouter = createTRPCRouter({ return { documentId: party!.documentId, role: party!.role }; }), + // ------------------------------------------------------------------------- + // v1 — Revision Provenance: diff, rollback, audit export + // ------------------------------------------------------------------------- + + /** + * Compare two versions of one document. + * + * Party-aware like `getById`: anyone who may read the document may compare + * its versions. The metadata diff always works; the line diff needs both + * versions' bytes inline and readable as text, and says why when it cannot + * run (see src/lib/documents/diff.ts). + */ + diffVersions: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + fromVersionId: z.string().min(1), + toVersionId: z.string().min(1), + }), + ) + .query(async ({ ctx, input }) => { + const { document } = await resolveDocumentAccess(ctx, input.documentId); + const versions = await ctx.db.documentVersion.findMany({ + where: { + documentId: document.id, + id: { in: [input.fromVersionId, input.toVersionId] }, + }, + }); + const from = versions.find((v) => v.id === input.fromVersionId); + const to = versions.find((v) => v.id === input.toVersionId); + if (!from || !to) { + // Same answer whether the version does not exist or belongs to another + // document, so this cannot be used to probe ids across documents. + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found on this document", + }); + } + return diffVersions(from, to); + }), + + /** + * Roll back to an earlier version — by re-publishing it, never by rewriting. + * + * Creates version N+1 carrying the chosen version's exact content (same hash, + * same bytes or reference), supersedes the current head, and starts it at + * zero approvals like any other new version. The old version's approvals do + * not come back: they were given in a round over a different history, and + * the snapshot that counted them may no longer match the wallet. Signers + * approve the restored content again. + * + * Append-only: the versions in between stay, the event log records + * `version.restored` with the source, and the attestation chain gains a link + * — so "we went back to v2" is itself part of the audited record. + * + * Wallet members only, like `uploadVersion`; a contract party can read and + * sign but not change which content is up for signature. + */ + restoreVersion: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + versionId: z.string().min(1), + reviewInstructions: z.string().max(5000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + if (document.status === "Archived") { + throw new TRPCError({ + code: "CONFLICT", + message: "Document is archived", + }); + } + const actor = actingAddress(addresses); + + const source = await ctx.db.documentVersion.findFirst({ + where: { id: input.versionId, documentId: document.id }, + }); + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found on this document", + }); + } + + const restored = await ctx.db.$transaction(async (tx) => { + // Read the head inside the transaction so two concurrent restores + // cannot both decide they are restoring something other than the head. + const head = await tx.documentVersion.findFirst({ + where: { documentId: document.id }, + orderBy: { versionNumber: "desc" }, + }); + if (head?.id === source.id) { + throw new TRPCError({ + code: "CONFLICT", + message: `v${source.versionNumber} is already the current version`, + }); + } + + return appendVersion(tx, { + document, + actor, + eventType: "version.restored", + eventMetadata: { + restoredFromVersionId: source.id, + restoredFromVersionNumber: source.versionNumber, + ...(head ? { replacedVersionNumber: head.versionNumber } : {}), + }, + row: { + contentHash: source.contentHash, + fileName: source.fileName ?? undefined, + mimeType: source.mimeType ?? undefined, + fileSize: source.fileSize ?? undefined, + storageMode: source.storageMode, + contentRef: source.contentRef ?? undefined, + contentInline: source.contentInline ?? undefined, + reviewInstructions: + input.reviewInstructions ?? + source.reviewInstructions ?? + undefined, + }, + }); + }); + + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "document.version.restore", + resourceType: "document", + resourceId: document.id, + outcome: "success", + metadata: { + restoredFromVersionNumber: source.versionNumber, + newVersionNumber: restored.versionNumber, + }, + }); + + return restored; + }), + + /** + * The full audit package for a document: every version with its lineage and + * proof, the whole event log, and the attestation chain with its keys. A + * mutation, like `exportProof`, because exporting is itself logged. + * + * Wallet members only. A party's view is scoped to what they sign; the audit + * bundle carries every round, including ones they were never part of. + */ + exportAudit: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .mutation(async ({ ctx, input }): Promise => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + const actor = actingAddress(addresses); + + const [versions, events, attestations] = await Promise.all([ + ctx.db.documentVersion.findMany({ + where: { documentId: document.id }, + orderBy: { versionNumber: "asc" }, + include: { + signerSnapshot: true, + reviews: { + orderBy: { signedAt: "asc" }, + include: { party: { select: { role: true } } }, + }, + }, + }), + ctx.db.documentEvent.findMany({ + where: { documentId: document.id }, + orderBy: { createdAt: "asc" }, + }), + ctx.db.documentAttestation.findMany({ + where: { documentId: document.id }, + orderBy: { sequence: "asc" }, + }), + ]); + + const pkg = buildAuditPackage({ + document, + versions, + events, + attestations, + publicKeys: getAttestationPublicKeys(), + exportedAt: new Date(), + }); + + await ctx.db.documentEvent.create({ + data: { + documentId: document.id, + type: "audit.exported", + actorAddress: actor, + metadata: { + versionCount: pkg.versions.length, + eventCount: pkg.events.length, + attested: pkg.attestation !== null, + }, + }, + }); + + return pkg; + }), + + /** + * Verify an audit package. Public and database-free, like `verifyProof`: + * it re-runs the maths in src/lib/documents/audit.ts and nothing else. + * Checks the chain against this deployment's published keys unless the + * caller supplies their own. + */ + verifyAudit: publicProcedure + .input( + z.object({ + audit: z.unknown(), + publicKeys: z.record(z.string(), z.string()).optional(), + }), + ) + .mutation(async ({ input }) => { + const pkg = input.audit as Partial | null; + if ( + !pkg || + typeof pkg !== "object" || + !Array.isArray(pkg.versions) || + !pkg.document + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That is not a Document Sign-Off audit package.", + }); + } + return verifyAuditPackage(pkg as AuditPackage, { + checkSignature, + publicKeys: input.publicKeys ?? getAttestationPublicKeys(), + }); + }), + /** Archive a document — history is retained, it just leaves active use. */ archiveDocument: protectedProcedure .input(z.object({ documentId: z.string().min(1) })) @@ -1662,6 +1768,93 @@ async function attestVersion( }); } +type AppendVersionRow = Omit< + VersionRowInput, + "documentId" | "versionNumber" | "createdBy" +>; + +/** + * Append the next version to a document — the only way a version is created + * after the first. + * + * Supersedes the current head (approvals never carry forward), creates the row, + * logs the creating event, attests the new version and returns the document to + * Draft, all in the caller's transaction. Upload, publish-from-draft and + * restore differ only in where the bytes come from and which event names the + * origin, so they share this rather than three copies of the supersede logic. + * + * Append-only by construction: nothing here edits or removes an existing + * version, which is what lets a restore be a rollback without rewriting + * history. + */ +async function appendVersion( + tx: Prisma.TransactionClient, + input: { + document: { id: string; walletId: string }; + actor: string; + eventType: "version.uploaded" | "version.published" | "version.restored"; + eventMetadata: Record; + row: AppendVersionRow; + }, +) { + const { document, actor } = input; + const latest = await tx.documentVersion.findFirst({ + where: { documentId: document.id }, + orderBy: { versionNumber: "desc" }, + }); + + if ( + latest && + latest.status !== "Superseded" && + latest.status !== "Archived" + ) { + await tx.documentVersion.update({ + where: { id: latest.id }, + data: { status: "Superseded", supersededAt: new Date() }, + }); + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: latest.id, + type: "version.superseded", + actorAddress: actor, + metadata: { versionNumber: latest.versionNumber }, + }, + }); + } + + const created = await createVersionRow(tx, { + ...input.row, + documentId: document.id, + versionNumber: (latest?.versionNumber ?? 0) + 1, + createdBy: actor, + }); + + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: created.id, + type: input.eventType, + actorAddress: actor, + metadata: { + versionNumber: created.versionNumber, + contentHash: created.contentHash, + ...input.eventMetadata, + approvalsReset: true, + }, + }, + }); + + await attestVersion(tx, created, document.walletId); + + await tx.document.update({ + where: { id: document.id }, + data: { status: "Draft" }, + }); + + return created; +} + async function createVersionRow( tx: Prisma.TransactionClient, input: VersionRowInput, diff --git a/vault/features/Revision Provenance.md b/vault/features/Revision Provenance.md index 92f9668f..22a9d6b6 100644 --- a/vault/features/Revision Provenance.md +++ b/vault/features/Revision Provenance.md @@ -1,10 +1,11 @@ --- type: feature area: Document Sign-Off -state: planned +state: in-progress owner: Quirin -milestone: 2026-10 -updated: 2026-07-27 +milestone: 2026-09 +prs: [375] +updated: 2026-10-16 --- # Revision Provenance @@ -12,6 +13,20 @@ updated: 2026-07-27 Version 1 of Sign-Off: revision history as a first-class object, diff and rollback, and a richer audit export. Still entirely off-chain. +- **History** — every version gets a platform attestation, chained to the one before + it, so inserting, removing, reordering or back-dating a version is detectable + offline (#375). +- **Diff** — any two versions of a document compare by metadata always, and line by + line when both carry their bytes inline as text. Hash-only versions say why there + is no line diff instead of guessing. +- **Rollback** — restore re-publishes an earlier version's exact content as a new + version. Append-only: nothing in between is deleted, the restore is logged and + attested, and approvals never come back — signers approve the restored content + again from zero. +- **Audit export** — `mesh-multisig.document-signoff.audit.v1`: every version with its + lineage and its standalone `proof.v1`, the whole event log, and the attestation + chain with its keys, verifiable offline. + ## Related [[Document Sign-Off MVP]] · [[On-Chain Checkpoints]] From 6cdab1f20376ebd4c931d247f54676489f781e66 Mon Sep 17 00:00:00 2001 From: QSchlegel <34195172+QSchlegel@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:03:44 +0200 Subject: [PATCH 2/3] =?UTF-8?q?docs(research):=20FROST=20and=20PQ=20multi-?= =?UTF-8?q?sig=20=E2=80=94=20findings,=20PoC=20and=20go/no-go=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the ROADMAP M6 research item. Verdict: no-go on replacing native scripts with FROST; conditional go on an opt-in FROST signer as one leaf inside a native script; no-go on post-quantum multi-sig until the ledger can verify a PQ signature. FROST(Ed25519, SHA-512) works on Cardano with no ledger change: RFC 9591's Ed25519 ciphersuite emits plain RFC 8032 signatures, so a t-of-n aggregate is one ordinary 101-byte vkey witness under one key hash. The research PoC (scripts/research/frost-ed25519, trusted dealer, not for production) reproduces the RFC 9591 Appendix E.1 vector byte for byte and verifies 2-of-3 and 3-of-5 aggregates with noble and with the CSL fork Mesh ships, as an enterprise address and as a sig leaf in an atLeast script. It also recovers a signer's share from a reused nonce, which is the operational hazard. The saving is 206 / 371 bytes per tx (0.009 / 0.016 ADA at 44 lovelace/byte); the cost is the on-chain signer set and threshold this app's DRep and CIP-1854 features read, plus CIP-30 and hardware-wallet support. "Lemour" is Lemur (ePrint 2026/1161), a lattice aggregate multi-signature with no public implementation; no Plutus builtin verifies any PQ signature. ROADMAP M6 proof-of-completion rows for Quirin's tasks; Andre's three M6 rows marked merged (#399, released in #393). FROST and PQC vault notes and the public roadmap research item updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016DwjSXosdJg8rATTa7pQEn --- ROADMAP.md | 12 +- docs/research/frost-go-no-go.md | 117 +++++ scripts/research/frost-ed25519/README.md | 29 ++ scripts/research/frost-ed25519/frost.ts | 410 ++++++++++++++++++ src/__tests__/frostResearchPoc.test.ts | 368 ++++++++++++++++ src/components/pages/homepage/roadmap/data.ts | 4 +- vault/features/FROST Threshold Signatures.md | 28 +- vault/features/Post-Quantum Multi-Sig.md | 17 +- 8 files changed, 964 insertions(+), 21 deletions(-) create mode 100644 docs/research/frost-go-no-go.md create mode 100644 scripts/research/frost-ed25519/README.md create mode 100644 scripts/research/frost-ed25519/frost.ts create mode 100644 src/__tests__/frostResearchPoc.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 3dad4f89..5ce601d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,13 +284,17 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date ### Proof of completion -Status of M6 tasks (Andre). Last updated 2026-09-07. +Status of M6 tasks. Andre's rows last updated 2026-09-07; Quirin's rows and the merge status 2026-10-16. | Task | Status | Evidence | |------|--------|----------| -| MCP unsigned transaction creation | In review | Branch `feat/mcp-unsigned-tx-creation`: new `transactions:write` scope; `transaction_preview` builds payments, staking certificates and DRep votes through the canvas builder's `src/lib/tx-draft/` pipeline server-side and returns a signed draft token; `transaction_propose` accepts only that token and creates the pending transaction with zero signatures (never broadcasts). Design and boundary in `src/pages/api/mcp/README.md`; guarded by `src/__tests__/mcpTools.test.ts` and `txReview*.test.ts` | -| Transaction review PNG & in-chat review | In review | Same branch: `src/lib/tx-review/summary.ts` (one summary model behind text, JSON and image), `card.ts` + `render-png.ts` (rasterized with the `ImageResponse` bundled in Next, no new dependency); the card is returned as an MCP `image` content block by preview, propose and `multisig_review_pending_transaction` (any pending transaction). Render proven by `src/__tests__/txReviewRenderPng.test.ts` | -| Project task board with multisig payouts | In review | Same branch: `Task` / `TaskRecipient` / `TaskPayout` models (`prisma/migrations/20260910090000_add_task_board`), kanban board at `/wallets/[wallet]/tasks` (`src/components/pages/wallet/tasks/`, dnd-kit), `task` tRPC router with `preparePayout` → `confirmPayout` sharing the `src/lib/tx-review/` pipeline server-side through `src/lib/task-payout/` (draft token bound to the task ids and a hash of their recipient rows; `TaskPayout` links created inside the pending row's insert transaction; links flip to Paid on submit and Cancelled on delete/replace); MCP tools `task_list`, `task_upsert` (new `tasks:write` scope) and `task_prepare_payout` (confirmed through the existing `transaction_propose`). Guarded by `src/__tests__/taskPayout*.test.ts`, `taskBoardModel.test.ts`, `trpc/taskRouter.test.ts`, extended `txReviewPropose`/`txReviewDraftToken`/`createPendingMultisigTransaction`/`mcpTools` tests, and `e2e/tests/task-board.spec.ts` | +| MCP unsigned transaction creation | Done — merged in [#399](https://github.com/MeshJS/multisig/pull/399), released in [#393](https://github.com/MeshJS/multisig/pull/393) (2026-10-05) | Branch `feat/mcp-unsigned-tx-creation`: new `transactions:write` scope; `transaction_preview` builds payments, staking certificates and DRep votes through the canvas builder's `src/lib/tx-draft/` pipeline server-side and returns a signed draft token; `transaction_propose` accepts only that token and creates the pending transaction with zero signatures (never broadcasts). Design and boundary in `src/pages/api/mcp/README.md`; guarded by `src/__tests__/mcpTools.test.ts` and `txReview*.test.ts` | +| Transaction review PNG & in-chat review | Done — merged in [#399](https://github.com/MeshJS/multisig/pull/399), released in [#393](https://github.com/MeshJS/multisig/pull/393) (2026-10-05) | Same branch: `src/lib/tx-review/summary.ts` (one summary model behind text, JSON and image), `card.ts` + `render-png.ts` (rasterized with the `ImageResponse` bundled in Next, no new dependency); the card is returned as an MCP `image` content block by preview, propose and `multisig_review_pending_transaction` (any pending transaction). Render proven by `src/__tests__/txReviewRenderPng.test.ts` | +| Project task board with multisig payouts | Done — merged in [#399](https://github.com/MeshJS/multisig/pull/399), released in [#393](https://github.com/MeshJS/multisig/pull/393) (2026-10-05) | Same branch: `Task` / `TaskRecipient` / `TaskPayout` models (`prisma/migrations/20260910090000_add_task_board`), kanban board at `/wallets/[wallet]/tasks` (`src/components/pages/wallet/tasks/`, dnd-kit), `task` tRPC router with `preparePayout` → `confirmPayout` sharing the `src/lib/tx-review/` pipeline server-side through `src/lib/task-payout/` (draft token bound to the task ids and a hash of their recipient rows; `TaskPayout` links created inside the pending row's insert transaction; links flip to Paid on submit and Cancelled on delete/replace); MCP tools `task_list`, `task_upsert` (new `tasks:write` scope) and `task_prepare_payout` (confirmed through the existing `transaction_propose`). Guarded by `src/__tests__/taskPayout*.test.ts`, `taskBoardModel.test.ts`, `trpc/taskRouter.test.ts`, extended `txReviewPropose`/`txReviewDraftToken`/`createPendingMultisigTransaction`/`mcpTools` tests, and `e2e/tests/task-board.spec.ts` | +| Document Sign-Off v1 — Provenance: history | Done | Chained Ed25519 platform attestation per version ([#375](https://github.com/MeshJS/multisig/pull/375)): inserting, removing, reordering or back-dating a version is detectable offline. `src/lib/documents/attestation.ts`, `src/__tests__/documentAttestation.test.ts` | +| Document Sign-Off v1 — Provenance: diff & rollback | In review (M6 completion PR) | `diffVersions` (metadata always; line diff when both versions are inline text, bounded, says why otherwise) in `src/lib/documents/diff.ts`; `restoreVersion` re-publishes an earlier version as N+1 — append-only, logged as `version.restored`, attested, approvals reset to zero. UI: Changes / Restore on each version card. `src/__tests__/documentDiff.test.ts`, `src/__tests__/trpc/documentProvenance.test.ts` (real DB, real CIP-8) | +| Document Sign-Off v1 — Provenance: richer audit export | In review (M6 completion PR) | `exportAudit` / public `verifyAudit`: format `mesh-multisig.document-signoff.audit.v1` — every version with lineage and its standalone `proof.v1`, the full event log, and the attestation chain with keys; `verifyAuditPackage` cross-checks the two records offline. `src/lib/documents/audit.ts`, `src/__tests__/documentAudit.test.ts` (tamper cases) | +| FROST research — findings, PoC, go/no-go (#220) | In review (M6 completion PR) | [`docs/research/frost-go-no-go.md`](docs/research/frost-go-no-go.md): **no-go** on replacing native scripts (saves 0.009–0.016 ADA/tx, loses on-chain signer auditability and hardware/CIP-30 support); **conditional go** on an opt-in FROST signer as one leaf inside a native script; **no-go** on PQ multisig until the ledger verifies a PQ signature ("Lemour" = Lemur, ePrint 2026/1161). PoC `scripts/research/frost-ed25519/` reproduces the RFC 9591 Ed25519 test vector and verifies 2-of-3 / 3-of-5 aggregates as ordinary Cardano vkey witnesses — `src/__tests__/frostResearchPoc.test.ts` | --- diff --git a/docs/research/frost-go-no-go.md b/docs/research/frost-go-no-go.md new file mode 100644 index 00000000..5287753b --- /dev/null +++ b/docs/research/frost-go-no-go.md @@ -0,0 +1,117 @@ +# FROST and post-quantum multi-sig: findings, PoC and go/no-go + +Issue #220 · ROADMAP Month 6 research track · 2026-10-16 · Owner: Quirin + +## 1. Summary and recommendation + +**NO-GO on replacing native scripts with FROST. CONDITIONAL GO on an optional FROST signer as one leaf inside an existing native script. NO-GO on PQ multisig until the Cardano ledger can verify a PQ signature.** + +FROST(Ed25519, SHA-512) does work on Cardano with no ledger change. Our PoC reproduces the RFC 9591 test vector byte for byte, and a 2-of-3 or 3-of-5 aggregate signature verifies as an ordinary vkey witness under the CSL fork Mesh ships. Replacing native scripts would save 206 bytes per transaction for 2-of-3 and 371 bytes for 3-of-5, which is 0.009 and 0.016 ADA in fees. That saving is too small to justify what it costs this platform: + +- **Auditability is lost.** The signer set and threshold leave the chain, and DRep, CIP-1854 and treasury users rely on reading them there. +- **Hardware and CIP-30 support disappear.** No CIP-30 wallet, Ledger or Trezor can hold a share. +- **Custody shifts to new software.** Shares would live in software we would have to build, and that software has a key-leaking failure mode (nonce reuse, shown in the PoC). + +"Lemour" does not exist under that name. The roadmap almost certainly means **Lemur** (Monash, NIST threshold call, 2026), a lattice *synchronized aggregate multi-signature*, not a t-of-n threshold scheme. It is pre-standard, has no public implementation, and produces signatures in the KB range. More decisively, no Cardano native script or Plutus builtin verifies any post-quantum signature today. + +## 2. How FROST maps onto Cardano + +[RFC 9591 §6.1](https://www.rfc-editor.org/rfc/rfc9591.html#section-6.1) defines FROST(Ed25519, SHA-512) "to produce Ed25519-compliant signatures as specified in Section 5.1 of RFC 8032". Its challenge hash H2 drops the domain separator "for compatibility with RFC 8032". The output is `R || z`, 64 bytes, under a 32-byte group public key. Verification is plain RFC 8032 §5.1.7 with the cofactored equation `[8][z]B = [8]R + [8][c]PK`. + +On Cardano that means: + +- **Address.** The group key's blake2b-224 hash is an ordinary 28-byte payment key hash. It can be an enterprise or base address credential, or a `sig` leaf in a native script ([CIP-1854](https://github.com/cardano-foundation/CIPs/blob/master/CIP-1854/README.md) scripts are built from such hashes). +- **Witness.** Spending needs **one** vkey witness `[vkey(32), sig(64)]`, measured at 101 bytes CBOR. The ledger cannot distinguish it from a single-key signature. +- **Script.** None is needed. With a pure FROST key, no native script is revealed on spend. + +The PoC proves each of these (§5). One caveat: the ledger verifies only the final Ed25519 signature. It knows nothing about t, n or who signed, so every threshold guarantee lives off chain in the share holders' software. + +## 3. Trade-offs: native script vs FROST + +Fee inputs: mainnet `txFeePerByte` = **44 lovelace/B**, `txFeeFixed` = 155,381, `maxTxSize` = 16,384, protocol 11.0 ([Koios protocol params](https://api.koios.rest/api/v1/cli_protocol_params), fetched 2026-10-16). Sizes were measured with `csl` (whisky-js 1.0.25): `sig` leaf 32 B, vkey witness 101 B, `atLeast` 2-of-3 script 100 B, 3-of-5 script 164 B. + +| | 2-of-3 native | 2-of-3 FROST | 3-of-5 native | 3-of-5 FROST | +|---|---|---|---|---| +| Vkey witnesses | 2 × 101 (array 206) | 1 × 101 (array 105) | 3 × 101 (array 307) | array 105 | +| Script in witness set | 100 | 0 | 164 | 0 | +| Witness set total (measured) | **313 B** | **107 B** | **478 B** | **107 B** | +| Saving | | **206 B × 44 = 9,064 lovelace ≈ 0.009 ADA** | | **371 B × 44 = 16,324 lovelace ≈ 0.016 ADA** | +| Share of a typical ~0.2 ADA multisig tx fee | | ~4–5 % | | ~7–8 % | + +The saving per extra signer is about 101 B (one witness) plus 32 B (one leaf), so roughly 0.006 ADA. Address size is unchanged: both forms are 28-byte credentials. A reference script removes the inline script bytes from the native-script side, but it has its own UTxO and ref-script costs, so the figures above favour FROST. + +| Dimension | Native script (today) | FROST | +|---|---|---| +| On-chain footprint | Grows with m and n (above) | Constant: 1 witness, 0 script | +| Privacy | Script, key hashes and threshold are public once spent | Looks like a single-key wallet, with no signer set visible | +| **Auditability of signer set and threshold** | Anyone can verify "3-of-5 of these keys" from the chain. This app's DRep pages, CIP-1854 discovery (`src/utils/cip146Discovery.ts`) and governance rationale rely on it. | **Lost.** t, n and membership are claims made off chain. A DRep credential backed by FROST is indistinguishable from one key, so voters cannot check the committee. | +| Key rotation / membership change | New script, so a **new address**. Funds must move, and DRep or stake credentials must be re-registered. | Resharing (ZF `refresh`) **keeps the address and credential**. This is FROST's strongest real advantage. | +| Threshold change | New script | Resharing can't change it either: ZF `refresh` cannot change `min_signers` ([CVE-2025-58359](https://vuln.today/cve/CVE-2025-58359), fixed in frost-core 2.2.0, which made this explicit). Changing the threshold needs a new DKG, a new key and a new address. | +| Signer UX and wallets | Any CIP-30 wallet, Ledger or Trezor signs with `signTx` | **None.** [CIP-30](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0030/README.md) exposes only `signTx` and `signData` (COSE-wrapped) over wallet-held keys, with no way to supply a share or nonce. We found no Ledger or Trezor Cardano app with FROST support. Bitcoin's Frostsnap is dedicated hardware ([2023, pre-release then](https://nobsbitcoin.com/frostsnap-frost-multisig-foss)). | +| Liveness and coordination | Asynchronous: each signer adds a witness whenever they like, which matches the app's pending-tx flow | 2 rounds. Round 1 commitments can be preprocessed, but round 2 needs a fixed signer subset, so one dropout restarts the round. A coordinator is required. | +| Secret state | None beyond the wallet key | Per-session nonces that **must** be single-use. Restoring one nonce pair 3× leaks the share (PoC test). | +| Custody model | Keys in users' own wallets or hardware | Shares in a new app-specific signer (browser, WASM or server), which is a custody product we would own and have audited | +| Failure modes | Lost key: the other m-of-n still sign | Lost share: repairable (ZF RTS) while ≥ t remain. Compromised coordinator: denial of service only. **Buggy signer or nonce reuse: silent key theft.** Software bug: all funds behind one key. | +| Ecosystem tooling | All Cardano tooling understands native scripts | Explorers show a single key. Nothing in Cardano tooling exposes the threshold. | + +## 4. Library and maturity assessment + +| Library | Lang | Status | +|---|---|---| +| [ZF FROST](https://github.com/ZcashFoundation/frost) `frost-ed25519` | Rust | Implements RFC 9591. Supports trusted-dealer and DKG keygen, refresh, and repairable threshold. [NCC Group audit](https://www.nccgroup.com/research/public-report-zcash-frost-security-assessment/) of v0.6.0 in 2023 (frost-core, -ed25519 and others; "all findings were fixed"). Current release is **3.0.0 (2026-04-23)** with ~530k downloads ([crates.io](https://crates.io/crates/frost-ed25519)). Later versions are not re-audited per the repo. One CVE (2025-58359, refresh semantics). **Production-grade candidate; would need a WASM build for the browser.** | +| [`@substrate-system/frost`](https://github.com/substrate-system/frost) | TS | Claims RFC 9591 Ed25519. 4 stars, no releases, no audit. Not usable for custody. | +| Viper Science, [Catalyst F11 "Threshold Signatures for Cardano Wallets"](https://www.lidonation.com/proposals/threshold-signatures-for-cardano-wallets-f11) | C++ | Funded at 75k ADA (12k disbursed per Lido Nation). Uses Shamir-split Ed25519 producing standard Ed25519 signatures, but it is not described as FROST and no public repo is linked. | +| IOG | — | The [Cardano Vision 2026 research program](https://www.iog.io/news/cardano-vision-2026-the-research-program-securing-cardano) mentions threshold signatures only for cross-chain coordination, not FROST wallets. | +| This PoC | TS | Reproduces the RFC vector. Research only: not constant-time, trusted dealer. | + +**Verdict.** The cryptography is mature (RFC plus an audited Rust library). The Cardano wallet layer has nothing: no CIP, no wallet, no hardware. + +## 5. PoC results + +- **Code.** [`scripts/research/frost-ed25519/frost.ts`](../../scripts/research/frost-ed25519/frost.ts) implements RFC 9591 §4–5 and §6.1 (contextString `FROST-ED25519-SHA512-v1`, H1–H5) on `@noble/curves` 1.9.7 with no new dependencies. It includes trusted-dealer Shamir with Feldman VSS, two-round signing, binding factors, Lagrange coefficients, share verification and aggregation. +- **Tests.** [`src/__tests__/frostResearchPoc.test.ts`](../../src/__tests__/frostResearchPoc.test.ts), run with `npx jest src/__tests__/frostResearchPoc.test.ts`. **13 of 13 pass** (about 3 s). + - **RFC 9591 Appendix E.1 reproduced byte for byte.** That covers the group key, the 3 shares, nonces, commitments, binding-factor inputs and factors, both sig shares, and the final `sig 36282629…3160b`. + - **(a)** 2-of-3 and 3-of-5 signatures, from two different qualifying subsets each, verify with noble's stock `ed25519.verify` and the cofactored check. + - **(b)** `csl.PublicKey.from_bytes(groupPk).verify(txBodyHash, sig)` returns `true` over a real tx body hash computed with `resolveTxHash`. The `Vkeywitness` round-trips at 101 B. The group key hash builds an `addr_test1v…` enterprise address and serves as a `sig` leaf in a 2-of-3 `atLeast` script. + - **(c)** t−1 shares and a 1-bit tampered message both fail under both verifiers. **Nonce reuse:** 3 signatures from one restored nonce pair let a coordinator recover the victim's share exactly. +- **Not done.** No preprod submission. Acceptance follows from (b), since the ledger checks vkey witnesses with standard Ed25519. No DKG and no wallet integration. + +## 6. Post-quantum: "Lemour" and the lattice threshold landscape + +- **"Lemour".** No scheme or library with this name exists in the sources we searched (IACR ePrint, NIST, general web). The nearest match is **Lemur: Scalable Post-Quantum Synchronized Multi-Signatures** (Lin, Esgin, Sakzad, Steinfeld, Saarinen; [ePrint 2026/1161](https://eprint.iacr.org/2026/1161); [NIST TCPT2 talk, 2026-07-08](https://csrc.nist.gov/presentations/2026/tcpt2-2a2)). + - **What it is.** A lattice (Module-LWE/SIS, "Dual Hint-MLWE") *synchronized aggregate multi-signature*. Signers in the same time step aggregate non-interactively. It is **not a t-of-n threshold scheme** and is stateful (Merkle-tree one-time keys, height 20). + - **Sizes.** 380 KB aggregate for 2^20 signers, 15 ms batch verification for 1,024 signers. The authors also estimate that predecessor Chipmunk reaches only ~30-bit rather than the claimed 112-bit security. + - **Implementation.** The paper mentions a Rust implementation, but no public repo is linked. +- **Threshold lattice schemes.** + - [Ringtail](https://eprint.iacr.org/2024/1113) (IEEE S&P 2025) is a 2-round LWE threshold scheme with 13.4 KB signatures at t = 1024. + - The [NIST Multi-Party Threshold call](https://csrc.nist.gov/projects/threshold-cryptography/tcall-1) is in its *preview* phase. Phase 3 talks run through 2026-10-13, and **packages are due around March 2027** (spec, open-source reference implementation, evaluation). The call includes lattice entries (Lemur, LoTRS, Tanuki, LaTSEC, Thimza, TalonG), threshold ML-DSA (Mithril, Quorus, TALUS), threshold Falcon (ATMACA) and threshold HAWK (Kettle). No PQ threshold standard exists yet. +- **Trade-offs vs FROST.** + - FROST is classical: discrete log, broken by Shor. Even FROST's privacy gain is weaker against a quantum adversary, because the group public key is revealed on first spend, exactly like any Ed25519 key. + - Lattice threshold signatures are 13 KB+ against 64 B, which is about 80% of `maxTxSize` (16,384 B) per signature before anything else. +- **The gate.** Cardano native scripts check only Ed25519 vkey witnesses. The [Plutus builtins](https://plutus.cardano.intersectmbo.org/haddock/master/plutus-core/PlutusCore-Default-Builtins.html) offer only `VerifyEd25519Signature`, `VerifyEcdsaSecp256k1Signature`, `VerifySchnorrSecp256k1Signature` and BLS12-381 primitives, with **no PQ signature verification of any kind**. The one escape hatch is a hash-based one-time signature (Lamport or WOTS) verified in a Plutus validator using hash builtins. That would be a custom, stateful, high-ex-unit contract rather than a native-script multisig, and it has nothing to do with lattice threshold schemes. IOG's 2026 research program lists PQ signatures as research, still to be designed, with a migration SIP planned, and gives no dates. [CIP-0197](https://coingape.com/cardano-opens-review-on-quantum-proof-wallet-cip-as-upgrade-2026-gains-momentum/) (in review since 2026-08-23) adds ZK seed-ownership proofs on top of Ed25519 and is not a PQ signature scheme. A PQ multisig on Cardano therefore needs a hard fork that adds a PQ verification primitive. An application cannot build one today. + +**PQ verdict: NO-GO and keep watching.** Re-assess when (i) NIST MPTS packages land (around March 2027) and (ii) a Cardano CIP adds a PQ signature builtin or witness type. + +## 7. Go/no-go + +| Option | Verdict | +|---|---| +| Replace native-script wallets with FROST | **NO-GO** | +| Optional FROST signer as **one leaf** inside an existing native script | **CONDITIONAL GO** (research-grade, opt-in) | +| PQ (Lemur or lattice threshold) multisig | **NO-GO** (ledger-gated) | + +**Why the hybrid.** A native script such as `atLeast 2 [ alice, bob, FROST(ops-team 3-of-5) ]` keeps the top-level policy auditable on chain, keeps hardware-wallet signers, and lets a sub-team share one seat. It also gets FROST's rotation benefit, because the sub-team can reshare and keep its leaf hash with no change to the wallet address. The PoC already builds this script. + +**Conditions before building it:** +1. Signer built on ZF `frost-ed25519` 3.x via WASM, not this PoC, with an audit of the WASM wrapper and nonce handling. +2. DKG instead of a trusted dealer. +3. Nonce state never persisted, or persisted with single-use enforcement. +4. A UI label that makes clear a leaf is a FROST group, so auditability of that seat is explicitly off chain. +5. Demand from at least one real wallet team. + +**What would flip the main NO-GO:** +- A CIP-30 extension, or Ledger/Trezor apps, that hold FROST shares. +- An on-chain way to attest the threshold. +- A user base where privacy outweighs auditability, which is not the case for DRep or treasury wallets. + +**Next step (if the hybrid is pursued).** A 2-week spike: compile `frost-ed25519` to WASM, run DKG and signing between two browser tabs over the existing pending-tx channel, and submit a preprod spend from a native script containing one FROST leaf. Exit criterion: confirmed on preprod and reviewed by a second engineer. Otherwise, close #220 with this document. diff --git a/scripts/research/frost-ed25519/README.md b/scripts/research/frost-ed25519/README.md new file mode 100644 index 00000000..7bc331df --- /dev/null +++ b/scripts/research/frost-ed25519/README.md @@ -0,0 +1,29 @@ +# FROST(Ed25519, SHA-512) research PoC + +Research only, for issue #220. **Not for production.** It uses bigint arithmetic with no constant-time guarantees, a trusted dealer for keygen (no DKG, refresh or repair), and keeps everything in one process with no coordinator transport. Findings and the go/no-go are in [`docs/research/frost-go-no-go.md`](../../../docs/research/frost-go-no-go.md). + +## Run + +```bash +npx jest src/__tests__/frostResearchPoc.test.ts +``` + +This runs under the CJS jest project (`jest.config.mjs`) with no new dependencies. It uses `@noble/curves` 1.9.7, `@noble/hashes` 1.8.0, and the CSL fork that `@meshsdk/core-csl` re-exports as `csl` (`@sidan-lab/whisky-js-nodejs`). The full suite runs it too via `npm test`. + +## What it proves (13 tests) + +| Test | Claim | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RFC 9591 E.1 vector (2 tests) | Reproduces the group key, the 3 shares, the nonces, commitments, binding-factor inputs, binding factors, signature shares and the final signature **byte for byte**. | +| (a) 2-of-3 and 3-of-5 | The aggregate signature passes noble's stock RFC 8032 `ed25519.verify` and the RFC 9591 cofactored check. | +| (b) vkey witness | `csl.PublicKey.verify(txBodyHash, sig)` is `true` over a real tx body hash. The `Vkeywitness` round-trips through CBOR and is 101 bytes, the same as any single-key witness. | +| (b) native script / address | The blake2b-224 hash of the group key works as an `addr_test1v…` enterprise address and as a `sig` leaf inside a 2-of-3 `atLeast` native script. | +| (c) below threshold | Interpolating over t−1 shares produces a well-formed signature that fails both verifiers. | +| (c) tampered message | Flipping one bit of the tx hash fails both verifiers. | +| Nonce reuse | If a signer answers 3 different messages with one restored nonce pair, a coordinator solves a 3×3 linear system mod L and **recovers the signer's key share exactly**. | + +## What it does not prove + +- **Wallet integration.** No CIP-30 wallet or Ledger/Trezor can hold a share or produce a signature share. That gap is the main blocker. +- **Production readiness.** There is no DKG, no share refresh, no constant-time code, and no secure nonce lifecycle. Any production work should use ZF `frost-ed25519` (Rust, NCC-audited at v0.6.0, now at 3.0.0) through WASM rather than this code. +- **On-chain submission.** Ledger acceptance follows from (b) because the node verifies vkey witnesses with standard Ed25519, but this PoC never submits a transaction to preprod. diff --git a/scripts/research/frost-ed25519/frost.ts b/scripts/research/frost-ed25519/frost.ts new file mode 100644 index 00000000..b9b881bb --- /dev/null +++ b/scripts/research/frost-ed25519/frost.ts @@ -0,0 +1,410 @@ +/** + * FROST(Ed25519, SHA-512) — RFC 9591 research proof of concept. + * + * RESEARCH ONLY. NOT FOR PRODUCTION. + * - No constant-time guarantees: scalar arithmetic uses JS bigint, which leaks + * timing. Secret shares and nonces must never pass through this code in a + * real deployment. + * - Trusted-dealer key generation only (RFC 9591 Appendix C). No DKG, no + * share refresh, no repair. + * - No coordinator networking, no persistence, no protection against nonce + * reuse beyond what the caller does (see `SigningNonces`). + * - Written to answer one question for issue #220: can a FROST group key sit + * in a Cardano address / native-script `sig` leaf as one ordinary Ed25519 + * key, witnessed by one ordinary vkey witness? See + * docs/research/frost-go-no-go.md. + * + * Follows RFC 9591 Sections 4, 5 and 6.1 and reproduces the Appendix E.1 test + * vector byte-for-byte (src/__tests__/frostResearchPoc.test.ts). + */ +import { ed25519 } from "@noble/curves/ed25519"; +import { sha512 } from "@noble/hashes/sha2"; +import { + bytesToHex, + bytesToNumberLE, + concatBytes, + hexToBytes, + numberToBytesLE, + randomBytes, +} from "@noble/curves/utils"; + +const Point = ed25519.Point; +type Point = InstanceType; + +/** Group order L = 2^252 + 27742317777372353535851937790883648493. */ +export const L = 2n ** 252n + 27742317777372353535851937790883648493n; + +const CONTEXT = new TextEncoder().encode("FROST-ED25519-SHA512-v1"); +const utf8 = (s: string) => new TextEncoder().encode(s); + +// --------------------------------------------------------------------------- +// Scalar field helpers +// --------------------------------------------------------------------------- + +export const mod = (a: bigint): bigint => { + const r = a % L; + return r >= 0n ? r : r + L; +}; + +export function invert(a: bigint): bigint { + // Fermat: a^(L-2) mod L. L is prime. + let base = mod(a); + if (base === 0n) throw new Error("invert(0)"); + let e = L - 2n; + let r = 1n; + while (e > 0n) { + if (e & 1n) r = (r * base) % L; + base = (base * base) % L; + e >>= 1n; + } + return r; +} + +export const serializeScalar = (s: bigint): Uint8Array => + numberToBytesLE(mod(s), 32); + +export function deserializeScalar(buf: Uint8Array): bigint { + if (buf.length !== 32) throw new Error("scalar must be 32 bytes"); + const s = bytesToNumberLE(buf); + if (s >= L) throw new Error("non-canonical scalar"); + return s; +} + +export function serializeElement(P: Point): Uint8Array { + if (P.is0()) throw new Error("cannot serialize identity element"); + return P.toBytes(); +} + +export function deserializeElement(buf: Uint8Array): Point { + const P = Point.fromBytes(buf); + if (P.is0()) throw new Error("identity element"); + if (!P.isTorsionFree()) throw new Error("point not in prime-order subgroup"); + return P; +} + +/** [s]B, allowing s = 0 (noble's multiply rejects 0). */ +const baseMul = (s: bigint): Point => + mod(s) === 0n ? Point.ZERO : Point.BASE.multiply(mod(s)); +const pointMul = (P: Point, s: bigint): Point => + mod(s) === 0n ? Point.ZERO : P.multiply(mod(s)); + +// --------------------------------------------------------------------------- +// RFC 9591 Section 6.1 hash functions +// --------------------------------------------------------------------------- + +const hashToScalar = (...parts: Uint8Array[]) => + mod(bytesToNumberLE(sha512(concatBytes(...parts)))); + +export const H1 = (m: Uint8Array) => hashToScalar(CONTEXT, utf8("rho"), m); +/** H2 deliberately has no domain separator: RFC 8032 compatibility. */ +export const H2 = (m: Uint8Array) => hashToScalar(m); +export const H3 = (m: Uint8Array) => hashToScalar(CONTEXT, utf8("nonce"), m); +export const H4 = (m: Uint8Array) => + sha512(concatBytes(CONTEXT, utf8("msg"), m)); +export const H5 = (m: Uint8Array) => + sha512(concatBytes(CONTEXT, utf8("com"), m)); + +// --------------------------------------------------------------------------- +// Key generation — trusted dealer (RFC 9591 Appendix C) +// --------------------------------------------------------------------------- + +export interface KeyShare { + identifier: bigint; // NonZeroScalar, 1..n + secretShare: bigint; // s_i + publicShare: Point; // PK_i = [s_i]B +} + +export interface KeyPackage { + groupPublicKey: Point; + threshold: number; + shares: KeyShare[]; + /** Polynomial commitment (VSS), [a_j]B for j=0..t-1. */ + vssCommitment: Point[]; +} + +export function randomScalar(): bigint { + // 64 bytes reduced mod L: bias < 2^-250. + return mod(bytesToNumberLE(randomBytes(64))); +} + +function evalPoly(coeffs: bigint[], x: bigint): bigint { + // Horner, coeffs[0] is the constant term. + let acc = 0n; + for (let i = coeffs.length - 1; i >= 0; i--) acc = mod(acc * x + coeffs[i]!); + return acc; +} + +/** + * Shamir-split `groupSecret` into `maxSigners` shares, `minSigners` of which + * reconstruct it. `coefficients` (a_1..a_{t-1}) may be passed for test vectors. + */ +export function trustedDealerKeygen( + groupSecret: bigint, + maxSigners: number, + minSigners: number, + coefficients?: bigint[], +): KeyPackage { + if (minSigners < 2 || minSigners > maxSigners) { + throw new Error("require 2 <= minSigners <= maxSigners"); + } + const rest = + coefficients ?? + Array.from({ length: minSigners - 1 }, () => randomScalar()); + if (rest.length !== minSigners - 1) throw new Error("bad coefficient count"); + const coeffs = [mod(groupSecret), ...rest.map(mod)]; + const shares: KeyShare[] = []; + for (let i = 1; i <= maxSigners; i++) { + const id = BigInt(i); + const s = evalPoly(coeffs, id); + shares.push({ identifier: id, secretShare: s, publicShare: baseMul(s) }); + } + return { + groupPublicKey: baseMul(coeffs[0]!), + threshold: minSigners, + shares, + vssCommitment: coeffs.map(baseMul), + }; +} + +/** Feldman VSS check a participant runs on receipt of its share. */ +export function vssVerify(share: KeyShare, vssCommitment: Point[]): boolean { + let expected = Point.ZERO; + let xPow = 1n; + for (const C of vssCommitment) { + expected = expected.add(pointMul(C, xPow)); + xPow = mod(xPow * share.identifier); + } + return expected.equals(baseMul(share.secretShare)); +} + +// --------------------------------------------------------------------------- +// Section 4 helpers +// --------------------------------------------------------------------------- + +export function nonceGenerate(secret: bigint, randomness?: Uint8Array): bigint { + const r = randomness ?? randomBytes(32); + if (r.length !== 32) throw new Error("nonce randomness must be 32 bytes"); + return H3(concatBytes(r, serializeScalar(secret))); +} + +export function deriveInterpolatingValue(L_: bigint[], xi: bigint): bigint { + if (!L_.includes(xi)) throw new Error("invalid parameters"); + if (new Set(L_.map(String)).size !== L_.length) { + throw new Error("invalid parameters"); + } + let num = 1n; + let den = 1n; + for (const xj of L_) { + if (xj === xi) continue; + num = mod(num * xj); + den = mod(den * (xj - xi)); + } + return mod(num * invert(den)); +} + +export interface Commitment { + identifier: bigint; + hiding: Point; // D_i + binding: Point; // E_i +} + +function sortCommitments(list: Commitment[]): Commitment[] { + return [...list].sort((a, b) => + a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0, + ); +} + +export function encodeGroupCommitmentList(list: Commitment[]): Uint8Array { + return concatBytes( + ...sortCommitments(list).flatMap((c) => [ + serializeScalar(c.identifier), + serializeElement(c.hiding), + serializeElement(c.binding), + ]), + ); +} + +export function bindingFactorInput( + groupPublicKey: Point, + list: Commitment[], + msg: Uint8Array, + identifier: bigint, +): Uint8Array { + return concatBytes( + serializeElement(groupPublicKey), + H4(msg), + H5(encodeGroupCommitmentList(list)), + serializeScalar(identifier), + ); +} + +export function computeBindingFactors( + groupPublicKey: Point, + list: Commitment[], + msg: Uint8Array, +): Map { + const out = new Map(); + for (const c of sortCommitments(list)) { + out.set( + c.identifier, + H1(bindingFactorInput(groupPublicKey, list, msg, c.identifier)), + ); + } + return out; +} + +export function computeGroupCommitment( + list: Commitment[], + bindingFactors: Map, +): Point { + let R = Point.ZERO; + for (const c of sortCommitments(list)) { + const rho = bindingFactors.get(c.identifier); + if (rho === undefined) throw new Error("invalid participant"); + R = R.add(c.hiding).add(pointMul(c.binding, rho)); + } + return R; +} + +export function computeChallenge( + R: Point, + groupPublicKey: Point, + msg: Uint8Array, +): bigint { + return H2( + concatBytes(serializeElement(R), serializeElement(groupPublicKey), msg), + ); +} + +// --------------------------------------------------------------------------- +// Section 5 — two-round signing +// --------------------------------------------------------------------------- + +/** + * Secret per-signing-session state. MUST be used for exactly one message and + * then destroyed. Persisting, restoring or reusing it leaks the key share + * (demonstrated in the test suite). This is the operational hazard of FROST. + */ +export interface SigningNonces { + hiding: bigint; // d_i + binding: bigint; // e_i +} + +/** Round 1 (Section 5.1). Optional randomness is for test vectors only. */ +export function commit( + share: KeyShare, + hidingRandomness?: Uint8Array, + bindingRandomness?: Uint8Array, +): { nonces: SigningNonces; commitment: Commitment } { + const hiding = nonceGenerate(share.secretShare, hidingRandomness); + const binding = nonceGenerate(share.secretShare, bindingRandomness); + return { + nonces: { hiding, binding }, + commitment: { + identifier: share.identifier, + hiding: baseMul(hiding), + binding: baseMul(binding), + }, + }; +} + +/** Round 2 (Section 5.2): z_i = d_i + e_i*rho_i + lambda_i*s_i*c. */ +export function signShare( + share: KeyShare, + nonces: SigningNonces, + groupPublicKey: Point, + list: Commitment[], + msg: Uint8Array, +): bigint { + const bf = computeBindingFactors(groupPublicKey, list, msg); + const rho = bf.get(share.identifier); + if (rho === undefined) throw new Error("signer not in commitment list"); + const R = computeGroupCommitment(list, bf); + const ids = sortCommitments(list).map((c) => c.identifier); + const lambda = deriveInterpolatingValue(ids, share.identifier); + const c = computeChallenge(R, groupPublicKey, msg); + return mod( + nonces.hiding + nonces.binding * rho + lambda * share.secretShare * c, + ); +} + +/** Coordinator-side share check (Section 5.4) — identifies a cheating signer. */ +export function verifySignatureShare( + identifier: bigint, + publicShare: Point, + sigShare: bigint, + groupPublicKey: Point, + list: Commitment[], + msg: Uint8Array, +): boolean { + const bf = computeBindingFactors(groupPublicKey, list, msg); + const comm = list.find((c) => c.identifier === identifier); + const rho = bf.get(identifier); + if (!comm || rho === undefined) return false; + const R = computeGroupCommitment(list, bf); + const c = computeChallenge(R, groupPublicKey, msg); + const ids = sortCommitments(list).map((x) => x.identifier); + const lambda = deriveInterpolatingValue(ids, identifier); + const lhs = baseMul(sigShare); + const rhs = comm.hiding + .add(pointMul(comm.binding, rho)) + .add(pointMul(publicShare, mod(c * lambda))); + return lhs.equals(rhs); +} + +/** Aggregate (Section 5.3): sig = SerializeElement(R) || SerializeScalar(sum z_i). */ +export function aggregate( + list: Commitment[], + msg: Uint8Array, + groupPublicKey: Point, + sigShares: bigint[], +): Uint8Array { + const bf = computeBindingFactors(groupPublicKey, list, msg); + const R = computeGroupCommitment(list, bf); + const z = sigShares.reduce((a, b) => mod(a + b), 0n); + return concatBytes(serializeElement(R), serializeScalar(z)); +} + +/** + * Convenience: run both rounds for a chosen signer subset in-process. + * (In reality each signer runs on its own device and the coordinator only + * sees commitments and shares.) + */ +export function signWithSubset( + pkg: KeyPackage, + signerIds: number[], + msg: Uint8Array, +): Uint8Array { + const signers = signerIds.map((id) => { + const s = pkg.shares.find((x) => x.identifier === BigInt(id)); + if (!s) throw new Error(`unknown signer ${id}`); + return s; + }); + const round1 = signers.map((s) => ({ share: s, ...commit(s) })); + const list = round1.map((r) => r.commitment); + const shares = round1.map((r) => + signShare(r.share, r.nonces, pkg.groupPublicKey, list, msg), + ); + return aggregate(list, msg, pkg.groupPublicKey, shares); +} + +/** RFC 8032 cofactored check required by RFC 9591 §6.1: [8][z]B = [8]R + [8][c]PK. */ +export function verifyCofactored( + sig: Uint8Array, + msg: Uint8Array, + groupPublicKey: Point, +): boolean { + if (sig.length !== 64) return false; + try { + const R = Point.fromBytes(sig.slice(0, 32)); + const z = deserializeScalar(sig.slice(32)); + const c = H2(concatBytes(sig.slice(0, 32), groupPublicKey.toBytes(), msg)); + const lhs = baseMul(z).clearCofactor(); + const rhs = R.add(pointMul(groupPublicKey, c)).clearCofactor(); + return lhs.equals(rhs); + } catch { + return false; + } +} + +export { Point, bytesToHex, hexToBytes }; diff --git a/src/__tests__/frostResearchPoc.test.ts b/src/__tests__/frostResearchPoc.test.ts new file mode 100644 index 00000000..eed85ff2 --- /dev/null +++ b/src/__tests__/frostResearchPoc.test.ts @@ -0,0 +1,368 @@ +/** + * Research PoC tests for issue #220 (FROST go/no-go). + * See docs/research/frost-go-no-go.md and scripts/research/frost-ed25519/. + * + * Proves: + * - the PoC reproduces RFC 9591 Appendix E.1 (FROST(Ed25519, SHA-512)) byte-for-byte; + * - a t-of-n FROST signature verifies with a stock RFC 8032 verifier (noble); + * - it verifies as a Cardano vkey witness under the CSL fork Mesh ships + * (@meshsdk/core-csl → whisky-js), over a real tx body hash; + * - the group key hash works as a native-script `sig` leaf and an enterprise address; + * - fewer-than-threshold and tampered-message signatures fail; + * - nonce reuse leaks the signer's key share (the operational hazard). + */ +import { describe, expect, it } from "@jest/globals"; +import { ed25519 } from "@noble/curves/ed25519"; +import { csl } from "@meshsdk/core-csl"; +import { resolveTxHash } from "@meshsdk/core-cst"; + +import { + aggregate, + bindingFactorInput, + bytesToHex, + commit, + computeBindingFactors, + computeChallenge, + computeGroupCommitment, + hexToBytes, + invert, + deriveInterpolatingValue, + mod, + signShare, + signWithSubset, + trustedDealerKeygen, + verifyCofactored, + verifySignatureShare, + vssVerify, + type Commitment, + type KeyPackage, +} from "../../scripts/research/frost-ed25519/frost"; + +const h = (s: string) => hexToBytes(s.replace(/\s+/g, "")); +const le = (s: string) => { + // little-endian hex → bigint + const b = h(s); + let n = 0n; + for (let i = b.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(b[i]!); + return n; +}; +const enc = (s: string) => new TextEncoder().encode(s); + +// RFC 9591 Appendix E.1, FROST(Ed25519, SHA-512), copied from +// https://www.rfc-editor.org/rfc/rfc9591.txt +const V = { + groupSecretKey: + "7b1c33d3f5291d85de664833beb1ad469f7fb6025a0ec78b3a790c6e13a98304", + groupPublicKey: + "15d21ccd7ee42959562fc8aa63224c8851fb3ec85a3faf66040d380fb9738673", + message: "74657374", + coeff1: "178199860edd8c62f5212ee91eff1295d0d670ab4ed4506866bae57e7030b204", + shares: { + 1: "929dcc590407aae7d388761cddb0c0db6f5627aea8e217f4a033f2ec83d93509", + 2: "a91e66e012e4364ac9aaa405fcafd370402d9859f7b6685c07eed76bf409e80d", + 3: "d3cb090a075eb154e82fdb4b3cb507f110040905468bb9c46da8bdea643a9a02", + } as Record, + p1: { + hidingRand: + "0fd2e39e111cdc266f6c0f4d0fd45c947761f1f5d3cb583dfcb9bbaf8d4c9fec", + bindingRand: + "69cd85f631d5f7f2721ed5e40519b1366f340a87c2f6856363dbdcda348a7501", + hidingNonce: + "812d6104142944d5a55924de6d49940956206909f2acaeedecda2b726e630407", + bindingNonce: + "b1110165fc2334149750b28dd813a39244f315cff14d4e89e6142f262ed83301", + hidingCommit: + "b5aa8ab305882a6fc69cbee9327e5a45e54c08af61ae77cb8207be3d2ce13de3", + bindingCommit: + "67e98ab55aa310c3120418e5050c9cf76cf387cb20ac9e4b6fdb6f82a469f932", + bfInput: + "15d21ccd7ee42959562fc8aa63224c8851fb3ec85a3faf66040d380fb9738673504df914fa965023fb75c25ded4bb260f417de6d32e5c442c6ba313791cc9a4948d6273e8d3511f93348ea7a708a9b862bc73ba2a79cfdfe07729a193751cbc973af46d8ac3440e518d4ce440a0e7d4ad5f62ca8940f32de6d8dc00fc12c660b817d587d82f856d277ce6473cae6d2f5763f7da2e8b4d799a3f3e725d4522ec70100000000000000000000000000000000000000000000000000000000000000", + bindingFactor: + "f2cb9d7dd9beff688da6fcc83fa89046b3479417f47f55600b106760eb3b5603", + sigShare: + "001719ab5a53ee1a12095cd088fd149702c0720ce5fd2f29dbecf24b7281b603", + }, + p3: { + hidingRand: + "86d64a260059e495d0fb4fcc17ea3da7452391baa494d4b00321098ed2a0062f", + bindingRand: + "13e6b25afb2eba51716a9a7d44130c0dbae0004a9ef8d7b5550c8a0e07c61775", + hidingNonce: + "c256de65476204095ebdc01bd11dc10e57b36bc96284595b8215222374f99c0e", + bindingNonce: + "243d71944d929063bc51205714ae3c2218bd3451d0214dfb5aeec2a90c35180d", + hidingCommit: + "cfbdb165bd8aad6eb79deb8d287bcc0ab6658ae57fdcc98ed12c0669e90aec91", + bindingCommit: + "7487bc41a6e712eea2f2af24681b58b1cf1da278ea11fe4e8b78398965f13552", + bfInput: + "15d21ccd7ee42959562fc8aa63224c8851fb3ec85a3faf66040d380fb9738673504df914fa965023fb75c25ded4bb260f417de6d32e5c442c6ba313791cc9a4948d6273e8d3511f93348ea7a708a9b862bc73ba2a79cfdfe07729a193751cbc973af46d8ac3440e518d4ce440a0e7d4ad5f62ca8940f32de6d8dc00fc12c660b817d587d82f856d277ce6473cae6d2f5763f7da2e8b4d799a3f3e725d4522ec70300000000000000000000000000000000000000000000000000000000000000", + bindingFactor: + "b087686bf35a13f3dc78e780a34b0fe8a77fef1b9938c563f5573d71d8d7890f", + sigShare: + "bd86125de990acc5e1f13781d8e32c03a9bbd4c53539bbc106058bfd14326007", + }, + sig: "36282629c383bb820a88b71cae937d41f2f2adfcc3d02e55507e2fb9e2dd3cbebd9d2b0844e49ae0f3fa935161e1419aab7b47d21a37ebeae1f17d4987b3160b", +}; + +const scalarHex = (s: bigint) => { + const out = new Uint8Array(32); + let x = s; + for (let i = 0; i < 32; i++) { + out[i] = Number(x & 0xffn); + x >>= 8n; + } + return bytesToHex(out); +}; + +/** Build a minimal but real Cardano tx body and return its blake2b-256 hash. */ +function realTxBodyHash(): Uint8Array { + const inputs = csl.TransactionInputs.new(); + inputs.add( + csl.TransactionInput.new( + csl.TransactionHash.from_bytes(new Uint8Array(32)), + 0, + ), + ); + const outputs = csl.TransactionOutputs.new(); + const sink = csl.PrivateKey.generate_ed25519().to_public(); + outputs.add( + csl.TransactionOutput.new( + csl.EnterpriseAddress.new( + 0, + csl.Credential.from_keyhash(sink.hash()), + ).to_address(), + csl.Value.new(csl.BigNum.from_str("1000000")), + ), + ); + const body = csl.TransactionBody.new( + inputs, + outputs, + csl.BigNum.from_str("200000"), + undefined, + ); + const txHex = csl.Transaction.new( + body, + csl.TransactionWitnessSet.new(), + undefined, + ).to_hex(); + return hexToBytes(resolveTxHash(txHex)); +} + +describe("FROST(Ed25519, SHA-512) — RFC 9591 Appendix E.1 test vector", () => { + const pkg: KeyPackage = trustedDealerKeygen(le(V.groupSecretKey), 3, 2, [ + le(V.coeff1), + ]); + const msg = h(V.message); + + it("reproduces group public key and all three participant shares", () => { + expect(bytesToHex(pkg.groupPublicKey.toBytes())).toBe(V.groupPublicKey); + for (const s of pkg.shares) { + expect(scalarHex(s.secretShare)).toBe(V.shares[Number(s.identifier)]); + expect(vssVerify(s, pkg.vssCommitment)).toBe(true); + } + }); + + it("reproduces nonces, commitments, binding factors, sig shares and the final signature", () => { + const p1 = pkg.shares[0]!; + const p3 = pkg.shares[2]!; + const r1 = commit(p1, h(V.p1.hidingRand), h(V.p1.bindingRand)); + const r3 = commit(p3, h(V.p3.hidingRand), h(V.p3.bindingRand)); + + expect(scalarHex(r1.nonces.hiding)).toBe(V.p1.hidingNonce); + expect(scalarHex(r1.nonces.binding)).toBe(V.p1.bindingNonce); + expect(bytesToHex(r1.commitment.hiding.toBytes())).toBe(V.p1.hidingCommit); + expect(bytesToHex(r1.commitment.binding.toBytes())).toBe( + V.p1.bindingCommit, + ); + expect(scalarHex(r3.nonces.hiding)).toBe(V.p3.hidingNonce); + expect(scalarHex(r3.nonces.binding)).toBe(V.p3.bindingNonce); + expect(bytesToHex(r3.commitment.hiding.toBytes())).toBe(V.p3.hidingCommit); + expect(bytesToHex(r3.commitment.binding.toBytes())).toBe( + V.p3.bindingCommit, + ); + + const list = [r1.commitment, r3.commitment]; + expect( + bytesToHex(bindingFactorInput(pkg.groupPublicKey, list, msg, 1n)), + ).toBe(V.p1.bfInput); + expect( + bytesToHex(bindingFactorInput(pkg.groupPublicKey, list, msg, 3n)), + ).toBe(V.p3.bfInput); + const bf = computeBindingFactors(pkg.groupPublicKey, list, msg); + expect(scalarHex(bf.get(1n)!)).toBe(V.p1.bindingFactor); + expect(scalarHex(bf.get(3n)!)).toBe(V.p3.bindingFactor); + + const z1 = signShare(p1, r1.nonces, pkg.groupPublicKey, list, msg); + const z3 = signShare(p3, r3.nonces, pkg.groupPublicKey, list, msg); + expect(scalarHex(z1)).toBe(V.p1.sigShare); + expect(scalarHex(z3)).toBe(V.p3.sigShare); + expect( + verifySignatureShare( + 1n, + p1.publicShare, + z1, + pkg.groupPublicKey, + list, + msg, + ), + ).toBe(true); + expect( + verifySignatureShare( + 3n, + p3.publicShare, + z3, + pkg.groupPublicKey, + list, + msg, + ), + ).toBe(true); + + const sig = aggregate(list, msg, pkg.groupPublicKey, [z1, z3]); + expect(bytesToHex(sig)).toBe(V.sig); + // And the RFC vector itself is a plain Ed25519 signature. + expect(ed25519.verify(sig, msg, pkg.groupPublicKey.toBytes())).toBe(true); + }); +}); + +describe.each([ + { t: 2, n: 3, signers: [1, 3], short: [2] }, + { t: 3, n: 5, signers: [2, 4, 5], short: [1, 3] }, +])("FROST $t-of-$n", ({ t, n, signers, short }) => { + const pkg = trustedDealerKeygen( + mod(BigInt("0x" + bytesToHex(ed25519.utils.randomSecretKey()))), + n, + t, + ); + const pk = pkg.groupPublicKey.toBytes(); + const txHash = realTxBodyHash(); + + it("(a) aggregate signature verifies with a stock RFC 8032 verifier", () => { + const sig = signWithSubset(pkg, signers, txHash); + expect(sig).toHaveLength(64); + expect(ed25519.verify(sig, txHash, pk)).toBe(true); + expect(verifyCofactored(sig, txHash, pkg.groupPublicKey)).toBe(true); + // Any other qualifying subset produces a (different) valid signature. + const other = Array.from({ length: n }, (_, i) => i + 1).slice(-t); + expect(ed25519.verify(signWithSubset(pkg, other, txHash), txHash, pk)).toBe( + true, + ); + }); + + it("(b) is accepted as a Cardano vkey witness by the CSL fork Mesh ships", () => { + const sig = signWithSubset(pkg, signers, txHash); + const cslPk = csl.PublicKey.from_bytes(pk); + const cslSig = csl.Ed25519Signature.from_bytes(sig); + expect(cslPk.verify(txHash, cslSig)).toBe(true); + + const witness = csl.Vkeywitness.new(csl.Vkey.new(cslPk), cslSig); + const roundTrip = csl.Vkeywitness.from_bytes(witness.to_bytes()); + expect( + roundTrip.vkey().public_key().verify(txHash, roundTrip.signature()), + ).toBe(true); + // One ordinary witness: 0x82 array, 32-byte vkey, 64-byte sig = 101 bytes. + expect(witness.to_bytes()).toHaveLength(101); + }); + + it("(b) group key hash works as a native-script sig leaf and an enterprise address", () => { + const keyHash = csl.PublicKey.from_bytes(pk).hash(); + expect(keyHash.to_bytes()).toHaveLength(28); + + const enterprise = csl.EnterpriseAddress.new( + 0, + csl.Credential.from_keyhash(keyHash), + ) + .to_address() + .to_bech32(undefined); + expect(enterprise.startsWith("addr_test1v")).toBe(true); + + // Hybrid: FROST group key as ONE leaf of a 2-of-3 native script alongside two ordinary keys. + const leaves = csl.NativeScripts.new(); + leaves.add( + csl.NativeScript.new_script_pubkey(csl.ScriptPubkey.new(keyHash)), + ); + for (let i = 0; i < 2; i++) { + const k = csl.PrivateKey.generate_ed25519().to_public().hash(); + leaves.add(csl.NativeScript.new_script_pubkey(csl.ScriptPubkey.new(k))); + } + const script = csl.NativeScript.new_script_n_of_k( + csl.ScriptNOfK.new(2, leaves), + ); + const required = script.get_required_signers(); + const hexes = Array.from({ length: required.len() }, (_, i) => + required.get(i).to_hex(), + ); + expect(hexes).toContain(keyHash.to_hex()); + expect(script.hash().to_bytes()).toHaveLength(28); + }); + + it("(c) fewer than threshold shares cannot produce a valid signature", () => { + // Interpolating over t-1 shares yields the wrong secret; the result is a + // well-formed but invalid signature. + const sub = short.map((id) => pkg.shares[id - 1]!); + const r = sub.map((s) => ({ s, ...commit(s) })); + const list = r.map((x) => x.commitment); + const z = r.map((x) => + signShare(x.s, x.nonces, pkg.groupPublicKey, list, txHash), + ); + const sig = aggregate(list, txHash, pkg.groupPublicKey, z); + expect(ed25519.verify(sig, txHash, pk)).toBe(false); + expect( + csl.PublicKey.from_bytes(pk).verify( + txHash, + csl.Ed25519Signature.from_bytes(sig), + ), + ).toBe(false); + }); + + it("(c) a tampered message fails verification", () => { + const sig = signWithSubset(pkg, signers, txHash); + const tampered = txHash.slice(); + tampered[0] = tampered[0]! ^ 0x01; + expect(ed25519.verify(sig, tampered, pk)).toBe(false); + expect( + csl.PublicKey.from_bytes(pk).verify( + tampered, + csl.Ed25519Signature.from_bytes(sig), + ), + ).toBe(false); + }); +}); + +describe("FROST operational hazard — nonce reuse leaks the key share", () => { + it("three signatures under one nonce pair let a malicious coordinator solve for s_i", () => { + const pkg = trustedDealerKeygen(mod(123456789n), 3, 2); + const victim = pkg.shares[0]!; + const honest = pkg.shares[1]!; + const { nonces, commitment } = commit(victim); // victim restores this state 3x + const ids = [victim.identifier, honest.identifier]; + const lambda = deriveInterpolatingValue(ids, victim.identifier); + + // Coordinator varies the message; victim answers each with the same (d, e). + const rows: [bigint, bigint, bigint, bigint][] = []; + for (const m of ["pay 1", "pay 2", "pay 3"]) { + const msg = enc(m); + const list: Commitment[] = [commitment, commit(honest).commitment]; + const z = signShare(victim, nonces, pkg.groupPublicKey, list, msg); + const bf = computeBindingFactors(pkg.groupPublicKey, list, msg); + const R = computeGroupCommitment(list, bf); + const c = computeChallenge(R, pkg.groupPublicKey, msg); + // z = 1*d + rho*e + c*(lambda*s) + rows.push([1n, bf.get(victim.identifier)!, c, z]); + } + + // Solve the 3x3 linear system mod L (Cramer) for x = lambda*s. + const det3 = (m: bigint[][]) => + mod( + m[0]![0]! * (m[1]![1]! * m[2]![2]! - m[1]![2]! * m[2]![1]!) - + m[0]![1]! * (m[1]![0]! * m[2]![2]! - m[1]![2]! * m[2]![0]!) + + m[0]![2]! * (m[1]![0]! * m[2]![1]! - m[1]![1]! * m[2]![0]!), + ); + const A = rows.map((r) => [r[0], r[1], r[2]]); + const Ax = rows.map((r) => [r[0], r[1], r[3]]); + const x = mod(det3(Ax) * invert(det3(A))); + const recovered = mod(x * invert(lambda)); + + expect(recovered).toBe(victim.secretShare); + }); +}); diff --git a/src/components/pages/homepage/roadmap/data.ts b/src/components/pages/homepage/roadmap/data.ts index 50ae2dc8..a0495c21 100644 --- a/src/components/pages/homepage/roadmap/data.ts +++ b/src/components/pages/homepage/roadmap/data.ts @@ -361,10 +361,10 @@ export const TRACKS: Track[] = [ label: "FROST & PQC — kickoff to go/no-go", start: 4, span: 3, - status: "planned", + status: "done", owner: "Q", detail: - "FROST threshold Schnorr for Cardano (#220), plus Lemour post-quantum multi-sig. Kickoff slipped from July — starting in August keeps runway for the October go/no-go. Deliverables: a trade-off note, a PoC if libraries allow, and a recommendation.", + "FROST threshold Schnorr for Cardano (#220) and Lemur post-quantum multi-sig, decided: a PoC verifies FROST(Ed25519) aggregates as ordinary Cardano vkey witnesses. No-go on replacing native scripts, conditional go on a FROST signer as one leaf inside a native script, no-go on PQ until the ledger can verify a PQ signature.", }, ], }, diff --git a/vault/features/FROST Threshold Signatures.md b/vault/features/FROST Threshold Signatures.md index d7260b4c..ffd8be5d 100644 --- a/vault/features/FROST Threshold Signatures.md +++ b/vault/features/FROST Threshold Signatures.md @@ -1,23 +1,31 @@ --- type: feature area: Research -state: planned +state: delivered owner: Quirin -milestone: 2026-08 +milestone: 2026-10 issues: [220] -updated: 2026-07-27 +updated: 2026-10-16 --- # FROST Threshold Signatures -Evaluate FROST — flexible round-optimized Schnorr threshold signatures — as a -replacement for or complement to native-script multisig: a smaller on-chain -footprint, better privacy from a single on-chain signature, and flexible thresholds. -Deliverables are a trade-off note against native scripts, a proof of concept if the -libraries allow, and a go/no-go. +Research delivered: [`docs/research/frost-go-no-go.md`](../../docs/research/frost-go-no-go.md). -Kickoff slipped from July; starting in August is what keeps runway before the -October decision. +FROST(Ed25519, SHA-512) works on Cardano without a ledger change. The group key is +one ordinary key hash, and its signature is one ordinary 101-byte vkey witness. The +PoC in `scripts/research/frost-ed25519/` reproduces the RFC 9591 test vector byte for +byte and verifies 2-of-3 and 3-of-5 signatures under the Mesh CSL. Run it with +`npx jest src/__tests__/frostResearchPoc.test.ts`; all 13 tests pass. + +**Verdict: NO-GO on replacing native scripts.** The saving is 206 B (≈0.009 ADA) per +transaction for 2-of-3 and 371 B (≈0.016 ADA) for 3-of-5. Against that, the signer +set and threshold leave the chain, no CIP-30 or hardware wallet can hold a share, and +nonce reuse leaks key shares. + +**CONDITIONAL GO on an opt-in FROST signer as one leaf inside a native script.** It +would be built on ZF `frost-ed25519` via WASM with DKG, so a sub-team can share a seat +and reshare without moving the wallet address. ## Related diff --git a/vault/features/Post-Quantum Multi-Sig.md b/vault/features/Post-Quantum Multi-Sig.md index ef09430e..97ee1371 100644 --- a/vault/features/Post-Quantum Multi-Sig.md +++ b/vault/features/Post-Quantum Multi-Sig.md @@ -1,18 +1,25 @@ --- type: feature area: Research -state: planned +state: delivered owner: Quirin milestone: 2026-10 issues: [220] -updated: 2026-07-27 +updated: 2026-10-16 --- # Post-Quantum Multi-Sig -Assess Lemour lattice-based threshold signatures for long-term quantum resistance, -as a forward-looking alternative or complement to FROST. Maturity and available -libraries are the open questions. +Assessment delivered in [`docs/research/frost-go-no-go.md`](../../docs/research/frost-go-no-go.md) §6. + +No scheme called "Lemour" exists. The roadmap item refers to **Lemur** (Monash, ePrint +2026/1161, NIST threshold call): a stateful lattice *synchronized aggregate +multi-signature*, not t-of-n, with no public implementation. Lattice threshold schemes +such as Ringtail sign at about 13 KB, and NIST MPTS packages are due around March 2027. + +**Verdict: NO-GO, ledger-gated.** Neither native scripts nor Plutus builtins verify any +post-quantum signature today, so an application cannot build PQ multisig. Re-assess +when NIST MPTS packages land and a CIP adds a PQ verification primitive. ## Related From de89bac6b7ad86317ed5e427075a59b609b96c71 Mon Sep 17 00:00:00 2001 From: QSchlegel <34195172+QSchlegel@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:40:37 +0200 Subject: [PATCH 3/3] fix(documents): harden provenance after independent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit verifier - Proof-level errors (format, hash shape, requiredSigners < 1) are reported, not dropped; proof wallet and attestation wallet are cross-checked. - Lineage is re-derived from the event log and must match what the package states, so a restore cannot be relabelled as an upload. Events now carry their versionId. - A version whose log shows review.started must carry its proof, and the proof's threshold must match the one the round started with — the threshold is in no signed payload, so this is the only check on it. - An emptied event log is an error; requireAttestation turns a stripped chain into an error. AUDIT_INSTRUCTIONS now say plainly what is signed and what is a database record. Public verifyAudit - auditPackageProblem: structural, bounded shape check (versions, reviews, chain, events capped) so malformed input is a 400, not a TypeError 500, and nobody can buy unbounded CIP-8 work. Verification wrapped likewise. - No Math.min spread over the chain; a deployment with no attestation key falls back to the package's keys (flagged) instead of failing every chain. restoreVersion - Refused when the head already has the source's content (not only when it is the same row), so a restore cannot silently end a round over the same bytes. - Optional expectedHeadVersionId: a stale view of the head is a CONFLICT. The UI sends it, hides Restore for same-content versions, and warns when restoring would end an in-review round with signatures. - A unique-constraint race (P2002) is a CONFLICT, not a 500. exportAudit reads versions, events and attestations in one RepeatableRead transaction and no longer loads inline bytes. Diff - Split on \n only: a line-ending change is visible, and a trailing-newline-only change explains itself instead of an empty diff. - UTF-8 decoded with fatal + ignoreBOM, so BOM files diff. - toHunks is linear; LCS table is Uint16 (8MB at the cap); removals before additions, as in unified diff. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016DwjSXosdJg8rATTa7pQEn --- src/__tests__/documentAudit.test.ts | 133 ++++++++- src/__tests__/documentDiff.test.ts | 30 ++- src/__tests__/trpc/documentProvenance.test.ts | 25 +- .../pages/wallet/documents/detail.tsx | 47 ++-- .../wallet/documents/version-diff-dialog.tsx | 6 +- src/lib/documents/audit.ts | 254 +++++++++++++++++- src/lib/documents/diff.ts | 93 ++++--- src/server/api/routers/documents.ts | 188 ++++++++----- 8 files changed, 638 insertions(+), 138 deletions(-) diff --git a/src/__tests__/documentAudit.test.ts b/src/__tests__/documentAudit.test.ts index 0ade1c57..d869615a 100644 --- a/src/__tests__/documentAudit.test.ts +++ b/src/__tests__/documentAudit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@jest/globals"; import { AUDIT_FORMAT, + auditPackageProblem, buildAuditPackage, verifyAuditPackage, versionLineage, @@ -192,6 +193,13 @@ function honestSource() { createdAt: at(10), metadata: { versionNumber: 1 }, }, + { + versionId: "ver_1", + type: "review.started", + actorAddress: SIGNERS[0]!, + createdAt: at(11), + metadata: { requiredSigners: 2, signerCount: 3 }, + }, { versionId: "ver_1", type: "version.superseded", @@ -224,6 +232,13 @@ function honestSource() { restoredFromVersionId: "ver_1", }, }, + { + versionId: "ver_3", + type: "review.started", + actorAddress: SIGNERS[1]!, + createdAt: at(31), + metadata: { requiredSigners: 2, signerCount: 3 }, + }, ]; return { versions, events, attestations: attest(versions) }; } @@ -270,7 +285,7 @@ describe("buildAuditPackage", () => { expect(pkg.format).toBe(AUDIT_FORMAT); expect(pkg.versions.map((v) => v.version.versionNumber)).toEqual([1, 2, 3]); expect(pkg.versions.map((v) => v.attestationSequence)).toEqual([1, 2, 3]); - expect(pkg.events).toHaveLength(6); + expect(pkg.events).toHaveLength(8); expect(pkg.attestation?.chain).toHaveLength(3); }); @@ -283,7 +298,11 @@ describe("buildAuditPackage", () => { ]); // A version's proof carries only its own events plus document-level ones. const v3Events = pkg.versions[2]!.proof!.events.map((e) => e.type); - expect(v3Events).toEqual(["document.created", "version.restored"]); + expect(v3Events).toEqual([ + "document.created", + "version.restored", + "review.started", + ]); }); it("omits the chain when the deployment never attested", () => { @@ -459,3 +478,113 @@ describe("verifyAuditPackage", () => { ); }); }); + +describe("verifyAuditPackage — records that are not signed", () => { + const verify = (pkg: AuditPackage, extra = {}) => + verifyAuditPackage(pkg, { + checkSignature: acceptAll, + publicKeys: KEYS, + ...extra, + }); + + it("catches a lowered threshold, even though no signature covers it", async () => { + const pkg = clone(honestPackage()); + pkg.versions[2]!.version.status = "Approved"; + pkg.versions[2]!.proof!.policy.requiredSigners = 1; + const result = await verify(pkg); + expect(result.valid).toBe(false); + expect(result.versions[2]!.errors.join(" ")).toMatch( + /round started with 2/, + ); + }); + + it("surfaces proof-level errors, not just review errors", async () => { + const pkg = clone(honestPackage()); + (pkg.versions[0]!.proof as { format: string }).format = "evil"; + const result = await verify(pkg); + expect(result.valid).toBe(false); + expect(result.versions[0]!.errors.join(" ")).toMatch( + /proof: Unknown proof format/, + ); + }); + + it("catches a signed round stripped from a superseded version", async () => { + const pkg = clone(honestPackage()); + pkg.versions[0]!.proof = null; + const result = await verify(pkg); + expect(result.valid).toBe(false); + expect(result.versions[0]!.errors.join(" ")).toMatch( + /no proof was included/, + ); + }); + + it("catches a restore relabelled as an upload", async () => { + const pkg = clone(honestPackage()); + pkg.versions[2]!.lineage = { + origin: "upload", + supersedes: 2, + restoredFrom: null, + }; + const result = await verify(pkg); + expect(result.valid).toBe(false); + expect(result.versions[2]!.errors.join(" ")).toMatch( + /does not match the event log/, + ); + }); + + it("refuses an emptied event log", async () => { + const pkg = clone(honestPackage()); + pkg.events = []; + const result = await verify(pkg); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/event log is empty/); + }); + + it("can require the chain, so stripping it cannot hide a swapped hash", async () => { + const pkg = clone(honestPackage()); + pkg.attestation = null; + pkg.versions[1]!.version.contentHash = "0".repeat(64); + expect((await verify(pkg)).valid).toBe(true); + const strict = await verify(pkg, { requireAttestation: true }); + expect(strict.valid).toBe(false); + expect(strict.errors.join(" ")).toMatch( + /expects the exporting deployment to attest/, + ); + }); +}); + +describe("auditPackageProblem", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const honest = () => clone(honestPackage()) as unknown as Record; + + it("accepts an honest package", () => { + expect(auditPackageProblem(honest())).toBeNull(); + }); + + it("names what is wrong with malformed input instead of throwing", () => { + const mutate = (fn: (p: ReturnType) => void) => { + const p = honest(); + fn(p); + return p; + }; + const cases: unknown[] = [ + null, + { document: {}, versions: [{}] }, + mutate((p) => (p.versions[0] = null)), + mutate((p) => delete p.versions[0].lineage), + mutate((p) => delete p.versions[0].proof.policy), + mutate((p) => (p.attestation.chain = "nope")), + mutate((p) => (p.attestation.chain[0] = { signature: "x" })), + ]; + for (const value of cases) { + expect(typeof auditPackageProblem(value)).toBe("string"); + } + }); + + it("bounds the work a public caller can ask for", () => { + const p = clone(honestPackage()); + const review = p.versions[0]!.proof!.reviews[0]!; + p.versions[0]!.proof!.reviews = Array.from({ length: 201 }, () => review); + expect(auditPackageProblem(p)).toMatch(/too long/); + }); +}); diff --git a/src/__tests__/documentDiff.test.ts b/src/__tests__/documentDiff.test.ts index 003633d6..40a685b3 100644 --- a/src/__tests__/documentDiff.test.ts +++ b/src/__tests__/documentDiff.test.ts @@ -53,7 +53,8 @@ function sides(text: { old: string; new: string }) { describe("splitLines", () => { it("does not invent a line for a trailing newline", () => { expect(splitLines("a\nb\n")).toEqual(["a", "b"]); - expect(splitLines("a\r\nb")).toEqual(["a", "b"]); + // \r stays on its line, so a line-ending change is a visible change. + expect(splitLines("a\r\nb")).toEqual(["a\r", "b"]); expect(splitLines("")).toEqual([]); }); }); @@ -78,8 +79,8 @@ describe("diffLines", () => { const { lines } = sides({ old: "a\nb\nc\n", new: "a\nX\nc\n" }); expect(lines).toEqual([ { op: "equal", oldLine: 1, newLine: 1, text: "a" }, - { op: "add", oldLine: null, newLine: 2, text: "X" }, { op: "remove", oldLine: 2, newLine: null, text: "b" }, + { op: "add", oldLine: null, newLine: 2, text: "X" }, { op: "equal", oldLine: 3, newLine: 3, text: "c" }, ]); }); @@ -145,6 +146,14 @@ describe("decodeVersionText", () => { ).toBe(false); }); + it("keeps a byte-order mark as text instead of rejecting the file", () => { + const r = decodeVersionText({ + storageMode: "inline", + contentInline: Buffer.from([0xef, 0xbb, 0xbf, 0x61]).toString("base64"), + }); + expect(r).toEqual({ ok: true, text: "\uFEFFa" }); + }); + it("keeps a genuine replacement character", () => { const r = decodeVersionText({ storageMode: "inline", @@ -184,6 +193,23 @@ describe("diffVersions", () => { ]); }); + it("shows a line-ending change instead of an empty diff", () => { + const d = diffVersions( + inline("v1", 1, "a\r\nb\r\n"), + inline("v2", 2, "a\nb\n"), + ); + expect(d.content).toMatchObject({ kind: "text", added: 2, removed: 2 }); + }); + + it("explains a trailing-newline-only change rather than showing nothing", () => { + const d = diffVersions(inline("v1", 1, "a\nb"), inline("v2", 2, "a\nb\n")); + expect(d.contentChanged).toBe(true); + expect(d.content).toEqual({ + kind: "unavailable", + reason: expect.stringMatching(/trailing newline/), + }); + }); + it("still reports a change between hash-only versions, without a line diff", () => { const hashOnly = ( id: string, diff --git a/src/__tests__/trpc/documentProvenance.test.ts b/src/__tests__/trpc/documentProvenance.test.ts index c8fded20..a81b5577 100644 --- a/src/__tests__/trpc/documentProvenance.test.ts +++ b/src/__tests__/trpc/documentProvenance.test.ts @@ -164,8 +164,8 @@ describeWithDb("document provenance — diff, rollback, audit", () => { .filter((l) => l.op !== "equal") .map((l) => `${l.op}:${l.text}`); expect(changed).toEqual([ - "add:The ceiling is 90000 EUR.", "remove:The ceiling is 50000 EUR.", + "add:The ceiling is 90000 EUR.", ]); // --- rollback ------------------------------------------------------------ @@ -177,10 +177,28 @@ describeWithDb("document provenance — diff, rollback, audit", () => { }), ).rejects.toThrow(/already the current version/); + // A stale view of the head is refused, not silently superseded. + await expect( + caller.document.restoreVersion({ + documentId: document.id, + versionId: v1.id, + expectedHeadVersionId: v1.id, + }), + ).rejects.toThrow(/newer version/); + const v3 = await caller.document.restoreVersion({ documentId: document.id, versionId: v1.id, + expectedHeadVersionId: v2.id, }); + + // Restoring v1 again would only re-publish what is already current. + await expect( + caller.document.restoreVersion({ + documentId: document.id, + versionId: v1.id, + }), + ).rejects.toThrow(/already has v1's content/); expect(v3.versionNumber).toBe(3); expect(v3.contentHash).toBe(v1.contentHash); expect(v3.contentInline).toBe(v1.contentInline); @@ -252,6 +270,11 @@ describeWithDb("document provenance — diff, rollback, audit", () => { const remote = await caller.document.verifyAudit({ audit: pkg }); expect(remote.valid).toBe(true); + // Malformed input is a clear 400, not a crash. + await expect( + caller.document.verifyAudit({ audit: { document: {}, versions: [{}] } }), + ).rejects.toThrow(/not a Document Sign-Off audit package|malformed/); + // And catches a tampered export: drop the version that was rolled back from. const tampered = JSON.parse(JSON.stringify(pkg)) as typeof pkg; tampered.versions.splice(1, 1); diff --git a/src/components/pages/wallet/documents/detail.tsx b/src/components/pages/wallet/documents/detail.tsx index 2c2f752f..9fc55396 100644 --- a/src/components/pages/wallet/documents/detail.tsx +++ b/src/components/pages/wallet/documents/detail.tsx @@ -224,7 +224,8 @@ export default function PageDocumentDetail() { ); // Versions arrive newest first, so the head is index 0. - const headId = document.versions[0]?.id; + const head = document.versions[0]; + const headId = head?.id; const versionOptions = document.versions.map((v) => ({ id: v.id, versionNumber: v.versionNumber, @@ -355,22 +356,24 @@ export default function PageDocumentDetail() { Changes )} - {version.id !== headId && document.status !== "Archived" && ( - - )} + {version.id !== headId && + version.contentHash !== head?.contentHash && + document.status !== "Archived" && ( + + )} {version.status === "Draft" && (
@@ -555,6 +567,7 @@ export default function PageDocumentDetail() { restoreVersion.mutate({ documentId, versionId: restoring.id, + expectedHeadVersionId: headId, }) } > diff --git a/src/components/pages/wallet/documents/version-diff-dialog.tsx b/src/components/pages/wallet/documents/version-diff-dialog.tsx index 32f14707..8e43b66e 100644 --- a/src/components/pages/wallet/documents/version-diff-dialog.tsx +++ b/src/components/pages/wallet/documents/version-diff-dialog.tsx @@ -38,7 +38,7 @@ const LINE_STYLE: Record = { const LINE_MARK: Record = { equal: " ", add: "+", - remove: "−", + remove: "-", }; function short(value: string | number | null): string { @@ -156,7 +156,7 @@ export default function VersionDiffDialog({ {data.content.kind === "text" ? (
- +{data.content.added} / −{data.content.removed} lines + +{data.content.added} / -{data.content.removed} lines {data.content.hunks.map((hunk, index) => (
                     
- @@ −{hunk.oldStart},{hunk.oldLines} +{hunk.newStart}, + @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart}, {hunk.newLines} @@
{hunk.lines.map((line, i) => ( diff --git a/src/lib/documents/audit.ts b/src/lib/documents/audit.ts index 1e03f87a..38b3a974 100644 --- a/src/lib/documents/audit.ts +++ b/src/lib/documents/audit.ts @@ -79,8 +79,11 @@ export interface AuditPackage { }; /** Ascending by version number. */ versions: AuditVersion[]; - /** The whole log, ascending — document-level and every version's. */ - events: ProofPackage["events"]; + /** + * The whole log, ascending — document-level and every version's. Each entry + * names its version, so lineage and review rounds can be re-derived here. + */ + events: (ProofPackage["events"][number] & { versionId: string | null })[]; /** Null when the deployment has no attestation key configured. */ attestation: { domain: string; @@ -100,6 +103,8 @@ export const AUDIT_INSTRUCTIONS = [ "2. If `attestation` is present, verify the chain: each payload is signed (Ed25519) by the key named in `publicKeyId`, links to the SHA-256 of the previous canonical payload, and runs sequence 1..N in non-decreasing time. Prefer public keys you obtained independently over the ones in this file.", "3. Cross-check: every version appears in the chain exactly once, with the same versionId, versionNumber and contentHash.", "4. A version whose lineage says `restoredFrom: k` must carry the same contentHash as version k. A restore re-publishes old bytes as a new version; it never carries the old version's approvals forward.", + "5. Lineage and review rounds must agree with `events`: each version's origin is the event that created it, and a version with a `review.started` event must carry a proof whose threshold matches that event.", + "What is authenticated: the CIP-8 signatures (by the signers) and the attestation chain (by the platform key). The event log, lineage, statuses and each round's threshold are records from the exporting database — consistent with the signed material when this verifies, but not themselves signed.", "The attestation chain is a timestamp and ordering record by the platform. It is not an approval. Approval is the CIP-8 signatures inside each version's proof.", ]; @@ -145,7 +150,11 @@ function metadataNumber(metadata: unknown, key: string): number | null { */ export function versionLineage( versions: readonly { id: string; versionNumber: number }[], - events: readonly AuditSourceEvent[], + events: readonly { + versionId: string | null; + type: string; + metadata?: unknown; + }[], ): Map { const byId = new Map(); const sorted = [...versions].sort( @@ -256,6 +265,7 @@ export function buildAuditPackage(input: { attestationSequence: sequenceFor.get(v.id) ?? null, })), events: events.map((e) => ({ + versionId: e.versionId, type: e.type, actorAddress: e.actorAddress, createdAt: e.createdAt.toISOString(), @@ -320,6 +330,12 @@ export async function verifyAuditPackage( options: { checkSignature: CheckSignatureFn; publicKeys?: Readonly>; + /** + * Treat a missing attestation chain as an error rather than a warning — + * for a verifier that knows the exporting deployment attests, so that + * stripping the chain cannot be used to hide a tampered version. + */ + requireAttestation?: boolean; }, ): Promise { const errors: string[] = []; @@ -340,6 +356,25 @@ export async function verifyAuditPackage( byNumber.set(n, entry); }); + if (pkg.versions.length > 0 && pkg.events.length === 0) { + errors.push("The event log is empty, but the document has versions."); + } + const lineage = versionLineage( + pkg.versions.map((v) => v.version), + pkg.events, + ); + const roundStarted = new Map(); + for (const e of pkg.events) { + if (e.type !== "review.started" || !e.versionId) continue; + const meta = e.metadata as { requiredSigners?: unknown } | null; + roundStarted.set(e.versionId, { + requiredSigners: + typeof meta?.requiredSigners === "number" + ? meta.requiredSigners + : undefined, + }); + } + const verdicts: AuditVersionVerdict[] = []; for (const entry of pkg.versions) { const n = entry.version.versionNumber; @@ -357,8 +392,11 @@ export async function verifyAuditPackage( if (entry.proof) { const proof = entry.proof; - if (proof.document.id !== pkg.document.id) { - verdict.errors.push("proof belongs to a different document"); + if ( + proof.document.id !== pkg.document.id || + proof.document.walletId !== pkg.document.walletId + ) { + verdict.errors.push("proof belongs to a different document or wallet"); } if ( proof.version.id !== entry.version.id || @@ -376,10 +414,22 @@ export async function verifyAuditPackage( verdict.rejections = result.rejections; verdict.requiredSigners = result.requiredSigners; verdict.thresholdReached = result.thresholdReached; + for (const e of result.errors) verdict.errors.push(`proof: ${e}`); for (const r of result.reviews) { for (const e of r.errors) verdict.errors.push(`${r.signerAddress}: ${e}`); } + // The threshold is not in any signed payload, so it is cross-checked + // against the log entry that started the round. + const started = roundStarted.get(entry.version.id); + if ( + started?.requiredSigners !== undefined && + started.requiredSigners !== proof.policy.requiredSigners + ) { + verdict.errors.push( + `proof says ${proof.policy.requiredSigners} required signers, the round started with ${started.requiredSigners}`, + ); + } // A version marked Approved must actually carry enough valid approvals. if (entry.version.status === "Approved" && !result.thresholdReached) { verdict.errors.push( @@ -393,6 +443,20 @@ export async function verifyAuditPackage( verdict.errors.push( `marked ${entry.version.status} but carries no review round`, ); + } else if (roundStarted.has(entry.version.id)) { + verdict.errors.push( + "the log shows a review round for this version, but no proof was included", + ); + } + + const derived = lineage.get(entry.version.id); + if ( + !derived || + derived.origin !== entry.lineage.origin || + derived.restoredFrom !== entry.lineage.restoredFrom || + derived.supersedes !== entry.lineage.supersedes + ) { + verdict.errors.push("stated lineage does not match the event log"); } if (entry.lineage.origin === "restore") { @@ -431,8 +495,13 @@ export async function verifyAuditPackage( for (const record of pkg.attestation.chain) { const p = record.payload; const entry = byNumber.get(p.versionNumber); - if (p.documentId !== pkg.document.id) { - errors.push(`attestation ${p.sequence} names a different document`); + if ( + p.documentId !== pkg.document.id || + p.walletId !== pkg.document.walletId + ) { + errors.push( + `attestation ${p.sequence} names a different document or wallet`, + ); } if (!entry || entry.version.id !== p.versionId) { errors.push( @@ -462,8 +531,9 @@ export async function verifyAuditPackage( // through a document's life leaves its earliest versions unattested. That // is a warning. A version AFTER the first attested one with no link is a // gap in the record, and that is an error. - const firstAttested = Math.min( - ...pkg.attestation.chain.map((r) => r.payload.versionNumber), + const firstAttested = pkg.attestation.chain.reduce( + (min, r) => Math.min(min, r.payload.versionNumber), + Infinity, ); for (const entry of pkg.versions) { if (seen.has(entry.version.id)) continue; @@ -477,9 +547,15 @@ export async function verifyAuditPackage( } } } else if (pkg.versions.length > 0) { - warnings.push( - "No attestation chain: version order and timing rest on the exporting database alone.", - ); + if (options.requireAttestation) { + errors.push( + "No attestation chain, but this verifier expects the exporting deployment to attest.", + ); + } else { + warnings.push( + "No attestation chain: version order and timing rest on the exporting database alone.", + ); + } } if (keysFromPackage && pkg.attestation) { warnings.push( @@ -500,3 +576,157 @@ export async function verifyAuditPackage( warnings, }; } + +// --------------------------------------------------------------------------- +// Shape check for untrusted input +// --------------------------------------------------------------------------- + +/** Caps for a package arriving at a public endpoint. Real documents sit far below. */ +export const AUDIT_LIMITS = { + versions: 500, + reviewsPerVersion: 200, + chain: 500, + events: 20_000, +} as const; + +const isObject = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v); +const isString = (v: unknown): v is string => typeof v === "string"; +const isInt = (v: unknown): v is number => Number.isInteger(v); +const isNullableString = (v: unknown) => v === null || isString(v); + +function proofProblem(proof: unknown, where: string): string | null { + if (!isObject(proof)) return `${where}: proof is not an object`; + const { document, version, policy, reviews } = proof; + if ( + !isObject(document) || + !isString(document.id) || + !isString(document.walletId) + ) { + return `${where}: proof.document is malformed`; + } + if ( + !isObject(version) || + !isString(version.id) || + !isInt(version.versionNumber) || + !isString(version.contentHash) + ) { + return `${where}: proof.version is malformed`; + } + if ( + !isObject(policy) || + !isInt(policy.requiredSigners) || + !isString(policy.walletPolicyHash) || + !Array.isArray(policy.signersAddresses) || + !policy.signersAddresses.every(isString) + ) { + return `${where}: proof.policy is malformed`; + } + if ( + !Array.isArray(reviews) || + reviews.length > AUDIT_LIMITS.reviewsPerVersion + ) { + return `${where}: proof.reviews is missing or too long`; + } + for (const r of reviews) { + if ( + !isObject(r) || + !isString(r.signerAddress) || + !isString(r.payload) || + !isString(r.signature) || + !isString(r.signatureKey) || + !isString(r.signedAt) || + !isString(r.action) + ) { + return `${where}: a review is malformed`; + } + } + return null; +} + +/** + * Why `value` cannot be verified as an audit package, or null if it can. + * + * Structural, and bounded: the verify endpoint is public, so malformed input + * must come back as a clear 400 rather than a TypeError, and a package padded + * with thousands of signatures must not buy unbounded CIP-8 work. + */ +export function auditPackageProblem(value: unknown): string | null { + if (!isObject(value)) return "That is not a Document Sign-Off audit package."; + const { document, versions, events, attestation } = value; + if ( + !isObject(document) || + !isString(document.id) || + !isString(document.walletId) + ) { + return "That is not a Document Sign-Off audit package — it carries no document."; + } + if (!Array.isArray(versions) || versions.length > AUDIT_LIMITS.versions) { + return `versions must be an array of at most ${AUDIT_LIMITS.versions}`; + } + if (!Array.isArray(events) || events.length > AUDIT_LIMITS.events) { + return `events must be an array of at most ${AUDIT_LIMITS.events}`; + } + for (const e of events) { + if ( + !isObject(e) || + !isString(e.type) || + !isNullableString(e.versionId ?? null) + ) { + return "an event is malformed"; + } + } + for (const [i, entry] of versions.entries()) { + const where = `versions[${i}]`; + if (!isObject(entry)) return `${where} is not an object`; + const { version, lineage, proof } = entry; + if ( + !isObject(version) || + !isString(version.id) || + !isInt(version.versionNumber) || + !isString(version.contentHash) || + !isString(version.status) + ) { + return `${where}.version is malformed`; + } + if ( + !isObject(lineage) || + !isString(lineage.origin) || + !(lineage.supersedes === null || isInt(lineage.supersedes)) || + !(lineage.restoredFrom === null || isInt(lineage.restoredFrom)) + ) { + return `${where}.lineage is malformed`; + } + if (proof !== null) { + const problem = proofProblem(proof, where); + if (problem) return problem; + } + } + if (attestation !== null) { + if ( + !isObject(attestation) || + !Array.isArray(attestation.chain) || + !isObject(attestation.publicKeys) + ) { + return "attestation is malformed"; + } + if (attestation.chain.length > AUDIT_LIMITS.chain) { + return `attestation.chain is longer than ${AUDIT_LIMITS.chain}`; + } + for (const r of attestation.chain) { + if ( + !isObject(r) || + !isObject(r.payload) || + !isString(r.signature) || + !isString(r.publicKeyId) || + !isInt(r.payload.versionNumber) || + !isInt(r.payload.sequence) || + !isString(r.payload.versionId) || + !isString(r.payload.contentHash) + ) { + return "an attestation record is malformed"; + } + } + } + return null; +} diff --git a/src/lib/documents/diff.ts b/src/lib/documents/diff.ts index e239c350..90c64f58 100644 --- a/src/lib/documents/diff.ts +++ b/src/lib/documents/diff.ts @@ -91,8 +91,9 @@ export interface VersionDiff { /** * Upper bound on the comparison grid after the common prefix and suffix are - * trimmed. The LCS table is old×new cells; at 4M cells it stays well under - * 100ms and 16MB. Past it the result says the diff is too large rather than + * trimmed. The LCS table is old×new cells; at 4M cells it is 8MB and a few + * hundred milliseconds, synchronous on the request. Past it the result says + * the diff is too large rather than * stalling a request — two hash-bound versions are still distinguishable by * their hashes, which the metadata diff always reports. */ @@ -124,19 +125,29 @@ export function decodeVersionText( if (bytes.includes(0)) { return { ok: false, reason: "binary content" }; } - const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); - // TextDecoder substitutes U+FFFD for invalid sequences. Round-tripping tells - // a genuine replacement character apart from a decoding failure. - if (!Buffer.from(text, "utf8").equals(bytes)) { + // fatal: invalid sequences throw instead of becoming U+FFFD. ignoreBOM: keep + // a byte-order mark as text rather than stripping it, so a BOM-only change + // still shows up as a difference. + try { + const text = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + return { ok: true, text }; + } catch { return { ok: false, reason: "not valid UTF-8 text" }; } - return { ok: true, text }; } -/** Split into lines, keeping a trailing newline from becoming a phantom line. */ +/** + * Split into lines on \n only, keeping a trailing newline from becoming a + * phantom line. A carriage return stays part of its line, so a change of line + * endings is a visible change rather than an empty diff over two different + * hashes. + */ export function splitLines(text: string): string[] { if (text === "") return []; - const lines = text.split(/\r\n|\n|\r/); + const lines = text.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); return lines; } @@ -178,7 +189,12 @@ export function diffLines( // lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened row-major. const width = m + 1; - const lcs = new Uint32Array((n + 1) * width); + // Within the cell cap, min(n, m) <= sqrt(MAX_DIFF_CELLS) = 2000, so LCS + // lengths fit 16 bits: 8MB at the cap instead of 16MB. + const lcs = + Math.min(n, m) < 0xffff + ? new Uint16Array((n + 1) * width) + : new Uint32Array((n + 1) * width); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { lcs[i * width + j] = @@ -204,17 +220,10 @@ export function diffLines( i++; j++; } else if ( - j < m && - (i >= n || lcs[i * width + (j + 1)]! >= lcs[(i + 1) * width + j]!) + i < n && + (j >= m || lcs[(i + 1) * width + j]! >= lcs[i * width + (j + 1)]!) ) { - out.push({ - op: "add", - oldLine: null, - newLine: newLine++, - text: midB[j]!, - }); - j++; - } else { + // Removals first on a tie — the unified-diff convention. out.push({ op: "remove", oldLine: oldLine++, @@ -222,6 +231,14 @@ export function diffLines( text: midA[i]!, }); i++; + } else { + out.push({ + op: "add", + oldLine: null, + newLine: newLine++, + text: midB[j]!, + }); + j++; } } @@ -256,27 +273,27 @@ export function toHunks( } } + // Walk once, carrying the last line number seen on each side, so building + // the hunks stays linear however many there are. + let lastOld = 0; + let lastNew = 0; + let cursor = 0; return ranges.map(([start, end]) => { + for (; cursor < start; cursor++) { + const l = lines[cursor]!; + if (l.oldLine !== null) lastOld = l.oldLine; + if (l.newLine !== null) lastNew = l.newLine; + } const slice = lines.slice(start, end + 1); // A hunk's start is the first line number it shows on that side. For a // side with no lines in the hunk (pure insert/delete) use the line before // it, which is what unified diff does. const firstOld = slice.find((l) => l.oldLine !== null)?.oldLine; const firstNew = slice.find((l) => l.newLine !== null)?.newLine; - const priorOld = - lines - .slice(0, start) - .reverse() - .find((l) => l.oldLine !== null)?.oldLine ?? 0; - const priorNew = - lines - .slice(0, start) - .reverse() - .find((l) => l.newLine !== null)?.newLine ?? 0; return { - oldStart: firstOld ?? priorOld, + oldStart: firstOld ?? lastOld, oldLines: slice.filter((l) => l.op !== "add").length, - newStart: firstNew ?? priorNew, + newStart: firstNew ?? lastNew, newLines: slice.filter((l) => l.op !== "remove").length, lines: slice, }; @@ -363,6 +380,18 @@ export function diffVersions( }; } + if (lines.every((l) => l.op === "equal")) { + return { + ...summary, + content: { + kind: "unavailable", + reason: + "The text is line-for-line the same; the hashes differ only by a " + + "trailing newline at the end of the file.", + }, + }; + } + return { ...summary, content: { diff --git a/src/server/api/routers/documents.ts b/src/server/api/routers/documents.ts index 85425cc1..788dd09e 100644 --- a/src/server/api/routers/documents.ts +++ b/src/server/api/routers/documents.ts @@ -70,6 +70,7 @@ import { } from "@/lib/documents/proof"; import { diffVersions } from "@/lib/documents/diff"; import { + auditPackageProblem, buildAuditPackage, verifyAuditPackage, type AuditPackage, @@ -1478,6 +1479,12 @@ export const documentRouter = createTRPCRouter({ documentId: z.string().min(1), versionId: z.string().min(1), reviewInstructions: z.string().max(5000).optional(), + /** + * The head the caller was looking at. If someone else published or + * restored in the meantime, the restore is refused rather than + * silently superseding a version the caller never saw. + */ + expectedHeadVersionId: z.string().min(1).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -1503,44 +1510,75 @@ export const documentRouter = createTRPCRouter({ }); } - const restored = await ctx.db.$transaction(async (tx) => { - // Read the head inside the transaction so two concurrent restores - // cannot both decide they are restoring something other than the head. - const head = await tx.documentVersion.findFirst({ - where: { documentId: document.id }, - orderBy: { versionNumber: "desc" }, - }); - if (head?.id === source.id) { - throw new TRPCError({ - code: "CONFLICT", - message: `v${source.versionNumber} is already the current version`, + const restored = await ctx.db + .$transaction(async (tx) => { + const head = await tx.documentVersion.findFirst({ + where: { documentId: document.id }, + orderBy: { versionNumber: "desc" }, }); - } - - return appendVersion(tx, { - document, - actor, - eventType: "version.restored", - eventMetadata: { - restoredFromVersionId: source.id, - restoredFromVersionNumber: source.versionNumber, - ...(head ? { replacedVersionNumber: head.versionNumber } : {}), - }, - row: { - contentHash: source.contentHash, - fileName: source.fileName ?? undefined, - mimeType: source.mimeType ?? undefined, - fileSize: source.fileSize ?? undefined, - storageMode: source.storageMode, - contentRef: source.contentRef ?? undefined, - contentInline: source.contentInline ?? undefined, - reviewInstructions: - input.reviewInstructions ?? - source.reviewInstructions ?? - undefined, - }, + if ( + input.expectedHeadVersionId && + head?.id !== input.expectedHeadVersionId + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This document has a newer version than the one you were looking " + + "at. Reload before restoring.", + }); + } + // Compared by content, not id: re-publishing the bytes that are + // already current would only discard the head's review round. + if (head && head.contentHash === source.contentHash) { + throw new TRPCError({ + code: "CONFLICT", + message: + head.id === source.id + ? `v${source.versionNumber} is already the current version` + : `The current version (v${head.versionNumber}) already has ` + + `v${source.versionNumber}'s content`, + }); + } + + return appendVersion(tx, { + document, + actor, + eventType: "version.restored", + eventMetadata: { + restoredFromVersionId: source.id, + restoredFromVersionNumber: source.versionNumber, + ...(head ? { replacedVersionNumber: head.versionNumber } : {}), + }, + row: { + contentHash: source.contentHash, + fileName: source.fileName ?? undefined, + mimeType: source.mimeType ?? undefined, + fileSize: source.fileSize ?? undefined, + storageMode: source.storageMode, + contentRef: source.contentRef ?? undefined, + contentInline: source.contentInline ?? undefined, + reviewInstructions: + input.reviewInstructions ?? + source.reviewInstructions ?? + undefined, + }, + }); + }) + .catch((error: unknown) => { + // Two writers racing for the same version number (or attestation + // sequence) — the unique constraint picked a winner. Say so. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Someone else changed this document at the same moment. Reload and try again.", + }); + } + throw error; }); - }); void audit(ctx.db, { actorAddress: actor, @@ -1575,27 +1613,34 @@ export const documentRouter = createTRPCRouter({ ); const actor = actingAddress(addresses); - const [versions, events, attestations] = await Promise.all([ - ctx.db.documentVersion.findMany({ - where: { documentId: document.id }, - orderBy: { versionNumber: "asc" }, - include: { - signerSnapshot: true, - reviews: { - orderBy: { signedAt: "asc" }, - include: { party: { select: { role: true } } }, - }, - }, - }), - ctx.db.documentEvent.findMany({ - where: { documentId: document.id }, - orderBy: { createdAt: "asc" }, - }), - ctx.db.documentAttestation.findMany({ - where: { documentId: document.id }, - orderBy: { sequence: "asc" }, - }), - ]); + // One snapshot: a version, event or attestation written between separate + // reads would make an honest export fail its own verification. + const [versions, events, attestations] = await ctx.db.$transaction( + (tx) => + Promise.all([ + tx.documentVersion.findMany({ + where: { documentId: document.id }, + orderBy: { versionNumber: "asc" }, + omit: { contentInline: true }, + include: { + signerSnapshot: true, + reviews: { + orderBy: { signedAt: "asc" }, + include: { party: { select: { role: true } } }, + }, + }, + }), + tx.documentEvent.findMany({ + where: { documentId: document.id }, + orderBy: { createdAt: "asc" }, + }), + tx.documentAttestation.findMany({ + where: { documentId: document.id }, + orderBy: { sequence: "asc" }, + }), + ]), + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); const pkg = buildAuditPackage({ document, @@ -1636,22 +1681,27 @@ export const documentRouter = createTRPCRouter({ }), ) .mutation(async ({ input }) => { - const pkg = input.audit as Partial | null; - if ( - !pkg || - typeof pkg !== "object" || - !Array.isArray(pkg.versions) || - !pkg.document - ) { + const problem = auditPackageProblem(input.audit); + if (problem !== null) { + throw new TRPCError({ code: "BAD_REQUEST", message: problem }); + } + // No keys configured here means nothing of ours to check against; fall + // back to the package's own keys, which the result then flags. + const ours = getAttestationPublicKeys(); + const publicKeys = + input.publicKeys ?? (Object.keys(ours).length > 0 ? ours : undefined); + try { + return await verifyAuditPackage(input.audit as AuditPackage, { + checkSignature, + publicKeys, + }); + } catch { throw new TRPCError({ code: "BAD_REQUEST", - message: "That is not a Document Sign-Off audit package.", + message: + "That audit package could not be verified — it is malformed.", }); } - return verifyAuditPackage(pkg as AuditPackage, { - checkSignature, - publicKeys: input.publicKeys ?? getAttestationPublicKeys(), - }); }), /** Archive a document — history is retained, it just leaves active use. */