Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions fixtures/signed/ga4-event.md
Original file line number Diff line number Diff line change
@@ -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 |
16 changes: 16 additions & 0 deletions fixtures/unsigned/ga4-event.md
Original file line number Diff line number Diff line change
@@ -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 |
28 changes: 28 additions & 0 deletions packages/signer/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
52 changes: 52 additions & 0 deletions packages/signer/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* `vokf-sign` — minimal CLI around signConcept().
*
* Usage:
* vokf-sign <concept.md> --key <seed.hex|file> [--issuer <did>]
* [--valid-from <rfc3339>] [--valid-until <rfc3339>] [-o <out.md>]
*
* 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 <concept.md> --key <seed.hex|file> [--issuer <did>] [--valid-from <ts>] [--valid-until <ts>] [-o <out.md>]");
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();
12 changes: 12 additions & 0 deletions packages/signer/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
44 changes: 44 additions & 0 deletions packages/signer/src/keys.ts
Original file line number Diff line number Diff line change
@@ -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));
}
73 changes: 73 additions & 0 deletions packages/signer/src/sign.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
/** 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<string, unknown> = {
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<string, unknown>)["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);
}
86 changes: 86 additions & 0 deletions packages/signer/test/sign.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
const sig = (prov["signature"] as Record<string, unknown>)["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<string, unknown>;
expect(String(prov["issuer"])).toMatch(/^did:key:z/);
});
});
5 changes: 5 additions & 0 deletions packages/signer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
Loading
Loading