@crawlcheck/sdk 1.0.4 → 1.0.6
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 +17 -0
- package/dist/gen/openapi.d.ts +2344 -1024
- package/dist/index.d.ts +176 -2
- package/dist/index.js +70 -2
- package/dist/log.d.ts +141 -0
- package/dist/log.js +194 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +54 -3
- package/package.json +1 -1
- package/test/fixtures/split-view.json +1 -0
- package/test/sdk.test.js +21 -1
- package/test/split-view.test.js +34 -0
package/dist/index.d.ts
CHANGED
|
@@ -123,6 +123,49 @@ export declare function normalizeDomain(v: string): string;
|
|
|
123
123
|
export declare function receiptHeader(receipt: {
|
|
124
124
|
sha256: string;
|
|
125
125
|
}): Record<string, string>;
|
|
126
|
+
/** MCP tool lockfile (https://crawlcheck.io/spec/mcp-lockfile). A tool's hash is sha256 of the canonical JSON of {name, description, inputSchema, annotations}. */
|
|
127
|
+
export interface McpTool {
|
|
128
|
+
name?: string;
|
|
129
|
+
description?: string;
|
|
130
|
+
inputSchema?: unknown;
|
|
131
|
+
input_schema?: unknown;
|
|
132
|
+
annotations?: unknown;
|
|
133
|
+
[k: string]: unknown;
|
|
134
|
+
}
|
|
135
|
+
export interface McpLock {
|
|
136
|
+
tools_sha256: string;
|
|
137
|
+
tools: {
|
|
138
|
+
name: string;
|
|
139
|
+
sha256: string;
|
|
140
|
+
}[];
|
|
141
|
+
descriptions?: Record<string, string>;
|
|
142
|
+
server?: {
|
|
143
|
+
url?: string | null;
|
|
144
|
+
} | null;
|
|
145
|
+
approved_at?: string;
|
|
146
|
+
[k: string]: unknown;
|
|
147
|
+
}
|
|
148
|
+
export interface McpLockCheck {
|
|
149
|
+
verdict: "unchanged" | "changed";
|
|
150
|
+
decision: "connect" | "block";
|
|
151
|
+
approved_tools_sha256: string;
|
|
152
|
+
current_tools_sha256: string;
|
|
153
|
+
added: string[];
|
|
154
|
+
removed: string[];
|
|
155
|
+
modified: string[];
|
|
156
|
+
}
|
|
157
|
+
export declare function lockToolHash(t: McpTool): Promise<string>;
|
|
158
|
+
/** Hash a tools/list result the way a lockfile does: per tool, and the sorted per-tool hashes joined by \n. No network, no key. */
|
|
159
|
+
export declare function lockFromTools(tools: McpTool[]): Promise<{
|
|
160
|
+
tools: {
|
|
161
|
+
name: string;
|
|
162
|
+
sha256: string;
|
|
163
|
+
}[];
|
|
164
|
+
tools_sha256: string;
|
|
165
|
+
count: number;
|
|
166
|
+
}>;
|
|
167
|
+
/** Compare a lockfile with the tools a server lists now. Anything added, removed or changed means "block" until a person re-approves. Offline. */
|
|
168
|
+
export declare function checkLock(lock: McpLock, tools: McpTool[]): Promise<McpLockCheck>;
|
|
126
169
|
export interface PrivateLookup {
|
|
127
170
|
found: boolean;
|
|
128
171
|
ready: boolean;
|
|
@@ -179,6 +222,112 @@ export interface GuardOptions extends PreflightOptions {
|
|
|
179
222
|
/** Also require the signing key to be in the published key directory (default true). */
|
|
180
223
|
onlineKeys?: boolean;
|
|
181
224
|
}
|
|
225
|
+
export interface LogHead {
|
|
226
|
+
kind: "crawlcheck-log-sth";
|
|
227
|
+
log_id: string;
|
|
228
|
+
batch: number;
|
|
229
|
+
first_index: number;
|
|
230
|
+
batch_size: number;
|
|
231
|
+
tree_size: number;
|
|
232
|
+
batch_root: string;
|
|
233
|
+
prev_sth_sha256: string | null;
|
|
234
|
+
created_at: string;
|
|
235
|
+
sha256: string;
|
|
236
|
+
signature: JsonObject;
|
|
237
|
+
[k: string]: unknown;
|
|
238
|
+
}
|
|
239
|
+
export interface LogFork {
|
|
240
|
+
kind: "two_heads_for_one_batch" | "chain_break" | "size_mismatch" | "inconsistent_head";
|
|
241
|
+
batch: number;
|
|
242
|
+
why: string;
|
|
243
|
+
a: LogHead;
|
|
244
|
+
b: LogHead;
|
|
245
|
+
detected_at: string;
|
|
246
|
+
proof: string;
|
|
247
|
+
}
|
|
248
|
+
export interface LogStore {
|
|
249
|
+
v: 1;
|
|
250
|
+
log_id: string;
|
|
251
|
+
heads: Record<string, {
|
|
252
|
+
batch: number;
|
|
253
|
+
sha256: string;
|
|
254
|
+
first_index: number;
|
|
255
|
+
batch_size: number;
|
|
256
|
+
tree_size: number;
|
|
257
|
+
prev_sth_sha256: string | null;
|
|
258
|
+
created_at: string | null;
|
|
259
|
+
sth: LogHead;
|
|
260
|
+
}>;
|
|
261
|
+
forks: LogFork[];
|
|
262
|
+
witnessed: {
|
|
263
|
+
batch: number;
|
|
264
|
+
sha256: string;
|
|
265
|
+
at: string | null;
|
|
266
|
+
run_id: string | null;
|
|
267
|
+
} | null;
|
|
268
|
+
}
|
|
269
|
+
export interface LogObservation {
|
|
270
|
+
state: "new" | "known" | "fork" | "invalid" | "none";
|
|
271
|
+
why?: string;
|
|
272
|
+
fork?: LogFork;
|
|
273
|
+
}
|
|
274
|
+
export interface LogCheckResult {
|
|
275
|
+
consistent: boolean;
|
|
276
|
+
forks: LogFork[];
|
|
277
|
+
all_forks: number;
|
|
278
|
+
witnessed: {
|
|
279
|
+
batch: number;
|
|
280
|
+
sha256: string;
|
|
281
|
+
at: string | null;
|
|
282
|
+
run_id: string | null;
|
|
283
|
+
token: boolean | null;
|
|
284
|
+
why: string;
|
|
285
|
+
observed: string;
|
|
286
|
+
warning?: string;
|
|
287
|
+
} | null;
|
|
288
|
+
unwitnessed: {
|
|
289
|
+
batch: number;
|
|
290
|
+
sha256: string;
|
|
291
|
+
created_at: string | null;
|
|
292
|
+
}[];
|
|
293
|
+
stale_unwitnessed: {
|
|
294
|
+
batch: number;
|
|
295
|
+
sha256: string;
|
|
296
|
+
created_at: string | null;
|
|
297
|
+
}[];
|
|
298
|
+
summary: string;
|
|
299
|
+
}
|
|
300
|
+
export declare const LOG_ID: string;
|
|
301
|
+
/** A new, empty transparency-log store (plain JSON: persist it between runs). */
|
|
302
|
+
export declare const newLogStore: () => LogStore;
|
|
303
|
+
/** Check one signed tree head against the store and keep it. A fork carries both contradicting signed heads. */
|
|
304
|
+
export declare const logObserve: (store: LogStore, sth: unknown, opts?: {
|
|
305
|
+
publishedKids?: string[];
|
|
306
|
+
maxHeads?: number;
|
|
307
|
+
}) => Promise<LogObservation>;
|
|
308
|
+
/** Check the head inside a Resolve answer (transparency.sth), if it carries one. */
|
|
309
|
+
export declare const logObserveAnswer: (store: LogStore, answer: unknown, opts?: {
|
|
310
|
+
publishedKids?: string[];
|
|
311
|
+
}) => Promise<LogObservation>;
|
|
312
|
+
/** The signed heads a store holds, newest first: what a client sends its peers. */
|
|
313
|
+
export declare const logHeads: (store: LogStore, limit?: number) => LogHead[];
|
|
314
|
+
/** Gossip: check another client's heads against this store. */
|
|
315
|
+
export declare const logGossip: (store: LogStore, sths: unknown[], opts?: {
|
|
316
|
+
publishedKids?: string[];
|
|
317
|
+
}) => Promise<{
|
|
318
|
+
forks: LogFork[];
|
|
319
|
+
results: (LogObservation & {
|
|
320
|
+
batch: number;
|
|
321
|
+
sha256: string;
|
|
322
|
+
})[];
|
|
323
|
+
}>;
|
|
324
|
+
/** Verify the outside witness's co-signature: a GitHub OIDC JWT (RS256) whose audience is crawlcheck-sth:<head sha256>. */
|
|
325
|
+
export declare const verifyWitnessToken: (token: string, sthSha256: string, jwks: unknown) => Promise<{
|
|
326
|
+
ok: boolean | null;
|
|
327
|
+
why: string;
|
|
328
|
+
claims?: JsonObject;
|
|
329
|
+
}>;
|
|
330
|
+
export declare const GITHUB_OIDC_JWKS = "https://token.actions.githubusercontent.com/.well-known/jwks";
|
|
182
331
|
export interface ClientOptions {
|
|
183
332
|
/** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
|
|
184
333
|
key?: string;
|
|
@@ -188,6 +337,8 @@ export interface ClientOptions {
|
|
|
188
337
|
fetch?: typeof fetch;
|
|
189
338
|
/** Extra headers for every request. */
|
|
190
339
|
headers?: Record<string, string>;
|
|
340
|
+
/** Transparency-log store (newLogStore()). Every Resolve answer's signed tree head is checked against it; keep it across runs to catch a split view. Default: a fresh in-memory store. */
|
|
341
|
+
logStore?: LogStore;
|
|
191
342
|
}
|
|
192
343
|
export declare class CrawlCheckError extends Error {
|
|
193
344
|
readonly status: number;
|
|
@@ -209,15 +360,31 @@ export declare class CrawlCheck {
|
|
|
209
360
|
private readonly key?;
|
|
210
361
|
private readonly f;
|
|
211
362
|
private readonly extra;
|
|
363
|
+
/** The transparency-log store this client checks every signed tree head against. Persist it (JSON) between runs. */
|
|
364
|
+
readonly log: LogStore;
|
|
212
365
|
constructor(opts?: ClientOptions);
|
|
213
366
|
/** Any documented GET, typed from the OpenAPI document: client.get("/api/explain", { domain: "example.com" }). */
|
|
214
367
|
get<P extends keyof paths>(path: P, query?: QueryOf<P, "get">): Promise<ResponseOf<P, "get">>;
|
|
215
368
|
/** Any documented POST, typed from the OpenAPI document. */
|
|
216
369
|
post<P extends keyof paths>(path: P, body: BodyOf<P> | JsonObject, query?: QueryOf<P, "post">): Promise<ResponseOf<P, "post">>;
|
|
217
370
|
/** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
|
|
218
|
-
resolve(domain: string): Promise<
|
|
371
|
+
resolve(domain: string): Promise<ResponseOf<"/api/v1/resolve", "get">>;
|
|
219
372
|
/** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
|
|
220
|
-
resolveBatch(domains: string[]): Promise<
|
|
373
|
+
resolveBatch(domains: string[]): Promise<any>;
|
|
374
|
+
/** Split-view check: the current head, GitHub's witness co-signature on the last witnessed head, and the chain from it down to every head this client holds. Reports the heads it holds to /api/v1/log/gossip unless report is false. */
|
|
375
|
+
logCheck(opts?: {
|
|
376
|
+
report?: boolean;
|
|
377
|
+
maxWalk?: number;
|
|
378
|
+
maxUnwitnessedMinutes?: number;
|
|
379
|
+
}): Promise<LogCheckResult>;
|
|
380
|
+
/** Check heads another client sent (logHeads on their side) against this client's store. */
|
|
381
|
+
logGossip(sths: unknown[]): Promise<{
|
|
382
|
+
forks: LogFork[];
|
|
383
|
+
results: (LogObservation & {
|
|
384
|
+
batch: number;
|
|
385
|
+
sha256: string;
|
|
386
|
+
})[];
|
|
387
|
+
}>;
|
|
221
388
|
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
222
389
|
preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
|
|
223
390
|
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
|
|
@@ -228,8 +395,15 @@ export declare class CrawlCheck {
|
|
|
228
395
|
receipt: DecisionReceipt;
|
|
229
396
|
verification: DocumentVerification & Trust;
|
|
230
397
|
header: Record<string, string> | null;
|
|
398
|
+
split_view?: LogFork[];
|
|
231
399
|
}>;
|
|
232
400
|
/** 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. */
|
|
401
|
+
/** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
|
|
402
|
+
mcpLock(url: string): Promise<McpLock & JsonObject>;
|
|
403
|
+
/** Read the server's tools now (through CrawlCheck) and compare with a lockfile; the answer is signed. For a fully offline check, list the tools yourself and call checkLock. */
|
|
404
|
+
mcpLockCheck(lock: McpLock, url?: string): Promise<McpLockCheck & JsonObject>;
|
|
405
|
+
/** Tool-poisoning scan of a server URL or a tool list. */
|
|
406
|
+
mcpScan(target: string | McpTool[]): Promise<JsonObject>;
|
|
233
407
|
resolvePrivate(domain: string, prefixLen?: number): Promise<PrivateLookup>;
|
|
234
408
|
/** 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
409
|
checkReceiptHeader(headerValue: string, host: string): Promise<ReceiptCheck>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as V from "./verify.js";
|
|
2
|
+
import * as L from "./log.js";
|
|
2
3
|
function withTrust(v, kid, published) {
|
|
3
4
|
let checks = v.checks;
|
|
4
5
|
let issuer = null;
|
|
@@ -39,6 +40,42 @@ async function sha256Hex(s) {
|
|
|
39
40
|
const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
|
40
41
|
return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
41
42
|
}
|
|
43
|
+
export async function lockToolHash(t) {
|
|
44
|
+
return sha256Hex(V.canon({ name: t?.name ?? null, description: t?.description ?? null, inputSchema: t?.inputSchema ?? t?.input_schema ?? null, annotations: t?.annotations ?? null }));
|
|
45
|
+
}
|
|
46
|
+
/** Hash a tools/list result the way a lockfile does: per tool, and the sorted per-tool hashes joined by \n. No network, no key. */
|
|
47
|
+
export async function lockFromTools(tools) {
|
|
48
|
+
const rows = [];
|
|
49
|
+
for (const t of (tools ?? []).slice(0, 300))
|
|
50
|
+
rows.push({ name: String(t?.name ?? "").slice(0, 120), sha256: await lockToolHash(t) });
|
|
51
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.sha256 < b.sha256 ? -1 : 1));
|
|
52
|
+
return { tools: rows, tools_sha256: await sha256Hex(rows.map((r) => r.sha256).sort().join("\n")), count: rows.length };
|
|
53
|
+
}
|
|
54
|
+
/** Compare a lockfile with the tools a server lists now. Anything added, removed or changed means "block" until a person re-approves. Offline. */
|
|
55
|
+
export async function checkLock(lock, tools) {
|
|
56
|
+
const now = await lockFromTools(tools);
|
|
57
|
+
const was = {};
|
|
58
|
+
(lock?.tools ?? []).forEach((r) => { was[r.name] = r.sha256; });
|
|
59
|
+
const cur = {};
|
|
60
|
+
now.tools.forEach((r) => { cur[r.name] = r.sha256; });
|
|
61
|
+
const added = Object.keys(cur).filter((n) => !(n in was)), removed = Object.keys(was).filter((n) => !(n in cur)), modified = Object.keys(cur).filter((n) => n in was && was[n] !== cur[n]);
|
|
62
|
+
const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
|
|
63
|
+
return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified };
|
|
64
|
+
}
|
|
65
|
+
export const LOG_ID = L.LOG_ID;
|
|
66
|
+
/** A new, empty transparency-log store (plain JSON: persist it between runs). */
|
|
67
|
+
export const newLogStore = L.newLogStore;
|
|
68
|
+
/** Check one signed tree head against the store and keep it. A fork carries both contradicting signed heads. */
|
|
69
|
+
export const logObserve = L.logObserve;
|
|
70
|
+
/** Check the head inside a Resolve answer (transparency.sth), if it carries one. */
|
|
71
|
+
export const logObserveAnswer = L.logObserveAnswer;
|
|
72
|
+
/** The signed heads a store holds, newest first: what a client sends its peers. */
|
|
73
|
+
export const logHeads = L.logHeads;
|
|
74
|
+
/** Gossip: check another client's heads against this store. */
|
|
75
|
+
export const logGossip = L.logGossip;
|
|
76
|
+
/** Verify the outside witness's co-signature: a GitHub OIDC JWT (RS256) whose audience is crawlcheck-sth:<head sha256>. */
|
|
77
|
+
export const verifyWitnessToken = L.verifyWitnessToken;
|
|
78
|
+
export const GITHUB_OIDC_JWKS = "https://token.actions.githubusercontent.com/.well-known/jwks";
|
|
42
79
|
export class CrawlCheckError extends Error {
|
|
43
80
|
status;
|
|
44
81
|
body;
|
|
@@ -59,10 +96,13 @@ export class CrawlCheck {
|
|
|
59
96
|
key;
|
|
60
97
|
f;
|
|
61
98
|
extra;
|
|
99
|
+
/** The transparency-log store this client checks every signed tree head against. Persist it (JSON) between runs. */
|
|
100
|
+
log;
|
|
62
101
|
constructor(opts = {}) {
|
|
63
102
|
this.base = (opts.base ?? "https://crawlcheck.io").replace(/\/+$/, "");
|
|
64
103
|
this.key = opts.key;
|
|
65
104
|
this.extra = opts.headers ?? {};
|
|
105
|
+
this.log = opts.logStore ?? newLogStore();
|
|
66
106
|
const f = opts.fetch ?? globalThis.fetch;
|
|
67
107
|
if (typeof f !== "function")
|
|
68
108
|
throw new Error("no fetch available: pass { fetch } (Node 18+ and every browser have one)");
|
|
@@ -78,9 +118,27 @@ export class CrawlCheck {
|
|
|
78
118
|
}
|
|
79
119
|
// ── read before acting ──
|
|
80
120
|
/** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
|
|
81
|
-
resolve(domain) {
|
|
121
|
+
async resolve(domain) {
|
|
122
|
+
const a = await this.get("/api/v1/resolve", { domain });
|
|
123
|
+
await logObserveAnswer(this.log, a).catch(() => null); // a head that contradicts one this client already holds is recorded in this.log.forks
|
|
124
|
+
return a;
|
|
125
|
+
}
|
|
82
126
|
/** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
|
|
83
|
-
resolveBatch(domains) {
|
|
127
|
+
async resolveBatch(domains) {
|
|
128
|
+
const r = await this.post("/api/v1/resolve", { domains });
|
|
129
|
+
for (const a of (r && r.answers) || [])
|
|
130
|
+
await logObserveAnswer(this.log, a).catch(() => null);
|
|
131
|
+
return r;
|
|
132
|
+
}
|
|
133
|
+
/** Split-view check: the current head, GitHub's witness co-signature on the last witnessed head, and the chain from it down to every head this client holds. Reports the heads it holds to /api/v1/log/gossip unless report is false. */
|
|
134
|
+
async logCheck(opts = {}) {
|
|
135
|
+
const kids = await this.publishedKeyIds();
|
|
136
|
+
return L.logCheck(this.log, (p) => this.req("GET", p), { publishedKids: kids, maxWalk: opts.maxWalk, maxUnwitnessedMinutes: opts.maxUnwitnessedMinutes,
|
|
137
|
+
fetchJwks: async () => { const r = await this.f(GITHUB_OIDC_JWKS); return r.ok ? r.json() : null; },
|
|
138
|
+
post: opts.report === false ? undefined : (p, b) => this.req("POST", p, undefined, b) });
|
|
139
|
+
}
|
|
140
|
+
/** Check heads another client sent (logHeads on their side) against this client's store. */
|
|
141
|
+
logGossip(sths) { return logGossip(this.log, sths); }
|
|
84
142
|
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
85
143
|
async preflight(domain, action = "read", opts = {}) {
|
|
86
144
|
const b = { domain, action };
|
|
@@ -99,6 +157,10 @@ export class CrawlCheck {
|
|
|
99
157
|
/** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
|
|
100
158
|
async guard(domain, action = "read", opts = {}) {
|
|
101
159
|
const receipt = await this.preflight(domain, action, opts);
|
|
160
|
+
if (this.log.forks.length) { // the log has shown this client two contradicting histories: no answer from it is trusted for action
|
|
161
|
+
const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
|
|
162
|
+
return { proceed: false, receipt, verification, header: null, split_view: this.log.forks.slice() };
|
|
163
|
+
}
|
|
102
164
|
const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
|
|
103
165
|
if (!verification.accepted && (opts.onlineKeys ?? true))
|
|
104
166
|
return { proceed: false, receipt, verification, header: null };
|
|
@@ -111,6 +173,12 @@ export class CrawlCheck {
|
|
|
111
173
|
return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null };
|
|
112
174
|
}
|
|
113
175
|
/** 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. */
|
|
176
|
+
/** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
|
|
177
|
+
async mcpLock(url) { return this.req("GET", "/api/v1/mcp/lock", { url }); }
|
|
178
|
+
/** Read the server's tools now (through CrawlCheck) and compare with a lockfile; the answer is signed. For a fully offline check, list the tools yourself and call checkLock. */
|
|
179
|
+
async mcpLockCheck(lock, url) { return this.req("POST", "/api/v1/mcp/lock/check", undefined, { lock, url: url ?? lock?.server?.url ?? undefined }); }
|
|
180
|
+
/** Tool-poisoning scan of a server URL or a tool list. */
|
|
181
|
+
async mcpScan(target) { return (typeof target === "string" ? this.req("GET", "/api/v1/mcp/scan", { url: target }) : this.req("POST", "/api/v1/mcp/scan", undefined, { tools: target })); }
|
|
114
182
|
async resolvePrivate(domain, prefixLen = 2) {
|
|
115
183
|
const d = normalizeDomain(domain);
|
|
116
184
|
if (!d)
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** A new, empty store. It is plain JSON: keep it across runs (a file, a KV value) so later heads are checked against earlier ones. */
|
|
2
|
+
export function newLogStore(): {
|
|
3
|
+
v: number;
|
|
4
|
+
log_id: string;
|
|
5
|
+
heads: {};
|
|
6
|
+
forks: never[];
|
|
7
|
+
witnessed: null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Check one signed tree head (kind crawlcheck-log-sth) against everything the store already holds, then keep it.
|
|
11
|
+
* Returns { state: "new" | "known" | "fork" | "invalid", why?, fork? }. invalid means the document is not a head
|
|
12
|
+
* CrawlCheck signed (it proves nothing either way); fork means two signed heads that contradict each other.
|
|
13
|
+
* opts.publishedKids: key ids from the published directory; a head signed by another key is invalid.
|
|
14
|
+
*/
|
|
15
|
+
export function logObserve(store: any, sth: any, opts?: {}): Promise<{
|
|
16
|
+
state: string;
|
|
17
|
+
fork: {
|
|
18
|
+
kind: any;
|
|
19
|
+
batch: any;
|
|
20
|
+
why: any;
|
|
21
|
+
a: any;
|
|
22
|
+
b: any;
|
|
23
|
+
detected_at: string;
|
|
24
|
+
proof: string;
|
|
25
|
+
};
|
|
26
|
+
} | {
|
|
27
|
+
state: string;
|
|
28
|
+
why: string;
|
|
29
|
+
} | {
|
|
30
|
+
state: string;
|
|
31
|
+
why?: undefined;
|
|
32
|
+
}>;
|
|
33
|
+
/** Observe the head inside a Resolve answer (transparency.sth), if it has one. */
|
|
34
|
+
export function logObserveAnswer(store: any, answer: any, opts?: {}): Promise<{
|
|
35
|
+
state: string;
|
|
36
|
+
fork: {
|
|
37
|
+
kind: any;
|
|
38
|
+
batch: any;
|
|
39
|
+
why: any;
|
|
40
|
+
a: any;
|
|
41
|
+
b: any;
|
|
42
|
+
detected_at: string;
|
|
43
|
+
proof: string;
|
|
44
|
+
};
|
|
45
|
+
} | {
|
|
46
|
+
state: string;
|
|
47
|
+
why: string;
|
|
48
|
+
} | {
|
|
49
|
+
state: string;
|
|
50
|
+
why?: undefined;
|
|
51
|
+
} | {
|
|
52
|
+
state: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** The signed heads this store holds, newest first: what a client sends its peers. */
|
|
55
|
+
export function logHeads(store: any, limit?: number): any[];
|
|
56
|
+
/** Gossip: check heads another client holds against this store. Returns { forks, results }. */
|
|
57
|
+
export function logGossip(store: any, sths: any, opts?: {}): Promise<{
|
|
58
|
+
forks: any;
|
|
59
|
+
results: (({
|
|
60
|
+
batch: any;
|
|
61
|
+
sha256: any;
|
|
62
|
+
} & {
|
|
63
|
+
state: string;
|
|
64
|
+
fork: {
|
|
65
|
+
kind: any;
|
|
66
|
+
batch: any;
|
|
67
|
+
why: any;
|
|
68
|
+
a: any;
|
|
69
|
+
b: any;
|
|
70
|
+
detected_at: string;
|
|
71
|
+
proof: string;
|
|
72
|
+
};
|
|
73
|
+
}) | ({
|
|
74
|
+
batch: any;
|
|
75
|
+
sha256: any;
|
|
76
|
+
} & {
|
|
77
|
+
state: string;
|
|
78
|
+
why: string;
|
|
79
|
+
}) | ({
|
|
80
|
+
batch: any;
|
|
81
|
+
sha256: any;
|
|
82
|
+
} & {
|
|
83
|
+
state: string;
|
|
84
|
+
why?: undefined;
|
|
85
|
+
}))[];
|
|
86
|
+
}>;
|
|
87
|
+
/**
|
|
88
|
+
* Verify a witness co-signature: the JWT's RS256 signature against GitHub's published keys (jwks: the JSON from
|
|
89
|
+
* https://token.actions.githubusercontent.com/.well-known/jwks), its issuer, repository, workflow and audience
|
|
90
|
+
* crawlcheck-sth:<sthSha256>. exp is not checked: the token is a record of what the witness saw when it ran.
|
|
91
|
+
* Returns { ok: true | false | null, why, claims? }; null when GitHub no longer publishes the key that signed it.
|
|
92
|
+
*/
|
|
93
|
+
export function verifyWitnessToken(token: any, sthSha256: any, jwks: any): Promise<{
|
|
94
|
+
ok: boolean;
|
|
95
|
+
why: string;
|
|
96
|
+
claims?: undefined;
|
|
97
|
+
} | {
|
|
98
|
+
ok: null;
|
|
99
|
+
why: string;
|
|
100
|
+
claims: any;
|
|
101
|
+
} | {
|
|
102
|
+
ok: boolean;
|
|
103
|
+
why: string;
|
|
104
|
+
claims: any;
|
|
105
|
+
}>;
|
|
106
|
+
/**
|
|
107
|
+
* Online check of a store against the log and its outside witness. get(path) must return parsed JSON from
|
|
108
|
+
* https://crawlcheck.io (the SDK clients pass their own). Steps: observe the current head; fetch the last witnessed
|
|
109
|
+
* head and verify GitHub's co-signature; walk the chain from the witnessed head down to the oldest head this store
|
|
110
|
+
* holds (at most opts.maxWalk batches), observing every link, so any head this client was shown that the witness's
|
|
111
|
+
* chain does not contain surfaces as a fork. Heads newer than the witnessed one are listed as unwitnessed.
|
|
112
|
+
*/
|
|
113
|
+
export function logCheck(store: any, get: any, opts?: {}): Promise<{
|
|
114
|
+
consistent: boolean;
|
|
115
|
+
forks: any;
|
|
116
|
+
all_forks: any;
|
|
117
|
+
witnessed: {
|
|
118
|
+
batch: any;
|
|
119
|
+
sha256: any;
|
|
120
|
+
at: any;
|
|
121
|
+
run_id: any;
|
|
122
|
+
token: boolean | null;
|
|
123
|
+
why: string;
|
|
124
|
+
observed: string;
|
|
125
|
+
} | null;
|
|
126
|
+
unwitnessed: {
|
|
127
|
+
batch: number;
|
|
128
|
+
sha256: any;
|
|
129
|
+
created_at: any;
|
|
130
|
+
}[];
|
|
131
|
+
stale_unwitnessed: {
|
|
132
|
+
batch: number;
|
|
133
|
+
sha256: any;
|
|
134
|
+
created_at: any;
|
|
135
|
+
}[];
|
|
136
|
+
summary: string;
|
|
137
|
+
}>;
|
|
138
|
+
export const LOG_ID: "crawlcheck-resolve-log-v1";
|
|
139
|
+
export const GITHUB_OIDC_ISSUER: "https://token.actions.githubusercontent.com";
|
|
140
|
+
export const WITNESS_REPOSITORY: "emmanuelorta/crawlcheck";
|
|
141
|
+
export const WITNESS_WORKFLOW_PREFIX: "emmanuelorta/crawlcheck/.github/workflows/log-witness.yml@refs/heads/";
|