diff --git a/fixtures/signed/ga4-event.md b/fixtures/signed/ga4-event.md new file mode 100644 index 0000000..d41c8a2 --- /dev/null +++ b/fixtures/signed/ga4-event.md @@ -0,0 +1,25 @@ +--- +title: GA4 Purchase Event +resource: https://example.com/okf/ga4/purchase +description: Server-side GA4 purchase event mapping for the storefront. +tags: + - analytics + - ga4 +provenance: + spec_version: verifiable-okf/0.3 + issuer: did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7 + content_hash: sha256:94ea40b78f963160edd233a2ad2aa10c5da2d945eb40e4059dd80399d4ccbd00 + valid_from: 2026-01-01T00:00:00Z + valid_until: 2027-01-01T00:00:00Z + signature: + scheme: ed25519 + value: 6efDAKAw+bF+3MJLZJGsy3rcSjDMVJXy4w6ElKOuYyUpq6PjP6OnRl6vVBdNikjO9CjqPBMPsVRDrMr+jyISBQ== +--- +# GA4 Purchase Event + +Send a `purchase` event when an order is confirmed. + +| param | source | +|---|---| +| value | order.total | +| currency | order.currency | diff --git a/fixtures/unsigned/ga4-event.md b/fixtures/unsigned/ga4-event.md new file mode 100644 index 0000000..b933a83 --- /dev/null +++ b/fixtures/unsigned/ga4-event.md @@ -0,0 +1,16 @@ +--- +title: GA4 Purchase Event +resource: https://example.com/okf/ga4/purchase +description: Server-side GA4 purchase event mapping for the storefront. +tags: + - analytics + - ga4 +--- +# GA4 Purchase Event + +Send a `purchase` event when an order is confirmed. + +| param | source | +|---|---| +| value | order.total | +| currency | order.currency | diff --git a/packages/signer/package.json b/packages/signer/package.json new file mode 100644 index 0000000..188203f --- /dev/null +++ b/packages/signer/package.json @@ -0,0 +1,28 @@ +{ + "name": "@verifiable-okf/signer", + "version": "0.1.0", + "description": "Verifiable OKF signer: attaches a `provenance` block (content_hash + Ed25519 signature) without mutating any other byte. CLI: vokf-sign.", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "bin": { "vokf-sign": "dist/cli.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@verifiable-okf/canon": "workspace:*", + "@noble/curves": "^1.6.0", + "@scure/base": "^1.1.9", + "yaml": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/signer/src/cli.ts b/packages/signer/src/cli.ts new file mode 100644 index 0000000..37268ca --- /dev/null +++ b/packages/signer/src/cli.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * `vokf-sign` — minimal CLI around signConcept(). + * + * Usage: + * vokf-sign --key [--issuer ] + * [--valid-from ] [--valid-until ] [-o ] + * + * The private key is read from a hex string or a file and is NEVER printed or + * written to the output. With no `-o`, the signed concept is written to stdout. + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { signConcept } from "./sign.js"; + +function arg(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function readSeed(keyArg: string): Uint8Array { + const raw = existsSync(keyArg) ? readFileSync(keyArg, "utf8").trim() : keyArg.trim(); + const hex = raw.replace(/^0x/, ""); + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error("--key must be a 32-byte Ed25519 seed in hex (or a file containing it)"); + } + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +function main(): void { + const conceptPath = process.argv[2]; + const keyArg = arg("--key"); + if (!conceptPath || conceptPath.startsWith("-") || !keyArg) { + console.error("usage: vokf-sign --key [--issuer ] [--valid-from ] [--valid-until ] [-o ]"); + process.exit(2); + } + const input = readFileSync(conceptPath); + const signed = signConcept(input, { + privateKey: readSeed(keyArg), + issuer: arg("--issuer"), + validFrom: arg("--valid-from"), + validUntil: arg("--valid-until"), + }); + const out = arg("-o") ?? arg("--out"); + if (out) { + writeFileSync(out, signed); + console.error(`signed → ${out}`); + } else { + process.stdout.write(signed); + } +} + +main(); diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts new file mode 100644 index 0000000..bd52bf3 --- /dev/null +++ b/packages/signer/src/index.ts @@ -0,0 +1,12 @@ +/** + * @verifiable-okf/signer — attach a `provenance` block (content_hash + + * Ed25519 signature) to an OKF concept without mutating any other byte. + */ +export { signConcept, type SignOptions } from "./sign.js"; +export { + publicKeyFromSeed, + signEd25519, + verifyEd25519, + didKeyFromPublicKey, + didKeyFromSeed, +} from "./keys.js"; diff --git a/packages/signer/src/keys.ts b/packages/signer/src/keys.ts new file mode 100644 index 0000000..bc6797f --- /dev/null +++ b/packages/signer/src/keys.ts @@ -0,0 +1,44 @@ +/** + * Ed25519 key helpers and `did:key` derivation for the Verifiable OKF signer. + * + * Signing uses a 32-byte Ed25519 seed (private key). The signer never logs or + * writes the private key; only the derived public key / DID and the signature + * leave this module. + */ +import { ed25519 } from "@noble/curves/ed25519"; +import { base58 } from "@scure/base"; + +/** multicodec prefix for an Ed25519 public key (0xed 0x01, varint). */ +const ED25519_PUB_MULTICODEC = Uint8Array.from([0xed, 0x01]); + +/** Public key (32 bytes) for an Ed25519 seed. */ +export function publicKeyFromSeed(seed: Uint8Array): Uint8Array { + return ed25519.getPublicKey(seed); +} + +/** Detached Ed25519 signature (64 bytes) over `message`. Deterministic (RFC 8032). */ +export function signEd25519(message: Uint8Array, seed: Uint8Array): Uint8Array { + return ed25519.sign(message, seed); +} + +/** Verify a detached Ed25519 signature. */ +export function verifyEd25519( + signature: Uint8Array, + message: Uint8Array, + publicKey: Uint8Array, +): boolean { + return ed25519.verify(signature, message, publicKey); +} + +/** `did:key` (multibase base58btc, multicodec ed25519-pub) for a public key. */ +export function didKeyFromPublicKey(publicKey: Uint8Array): string { + const bytes = new Uint8Array(ED25519_PUB_MULTICODEC.length + publicKey.length); + bytes.set(ED25519_PUB_MULTICODEC, 0); + bytes.set(publicKey, ED25519_PUB_MULTICODEC.length); + return "did:key:z" + base58.encode(bytes); // multibase 'z' = base58btc +} + +/** `did:key` for an Ed25519 seed. */ +export function didKeyFromSeed(seed: Uint8Array): string { + return didKeyFromPublicKey(publicKeyFromSeed(seed)); +} diff --git a/packages/signer/src/sign.ts b/packages/signer/src/sign.ts new file mode 100644 index 0000000..f29411d --- /dev/null +++ b/packages/signer/src/sign.ts @@ -0,0 +1,73 @@ +/** + * Verifiable OKF signer (SPEC §4/§5/§6). + * + * Attaches a `provenance` block to a concept WITHOUT mutating any existing + * frontmatter byte or any body byte: the original frontmatter text and body are + * preserved verbatim and the `provenance` block is appended at the end of the + * frontmatter, before the closing `---`. Deterministic given the same inputs + * (idempotent): Ed25519 (RFC 8032) is deterministic and timestamps are inputs. + */ +import YAML from "yaml"; +import { + splitConcept, + parseFrontmatter, + canonicalizeBody, + contentHash, + buildSigningPayload, +} from "@verifiable-okf/canon"; +import { signEd25519, didKeyFromSeed } from "./keys.js"; + +export interface SignOptions { + /** 32-byte Ed25519 seed (private key). Never logged or written to output. */ + readonly privateKey: Uint8Array; + /** Issuer DID. Defaults to the `did:key` derived from `privateKey`. */ + readonly issuer?: string; + readonly specVersion?: string; + readonly validFrom?: string; + readonly validUntil?: string; + readonly attestations?: ReadonlyArray>; + /** Include `content_hash` (default true). */ + readonly includeContentHash?: boolean; +} + +const utf8 = new TextEncoder(); + +function ensureTrailingNewline(s: string): string { + return s.endsWith("\n") ? s : s + "\n"; +} + +/** Sign a concept, returning the signed concept bytes. Input is never mutated. */ +export function signConcept(input: Uint8Array | string, opts: SignOptions): Uint8Array { + const { hasFrontmatter, frontmatterText, body } = splitConcept(input); + const canonicalBody = canonicalizeBody(body); + const issuer = opts.issuer ?? didKeyFromSeed(opts.privateKey); + + // Build provenance with a stable key order. signature.value is filled after signing. + const provenance: Record = { + spec_version: opts.specVersion ?? "verifiable-okf/0.3", + issuer, + }; + if (opts.includeContentHash !== false) provenance["content_hash"] = contentHash(canonicalBody); + if (opts.validFrom) provenance["valid_from"] = opts.validFrom; + if (opts.validUntil) provenance["valid_until"] = opts.validUntil; + if (opts.attestations && opts.attestations.length > 0) { + provenance["attestations"] = opts.attestations; + } + provenance["signature"] = { scheme: "ed25519" }; + + // Signing payload binds the issuer, body, and all other frontmatter (§6). + const baseFrontmatter = hasFrontmatter ? parseFrontmatter(frontmatterText) : {}; + const frontmatterStar = { ...baseFrontmatter, provenance }; + const payload = buildSigningPayload(frontmatterStar, canonicalBody); + const signature = signEd25519(payload, opts.privateKey); + (provenance["signature"] as Record)["value"] = + Buffer.from(signature).toString("base64"); + + // Emit: original frontmatter (verbatim) + appended provenance block + verbatim body. + const provenanceYaml = YAML.stringify({ provenance }, { lineWidth: 0 }); + const frontmatterRegion = hasFrontmatter + ? ensureTrailingNewline(frontmatterText) + provenanceYaml + : provenanceYaml; + const out = `---\n${frontmatterRegion}---\n${body}`; + return utf8.encode(out); +} diff --git a/packages/signer/test/sign.test.ts b/packages/signer/test/sign.test.ts new file mode 100644 index 0000000..54e821f --- /dev/null +++ b/packages/signer/test/sign.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + splitConcept, + parseFrontmatter, + canonicalizeBody, + buildSigningPayload, + contentHash, +} from "@verifiable-okf/canon"; +import { signConcept, publicKeyFromSeed, verifyEd25519 } from "../src/index.js"; + +const root = (p: string) => fileURLToPath(new URL(`../../../${p}`, import.meta.url)); +const dec = new TextDecoder(); + +// Deterministic test seed (0x01..0x20). Test-only; never a real key. +const SEED = Uint8Array.from( + Array.from({ length: 32 }, (_, i) => i + 1), +); +const OPTS = { + privateKey: SEED, + validFrom: "2026-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", +}; + +const unsigned = readFileSync(root("fixtures/unsigned/ga4-event.md")); +const signedGolden = readFileSync(root("fixtures/signed/ga4-event.md")); + +describe("signConcept (VOKF-2, SPEC §4/§5/§6)", () => { + it("reproduces fixtures/signed from fixtures/unsigned (golden)", () => { + const out = signConcept(unsigned, OPTS); + expect(dec.decode(out)).toBe(dec.decode(signedGolden)); + }); + + it("is idempotent: re-signing the same input/key/timestamps is byte-identical", () => { + const a = signConcept(unsigned, OPTS); + const b = signConcept(unsigned, OPTS); + expect(dec.decode(a)).toBe(dec.decode(b)); + }); + + it("adds only the provenance block — original frontmatter and body are byte-identical", () => { + const before = splitConcept(unsigned); + const after = splitConcept(signConcept(unsigned, OPTS)); + // body unchanged + expect(after.body).toBe(before.body); + // every original frontmatter line is preserved verbatim, in order, before provenance + const beforeLines = before.frontmatterText.split("\n"); + const afterLines = after.frontmatterText.split("\n"); + expect(afterLines.slice(0, beforeLines.length)).toEqual(beforeLines); + expect(after.frontmatterText).toContain("provenance:"); + }); + + it("writes a content_hash that matches the canonical body", () => { + const after = splitConcept(signConcept(unsigned, OPTS)); + const fm = parseFrontmatter(after.frontmatterText); + const prov = fm["provenance"] as Record; + expect(prov["content_hash"]).toBe(contentHash(canonicalizeBody(after.body))); + }); + + it("produces a signature that verifies over the reconstructed payload", () => { + const after = splitConcept(signConcept(unsigned, OPTS)); + const fm = parseFrontmatter(after.frontmatterText); + const prov = fm["provenance"] as Record; + const sig = (prov["signature"] as Record)["value"] as string; + const payload = buildSigningPayload(fm, canonicalizeBody(after.body)); + const ok = verifyEd25519( + Uint8Array.from(Buffer.from(sig, "base64")), + payload, + publicKeyFromSeed(SEED), + ); + expect(ok).toBe(true); + }); + + it("never leaks the private key into the output", () => { + const out = dec.decode(signConcept(unsigned, OPTS)); + const seedHex = Buffer.from(SEED).toString("hex"); + expect(out).not.toContain(seedHex); + }); + + it("derives a did:key issuer when none is provided", () => { + const after = splitConcept(signConcept(unsigned, OPTS)); + const fm = parseFrontmatter(after.frontmatterText); + const prov = fm["provenance"] as Record; + expect(String(prov["issuer"])).toMatch(/^did:key:z/); + }); +}); diff --git a/packages/signer/tsconfig.json b/packages/signer/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/signer/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fad0f3..88bf051 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,31 @@ importers: specifier: ^20.14.0 version: 20.19.43 + packages/signer: + dependencies: + '@noble/curves': + specifier: ^1.6.0 + version: 1.9.7 + '@scure/base': + specifier: ^1.1.9 + version: 1.2.6 + '@verifiable-okf/canon': + specifier: workspace:* + version: link:../canon + yaml: + specifier: ^2.6.0 + version: 2.9.0 + devDependencies: + '@types/node': + specifier: ^20.14.0 + version: 20.19.43 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@20.19.43) + packages: '@esbuild/aix-ppc64@0.21.5': @@ -174,6 +199,14 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@rollup/rollup-android-arm-eabi@4.62.0': resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} cpu: [arm] @@ -299,6 +332,9 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@turbo/darwin-64@2.9.18': resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} cpu: [x64] @@ -637,6 +673,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@rollup/rollup-android-arm-eabi@4.62.0': optional: true @@ -712,6 +754,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@scure/base@1.2.6': {} + '@turbo/darwin-64@2.9.18': optional: true diff --git a/turbo.json b/turbo.json index c46ebd7..5603af2 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { - "build": { "outputs": ["dist/**"] }, + "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["^build"] }, "lint": {} }