@crawlcheck/sdk 1.0.0 → 1.0.2
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 +13 -1
- package/dist/gen/openapi.d.ts +3529 -654
- package/dist/index.d.ts +91 -0
- package/dist/index.js +51 -0
- package/dist/verify.d.ts +64 -0
- package/dist/verify.js +246 -5
- package/package.json +10 -2
- package/test/sdk.test.js +19 -1
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,32 @@ export interface ReceiptVerification {
|
|
|
36
36
|
export declare const verifyBundle: (b: unknown) => Promise<BundleVerification>;
|
|
37
37
|
/** Verify a remediation receipt with no call to CrawlCheck. */
|
|
38
38
|
export declare const verifyReceipt: (rc: unknown) => Promise<ReceiptVerification>;
|
|
39
|
+
export interface DocumentVerification {
|
|
40
|
+
verified: boolean;
|
|
41
|
+
summary: string;
|
|
42
|
+
document: {
|
|
43
|
+
kind: string;
|
|
44
|
+
domain?: string;
|
|
45
|
+
sha256: string;
|
|
46
|
+
};
|
|
47
|
+
checks: VerifyCheck[];
|
|
48
|
+
}
|
|
49
|
+
/** 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>;
|
|
51
|
+
/** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
|
|
52
|
+
export declare const verifyExport: (x: unknown) => Promise<{
|
|
53
|
+
verified: boolean;
|
|
54
|
+
summary: string;
|
|
55
|
+
checks: VerifyCheck[];
|
|
56
|
+
[k: string]: unknown;
|
|
57
|
+
}>;
|
|
58
|
+
/** Verify a signed dispute resolution offline (re-runs the published decision table over the recorded readings). */
|
|
59
|
+
export declare const verifyResolution: (x: unknown) => Promise<{
|
|
60
|
+
verified: boolean;
|
|
61
|
+
summary: string;
|
|
62
|
+
checks: VerifyCheck[];
|
|
63
|
+
[k: string]: unknown;
|
|
64
|
+
}>;
|
|
39
65
|
export declare const canon: (v: unknown) => string;
|
|
40
66
|
export declare const merkleRootHex: (hexLeaves: string[]) => Promise<string>;
|
|
41
67
|
export declare const otsInspect: (u8: Uint8Array) => Promise<{
|
|
@@ -78,6 +104,48 @@ export type BodyOf<P extends keyof paths> = Op<P, "post"> extends {
|
|
|
78
104
|
};
|
|
79
105
|
};
|
|
80
106
|
} ? B : JsonObject;
|
|
107
|
+
export type Action = "read" | "cite" | "connect" | "transact" | "administer";
|
|
108
|
+
export type Decision = "allow" | "warn" | "require_confirmation" | "block" | "unsupported";
|
|
109
|
+
export interface PreflightPolicy {
|
|
110
|
+
max_age_hours?: number;
|
|
111
|
+
on_warn?: "warn" | "require_confirmation" | "block";
|
|
112
|
+
require_entity?: boolean;
|
|
113
|
+
human_approval?: Action[];
|
|
114
|
+
allow?: Action[];
|
|
115
|
+
deny?: Action[];
|
|
116
|
+
[k: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
export interface PreflightOptions {
|
|
119
|
+
agent?: string;
|
|
120
|
+
template?: string;
|
|
121
|
+
policy?: PreflightPolicy;
|
|
122
|
+
}
|
|
123
|
+
/** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
|
|
124
|
+
export interface DecisionReceipt {
|
|
125
|
+
kind: "crawlcheck-decision";
|
|
126
|
+
domain: string;
|
|
127
|
+
action: Action;
|
|
128
|
+
decision: Decision;
|
|
129
|
+
sha256: string;
|
|
130
|
+
signature: {
|
|
131
|
+
kid: string;
|
|
132
|
+
[k: string]: unknown;
|
|
133
|
+
};
|
|
134
|
+
resolve?: {
|
|
135
|
+
sha256: string;
|
|
136
|
+
kid: string;
|
|
137
|
+
};
|
|
138
|
+
alternatives?: JsonObject[];
|
|
139
|
+
[k: string]: unknown;
|
|
140
|
+
}
|
|
141
|
+
export interface GuardOptions extends PreflightOptions {
|
|
142
|
+
/** Proceed on "warn" too. Default false: warn does not proceed. */
|
|
143
|
+
allowWarn?: boolean;
|
|
144
|
+
/** Called on "require_confirmation"; proceed only if it resolves true. Without it, require_confirmation does not proceed. */
|
|
145
|
+
confirm?: (receipt: DecisionReceipt) => boolean | Promise<boolean>;
|
|
146
|
+
/** Also require the signing key to be in the published key directory (default true). */
|
|
147
|
+
onlineKeys?: boolean;
|
|
148
|
+
}
|
|
81
149
|
export interface ClientOptions {
|
|
82
150
|
/** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
|
|
83
151
|
key?: string;
|
|
@@ -113,6 +181,29 @@ export declare class CrawlCheck {
|
|
|
113
181
|
get<P extends keyof paths>(path: P, query?: QueryOf<P, "get">): Promise<ResponseOf<P, "get">>;
|
|
114
182
|
/** Any documented POST, typed from the OpenAPI document. */
|
|
115
183
|
post<P extends keyof paths>(path: P, body: BodyOf<P> | JsonObject, query?: QueryOf<P, "post">): Promise<ResponseOf<P, "post">>;
|
|
184
|
+
/** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
|
|
185
|
+
resolve(domain: string): Promise<JsonObject>;
|
|
186
|
+
/** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
|
|
187
|
+
resolveBatch(domains: string[]): Promise<JsonObject>;
|
|
188
|
+
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
189
|
+
preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
|
|
190
|
+
/** 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>;
|
|
192
|
+
/** 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
|
+
guard(domain: string, action?: Action, opts?: GuardOptions): Promise<{
|
|
194
|
+
proceed: boolean;
|
|
195
|
+
receipt: DecisionReceipt;
|
|
196
|
+
verification: DocumentVerification;
|
|
197
|
+
}>;
|
|
198
|
+
/** Signed change feed (what changed since a time, optionally for one domain). */
|
|
199
|
+
changes(q?: QueryOf<"/api/v1/changes">): Promise<JsonObject>;
|
|
200
|
+
/** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
|
|
201
|
+
outcome(body: JsonObject & {
|
|
202
|
+
domain: string;
|
|
203
|
+
action: Action;
|
|
204
|
+
}): Promise<JsonObject>;
|
|
205
|
+
/** The registry record (RDAP) and lifecycle of a domain. */
|
|
206
|
+
domain(domain: string): Promise<JsonObject>;
|
|
116
207
|
/** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
|
|
117
208
|
machineRecord(q: QueryOf<"/api/v1/machine-record">): Promise<MachineRecordV1>;
|
|
118
209
|
/** Scan a domain now (free lane without a key; the licensed lane with one). */
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,12 @@ import * as V from "./verify.js";
|
|
|
3
3
|
export const verifyBundle = V.verifyBundle;
|
|
4
4
|
/** Verify a remediation receipt with no call to CrawlCheck. */
|
|
5
5
|
export const verifyReceipt = V.verifyReceipt;
|
|
6
|
+
/** Verify a signed resolve answer, preflight decision receipt, data inventory, export part or deletion record offline. */
|
|
7
|
+
export const verifyDocument = V.verifyDataDoc;
|
|
8
|
+
/** Verify a signed evidence export (and every bundle and receipt inside it) offline. */
|
|
9
|
+
export const verifyExport = V.verifyExport;
|
|
10
|
+
/** Verify a signed dispute resolution offline (re-runs the published decision table over the recorded readings). */
|
|
11
|
+
export const verifyResolution = V.verifyResolution;
|
|
6
12
|
export const canon = V.canon;
|
|
7
13
|
export const merkleRootHex = V.merkleRootHex;
|
|
8
14
|
export const otsInspect = V.otsInspect;
|
|
@@ -43,6 +49,51 @@ export class CrawlCheck {
|
|
|
43
49
|
async post(path, body, query) {
|
|
44
50
|
return this.req("POST", path, query, body);
|
|
45
51
|
}
|
|
52
|
+
// ── read before acting ──
|
|
53
|
+
/** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
|
|
54
|
+
resolve(domain) { return this.get("/api/v1/resolve", { domain }); }
|
|
55
|
+
/** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
|
|
56
|
+
resolveBatch(domains) { return this.post("/api/v1/resolve", { domains }); }
|
|
57
|
+
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
58
|
+
async preflight(domain, action = "read", opts = {}) {
|
|
59
|
+
const b = { domain, action };
|
|
60
|
+
if (opts.agent)
|
|
61
|
+
b.agent = opts.agent;
|
|
62
|
+
if (opts.template)
|
|
63
|
+
b.template = opts.template;
|
|
64
|
+
if (opts.policy)
|
|
65
|
+
b.policy = opts.policy;
|
|
66
|
+
return this.post("/api/v1/preflight", b);
|
|
67
|
+
}
|
|
68
|
+
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
|
69
|
+
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 };
|
|
78
|
+
}
|
|
79
|
+
/** 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
|
+
async guard(domain, action = "read", opts = {}) {
|
|
81
|
+
const receipt = await this.preflight(domain, action, opts);
|
|
82
|
+
const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
|
|
83
|
+
if (!verification.verified)
|
|
84
|
+
return { proceed: false, receipt, verification };
|
|
85
|
+
const d = receipt.decision;
|
|
86
|
+
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
|
|
87
|
+
if (d === "require_confirmation" && opts.confirm)
|
|
88
|
+
proceed = !!(await opts.confirm(receipt));
|
|
89
|
+
return { proceed, receipt, verification };
|
|
90
|
+
}
|
|
91
|
+
/** Signed change feed (what changed since a time, optionally for one domain). */
|
|
92
|
+
changes(q = {}) { return this.get("/api/v1/changes", q); }
|
|
93
|
+
/** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
|
|
94
|
+
outcome(body) { return this.post("/api/v1/outcome", body); }
|
|
95
|
+
/** The registry record (RDAP) and lifecycle of a domain. */
|
|
96
|
+
domain(domain) { return this.get("/api/v1/domain", { domain }); }
|
|
46
97
|
// ── the records ──
|
|
47
98
|
/** The whole observation in one shape: findings (gated like the site), trust, freshness, score ledger, evidence links. */
|
|
48
99
|
machineRecord(q) { return this.get("/api/v1/machine-record", q); }
|
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
|
|
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 (
|
|
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(
|
|
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.
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
},
|
|
22
22
|
"sideEffects": false,
|
|
23
23
|
"homepage": "https://crawlcheck.io/sdk",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/emmanuelorta/crawlcheck.git",
|
|
27
|
+
"directory": "sdk"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/emmanuelorta/crawlcheck/issues"
|
|
31
|
+
},
|
|
24
32
|
"keywords": [
|
|
25
33
|
"crawlcheck",
|
|
26
34
|
"verification",
|
|
@@ -34,4 +42,4 @@
|
|
|
34
42
|
"scripts": {
|
|
35
43
|
"test": "node --test test/*.test.js"
|
|
36
44
|
}
|
|
37
|
-
}
|
|
45
|
+
}
|
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 } from "../dist/index.js";
|
|
3
|
+
import { CrawlCheck, verifyBundle, verifyReceipt, CrawlCheckError, verifyDocument } 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 () => {
|
|
@@ -43,3 +43,21 @@ test("a tampered live bundle fails offline", async () => {
|
|
|
43
43
|
test("locked data surfaces as a typed error, not a crash", async () => {
|
|
44
44
|
await assert.rejects(c.impact({ domain: "wikipedia.org" }), (e) => e instanceof CrawlCheckError && e.locked === true && e.status === 403);
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("live preflight: the decision receipt verifies offline, its key is published, a changed decision fails, guard agrees", async () => {
|
|
48
|
+
const rc = await c.preflight("example.com", "read");
|
|
49
|
+
assert.equal(rc.kind, "crawlcheck-decision");
|
|
50
|
+
assert.ok(["allow", "warn", "require_confirmation", "block", "unsupported"].includes(rc.decision));
|
|
51
|
+
const v = await c.verifyDocument(rc);
|
|
52
|
+
assert.equal(v.verified, true, JSON.stringify(v.checks));
|
|
53
|
+
for (const id of ["document_hash", "key_id", "signature", "key_published", "resolve_bound", "decision_known"]) assert.equal(v.checks.find((c) => c.id === id)?.ok, true, id);
|
|
54
|
+
const forged = JSON.parse(JSON.stringify(rc)); forged.decision = rc.decision === "allow" ? "block" : "allow";
|
|
55
|
+
const f = await verifyDocument(forged);
|
|
56
|
+
assert.equal(f.verified, false);
|
|
57
|
+
assert.equal(f.checks.find((c) => c.id === "document_hash").ok, false);
|
|
58
|
+
const g = await c.guard("example.com", "read");
|
|
59
|
+
assert.equal(g.verification.verified, true);
|
|
60
|
+
assert.equal(g.proceed, g.receipt.decision === "allow");
|
|
61
|
+
const r = await c.resolve("example.com");
|
|
62
|
+
assert.equal((await verifyDocument(r)).verified, true);
|
|
63
|
+
});
|