@crawlcheck/sdk 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -16,6 +16,8 @@ const { proceed, receipt } = await cc.guard("example.com", "read", { agent: "MyA
16
16
  // The receipt is a signed crawlcheck-decision; guard() verified it here and checked its key against the published directory.
17
17
  ```
18
18
 
19
+ Every verification returns `integrity_valid`, `issuer_trusted` and `accepted`. Act on `accepted`: it needs the signing key to be one CrawlCheck publishes, so a document someone signed with their own key is `verified` offline but never `accepted`. The client methods (`verifyDocument`, `verifyOffline`, `verifyReceiptOffline`, `guard`) check the published key directory for you.
20
+
19
21
  `preflight(domain, action, { agent, template, policy })` returns the signed receipt without deciding for you; `verifyDocument(doc)` verifies any signed resolve answer or decision receipt offline. `resolve(domain)` returns the full signed ResolveV1 answer.
20
22
 
21
23
  ## Read a record
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,7 +46,7 @@ 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>;
39
- export interface DocumentVerification {
49
+ export interface DocumentVerification extends Partial<Trust> {
40
50
  verified: boolean;
41
51
  summary: string;
42
52
  document: {
@@ -47,7 +57,7 @@ export interface DocumentVerification {
47
57
  checks: VerifyCheck[];
48
58
  }
49
59
  /** Verify a signed resolve answer, preflight decision receipt, data inventory, export part or deletion record offline. */
50
- export declare const verifyDocument: (doc: unknown) => Promise<DocumentVerification>;
60
+ export declare const verifyDocument: (doc: unknown) => Promise<DocumentVerification & Trust>;
51
61
  /** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
52
62
  export declare const verifyExport: (x: unknown) => Promise<{
53
63
  verified: boolean;
@@ -105,6 +115,29 @@ export type BodyOf<P extends keyof paths> = Op<P, "post"> extends {
105
115
  };
106
116
  } ? B : JsonObject;
107
117
  export type Action = "read" | "cite" | "connect" | "transact" | "administer";
118
+ /** The header an agent sends with its request so the site can see it checked CrawlCheck first. */
119
+ export declare const RECEIPT_HEADER = "CrawlCheck-Receipt";
120
+ /** The domain the way CrawlCheck hashes and stores it: lowercased, no scheme, no leading www., no path or port. "" when not a domain. */
121
+ export declare function normalizeDomain(v: string): string;
122
+ /** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
123
+ export declare function receiptHeader(receipt: {
124
+ sha256: string;
125
+ }): Record<string, string>;
126
+ export interface PrivateLookup {
127
+ found: boolean;
128
+ ready: boolean;
129
+ in_bucket: number | null;
130
+ answer: JsonObject | null;
131
+ verification: (DocumentVerification & Trust) | null;
132
+ accepted: boolean;
133
+ why?: string;
134
+ }
135
+ export interface ReceiptCheck {
136
+ accept: boolean;
137
+ why: string;
138
+ check: JsonObject | null;
139
+ verification: (DocumentVerification & Trust) | null;
140
+ }
108
141
  export type Decision = "allow" | "warn" | "require_confirmation" | "block" | "unsupported";
109
142
  export interface PreflightPolicy {
110
143
  max_age_hours?: number;
@@ -188,13 +221,18 @@ export declare class CrawlCheck {
188
221
  /** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
189
222
  preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
190
223
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
191
- verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification>;
224
+ verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
192
225
  /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
193
226
  guard(domain: string, action?: Action, opts?: GuardOptions): Promise<{
194
227
  proceed: boolean;
195
228
  receipt: DecisionReceipt;
196
- verification: DocumentVerification;
229
+ verification: DocumentVerification & Trust;
230
+ header: Record<string, string> | null;
197
231
  }>;
232
+ /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
233
+ resolvePrivate(domain: string, prefixLen?: number): Promise<PrivateLookup>;
234
+ /** For sites: check a CrawlCheck-Receipt header received with a request to host. accept only when the receipt is held, names host, has not expired, decided allow or warn, and verifies with a published key. */
235
+ checkReceiptHeader(headerValue: string, host: string): Promise<ReceiptCheck>;
198
236
  /** Signed change feed (what changed since a time, optionally for one domain). */
199
237
  changes(q?: QueryOf<"/api/v1/changes">): Promise<JsonObject>;
200
238
  /** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
@@ -219,11 +257,11 @@ export declare class CrawlCheck {
219
257
  /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
220
258
  bundle(id: string): Promise<EvidenceBundleV1>;
221
259
  /** Download the bundle and verify it here, with the offline verifier: no trust in the API's own answer. */
222
- verifyOffline(id: string): Promise<BundleVerification>;
260
+ verifyOffline(id: string): Promise<BundleVerification & Trust>;
223
261
  /** A signed remediation receipt by its id (rc1:…). */
224
262
  receipt(id: string): Promise<RemediationReceiptV1>;
225
263
  /** Fetch a receipt and verify its hash and signature here. key_published stays null offline: compare the key id with publishedKeyIds(). */
226
- verifyReceiptOffline(id: string): Promise<ReceiptVerification>;
264
+ verifyReceiptOffline(id: string): Promise<ReceiptVerification & Trust>;
227
265
  /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
228
266
  publishedKeyIds(): Promise<string[]>;
229
267
  receipts(domain: string): Promise<JsonObject>;
package/dist/index.js CHANGED
@@ -1,10 +1,24 @@
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;
6
20
  /** Verify a signed resolve answer, preflight decision receipt, data inventory, export part or deletion record offline. */
7
- export const verifyDocument = V.verifyDataDoc;
21
+ export const verifyDocument = async (doc) => withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, null);
8
22
  /** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
9
23
  export const verifyExport = V.verifyExport;
10
24
  /** Verify a signed dispute resolution offline (re-runs the published decision table over the recorded readings). */
@@ -12,6 +26,19 @@ export const verifyResolution = V.verifyResolution;
12
26
  export const canon = V.canon;
13
27
  export const merkleRootHex = V.merkleRootHex;
14
28
  export const otsInspect = V.otsInspect;
29
+ /** The header an agent sends with its request so the site can see it checked CrawlCheck first. */
30
+ export const RECEIPT_HEADER = "CrawlCheck-Receipt";
31
+ /** The domain the way CrawlCheck hashes and stores it: lowercased, no scheme, no leading www., no path or port. "" when not a domain. */
32
+ export function normalizeDomain(v) {
33
+ const d = String(v ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/[\/?#].*$/, "").replace(/:\d+$/, "");
34
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(d) ? d : "";
35
+ }
36
+ /** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
37
+ export function receiptHeader(receipt) { return { [RECEIPT_HEADER]: "v=1; sha256=" + receipt.sha256 }; }
38
+ async function sha256Hex(s) {
39
+ const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
40
+ return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
41
+ }
15
42
  export class CrawlCheckError extends Error {
16
43
  status;
17
44
  body;
@@ -67,26 +94,62 @@ export class CrawlCheck {
67
94
  }
68
95
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
69
96
  async verifyDocument(doc, onlineKeys = true) {
70
- const v = await verifyDocument(doc);
71
- if (!onlineKeys)
72
- return v;
73
- const kid = doc?.signature?.kid;
74
- const ok = !!kid && (await this.publishedKeyIds()).includes(kid);
75
- const checks = v.checks.map((c) => c.id === "key_published" ? { id: c.id, ok, why: ok ? "key id " + kid + " is in the published key directory" : "key id " + kid + " is NOT in the published key directory" } : c);
76
- const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
77
- 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: v.document, checks };
97
+ return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
78
98
  }
79
99
  /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
80
100
  async guard(domain, action = "read", opts = {}) {
81
101
  const receipt = await this.preflight(domain, action, opts);
82
102
  const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
103
+ if (!verification.accepted && (opts.onlineKeys ?? true))
104
+ return { proceed: false, receipt, verification, header: null };
83
105
  if (!verification.verified)
84
- return { proceed: false, receipt, verification };
106
+ return { proceed: false, receipt, verification, header: null };
85
107
  const d = receipt.decision;
86
108
  let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
87
109
  if (d === "require_confirmation" && opts.confirm)
88
110
  proceed = !!(await opts.confirm(receipt));
89
- return { proceed, receipt, verification };
111
+ return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null };
112
+ }
113
+ /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
114
+ async resolvePrivate(domain, prefixLen = 2) {
115
+ const d = normalizeDomain(domain);
116
+ if (!d)
117
+ throw new Error("not a domain name: " + domain);
118
+ if (prefixLen < 2 || prefixLen > 8)
119
+ throw new Error("prefixLen must be 2 to 8");
120
+ const h = await sha256Hex(d);
121
+ let r;
122
+ try {
123
+ r = await this.req("GET", "/api/v1/resolve-prefix/" + h.slice(0, prefixLen));
124
+ }
125
+ catch (e) {
126
+ if (e instanceof CrawlCheckError && e.status === 503)
127
+ return { found: false, ready: false, in_bucket: null, answer: null, verification: null, accepted: false, why: "the prefix index is not built yet" };
128
+ throw e;
129
+ }
130
+ const hit = (r.answers || []).find((a) => a.domain_sha256 === h);
131
+ if (!hit)
132
+ return { found: false, ready: true, in_bucket: r.in_bucket ?? null, answer: null, verification: null, accepted: false };
133
+ const verification = await this.verifyDocument(hit.answer);
134
+ return { found: true, ready: true, in_bucket: r.in_bucket ?? null, answer: hit.answer, verification, accepted: !!verification.accepted && normalizeDomain(hit.answer.domain) === d };
135
+ }
136
+ /** For sites: check a CrawlCheck-Receipt header received with a request to host. accept only when the receipt is held, names host, has not expired, decided allow or warn, and verifies with a published key. */
137
+ async checkReceiptHeader(headerValue, host) {
138
+ const m = String(headerValue || "").match(/sha256=([0-9a-fA-F]{64})/) || String(headerValue || "").trim().match(/^([0-9a-fA-F]{64})$/);
139
+ if (!m)
140
+ return { accept: false, why: "no receipt digest in the header", check: null, verification: null };
141
+ const sha = m[1].toLowerCase(), h = normalizeDomain(host);
142
+ let r;
143
+ try {
144
+ r = await this.req("GET", "/api/v1/decisions/" + sha, { host: h });
145
+ }
146
+ catch (e) {
147
+ if (e instanceof CrawlCheckError && e.status === 404)
148
+ return { accept: false, why: "CrawlCheck holds no receipt with that digest", check: e.body, verification: null };
149
+ throw e;
150
+ }
151
+ const rc = r.receipt || {}, verification = await this.verifyDocument(rc);
152
+ return { accept: !!r.accept && !!verification.accepted && rc.sha256 === sha && normalizeDomain(rc.domain) === h, why: r.reason, check: r, verification };
90
153
  }
91
154
  /** Signed change feed (what changed since a time, optionally for one domain). */
92
155
  changes(q = {}) { return this.get("/api/v1/changes", q); }
@@ -112,11 +175,11 @@ export class CrawlCheck {
112
175
  /** The evidence bundle (anonymous: subject and leaves withheld; with a licence for the domain: complete). */
113
176
  bundle(id) { return this.get("/api/bundle", { id, download: "0" }); }
114
177
  /** Download the bundle and verify it here, with the offline verifier: no trust in the API's own answer. */
115
- async verifyOffline(id) { return verifyBundle(await this.bundle(id)); }
178
+ async verifyOffline(id) { const b = await this.bundle(id); return withTrust(await verifyBundle(b), b?.manifest?.signature?.kid, await this.publishedKeyIds()); }
116
179
  /** A signed remediation receipt by its id (rc1:…). */
117
180
  async receipt(id) { const r = await this.get("/api/receipt", { id }); return r.receipt; }
118
181
  /** Fetch a receipt and verify its hash and signature here. key_published stays null offline: compare the key id with publishedKeyIds(). */
119
- async verifyReceiptOffline(id) { return verifyReceipt(await this.receipt(id)); }
182
+ async verifyReceiptOffline(id) { const rc = await this.receipt(id); return withTrust(await verifyReceipt(rc), rc?.signature?.kid, await this.publishedKeyIds()); }
120
183
  /** The observer's published Ed25519 key ids (RFC 7638 thumbprints), to close the one check a verifier cannot do offline. */
121
184
  async publishedKeyIds() {
122
185
  const d = await this.req("GET", "/.well-known/http-message-signatures-directory");
package/dist/verify.d.ts CHANGED
@@ -4,88 +4,15 @@ export function otsInspect(u8: any): Promise<{
4
4
  digest: string;
5
5
  attestations: any[];
6
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
- }>;
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
7
  export function dspVerdict(m: any): {
63
8
  verdict: string;
64
9
  effect: string;
65
10
  rule: string;
66
11
  };
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
- }>;
12
+ export function trustOf(res: any, kid: any, publishedKids: any): any;
13
+ export function publishedKeyIds(base: any): Promise<string[]>;
14
+ export function verifyBundle(b: any, opts: any): Promise<any>;
15
+ export function verifyReceipt(rc: any, opts: any): Promise<any>;
16
+ export function verifyExport(x: any, opts: any): Promise<any>;
17
+ export function verifyResolution(x: any, opts: any): Promise<any>;
18
+ export function verifyDataDoc(x: any, opts: any): Promise<any>;
package/dist/verify.js CHANGED
@@ -20,6 +20,14 @@
20
20
  // decision_root (manifest v3) the sealed root over the decisions recomputes from the decision leaves (licence bundles)
21
21
  // lineage_root (manifest v3) the sealed root over the extracted business facts recomputes from their leaves (licence bundles)
22
22
  // A check that cannot run (a withheld subject, an unsealed day) is null with the reason, never a pass.
23
+ // Every result also separates the questions a single "verified" used to answer at once (vt1, 2026-10-06):
24
+ // integrity_valid no check over the bytes failed (hashes, signature, proof paths)
25
+ // issuer_trusted the signing key is in CrawlCheck's published key directory: true / false, or null offline
26
+ // accepted integrity_valid AND issuer_trusted === true. Act on this, never on verified alone.
27
+ // accepted_strict accepted AND every check ran (legacy and pre-manifest records stay false: less was sealed)
28
+ // assurance "complete" or "partial", with unavailable[] naming the checks that could not run
29
+ // Offline, issuer_trusted is null and accepted is false. Close it with --online (fetches the key directory) or
30
+ // pass the published key ids yourself: verifyReceipt(doc, { publishedKids: [...] }).
23
31
  // What it cannot do offline: read a Bitcoin block header. It prints the block height and the merkle root the
24
32
  // proof commits to; compare that value with any Bitcoin node or block explorer.
25
33
  const MEG_SIG_PREFIX = "crawlcheck-meg-manifest-v1\n";
@@ -136,7 +144,7 @@ export async function otsInspect(u8) {
136
144
  return { digest: hex(digest), attestations };
137
145
  }
138
146
  // ── the bundle ────────────────────────────────────────────────────────────────
139
- export async function verifyBundle(b) {
147
+ async function verifyBundle0(b) {
140
148
  if (!subtle)
141
149
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
142
150
  const checks = [];
@@ -274,7 +282,7 @@ export async function verifyBundle(b) {
274
282
  // verification verdict, and is signed with the observer's Ed25519 key over sha256(canonical JSON of everything but
275
283
  // receipt_id and signature). Change any field and receipt_hash fails; swap the key and key_id fails.
276
284
  const RCPT_SIG_PREFIX = "crawlcheck-receipt-v1\n";
277
- export async function verifyReceipt(rc) {
285
+ async function verifyReceipt0(rc) {
278
286
  if (!subtle)
279
287
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
280
288
  const checks = [];
@@ -322,7 +330,7 @@ export async function verifyReceipt(rc) {
322
330
  // export_sha256 and signature); then every bundle and every receipt inside it is verified exactly as it would be on
323
331
  // its own. One altered byte anywhere fails export_hash; one altered bundle fails its own checks.
324
332
  const EXPORT_SIG_PREFIX = "crawlcheck-evidence-export-v1\n";
325
- export async function verifyExport(x) {
333
+ async function verifyExport0(x) {
326
334
  if (!subtle)
327
335
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
328
336
  const checks = [];
@@ -432,7 +440,7 @@ export function dspVerdict(m) {
432
440
  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
441
  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
442
  }
435
- export async function verifyResolution(x) {
443
+ async function verifyResolution0(x) {
436
444
  if (!subtle)
437
445
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
438
446
  const checks = [];
@@ -483,13 +491,13 @@ export async function verifyResolution(x) {
483
491
  // Signed over sha256(canonical JSON of every field except sha256 and signature) with the prefix below. A deletion
484
492
  // record carries counts per family and the digest of the sorted deleted-key list, never the data.
485
493
  const DATA_SIG_PREFIX = "crawlcheck-data-v1\n";
486
- export async function verifyDataDoc(x) {
494
+ async function verifyDataDoc0(x) {
487
495
  if (!subtle)
488
496
  throw new Error("this runtime has no WebCrypto (crypto.subtle)");
489
497
  const checks = [];
490
498
  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");
499
+ if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot)$/.test(String(x.kind)) || !x.signature)
500
+ throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer, decision receipt or snapshot manifest");
493
501
  const body = {};
494
502
  for (const k of Object.keys(x))
495
503
  if (k !== "sha256" && k !== "signature")
@@ -533,6 +541,10 @@ export async function verifyDataDoc(x) {
533
541
  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
542
  C("decision_known", ["allow", "warn", "require_confirmation", "block", "unsupported"].indexOf(x.decision) >= 0, "decision is " + x.decision);
535
543
  }
544
+ if (x.kind === "crawlcheck-snapshot") {
545
+ const ps = Array.isArray(x.parts) ? x.parts : [];
546
+ C("parts", ps.length > 0 && ps.every((p) => /^part-\d{4}\.ndjson\.gz$/.test(String(p.path)) && /^[0-9a-f]{64}$/.test(String(p.sha256)) && p.records > 0) && ps.reduce((a, p) => a + p.records, 0) === x.records, ps.reduce((a, p) => a + (p.records || 0), 0) === x.records ? ps.length + " parts, " + x.records + " records; check each downloaded part with sha256sum against its sha256, then each line with this tool" : "the parts' record counts do not add up to records (" + x.records + ")");
547
+ }
536
548
  if (x.kind === "crawlcheck-deletion-record")
537
549
  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
550
  const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
@@ -541,6 +553,43 @@ export async function verifyDataDoc(x) {
541
553
  // ── command line ─────────────────────────────────────────────────────────────
542
554
  // Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
543
555
  // stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
556
+ // vt1: integrity, issuer trust and completeness, reported apart. Same semantics as @crawlcheck/sdk and the Python package.
557
+ export function trustOf(res, kid, publishedKids) {
558
+ let checks = Array.isArray(res && res.checks) ? res.checks : [];
559
+ let issuer = null;
560
+ if (Array.isArray(publishedKids)) {
561
+ issuer = !!kid && publishedKids.indexOf(kid) > -1;
562
+ const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : !kid ? "this document carries no signature, so its issuer cannot be authenticated" : "key id " + kid + " is NOT in the published key directory: reject this document" };
563
+ checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
564
+ }
565
+ const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
566
+ const unavailable = checks.filter((c) => c.ok === null && c.id !== "key_published").map((c) => c.id);
567
+ const verified = ran.length > 0 && failed.length === 0;
568
+ const integrity_valid = ran.some((c) => c.id !== "key_published") && checks.filter((c) => c.id !== "key_published").every((c) => c.ok !== false);
569
+ const accepted = integrity_valid && issuer === true;
570
+ return Object.assign({}, res, { checks, verified, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
571
+ integrity_valid, issuer_trusted: issuer, accepted, accepted_strict: accepted && unavailable.length === 0,
572
+ assurance: unavailable.length ? "partial" : "complete", unavailable });
573
+ }
574
+ // The published Ed25519 key ids (RFC 7638 thumbprints) from the observer's key directory.
575
+ export async function publishedKeyIds(base) {
576
+ const r = await fetch(String(base || "https://crawlcheck.io").replace(/\/+$/, "") + "/.well-known/http-message-signatures-directory");
577
+ if (!r.ok)
578
+ throw new Error("key directory answered HTTP " + r.status);
579
+ const d = await r.json(), out = [];
580
+ for (const k of (d && d.keys) || []) {
581
+ if (!k || !k.x)
582
+ continue;
583
+ out.push(b64u(await sha256(enc.encode(JSON.stringify({ crv: k.crv, kty: k.kty, x: k.x })))));
584
+ }
585
+ return out;
586
+ }
587
+ const kidOf = (o) => (o && o.signature && o.signature.kid) || (o && o.manifest && o.manifest.signature && o.manifest.signature.kid) || undefined;
588
+ export async function verifyBundle(b, opts) { return trustOf(await verifyBundle0(b), b && b.manifest && b.manifest.signature && b.manifest.signature.kid, opts && opts.publishedKids); }
589
+ export async function verifyReceipt(rc, opts) { return trustOf(await verifyReceipt0(rc), rc && rc.signature && rc.signature.kid, opts && opts.publishedKids); }
590
+ export async function verifyExport(x, opts) { return trustOf(await verifyExport0(x), kidOf(x), opts && opts.publishedKids); }
591
+ export async function verifyResolution(x, opts) { return trustOf(await verifyResolution0(x), kidOf(x), opts && opts.publishedKids); }
592
+ export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
544
593
  const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
545
594
  const { pathToFileURL } = await import("node:url");
546
595
  const { realpathSync } = await import("node:fs");
@@ -550,9 +599,10 @@ catch (e) {
550
599
  return false;
551
600
  } })();
552
601
  if (isMain) {
553
- const f = process.argv[2];
602
+ const online = process.argv.includes("--online");
603
+ const f = process.argv.slice(2).filter((a) => a !== "--online")[0];
554
604
  if (!f) {
555
- console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json>");
605
+ console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
556
606
  process.exit(2);
557
607
  }
558
608
  const { readFile } = await import("node:fs/promises");
@@ -560,8 +610,18 @@ if (isMain) {
560
610
  const isRc = doc && (doc.kind === "crawlcheck-remediation-receipt" || (doc.receipt && doc.receipt.kind === "crawlcheck-remediation-receipt"));
561
611
  const isEx = doc && doc.kind === "crawlcheck-evidence-export";
562
612
  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);
613
+ const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot)$/.test(String(doc.kind));
614
+ let kids;
615
+ if (online) {
616
+ try {
617
+ kids = await publishedKeyIds();
618
+ }
619
+ catch (e) {
620
+ console.log("could not read the published key directory: " + e.message);
621
+ }
622
+ }
623
+ const vo = { publishedKids: kids };
624
+ const res = isDt ? await verifyDataDoc(doc, vo) : isDr ? await verifyResolution(doc, vo) : isEx ? await verifyExport(doc, vo) : isRc ? await verifyReceipt(doc.kind ? doc : doc.receipt, vo) : await verifyBundle(doc, vo);
565
625
  const mark = (ok) => ok === true ? "PASS" : ok === false ? "FAIL" : " -- ";
566
626
  if (isDt)
567
627
  console.log("CrawlCheck " + doc.kind.replace("crawlcheck-", "").replace(/-/g, " ") + ": " + (doc.domain || "?") + " " + String(doc.sha256 || "").slice(0, 16) + "…");
@@ -582,5 +642,7 @@ if (isMain) {
582
642
  for (const r of res.receipts)
583
643
  console.log(mark(r.verified) + " receipt " + String(r.id || "?").slice(0, 20) + (r.failed && r.failed.length ? " failed: " + r.failed.join(", ") : ""));
584
644
  console.log(res.summary);
645
+ console.log("integrity " + (res.integrity_valid ? "valid" : "NOT valid") + " · issuer " + (res.issuer_trusted === true ? "trusted (key published)" : res.issuer_trusted === false ? "NOT trusted (key not published)" : "not checked offline: run with --online") + " · assurance " + res.assurance + (res.unavailable.length ? " (" + res.unavailable.join(", ") + " could not run)" : ""));
646
+ console.log(res.accepted ? (res.accepted_strict ? "ACCEPTED" : "ACCEPTED, partial assurance") : "NOT ACCEPTED" + (res.integrity_valid && res.issuer_trusted === null ? ": issuer unknown offline" : ""));
585
647
  process.exit(res.checks.some((c) => c.ok === false) ? 1 : 0);
586
648
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",
package/test/sdk.test.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Runs against the live API. npm test
2
2
  import test from "node:test"; import assert from "node:assert/strict";
3
- import { CrawlCheck, verifyBundle, verifyReceipt, CrawlCheckError, verifyDocument } from "../dist/index.js";
3
+ import { CrawlCheck, verifyBundle, verifyReceipt, CrawlCheckError, verifyDocument, normalizeDomain, receiptHeader } from "../dist/index.js";
4
4
  const c = new CrawlCheck();
5
5
 
6
6
  test("conformance: every published fixture gives its expected output through this package's verifier", async () => {
@@ -59,5 +59,30 @@ test("live preflight: the decision receipt verifies offline, its key is publishe
59
59
  assert.equal(g.verification.verified, true);
60
60
  assert.equal(g.proceed, g.receipt.decision === "allow");
61
61
  const r = await c.resolve("example.com");
62
- assert.equal((await verifyDocument(r)).verified, true);
62
+ const off = await verifyDocument(r);
63
+ assert.equal(off.verified, true); assert.equal(off.issuer_trusted, null); assert.equal(off.accepted, false, "offline is never accepted");
64
+ assert.equal((await c.verifyDocument(r)).accepted, true);
65
+ const ids = await c.publishedKeyIds();
66
+ const ix = await (await fetch(c.base + "/conformance/index.json")).json();
67
+ const foreign = await (await fetch(ix.fixtures.find((x) => x.name === "receipt-foreign-key").file)).json();
68
+ assert.equal((await verifyReceipt(foreign)).verified, true, "self-consistent offline");
69
+ assert.ok(!ids.includes(foreign.signature.kid), "but not a CrawlCheck key, so never accepted");
70
+ });
71
+
72
+ test("receipt header: a site accepts the receipt for its own host only", async () => {
73
+ const rc = await c.preflight("github.com", "read", { agent: "crawlcheck-sdk-test" });
74
+ const hv = receiptHeader(rc)["CrawlCheck-Receipt"];
75
+ assert.match(hv, /^v=1; sha256=[0-9a-f]{64}$/);
76
+ const ok = await c.checkReceiptHeader(hv, "https://www.github.com/x");
77
+ assert.equal(ok.accept, rc.decision === "allow" || rc.decision === "warn", JSON.stringify(ok.why));
78
+ assert.equal((await c.checkReceiptHeader(hv, "gitlab.com")).accept, false);
79
+ assert.equal((await c.checkReceiptHeader("v=1; sha256=" + "c".repeat(64), "github.com")).accept, false);
63
80
  });
81
+
82
+ test("private lookup sends only a hash prefix and verifies what it finds", async () => {
83
+ const r = await c.resolvePrivate("github.com");
84
+ assert.ok(r.ready === true || r.ready === false);
85
+ if (r.found) { assert.equal(r.accepted, true); assert.equal(r.answer.domain, "github.com"); }
86
+ assert.equal(normalizeDomain("HTTPS://WWW.Example.com:443/a?b"), "example.com");
87
+ });
88
+