@zanii/blackbox 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a2a/index.d.ts +50 -0
- package/dist/a2a/index.js +202 -0
- package/dist/analysis/faults.js +40 -0
- package/dist/analysis/taxonomy.d.ts +18 -0
- package/dist/analysis/taxonomy.js +65 -0
- package/dist/archive/index.d.ts +39 -0
- package/dist/archive/index.js +96 -0
- package/dist/bom/index.d.ts +14 -0
- package/dist/bom/index.js +132 -0
- package/dist/cli.js +38 -0
- package/dist/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +163 -0
- package/dist/data/index.d.ts +191 -0
- package/dist/data/index.js +762 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/ocsf/index.d.ts +1 -1
- package/dist/ocsf/index.js +36 -3
- package/dist/otlp/index.d.ts +8 -1
- package/dist/otlp/index.js +231 -1
- package/dist/packs/index.d.ts +8 -0
- package/dist/packs/index.js +128 -2
- package/dist/policy/index.d.ts +20 -3
- package/dist/policy/index.js +39 -4
- package/dist/session/index.d.ts +37 -0
- package/dist/session/index.js +69 -0
- package/dist/timestamp/index.d.ts +24 -0
- package/dist/timestamp/index.js +274 -0
- package/dist/transparency/index.d.ts +188 -0
- package/dist/transparency/index.js +712 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/policy/index.js
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
import { callsOf } from "../reconcile/record.js";
|
|
3
3
|
import { canonical } from "../reconcile/shared.js";
|
|
4
4
|
import { checkCompensations } from "../undo/index.js";
|
|
5
|
+
const HINTS = ["read_only", "destructive", "idempotent", "open_world"];
|
|
6
|
+
/**
|
|
7
|
+
* spec/policy.md §1: a tool's annotations (MCP `readOnlyHint`, `destructiveHint`, `idempotentHint`,
|
|
8
|
+
* `openWorldHint`) as hints. Missing ones take MCP's defaults: not read-only, destructive, not
|
|
9
|
+
* idempotent, open-world; destructive and idempotent only mean something for a tool that writes.
|
|
10
|
+
* An unknown tool gets the defaults too. Hints are the server's word, not proof.
|
|
11
|
+
*/
|
|
12
|
+
export function mcpToolHints(annotations) {
|
|
13
|
+
const a = (typeof annotations === "object" && annotations !== null ? annotations : {});
|
|
14
|
+
const readOnly = a.readOnlyHint === true;
|
|
15
|
+
return {
|
|
16
|
+
read_only: readOnly,
|
|
17
|
+
destructive: !readOnly && a.destructiveHint !== false,
|
|
18
|
+
idempotent: !readOnly && a.idempotentHint === true,
|
|
19
|
+
open_world: a.openWorldHint !== false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const hintsMatch = (want, have) => want === undefined ||
|
|
23
|
+
(have !== undefined && Object.entries(want).every(([k, v]) => have[k] === v));
|
|
5
24
|
const MAX_ARGS = 64 * 1024;
|
|
6
25
|
/** Escapes a string for use inside a regular expression. */
|
|
7
26
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -26,6 +45,15 @@ export function loadPolicy(bytes) {
|
|
|
26
45
|
}
|
|
27
46
|
if (r.audit !== undefined && typeof r.audit !== "boolean")
|
|
28
47
|
throw new Error(`policy ${r.id}: audit must be true or false`);
|
|
48
|
+
if (r.hints !== undefined) {
|
|
49
|
+
const h = r.hints;
|
|
50
|
+
if (typeof h !== "object" ||
|
|
51
|
+
h === null ||
|
|
52
|
+
Array.isArray(h) ||
|
|
53
|
+
Object.keys(h).length === 0 ||
|
|
54
|
+
!Object.entries(h).every(([k, v]) => HINTS.includes(k) && typeof v === "boolean"))
|
|
55
|
+
throw new Error(`policy ${r.id}: hints must set some of read_only, destructive, idempotent, open_world to true or false`);
|
|
56
|
+
}
|
|
29
57
|
}
|
|
30
58
|
const ids = doc.rules.map((r) => r.id);
|
|
31
59
|
const twice = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
@@ -45,7 +73,10 @@ function checkShadowed(rules) {
|
|
|
45
73
|
.slice(0, j)
|
|
46
74
|
.find((e) => glob(e.tool).test(later.tool) &&
|
|
47
75
|
(e.args_match === undefined ||
|
|
48
|
-
(e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case))
|
|
76
|
+
(e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case)) &&
|
|
77
|
+
(e.hints === undefined ||
|
|
78
|
+
(later.hints !== undefined &&
|
|
79
|
+
Object.entries(e.hints).every(([k, v]) => later.hints[k] === v))));
|
|
49
80
|
if (first && first.action !== later.action)
|
|
50
81
|
throw new Error(`policy ${later.id}: never applies: ${first.id} matches the same calls first (${first.action})`);
|
|
51
82
|
});
|
|
@@ -59,12 +90,15 @@ export function compilePolicy(policy) {
|
|
|
59
90
|
: new RegExp(rule.args_match, rule.ignore_case ? "i" : ""),
|
|
60
91
|
}));
|
|
61
92
|
}
|
|
62
|
-
/** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
|
|
63
|
-
|
|
93
|
+
/** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
|
|
94
|
+
* has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without. */
|
|
95
|
+
export function decide(compiled, names, args, hints) {
|
|
64
96
|
let text = null;
|
|
65
97
|
for (const c of compiled) {
|
|
66
98
|
if (c.rule.audit === true || !names.some((n) => c.tool.test(n)))
|
|
67
99
|
continue;
|
|
100
|
+
if (!hintsMatch(c.rule.hints, hints))
|
|
101
|
+
continue;
|
|
68
102
|
if (c.args) {
|
|
69
103
|
text ??= canonical(args ?? null).slice(0, MAX_ARGS);
|
|
70
104
|
if (!c.args.test(text))
|
|
@@ -79,10 +113,11 @@ export function decide(compiled, names, args) {
|
|
|
79
113
|
return null;
|
|
80
114
|
}
|
|
81
115
|
/** N3 (idea C5): every audit rule this call matches, and what it would do if it were enforced. */
|
|
82
|
-
export function audited(compiled, names, args) {
|
|
116
|
+
export function audited(compiled, names, args, hints) {
|
|
83
117
|
const text = canonical(args ?? null).slice(0, MAX_ARGS);
|
|
84
118
|
return compiled
|
|
85
119
|
.filter((c) => c.rule.audit === true && names.some((n) => c.tool.test(n)))
|
|
120
|
+
.filter((c) => hintsMatch(c.rule.hints, hints))
|
|
86
121
|
.filter((c) => !c.args || c.args.test(text))
|
|
87
122
|
.map((c) => ({ rule: c.rule.id, would: c.rule.action }));
|
|
88
123
|
}
|
package/dist/session/index.d.ts
CHANGED
|
@@ -76,6 +76,16 @@ export interface FlightPlan {
|
|
|
76
76
|
}
|
|
77
77
|
/** Audit S17: what the gateway says about this session right now (GET /v1/sessions/:id/state). */
|
|
78
78
|
/** What `land()` found (spec/findings.md §5). */
|
|
79
|
+
export interface EgressCheck {
|
|
80
|
+
url: string;
|
|
81
|
+
/** The request got any answer: the agent can reach the internet around the gateway. */
|
|
82
|
+
open: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
|
|
85
|
+
export declare function checkEgress(options?: {
|
|
86
|
+
url?: string;
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
}): Promise<EgressCheck>;
|
|
79
89
|
export interface LandingResult {
|
|
80
90
|
/** Only a `satisfied` verdict lands: `unverified` never counts as a pass. */
|
|
81
91
|
landed: boolean;
|
|
@@ -115,6 +125,8 @@ export interface LlmCallIds {
|
|
|
115
125
|
}
|
|
116
126
|
/** N4 (idea R6): the same parts always give the same event id (sha256, 128 bits). */
|
|
117
127
|
export declare function stableEventId(...parts: readonly string[]): string;
|
|
128
|
+
/** The image type of a screenshot, by its first bytes: PNG, JPEG or WebP. */
|
|
129
|
+
export declare function imageType(image: Uint8Array): "image/png" | "image/jpeg" | "image/webp" | null;
|
|
118
130
|
/** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
|
|
119
131
|
export declare function session(options: SessionOptions): Promise<BlackboxSession>;
|
|
120
132
|
export declare class BlackboxSession {
|
|
@@ -142,6 +154,11 @@ export declare class BlackboxSession {
|
|
|
142
154
|
keepToken?: boolean);
|
|
143
155
|
event(type: string, name?: string, data?: Record<string, unknown>, options?: {
|
|
144
156
|
eventId?: string;
|
|
157
|
+
/** spec/sdk.md §1.1: an image the event's body is (see `screen`). */
|
|
158
|
+
attachment?: {
|
|
159
|
+
contentType: string;
|
|
160
|
+
bytes: Uint8Array;
|
|
161
|
+
};
|
|
145
162
|
}): void;
|
|
146
163
|
step(name: string, data?: Record<string, unknown>): void;
|
|
147
164
|
toolCall(name: string, args?: unknown): void;
|
|
@@ -149,6 +166,15 @@ export declare class BlackboxSession {
|
|
|
149
166
|
ok?: boolean;
|
|
150
167
|
[key: string]: unknown;
|
|
151
168
|
}): void;
|
|
169
|
+
/** spec/data.md §7: a subject's consent for a purpose. The subject is fingerprinted at capture,
|
|
170
|
+
* so it must be an identifier the deployment knows (an e-mail, an Emirates ID, a pack's own). */
|
|
171
|
+
consent(subject: string, purpose: string, granted: boolean): void;
|
|
172
|
+
/** spec/data.md §6: tries the internet directly, outside the gateway, and records the answer.
|
|
173
|
+
* `open: true` means the gateway isn't the only way out (EGRESS_OPEN). */
|
|
174
|
+
egressCheck(options?: {
|
|
175
|
+
url?: string;
|
|
176
|
+
timeoutMs?: number;
|
|
177
|
+
}): Promise<EgressCheck>;
|
|
152
178
|
llmCall(ids: LlmCallIds): void;
|
|
153
179
|
note(text: string): void;
|
|
154
180
|
/** Files the flight plan (spec/findings.md §5), unless one was filed when the session opened. */
|
|
@@ -196,6 +222,17 @@ export declare class BlackboxSession {
|
|
|
196
222
|
nearMiss(description: string): void;
|
|
197
223
|
/** spec/attestation.md: re-runs a read-only command and records whether the output matches. */
|
|
198
224
|
attest(argv: string[], claimed: string, cwd?: string, claimedExit?: number): Attestation | undefined;
|
|
225
|
+
/**
|
|
226
|
+
* spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
|
|
227
|
+
* body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
|
|
228
|
+
* about to do (≤ 500 characters). Mask what mustn't be kept before calling: the image isn't
|
|
229
|
+
* redacted. One that's too big or not an image is dropped, and the event says why.
|
|
230
|
+
*/
|
|
231
|
+
screen(image: Uint8Array, options?: {
|
|
232
|
+
action?: string;
|
|
233
|
+
width?: number;
|
|
234
|
+
height?: number;
|
|
235
|
+
}): void;
|
|
199
236
|
/** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
|
|
200
237
|
memoryWrite(memoryId: string, summary?: string): void;
|
|
201
238
|
memoryRevoke(memoryId: string, reason?: string): void;
|
package/dist/session/index.js
CHANGED
|
@@ -7,6 +7,17 @@ import { request as httpsRequest } from "node:https";
|
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { attest, isReadOnly } from "../attest/index.js";
|
|
10
|
+
/** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
|
|
11
|
+
export async function checkEgress(options = {}) {
|
|
12
|
+
const url = options.url ?? "https://example.com";
|
|
13
|
+
try {
|
|
14
|
+
await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(options.timeoutMs ?? 5000) });
|
|
15
|
+
return { url, open: true };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { url, open: false };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
const TYPE = /^[a-z][a-z0-9_.]{0,63}$/;
|
|
11
22
|
/** N4 (idea R6): a caller's logical id for an event, so a re-sent one is recorded as a duplicate. */
|
|
12
23
|
const EVENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
@@ -19,6 +30,20 @@ const MAX_DATA = 64 * 1024;
|
|
|
19
30
|
const PREVIEW = 4096;
|
|
20
31
|
const BATCH = 500;
|
|
21
32
|
const READ_CHUNK = 4 * 1024 * 1024;
|
|
33
|
+
/** spec/sdk.md §1.1: a screenshot's bytes at most; base64 in its spool line, it stays under READ_CHUNK. */
|
|
34
|
+
const MAX_ATTACHMENT = 2 * 1024 * 1024;
|
|
35
|
+
/** The image type of a screenshot, by its first bytes: PNG, JPEG or WebP. */
|
|
36
|
+
export function imageType(image) {
|
|
37
|
+
const b = Buffer.from(image.buffer, image.byteOffset, image.byteLength);
|
|
38
|
+
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
39
|
+
return "image/png";
|
|
40
|
+
if (b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
|
|
41
|
+
return "image/jpeg";
|
|
42
|
+
if (b.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
43
|
+
b.subarray(8, 12).toString("latin1") === "WEBP")
|
|
44
|
+
return "image/webp";
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
22
47
|
const LOCK_WAIT_MS = 2_000;
|
|
23
48
|
const LOCK_STALE_MS = 10_000;
|
|
24
49
|
/** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
|
|
@@ -159,6 +184,14 @@ export class BlackboxSession {
|
|
|
159
184
|
...(eventId === undefined ? {} : { event_id: eventId }),
|
|
160
185
|
ts: new Date().toISOString(),
|
|
161
186
|
...(kept === undefined ? {} : { data: kept }),
|
|
187
|
+
...(options.attachment
|
|
188
|
+
? {
|
|
189
|
+
attachment: {
|
|
190
|
+
content_type: options.attachment.contentType,
|
|
191
|
+
data: Buffer.from(options.attachment.bytes).toString("base64"),
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
162
195
|
});
|
|
163
196
|
appendFileSync(this.spool, `${line}\n`);
|
|
164
197
|
this.nextSeq++;
|
|
@@ -181,6 +214,18 @@ export class BlackboxSession {
|
|
|
181
214
|
toolResult(name, result) {
|
|
182
215
|
this.event("tool.result", name, result);
|
|
183
216
|
}
|
|
217
|
+
/** spec/data.md §7: a subject's consent for a purpose. The subject is fingerprinted at capture,
|
|
218
|
+
* so it must be an identifier the deployment knows (an e-mail, an Emirates ID, a pack's own). */
|
|
219
|
+
consent(subject, purpose, granted) {
|
|
220
|
+
this.event("consent", granted ? "granted" : "withdrawn", { subject, purpose });
|
|
221
|
+
}
|
|
222
|
+
/** spec/data.md §6: tries the internet directly, outside the gateway, and records the answer.
|
|
223
|
+
* `open: true` means the gateway isn't the only way out (EGRESS_OPEN). */
|
|
224
|
+
async egressCheck(options = {}) {
|
|
225
|
+
const result = await checkEgress(options);
|
|
226
|
+
this.event("egress_check", result.open ? "open" : "closed", { url: result.url });
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
184
229
|
llmCall(ids) {
|
|
185
230
|
this.event("llm.call", ids.provider, { ...ids });
|
|
186
231
|
}
|
|
@@ -268,6 +313,30 @@ export class BlackboxSession {
|
|
|
268
313
|
this.event("attestation", argv[0], { ...result });
|
|
269
314
|
return result;
|
|
270
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
|
|
318
|
+
* body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
|
|
319
|
+
* about to do (≤ 500 characters). Mask what mustn't be kept before calling: the image isn't
|
|
320
|
+
* redacted. One that's too big or not an image is dropped, and the event says why.
|
|
321
|
+
*/
|
|
322
|
+
screen(image, options = {}) {
|
|
323
|
+
const data = {
|
|
324
|
+
...(options.action ? { action: options.action.slice(0, 500) } : {}),
|
|
325
|
+
...(Number.isSafeInteger(options.width) ? { width: options.width } : {}),
|
|
326
|
+
...(Number.isSafeInteger(options.height) ? { height: options.height } : {}),
|
|
327
|
+
};
|
|
328
|
+
const type = imageType(image);
|
|
329
|
+
if (!type || image.length > MAX_ATTACHMENT) {
|
|
330
|
+
this.event("screen", undefined, {
|
|
331
|
+
...data,
|
|
332
|
+
attachment_dropped: type
|
|
333
|
+
? `${image.length} bytes, over 2 MB`
|
|
334
|
+
: "not a PNG, JPEG or WebP image",
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.event("screen", undefined, data, { attachment: { contentType: type, bytes: image } });
|
|
339
|
+
}
|
|
271
340
|
/** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
|
|
272
341
|
memoryWrite(memoryId, summary) {
|
|
273
342
|
this.event("memory.write", undefined, { memory_id: memoryId, ...(summary ? { summary } : {}) });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A DER TimeStampReq for a SHA-256 digest, asking for the TSA's certificate; with a nonce when given. */
|
|
2
|
+
export declare function timestampRequest(digest: Uint8Array, nonce?: Uint8Array): Uint8Array;
|
|
3
|
+
export interface TimestampReport {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** ISO-8601, from the token's GeneralizedTime as written. */
|
|
6
|
+
gen_time: string | null;
|
|
7
|
+
serial: string | null;
|
|
8
|
+
policy: string | null;
|
|
9
|
+
/** SHA-256 of the TSA certificate that signed it. */
|
|
10
|
+
signer: string | null;
|
|
11
|
+
/** True when the signer chains to one of the trust anchors given; null when none were given. */
|
|
12
|
+
chain: boolean | null;
|
|
13
|
+
problems: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Checks an RFC 3161 TimeStampResp (or its token, the ContentInfo) offline against the SHA-256
|
|
17
|
+
* `digest` that was timestamped. `roots`: PEM trust anchors; without them the chain isn't checked
|
|
18
|
+
* (`chain: null`) and the report says only that the token is well-formed and signed by its own cert.
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifyTimestamp(token: Uint8Array, options: {
|
|
21
|
+
digest: Uint8Array;
|
|
22
|
+
nonce?: Uint8Array;
|
|
23
|
+
roots?: readonly string[];
|
|
24
|
+
}): TimestampReport;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// RFC 3161 timestamps (spec/timestamp.md): the request a gateway sends a Time-Stamp Authority, and an
|
|
2
|
+
// offline check of the token it gets back: the imprint, the CMS signature (RSA PKCS#1 v1.5 or ECDSA,
|
|
3
|
+
// SHA-256/384/512), the signer certificate (ESSCertID v1 or v2), its timeStamping EKU and validity,
|
|
4
|
+
// and the chain to trust anchors you give. node:crypto only. Mirrors zanii_blackbox/timestamp.py.
|
|
5
|
+
import { createHash, verify as cryptoVerify, X509Certificate } from "node:crypto";
|
|
6
|
+
function parse(buf, depth = 0) {
|
|
7
|
+
const nodes = parseAll(buf, depth);
|
|
8
|
+
if (nodes.length !== 1)
|
|
9
|
+
throw new Error("der: expected one element");
|
|
10
|
+
return nodes[0];
|
|
11
|
+
}
|
|
12
|
+
function parseAll(buf, depth) {
|
|
13
|
+
if (depth > 40)
|
|
14
|
+
throw new Error("der: too deep");
|
|
15
|
+
const out = [];
|
|
16
|
+
let at = 0;
|
|
17
|
+
while (at < buf.length) {
|
|
18
|
+
const start = at;
|
|
19
|
+
const tag = buf[at++];
|
|
20
|
+
if ((tag & 0x1f) === 0x1f)
|
|
21
|
+
throw new Error("der: high tag numbers aren't used here");
|
|
22
|
+
let len = buf[at++];
|
|
23
|
+
if (len === undefined)
|
|
24
|
+
throw new Error("der: truncated");
|
|
25
|
+
if (len === 0x80)
|
|
26
|
+
throw new Error("der: indefinite length");
|
|
27
|
+
if (len > 0x80) {
|
|
28
|
+
const n = len & 0x7f;
|
|
29
|
+
if (n > 4 || at + n > buf.length)
|
|
30
|
+
throw new Error("der: bad length");
|
|
31
|
+
len = 0;
|
|
32
|
+
for (let i = 0; i < n; i++)
|
|
33
|
+
len = len * 256 + buf[at++];
|
|
34
|
+
}
|
|
35
|
+
if (at + len > buf.length)
|
|
36
|
+
throw new Error("der: truncated");
|
|
37
|
+
const value = buf.subarray(at, at + len);
|
|
38
|
+
at += len;
|
|
39
|
+
const constructed = (tag & 0x20) !== 0;
|
|
40
|
+
out.push({
|
|
41
|
+
tag,
|
|
42
|
+
raw: buf.subarray(start, at),
|
|
43
|
+
value,
|
|
44
|
+
children: constructed ? parseAll(value, depth + 1) : [],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** OID content bytes as dotted text. */
|
|
50
|
+
function oid(n) {
|
|
51
|
+
if (n.tag !== 0x06)
|
|
52
|
+
throw new Error("der: expected an OID");
|
|
53
|
+
const b = n.value;
|
|
54
|
+
const first = b[0];
|
|
55
|
+
const parts = [Math.floor(first / 40), first % 40];
|
|
56
|
+
let v = 0;
|
|
57
|
+
for (let i = 1; i < b.length; i++) {
|
|
58
|
+
v = v * 128 + (b[i] & 0x7f);
|
|
59
|
+
if ((b[i] & 0x80) === 0) {
|
|
60
|
+
parts.push(v);
|
|
61
|
+
v = 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return parts.join(".");
|
|
65
|
+
}
|
|
66
|
+
const child = (n, i) => {
|
|
67
|
+
const c = n?.children[i];
|
|
68
|
+
if (!c)
|
|
69
|
+
throw new Error("der: missing element");
|
|
70
|
+
return c;
|
|
71
|
+
};
|
|
72
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
73
|
+
// ---------------------------------------------------------------- OIDs
|
|
74
|
+
const SIGNED_DATA = "1.2.840.113549.1.7.2";
|
|
75
|
+
const TST_INFO = "1.2.840.113549.1.9.16.1.4";
|
|
76
|
+
const CONTENT_TYPE = "1.2.840.113549.1.9.3";
|
|
77
|
+
const MESSAGE_DIGEST = "1.2.840.113549.1.9.4";
|
|
78
|
+
const SIGNING_CERT = "1.2.840.113549.1.9.16.2.12";
|
|
79
|
+
const SIGNING_CERT_V2 = "1.2.840.113549.1.9.16.2.47";
|
|
80
|
+
const TIME_STAMPING = "1.3.6.1.5.5.7.3.8";
|
|
81
|
+
const HASHES = {
|
|
82
|
+
"2.16.840.1.101.3.4.2.1": "sha256",
|
|
83
|
+
"2.16.840.1.101.3.4.2.2": "sha384",
|
|
84
|
+
"2.16.840.1.101.3.4.2.3": "sha512",
|
|
85
|
+
"1.3.14.3.2.26": "sha1",
|
|
86
|
+
};
|
|
87
|
+
const SIGNATURES = {
|
|
88
|
+
"1.2.840.113549.1.1.1": null, // rsaEncryption: the signer's digest algorithm
|
|
89
|
+
"1.2.840.113549.1.1.11": "sha256",
|
|
90
|
+
"1.2.840.113549.1.1.12": "sha384",
|
|
91
|
+
"1.2.840.113549.1.1.13": "sha512",
|
|
92
|
+
"1.2.840.10045.4.3.2": "sha256",
|
|
93
|
+
"1.2.840.10045.4.3.3": "sha384",
|
|
94
|
+
"1.2.840.10045.4.3.4": "sha512",
|
|
95
|
+
};
|
|
96
|
+
// ---------------------------------------------------------------- the request
|
|
97
|
+
const der = (tag, content) => {
|
|
98
|
+
const n = content.length;
|
|
99
|
+
const len = n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : [0x82, n >> 8, n & 0xff];
|
|
100
|
+
return new Uint8Array(Buffer.concat([Buffer.from([tag, ...len]), Buffer.from(content)]));
|
|
101
|
+
};
|
|
102
|
+
/** A DER TimeStampReq for a SHA-256 digest, asking for the TSA's certificate; with a nonce when given. */
|
|
103
|
+
export function timestampRequest(digest, nonce) {
|
|
104
|
+
if (digest.length !== 32)
|
|
105
|
+
throw new Error("a SHA-256 digest is 32 bytes");
|
|
106
|
+
const algId = Buffer.from("300d06096086480165030402010500", "hex");
|
|
107
|
+
const imprint = der(0x30, new Uint8Array(Buffer.concat([algId, Buffer.from(der(0x04, digest))])));
|
|
108
|
+
let n = null;
|
|
109
|
+
if (nonce) {
|
|
110
|
+
let i = 0;
|
|
111
|
+
while (i < nonce.length - 1 && nonce[i] === 0)
|
|
112
|
+
i++;
|
|
113
|
+
const trimmed = nonce.subarray(i);
|
|
114
|
+
n = der(0x02, trimmed[0] & 0x80 ? new Uint8Array([0, ...trimmed]) : trimmed);
|
|
115
|
+
}
|
|
116
|
+
return der(0x30, new Uint8Array(Buffer.concat([
|
|
117
|
+
Buffer.from([0x02, 0x01, 0x01]),
|
|
118
|
+
Buffer.from(imprint),
|
|
119
|
+
...(n ? [Buffer.from(n)] : []),
|
|
120
|
+
Buffer.from([0x01, 0x01, 0xff]),
|
|
121
|
+
])));
|
|
122
|
+
}
|
|
123
|
+
const isoOf = (generalized) => {
|
|
124
|
+
const m = /^([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})(\.[0-9]+)?Z$/.exec(generalized);
|
|
125
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7] ?? ""}Z` : null;
|
|
126
|
+
};
|
|
127
|
+
/** The TSTInfo's fields we check. */
|
|
128
|
+
function tstInfo(bytes) {
|
|
129
|
+
const t = parse(bytes);
|
|
130
|
+
const imprint = child(t, 2);
|
|
131
|
+
const genTime = Buffer.from(child(t, 4).value).toString("latin1");
|
|
132
|
+
let nonce = null;
|
|
133
|
+
for (const c of t.children.slice(5))
|
|
134
|
+
if (c.tag === 0x02)
|
|
135
|
+
nonce = hex(c.value).replace(/^00(?=[0-9a-f]{2})/, "");
|
|
136
|
+
return {
|
|
137
|
+
policy: oid(child(t, 1)),
|
|
138
|
+
hashAlg: oid(child(child(imprint, 0), 0)),
|
|
139
|
+
hashed: child(imprint, 1).value,
|
|
140
|
+
serial: hex(child(t, 3).value),
|
|
141
|
+
genTime,
|
|
142
|
+
nonce,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Checks an RFC 3161 TimeStampResp (or its token, the ContentInfo) offline against the SHA-256
|
|
147
|
+
* `digest` that was timestamped. `roots`: PEM trust anchors; without them the chain isn't checked
|
|
148
|
+
* (`chain: null`) and the report says only that the token is well-formed and signed by its own cert.
|
|
149
|
+
*/
|
|
150
|
+
export function verifyTimestamp(token, options) {
|
|
151
|
+
const report = {
|
|
152
|
+
ok: false,
|
|
153
|
+
gen_time: null,
|
|
154
|
+
serial: null,
|
|
155
|
+
policy: null,
|
|
156
|
+
signer: null,
|
|
157
|
+
chain: null,
|
|
158
|
+
problems: [],
|
|
159
|
+
};
|
|
160
|
+
const fail = (why) => {
|
|
161
|
+
report.problems.push(why);
|
|
162
|
+
return report;
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
let top = parse(token);
|
|
166
|
+
// A TimeStampResp: [PKIStatusInfo, token]; a token: [OID signedData, [0] SignedData]
|
|
167
|
+
if (child(top, 0).tag === 0x30) {
|
|
168
|
+
const status = child(child(top, 0), 0).value;
|
|
169
|
+
if (status.length !== 1 || (status[0] !== 0 && status[0] !== 1))
|
|
170
|
+
return fail("the TSA didn't grant the timestamp");
|
|
171
|
+
top = child(top, 1);
|
|
172
|
+
}
|
|
173
|
+
if (oid(child(top, 0)) !== SIGNED_DATA)
|
|
174
|
+
return fail("not a CMS SignedData token");
|
|
175
|
+
const sd = child(child(top, 1), 0);
|
|
176
|
+
const encap = child(sd, 2);
|
|
177
|
+
if (oid(child(encap, 0)) !== TST_INFO)
|
|
178
|
+
return fail("the token doesn't carry a TSTInfo");
|
|
179
|
+
const tstBytes = child(child(encap, 1), 0).value;
|
|
180
|
+
const info = tstInfo(tstBytes);
|
|
181
|
+
report.gen_time = isoOf(info.genTime);
|
|
182
|
+
report.serial = info.serial;
|
|
183
|
+
report.policy = info.policy;
|
|
184
|
+
if (info.hashAlg !== "2.16.840.1.101.3.4.2.1" || hex(info.hashed) !== hex(options.digest))
|
|
185
|
+
fail("the token is for another digest");
|
|
186
|
+
if (options.nonce && info.nonce !== hex(options.nonce).replace(/^(00)+(?=[0-9a-f]{2})/, ""))
|
|
187
|
+
fail("the nonce isn't the one sent");
|
|
188
|
+
const certs = sd.children.find((c) => c.tag === 0xa0)?.children.map((c) => c.raw) ?? [];
|
|
189
|
+
const signerInfos = sd.children.at(-1);
|
|
190
|
+
if (signerInfos?.tag !== 0x31 || signerInfos.children.length !== 1)
|
|
191
|
+
return fail("a token has exactly one signer");
|
|
192
|
+
const si = child(signerInfos, 0);
|
|
193
|
+
const digestAlg = HASHES[oid(child(child(si, 2), 0))];
|
|
194
|
+
const attrs = child(si, 3);
|
|
195
|
+
if (attrs.tag !== 0xa0 || !digestAlg)
|
|
196
|
+
return fail("the signer's attributes or digest aren't supported");
|
|
197
|
+
const sigAlgOid = oid(child(child(si, 4), 0));
|
|
198
|
+
const sig = child(si, 5).value;
|
|
199
|
+
// signed attributes: contentType, messageDigest over the TSTInfo, and the signing certificate
|
|
200
|
+
const attr = (id) => attrs.children.find((a) => oid(child(a, 0)) === id);
|
|
201
|
+
const ct = attr(CONTENT_TYPE);
|
|
202
|
+
if (!ct || oid(child(child(ct, 1), 0)) !== TST_INFO)
|
|
203
|
+
fail("the signed content type isn't TSTInfo");
|
|
204
|
+
const md = attr(MESSAGE_DIGEST);
|
|
205
|
+
if (!md ||
|
|
206
|
+
hex(child(child(md, 1), 0).value) !== createHash(digestAlg).update(tstBytes).digest("hex"))
|
|
207
|
+
fail("the signed message digest isn't the TSTInfo's");
|
|
208
|
+
const v1 = attr(SIGNING_CERT);
|
|
209
|
+
const v2 = attr(SIGNING_CERT_V2);
|
|
210
|
+
let want = null;
|
|
211
|
+
if (v2) {
|
|
212
|
+
const id = child(child(child(child(v2, 1), 0), 0), 0); // SigningCertificateV2.certs[0]
|
|
213
|
+
const first = child(id, 0);
|
|
214
|
+
want =
|
|
215
|
+
first.tag === 0x30
|
|
216
|
+
? { alg: HASHES[oid(child(first, 0))] ?? "unknown", hash: hex(child(id, 1).value) }
|
|
217
|
+
: { alg: "sha256", hash: hex(first.value) };
|
|
218
|
+
}
|
|
219
|
+
else if (v1) {
|
|
220
|
+
const id = child(child(child(child(v1, 1), 0), 0), 0);
|
|
221
|
+
want = { alg: "sha1", hash: hex(child(id, 0).value) };
|
|
222
|
+
}
|
|
223
|
+
if (!want)
|
|
224
|
+
return fail("the token doesn't name its signing certificate");
|
|
225
|
+
const signerDer = certs.find((c) => createHash(want.alg).update(c).digest("hex") === want.hash);
|
|
226
|
+
if (!signerDer)
|
|
227
|
+
return fail("the signing certificate isn't in the token");
|
|
228
|
+
const cert = new X509Certificate(Buffer.from(signerDer));
|
|
229
|
+
report.signer = createHash("sha256").update(signerDer).digest("hex");
|
|
230
|
+
// the signature over the DER of the signed attributes, re-tagged as a SET
|
|
231
|
+
const signed = Buffer.from(attrs.raw);
|
|
232
|
+
signed[0] = 0x31;
|
|
233
|
+
if (!(sigAlgOid in SIGNATURES))
|
|
234
|
+
return fail(`the signature algorithm ${sigAlgOid} isn't supported`);
|
|
235
|
+
const hashName = SIGNATURES[sigAlgOid] ?? digestAlg;
|
|
236
|
+
const key = cert.publicKey;
|
|
237
|
+
const good = key.asymmetricKeyType === "ec"
|
|
238
|
+
? cryptoVerify(hashName, signed, { key, dsaEncoding: "der" }, sig)
|
|
239
|
+
: cryptoVerify(hashName, signed, key, sig);
|
|
240
|
+
if (!good)
|
|
241
|
+
fail("the TSA's signature doesn't verify");
|
|
242
|
+
if (!(cert.keyUsage ?? []).includes(TIME_STAMPING))
|
|
243
|
+
fail("the signing certificate isn't for time-stamping");
|
|
244
|
+
const at = report.gen_time ? Date.parse(report.gen_time) : Number.NaN;
|
|
245
|
+
if (!(Date.parse(cert.validFrom) <= at && at <= Date.parse(cert.validTo)))
|
|
246
|
+
fail("the signing certificate wasn't valid at the stamped time");
|
|
247
|
+
if (options.roots?.length)
|
|
248
|
+
report.chain = chains(cert, certs, options.roots);
|
|
249
|
+
if (report.chain === false)
|
|
250
|
+
fail("the signing certificate doesn't chain to a trusted root");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return fail("not a well-formed timestamp");
|
|
254
|
+
}
|
|
255
|
+
report.ok = report.problems.length === 0;
|
|
256
|
+
return report;
|
|
257
|
+
}
|
|
258
|
+
/** Walks issuer links from the signer through the token's certificates to a root (at most 6 hops). */
|
|
259
|
+
function chains(signer, certs, roots) {
|
|
260
|
+
const anchors = roots.map((r) => new X509Certificate(r));
|
|
261
|
+
const pool = certs.map((c) => new X509Certificate(Buffer.from(c)));
|
|
262
|
+
let current = signer;
|
|
263
|
+
for (let hop = 0; hop < 6; hop++) {
|
|
264
|
+
if (anchors.some((a) => current.checkIssued(a) && current.verify(a.publicKey)))
|
|
265
|
+
return true;
|
|
266
|
+
const next = pool.find((c) => c.fingerprint256 !== current.fingerprint256 &&
|
|
267
|
+
current.checkIssued(c) &&
|
|
268
|
+
current.verify(c.publicKey));
|
|
269
|
+
if (!next)
|
|
270
|
+
return false;
|
|
271
|
+
current = next;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|