|
| 1 | +import { describe, it, expect } from "vitest"; |
| 2 | +import { readFileSync } from "node:fs"; |
| 3 | +import { fileURLToPath } from "node:url"; |
| 4 | +import { |
| 5 | + splitConcept, |
| 6 | + parseFrontmatter, |
| 7 | + canonicalizeBody, |
| 8 | + buildSigningPayload, |
| 9 | + contentHash, |
| 10 | +} from "@verifiable-okf/canon"; |
| 11 | +import { signConcept, publicKeyFromSeed, verifyEd25519 } from "../src/index.js"; |
| 12 | + |
| 13 | +const root = (p: string) => fileURLToPath(new URL(`../../../${p}`, import.meta.url)); |
| 14 | +const dec = new TextDecoder(); |
| 15 | + |
| 16 | +// Deterministic test seed (0x01..0x20). Test-only; never a real key. |
| 17 | +const SEED = Uint8Array.from( |
| 18 | + Array.from({ length: 32 }, (_, i) => i + 1), |
| 19 | +); |
| 20 | +const OPTS = { |
| 21 | + privateKey: SEED, |
| 22 | + validFrom: "2026-01-01T00:00:00Z", |
| 23 | + validUntil: "2027-01-01T00:00:00Z", |
| 24 | +}; |
| 25 | + |
| 26 | +const unsigned = readFileSync(root("fixtures/unsigned/ga4-event.md")); |
| 27 | +const signedGolden = readFileSync(root("fixtures/signed/ga4-event.md")); |
| 28 | + |
| 29 | +describe("signConcept (VOKF-2, SPEC §4/§5/§6)", () => { |
| 30 | + it("reproduces fixtures/signed from fixtures/unsigned (golden)", () => { |
| 31 | + const out = signConcept(unsigned, OPTS); |
| 32 | + expect(dec.decode(out)).toBe(dec.decode(signedGolden)); |
| 33 | + }); |
| 34 | + |
| 35 | + it("is idempotent: re-signing the same input/key/timestamps is byte-identical", () => { |
| 36 | + const a = signConcept(unsigned, OPTS); |
| 37 | + const b = signConcept(unsigned, OPTS); |
| 38 | + expect(dec.decode(a)).toBe(dec.decode(b)); |
| 39 | + }); |
| 40 | + |
| 41 | + it("adds only the provenance block — original frontmatter and body are byte-identical", () => { |
| 42 | + const before = splitConcept(unsigned); |
| 43 | + const after = splitConcept(signConcept(unsigned, OPTS)); |
| 44 | + // body unchanged |
| 45 | + expect(after.body).toBe(before.body); |
| 46 | + // every original frontmatter line is preserved verbatim, in order, before provenance |
| 47 | + const beforeLines = before.frontmatterText.split("\n"); |
| 48 | + const afterLines = after.frontmatterText.split("\n"); |
| 49 | + expect(afterLines.slice(0, beforeLines.length)).toEqual(beforeLines); |
| 50 | + expect(after.frontmatterText).toContain("provenance:"); |
| 51 | + }); |
| 52 | + |
| 53 | + it("writes a content_hash that matches the canonical body", () => { |
| 54 | + const after = splitConcept(signConcept(unsigned, OPTS)); |
| 55 | + const fm = parseFrontmatter(after.frontmatterText); |
| 56 | + const prov = fm["provenance"] as Record<string, unknown>; |
| 57 | + expect(prov["content_hash"]).toBe(contentHash(canonicalizeBody(after.body))); |
| 58 | + }); |
| 59 | + |
| 60 | + it("produces a signature that verifies over the reconstructed payload", () => { |
| 61 | + const after = splitConcept(signConcept(unsigned, OPTS)); |
| 62 | + const fm = parseFrontmatter(after.frontmatterText); |
| 63 | + const prov = fm["provenance"] as Record<string, unknown>; |
| 64 | + const sig = (prov["signature"] as Record<string, unknown>)["value"] as string; |
| 65 | + const payload = buildSigningPayload(fm, canonicalizeBody(after.body)); |
| 66 | + const ok = verifyEd25519( |
| 67 | + Uint8Array.from(Buffer.from(sig, "base64")), |
| 68 | + payload, |
| 69 | + publicKeyFromSeed(SEED), |
| 70 | + ); |
| 71 | + expect(ok).toBe(true); |
| 72 | + }); |
| 73 | + |
| 74 | + it("never leaks the private key into the output", () => { |
| 75 | + const out = dec.decode(signConcept(unsigned, OPTS)); |
| 76 | + const seedHex = Buffer.from(SEED).toString("hex"); |
| 77 | + expect(out).not.toContain(seedHex); |
| 78 | + }); |
| 79 | + |
| 80 | + it("derives a did:key issuer when none is provided", () => { |
| 81 | + const after = splitConcept(signConcept(unsigned, OPTS)); |
| 82 | + const fm = parseFrontmatter(after.frontmatterText); |
| 83 | + const prov = fm["provenance"] as Record<string, unknown>; |
| 84 | + expect(String(prov["issuer"])).toMatch(/^did:key:z/); |
| 85 | + }); |
| 86 | +}); |
0 commit comments