@crawlcheck/sdk 1.0.1 → 1.0.3

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.
package/dist/index.d.ts CHANGED
@@ -7,7 +7,17 @@ export interface VerifyCheck {
7
7
  why: string;
8
8
  [k: string]: unknown;
9
9
  }
10
- export interface BundleVerification {
10
+ /**
11
+ * Three separate answers. integrity_valid: hashes and the signature agree with the document. issuer_trusted: the
12
+ * signing key is one CrawlCheck publishes (null offline: not decided). accepted: both - the only field to act on.
13
+ * A document someone else signed with their own key is verified: true offline and accepted: false.
14
+ */
15
+ export interface Trust {
16
+ integrity_valid: boolean;
17
+ issuer_trusted: boolean | null;
18
+ accepted: boolean;
19
+ }
20
+ export interface BundleVerification extends Partial<Trust> {
11
21
  verified: boolean;
12
22
  summary: string;
13
23
  report: {
@@ -19,7 +29,7 @@ export interface BundleVerification {
19
29
  experiences: number | null;
20
30
  note: string;
21
31
  }
22
- export interface ReceiptVerification {
32
+ export interface ReceiptVerification extends Partial<Trust> {
23
33
  verified: boolean;
24
34
  summary: string;
25
35
  receipt: {
@@ -36,6 +46,32 @@ export interface ReceiptVerification {
36
46
  export declare const verifyBundle: (b: unknown) => Promise<BundleVerification>;
37
47
  /** Verify a remediation receipt with no call to CrawlCheck. */
38
48
  export declare const verifyReceipt: (rc: unknown) => Promise<ReceiptVerification>;
49
+ export interface DocumentVerification extends Partial<Trust> {
50
+ verified: boolean;
51
+ summary: string;
52
+ document: {
53
+ kind: string;
54
+ domain?: string;
55
+ sha256: string;
56
+ };
57
+ checks: VerifyCheck[];
58
+ }
59
+ /** Verify a signed resolve answer, preflight decision receipt, data inventory, export part or deletion record offline. */
60
+ export declare const verifyDocument: (doc: unknown) => Promise<DocumentVerification & Trust>;
61
+ /** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
62
+ export declare const verifyExport: (x: unknown) => Promise<{
63
+ verified: boolean;
64
+ summary: string;
65
+ checks: VerifyCheck[];
66
+ [k: string]: unknown;
67
+ }>;
68
+ /** Verify a signed dispute resolution offline (re-runs the published decision table over the recorded readings). */
69
+ export declare const verifyResolution: (x: unknown) => Promise<{
70
+ verified: boolean;
71
+ summary: string;
72
+ checks: VerifyCheck[];
73
+ [k: string]: unknown;
74
+ }>;
39
75
  export declare const canon: (v: unknown) => string;
40
76
  export declare const merkleRootHex: (hexLeaves: string[]) => Promise<string>;
41
77
  export declare const otsInspect: (u8: Uint8Array) => Promise<{
@@ -78,6 +114,48 @@ export type BodyOf<P extends keyof paths> = Op<P, "post"> extends {
78
114
  };
79
115
  };
80
116
  } ? B : JsonObject;
117
+ export type Action = "read" | "cite" | "connect" | "transact" | "administer";
118
+ export type Decision = "allow" | "warn" | "require_confirmation" | "block" | "unsupported";
119
+ export interface PreflightPolicy {
120
+ max_age_hours?: number;
121
+ on_warn?: "warn" | "require_confirmation" | "block";
122
+ require_entity?: boolean;
123
+ human_approval?: Action[];
124
+ allow?: Action[];
125
+ deny?: Action[];
126
+ [k: string]: unknown;
127
+ }
128
+ export interface PreflightOptions {
129
+ agent?: string;
130
+ template?: string;
131
+ policy?: PreflightPolicy;
132
+ }
133
+ /** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
134
+ export interface DecisionReceipt {
135
+ kind: "crawlcheck-decision";
136
+ domain: string;
137
+ action: Action;
138
+ decision: Decision;
139
+ sha256: string;
140
+ signature: {
141
+ kid: string;
142
+ [k: string]: unknown;
143
+ };
144
+ resolve?: {
145
+ sha256: string;
146
+ kid: string;
147
+ };
148
+ alternatives?: JsonObject[];
149
+ [k: string]: unknown;
150
+ }
151
+ export interface GuardOptions extends PreflightOptions {
152
+ /** Proceed on "warn" too. Default false: warn does not proceed. */
153
+ allowWarn?: boolean;
154
+ /** Called on "require_confirmation"; proceed only if it resolves true. Without it, require_confirmation does not proceed. */
155
+ confirm?: (receipt: DecisionReceipt) => boolean | Promise<boolean>;
156
+ /** Also require the signing key to be in the published key directory (default true). */
157
+ onlineKeys?: boolean;
158
+ }
81
159
  export interface ClientOptions {
82
160
  /** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
83
161
  key?: string;
@@ -113,6 +191,29 @@ export declare class CrawlCheck {
113
191
  get<P extends keyof paths>(path: P, query?: QueryOf<P, "get">): Promise<ResponseOf<P, "get">>;
114
192
  /** Any documented POST, typed from the OpenAPI document. */
115
193
  post<P extends keyof paths>(path: P, body: BodyOf<P> | JsonObject, query?: QueryOf<P, "post">): Promise<ResponseOf<P, "post">>;
194
+ /** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
195
+ resolve(domain: string): Promise<JsonObject>;
196
+ /** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
197
+ resolveBatch(domains: string[]): Promise<JsonObject>;
198
+ /** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
199
+ preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
200
+ /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
201
+ verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
202
+ /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
203
+ guard(domain: string, action?: Action, opts?: GuardOptions): Promise<{
204
+ proceed: boolean;
205
+ receipt: DecisionReceipt;
206
+ verification: DocumentVerification & Trust;
207
+ }>;
208
+ /** Signed change feed (what changed since a time, optionally for one domain). */
209
+ changes(q?: QueryOf<"/api/v1/changes">): Promise<JsonObject>;
210
+ /** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
211
+ outcome(body: JsonObject & {
212
+ domain: string;
213
+ action: Action;
214
+ }): Promise<JsonObject>;
215
+ /** The registry record (RDAP) and lifecycle of a domain. */
216
+ domain(domain: string): Promise<JsonObject>;
116
217
  /** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
117
218
  machineRecord(q: QueryOf<"/api/v1/machine-record">): Promise<MachineRecordV1>;
118
219
  /** Scan a domain now (free lane without a key; the licensed lane with one). */
@@ -128,11 +229,11 @@ export declare class CrawlCheck {
128
229
  /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
129
230
  bundle(id: string): Promise<EvidenceBundleV1>;
130
231
  /** 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>;
232
+ verifyOffline(id: string): Promise<BundleVerification & Trust>;
132
233
  /** A signed remediation receipt by its id (rc1:…). */
133
234
  receipt(id: string): Promise<RemediationReceiptV1>;
134
235
  /** 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>;
236
+ verifyReceiptOffline(id: string): Promise<ReceiptVerification & Trust>;
136
237
  /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
137
238
  publishedKeyIds(): Promise<string[]>;
138
239
  receipts(domain: string): Promise<JsonObject>;
package/dist/index.js CHANGED
@@ -1,8 +1,28 @@
1
1
  import * as V from "./verify.js";
2
+ function withTrust(v, kid, published) {
3
+ let checks = v.checks;
4
+ let issuer = null;
5
+ if (published) {
6
+ issuer = !!kid && published.includes(kid);
7
+ const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : "key id " + (kid || "?") + " is NOT in the published key directory - reject this document" };
8
+ checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
9
+ }
10
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
11
+ const verified = ran.length > 0 && failed.length === 0;
12
+ const integrity_valid = checks.filter((c) => c.id !== "key_published").every((c) => c.ok !== false);
13
+ return Object.assign({}, v, { checks, verified, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
14
+ integrity_valid, issuer_trusted: issuer, accepted: verified && issuer === true });
15
+ }
2
16
  /** Verify an evidence bundle with no call to CrawlCheck (the same code as /crawlcheck-verify.mjs). */
3
17
  export const verifyBundle = V.verifyBundle;
4
18
  /** Verify a remediation receipt with no call to CrawlCheck. */
5
19
  export const verifyReceipt = V.verifyReceipt;
20
+ /** Verify a signed resolve answer, preflight decision receipt, data inventory, export part or deletion record offline. */
21
+ export const verifyDocument = async (doc) => withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, null);
22
+ /** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
23
+ export const verifyExport = V.verifyExport;
24
+ /** Verify a signed dispute resolution offline (re-runs the published decision table over the recorded readings). */
25
+ export const verifyResolution = V.verifyResolution;
6
26
  export const canon = V.canon;
7
27
  export const merkleRootHex = V.merkleRootHex;
8
28
  export const otsInspect = V.otsInspect;
@@ -43,6 +63,46 @@ export class CrawlCheck {
43
63
  async post(path, body, query) {
44
64
  return this.req("POST", path, query, body);
45
65
  }
66
+ // ── read before acting ──
67
+ /** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
68
+ resolve(domain) { return this.get("/api/v1/resolve", { domain }); }
69
+ /** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
70
+ resolveBatch(domains) { return this.post("/api/v1/resolve", { domains }); }
71
+ /** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
72
+ async preflight(domain, action = "read", opts = {}) {
73
+ const b = { domain, action };
74
+ if (opts.agent)
75
+ b.agent = opts.agent;
76
+ if (opts.template)
77
+ b.template = opts.template;
78
+ if (opts.policy)
79
+ b.policy = opts.policy;
80
+ return this.post("/api/v1/preflight", b);
81
+ }
82
+ /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
83
+ async verifyDocument(doc, onlineKeys = true) {
84
+ return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
85
+ }
86
+ /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
87
+ async guard(domain, action = "read", opts = {}) {
88
+ const receipt = await this.preflight(domain, action, opts);
89
+ const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
90
+ if (!verification.accepted && (opts.onlineKeys ?? true))
91
+ return { proceed: false, receipt, verification };
92
+ if (!verification.verified)
93
+ return { proceed: false, receipt, verification };
94
+ const d = receipt.decision;
95
+ let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
96
+ if (d === "require_confirmation" && opts.confirm)
97
+ proceed = !!(await opts.confirm(receipt));
98
+ return { proceed, receipt, verification };
99
+ }
100
+ /** Signed change feed (what changed since a time, optionally for one domain). */
101
+ changes(q = {}) { return this.get("/api/v1/changes", q); }
102
+ /** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
103
+ outcome(body) { return this.post("/api/v1/outcome", body); }
104
+ /** The registry record (RDAP) and lifecycle of a domain. */
105
+ domain(domain) { return this.get("/api/v1/domain", { domain }); }
46
106
  // ── the records ──
47
107
  /** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
48
108
  machineRecord(q) { return this.get("/api/v1/machine-record", q); }
@@ -61,11 +121,11 @@ export class CrawlCheck {
61
121
  /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
62
122
  bundle(id) { return this.get("/api/bundle", { id, download: "0" }); }
63
123
  /** 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)); }
124
+ async verifyOffline(id) { const b = await this.bundle(id); return withTrust(await verifyBundle(b), b?.manifest?.signature?.kid, await this.publishedKeyIds()); }
65
125
  /** A signed remediation receipt by its id (rc1:…). */
66
126
  async receipt(id) { const r = await this.get("/api/receipt", { id }); return r.receipt; }
67
127
  /** 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)); }
128
+ async verifyReceiptOffline(id) { const rc = await this.receipt(id); return withTrust(await verifyReceipt(rc), rc?.signature?.kid, await this.publishedKeyIds()); }
69
129
  /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
70
130
  async publishedKeyIds() {
71
131
  const d = await this.req("GET", "/.well-known/http-message-signatures-directory");
package/dist/verify.d.ts CHANGED
@@ -25,3 +25,67 @@ export function verifyReceipt(rc: any): Promise<{
25
25
  checks: any[];
26
26
  note: string;
27
27
  }>;
28
+ export function verifyExport(x: any): Promise<{
29
+ verified: boolean;
30
+ summary: string;
31
+ export: {
32
+ domain: any;
33
+ window: any;
34
+ records: number;
35
+ receipts: number;
36
+ generated_at: any;
37
+ };
38
+ checks: any[];
39
+ records: ({
40
+ id: any;
41
+ verified: null;
42
+ failed: never[];
43
+ why: any;
44
+ scanned_at?: undefined;
45
+ ran?: undefined;
46
+ } | {
47
+ id: any;
48
+ scanned_at: any;
49
+ verified: boolean | null;
50
+ failed: any[];
51
+ ran: number;
52
+ why: string | null;
53
+ })[];
54
+ receipts: {
55
+ id: any;
56
+ verified: boolean | null;
57
+ failed: any[];
58
+ why: string | null;
59
+ }[];
60
+ note: string;
61
+ }>;
62
+ export function dspVerdict(m: any): {
63
+ verdict: string;
64
+ effect: string;
65
+ rule: string;
66
+ };
67
+ export function verifyResolution(x: any): Promise<{
68
+ verified: boolean;
69
+ summary: string;
70
+ resolution: {
71
+ id: any;
72
+ dispute: any;
73
+ domain: any;
74
+ code: any;
75
+ verdict: any;
76
+ effect: any;
77
+ observers: any;
78
+ };
79
+ checks: any[];
80
+ note: string;
81
+ }>;
82
+ export function verifyDataDoc(x: any): Promise<{
83
+ verified: boolean;
84
+ summary: string;
85
+ document: {
86
+ kind: any;
87
+ domain: any;
88
+ sha256: any;
89
+ };
90
+ checks: any[];
91
+ }>;
package/dist/verify.js CHANGED
@@ -2,7 +2,11 @@
2
2
  // Zero dependencies. Node 20+: node crawlcheck-verify.mjs bundle.json
3
3
  // Browser or Node module: import { verifyBundle } from "./crawlcheck-verify.mjs"
4
4
  //
5
- // Also verifies a remediation receipt (node crawlcheck-verify.mjs receipt.json): receipt_hash, key_id, signature, order.
5
+ // Also verifies a remediation receipt (node crawlcheck-verify.mjs receipt.json): receipt_hash, key_id, signature, order;
6
+ // and an evidence export (node crawlcheck-verify.mjs export.json): export_hash, key_id, signature, then every bundle and receipt inside it.
7
+ // And a dispute resolution (node crawlcheck-verify.mjs resolution.json): resolution_hash, key_id, signature,
8
+ // verdict_derivable (re-runs the published decision table over the recorded readings), readings_after_dispute.
9
+ // And a data inventory, export part or deletion record (node crawlcheck-verify.mjs deletion.json): document_hash, key_id, signature.
6
10
  // What it checks, each from the bytes inside the bundle:
7
11
  // manifest_hash sha256(manifest.body) equals manifest.sha256
8
12
  // key_id the RFC 7638 thumbprint of the public key equals the signature's key id
@@ -312,6 +316,228 @@ export async function verifyReceipt(rc) {
312
316
  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
317
  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
318
  }
319
+ // ── evidence exports ─────────────────────────────────────────────────────────────────────────────────────
320
+ // An export is one signed file for a domain and a period: records[] each carrying a full evidence bundle,
321
+ // receipts[], the certificate days and the seals. The file is signed over sha256(canonical JSON of everything but
322
+ // export_sha256 and signature); then every bundle and every receipt inside it is verified exactly as it would be on
323
+ // its own. One altered byte anywhere fails export_hash; one altered bundle fails its own checks.
324
+ const EXPORT_SIG_PREFIX = "crawlcheck-evidence-export-v1\n";
325
+ export async function verifyExport(x) {
326
+ if (!subtle)
327
+ throw new Error("this runtime has no WebCrypto (crypto.subtle)");
328
+ const checks = [];
329
+ const C = (id, ok, why) => checks.push({ id, ok, why });
330
+ if (!x || x.kind !== "crawlcheck-evidence-export")
331
+ throw new Error("not a CrawlCheck evidence export");
332
+ const body = {};
333
+ for (const k of Object.keys(x))
334
+ if (k !== "export_sha256" && k !== "signature")
335
+ body[k] = x[k];
336
+ const sha = hex(await sha256(enc.encode(canon(body))));
337
+ if (!x.signature) {
338
+ C("export_hash", sha === x.export_sha256, sha === x.export_sha256 ? "the file hashes to its stated digest " + sha.slice(0, 16) + "…" : "the file hashes to " + sha + ", it states " + x.export_sha256 + " - a field was changed");
339
+ C("signature", null, "the export is unsigned (the observer had no signing key when it was made)");
340
+ }
341
+ else {
342
+ C("export_hash", sha === x.export_sha256 && x.signature.export_sha256 === sha, sha === x.export_sha256 ? "the file hashes to its stated digest " + sha.slice(0, 16) + "…" : "the file hashes to " + sha + ", it states " + x.export_sha256 + " - a field was changed");
343
+ const jwk = x.signature.key && x.signature.key.jwk;
344
+ if (!jwk || !jwk.x) {
345
+ C("key_id", null, "no key in the export");
346
+ C("signature", null, "no key to check against");
347
+ }
348
+ else {
349
+ const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }))));
350
+ C("key_id", thumb === x.signature.kid, thumb === x.signature.kid ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…; the same id is published in " + (x.signature.key.published_in || "the key directory") : "thumbprint " + thumb + " does not match key id " + x.signature.kid);
351
+ const msg = EXPORT_SIG_PREFIX + sha;
352
+ let ok = null;
353
+ try {
354
+ const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
355
+ ok = x.signature.message === msg && await subtle.verify({ name: "Ed25519" }, pk, b64(x.signature.sig), enc.encode(msg));
356
+ }
357
+ catch (e) {
358
+ ok = null;
359
+ C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
360
+ }
361
+ if (ok !== null)
362
+ C("signature", ok, ok ? "Ed25519 signature over the export digest verifies" : "signature does NOT verify over this content");
363
+ }
364
+ C("key_published", null, "offline this cannot be decided: compare key id " + (x.signature.kid || "?") + " with the ids published at " + ((x.signature.key && x.signature.key.published_in) || "the key directory"));
365
+ }
366
+ const records = [];
367
+ for (const rec of (Array.isArray(x.records) ? x.records : [])) {
368
+ if (!rec || !rec.bundle) {
369
+ records.push({ id: rec && rec.id, verified: null, failed: [], why: (rec && rec.missing) || "no bundle" });
370
+ continue;
371
+ }
372
+ let v = null, why = null;
373
+ try {
374
+ v = await verifyBundle(rec.bundle);
375
+ }
376
+ catch (e) {
377
+ why = String(e && e.message || e);
378
+ }
379
+ const same = !!(v && rec.bundle.report && rec.bundle.report.id === rec.id && rec.bundle.report.domain === x.domain);
380
+ records.push({ id: rec.id, scanned_at: rec.scanned_at, verified: v ? (v.verified && same) : null, failed: v ? v.checks.filter((c) => c.ok === false).map((c) => c.id).concat(same ? [] : ["bundle_names_another_record"]) : [], ran: v ? v.checks.filter((c) => c.ok !== null).length : 0, why });
381
+ }
382
+ const receipts = [];
383
+ for (const rc of (Array.isArray(x.receipts) ? x.receipts : [])) {
384
+ let v = null, why = null;
385
+ try {
386
+ v = await verifyReceipt(rc);
387
+ }
388
+ catch (e) {
389
+ why = String(e && e.message || e);
390
+ }
391
+ receipts.push({ id: rc && rc.receipt_id, verified: v ? v.verified : null, failed: v ? v.checks.filter((c) => c.ok === false).map((c) => c.id) : [], why });
392
+ }
393
+ const rf = records.filter((r) => r.verified === false).length, cf = receipts.filter((r) => r.verified === false).length;
394
+ C("records", records.length ? rf === 0 : null, records.length ? (rf ? rf + " of " + records.length + " bundles FAILED" : records.length + " bundle" + (records.length === 1 ? "" : "s") + " inside the file verified on their own (" + records.filter((r) => r.verified === true).length + " fully, " + records.filter((r) => r.verified === null).length + " with checks that could not run)") : "the file carries no records");
395
+ C("receipts", receipts.length ? cf === 0 : null, receipts.length ? (cf ? cf + " of " + receipts.length + " receipts FAILED" : receipts.length + " receipt" + (receipts.length === 1 ? "" : "s") + " verified") : "the file carries no receipts");
396
+ const cnt = x.counts || {};
397
+ C("counts", (Array.isArray(x.records) ? x.records.length : 0) === ((cnt.records || 0) + (cnt.missing || 0)) && (Array.isArray(x.receipts) ? x.receipts.length : 0) === (cnt.receipts || 0), "the file's own counts match what it carries (" + (Array.isArray(x.records) ? x.records.length : 0) + " records, " + (Array.isArray(x.receipts) ? x.receipts.length : 0) + " receipts)");
398
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
399
+ 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",
400
+ export: { domain: x.domain, window: x.window, records: records.length, receipts: receipts.length, generated_at: x.generated_at }, checks, records, receipts,
401
+ note: "Every check used only the file itself. To confirm the key is CrawlCheck's, compare its id with " + ((x.signature && x.signature.key && x.signature.key.published_in) || "the published key directory") + "." };
402
+ }
403
+ // ── dispute resolutions ──────────────────────────────────────────────────────────────────────────────────
404
+ // A resolution records a dispute against one finding, the three readings that settled it (the rule replayed over
405
+ // the disputed record's sealed bytes, the primary observer's fresh audit, every independent observer's reading) and
406
+ // the verdict. It is signed like a receipt. verdict_derivable runs the published decision table (dspVerdict, the
407
+ // same function the Worker runs) over the recorded readings: a resolution whose verdict was edited, or whose
408
+ // readings were edited to fit it, fails here even before the signature does.
409
+ const DSP_SIG_PREFIX = "crawlcheck-dispute-resolution-v1\n";
410
+ export function dspVerdict(m) {
411
+ // The published decision table. Inputs are only what the resolution itself records, so a verifier can re-derive
412
+ // the verdict from the record: replay of the rule over the bytes the disputed scan received, the primary
413
+ // observer's fresh re-measurement, and every independent observer's reading after the dispute was filed.
414
+ const rp = m && m.replay ? m.replay.reproduces : null;
415
+ const obs = (m && Array.isArray(m.observers)) ? m.observers : [];
416
+ const prim = obs.filter(function (o) { return o && o.role === "primary"; })[0] || null;
417
+ const ind = obs.filter(function (o) { return o && o.role === "independent"; });
418
+ if (rp === false)
419
+ return { verdict: "corrected", effect: "withdrawn", rule: "the rule, run again over the exact bytes the disputed scan received, does not hold: the finding was wrong when it was made" };
420
+ if (!prim || prim.finding_present === null || prim.finding_present === undefined)
421
+ return { verdict: "inconclusive", effect: "stands_unresolved", rule: "the primary observer could not re-measure the site" };
422
+ const decided = ind.filter(function (o) { return o.finding_present === true || o.finding_present === false; });
423
+ const agreeIn = ind.filter(function (o) { return o.finding_present !== true && o.finding_present !== false && o.inputs_agree === true; });
424
+ const differIn = ind.filter(function (o) { return o.finding_present !== true && o.finding_present !== false && o.inputs_agree === false; });
425
+ if (!decided.length && !agreeIn.length && !differIn.length)
426
+ return { verdict: "inconclusive", effect: "stands_unresolved", rule: "no independent observer gave a usable reading before the deadline" };
427
+ if (decided.some(function (o) { return o.finding_present !== prim.finding_present; }) || differIn.length)
428
+ return { verdict: "vantage_dependent", effect: "stands_with_scope", rule: "an independent network received something different from the primary observer: the finding holds only for the network that saw it" };
429
+ if (prim.finding_present === true)
430
+ return { verdict: "upheld", effect: "stands", rule: "the rule holds over the original bytes and the condition is still present from every network that re-measured it" };
431
+ if (rp === true)
432
+ return { verdict: "fixed_since", effect: "stands_for_record", rule: "the rule holds over the original bytes, and every network now finds the condition gone: correct when made, fixed since" };
433
+ return { verdict: "no_longer_observed", effect: "stands_for_record", rule: "every network now finds the condition gone, and the original bytes could not be re-checked, so whether it was ever wrong cannot be decided" };
434
+ }
435
+ export async function verifyResolution(x) {
436
+ if (!subtle)
437
+ throw new Error("this runtime has no WebCrypto (crypto.subtle)");
438
+ const checks = [];
439
+ const C = (id, ok, why) => checks.push({ id, ok, why });
440
+ if (!x || x.kind !== "crawlcheck-dispute-resolution" || !x.signature)
441
+ throw new Error("not a CrawlCheck dispute resolution");
442
+ const body = {};
443
+ for (const k of Object.keys(x))
444
+ if (k !== "resolution_id" && k !== "signature")
445
+ body[k] = x[k];
446
+ const sha = hex(await sha256(enc.encode(canon(body))));
447
+ C("resolution_hash", sha === x.signature.resolution_sha256 && x.resolution_id === "dr1:" + sha, sha === x.signature.resolution_sha256 ? "the resolution hashes to its id " + sha.slice(0, 16) + "…" : "the resolution hashes to " + sha + ", it claims " + x.signature.resolution_sha256 + " - a field was changed");
448
+ const jwk = x.issuer && x.issuer.key && x.issuer.key.jwk;
449
+ if (!jwk || !jwk.x) {
450
+ C("key_id", null, "no key in the resolution");
451
+ C("signature", null, "no key to check against");
452
+ }
453
+ else {
454
+ const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }))));
455
+ C("key_id", thumb === x.signature.kid, thumb === x.signature.kid ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…; the same id is published in " + (x.issuer.key.published_in || "the key directory") : "thumbprint " + thumb + " does not match key id " + x.signature.kid);
456
+ const msg = DSP_SIG_PREFIX + sha;
457
+ let ok = null;
458
+ try {
459
+ const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
460
+ ok = x.signature.message === msg && await subtle.verify({ name: "Ed25519" }, pk, b64(x.signature.sig), enc.encode(msg));
461
+ }
462
+ catch (e) {
463
+ ok = null;
464
+ C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
465
+ }
466
+ if (ok !== null)
467
+ C("signature", ok, ok ? "Ed25519 signature over the resolution digest verifies" : "signature does NOT verify over this content");
468
+ }
469
+ C("key_published", null, "offline this cannot be decided: compare key id " + (x.signature.kid || "?") + " with the ids published at " + ((x.issuer && x.issuer.key && x.issuer.key.published_in) || "the key directory"));
470
+ const dv = dspVerdict(x.remeasurement || {});
471
+ const said = x.decision || {};
472
+ C("verdict_derivable", dv.verdict === said.verdict && dv.effect === said.effect, "the decision table over the recorded readings gives " + dv.verdict + " / " + dv.effect + "; the record says " + (said.verdict || "?") + " / " + (said.effect || "?"));
473
+ const obs = (x.remeasurement && x.remeasurement.observers) || [];
474
+ const t0 = x.dispute && Date.parse(x.dispute.ownership && x.dispute.ownership.at || x.dispute.filed_at);
475
+ const late = obs.filter((o) => o && o.at && isFinite(t0) && Date.parse(o.at) < t0);
476
+ C("readings_after_dispute", obs.length ? late.length === 0 : null, obs.length ? (late.length ? late.length + " reading(s) predate the dispute: a re-measurement must come after it" : "every reading (" + obs.length + ") was taken after the dispute was accepted") : "no readings recorded");
477
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
478
+ 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",
479
+ resolution: { id: x.resolution_id, dispute: x.dispute_id, domain: x.subject && x.subject.domain, code: x.subject && x.subject.finding && x.subject.finding.code, verdict: said.verdict, effect: said.effect, observers: obs.map((o) => o.observer_id + "@" + (o.network_class || "?")) }, checks,
480
+ note: "Every check used only the resolution itself. To confirm the key is CrawlCheck's, compare its id with " + ((x.issuer && x.issuer.key && x.issuer.key.published_in) || "the published key directory") + "; to check a reading, open the record or the signed observation it links." };
481
+ }
482
+ // ── data inventories, export parts and deletion records ─────────────────────────────────────────────────────────
483
+ // Signed over sha256(canonical JSON of every field except sha256 and signature) with the prefix below. A deletion
484
+ // record carries counts per family and the digest of the sorted deleted-key list, never the data.
485
+ const DATA_SIG_PREFIX = "crawlcheck-data-v1\n";
486
+ export async function verifyDataDoc(x) {
487
+ if (!subtle)
488
+ throw new Error("this runtime has no WebCrypto (crypto.subtle)");
489
+ const checks = [];
490
+ const C = (id, ok, why) => checks.push({ id, ok, why });
491
+ if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision)$/.test(String(x.kind)) || !x.signature)
492
+ throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer or decision receipt");
493
+ const body = {};
494
+ for (const k of Object.keys(x))
495
+ if (k !== "sha256" && k !== "signature")
496
+ body[k] = x[k];
497
+ const sha = hex(await sha256(enc.encode(canon(body))));
498
+ C("document_hash", sha === x.sha256, sha === x.sha256 ? "the document hashes to its stated digest " + sha.slice(0, 16) + "…" : "the document hashes to " + sha + ", it states " + x.sha256 + " - a field was changed");
499
+ const jwk = x.signature.key && x.signature.key.jwk;
500
+ if (!jwk || !jwk.x) {
501
+ C("key_id", null, "no key in the document");
502
+ C("signature", null, "no key to check against");
503
+ }
504
+ else {
505
+ const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }))));
506
+ C("key_id", thumb === x.signature.kid, thumb === x.signature.kid ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…" : "thumbprint " + thumb + " does not match key id " + x.signature.kid);
507
+ const msg = DATA_SIG_PREFIX + sha;
508
+ let ok = null;
509
+ try {
510
+ const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
511
+ ok = x.signature.message === msg && await subtle.verify({ name: "Ed25519" }, pk, b64(x.signature.sig), enc.encode(msg));
512
+ }
513
+ catch (e) {
514
+ ok = null;
515
+ C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
516
+ }
517
+ if (ok !== null)
518
+ C("signature", ok, ok ? "Ed25519 signature over the document digest verifies" : "signature does NOT verify over this content");
519
+ }
520
+ C("key_published", null, "offline this cannot be decided: compare key id " + (x.signature.kid || "?") + " with the ids published at " + ((x.signature.key && x.signature.key.published_in) || "the key directory"));
521
+ if (x.kind === "crawlcheck-data-export-part" && x.inventory) {
522
+ let iv = null;
523
+ try {
524
+ iv = await verifyDataDoc(x.inventory);
525
+ }
526
+ catch (e) {
527
+ iv = null;
528
+ }
529
+ C("inventory", !!(iv && iv.verified && x.inventory.sha256 === x.inventory_sha256), iv && iv.verified ? "the part carries the signed inventory it names" : "the inventory inside this part does not verify or is not the one named");
530
+ }
531
+ if (x.kind === "crawlcheck-decision") {
532
+ const r = x.resolve || {};
533
+ C("resolve_bound", /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) && r.kid === x.signature.kid, /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) ? "the decision names the resolve answer it was made from (" + String(r.sha256).slice(0, 16) + "…), signed by the same key" : "the decision does not name a resolve answer digest");
534
+ C("decision_known", ["allow", "warn", "require_confirmation", "block", "unsupported"].indexOf(x.decision) >= 0, "decision is " + x.decision);
535
+ }
536
+ if (x.kind === "crawlcheck-deletion-record")
537
+ C("counts", (x.deleted || []).reduce((a, f) => a + (f.keys || 0), 0) === x.deleted_total, "the per-family counts add up to deleted_total (" + x.deleted_total + ")");
538
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
539
+ 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", document: { kind: x.kind, domain: x.domain, sha256: x.sha256 }, checks };
540
+ }
315
541
  // ── command line ─────────────────────────────────────────────────────────────
316
542
  // Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
317
543
  // stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
@@ -326,20 +552,35 @@ catch (e) {
326
552
  if (isMain) {
327
553
  const f = process.argv[2];
328
554
  if (!f) {
329
- console.log("usage: node crawlcheck-verify.mjs <bundle.json>");
555
+ console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json>");
330
556
  process.exit(2);
331
557
  }
332
558
  const { readFile } = await import("node:fs/promises");
333
559
  const doc = JSON.parse(await readFile(f, "utf8"));
334
560
  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);
561
+ const isEx = doc && doc.kind === "crawlcheck-evidence-export";
562
+ const isDr = doc && doc.kind === "crawlcheck-dispute-resolution";
563
+ const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision)$/.test(String(doc.kind));
564
+ const res = isDt ? await verifyDataDoc(doc) : isDr ? await verifyResolution(doc) : isEx ? await verifyExport(doc) : isRc ? await verifyReceipt(doc.kind ? doc : doc.receipt) : await verifyBundle(doc);
336
565
  const mark = (ok) => ok === true ? "PASS" : ok === false ? "FAIL" : " -- ";
337
- if (isRc)
566
+ if (isDt)
567
+ console.log("CrawlCheck " + doc.kind.replace("crawlcheck-", "").replace(/-/g, " ") + ": " + (doc.domain || "?") + " " + String(doc.sha256 || "").slice(0, 16) + "…");
568
+ else if (isDr)
569
+ console.log("CrawlCheck dispute resolution: " + (res.resolution.id || "?").slice(0, 24) + "… " + (res.resolution.domain || "") + " " + (res.resolution.code || "") + " verdict " + (res.resolution.verdict || "?"));
570
+ else if (isEx)
571
+ console.log("CrawlCheck evidence export: " + (res.export.domain || "?") + " " + (res.export.window ? res.export.window.from + " to " + res.export.window.to : "") + " (" + res.export.records + " records, " + res.export.receipts + " receipts)");
572
+ else if (isRc)
338
573
  console.log("CrawlCheck remediation receipt: " + (res.receipt.id || "?") + " " + (res.receipt.domain || "") + " verdict " + (res.receipt.verdict || "?"));
339
574
  else
340
575
  console.log("CrawlCheck evidence bundle: " + (res.report.id || "?") + " " + (res.report.domain || "") + " scanned " + (res.report.scanned_at || "?"));
341
576
  for (const c of res.checks)
342
- console.log(mark(c.ok) + " " + c.id.padEnd(18) + c.why);
577
+ console.log(mark(c.ok) + " " + c.id.padEnd(23) + c.why);
578
+ if (isEx)
579
+ for (const r of res.records)
580
+ console.log(mark(r.verified) + " record " + String(r.id || "?").padEnd(14) + (r.failed && r.failed.length ? "failed: " + r.failed.join(", ") : (r.why || (r.ran + " checks ran"))));
581
+ if (isEx)
582
+ for (const r of res.receipts)
583
+ console.log(mark(r.verified) + " receipt " + String(r.id || "?").slice(0, 20) + (r.failed && r.failed.length ? " failed: " + r.failed.join(", ") : ""));
343
584
  console.log(res.summary);
344
585
  process.exit(res.checks.some((c) => c.ok === false) ? 1 : 0);
345
586
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",