@finguard/pack-spec 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Canonical JSON — the only serialization allowed inside a pack_hash preimage.
3
+ * Rules (Evidence Pack Spec v1 §3): lexicographically sorted object keys,
4
+ * no insignificant whitespace, UTF-8. Arrays keep their order (callers must
5
+ * pre-sort where the spec requires it, e.g. attested_files by name).
6
+ */
7
+ export type Json = string | number | boolean | null | Json[] | {
8
+ [key: string]: Json;
9
+ };
10
+ export declare function canonicalJson(value: Json): string;
@@ -0,0 +1,16 @@
1
+ export function canonicalJson(value) {
2
+ if (value === null || typeof value !== "object") {
3
+ if (typeof value === "number" && !Number.isFinite(value)) {
4
+ throw new Error("canonicalJson: non-finite numbers are not permitted");
5
+ }
6
+ return JSON.stringify(value);
7
+ }
8
+ if (Array.isArray(value)) {
9
+ return `[${value.map(canonicalJson).join(",")}]`;
10
+ }
11
+ const keys = Object.keys(value).sort();
12
+ const body = keys
13
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`)
14
+ .join(",");
15
+ return `{${body}}`;
16
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Ed25519 verification via WebCrypto (Node >= 18, Chrome >= 113, Firefox,
3
+ * Safari). Signing is NOT implemented here — signing is the commercial
4
+ * control plane's job; the open verifier only ever needs public keys.
5
+ * (Spec v1 §7: the format is open, the trust anchor is operated.)
6
+ */
7
+ export declare function verifyEd25519(opts: {
8
+ /** 32-byte raw public key, as hex or base64 string, or raw bytes. */
9
+ publicKey: string | Uint8Array;
10
+ /** The message that was signed: ascii(pack_hash). */
11
+ message: string;
12
+ /** Base64 signature from MANIFEST.signature. */
13
+ signatureBase64: string;
14
+ }): Promise<boolean>;
15
+ /** Key registry format (Spec v1 §7). */
16
+ export interface RegistryKey {
17
+ key_id: string;
18
+ alg: "ed25519";
19
+ /** base64 or hex raw 32-byte public key */
20
+ public_key: string;
21
+ valid_from: string;
22
+ revoked_at?: string | null;
23
+ }
24
+ export declare function findRegistryKey(registry: RegistryKey[], key_id: string): RegistryKey | undefined;
@@ -0,0 +1,26 @@
1
+ import { fromBase64, fromHex, utf8 } from "./hash.js";
2
+ /**
3
+ * Ed25519 verification via WebCrypto (Node >= 18, Chrome >= 113, Firefox,
4
+ * Safari). Signing is NOT implemented here — signing is the commercial
5
+ * control plane's job; the open verifier only ever needs public keys.
6
+ * (Spec v1 §7: the format is open, the trust anchor is operated.)
7
+ */
8
+ export async function verifyEd25519(opts) {
9
+ const raw = normalizeKey(opts.publicKey);
10
+ if (raw.byteLength !== 32) {
11
+ throw new Error(`Ed25519 public key must be 32 raw bytes, got ${raw.byteLength}`);
12
+ }
13
+ const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
14
+ return crypto.subtle.verify({ name: "Ed25519" }, key, fromBase64(opts.signatureBase64), utf8(opts.message));
15
+ }
16
+ function normalizeKey(key) {
17
+ if (typeof key !== "string")
18
+ return key;
19
+ const trimmed = key.trim();
20
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed))
21
+ return fromHex(trimmed);
22
+ return fromBase64(trimmed);
23
+ }
24
+ export function findRegistryKey(registry, key_id) {
25
+ return registry.find((k) => k.key_id === key_id && !k.revoked_at);
26
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SHA-256 via WebCrypto — available in every modern browser and Node >= 18.
3
+ * Zero dependencies keeps the verifier trivially auditable.
4
+ */
5
+ export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
6
+ export declare function hex(bytes: Uint8Array): string;
7
+ export declare function fromHex(s: string): Uint8Array;
8
+ export declare function fromBase64(s: string): Uint8Array;
9
+ export declare function utf8(s: string): Uint8Array;
package/dist/hash.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * SHA-256 via WebCrypto — available in every modern browser and Node >= 18.
3
+ * Zero dependencies keeps the verifier trivially auditable.
4
+ */
5
+ export async function sha256Hex(bytes) {
6
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
7
+ return hex(new Uint8Array(digest));
8
+ }
9
+ export function hex(bytes) {
10
+ let out = "";
11
+ for (const b of bytes)
12
+ out += b.toString(16).padStart(2, "0");
13
+ return out;
14
+ }
15
+ export function fromHex(s) {
16
+ if (!/^[0-9a-fA-F]*$/.test(s) || s.length % 2 !== 0) {
17
+ throw new Error("invalid hex string");
18
+ }
19
+ const out = new Uint8Array(s.length / 2);
20
+ for (let i = 0; i < out.length; i++) {
21
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
22
+ }
23
+ return out;
24
+ }
25
+ export function fromBase64(s) {
26
+ // atob exists in browsers and Node >= 16
27
+ const bin = atob(s.replace(/\s+/g, ""));
28
+ const out = new Uint8Array(bin.length);
29
+ for (let i = 0; i < bin.length; i++)
30
+ out[i] = bin.charCodeAt(i);
31
+ return out;
32
+ }
33
+ export function utf8(s) {
34
+ return new TextEncoder().encode(s);
35
+ }
@@ -0,0 +1,5 @@
1
+ export { canonicalJson, type Json } from "./canonical.js";
2
+ export { sha256Hex, hex, fromHex, fromBase64, utf8 } from "./hash.js";
3
+ export { PACK_SPEC_VERSION, fileEntries, packHashPreimage, computePackHash, buildManifest, serializeManifest, type Producer, type Role, type FileEntry, type Manifest, type SignatureStatus, type BuildManifestOptions, } from "./manifest.js";
4
+ export { verifyEd25519, findRegistryKey, type RegistryKey, } from "./ed25519.js";
5
+ export { verifyPack, type VerifyReport, type FileCheck, type SignatureResult, type Scope, } from "./verify-core.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { canonicalJson } from "./canonical.js";
2
+ export { sha256Hex, hex, fromHex, fromBase64, utf8 } from "./hash.js";
3
+ export { PACK_SPEC_VERSION, fileEntries, packHashPreimage, computePackHash, buildManifest, serializeManifest, } from "./manifest.js";
4
+ export { verifyEd25519, findRegistryKey, } from "./ed25519.js";
5
+ export { verifyPack, } from "./verify-core.js";
@@ -0,0 +1,78 @@
1
+ export declare const PACK_SPEC_VERSION = "1.0";
2
+ export type Role = "ciso" | "md" | "analyst";
3
+ export interface Producer {
4
+ user_id: string;
5
+ display_name: string;
6
+ role: Role;
7
+ }
8
+ export interface FileEntry {
9
+ name: string;
10
+ sha256: string;
11
+ bytes: number;
12
+ }
13
+ export type SignatureStatus = "unsigned-demo" | "signed";
14
+ export interface Manifest {
15
+ pack_spec_version: string;
16
+ generator: {
17
+ name: string;
18
+ version: string;
19
+ };
20
+ snapshot_id: string;
21
+ producer: Producer;
22
+ /** Informational — OUTSIDE the pack_hash preimage (Spec v1 §2). */
23
+ generated_at: string;
24
+ /** Informational — OUTSIDE the pack_hash preimage (Spec v1 §2). */
25
+ requested_at: string;
26
+ /** Sorted ascending by name (Spec v1 §2). */
27
+ attested_files: FileEntry[];
28
+ /** Listed + hashed, but outside the pack_hash preimage (Spec v1 §4). */
29
+ informational_files: FileEntry[];
30
+ pack_hash: string;
31
+ /** Base64 Ed25519 signature over ascii(pack_hash), or null. */
32
+ signature: string | null;
33
+ signature_status: SignatureStatus;
34
+ key_id: string | null;
35
+ /** Committed to the audit vault BEFORE download (Spec v1 §5). */
36
+ vault_entry_id: string | null;
37
+ }
38
+ /** Hash every file and return entries sorted ascending by name. */
39
+ export declare function fileEntries(files: ReadonlyMap<string, Uint8Array>): Promise<FileEntry[]>;
40
+ /**
41
+ * The canonical pack_hash preimage (Spec v1 §3).
42
+ * Timestamps, generator version, informational files, signature fields and
43
+ * vault_entry_id are deliberately EXCLUDED — same snapshot + same spec version
44
+ * + same producer must yield an identical pack_hash regardless of when or
45
+ * where the pack is built.
46
+ */
47
+ export declare function packHashPreimage(input: {
48
+ pack_spec_version: string;
49
+ snapshot_id: string;
50
+ producer: Producer;
51
+ attested_files: FileEntry[];
52
+ }): string;
53
+ export declare function computePackHash(input: {
54
+ pack_spec_version: string;
55
+ snapshot_id: string;
56
+ producer: Producer;
57
+ attested_files: FileEntry[];
58
+ }): Promise<string>;
59
+ export interface BuildManifestOptions {
60
+ generator: {
61
+ name: string;
62
+ version: string;
63
+ };
64
+ snapshot_id: string;
65
+ producer: Producer;
66
+ generated_at: string;
67
+ requested_at: string;
68
+ attestedFiles: ReadonlyMap<string, Uint8Array>;
69
+ informationalFiles?: ReadonlyMap<string, Uint8Array>;
70
+ vault_entry_id?: string | null;
71
+ signature?: {
72
+ value: string;
73
+ key_id: string;
74
+ } | null;
75
+ }
76
+ export declare function buildManifest(opts: BuildManifestOptions): Promise<Manifest>;
77
+ /** Stable, human-readable serialization used when writing MANIFEST.json to disk. */
78
+ export declare function serializeManifest(manifest: Manifest): string;
@@ -0,0 +1,60 @@
1
+ import { canonicalJson } from "./canonical.js";
2
+ import { sha256Hex, utf8 } from "./hash.js";
3
+ export const PACK_SPEC_VERSION = "1.0";
4
+ /** Hash every file and return entries sorted ascending by name. */
5
+ export async function fileEntries(files) {
6
+ const entries = await Promise.all([...files.entries()].map(async ([name, bytes]) => ({
7
+ name,
8
+ sha256: await sha256Hex(bytes),
9
+ bytes: bytes.byteLength,
10
+ })));
11
+ return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
12
+ }
13
+ /**
14
+ * The canonical pack_hash preimage (Spec v1 §3).
15
+ * Timestamps, generator version, informational files, signature fields and
16
+ * vault_entry_id are deliberately EXCLUDED — same snapshot + same spec version
17
+ * + same producer must yield an identical pack_hash regardless of when or
18
+ * where the pack is built.
19
+ */
20
+ export function packHashPreimage(input) {
21
+ const sorted = [...input.attested_files].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
22
+ return canonicalJson({
23
+ pack_spec_version: input.pack_spec_version,
24
+ snapshot_id: input.snapshot_id,
25
+ producer: { ...input.producer },
26
+ attested_files: sorted.map((f) => ({ ...f })),
27
+ });
28
+ }
29
+ export async function computePackHash(input) {
30
+ return sha256Hex(utf8(packHashPreimage(input)));
31
+ }
32
+ export async function buildManifest(opts) {
33
+ const attested_files = await fileEntries(opts.attestedFiles);
34
+ const informational_files = await fileEntries(opts.informationalFiles ?? new Map());
35
+ const pack_hash = await computePackHash({
36
+ pack_spec_version: PACK_SPEC_VERSION,
37
+ snapshot_id: opts.snapshot_id,
38
+ producer: opts.producer,
39
+ attested_files,
40
+ });
41
+ return {
42
+ pack_spec_version: PACK_SPEC_VERSION,
43
+ generator: opts.generator,
44
+ snapshot_id: opts.snapshot_id,
45
+ producer: opts.producer,
46
+ generated_at: opts.generated_at,
47
+ requested_at: opts.requested_at,
48
+ attested_files,
49
+ informational_files,
50
+ pack_hash,
51
+ signature: opts.signature?.value ?? null,
52
+ signature_status: opts.signature ? "signed" : "unsigned-demo",
53
+ key_id: opts.signature?.key_id ?? null,
54
+ vault_entry_id: opts.vault_entry_id ?? null,
55
+ };
56
+ }
57
+ /** Stable, human-readable serialization used when writing MANIFEST.json to disk. */
58
+ export function serializeManifest(manifest) {
59
+ return JSON.stringify(manifest, null, 2) + "\n";
60
+ }
@@ -0,0 +1,39 @@
1
+ import { type Manifest } from "./manifest.js";
2
+ import { type RegistryKey } from "./ed25519.js";
3
+ export type Scope = "attested" | "informational";
4
+ export interface FileCheck {
5
+ name: string;
6
+ scope: Scope;
7
+ present: boolean;
8
+ expected_sha256: string;
9
+ actual_sha256: string | null;
10
+ expected_bytes: number;
11
+ actual_bytes: number | null;
12
+ ok: boolean;
13
+ }
14
+ export type SignatureResult = "signed-verified" | "signed-invalid" | "signed-unverified-no-key" | "unsigned-demo";
15
+ export interface VerifyReport {
16
+ spec_version: string;
17
+ spec_supported: boolean;
18
+ manifest: Manifest;
19
+ files: FileCheck[];
20
+ /** Files in the archive that the MANIFEST does not list (MANIFEST/VERIFY/SPEC excluded). */
21
+ unlisted_files: string[];
22
+ expected_pack_hash: string;
23
+ computed_pack_hash: string;
24
+ pack_hash_ok: boolean;
25
+ signature: SignatureResult;
26
+ /** True iff spec supported, every listed file checks out, and pack_hash matches.
27
+ * Signature ABSENCE does not fail verification; signature INVALIDITY does. */
28
+ ok: boolean;
29
+ }
30
+ /**
31
+ * Verify a pack given its files as bytes — fully offline (Spec v1 §6).
32
+ * Works identically in the browser and in Node; the CLI is a thin wrapper.
33
+ */
34
+ export declare function verifyPack(files: ReadonlyMap<string, Uint8Array>, opts?: {
35
+ /** Raw Ed25519 public key (hex/base64) to verify a signed pack against. */
36
+ publicKey?: string | Uint8Array;
37
+ /** Key registry (Spec v1 §7); used when key_id is present and no publicKey given. */
38
+ registry?: RegistryKey[];
39
+ }): Promise<VerifyReport>;
@@ -0,0 +1,92 @@
1
+ import { sha256Hex } from "./hash.js";
2
+ import { computePackHash, PACK_SPEC_VERSION, } from "./manifest.js";
3
+ import { findRegistryKey, verifyEd25519 } from "./ed25519.js";
4
+ const SELF_FILES = new Set(["MANIFEST.json", "VERIFY.md", "SPEC.md", "UPGRADE.md"]);
5
+ /**
6
+ * Verify a pack given its files as bytes — fully offline (Spec v1 §6).
7
+ * Works identically in the browser and in Node; the CLI is a thin wrapper.
8
+ */
9
+ export async function verifyPack(files, opts = {}) {
10
+ const manifestBytes = files.get("MANIFEST.json");
11
+ if (!manifestBytes) {
12
+ throw new Error("MANIFEST.json not found in pack");
13
+ }
14
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
15
+ const spec_supported = manifest.pack_spec_version === PACK_SPEC_VERSION;
16
+ const checks = [];
17
+ const checkList = async (entries, scope) => {
18
+ for (const entry of entries ?? []) {
19
+ const bytes = files.get(entry.name);
20
+ if (!bytes) {
21
+ checks.push({
22
+ name: entry.name,
23
+ scope,
24
+ present: false,
25
+ expected_sha256: entry.sha256,
26
+ actual_sha256: null,
27
+ expected_bytes: entry.bytes,
28
+ actual_bytes: null,
29
+ ok: false,
30
+ });
31
+ continue;
32
+ }
33
+ const actual = await sha256Hex(bytes);
34
+ checks.push({
35
+ name: entry.name,
36
+ scope,
37
+ present: true,
38
+ expected_sha256: entry.sha256,
39
+ actual_sha256: actual,
40
+ expected_bytes: entry.bytes,
41
+ actual_bytes: bytes.byteLength,
42
+ ok: actual === entry.sha256 && bytes.byteLength === entry.bytes,
43
+ });
44
+ }
45
+ };
46
+ await checkList(manifest.attested_files, "attested");
47
+ await checkList(manifest.informational_files, "informational");
48
+ const listed = new Set([
49
+ ...(manifest.attested_files ?? []).map((f) => f.name),
50
+ ...(manifest.informational_files ?? []).map((f) => f.name),
51
+ ]);
52
+ const unlisted_files = [...files.keys()].filter((name) => !listed.has(name) && !SELF_FILES.has(name));
53
+ const computed_pack_hash = await computePackHash({
54
+ pack_spec_version: manifest.pack_spec_version,
55
+ snapshot_id: manifest.snapshot_id,
56
+ producer: manifest.producer,
57
+ attested_files: manifest.attested_files ?? [],
58
+ });
59
+ const pack_hash_ok = computed_pack_hash === manifest.pack_hash;
60
+ let signature = "unsigned-demo";
61
+ if (manifest.signature) {
62
+ let key = opts.publicKey;
63
+ if (!key && opts.registry && manifest.key_id) {
64
+ key = findRegistryKey(opts.registry, manifest.key_id)?.public_key;
65
+ }
66
+ if (!key) {
67
+ signature = "signed-unverified-no-key";
68
+ }
69
+ else {
70
+ const valid = await verifyEd25519({
71
+ publicKey: key,
72
+ message: manifest.pack_hash,
73
+ signatureBase64: manifest.signature,
74
+ });
75
+ signature = valid ? "signed-verified" : "signed-invalid";
76
+ }
77
+ }
78
+ const filesOk = checks.every((c) => c.ok);
79
+ const ok = spec_supported && filesOk && pack_hash_ok && signature !== "signed-invalid";
80
+ return {
81
+ spec_version: manifest.pack_spec_version,
82
+ spec_supported,
83
+ manifest,
84
+ files: checks,
85
+ unlisted_files,
86
+ expected_pack_hash: manifest.pack_hash,
87
+ computed_pack_hash,
88
+ pack_hash_ok,
89
+ signature,
90
+ ok,
91
+ };
92
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@finguard/pack-spec",
3
+ "version": "1.0.0",
4
+ "description": "Canonical types, hashing, and verification core for the FinGuard Evidence Pack Spec v1 — isomorphic (browser + Node), zero dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
11
+ },
12
+ "files": ["dist", "README.md"],
13
+ "scripts": {
14
+ "build": "tsc -p ."
15
+ },
16
+ "repository": { "type": "git", "url": "git+https://github.com/KGEmmanuel/finguard.git", "directory": "packages/pack-spec" },
17
+ "keywords": ["ai-governance", "evidence", "audit", "sha256", "ed25519", "regtech"]
18
+ }