@crawlcheck/sdk 0.0.0-stage → 1.0.1

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,5 @@
1
+ /**
2
+ * This file was auto-generated by openapi-typescript.
3
+ * Do not make direct changes to the file.
4
+ */
5
+ export {};
@@ -0,0 +1,148 @@
1
+ import type { paths, components } from "./gen/openapi.js";
2
+ export type { paths, components };
3
+ /** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
4
+ export interface VerifyCheck {
5
+ id: string;
6
+ ok: boolean | null;
7
+ why: string;
8
+ [k: string]: unknown;
9
+ }
10
+ export interface BundleVerification {
11
+ verified: boolean;
12
+ summary: string;
13
+ report: {
14
+ id?: string;
15
+ domain?: string;
16
+ scanned_at?: string;
17
+ };
18
+ checks: VerifyCheck[];
19
+ experiences: number | null;
20
+ note: string;
21
+ }
22
+ export interface ReceiptVerification {
23
+ verified: boolean;
24
+ summary: string;
25
+ receipt: {
26
+ id: string;
27
+ domain: string;
28
+ verdict: string;
29
+ before: string;
30
+ after: string;
31
+ };
32
+ checks: VerifyCheck[];
33
+ note: string;
34
+ }
35
+ /** Verify an evidence bundle with no call to CrawlCheck (the same code as /crawlcheck-verify.mjs). */
36
+ export declare const verifyBundle: (b: unknown) => Promise<BundleVerification>;
37
+ /** Verify a remediation receipt with no call to CrawlCheck. */
38
+ export declare const verifyReceipt: (rc: unknown) => Promise<ReceiptVerification>;
39
+ export declare const canon: (v: unknown) => string;
40
+ export declare const merkleRootHex: (hexLeaves: string[]) => Promise<string>;
41
+ export declare const otsInspect: (u8: Uint8Array) => Promise<{
42
+ digest: string;
43
+ attestations: Array<{
44
+ kind: string;
45
+ [k: string]: unknown;
46
+ }>;
47
+ }>;
48
+ type S = components["schemas"];
49
+ export type MachineRecordV1 = S["MachineRecordV1"];
50
+ export type PublicReportV1 = S["PublicReportV1"];
51
+ export type EvidenceBundleV1 = S["EvidenceBundleV1"];
52
+ export type RemediationReceiptV1 = S["RemediationReceiptV1"];
53
+ export type ErrorV1 = S["ErrorV1"];
54
+ export type JsonObject = {
55
+ [k: string]: unknown;
56
+ };
57
+ type Op<P extends keyof paths, M extends "get" | "post"> = paths[P] extends {
58
+ [K in M]: infer O;
59
+ } ? O : never;
60
+ export type QueryOf<P extends keyof paths, M extends "get" | "post" = "get"> = Op<P, M> extends {
61
+ parameters: {
62
+ query?: infer Q;
63
+ };
64
+ } ? NonNullable<Q> : Record<string, never>;
65
+ export type ResponseOf<P extends keyof paths, M extends "get" | "post" = "get"> = Op<P, M> extends {
66
+ responses: {
67
+ 200: {
68
+ content: {
69
+ "application/json": infer J;
70
+ };
71
+ };
72
+ };
73
+ } ? (unknown extends J ? JsonObject : J) : JsonObject;
74
+ export type BodyOf<P extends keyof paths> = Op<P, "post"> extends {
75
+ requestBody?: {
76
+ content: {
77
+ "application/json": infer B;
78
+ };
79
+ };
80
+ } ? B : JsonObject;
81
+ export interface ClientOptions {
82
+ /** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
83
+ key?: string;
84
+ /** API origin. Default https://crawlcheck.io */
85
+ base?: string;
86
+ /** A fetch implementation (default: globalThis.fetch). */
87
+ fetch?: typeof fetch;
88
+ /** Extra headers for every request. */
89
+ headers?: Record<string, string>;
90
+ }
91
+ export declare class CrawlCheckError extends Error {
92
+ readonly status: number;
93
+ readonly body: unknown;
94
+ readonly path: string;
95
+ constructor(status: number, path: string, body: unknown);
96
+ /** true when the answer was "this needs a licence for the domain (or the owner's session)". */
97
+ get locked(): boolean;
98
+ }
99
+ export interface ConformanceResult {
100
+ name: string;
101
+ group: string;
102
+ ok: boolean;
103
+ diffs: string[];
104
+ must_reject: string | null;
105
+ }
106
+ export declare class CrawlCheck {
107
+ readonly base: string;
108
+ private readonly key?;
109
+ private readonly f;
110
+ private readonly extra;
111
+ constructor(opts?: ClientOptions);
112
+ /** Any documented GET, typed from the OpenAPI document: client.get("/api/explain", { domain: "example.com" }). */
113
+ get<P extends keyof paths>(path: P, query?: QueryOf<P, "get">): Promise<ResponseOf<P, "get">>;
114
+ /** Any documented POST, typed from the OpenAPI document. */
115
+ post<P extends keyof paths>(path: P, body: BodyOf<P> | JsonObject, query?: QueryOf<P, "post">): Promise<ResponseOf<P, "post">>;
116
+ /** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
117
+ machineRecord(q: QueryOf<"/api/v1/machine-record">): Promise<MachineRecordV1>;
118
+ /** Scan a domain now (free lane without a key; the licensed lane with one). */
119
+ scan(domain: string): Promise<PublicReportV1>;
120
+ /** A stored report by id, in the public v1 shape. */
121
+ report(id: string): Promise<PublicReportV1>;
122
+ explain(q: QueryOf<"/api/explain">): Promise<JsonObject>;
123
+ impact(q: QueryOf<"/api/impact">): Promise<JsonObject>;
124
+ whatIf(q: QueryOf<"/api/whatif">): Promise<JsonObject>;
125
+ locate(q: QueryOf<"/api/locate">): Promise<JsonObject>;
126
+ /** Every server-side verifier on one record. */
127
+ verify(id: string): Promise<JsonObject>;
128
+ /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
129
+ bundle(id: string): Promise<EvidenceBundleV1>;
130
+ /** Download the bundle and verify it here, with the offline verifier: no trust in the API's own answer. */
131
+ verifyOffline(id: string): Promise<BundleVerification>;
132
+ /** A signed remediation receipt by its id (rc1:…). */
133
+ receipt(id: string): Promise<RemediationReceiptV1>;
134
+ /** Fetch a receipt and verify its hash and signature here. key_published stays null offline: compare the key id with publishedKeyIds(). */
135
+ verifyReceiptOffline(id: string): Promise<ReceiptVerification>;
136
+ /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
137
+ publishedKeyIds(): Promise<string[]>;
138
+ receipts(domain: string): Promise<JsonObject>;
139
+ /** Run the published conformance fixtures through THIS package's verifier. Every result must be ok. */
140
+ conformance(): Promise<{
141
+ ok: boolean;
142
+ results: ConformanceResult[];
143
+ built_at: string;
144
+ verifier_sha256: string;
145
+ }>;
146
+ private req;
147
+ }
148
+ export default CrawlCheck;
package/dist/index.js ADDED
@@ -0,0 +1,128 @@
1
+ import * as V from "./verify.js";
2
+ /** Verify an evidence bundle with no call to CrawlCheck (the same code as /crawlcheck-verify.mjs). */
3
+ export const verifyBundle = V.verifyBundle;
4
+ /** Verify a remediation receipt with no call to CrawlCheck. */
5
+ export const verifyReceipt = V.verifyReceipt;
6
+ export const canon = V.canon;
7
+ export const merkleRootHex = V.merkleRootHex;
8
+ export const otsInspect = V.otsInspect;
9
+ export class CrawlCheckError extends Error {
10
+ status;
11
+ body;
12
+ path;
13
+ constructor(status, path, body) {
14
+ const m = body && typeof body === "object" ? (body.error?.message ?? body.error ?? body.message) : null;
15
+ super("CrawlCheck " + path + " answered " + status + (m ? ": " + (typeof m === "string" ? m : JSON.stringify(m)) : ""));
16
+ this.name = "CrawlCheckError";
17
+ this.status = status;
18
+ this.body = body;
19
+ this.path = path;
20
+ }
21
+ /** true when the answer was "this needs a licence for the domain (or the owner's session)". */
22
+ get locked() { return this.status === 403; }
23
+ }
24
+ export class CrawlCheck {
25
+ base;
26
+ key;
27
+ f;
28
+ extra;
29
+ constructor(opts = {}) {
30
+ this.base = (opts.base ?? "https://crawlcheck.io").replace(/\/+$/, "");
31
+ this.key = opts.key;
32
+ this.extra = opts.headers ?? {};
33
+ const f = opts.fetch ?? globalThis.fetch;
34
+ if (typeof f !== "function")
35
+ throw new Error("no fetch available: pass { fetch } (Node 18+ and every browser have one)");
36
+ this.f = f.bind(globalThis);
37
+ }
38
+ /** Any documented GET, typed from the OpenAPI document: client.get("/api/explain", { domain: "example.com" }). */
39
+ async get(path, query) {
40
+ return this.req("GET", path, query);
41
+ }
42
+ /** Any documented POST, typed from the OpenAPI document. */
43
+ async post(path, body, query) {
44
+ return this.req("POST", path, query, body);
45
+ }
46
+ // ── the records ──
47
+ /** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
48
+ machineRecord(q) { return this.get("/api/v1/machine-record", q); }
49
+ /** Scan a domain now (free lane without a key; the licensed lane with one). */
50
+ scan(domain) { return this.post("/api/v1/scan", { domain }); }
51
+ /** A stored report by id, in the public v1 shape. */
52
+ async report(id) { return this.req("GET", "/r/" + encodeURIComponent(id) + ".json"); }
53
+ // ── why ──
54
+ explain(q) { return this.get("/api/explain", q); }
55
+ impact(q) { return this.get("/api/impact", q); }
56
+ whatIf(q) { return this.get("/api/whatif", q); }
57
+ locate(q) { return this.get("/api/locate", q); }
58
+ // ── proof ──
59
+ /** Every server-side verifier on one record. */
60
+ verify(id) { return this.get("/api/verify", { id }); }
61
+ /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
62
+ bundle(id) { return this.get("/api/bundle", { id, download: "0" }); }
63
+ /** Download the bundle and verify it here, with the offline verifier: no trust in the API's own answer. */
64
+ async verifyOffline(id) { return verifyBundle(await this.bundle(id)); }
65
+ /** A signed remediation receipt by its id (rc1:…). */
66
+ async receipt(id) { const r = await this.get("/api/receipt", { id }); return r.receipt; }
67
+ /** Fetch a receipt and verify its hash and signature here. key_published stays null offline: compare the key id with publishedKeyIds(). */
68
+ async verifyReceiptOffline(id) { return verifyReceipt(await this.receipt(id)); }
69
+ /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
70
+ async publishedKeyIds() {
71
+ const d = await this.req("GET", "/.well-known/http-message-signatures-directory");
72
+ const out = [];
73
+ for (const k of (d?.keys ?? [])) {
74
+ if (!k?.x)
75
+ continue;
76
+ const h = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ crv: k.crv, kty: k.kty, x: k.x }))));
77
+ let s = "";
78
+ for (const b of h)
79
+ s += String.fromCharCode(b);
80
+ out.push(btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
81
+ }
82
+ return out;
83
+ }
84
+ receipts(domain) { return this.get("/api/receipts", { domain }); }
85
+ // ── the suite ──
86
+ /** Run the published conformance fixtures through THIS package's verifier. Every result must be ok. */
87
+ async conformance() {
88
+ const ix = await this.req("GET", "/conformance/index.json");
89
+ const results = [];
90
+ for (const f of ix.fixtures) {
91
+ const doc = await this.req("GET", new URL(f.file).pathname + new URL(f.file).search);
92
+ const res = f.kind === "receipt" ? await verifyReceipt(doc) : await verifyBundle(doc);
93
+ const got = {};
94
+ for (const c of res.checks)
95
+ got[c.id] = c.ok;
96
+ const diffs = Object.keys(f.expected.checks).filter((k) => got[k] !== f.expected.checks[k]).map((k) => k + " expected " + f.expected.checks[k] + " got " + got[k]);
97
+ if (res.verified !== f.expected.verified)
98
+ diffs.push("verified expected " + f.expected.verified + " got " + res.verified);
99
+ results.push({ name: f.name, group: f.group, ok: diffs.length === 0, diffs, must_reject: f.must_reject ?? null });
100
+ }
101
+ return { ok: results.every((r) => r.ok), results, built_at: ix.built_at, verifier_sha256: ix.verifier.sha256 };
102
+ }
103
+ async req(method, path, query, body) {
104
+ const u = new URL(path, this.base + "/");
105
+ if (query)
106
+ for (const [k, v] of Object.entries(query))
107
+ if (v !== undefined && v !== null)
108
+ u.searchParams.set(k, Array.isArray(v) ? v.join(",") : String(v));
109
+ const h = Object.assign({ accept: "application/json" }, this.extra);
110
+ if (this.key)
111
+ h["x-crawlcheck-key"] = this.key;
112
+ if (body !== undefined)
113
+ h["content-type"] = "application/json";
114
+ const r = await this.f(u.toString(), { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
115
+ const t = await r.text();
116
+ let j;
117
+ try {
118
+ j = t ? JSON.parse(t) : null;
119
+ }
120
+ catch {
121
+ j = t;
122
+ }
123
+ if (!r.ok)
124
+ throw new CrawlCheckError(r.status, u.pathname, j);
125
+ return j;
126
+ }
127
+ }
128
+ export default CrawlCheck;
@@ -0,0 +1,27 @@
1
+ export function canon(v: any): any;
2
+ export function merkleRootHex(hexLeaves: any): Promise<any>;
3
+ export function otsInspect(u8: any): Promise<{
4
+ digest: string;
5
+ attestations: any[];
6
+ }>;
7
+ export function verifyBundle(b: any): Promise<{
8
+ verified: boolean;
9
+ summary: string;
10
+ report: any;
11
+ checks: any[];
12
+ experiences: any;
13
+ note: string;
14
+ }>;
15
+ export function verifyReceipt(rc: any): Promise<{
16
+ verified: boolean;
17
+ summary: string;
18
+ receipt: {
19
+ id: any;
20
+ domain: any;
21
+ verdict: any;
22
+ before: any;
23
+ after: any;
24
+ };
25
+ checks: any[];
26
+ note: string;
27
+ }>;
package/dist/verify.js ADDED
@@ -0,0 +1,345 @@
1
+ // crawlcheck-verify.mjs — verifies a CrawlCheck evidence bundle with no call to crawlcheck.io.
2
+ // Zero dependencies. Node 20+: node crawlcheck-verify.mjs bundle.json
3
+ // Browser or Node module: import { verifyBundle } from "./crawlcheck-verify.mjs"
4
+ //
5
+ // Also verifies a remediation receipt (node crawlcheck-verify.mjs receipt.json): receipt_hash, key_id, signature, order.
6
+ // What it checks, each from the bytes inside the bundle:
7
+ // manifest_hash sha256(manifest.body) equals manifest.sha256
8
+ // key_id the RFC 7638 thumbprint of the public key equals the signature's key id
9
+ // signature Ed25519 over "crawlcheck-meg-manifest-v1\n" + manifest sha256
10
+ // subject_hash sha256(record.subject) equals the record digest (the leaf)
11
+ // subject_binds the subject names this report, and its meg field names this manifest
12
+ // merkle_path the leaf folds along the path to the day's root
13
+ // ots_commits_root the .ots file timestamps exactly that root
14
+ // ots_attestations the op chain evaluated: pending calendars, or the Bitcoin block and the merkle root it commits to
15
+ // experience_root (manifest v3) the manifest's sealed root over its fetches recomputes from the fetches it lists
16
+ // decision_root (manifest v3) the sealed root over the decisions recomputes from the decision leaves (licence bundles)
17
+ // lineage_root (manifest v3) the sealed root over the extracted business facts recomputes from their leaves (licence bundles)
18
+ // A check that cannot run (a withheld subject, an unsealed day) is null with the reason, never a pass.
19
+ // What it cannot do offline: read a Bitcoin block header. It prints the block height and the merkle root the
20
+ // proof commits to; compare that value with any Bitcoin node or block explorer.
21
+ const MEG_SIG_PREFIX = "crawlcheck-meg-manifest-v1\n";
22
+ const OTS_MAGIC = [0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94];
23
+ const TAG_BITCOIN = "0588960d73d71901", TAG_PENDING = "83dfe30d2ef90c8e", TAG_LITECOIN = "06869a0d73d71b45";
24
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
25
+ const enc = new TextEncoder();
26
+ const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
27
+ const unhex = (h) => { const s = String(h || ""); const o = new Uint8Array(s.length >> 1); for (let i = 0; i < o.length; i++)
28
+ o[i] = parseInt(s.substr(i * 2, 2), 16); return o; };
29
+ const sha256 = async (u8) => new Uint8Array(await subtle.digest("SHA-256", u8));
30
+ const b64 = (s) => { const t = String(s || "").replace(/-/g, "+").replace(/_/g, "/"); const p = t.padEnd(Math.ceil(t.length / 4) * 4, "="); const bin = atob(p); const o = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++)
31
+ o[i] = bin.charCodeAt(i); return o; };
32
+ const b64u = (u8) => { let s = ""; for (const b of u8)
33
+ s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); };
34
+ const cat = (a, b) => { const o = new Uint8Array(a.length + b.length); o.set(a, 0); o.set(b, a.length); return o; };
35
+ // The same canonical JSON the observer hashes: keys sorted at every level, no whitespace, undefined as null.
36
+ export function canon(v) {
37
+ if (v === null || typeof v !== "object")
38
+ return JSON.stringify(v === undefined ? null : v);
39
+ if (Array.isArray(v))
40
+ return "[" + v.map(canon).join(",") + "]";
41
+ return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}";
42
+ }
43
+ // The same Merkle tree the daily seal uses: leaves sorted as hex, parent = SHA-256(left || right), odd node paired with itself.
44
+ export async function merkleRootHex(hexLeaves) {
45
+ if (!hexLeaves.length)
46
+ return "";
47
+ let level = hexLeaves.slice().sort();
48
+ while (level.length > 1) {
49
+ const next = [];
50
+ for (let i = 0; i < level.length; i += 2)
51
+ next.push(hex(await sha256(cat(unhex(level[i]), unhex(level[i + 1] === undefined ? level[i] : level[i + 1])))));
52
+ level = next;
53
+ }
54
+ return level[0];
55
+ }
56
+ const leafHex = async (o) => hex(await sha256(enc.encode(canon(o))));
57
+ // ── OpenTimestamps: parse the detached proof and evaluate every branch ─────────
58
+ function reader(u8) {
59
+ let i = 0;
60
+ return {
61
+ left: () => u8.length - i,
62
+ byte() { if (i >= u8.length)
63
+ throw new Error("ots: truncated"); return u8[i++]; },
64
+ bytes(n) { if (i + n > u8.length)
65
+ throw new Error("ots: truncated"); const o = u8.slice(i, i + n); i += n; return o; },
66
+ varuint() { let v = 0, s = 0, b; do {
67
+ b = this.byte();
68
+ v += (b & 0x7f) * Math.pow(2, s);
69
+ s += 7;
70
+ } while (b & 0x80); return v; },
71
+ varbytes() { return this.bytes(this.varuint()); }
72
+ };
73
+ }
74
+ async function otsBranch(rd, msg, out, depth) {
75
+ if (depth > 256)
76
+ throw new Error("ots: too deep");
77
+ const one = async (tag) => {
78
+ if (tag === 0x00) {
79
+ const t = hex(rd.bytes(8)), payload = rd.varbytes();
80
+ if (t === TAG_BITCOIN) {
81
+ const pr = reader(payload);
82
+ out.push({ kind: "bitcoin", height: pr.varuint(), commits_to_le: hex(msg), merkle_root: hex(msg.slice().reverse()) });
83
+ }
84
+ else if (t === TAG_PENDING) {
85
+ const pr = reader(payload);
86
+ out.push({ kind: "pending", calendar: new TextDecoder().decode(pr.varbytes()) });
87
+ }
88
+ else if (t === TAG_LITECOIN) {
89
+ const pr = reader(payload);
90
+ out.push({ kind: "litecoin", height: pr.varuint() });
91
+ }
92
+ else
93
+ out.push({ kind: "unknown", tag: t });
94
+ return;
95
+ }
96
+ let next;
97
+ if (tag === 0x08)
98
+ next = await sha256(msg);
99
+ else if (tag === 0xf0)
100
+ next = cat(msg, rd.varbytes());
101
+ else if (tag === 0xf1)
102
+ next = cat(rd.varbytes(), msg);
103
+ else if (tag === 0xf2)
104
+ next = msg.slice().reverse();
105
+ else if (tag === 0xf3)
106
+ next = enc.encode(hex(msg));
107
+ else
108
+ throw new Error("ots: op 0x" + tag.toString(16) + " is not supported by this verifier");
109
+ await otsBranch(rd, next, out, depth + 1);
110
+ };
111
+ let tag = rd.byte();
112
+ while (tag === 0xff) {
113
+ await one(rd.byte());
114
+ tag = rd.byte();
115
+ }
116
+ await one(tag);
117
+ }
118
+ export async function otsInspect(u8) {
119
+ const rd = reader(u8);
120
+ const magic = rd.bytes(OTS_MAGIC.length);
121
+ if (!OTS_MAGIC.every((b, i) => magic[i] === b))
122
+ throw new Error("not an OpenTimestamps proof (magic bytes differ)");
123
+ const version = rd.varuint();
124
+ if (version !== 1)
125
+ throw new Error("ots: unsupported version " + version);
126
+ const op = rd.byte();
127
+ if (op !== 0x08)
128
+ throw new Error("ots: file hash is not sha256");
129
+ const digest = rd.bytes(32);
130
+ const attestations = [];
131
+ await otsBranch(rd, digest, attestations, 0);
132
+ return { digest: hex(digest), attestations };
133
+ }
134
+ // ── the bundle ────────────────────────────────────────────────────────────────
135
+ export async function verifyBundle(b) {
136
+ if (!subtle)
137
+ throw new Error("this runtime has no WebCrypto (crypto.subtle)");
138
+ const checks = [];
139
+ const C = (id, ok, why, extra) => checks.push(Object.assign({ id, ok, why }, extra || {}));
140
+ if (!b || b.kind !== "crawlcheck-evidence-bundle")
141
+ throw new Error("not a CrawlCheck evidence bundle");
142
+ const rep = b.report || {}, man = b.manifest || null, rec = b.record || {}, seal = b.seal || {};
143
+ // 1. manifest bytes
144
+ let manObj = null;
145
+ if (!man)
146
+ C("manifest_hash", null, "this record predates evidence manifests (2026-09-20)");
147
+ else if (!man.body)
148
+ C("manifest_hash", null, man.body_missing || "manifest body not included");
149
+ else {
150
+ const h = hex(await sha256(enc.encode(man.body)));
151
+ C("manifest_hash", h === man.sha256, h === man.sha256 ? "manifest body hashes to " + h.slice(0, 16) + "…" : "manifest body hashes to " + h + ", bundle says " + man.sha256);
152
+ try {
153
+ manObj = JSON.parse(man.body);
154
+ }
155
+ catch (e) {
156
+ manObj = null;
157
+ }
158
+ }
159
+ // 2. key id + signature
160
+ const sg = man && man.signature, key = man && man.key;
161
+ if (!sg)
162
+ C("key_id", null, "the manifest is unsigned (recorded before signing began, 2026-09-23)"), C("signature", null, "no signature to check");
163
+ else if (!key || !key.jwk)
164
+ C("key_id", null, "the signing key is not in the bundle (retired or unknown); look it up by id " + sg.kid), C("signature", null, "no key to check against");
165
+ else {
166
+ const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: key.jwk.crv, kty: key.jwk.kty, x: key.jwk.x }))));
167
+ C("key_id", thumb === sg.kid, thumb === sg.kid ? "public key thumbprint (RFC 7638) is the key id " + sg.kid.slice(0, 12) + "…; the same id is published in " + (key.published_in || "the key directory") : "thumbprint " + thumb + " does not match key id " + sg.kid);
168
+ const msg = MEG_SIG_PREFIX + man.sha256;
169
+ if (sg.message && sg.message !== msg)
170
+ C("signature", false, "the signed message does not name this manifest");
171
+ else {
172
+ let ok = false;
173
+ try {
174
+ const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: key.jwk.x }, { name: "Ed25519" }, false, ["verify"]);
175
+ ok = await subtle.verify({ name: "Ed25519" }, pk, b64(sg.sig), enc.encode(msg));
176
+ }
177
+ catch (e) {
178
+ C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
179
+ ok = null;
180
+ }
181
+ if (ok !== null)
182
+ C("signature", ok, ok ? "Ed25519 signature over the manifest digest verifies" : "signature does NOT verify");
183
+ }
184
+ }
185
+ // 3. subject
186
+ let subj = null;
187
+ if (!rec.digest)
188
+ C("subject_hash", null, "no record digest in the bundle");
189
+ else if (!rec.subject)
190
+ C("subject_hash", null, rec.subject_withheld || "subject not included");
191
+ else {
192
+ const h = hex(await sha256(enc.encode(rec.subject)));
193
+ C("subject_hash", h === rec.digest, h === rec.digest ? "the sealed subject hashes to the record digest" : "subject hashes to " + h + ", digest is " + rec.digest);
194
+ try {
195
+ subj = JSON.parse(rec.subject);
196
+ }
197
+ catch (e) {
198
+ subj = null;
199
+ }
200
+ }
201
+ if (!subj)
202
+ C("subject_binds", null, "needs the subject");
203
+ else {
204
+ const idOk = !rep.id || subj.id === rep.id, domOk = !rep.domain || subj.domain === rep.domain;
205
+ const mOk = man ? subj.meg === man.sha256 : !("meg" in subj);
206
+ C("subject_binds", idOk && domOk && mOk, (idOk ? "names report " + subj.id : "names report " + subj.id + ", bundle says " + rep.id) + (domOk ? ", domain " + subj.domain : ", domain mismatch") +
207
+ (man ? (mOk ? ", and commits to this manifest" : ", but its meg field " + subj.meg + " is not this manifest") : ""));
208
+ }
209
+ // 4. merkle path
210
+ if (seal.state !== "sealed")
211
+ C("merkle_path", null, "the day is not sealed: " + (seal.why || seal.state || "unknown"));
212
+ else {
213
+ let h = unhex(rec.digest);
214
+ for (const s of seal.path || [])
215
+ h = await sha256(s.side === "right" ? cat(h, unhex(s.hash)) : cat(unhex(s.hash), h));
216
+ const got = hex(h);
217
+ C("merkle_path", got === seal.root, got === seal.root ? "the digest folds to the root of " + seal.day + " (" + (seal.path || []).length + " steps, " + (seal.leaves_that_day || "?") + " leaves that day)" : "the path folds to " + got + ", not the root " + seal.root);
218
+ }
219
+ // 5. timestamp
220
+ let att = [];
221
+ if (seal.state !== "sealed")
222
+ C("ots_commits_root", null, "no seal yet"), C("ots_attestations", null, "no seal yet");
223
+ else if (!seal.ots_base64)
224
+ C("ots_commits_root", null, "the day's .ots bytes are not in the bundle"), C("ots_attestations", null, "no .ots");
225
+ else {
226
+ try {
227
+ const o = await otsInspect(b64(seal.ots_base64));
228
+ att = o.attestations;
229
+ C("ots_commits_root", o.digest === seal.root, o.digest === seal.root ? "the timestamp proof is over exactly this root" : "the .ots timestamps " + o.digest + ", not the root " + seal.root);
230
+ const btc = att.filter((a) => a.kind === "bitcoin"), pend = att.filter((a) => a.kind === "pending");
231
+ if (btc.length)
232
+ C("ots_attestations", true, "anchored in Bitcoin block " + Array.from(new Set(btc.map((a) => a.height))).join(", ") + "; the proof commits the root into merkle root " + btc[0].merkle_root + " - compare it with that block's header on any Bitcoin node", { bitcoin: btc });
233
+ else if (pend.length)
234
+ C("ots_attestations", null, "submitted to " + pend.map((a) => a.calendar).join(", ") + ", not yet confirmed in a Bitcoin block (usually within a few hours); download the bundle again later", { pending: pend });
235
+ else
236
+ C("ots_attestations", false, "the proof carries no attestation");
237
+ }
238
+ catch (e) {
239
+ C("ots_commits_root", false, String(e && e.message || e));
240
+ C("ots_attestations", null, "could not parse the proof");
241
+ }
242
+ }
243
+ // 6. sealed roots (manifest v3)
244
+ const roots = manObj && manObj.roots;
245
+ const rootCheck = async (id, label, sealed, objs, missingWhy) => {
246
+ if (!roots)
247
+ return C(id, null, "the manifest predates sealed roots (manifest v3, 2026-09-30)");
248
+ if (!sealed)
249
+ return C(id, null, "the manifest carries no " + label + " root");
250
+ if (!Array.isArray(objs))
251
+ return C(id, null, missingWhy);
252
+ const got = await merkleRootHex(await Promise.all(objs.map(leafHex)));
253
+ const want = sealed.root || "";
254
+ C(id, got === want && objs.length === sealed.n, got === want && objs.length === sealed.n ? objs.length + " " + label + " leaves recompute to the sealed root" : "the " + label + " leaves give " + (got || "(empty)").slice(0, 16) + "… over " + objs.length + ", the manifest sealed " + want.slice(0, 16) + "… over " + sealed.n);
255
+ };
256
+ await rootCheck("experience_root", "fetch", roots && roots.experiences, manObj && Array.isArray(manObj.experiences) ? manObj.experiences : null, "the manifest body is not in the bundle");
257
+ await rootCheck("decision_root", "decision", roots && roots.decisions, rec.decision_leaves, rec.leaves_withheld || "decision leaves not included");
258
+ await rootCheck("lineage_root", "lineage", roots && roots.lineage, rec.lineage_leaves, rec.leaves_withheld || "lineage leaves not included");
259
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
260
+ return {
261
+ verified: ran.length > 0 && failed.length === 0,
262
+ summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed; " + (checks.length - ran.length) + " could not run (reasons given)",
263
+ report: rep, checks,
264
+ experiences: manObj && Array.isArray(manObj.experiences) ? manObj.experiences.length : null,
265
+ note: "Every check above used only bytes inside the bundle. Only the Bitcoin block header has to come from outside, and from any source you trust."
266
+ };
267
+ }
268
+ // ── remediation receipts ─────────────────────────────────────────────────────────────────────────────────
269
+ // A receipt joins the record before a fix, the declared fix, when it went live, the record after it and the
270
+ // verification verdict, and is signed with the observer's Ed25519 key over sha256(canonical JSON of everything but
271
+ // receipt_id and signature). Change any field and receipt_hash fails; swap the key and key_id fails.
272
+ const RCPT_SIG_PREFIX = "crawlcheck-receipt-v1\n";
273
+ export async function verifyReceipt(rc) {
274
+ if (!subtle)
275
+ throw new Error("this runtime has no WebCrypto (crypto.subtle)");
276
+ const checks = [];
277
+ const C = (id, ok, why) => checks.push({ id, ok, why });
278
+ if (!rc || rc.kind !== "crawlcheck-remediation-receipt" || !rc.signature)
279
+ throw new Error("not a CrawlCheck remediation receipt");
280
+ const body = {};
281
+ for (const k of Object.keys(rc))
282
+ if (k !== "receipt_id" && k !== "signature")
283
+ body[k] = rc[k];
284
+ const sha = hex(await sha256(enc.encode(canon(body))));
285
+ C("receipt_hash", sha === rc.signature.receipt_sha256 && rc.receipt_id === "rc1:" + sha, sha === rc.signature.receipt_sha256 ? "the receipt hashes to its id " + sha.slice(0, 16) + "…" : "the receipt hashes to " + sha + ", it claims " + rc.signature.receipt_sha256 + " - a field was changed");
286
+ const jwk = rc.issuer && rc.issuer.key && rc.issuer.key.jwk;
287
+ if (!jwk || !jwk.x) {
288
+ C("key_id", null, "no key in the receipt");
289
+ C("signature", null, "no key to check against");
290
+ }
291
+ else {
292
+ const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }))));
293
+ C("key_id", thumb === rc.signature.kid, thumb === rc.signature.kid ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…; the same id is published in " + (rc.issuer.key.published_in || "the key directory") : "thumbprint " + thumb + " does not match key id " + rc.signature.kid);
294
+ const msg = RCPT_SIG_PREFIX + sha;
295
+ let ok = null;
296
+ try {
297
+ const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
298
+ ok = rc.signature.message === msg && await subtle.verify({ name: "Ed25519" }, pk, b64(rc.signature.sig), enc.encode(msg));
299
+ }
300
+ catch (e) {
301
+ ok = null;
302
+ C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
303
+ }
304
+ if (ok !== null)
305
+ C("signature", ok, ok ? "Ed25519 signature over the receipt digest verifies" : "signature does NOT verify over this content");
306
+ }
307
+ C("key_published", null, "offline this cannot be decided: compare key id " + (rc.signature.kid || "?") + " with the ids published at " + ((rc.issuer && rc.issuer.key && rc.issuer.key.published_in) || "the key directory") + " (a receipt re-signed with any other key passes every other check here)");
308
+ const b = rc.before || {}, a = rc.after || {};
309
+ C("order", !!(b.scanned_at && a.scanned_at && Date.parse(b.scanned_at) < Date.parse(a.scanned_at)), b.scanned_at && a.scanned_at ? "before " + b.scanned_at + " precedes after " + a.scanned_at : "a scan time is missing");
310
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
311
+ return { verified: ran.length > 0 && failed.length === 0, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
312
+ receipt: { id: rc.receipt_id, domain: rc.subject && rc.subject.domain, verdict: rc.verification && rc.verification.verdict, before: b.report_id, after: a.report_id }, checks,
313
+ note: "Every check used only the receipt itself. To confirm the key is CrawlCheck's, compare its id with " + ((rc.issuer && rc.issuer.key && rc.issuer.key.published_in) || "the published key directory") + "; to see the evidence behind each side, verify the two reports' bundles." };
314
+ }
315
+ // ── command line ─────────────────────────────────────────────────────────────
316
+ // Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
317
+ // stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
318
+ const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
319
+ const { pathToFileURL } = await import("node:url");
320
+ const { realpathSync } = await import("node:fs");
321
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
322
+ }
323
+ catch (e) {
324
+ return false;
325
+ } })();
326
+ if (isMain) {
327
+ const f = process.argv[2];
328
+ if (!f) {
329
+ console.log("usage: node crawlcheck-verify.mjs <bundle.json>");
330
+ process.exit(2);
331
+ }
332
+ const { readFile } = await import("node:fs/promises");
333
+ const doc = JSON.parse(await readFile(f, "utf8"));
334
+ const isRc = doc && (doc.kind === "crawlcheck-remediation-receipt" || (doc.receipt && doc.receipt.kind === "crawlcheck-remediation-receipt"));
335
+ const res = isRc ? await verifyReceipt(doc.kind ? doc : doc.receipt) : await verifyBundle(doc);
336
+ const mark = (ok) => ok === true ? "PASS" : ok === false ? "FAIL" : " -- ";
337
+ if (isRc)
338
+ console.log("CrawlCheck remediation receipt: " + (res.receipt.id || "?") + " " + (res.receipt.domain || "") + " verdict " + (res.receipt.verdict || "?"));
339
+ else
340
+ console.log("CrawlCheck evidence bundle: " + (res.report.id || "?") + " " + (res.report.domain || "") + " scanned " + (res.report.scanned_at || "?"));
341
+ for (const c of res.checks)
342
+ console.log(mark(c.ok) + " " + c.id.padEnd(18) + c.why);
343
+ console.log(res.summary);
344
+ process.exit(res.checks.some((c) => c.ok === false) ? 1 : 0);
345
+ }