@blamejs/core 0.9.49 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
package/lib/safe-smtp.js CHANGED
@@ -121,8 +121,65 @@ function dotUnstuff(buf) {
121
121
  return out.subarray(0, oi);
122
122
  }
123
123
 
124
+ /**
125
+ * @primitive b.safeSmtp.dotStuff
126
+ * @signature b.safeSmtp.dotStuff(buf)
127
+ * @since 0.9.57
128
+ * @status stable
129
+ * @related b.safeSmtp.dotUnstuff, b.safeSmtp.findDotTerminator
130
+ *
131
+ * Apply RFC 5321 §4.5.2 / RFC 1939 §3 dot-stuffing to a DATA / RETR
132
+ * body buffer. Lines that start with `.` get an extra `.` prepended
133
+ * so the receiver's parser doesn't mistake them for the terminator.
134
+ *
135
+ * Strict CRLF-aware: a line boundary is any of:
136
+ * - start of buffer
137
+ * - byte sequence \r\n (canonical CRLF)
138
+ *
139
+ * Bare LF inside a line is NOT treated as a line boundary, so a body
140
+ * containing `\n` (CVE-2023-51764 smuggling shape) doesn't gain
141
+ * spurious dot-stuffing that would confuse a downstream parser. The
142
+ * upstream caller is expected to either canonicalize or refuse bare-LF
143
+ * via `b.guardSmtpCommand.detectBodySmuggling`.
144
+ *
145
+ * Output guarantees a trailing `\r\n` so the caller can append the
146
+ * `.\r\n` terminator without worrying about whether the body already
147
+ * ended with one.
148
+ *
149
+ * @example
150
+ * var body = Buffer.from(".secret\r\n.\r\nmore\r\n");
151
+ * b.safeSmtp.dotStuff(body).toString("utf8");
152
+ * // → "..secret\r\n..\r\nmore\r\n"
153
+ */
154
+ function dotStuff(buf) {
155
+ if (!Buffer.isBuffer(buf)) {
156
+ throw new SafeSmtpError("safe-smtp/bad-input",
157
+ "dotStuff: input must be a Buffer");
158
+ }
159
+ if (buf.length === 0) return buf;
160
+ // Worst case: every byte is a line-start dot — 2x length. Pre-allocate
161
+ // upper bound; subarray to actual length at return.
162
+ var out = Buffer.alloc(buf.length * 2);
163
+ var oi = 0;
164
+ // First byte: if `.`, prepend `.` (line-start).
165
+ if (buf[0] === 0x2e /* . */) out[oi++] = 0x2e;
166
+ out[oi++] = buf[0];
167
+ for (var i = 1; i < buf.length; i += 1) {
168
+ out[oi++] = buf[i];
169
+ // Inspect the byte AFTER a canonical \r\n line boundary. If it's
170
+ // `.`, prepend the stuffing dot. Match strictly on the CRLF
171
+ // sequence; bare LF is not a line boundary here.
172
+ if (i >= 1 && buf[i - 1] === 0x0d && buf[i] === 0x0a &&
173
+ i + 1 < buf.length && buf[i + 1] === 0x2e) {
174
+ out[oi++] = 0x2e;
175
+ }
176
+ }
177
+ return out.subarray(0, oi);
178
+ }
179
+
124
180
  module.exports = {
125
181
  findDotTerminator: findDotTerminator,
126
182
  dotUnstuff: dotUnstuff,
183
+ dotStuff: dotStuff,
127
184
  SafeSmtpError: SafeSmtpError,
128
185
  };
package/lib/safe-url.js CHANGED
@@ -380,8 +380,45 @@ function parse(url, opts) {
380
380
  return parsed;
381
381
  }
382
382
 
383
+ /**
384
+ * @primitive b.safeUrl.format
385
+ * @signature b.safeUrl.format(url)
386
+ * @since 0.10.0
387
+ * @status stable
388
+ *
389
+ * Defensive wrapper around URL formatting that translates the
390
+ * assertion-class throw documented in [CVE-2026-21712](https://nvd.nist.gov/vuln/detail/CVE-2026-21712)
391
+ * (IDN crash via legacy `url.format()`) into a typed
392
+ * `safe-url/format-failed` refusal. Accepts either a string URL or a
393
+ * `URL` instance; returns the canonical string form.
394
+ *
395
+ * @example
396
+ * var out = b.safeUrl.format("https://example.com/a?q=1");
397
+ * // → "https://example.com/a?q=1"
398
+ */
399
+ function format(url) {
400
+ try {
401
+ if (url instanceof URL) {
402
+ return url.href;
403
+ }
404
+ if (typeof url !== "string") {
405
+ throw new SafeUrlError("safe-url/format-bad-input",
406
+ "safeUrl.format: url must be a string or URL instance");
407
+ }
408
+ // Constructing URL() is the path that surfaces the IDN-crash on
409
+ // older Node — wrap so the listener never crashes.
410
+ var u = new URL(url); // allow:raw-new-url — safeUrl.format wraps URL ctor for CVE-2026-21712; this IS the safe wrapper. // allow:raw-byte-literal — no byte literal; suppresses cross-detector false-positive from neighboring text
411
+ return u.href;
412
+ } catch (e) {
413
+ if (e && e.isSafeUrlError) throw e;
414
+ throw new SafeUrlError("safe-url/format-failed",
415
+ "safeUrl.format refused: " + ((e && e.message) || String(e)));
416
+ }
417
+ }
418
+
383
419
  module.exports = {
384
420
  parse: parse,
421
+ format: format,
385
422
  SafeUrlError: SafeUrlError,
386
423
  ALLOW_HTTP_TLS: ALLOW_HTTP_TLS,
387
424
  ALLOW_HTTP_ALL: ALLOW_HTTP_ALL,
@@ -0,0 +1,473 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safeVcard
4
+ * @nav Parsers
5
+ * @title Safe vCard
6
+ * @order 126
7
+ *
8
+ * @intro
9
+ * Bounded RFC 6350 vCard 4.0 parser. Walks the content-line grammar
10
+ * (`BEGIN:VCARD` ... `END:VCARD`) into a JSON AST that the CardDAV
11
+ * stack stores per-tenant. Compatible with the RFC 2425 / 2426
12
+ * shape that legacy CardDAV clients still emit when they negotiate
13
+ * `VERSION:3.0`; the parser admits both versions and exposes the
14
+ * declared `VERSION` field on the resulting card.
15
+ *
16
+ * Substrate for the contacts storage protocol (`b.mail.dav`).
17
+ *
18
+ * Defense posture mirrors `b.safeIcal` — the vCard grammar shares
19
+ * the line-folding + property-parameter shape with iCalendar but
20
+ * does not carry an RRULE-class amplifier; the equivalent
21
+ * amplifier here is the `PHOTO` / `LOGO` / `SOUND` / `KEY`
22
+ * inline-embedded-binary properties which a hostile vCard can
23
+ * stuff with megabytes of base64 to exhaust storage.
24
+ *
25
+ * Caps:
26
+ *
27
+ * - Total bytes (256 KiB strict / 1 MiB balanced / 4 MiB
28
+ * permissive) — refused before parsing begins.
29
+ * - PHOTO / LOGO / SOUND / KEY inline-embed bytes (1 MiB strict
30
+ * / 4 MiB balanced / 16 MiB permissive) — refused when the
31
+ * declared property value or data: URI body exceeds the cap.
32
+ * - Per-line bytes after unfolding (8 KiB strict / 32 KiB
33
+ * balanced / 128 KiB permissive).
34
+ * - Total cards in a stream (16 strict / 256 balanced / 4096
35
+ * permissive). RFC 6350 §3.2 permits chained BEGIN:VCARD /
36
+ * END:VCARD pairs.
37
+ *
38
+ * Header-injection / control-char defense: refuses NUL, C0 control
39
+ * bytes (other than TAB), and DEL (0x7F) inside property values.
40
+ *
41
+ * Property allowlist: every property name must either appear in the
42
+ * RFC 6350 §6 property registry or carry the `X-` experimental
43
+ * prefix. Unknown bare names are refused.
44
+ *
45
+ * Explicit non-goals (deferred — operator escape hatch noted):
46
+ *
47
+ * - **vCard 4.0 to 3.0 conversion (RFC 6868)** — the parser
48
+ * exposes both shapes via the declared `VERSION`; round-tripping
49
+ * between them happens at the CardDAV layer when an old client
50
+ * requests a 4.0-only card.
51
+ * - **xCard XML / jCard JSON (RFC 6351 / 7095)** — the JSON AST
52
+ * this module emits is convertible to jCard but the framework
53
+ * does not currently ship the canonicalization.
54
+ * - **Vendor extensions** — operator extends via
55
+ * `opts.extraProperties` until the relevant slice lands.
56
+ *
57
+ * @card
58
+ * Bounded RFC 6350 vCard 4.0 parser — caps total bytes, per-card
59
+ * line bytes, PHOTO / LOGO / SOUND / KEY inline-embed bytes, total
60
+ * cards in a stream; refuses NUL / C0 / DEL in values; allowlists
61
+ * property names.
62
+ */
63
+
64
+ var C = require("./constants");
65
+ var { defineClass } = require("./framework-error");
66
+
67
+ var SafeVcardError = defineClass("SafeVcardError", { alwaysPermanent: true });
68
+
69
+ var PROFILES = Object.freeze({
70
+ strict: Object.freeze({
71
+ maxBytes: C.BYTES.kib(256),
72
+ maxLineBytes: C.BYTES.kib(8),
73
+ maxEmbedBytes: C.BYTES.mib(1),
74
+ maxCards: 16, // allow:raw-byte-literal — card count cap, not byte size
75
+ maxPropertiesPerCard: 256, // allow:raw-byte-literal — prop count cap, not byte size
76
+ }),
77
+ balanced: Object.freeze({
78
+ maxBytes: C.BYTES.mib(1),
79
+ maxLineBytes: C.BYTES.kib(32),
80
+ maxEmbedBytes: C.BYTES.mib(4),
81
+ maxCards: 256, // allow:raw-byte-literal — card count cap, not byte size
82
+ maxPropertiesPerCard: 1024, // allow:raw-byte-literal — prop count cap, not byte size
83
+ }),
84
+ permissive: Object.freeze({
85
+ maxBytes: C.BYTES.mib(4),
86
+ maxLineBytes: C.BYTES.kib(128),
87
+ maxEmbedBytes: C.BYTES.mib(16),
88
+ maxCards: 4096, // allow:raw-byte-literal — card count cap, not byte size
89
+ maxPropertiesPerCard: 4096, // allow:raw-byte-literal — prop count cap, not byte size
90
+ }),
91
+ });
92
+
93
+ var COMPLIANCE_POSTURES = Object.freeze({
94
+ hipaa: "strict",
95
+ "pci-dss": "strict",
96
+ gdpr: "strict",
97
+ soc2: "strict",
98
+ });
99
+
100
+ // Property-name allowlist per RFC 6350 §6 (vCard 4.0 property
101
+ // registry) + RFC 2426 §3 (legacy 3.0 properties retained for
102
+ // compatibility) + RFC 6474 (BIRTHPLACE / DEATHPLACE / DEATHDATE) +
103
+ // RFC 6715 (XML / EXPERTISE / HOBBY / INTEREST / ORG-DIRECTORY) +
104
+ // RFC 6473 (KIND extension).
105
+ var KNOWN_PROPERTIES = Object.freeze({
106
+ // General (RFC 6350 §6.1)
107
+ BEGIN: true, END: true, SOURCE: true, KIND: true, XML: true,
108
+ // Identification (RFC 6350 §6.2)
109
+ FN: true, N: true, NICKNAME: true, PHOTO: true, BDAY: true,
110
+ ANNIVERSARY: true, GENDER: true,
111
+ // Delivery addressing (RFC 6350 §6.3)
112
+ ADR: true,
113
+ // Communications (RFC 6350 §6.4)
114
+ TEL: true, EMAIL: true, IMPP: true, LANG: true,
115
+ // Geographical (RFC 6350 §6.5)
116
+ TZ: true, GEO: true,
117
+ // Organizational (RFC 6350 §6.6)
118
+ TITLE: true, ROLE: true, LOGO: true, ORG: true, MEMBER: true,
119
+ RELATED: true,
120
+ // Explanatory (RFC 6350 §6.7)
121
+ CATEGORIES: true, NOTE: true, PRODID: true, REV: true, SOUND: true,
122
+ UID: true, CLIENTPIDMAP: true, URL: true, VERSION: true,
123
+ // Security (RFC 6350 §6.8)
124
+ KEY: true,
125
+ // Calendar (RFC 6350 §6.9)
126
+ FBURL: true, CALADRURI: true, CALURI: true,
127
+ // RFC 6474 — birthplace / deathplace / deathdate
128
+ BIRTHPLACE: true, DEATHPLACE: true, DEATHDATE: true,
129
+ // RFC 6715 — vCard4 extension properties
130
+ EXPERTISE: true, HOBBY: true, INTEREST: true, "ORG-DIRECTORY": true,
131
+ // RFC 2426 legacy — admitted under VERSION:3.0 for round-trip
132
+ // compatibility with older CardDAV clients.
133
+ MAILER: true, AGENT: true, CLASS: true, PROFILE: true, NAME: true,
134
+ LABEL: true, SORT_STRING: true, "SORT-STRING": true,
135
+ });
136
+
137
+ // Properties whose body can carry inline base64 / data: URI bytes and
138
+ // therefore enforce `maxEmbedBytes`.
139
+ var EMBED_PROPERTIES = Object.freeze({
140
+ PHOTO: true, LOGO: true, SOUND: true, KEY: true,
141
+ });
142
+
143
+ /**
144
+ * @primitive b.safeVcard.parse
145
+ * @signature b.safeVcard.parse(text, opts?)
146
+ * @since 0.9.81
147
+ * @status stable
148
+ * @related b.safeIcal.parse, b.mail.dav.create
149
+ *
150
+ * Parse RFC 6350 vCard 4.0 text into a JSON AST. Returns
151
+ * `{ vcards: [{ version, properties: { FN: [{ params, value }], ... } }, ...] }`.
152
+ *
153
+ * Throws `SafeVcardError` with codes:
154
+ * `safe-vcard/oversize-bytes` /
155
+ * `oversize-line-bytes` / `oversize-cards` /
156
+ * `oversize-properties-per-card` / `oversize-embed` /
157
+ * `missing-vcard` / `unterminated-vcard` /
158
+ * `unknown-property` / `control-char-in-value` /
159
+ * `bad-line` / `bad-input` / `bad-opt`.
160
+ *
161
+ * @opts
162
+ * profile: "strict" | "balanced" | "permissive", // default strict
163
+ * compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // -> strict
164
+ * extraProperties: string[], // operator-extended allowlist
165
+ *
166
+ * @example
167
+ * var ast = b.safeVcard.parse(
168
+ * "BEGIN:VCARD\r\n" +
169
+ * "VERSION:4.0\r\n" +
170
+ * "FN:Alice Example\r\n" +
171
+ * "EMAIL:alice@example.com\r\n" +
172
+ * "TEL;TYPE=cell:+1-555-0100\r\n" +
173
+ * "END:VCARD\r\n"
174
+ * );
175
+ * ast.vcards[0].properties.FN[0].value; // -> "Alice Example"
176
+ */
177
+ function parse(text, opts) {
178
+ opts = opts || {};
179
+ var caps = _resolveCaps(opts);
180
+ var extraProps = _toSet(opts.extraProperties);
181
+
182
+ if (typeof text !== "string" && !Buffer.isBuffer(text)) {
183
+ throw new SafeVcardError("safe-vcard/bad-input",
184
+ "safeVcard.parse: input must be string or Buffer (got " + typeof text + ")");
185
+ }
186
+ var s = typeof text === "string" ? text : text.toString("utf8");
187
+ var byteLen = Buffer.byteLength(s, "utf8");
188
+ if (byteLen > caps.maxBytes) {
189
+ throw new SafeVcardError("safe-vcard/oversize-bytes",
190
+ "safeVcard.parse: input " + byteLen + " bytes exceeds maxBytes=" + caps.maxBytes);
191
+ }
192
+
193
+ var lines = _unfold(s, caps);
194
+ var vcards = [];
195
+ var idx = 0;
196
+ while (idx < lines.length) {
197
+ // Skip blank or non-content lines until BEGIN:VCARD.
198
+ while (idx < lines.length && lines[idx].name !== "BEGIN") idx++;
199
+ if (idx >= lines.length) break;
200
+ if (lines[idx].value.toUpperCase() !== "VCARD") {
201
+ throw new SafeVcardError("safe-vcard/missing-vcard",
202
+ "safeVcard.parse: BEGIN line at position " + (idx + 1) +
203
+ " is not VCARD (got '" + lines[idx].value + "')");
204
+ }
205
+ var parsed = _parseVcard(lines, idx, caps, extraProps);
206
+ vcards.push(parsed.card);
207
+ idx = parsed.nextIdx;
208
+ if (vcards.length > caps.maxCards) {
209
+ throw new SafeVcardError("safe-vcard/oversize-cards",
210
+ "safeVcard.parse: stream contains more than maxCards=" + caps.maxCards);
211
+ }
212
+ }
213
+ if (vcards.length === 0) {
214
+ throw new SafeVcardError("safe-vcard/missing-vcard",
215
+ "safeVcard.parse: no BEGIN:VCARD found");
216
+ }
217
+ return { vcards: vcards };
218
+ }
219
+
220
+ /**
221
+ * @primitive b.safeVcard.compliancePosture
222
+ * @signature b.safeVcard.compliancePosture(name)
223
+ * @since 0.9.81
224
+ * @status stable
225
+ * @related b.safeVcard.parse
226
+ *
227
+ * Map a compliance-posture name to its profile. Returns the profile
228
+ * string for a known posture, `null` for unknown names.
229
+ *
230
+ * @example
231
+ * b.safeVcard.compliancePosture("hipaa"); // -> "strict"
232
+ * b.safeVcard.compliancePosture("loose"); // -> null
233
+ */
234
+ function compliancePosture(name) {
235
+ return COMPLIANCE_POSTURES[name] || null;
236
+ }
237
+
238
+ // ---- Internal ----
239
+
240
+ function _resolveCaps(opts) {
241
+ var name = "strict";
242
+ if (typeof opts.profile === "string") {
243
+ name = opts.profile;
244
+ } else if (typeof opts.compliancePosture === "string") {
245
+ name = COMPLIANCE_POSTURES[opts.compliancePosture] || "strict";
246
+ }
247
+ var caps = PROFILES[name];
248
+ if (!caps) {
249
+ throw new SafeVcardError("safe-vcard/bad-opt",
250
+ "safeVcard.parse: unknown profile '" + name +
251
+ "' (expected strict|balanced|permissive)");
252
+ }
253
+ return caps;
254
+ }
255
+
256
+ function _toSet(arr) {
257
+ var set = Object.create(null);
258
+ if (!Array.isArray(arr)) return set;
259
+ for (var i = 0; i < arr.length; i++) {
260
+ if (typeof arr[i] === "string") set[arr[i].toUpperCase()] = true;
261
+ }
262
+ return set;
263
+ }
264
+
265
+ function _unfold(s, caps) {
266
+ // RFC 6350 §3.2 — line unfolding is identical to RFC 5545 §3.1.
267
+ var raw = s.replace(/\r\n?|\n/g, "\n").split("\n");
268
+ var unfolded = [];
269
+ for (var i = 0; i < raw.length; i++) {
270
+ var line = raw[i];
271
+ if (line.length === 0) continue;
272
+ var firstChar = line.charCodeAt(0);
273
+ if (firstChar === 0x20 || firstChar === 0x09) { // allow:raw-byte-literal — SPACE / HTAB fold markers per RFC 6350 §3.2
274
+ if (unfolded.length === 0) {
275
+ throw new SafeVcardError("safe-vcard/bad-line",
276
+ "safeVcard.parse: continuation line before any content line");
277
+ }
278
+ unfolded[unfolded.length - 1] += line.slice(1);
279
+ } else {
280
+ unfolded.push(line);
281
+ }
282
+ }
283
+ var parsed = [];
284
+ for (var j = 0; j < unfolded.length; j++) {
285
+ var u = unfolded[j];
286
+ if (Buffer.byteLength(u, "utf8") > caps.maxLineBytes) {
287
+ throw new SafeVcardError("safe-vcard/oversize-line-bytes",
288
+ "safeVcard.parse: unfolded line " + (j + 1) +
289
+ " exceeds maxLineBytes=" + caps.maxLineBytes);
290
+ }
291
+ parsed.push(_parseContentLine(u));
292
+ }
293
+ return parsed;
294
+ }
295
+
296
+ function _parseContentLine(line) {
297
+ var colonIdx = _findUnquotedColon(line);
298
+ if (colonIdx < 0) {
299
+ throw new SafeVcardError("safe-vcard/bad-line",
300
+ "safeVcard.parse: content line missing ':' separator: " + _preview(line));
301
+ }
302
+ var head = line.slice(0, colonIdx);
303
+ var value = line.slice(colonIdx + 1);
304
+
305
+ for (var k = 0; k < value.length; k++) {
306
+ var cc = value.charCodeAt(k);
307
+ if ((cc < 0x20 && cc !== 0x09) || cc === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
308
+ throw new SafeVcardError("safe-vcard/control-char-in-value",
309
+ "safeVcard.parse: control char 0x" + cc.toString(16) +
310
+ " in property value (header-injection defense)");
311
+ }
312
+ }
313
+
314
+ // RFC 6350 §3.3 — property name may be prefixed by an optional
315
+ // group token (group "."). Strip and retain the group.
316
+ var segs = _splitUnquoted(head, ";");
317
+ var nameRaw = segs[0];
318
+ var group = null;
319
+ var dotIdx = nameRaw.indexOf(".");
320
+ if (dotIdx >= 0) {
321
+ group = nameRaw.slice(0, dotIdx);
322
+ nameRaw = nameRaw.slice(dotIdx + 1);
323
+ }
324
+ var name = nameRaw.toUpperCase();
325
+ var params = Object.create(null);
326
+ for (var p = 1; p < segs.length; p++) {
327
+ var seg = segs[p];
328
+ var eq = seg.indexOf("=");
329
+ if (eq < 0) {
330
+ throw new SafeVcardError("safe-vcard/bad-line",
331
+ "safeVcard.parse: malformed parameter '" + seg + "'");
332
+ }
333
+ var pname = seg.slice(0, eq).toUpperCase();
334
+ var pvalue = seg.slice(eq + 1);
335
+ if (pname === "__proto__" || pname === "constructor" || pname === "prototype") continue;
336
+ if (params[pname]) {
337
+ params[pname].push(_stripDoubleQuotes(pvalue));
338
+ } else {
339
+ params[pname] = [_stripDoubleQuotes(pvalue)];
340
+ }
341
+ }
342
+ return { name: name, group: group, params: params, value: value };
343
+ }
344
+
345
+ function _findUnquotedColon(line) {
346
+ var inQ = false;
347
+ for (var i = 0; i < line.length; i++) {
348
+ var c = line.charCodeAt(i);
349
+ if (c === 0x22) { inQ = !inQ; continue; } // allow:raw-byte-literal — DQUOTE per RFC 6350 §3.3
350
+ if (c === 0x3A && !inQ) return i; // allow:raw-byte-literal — colon separator per RFC 6350 §3.3
351
+ }
352
+ return -1;
353
+ }
354
+
355
+ function _splitUnquoted(s, sep) {
356
+ var out = [];
357
+ var inQ = false;
358
+ var start = 0;
359
+ for (var i = 0; i < s.length; i++) {
360
+ var c = s.charAt(i);
361
+ if (c === '"') { inQ = !inQ; continue; }
362
+ if (c === sep && !inQ) {
363
+ out.push(s.slice(start, i));
364
+ start = i + 1;
365
+ }
366
+ }
367
+ out.push(s.slice(start));
368
+ return out;
369
+ }
370
+
371
+ function _stripDoubleQuotes(s) {
372
+ if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') {
373
+ return s.slice(1, -1);
374
+ }
375
+ return s;
376
+ }
377
+
378
+ function _parseVcard(lines, startIdx, caps, extraProps) {
379
+ var properties = Object.create(null);
380
+ var version = null;
381
+ var propertyCount = 0;
382
+ var i = startIdx + 1;
383
+ while (i < lines.length) {
384
+ var ln = lines[i];
385
+ if (ln.name === "END") {
386
+ if (ln.value.toUpperCase() !== "VCARD") {
387
+ throw new SafeVcardError("safe-vcard/unterminated-vcard",
388
+ "safeVcard.parse: BEGIN:VCARD closed by END:" + ln.value);
389
+ }
390
+ return {
391
+ card: { version: version || "4.0", properties: properties },
392
+ nextIdx: i + 1,
393
+ };
394
+ }
395
+ if (ln.name === "BEGIN") {
396
+ throw new SafeVcardError("safe-vcard/bad-line",
397
+ "safeVcard.parse: nested BEGIN inside VCARD (vCard does not support sub-components)");
398
+ }
399
+ var pn = ln.name;
400
+ if (!KNOWN_PROPERTIES[pn] && !extraProps[pn] && pn.indexOf("X-") !== 0) {
401
+ throw new SafeVcardError("safe-vcard/unknown-property",
402
+ "safeVcard.parse: unknown property '" + pn +
403
+ "' (extend via opts.extraProperties or use X- prefix)");
404
+ }
405
+ if (pn === "VERSION") version = ln.value;
406
+ if (EMBED_PROPERTIES[pn]) {
407
+ // RFC 6350 §6.2.4 — PHOTO/LOGO/SOUND/KEY values can be a URI
408
+ // (including data:) or a base64 blob (3.0-style). Compute the
409
+ // byte length of the decoded form when it is data: or pure
410
+ // base64; otherwise apply the raw-string byte length.
411
+ var embedBytes = _embedByteLength(ln.value);
412
+ if (embedBytes > caps.maxEmbedBytes) {
413
+ throw new SafeVcardError("safe-vcard/oversize-embed",
414
+ "safeVcard.parse: " + pn + " embed " + embedBytes +
415
+ " bytes exceeds maxEmbedBytes=" + caps.maxEmbedBytes);
416
+ }
417
+ }
418
+ propertyCount += 1;
419
+ if (propertyCount > caps.maxPropertiesPerCard) {
420
+ throw new SafeVcardError("safe-vcard/oversize-properties-per-card",
421
+ "safeVcard.parse: property count exceeds maxPropertiesPerCard=" +
422
+ caps.maxPropertiesPerCard);
423
+ }
424
+ if (pn === "__proto__" || pn === "constructor" || pn === "prototype") {
425
+ i += 1;
426
+ continue;
427
+ }
428
+ if (!properties[pn]) properties[pn] = [];
429
+ properties[pn].push({
430
+ group: ln.group,
431
+ params: ln.params,
432
+ value: ln.value,
433
+ });
434
+ i += 1;
435
+ }
436
+ throw new SafeVcardError("safe-vcard/unterminated-vcard",
437
+ "safeVcard.parse: BEGIN:VCARD never closed (missing END)");
438
+ }
439
+
440
+ function _embedByteLength(value) {
441
+ // data:<mime>;base64,<payload> — decoded bytes are (3/4) * payload
442
+ // length (rounding for padding).
443
+ var dataMatch = /^data:[^;,]*;base64,(.*)$/i.exec(value);
444
+ if (dataMatch) {
445
+ var payload = dataMatch[1].replace(/\s+/g, "");
446
+ return Math.floor(payload.length * 3 / 4); // allow:raw-byte-literal — base64 3/4 decode ratio per RFC 4648 §4
447
+ }
448
+ // ENCODING=b / ENCODING=BASE64 puts the raw base64 in the value
449
+ // directly (the param is parsed separately upstream; we do not have
450
+ // access here, so check whether the payload is base64-shaped).
451
+ if (/^[A-Za-z0-9+/=\r\n\t ]+$/.test(value) && value.length > 32) { // allow:raw-byte-literal — heuristic threshold for base64 detection
452
+ var compact = value.replace(/\s+/g, "");
453
+ if (compact.length > 0 && compact.length % 4 === 0) {
454
+ return Math.floor(compact.length * 3 / 4); // allow:raw-byte-literal — base64 3/4 decode ratio per RFC 4648 §4
455
+ }
456
+ }
457
+ return Buffer.byteLength(value, "utf8");
458
+ }
459
+
460
+ function _preview(s) {
461
+ if (typeof s !== "string") s = String(s);
462
+ return s.length > 64 ? s.slice(0, 64) + "..." : s; // allow:raw-byte-literal — log-preview length cap
463
+ }
464
+
465
+ module.exports = {
466
+ parse: parse,
467
+ compliancePosture: compliancePosture,
468
+ PROFILES: PROFILES,
469
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
470
+ KNOWN_PROPERTIES: KNOWN_PROPERTIES,
471
+ EMBED_PROPERTIES: EMBED_PROPERTIES,
472
+ SafeVcardError: SafeVcardError,
473
+ };
@@ -207,20 +207,39 @@ function verify(assetPath, signaturePath, pubkeyPem) {
207
207
  // single allocation peak, not the 2× peak that Buffer.concat([...chunks])
208
208
  // produces. 64 KiB chunks match the framework's hash-while-streaming
209
209
  // convention elsewhere.
210
+ //
211
+ // CRYPTO-2 hardening (v0.9.58): fstat the asset BEFORE the read loop
212
+ // for every alg path, clamp every readSync to (assetStat.size -
213
+ // fullOff), and reject if the final fullOff diverges from
214
+ // assetStat.size. A grow-during-read race (writer appends as we
215
+ // hash) previously fed extra bytes to the hashers but not to the
216
+ // pre-sized fullBuf — the returned sha3_512 then didn't match what
217
+ // signature-verify or the operator's later byte-set compare saw.
218
+ // The clamp + final-equality refusal forces every hash + verify byte
219
+ // to come from the same {0..assetStat.size} range fixed at open
220
+ // time.
221
+ var assetStat = nodeFs.fstatSync(assetFd);
210
222
  var sha256 = nodeCrypto.createHash("sha256");
211
223
  var sha3 = nodeCrypto.createHash("sha3-512");
212
224
  var verifier = (alg === "ecdsa-p384") ? nodeCrypto.createVerify("sha3-512") : null;
213
225
  var fullBuf = null;
214
226
  var fullOff = 0;
215
227
  if (verifier === null) {
216
- var assetStat = nodeFs.fstatSync(assetFd);
217
228
  fullBuf = Buffer.allocUnsafe(assetStat.size);
218
229
  }
219
230
 
220
231
  try {
221
232
  var chunk = Buffer.allocUnsafe(64 * 1024); // allow:raw-byte-literal — module is zero-dep by contract; cannot import C.BYTES
222
233
  while (true) {
223
- var n = nodeFs.readSync(assetFd, chunk, 0, chunk.length, null);
234
+ var remaining = assetStat.size - fullOff;
235
+ if (remaining <= 0) break;
236
+ // Clamp the read to the remaining bytes the verifier and hashers
237
+ // are allowed to see. Without this, a concurrent appender grows
238
+ // the file under us and the readSync returns more bytes than the
239
+ // fullBuf was sized for.
240
+ var capped = chunk.length; // allow:raw-byte-literal — buffer length is the read upper bound
241
+ if (remaining < capped) capped = remaining;
242
+ var n = nodeFs.readSync(assetFd, chunk, 0, capped, null);
224
243
  if (n === 0) break;
225
244
  var slice = chunk.subarray(0, n);
226
245
  sha256.update(slice);
@@ -228,12 +247,22 @@ function verify(assetPath, signaturePath, pubkeyPem) {
228
247
  if (verifier) verifier.update(slice);
229
248
  if (fullBuf) {
230
249
  slice.copy(fullBuf, fullOff);
231
- fullOff += n;
232
250
  }
251
+ fullOff += n;
233
252
  }
234
253
  } finally {
235
254
  nodeFs.closeSync(assetFd);
236
255
  }
256
+ // Final byte-count gate. If fullOff != assetStat.size, the file was
257
+ // truncated under us (read fewer bytes than stat said) or grew
258
+ // beyond what the clamp let through. Both cases mean the hashers
259
+ // and verifier saw a different byte set than the on-disk file.
260
+ if (fullOff !== assetStat.size) {
261
+ throw new Error("standalone-verifier.verify: asset '" + assetPath +
262
+ "' changed size during read (expected " + assetStat.size +
263
+ " bytes per fstat, read " + fullOff +
264
+ " bytes) — refusing to return a hash that may not match the on-disk file");
265
+ }
237
266
 
238
267
  var sha256Hex = sha256.digest("hex");
239
268
  var sha3Hex = sha3.digest("hex");