Skip to content

Commit a1c5b45

Browse files
Mayumi Haraclaude
authored andcommitted
feat(signer): vokf-sign — attach provenance without mutating other bytes (VOKF-2)
`@verifiable-okf/signer` attaches a `provenance` block (content_hash + Ed25519 signature over the §6 payload) by appending it at the end of the frontmatter, before the closing `---`. The original frontmatter text and the body are preserved verbatim — only the provenance block is added. Deterministic (idempotent): RFC 8032 Ed25519 + timestamps are inputs. - keys.ts: Ed25519 (via @noble/curves), did:key derivation (multicodec ed25519-pub + base58btc multibase). - sign.ts: signConcept(); signs over JCS(frontmatter* incl. provenance without signature.value) || 0x0A || canonical_body; issuer defaults to the did:key of the signing seed. - cli.ts: `vokf-sign` (key from hex/file, never logged or written out). - fixtures/{unsigned,signed}/ga4-event.md golden (fixed test seed + timestamps). Tests (7): reproduces fixtures/signed from fixtures/unsigned; idempotent; adds only provenance (body + original frontmatter byte-identical); content_hash matches canon; signature verifies over the reconstructed payload; private key never leaks into output; did:key issuer derived. build + full suite green (35 tests total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9e34c4 commit a1c5b45

10 files changed

Lines changed: 385 additions & 0 deletions

File tree

fixtures/signed/ga4-event.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
title: GA4 Purchase Event
3+
resource: https://example.com/okf/ga4/purchase
4+
description: Server-side GA4 purchase event mapping for the storefront.
5+
tags:
6+
- analytics
7+
- ga4
8+
provenance:
9+
spec_version: verifiable-okf/0.3
10+
issuer: did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7
11+
content_hash: sha256:94ea40b78f963160edd233a2ad2aa10c5da2d945eb40e4059dd80399d4ccbd00
12+
valid_from: 2026-01-01T00:00:00Z
13+
valid_until: 2027-01-01T00:00:00Z
14+
signature:
15+
scheme: ed25519
16+
value: 6efDAKAw+bF+3MJLZJGsy3rcSjDMVJXy4w6ElKOuYyUpq6PjP6OnRl6vVBdNikjO9CjqPBMPsVRDrMr+jyISBQ==
17+
---
18+
# GA4 Purchase Event
19+
20+
Send a `purchase` event when an order is confirmed.
21+
22+
| param | source |
23+
|---|---|
24+
| value | order.total |
25+
| currency | order.currency |

fixtures/unsigned/ga4-event.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
title: GA4 Purchase Event
3+
resource: https://example.com/okf/ga4/purchase
4+
description: Server-side GA4 purchase event mapping for the storefront.
5+
tags:
6+
- analytics
7+
- ga4
8+
---
9+
# GA4 Purchase Event
10+
11+
Send a `purchase` event when an order is confirmed.
12+
13+
| param | source |
14+
|---|---|
15+
| value | order.total |
16+
| currency | order.currency |

packages/signer/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@verifiable-okf/signer",
3+
"version": "0.1.0",
4+
"description": "Verifiable OKF signer: attaches a `provenance` block (content_hash + Ed25519 signature) without mutating any other byte. CLI: vokf-sign.",
5+
"license": "Apache-2.0",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
10+
"bin": { "vokf-sign": "dist/cli.js" },
11+
"files": ["dist"],
12+
"scripts": {
13+
"build": "tsc -p tsconfig.json",
14+
"test": "vitest run",
15+
"lint": "tsc -p tsconfig.json --noEmit"
16+
},
17+
"dependencies": {
18+
"@verifiable-okf/canon": "workspace:*",
19+
"@noble/curves": "^1.6.0",
20+
"@scure/base": "^1.1.9",
21+
"yaml": "^2.6.0"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^20.14.0",
25+
"typescript": "^5.6.0",
26+
"vitest": "^2.1.0"
27+
}
28+
}

packages/signer/src/cli.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env node
2+
/**
3+
* `vokf-sign` — minimal CLI around signConcept().
4+
*
5+
* Usage:
6+
* vokf-sign <concept.md> --key <seed.hex|file> [--issuer <did>]
7+
* [--valid-from <rfc3339>] [--valid-until <rfc3339>] [-o <out.md>]
8+
*
9+
* The private key is read from a hex string or a file and is NEVER printed or
10+
* written to the output. With no `-o`, the signed concept is written to stdout.
11+
*/
12+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
13+
import { signConcept } from "./sign.js";
14+
15+
function arg(flag: string): string | undefined {
16+
const i = process.argv.indexOf(flag);
17+
return i >= 0 ? process.argv[i + 1] : undefined;
18+
}
19+
20+
function readSeed(keyArg: string): Uint8Array {
21+
const raw = existsSync(keyArg) ? readFileSync(keyArg, "utf8").trim() : keyArg.trim();
22+
const hex = raw.replace(/^0x/, "");
23+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
24+
throw new Error("--key must be a 32-byte Ed25519 seed in hex (or a file containing it)");
25+
}
26+
return Uint8Array.from(Buffer.from(hex, "hex"));
27+
}
28+
29+
function main(): void {
30+
const conceptPath = process.argv[2];
31+
const keyArg = arg("--key");
32+
if (!conceptPath || conceptPath.startsWith("-") || !keyArg) {
33+
console.error("usage: vokf-sign <concept.md> --key <seed.hex|file> [--issuer <did>] [--valid-from <ts>] [--valid-until <ts>] [-o <out.md>]");
34+
process.exit(2);
35+
}
36+
const input = readFileSync(conceptPath);
37+
const signed = signConcept(input, {
38+
privateKey: readSeed(keyArg),
39+
issuer: arg("--issuer"),
40+
validFrom: arg("--valid-from"),
41+
validUntil: arg("--valid-until"),
42+
});
43+
const out = arg("-o") ?? arg("--out");
44+
if (out) {
45+
writeFileSync(out, signed);
46+
console.error(`signed → ${out}`);
47+
} else {
48+
process.stdout.write(signed);
49+
}
50+
}
51+
52+
main();

packages/signer/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @verifiable-okf/signer — attach a `provenance` block (content_hash +
3+
* Ed25519 signature) to an OKF concept without mutating any other byte.
4+
*/
5+
export { signConcept, type SignOptions } from "./sign.js";
6+
export {
7+
publicKeyFromSeed,
8+
signEd25519,
9+
verifyEd25519,
10+
didKeyFromPublicKey,
11+
didKeyFromSeed,
12+
} from "./keys.js";

packages/signer/src/keys.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Ed25519 key helpers and `did:key` derivation for the Verifiable OKF signer.
3+
*
4+
* Signing uses a 32-byte Ed25519 seed (private key). The signer never logs or
5+
* writes the private key; only the derived public key / DID and the signature
6+
* leave this module.
7+
*/
8+
import { ed25519 } from "@noble/curves/ed25519";
9+
import { base58 } from "@scure/base";
10+
11+
/** multicodec prefix for an Ed25519 public key (0xed 0x01, varint). */
12+
const ED25519_PUB_MULTICODEC = Uint8Array.from([0xed, 0x01]);
13+
14+
/** Public key (32 bytes) for an Ed25519 seed. */
15+
export function publicKeyFromSeed(seed: Uint8Array): Uint8Array {
16+
return ed25519.getPublicKey(seed);
17+
}
18+
19+
/** Detached Ed25519 signature (64 bytes) over `message`. Deterministic (RFC 8032). */
20+
export function signEd25519(message: Uint8Array, seed: Uint8Array): Uint8Array {
21+
return ed25519.sign(message, seed);
22+
}
23+
24+
/** Verify a detached Ed25519 signature. */
25+
export function verifyEd25519(
26+
signature: Uint8Array,
27+
message: Uint8Array,
28+
publicKey: Uint8Array,
29+
): boolean {
30+
return ed25519.verify(signature, message, publicKey);
31+
}
32+
33+
/** `did:key` (multibase base58btc, multicodec ed25519-pub) for a public key. */
34+
export function didKeyFromPublicKey(publicKey: Uint8Array): string {
35+
const bytes = new Uint8Array(ED25519_PUB_MULTICODEC.length + publicKey.length);
36+
bytes.set(ED25519_PUB_MULTICODEC, 0);
37+
bytes.set(publicKey, ED25519_PUB_MULTICODEC.length);
38+
return "did:key:z" + base58.encode(bytes); // multibase 'z' = base58btc
39+
}
40+
41+
/** `did:key` for an Ed25519 seed. */
42+
export function didKeyFromSeed(seed: Uint8Array): string {
43+
return didKeyFromPublicKey(publicKeyFromSeed(seed));
44+
}

packages/signer/src/sign.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Verifiable OKF signer (SPEC §4/§5/§6).
3+
*
4+
* Attaches a `provenance` block to a concept WITHOUT mutating any existing
5+
* frontmatter byte or any body byte: the original frontmatter text and body are
6+
* preserved verbatim and the `provenance` block is appended at the end of the
7+
* frontmatter, before the closing `---`. Deterministic given the same inputs
8+
* (idempotent): Ed25519 (RFC 8032) is deterministic and timestamps are inputs.
9+
*/
10+
import YAML from "yaml";
11+
import {
12+
splitConcept,
13+
parseFrontmatter,
14+
canonicalizeBody,
15+
contentHash,
16+
buildSigningPayload,
17+
} from "@verifiable-okf/canon";
18+
import { signEd25519, didKeyFromSeed } from "./keys.js";
19+
20+
export interface SignOptions {
21+
/** 32-byte Ed25519 seed (private key). Never logged or written to output. */
22+
readonly privateKey: Uint8Array;
23+
/** Issuer DID. Defaults to the `did:key` derived from `privateKey`. */
24+
readonly issuer?: string;
25+
readonly specVersion?: string;
26+
readonly validFrom?: string;
27+
readonly validUntil?: string;
28+
readonly attestations?: ReadonlyArray<Record<string, unknown>>;
29+
/** Include `content_hash` (default true). */
30+
readonly includeContentHash?: boolean;
31+
}
32+
33+
const utf8 = new TextEncoder();
34+
35+
function ensureTrailingNewline(s: string): string {
36+
return s.endsWith("\n") ? s : s + "\n";
37+
}
38+
39+
/** Sign a concept, returning the signed concept bytes. Input is never mutated. */
40+
export function signConcept(input: Uint8Array | string, opts: SignOptions): Uint8Array {
41+
const { hasFrontmatter, frontmatterText, body } = splitConcept(input);
42+
const canonicalBody = canonicalizeBody(body);
43+
const issuer = opts.issuer ?? didKeyFromSeed(opts.privateKey);
44+
45+
// Build provenance with a stable key order. signature.value is filled after signing.
46+
const provenance: Record<string, unknown> = {
47+
spec_version: opts.specVersion ?? "verifiable-okf/0.3",
48+
issuer,
49+
};
50+
if (opts.includeContentHash !== false) provenance["content_hash"] = contentHash(canonicalBody);
51+
if (opts.validFrom) provenance["valid_from"] = opts.validFrom;
52+
if (opts.validUntil) provenance["valid_until"] = opts.validUntil;
53+
if (opts.attestations && opts.attestations.length > 0) {
54+
provenance["attestations"] = opts.attestations;
55+
}
56+
provenance["signature"] = { scheme: "ed25519" };
57+
58+
// Signing payload binds the issuer, body, and all other frontmatter (§6).
59+
const baseFrontmatter = hasFrontmatter ? parseFrontmatter(frontmatterText) : {};
60+
const frontmatterStar = { ...baseFrontmatter, provenance };
61+
const payload = buildSigningPayload(frontmatterStar, canonicalBody);
62+
const signature = signEd25519(payload, opts.privateKey);
63+
(provenance["signature"] as Record<string, unknown>)["value"] =
64+
Buffer.from(signature).toString("base64");
65+
66+
// Emit: original frontmatter (verbatim) + appended provenance block + verbatim body.
67+
const provenanceYaml = YAML.stringify({ provenance }, { lineWidth: 0 });
68+
const frontmatterRegion = hasFrontmatter
69+
? ensureTrailingNewline(frontmatterText) + provenanceYaml
70+
: provenanceYaml;
71+
const out = `---\n${frontmatterRegion}---\n${body}`;
72+
return utf8.encode(out);
73+
}

packages/signer/test/sign.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
});

packages/signer/tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
4+
"include": ["src"]
5+
}

0 commit comments

Comments
 (0)