@zanii/blackbox 0.2.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/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/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +163 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- 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/policy/index.d.ts +20 -3
- package/dist/policy/index.js +39 -4
- package/dist/session/index.d.ts +18 -0
- package/dist/session/index.js +46 -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
|
@@ -125,6 +125,8 @@ export interface LlmCallIds {
|
|
|
125
125
|
}
|
|
126
126
|
/** N4 (idea R6): the same parts always give the same event id (sha256, 128 bits). */
|
|
127
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;
|
|
128
130
|
/** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
|
|
129
131
|
export declare function session(options: SessionOptions): Promise<BlackboxSession>;
|
|
130
132
|
export declare class BlackboxSession {
|
|
@@ -152,6 +154,11 @@ export declare class BlackboxSession {
|
|
|
152
154
|
keepToken?: boolean);
|
|
153
155
|
event(type: string, name?: string, data?: Record<string, unknown>, options?: {
|
|
154
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
|
+
};
|
|
155
162
|
}): void;
|
|
156
163
|
step(name: string, data?: Record<string, unknown>): void;
|
|
157
164
|
toolCall(name: string, args?: unknown): void;
|
|
@@ -215,6 +222,17 @@ export declare class BlackboxSession {
|
|
|
215
222
|
nearMiss(description: string): void;
|
|
216
223
|
/** spec/attestation.md: re-runs a read-only command and records whether the output matches. */
|
|
217
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;
|
|
218
236
|
/** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
|
|
219
237
|
memoryWrite(memoryId: string, summary?: string): void;
|
|
220
238
|
memoryRevoke(memoryId: string, reason?: string): void;
|
package/dist/session/index.js
CHANGED
|
@@ -30,6 +30,20 @@ const MAX_DATA = 64 * 1024;
|
|
|
30
30
|
const PREVIEW = 4096;
|
|
31
31
|
const BATCH = 500;
|
|
32
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
|
+
}
|
|
33
47
|
const LOCK_WAIT_MS = 2_000;
|
|
34
48
|
const LOCK_STALE_MS = 10_000;
|
|
35
49
|
/** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
|
|
@@ -170,6 +184,14 @@ export class BlackboxSession {
|
|
|
170
184
|
...(eventId === undefined ? {} : { event_id: eventId }),
|
|
171
185
|
ts: new Date().toISOString(),
|
|
172
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
|
+
: {}),
|
|
173
195
|
});
|
|
174
196
|
appendFileSync(this.spool, `${line}\n`);
|
|
175
197
|
this.nextSeq++;
|
|
@@ -291,6 +313,30 @@ export class BlackboxSession {
|
|
|
291
313
|
this.event("attestation", argv[0], { ...result });
|
|
292
314
|
return result;
|
|
293
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
|
+
}
|
|
294
340
|
/** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
|
|
295
341
|
memoryWrite(memoryId, summary) {
|
|
296
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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** A CBOR value: integers, byte and text strings, arrays, maps (Map, for integer keys), tags, null and booleans. */
|
|
2
|
+
export type Cbor = number | string | Uint8Array | boolean | null | Cbor[] | Map<Cbor, Cbor> | {
|
|
3
|
+
tag: number;
|
|
4
|
+
value: Cbor;
|
|
5
|
+
};
|
|
6
|
+
export declare const tagged: (tag: number, value: Cbor) => Cbor;
|
|
7
|
+
export declare function cborEncode(v: Cbor): Buffer;
|
|
8
|
+
/** Decodes one deterministic CBOR item; throws on anything else (indefinite lengths, floats, extra bytes). */
|
|
9
|
+
export declare function cborDecode(bytes: Uint8Array): Cbor;
|
|
10
|
+
export declare const ed25519Sign: (seed: Uint8Array, msg: Uint8Array) => Uint8Array<ArrayBuffer>;
|
|
11
|
+
export declare function ed25519Verify(publicKey: Uint8Array, msg: Uint8Array, sig: Uint8Array): boolean;
|
|
12
|
+
export declare const ed25519PublicKey: (seed: Uint8Array) => Uint8Array<ArrayBuffer>;
|
|
13
|
+
export declare const ALG_ED25519 = -19;
|
|
14
|
+
export interface StatementInput {
|
|
15
|
+
/** The payload's media type. */
|
|
16
|
+
contentType: string;
|
|
17
|
+
payload: Uint8Array;
|
|
18
|
+
/** CWT claims (RFC 9597): who issued it and what it's about. */
|
|
19
|
+
iss: string;
|
|
20
|
+
sub: string;
|
|
21
|
+
/** Unix seconds. */
|
|
22
|
+
iat: number;
|
|
23
|
+
/** The key id (bstr), e.g. the first 8 bytes of SHA-256(public key). */
|
|
24
|
+
kid: Uint8Array;
|
|
25
|
+
}
|
|
26
|
+
/** A SCITT Signed Statement: tagged COSE_Sign1, Ed25519 (alg -19), empty unprotected header, payload attached. */
|
|
27
|
+
export declare function signStatement(input: StatementInput, seed: Uint8Array): Uint8Array;
|
|
28
|
+
export interface CoseSign1 {
|
|
29
|
+
protectedBytes: Uint8Array;
|
|
30
|
+
protected: Map<Cbor, Cbor>;
|
|
31
|
+
unprotected: Map<Cbor, Cbor>;
|
|
32
|
+
payload: Uint8Array | null;
|
|
33
|
+
signature: Uint8Array;
|
|
34
|
+
}
|
|
35
|
+
/** Parses a tagged COSE_Sign1; throws if it isn't one. */
|
|
36
|
+
export declare function parseCoseSign1(bytes: Uint8Array): CoseSign1;
|
|
37
|
+
/** Does a COSE_Sign1 verify with this Ed25519 key? `detached`: the payload, when it isn't attached. */
|
|
38
|
+
export declare function verifyCoseSign1(bytes: Uint8Array, publicKey: Uint8Array, detached?: Uint8Array): boolean;
|
|
39
|
+
/** The bytes a log entry is: the statement with its unprotected header emptied (RFC 9943 §6.3). */
|
|
40
|
+
export declare function statementEntry(statement: Uint8Array): Uint8Array;
|
|
41
|
+
export declare const leafHash: (entry: Uint8Array) => Uint8Array<ArrayBuffer>;
|
|
42
|
+
/** MTH over leaf hashes (already `leafHash`ed). */
|
|
43
|
+
export declare function merkleRoot(leaves: readonly Uint8Array[]): Uint8Array;
|
|
44
|
+
/** The audit path for leaf `m` in a tree of these leaf hashes, leaf to root. */
|
|
45
|
+
export declare function inclusionProof(m: number, leaves: readonly Uint8Array[]): Uint8Array[];
|
|
46
|
+
/** RFC 9162 §2.1.3.2: does `path` prove leaf `index` (its hash) in a tree of `size` with `root`? */
|
|
47
|
+
export declare function verifyInclusion(leaf: Uint8Array, index: number, size: number, path: readonly Uint8Array[], root: Uint8Array): boolean;
|
|
48
|
+
/** RFC 9162 §2.1.4.1: proof that the tree of the first `m` leaves is a prefix of all of them. */
|
|
49
|
+
export declare function consistencyProof(m: number, leaves: readonly Uint8Array[]): Uint8Array[];
|
|
50
|
+
/** RFC 9162 §2.1.4.2, with the trivial cases: an empty first tree, and equal sizes. */
|
|
51
|
+
export declare function verifyConsistency(first: number, second: number, firstRoot: Uint8Array, secondRoot: Uint8Array, proof: readonly Uint8Array[]): boolean;
|
|
52
|
+
export interface ReceiptSigner {
|
|
53
|
+
iss: string;
|
|
54
|
+
sub: string;
|
|
55
|
+
kid: Uint8Array;
|
|
56
|
+
seed: Uint8Array;
|
|
57
|
+
}
|
|
58
|
+
/** A receipt of inclusion (vds RFC9162_SHA256): signed over the root, payload detached. */
|
|
59
|
+
export declare function makeReceipt(proof: {
|
|
60
|
+
size: number;
|
|
61
|
+
index: number;
|
|
62
|
+
path: readonly Uint8Array[];
|
|
63
|
+
root: Uint8Array;
|
|
64
|
+
}, signer: ReceiptSigner): Uint8Array;
|
|
65
|
+
/** RFC 9942 §5.2.1: does the receipt prove this entry (the statement, unprotected emptied) under this key?
|
|
66
|
+
* Returns the tree size and leaf index it proves, or null. Accepts a size-1 tree's empty path. */
|
|
67
|
+
export declare function verifyReceipt(receipt: Uint8Array, entry: Uint8Array, publicKey: Uint8Array): {
|
|
68
|
+
size: number;
|
|
69
|
+
index: number;
|
|
70
|
+
} | null;
|
|
71
|
+
/** A Transparent Statement: the statement with its receipts in unprotected header 394. */
|
|
72
|
+
export declare function transparentStatement(statement: Uint8Array, receipts: readonly Uint8Array[]): Uint8Array;
|
|
73
|
+
/** c2sp.org/signed-note: SHA-256(name || "\n" || type || public key)[:4]. Type 0x01 note, 0x04 cosignature. */
|
|
74
|
+
export declare const noteKeyId: (name: string, type: number, publicKey: Uint8Array) => Uint8Array<ArrayBuffer>;
|
|
75
|
+
/** A verifier key string: `name+hex(keyID)+base64(type || public key)`. */
|
|
76
|
+
export declare const noteVkey: (name: string, type: number, publicKey: Uint8Array) => string;
|
|
77
|
+
export declare function parseVkey(vkey: string): {
|
|
78
|
+
name: string;
|
|
79
|
+
type: number;
|
|
80
|
+
keyId: Uint8Array;
|
|
81
|
+
publicKey: Uint8Array;
|
|
82
|
+
} | null;
|
|
83
|
+
/** Signs a note's text (which ends in "\n") as `name`; returns the note with its signature line. */
|
|
84
|
+
export declare function signNote(text: string, name: string, seed: Uint8Array): string;
|
|
85
|
+
/** Splits a signed note into its text and signature lines. */
|
|
86
|
+
export declare function splitNote(note: string): {
|
|
87
|
+
text: string;
|
|
88
|
+
signatures: Array<{
|
|
89
|
+
name: string;
|
|
90
|
+
keyId: Uint8Array;
|
|
91
|
+
sig: Uint8Array;
|
|
92
|
+
}>;
|
|
93
|
+
} | null;
|
|
94
|
+
/** Is the note signed (type 0x01) by this vkey? */
|
|
95
|
+
export declare function verifyNote(note: string, vkey: string): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* §9: a log key rotation, as a note signed by the old key and the new one: the new key's vkey, the
|
|
98
|
+
* tree size it takes over at, and when. A verifier that pinned the old key follows it to the new.
|
|
99
|
+
*/
|
|
100
|
+
export declare function keyRotationNote(old: {
|
|
101
|
+
seed: Uint8Array;
|
|
102
|
+
origin: string;
|
|
103
|
+
}, next: {
|
|
104
|
+
seed: Uint8Array;
|
|
105
|
+
origin: string;
|
|
106
|
+
}, size: number, time: number): string;
|
|
107
|
+
/** §9: the new key, size and time a rotation note names, if the old key and the new one both signed it. */
|
|
108
|
+
export declare function verifyKeyRotation(note: string, oldVkey: string): {
|
|
109
|
+
new_vkey: string;
|
|
110
|
+
size: number;
|
|
111
|
+
time: number;
|
|
112
|
+
} | null;
|
|
113
|
+
/** c2sp.org/tlog-checkpoint: origin, size, base64 root, each on its own line. */
|
|
114
|
+
export declare const checkpointText: (origin: string, size: number, root: Uint8Array) => string;
|
|
115
|
+
export declare function parseCheckpoint(text: string): {
|
|
116
|
+
origin: string;
|
|
117
|
+
size: number;
|
|
118
|
+
root: Uint8Array;
|
|
119
|
+
} | null;
|
|
120
|
+
/** c2sp.org/tlog-cosignature: the witness's timestamped Ed25519 cosignature (type 0x04) on a checkpoint. */
|
|
121
|
+
export declare function cosign(checkpoint: string, name: string, seed: Uint8Array, time: number): string;
|
|
122
|
+
/** The raw ML-DSA-44 public key (1312 bytes) for a 32-byte seed. Needs Node ≥ 24.6. */
|
|
123
|
+
export declare const mldsaPublicKey: (seed: Uint8Array) => Uint8Array;
|
|
124
|
+
/** Whether this runtime has ML-DSA (Node ≥ 24.6 with OpenSSL ≥ 3.5). */
|
|
125
|
+
export declare function mldsaAvailable(): boolean;
|
|
126
|
+
/** c2sp.org/tlog-cosignature v1.1 (type 0x06): what an ML-DSA-44 subtree cosignature signs. */
|
|
127
|
+
export declare function subtreeMessage(name: string, time: number, origin: string, start: number, end: number, hash: Uint8Array): Uint8Array;
|
|
128
|
+
/** An ML-DSA-44 cosignature line (type 0x06) on a checkpoint: what a log or witness adds for post-quantum. */
|
|
129
|
+
export declare function cosignMldsa(checkpoint: string, name: string, seed: Uint8Array, time: number): string;
|
|
130
|
+
/**
|
|
131
|
+
* The time a cosignature (in a note's signature lines) says it saw the checkpoint, or null. Takes a
|
|
132
|
+
* witness's Ed25519 vkey (type 0x04) or an ML-DSA-44 one (type 0x06).
|
|
133
|
+
*/
|
|
134
|
+
export declare function verifyCosignature(note: string, vkey: string): number | null;
|
|
135
|
+
/** c2sp.org/tlog-witness: the body of `POST add-checkpoint`. */
|
|
136
|
+
export declare const addCheckpointBody: (old: number, proof: readonly Uint8Array[], signedCheckpoint: string) => string;
|
|
137
|
+
export declare function parseAddCheckpoint(body: string): {
|
|
138
|
+
old: number;
|
|
139
|
+
proof: Uint8Array[];
|
|
140
|
+
note: string;
|
|
141
|
+
} | null;
|
|
142
|
+
export declare const SESSION_CONTENT_TYPE = "application/vnd.zanii.blackbox.session+json";
|
|
143
|
+
export declare const RECEIPT_SUB = "transparency-log";
|
|
144
|
+
/** spec/transparency.md §1: the first 8 bytes of SHA-256(public key). */
|
|
145
|
+
export declare const logKid: (publicKey: Uint8Array) => Uint8Array<ArrayBuffer>;
|
|
146
|
+
export interface SessionHead {
|
|
147
|
+
session_id: string;
|
|
148
|
+
tenant: string;
|
|
149
|
+
count: number;
|
|
150
|
+
head: string;
|
|
151
|
+
closed_at: string;
|
|
152
|
+
}
|
|
153
|
+
/** spec/transparency.md §2: the Signed Statement a closed session gets. `iat` is `closed_at` in seconds. */
|
|
154
|
+
export declare function sessionStatement(head: SessionHead, origin: string, seed: Uint8Array): Uint8Array;
|
|
155
|
+
export interface TransparencyReport {
|
|
156
|
+
ok: boolean;
|
|
157
|
+
statement: boolean;
|
|
158
|
+
receipt: {
|
|
159
|
+
size: number;
|
|
160
|
+
index: number;
|
|
161
|
+
} | null;
|
|
162
|
+
checkpoint: {
|
|
163
|
+
origin: string;
|
|
164
|
+
size: number;
|
|
165
|
+
} | null;
|
|
166
|
+
witnessed: string[];
|
|
167
|
+
/** The RFC 3161 time of the checkpoint (spec/timestamp.md §3), when the bundle carries a token. */
|
|
168
|
+
timestamp: string | null;
|
|
169
|
+
/** §8: the checkpoint's ML-DSA-44 signature verifies under `logPqKey`; null when none was given. */
|
|
170
|
+
pq: boolean | null;
|
|
171
|
+
problems: string[];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* spec/transparency.md §6: checks a bundle's `transparency` offline, against a log key the verifier
|
|
175
|
+
* pins (a C2SP vkey, type 0x01) and the witnesses it trusts (vkeys, type 0x04).
|
|
176
|
+
*/
|
|
177
|
+
export declare function verifyTransparency(bundle: {
|
|
178
|
+
session: {
|
|
179
|
+
session_id: string;
|
|
180
|
+
};
|
|
181
|
+
lines: readonly string[];
|
|
182
|
+
transparency?: unknown;
|
|
183
|
+
}, options: {
|
|
184
|
+
logKey: string;
|
|
185
|
+
witnesses?: readonly string[];
|
|
186
|
+
tsaRoots?: readonly string[];
|
|
187
|
+
logPqKey?: string;
|
|
188
|
+
}): TransparencyReport;
|