From c6dec72f6b0030566d38d02d02d29286a758e04e Mon Sep 17 00:00:00 2001 From: Mayumi Hara Date: Tue, 16 Jun 2026 07:30:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(verifier):=20=C2=A710=20verify=20+=20schem?= =?UTF-8?q?a=20+=20did=20+=20tampered=20fixtures=20(VOKF-3/4/5/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@verifiable-okf/verifier` implements the SPEC §10 algorithm and completes the M0 sign→verify loop. - verify.ts: status {unverified|verified|failed} + reason codes, in §10 order (schema → content_hash → resolve issuer → signature → validity → policy attestations). unverified and unknown keys always pass through. - schema.ts: §11 JSON Schema validation via Ajv 2020 (draft 2020-12) → schema_invalid; schema embedded with a drift test vs the repo file. - did.ts: did:key (offline) + did:web (HTTPS, TLS required, injectable fetch) → Ed25519 public key, else issuer_unresolved. - cli.ts: `vokf-verify` → machine-readable JSON; exit 0/1/2. - fixtures/tampered/{body-modified,bad-signature,schema-invalid, unresolvable-issuer}.md each map to the exact reason code; signed/ with-attestation.md drives unsupported_scheme / attestation_failed via policy. expired / not_yet_valid covered with a fixed `now`. - README: real < 5-minute sign → verify quickstart. Tests (22): signed → verified; each tampered → exact reason; validity window + 60s skew; attestation policy paths; did:key round-trip; did:web via stub fetch; schema drift + accept/reject. Full suite green (canon 28 + signer 7 + verifier 22 = 57). Clean build orders all three packages; CLI quickstart reproduces the golden and flags tampering. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 34 ++++-- fixtures/signed/with-attestation.md | 30 +++++ fixtures/tampered/bad-signature.md | 25 ++++ fixtures/tampered/body-modified.md | 25 ++++ fixtures/tampered/schema-invalid.md | 25 ++++ fixtures/tampered/unresolvable-issuer.md | 25 ++++ packages/verifier/package.json | 39 ++++++ packages/verifier/src/cli.ts | 34 ++++++ packages/verifier/src/did.ts | 77 ++++++++++++ packages/verifier/src/index.ts | 20 +++ packages/verifier/src/schema.ts | 68 +++++++++++ packages/verifier/src/verify.ts | 127 ++++++++++++++++++++ packages/verifier/test/did.test.ts | 51 ++++++++ packages/verifier/test/schema-drift.test.ts | 32 +++++ packages/verifier/test/verify.test.ts | 91 ++++++++++++++ packages/verifier/tsconfig.json | 5 + pnpm-lock.yaml | 74 ++++++++++++ 17 files changed, 774 insertions(+), 8 deletions(-) create mode 100644 fixtures/signed/with-attestation.md create mode 100644 fixtures/tampered/bad-signature.md create mode 100644 fixtures/tampered/body-modified.md create mode 100644 fixtures/tampered/schema-invalid.md create mode 100644 fixtures/tampered/unresolvable-issuer.md create mode 100644 packages/verifier/package.json create mode 100644 packages/verifier/src/cli.ts create mode 100644 packages/verifier/src/did.ts create mode 100644 packages/verifier/src/index.ts create mode 100644 packages/verifier/src/schema.ts create mode 100644 packages/verifier/src/verify.ts create mode 100644 packages/verifier/test/did.test.ts create mode 100644 packages/verifier/test/schema-drift.test.ts create mode 100644 packages/verifier/test/verify.test.ts create mode 100644 packages/verifier/tsconfig.json diff --git a/README.md b/README.md index b6f435d..71095a9 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,32 @@ consumers must preserve unknown fields. ## Packages | Package | Status | Purpose | |---|---|---| -| `@verifiable-okf/canon` | M0 | Canonicalization (§5) + JCS (§6) + signing-payload builder. The shared, version-pinned core. | -| `@verifiable-okf/signer` | M0 (S2) | `vokf sign` — attaches `provenance` without mutating any other byte. | -| `@verifiable-okf/verifier` | M0 (S3) | `vokf verify` — implements the §10 verification algorithm. | -| `@verifiable-okf/visualizer` | M1 | Bundle → single offline HTML with per-concept badges. | - -## Quickstart -> Filled in once `signer` / `verifier` land (S2/S3). The <5-minute sign → verify walkthrough -> will reproduce `fixtures/signed` from `fixtures/unsigned` and verify it. +| `@verifiable-okf/canon` | ✅ M0 | Canonicalization (§5) + JCS (§6) + signing-payload builder. The shared, version-pinned core. | +| `@verifiable-okf/signer` | ✅ M0 | `vokf-sign` — attaches `provenance` without mutating any other byte. | +| `@verifiable-okf/verifier` | ✅ M0 | `vokf-verify` — the §10 verification algorithm (status + reason codes), schema validation, did:web/did:key. | +| `@verifiable-okf/visualizer` | ⏳ M1 | Bundle → single offline HTML with per-concept badges. | + +## Quickstart (< 5 min) + +```bash +pnpm install && pnpm build + +# A test-only Ed25519 seed (32 bytes hex). NEVER use a real key on the CLI. +SEED=0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 + +# 1) Sign an unsigned concept → attaches a `provenance` block (only). +node packages/signer/dist/cli.js fixtures/unsigned/ga4-event.md \ + --key "$SEED" --valid-from 2026-01-01T00:00:00Z --valid-until 2027-01-01T00:00:00Z \ + -o /tmp/signed.md + +# 2) Verify it → machine-readable JSON, exit 0 when verified. +node packages/verifier/dist/cli.js /tmp/signed.md +# {"status":"verified","reasons":[],"issuer":"did:key:z6Mkne..."} +``` + +The signed output is byte-identical to `fixtures/signed/ga4-event.md`, and tampering with the body, +signature, schema, or issuer yields the corresponding `failed` reason code (see `fixtures/tampered/`). +Verification is fully offline for `did:key`; `did:web` resolves the issuer over HTTPS. ## Development ```bash diff --git a/fixtures/signed/with-attestation.md b/fixtures/signed/with-attestation.md new file mode 100644 index 0000000..fc5eddf --- /dev/null +++ b/fixtures/signed/with-attestation.md @@ -0,0 +1,30 @@ +--- +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 + attestations: + - claim: contains_no_pii + scheme: zk-groth16 + circuit: pii-absence + proof: AAAA + signature: + scheme: ed25519 + value: ARAqELugqKSMPQtGun5BjNHX3usUJSb+exKgam476xjuqyRnjPLXnCBUiVgMUkz0uJkmSSX2JajYDix78N2IAQ== +--- +# GA4 Purchase Event + +Send a `purchase` event when an order is confirmed. + +| param | source | +|---|---| +| value | order.total | +| currency | order.currency | diff --git a/fixtures/tampered/bad-signature.md b/fixtures/tampered/bad-signature.md new file mode 100644 index 0000000..3d4ca6d --- /dev/null +++ b/fixtures/tampered/bad-signature.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: AefDAKAw+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/tampered/body-modified.md b/fixtures/tampered/body-modified.md new file mode 100644 index 0000000..3ad6a30 --- /dev/null +++ b/fixtures/tampered/body-modified.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/tampered/schema-invalid.md b/fixtures/tampered/schema-invalid.md new file mode 100644 index 0000000..8d9fb71 --- /dev/null +++ b/fixtures/tampered/schema-invalid.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: "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/tampered/unresolvable-issuer.md b/fixtures/tampered/unresolvable-issuer.md new file mode 100644 index 0000000..bcf4802 --- /dev/null +++ b/fixtures/tampered/unresolvable-issuer.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:example:unresolvable + 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/packages/verifier/package.json b/packages/verifier/package.json new file mode 100644 index 0000000..29f2499 --- /dev/null +++ b/packages/verifier/package.json @@ -0,0 +1,39 @@ +{ + "name": "@verifiable-okf/verifier", + "version": "0.1.0", + "description": "Verifiable OKF verifier: the SPEC §10 algorithm (status + reason codes), JSON Schema validation, and did:web/did:key resolution. CLI: vokf-verify.", + "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-verify": "dist/cli.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@noble/curves": "^1.6.0", + "@scure/base": "^1.1.9", + "@verifiable-okf/canon": "workspace:*", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@verifiable-okf/signer": "workspace:*", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/verifier/src/cli.ts b/packages/verifier/src/cli.ts new file mode 100644 index 0000000..e268089 --- /dev/null +++ b/packages/verifier/src/cli.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * `vokf-verify` — verify a concept and print machine-readable JSON. + * + * Usage: + * vokf-verify [--require ]... + * + * Exit code: 0 = verified, 1 = failed, 2 = unverified / usage error. + */ +import { readFileSync } from "node:fs"; +import { verifyConcept } from "./verify.js"; + +function main(): void { + const path = process.argv[2]; + if (!path || path.startsWith("-")) { + console.error("usage: vokf-verify [--require ]..."); + process.exit(2); + } + const requiredClaims: string[] = []; + for (let i = 3; i < process.argv.length - 1; i++) { + if (process.argv[i] === "--require") requiredClaims.push(process.argv[i + 1]!); + } + verifyConcept(readFileSync(path), { policy: { requiredClaims } }) + .then((result) => { + process.stdout.write(JSON.stringify(result) + "\n"); + process.exit(result.status === "verified" ? 0 : result.status === "failed" ? 1 : 2); + }) + .catch((err: unknown) => { + console.error(String(err)); + process.exit(2); + }); +} + +main(); diff --git a/packages/verifier/src/did.ts b/packages/verifier/src/did.ts new file mode 100644 index 0000000..d7cbe3a --- /dev/null +++ b/packages/verifier/src/did.ts @@ -0,0 +1,77 @@ +/** + * DID resolution → Ed25519 public key (SPEC §7). + * + * - `did:key`: fully offline (multibase base58btc + multicodec ed25519-pub). + * - `did:web`: resolved over HTTPS (TLS REQUIRED); `fetch` is injectable for tests. + * + * Returns the 32-byte Ed25519 public key, or `null` if it cannot be resolved. + */ +import { base58 } from "@scure/base"; + +const ED25519_PUB_MULTICODEC = [0xed, 0x01] as const; + +/** Decode an Ed25519 `did:key` to its 32-byte public key. */ +export function resolveDidKey(did: string): Uint8Array | null { + const mb = did.slice("did:key:".length); + if (!mb.startsWith("z")) return null; + let bytes: Uint8Array; + try { + bytes = base58.decode(mb.slice(1)); + } catch { + return null; + } + if (bytes[0] !== ED25519_PUB_MULTICODEC[0] || bytes[1] !== ED25519_PUB_MULTICODEC[1]) return null; + const pub = bytes.slice(2); + return pub.length === 32 ? pub : null; +} + +/** did:web → HTTPS URL of the DID Document. */ +export function didWebToUrl(did: string): string | null { + const rest = did.slice("did:web:".length); + if (rest === "") return null; + const parts = rest.split(":").map((p) => decodeURIComponent(p)); + const host = parts[0]!; + if (parts.length === 1) return `https://${host}/.well-known/did.json`; + return `https://${host}/${parts.slice(1).join("/")}/did.json`; +} + +type FetchLike = (url: string) => Promise<{ ok: boolean; json: () => Promise }>; + +function pubKeyFromDidDocument(doc: unknown): Uint8Array | null { + if (typeof doc !== "object" || doc === null) return null; + const vms = (doc as { verificationMethod?: unknown }).verificationMethod; + if (!Array.isArray(vms)) return null; + for (const vm of vms) { + const mb = (vm as { publicKeyMultibase?: unknown }).publicKeyMultibase; + if (typeof mb === "string" && mb.startsWith("z")) { + const key = resolveDidKey("did:key:" + mb); + if (key) return key; + } + } + return null; +} + +/** Resolve a did:web over HTTPS. `fetchImpl` defaults to global fetch. */ +export async function resolveDidWeb( + did: string, + fetchImpl: FetchLike = fetch as unknown as FetchLike, +): Promise { + const url = didWebToUrl(did); + if (!url || !url.startsWith("https://")) return null; // TLS required + try { + const res = await fetchImpl(url); + if (!res.ok) return null; + return pubKeyFromDidDocument(await res.json()); + } catch { + return null; + } +} + +export type DidResolver = (did: string) => Promise; + +/** Default resolver: did:key offline, did:web over HTTPS, others unresolved. */ +export const defaultResolveDid: DidResolver = async (did) => { + if (did.startsWith("did:key:")) return resolveDidKey(did); + if (did.startsWith("did:web:")) return resolveDidWeb(did); + return null; +}; diff --git a/packages/verifier/src/index.ts b/packages/verifier/src/index.ts new file mode 100644 index 0000000..ebe8e87 --- /dev/null +++ b/packages/verifier/src/index.ts @@ -0,0 +1,20 @@ +/** + * @verifiable-okf/verifier — the SPEC §10 verification algorithm + * (status + reason codes), JSON Schema validation (§11), and DID resolution (§7). + */ +export { + verifyConcept, + type VerifyResult, + type VerifyOptions, + type TrustStatus, + type ReasonCode, + type AttestationVerifier, +} from "./verify.js"; +export { isSchemaValid, PROVENANCE_SCHEMA } from "./schema.js"; +export { + resolveDidKey, + resolveDidWeb, + didWebToUrl, + defaultResolveDid, + type DidResolver, +} from "./did.js"; diff --git a/packages/verifier/src/schema.ts b/packages/verifier/src/schema.ts new file mode 100644 index 0000000..e04ffcc --- /dev/null +++ b/packages/verifier/src/schema.ts @@ -0,0 +1,68 @@ +/** + * Provenance JSON Schema validation (SPEC §11) → `schema_invalid`. + * + * The schema is embedded so the published package is self-contained; a drift + * test asserts it equals the repo's `schemas/provenance.schema.json`. + */ +// The schema declares JSON Schema draft 2020-12, so use Ajv's 2020 build +// (the default Ajv export only supports draft-07). +import AjvImport from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; + +// ajv and ajv-formats are CJS; normalize the default export across NodeNext +// interop shapes (class directly vs `{ default: class }`). +const Ajv = ((AjvImport as unknown as { default?: unknown }).default ?? AjvImport) as new ( + opts?: Record, +) => { compile: (schema: unknown) => (data: unknown) => boolean }; +const addFormats = ((addFormatsImport as unknown as { default?: unknown }).default ?? + addFormatsImport) as (ajv: unknown) => void; + +export const PROVENANCE_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://verifiable-okf.dev/schemas/provenance-0.3.json", + title: "Verifiable OKF provenance block", + type: "object", + additionalProperties: false, + properties: { + spec_version: { type: "string", pattern: "^verifiable-okf/\\d+\\.\\d+$" }, + issuer: { type: "string", pattern: "^did:" }, + content_hash: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" }, + cid: { type: "string" }, + signature: { + type: "object", + additionalProperties: false, + required: ["scheme", "value"], + properties: { + scheme: { type: "string", enum: ["ed25519"] }, + value: { type: "string" }, + }, + }, + attestations: { + type: "array", + items: { + type: "object", + required: ["claim", "scheme"], + properties: { + claim: { type: "string" }, + scheme: { type: "string" }, + circuit: { type: "string" }, + public_inputs: {}, + disclosed: {}, + proof: { type: "string" }, + verifier: { type: "string", format: "uri" }, + }, + }, + }, + valid_from: { type: "string", format: "date-time" }, + valid_until: { type: "string", format: "date-time" }, + }, +} as const; + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const validate = ajv.compile(PROVENANCE_SCHEMA); + +/** True iff the provenance block conforms to the §11 schema. */ +export function isSchemaValid(provenance: unknown): boolean { + return validate(provenance) === true; +} diff --git a/packages/verifier/src/verify.ts b/packages/verifier/src/verify.ts new file mode 100644 index 0000000..6a2ce67 --- /dev/null +++ b/packages/verifier/src/verify.ts @@ -0,0 +1,127 @@ +/** + * Verifiable OKF verification algorithm (SPEC §10). + * + * Produces exactly one trust status in {unverified, verified, failed} plus zero + * or more reason codes. `unverified` (no provenance) and unknown extra keys are + * always acceptable base OKF — a verifier MUST NOT reject for them. + */ +import { ed25519 } from "@noble/curves/ed25519"; +import { + splitConcept, + parseFrontmatter, + canonicalizeBody, + contentHash, + buildSigningPayload, +} from "@verifiable-okf/canon"; +import { isSchemaValid } from "./schema.js"; +import { defaultResolveDid, type DidResolver } from "./did.js"; + +export type TrustStatus = "unverified" | "verified" | "failed"; +export type ReasonCode = + | "schema_invalid" + | "integrity_mismatch" + | "issuer_unresolved" + | "signature_invalid" + | "expired" + | "not_yet_valid" + | "unsupported_scheme" + | "attestation_failed"; + +export interface VerifyResult { + readonly status: TrustStatus; + readonly reasons: ReasonCode[]; + readonly issuer?: string; +} + +export type AttestationVerifier = ( + attestation: Record, + issuerPublicKey?: Uint8Array, +) => Promise | boolean; + +export interface VerifyOptions { + readonly now?: Date; + readonly skewSeconds?: number; + readonly policy?: { readonly requiredClaims?: ReadonlyArray }; + readonly resolveDid?: DidResolver; + readonly attestationVerifiers?: Readonly>; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +const fail = (reason: ReasonCode, issuer?: string): VerifyResult => ({ + status: "failed", + reasons: [reason], + issuer, +}); + +export async function verifyConcept( + input: Uint8Array | string, + opts: VerifyOptions = {}, +): Promise { + // 1. no provenance → unverified + const { hasFrontmatter, frontmatterText, body } = splitConcept(input); + if (!hasFrontmatter) return { status: "unverified", reasons: [] }; + const frontmatter = parseFrontmatter(frontmatterText); + const prov = frontmatter["provenance"]; + if (prov === undefined) return { status: "unverified", reasons: [] }; + + // 2. schema-invalid + if (!isSchemaValid(prov)) return fail("schema_invalid"); + const provenance = prov as Record; + const issuer = typeof provenance["issuer"] === "string" ? (provenance["issuer"] as string) : undefined; + + // 3. content_hash mismatch + const canonicalBody = canonicalizeBody(body); + const ch = provenance["content_hash"]; + if (typeof ch === "string" && ch !== contentHash(canonicalBody)) { + return fail("integrity_mismatch", issuer); + } + + // 4. signature: resolve issuer, then verify + const resolveDid = opts.resolveDid ?? defaultResolveDid; + let issuerKey: Uint8Array | undefined; + const signature = provenance["signature"]; + if (isObject(signature) && typeof signature["value"] === "string") { + if (!issuer) return fail("issuer_unresolved"); + const key = await resolveDid(issuer); + if (!key) return fail("issuer_unresolved", issuer); + issuerKey = key; + const payload = buildSigningPayload(frontmatter, canonicalBody); + const sigBytes = Uint8Array.from(Buffer.from(signature["value"] as string, "base64")); + let ok = false; + try { + ok = ed25519.verify(sigBytes, payload, key); + } catch { + ok = false; + } + if (!ok) return fail("signature_invalid", issuer); + } + + // 5. validity window (≤ skew tolerance) + const now = (opts.now ?? new Date()).getTime(); + const skew = (opts.skewSeconds ?? 60) * 1000; + const validUntil = provenance["valid_until"]; + if (typeof validUntil === "string" && now > Date.parse(validUntil) + skew) { + return fail("expired", issuer); + } + const validFrom = provenance["valid_from"]; + if (typeof validFrom === "string" && now < Date.parse(validFrom) - skew) { + return fail("not_yet_valid", issuer); + } + + // 6. policy-required attestations + const required = new Set(opts.policy?.requiredClaims ?? []); + const attestations = provenance["attestations"]; + if (required.size > 0 && Array.isArray(attestations)) { + for (const att of attestations) { + if (!isObject(att) || !required.has(String(att["claim"]))) continue; + const verifier = opts.attestationVerifiers?.[String(att["scheme"])]; + if (!verifier) return fail("unsupported_scheme", issuer); + if (!(await verifier(att, issuerKey))) return fail("attestation_failed", issuer); + } + } + + // 7. verified + return { status: "verified", reasons: [], issuer }; +} diff --git a/packages/verifier/test/did.test.ts b/packages/verifier/test/did.test.ts new file mode 100644 index 0000000..be41fee --- /dev/null +++ b/packages/verifier/test/did.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { ed25519 } from "@noble/curves/ed25519"; +import { signConcept, didKeyFromSeed } from "@verifiable-okf/signer"; +import { resolveDidKey, didWebToUrl, resolveDidWeb, verifyConcept } from "../src/index.js"; + +const SEED = Uint8Array.from(Array.from({ length: 32 }, (_, i) => i + 1)); +const PUB = ed25519.getPublicKey(SEED); +const UNSIGNED = "---\ntitle: X\n---\nhello\n"; + +describe("did:key resolution (offline)", () => { + it("resolves the issuer did:key back to the signing public key", () => { + const key = resolveDidKey(didKeyFromSeed(SEED)); + expect(key && Buffer.from(key).toString("hex")).toBe(Buffer.from(PUB).toString("hex")); + }); + + it("returns null for a malformed did:key", () => { + expect(resolveDidKey("did:key:znotbase58!!")).toBeNull(); + expect(resolveDidKey("did:key:zABC")).toBeNull(); // wrong multicodec/length + }); +}); + +describe("did:web", () => { + it("maps did:web to an HTTPS DID-document URL", () => { + expect(didWebToUrl("did:web:example.com")).toBe("https://example.com/.well-known/did.json"); + expect(didWebToUrl("did:web:example.com:u:alice")).toBe("https://example.com/u/alice/did.json"); + }); + + it("resolves a did:web public key over an (injected) HTTPS fetch", async () => { + const didDoc = { + verificationMethod: [ + { id: "did:web:example.com#k", type: "Ed25519VerificationKey2020", publicKeyMultibase: didKeyFromSeed(SEED).slice("did:key:".length) }, + ], + }; + const fetchStub = async () => ({ ok: true, json: async () => didDoc }); + const key = await resolveDidWeb("did:web:example.com", fetchStub); + expect(key && Buffer.from(key).toString("hex")).toBe(Buffer.from(PUB).toString("hex")); + }); + + it("verifies a concept whose issuer is a did:web (via injected resolver)", async () => { + const signed = signConcept(UNSIGNED, { privateKey: SEED, issuer: "did:web:example.com" }); + const r = await verifyConcept(signed, { + resolveDid: async (did) => (did === "did:web:example.com" ? PUB : null), + }); + expect(r.status).toBe("verified"); + expect(r.issuer).toBe("did:web:example.com"); + }); + + it("non-HTTPS / unresolvable did:web → null", async () => { + expect(await resolveDidWeb("did:web:example.com", async () => ({ ok: false, json: async () => ({}) }))).toBeNull(); + }); +}); diff --git a/packages/verifier/test/schema-drift.test.ts b/packages/verifier/test/schema-drift.test.ts new file mode 100644 index 0000000..8548314 --- /dev/null +++ b/packages/verifier/test/schema-drift.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { PROVENANCE_SCHEMA, isSchemaValid } from "../src/index.js"; + +const repoSchema = JSON.parse( + readFileSync(fileURLToPath(new URL("../../../schemas/provenance.schema.json", import.meta.url)), "utf8"), +); + +describe("provenance schema (§11)", () => { + it("embedded schema matches the repo schemas/provenance.schema.json (no drift)", () => { + expect(PROVENANCE_SCHEMA).toEqual(repoSchema); + }); + + it("accepts a well-formed provenance block", () => { + expect( + isSchemaValid({ + spec_version: "verifiable-okf/0.3", + issuer: "did:key:z6Mk", + content_hash: "sha256:" + "a".repeat(64), + signature: { scheme: "ed25519", value: "AAAA" }, + }), + ).toBe(true); + }); + + it("rejects bad spec_version, hash, scheme, and unknown keys", () => { + expect(isSchemaValid({ spec_version: "0.3" })).toBe(false); + expect(isSchemaValid({ content_hash: "sha256:xyz" })).toBe(false); + expect(isSchemaValid({ signature: { scheme: "rsa", value: "x" } })).toBe(false); + expect(isSchemaValid({ unknown_key: 1 })).toBe(false); + }); +}); diff --git a/packages/verifier/test/verify.test.ts b/packages/verifier/test/verify.test.ts new file mode 100644 index 0000000..b55fcbf --- /dev/null +++ b/packages/verifier/test/verify.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { verifyConcept } from "../src/index.js"; + +const root = (p: string) => fileURLToPath(new URL(`../../../${p}`, import.meta.url)); +const read = (p: string) => readFileSync(root(p)); + +const NOW = new Date("2026-06-15T00:00:00Z"); // inside the golden's validity window + +describe("verifyConcept (SPEC §10)", () => { + it("signed concept → verified", async () => { + const r = await verifyConcept(read("fixtures/signed/ga4-event.md"), { now: NOW }); + expect(r.status).toBe("verified"); + expect(r.reasons).toEqual([]); + expect(r.issuer).toMatch(/^did:key:z/); + }); + + it("no provenance → unverified (passes through as base OKF)", async () => { + expect((await verifyConcept(read("fixtures/unsigned/ga4-event.md"), { now: NOW })).status).toBe( + "unverified", + ); + }); + + it("plain markdown / unknown keys are never rejected → unverified", async () => { + expect((await verifyConcept("# just markdown\n")).status).toBe("unverified"); + expect((await verifyConcept("---\ntitle: X\nweird_key: 1\n---\nbody\n")).status).toBe( + "unverified", + ); + }); + + it.each([ + ["fixtures/tampered/schema-invalid.md", "schema_invalid"], + ["fixtures/tampered/body-modified.md", "integrity_mismatch"], + ["fixtures/tampered/bad-signature.md", "signature_invalid"], + ["fixtures/tampered/unresolvable-issuer.md", "issuer_unresolved"], + ])("tampered %s → failed: %s", async (file, reason) => { + const r = await verifyConcept(read(file), { now: NOW }); + expect(r.status).toBe("failed"); + expect(r.reasons).toEqual([reason]); + }); + + it("validity window: expired and not_yet_valid", async () => { + const signed = read("fixtures/signed/ga4-event.md"); + expect((await verifyConcept(signed, { now: new Date("2030-01-01T00:00:00Z") })).reasons).toEqual( + ["expired"], + ); + expect((await verifyConcept(signed, { now: new Date("2020-01-01T00:00:00Z") })).reasons).toEqual( + ["not_yet_valid"], + ); + }); + + it("clock skew ≤ 60s is tolerated", async () => { + const signed = read("fixtures/signed/ga4-event.md"); + // 30s past valid_until, within the 60s skew → still verified + const r = await verifyConcept(signed, { now: new Date("2027-01-01T00:00:30Z") }); + expect(r.status).toBe("verified"); + }); + + it("policy-required attestation with an unknown scheme → unsupported_scheme", async () => { + const r = await verifyConcept(read("fixtures/signed/with-attestation.md"), { + now: NOW, + policy: { requiredClaims: ["contains_no_pii"] }, + }); + expect(r.status).toBe("failed"); + expect(r.reasons).toEqual(["unsupported_scheme"]); + }); + + it("attestation skipped when not required by policy → verified", async () => { + const r = await verifyConcept(read("fixtures/signed/with-attestation.md"), { now: NOW }); + expect(r.status).toBe("verified"); + }); + + it("required attestation with a failing verifier → attestation_failed", async () => { + const r = await verifyConcept(read("fixtures/signed/with-attestation.md"), { + now: NOW, + policy: { requiredClaims: ["contains_no_pii"] }, + attestationVerifiers: { "zk-groth16": () => false }, + }); + expect(r.reasons).toEqual(["attestation_failed"]); + }); + + it("required attestation with a passing verifier → verified", async () => { + const r = await verifyConcept(read("fixtures/signed/with-attestation.md"), { + now: NOW, + policy: { requiredClaims: ["contains_no_pii"] }, + attestationVerifiers: { "zk-groth16": () => true }, + }); + expect(r.status).toBe("verified"); + }); +}); diff --git a/packages/verifier/tsconfig.json b/packages/verifier/tsconfig.json new file mode 100644 index 0000000..e0d0985 --- /dev/null +++ b/packages/verifier/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src", "resolveJsonModule": true }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88bf051..9a9a3e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,37 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@20.19.43) + packages/verifier: + 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 + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + devDependencies: + '@types/node': + specifier: ^20.14.0 + version: 20.19.43 + '@verifiable-okf/signer': + specifier: workspace:* + version: link:../signer + 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': @@ -400,6 +431,17 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -448,11 +490,20 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -481,6 +532,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -820,6 +875,17 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} cac@6.7.14: {} @@ -876,9 +942,15 @@ snapshots: expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + fsevents@2.3.3: optional: true + json-schema-traverse@1.0.0: {} + loupe@3.2.1: {} magic-string@0.30.21: @@ -901,6 +973,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + require-from-string@2.0.2: {} + rollup@4.62.0: dependencies: '@types/estree': 1.0.9