@blamejs/core 0.9.49 → 0.10.1
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/CHANGELOG.md +951 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/agent-stream.js
CHANGED
|
@@ -138,6 +138,14 @@ function _makeIterator(ctx) {
|
|
|
138
138
|
var done = false;
|
|
139
139
|
var closed = false;
|
|
140
140
|
var drained = false;
|
|
141
|
+
// SUBSTRATE-14 — track the cursor of the LAST row actually yielded
|
|
142
|
+
// to the consumer. The prior shape called cursor.lastSeenCursor()
|
|
143
|
+
// at drain-marker emit, which returned the position of the last
|
|
144
|
+
// FETCHED batch — clients resuming from that cursor SKIPPED every
|
|
145
|
+
// row still in `buffer` that hadn't been yielded yet. Now we record
|
|
146
|
+
// the per-row cursor at yield time so the marker carries the
|
|
147
|
+
// correct resume point.
|
|
148
|
+
var lastYieldedCursor = null;
|
|
141
149
|
_safeAudit(ctx.audit, "agent.stream.opened", ctx.actor, { kind: ctx.kind, streamId: streamId });
|
|
142
150
|
|
|
143
151
|
async function _closeOnce(reason) {
|
|
@@ -159,6 +167,13 @@ function _makeIterator(ctx) {
|
|
|
159
167
|
try {
|
|
160
168
|
if (buffer.length > 0) {
|
|
161
169
|
var row = buffer.shift();
|
|
170
|
+
// SUBSTRATE-14 — record the cursor for this yielded row so a
|
|
171
|
+
// drain that fires BETWEEN buffered yields emits a marker
|
|
172
|
+
// whose lastSeenCursor matches what the client actually
|
|
173
|
+
// received. The cursor extraction shape mirrors the
|
|
174
|
+
// operator's `cursor.lastSeenCursor()` contract: row carries
|
|
175
|
+
// `_cursor` OR the cursor object exposes a per-row helper.
|
|
176
|
+
lastYieldedCursor = _resumeCursorFor(row, lastYieldedCursor);
|
|
162
177
|
return { value: row, done: false };
|
|
163
178
|
}
|
|
164
179
|
if (done) {
|
|
@@ -171,14 +186,18 @@ function _makeIterator(ctx) {
|
|
|
171
186
|
drained = true;
|
|
172
187
|
var marker = {
|
|
173
188
|
_drainMarker: true,
|
|
174
|
-
lastSeenCursor:
|
|
175
|
-
? cursor.lastSeenCursor() : null,
|
|
189
|
+
lastSeenCursor: lastYieldedCursor,
|
|
176
190
|
reason: "drain",
|
|
177
191
|
};
|
|
178
192
|
_safeAudit(ctx.audit, "agent.stream.drain_marker_emitted", ctx.actor, {
|
|
179
193
|
kind: ctx.kind, streamId: streamId,
|
|
194
|
+
bufferedRowsDropped: buffer.length,
|
|
195
|
+
lastSeenCursor: lastYieldedCursor,
|
|
180
196
|
});
|
|
181
197
|
done = true;
|
|
198
|
+
// Discard any buffered rows so the consumer doesn't see
|
|
199
|
+
// post-drain rows after the marker.
|
|
200
|
+
buffer.length = 0;
|
|
182
201
|
return { value: marker, done: false };
|
|
183
202
|
}
|
|
184
203
|
await _closeOnce("drain");
|
|
@@ -204,6 +223,7 @@ function _makeIterator(ctx) {
|
|
|
204
223
|
}
|
|
205
224
|
// Push all but the first into the buffer; return the first.
|
|
206
225
|
for (var i = 1; i < rows.length; i += 1) buffer.push(rows[i]);
|
|
226
|
+
lastYieldedCursor = _resumeCursorFor(rows[0], lastYieldedCursor);
|
|
207
227
|
return { value: rows[0], done: false };
|
|
208
228
|
} catch (e) {
|
|
209
229
|
// Any error closes the cursor + emits an audit. Re-throw to
|
|
@@ -228,6 +248,18 @@ function _safeAudit(auditImpl, action, actor, metadata) {
|
|
|
228
248
|
agentAudit.safeAudit(auditImpl, action, actor, metadata);
|
|
229
249
|
}
|
|
230
250
|
|
|
251
|
+
// SUBSTRATE-14 — resolve the resume cursor for a row about to be
|
|
252
|
+
// yielded. Operators may attach the cursor per-row (`row._cursor` /
|
|
253
|
+
// `row.cursor`) OR rely on the cursor's own per-row tracker
|
|
254
|
+
// (`cursor.cursorForRow(row)`) — both shapes supported.
|
|
255
|
+
function _resumeCursorFor(row, fallback) {
|
|
256
|
+
if (row && typeof row === "object") {
|
|
257
|
+
if (row._cursor != null) return row._cursor;
|
|
258
|
+
if (row.cursor != null) return row.cursor;
|
|
259
|
+
}
|
|
260
|
+
return fallback;
|
|
261
|
+
}
|
|
262
|
+
|
|
231
263
|
module.exports = {
|
|
232
264
|
create: create,
|
|
233
265
|
AgentStreamError: AgentStreamError,
|
package/lib/agent-tenant.js
CHANGED
|
@@ -58,11 +58,22 @@ var agentAudit = require("./agent-audit");
|
|
|
58
58
|
|
|
59
59
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
60
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
61
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
61
62
|
|
|
62
63
|
var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
|
|
63
64
|
|
|
64
65
|
var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
|
|
65
66
|
|
|
67
|
+
// Per-tenant key derivation domain separators. NIST SP 800-108 r1 §5.1
|
|
68
|
+
// KDF-in-Counter shape — fixed "label" + tenantId-as-salt + purpose-as-
|
|
69
|
+
// info. Operator passphrase rotation produces a fresh master, breaking
|
|
70
|
+
// every prior tenant ciphertext (operator intent: rotation = re-seal).
|
|
71
|
+
var TENANT_KDF_LABEL = "blamejs.agent.tenant/v1";
|
|
72
|
+
// 32 bytes — XChaCha20-Poly1305 key length. Distinct from the audit
|
|
73
|
+
// truncation buffer so future key-length bumps don't have to chase a
|
|
74
|
+
// magic constant.
|
|
75
|
+
var TENANT_KEY_BYTES = 32; // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
|
|
76
|
+
|
|
66
77
|
/**
|
|
67
78
|
* @primitive b.agent.tenant.create
|
|
68
79
|
* @signature b.agent.tenant.create(opts)
|
|
@@ -111,8 +122,8 @@ function create(opts) {
|
|
|
111
122
|
sealField: function (tenantId, table, field, plaintext) { return _sealField(tenantId, table, field, plaintext); },
|
|
112
123
|
unsealField: function (tenantId, table, field, ciphertext) { return _unsealField(tenantId, table, field, ciphertext); },
|
|
113
124
|
sealRowForTenant: function (tenantId, table, row) { return _sealRowForTenant(tenantId, table, row); },
|
|
114
|
-
unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(tenantId, table, row); },
|
|
115
|
-
listArchived: function () {
|
|
125
|
+
unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(ctx, tenantId, table, row); },
|
|
126
|
+
listArchived: function () { return _listArchived(ctx); },
|
|
116
127
|
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
117
128
|
AgentTenantError: AgentTenantError,
|
|
118
129
|
_ctx: ctx,
|
|
@@ -160,14 +171,40 @@ async function _unregister(ctx, tenantId, args) {
|
|
|
160
171
|
}
|
|
161
172
|
// Archive default — retain the key + metadata for retention-mandated
|
|
162
173
|
// restoration. Operator's compliance regime drives archivePolicy.
|
|
174
|
+
// SUBSTRATE-19 — persist as a `status: "archived"` row in the same
|
|
175
|
+
// backend rather than only the process-local Map. GDPR Art. 17 +
|
|
176
|
+
// HIPAA §164.530(j) require the archived state to survive process
|
|
177
|
+
// restart (auditor pulls a deleted tenant's archival record years
|
|
178
|
+
// later); a Map-only archive evaporated on every redeploy.
|
|
179
|
+
var archivedRow = {
|
|
180
|
+
tenantId: tenantId,
|
|
181
|
+
posture: row.posture,
|
|
182
|
+
archivePolicy: row.archivePolicy || "default-archive",
|
|
183
|
+
metadata: row.metadata,
|
|
184
|
+
registeredAt: row.registeredAt,
|
|
185
|
+
archivedAt: Date.now(),
|
|
186
|
+
status: "archived",
|
|
187
|
+
};
|
|
163
188
|
ctx.archive.set(tenantId, {
|
|
164
|
-
tenantId: tenantId, archivedAt:
|
|
165
|
-
policy:
|
|
166
|
-
row: row,
|
|
189
|
+
tenantId: tenantId, archivedAt: archivedRow.archivedAt,
|
|
190
|
+
policy: archivedRow.archivePolicy, row: row,
|
|
167
191
|
});
|
|
192
|
+
// Two-phase: persist the archived row first, then delete the live
|
|
193
|
+
// row. If the persist fails, the live row stays and the operator
|
|
194
|
+
// can retry; the inverse (delete-then-persist) loses the row on
|
|
195
|
+
// a backend hiccup. Operators wiring a durable backend get
|
|
196
|
+
// restart-survival for free.
|
|
197
|
+
if (typeof ctx.backend.archive === "function") {
|
|
198
|
+
await ctx.backend.archive(tenantId, archivedRow);
|
|
199
|
+
} else {
|
|
200
|
+
// Backends without a dedicated archive() API store the archived
|
|
201
|
+
// row under a sentinel-prefixed key so list() can find it and
|
|
202
|
+
// lookup() (which checks status) refuses live-row access.
|
|
203
|
+
await ctx.backend.set("__archived__/" + tenantId, archivedRow);
|
|
204
|
+
}
|
|
168
205
|
await ctx.backend.delete(tenantId);
|
|
169
206
|
agentAudit.safeAudit(ctx.audit, "agent.tenant.archived", args.actor, {
|
|
170
|
-
tenantId: tenantId, policy:
|
|
207
|
+
tenantId: tenantId, policy: archivedRow.archivePolicy,
|
|
171
208
|
});
|
|
172
209
|
return { tenantId: tenantId, mode: "archived" };
|
|
173
210
|
}
|
|
@@ -187,7 +224,14 @@ async function _lookup(ctx, tenantId, args) {
|
|
|
187
224
|
|
|
188
225
|
async function _list(ctx, args) {
|
|
189
226
|
var rows = await ctx.backend.list();
|
|
190
|
-
return rows.
|
|
227
|
+
return rows.filter(function (r) {
|
|
228
|
+
// Skip archived rows from the live list; listArchived surfaces
|
|
229
|
+
// them separately. Backends without an `archive()` API stored the
|
|
230
|
+
// archived row under "__archived__/<tenantId>" via the fallback;
|
|
231
|
+
// the row's status === "archived" sentinel is the canonical
|
|
232
|
+
// discriminator regardless of backend.
|
|
233
|
+
return r && r.status !== "archived";
|
|
234
|
+
}).map(function (r) {
|
|
191
235
|
return {
|
|
192
236
|
tenantId: r.tenantId,
|
|
193
237
|
posture: r.posture,
|
|
@@ -197,6 +241,51 @@ async function _list(ctx, args) {
|
|
|
197
241
|
});
|
|
198
242
|
}
|
|
199
243
|
|
|
244
|
+
async function _listArchived(ctx) {
|
|
245
|
+
// Prefer the operator's `listArchived()` when available — durable
|
|
246
|
+
// backends that need an index can implement it cheaper than walking
|
|
247
|
+
// every row. Fall back to scanning list() for `status: "archived"`
|
|
248
|
+
// rows (the in-memory + sentinel-prefix path).
|
|
249
|
+
var out = [];
|
|
250
|
+
if (typeof ctx.backend.listArchived === "function") {
|
|
251
|
+
var rows = await ctx.backend.listArchived();
|
|
252
|
+
if (Array.isArray(rows)) {
|
|
253
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
254
|
+
out.push({
|
|
255
|
+
tenantId: rows[i].tenantId,
|
|
256
|
+
archivedAt: rows[i].archivedAt,
|
|
257
|
+
policy: rows[i].archivePolicy || rows[i].policy || "default-archive",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
var allRows = await ctx.backend.list();
|
|
263
|
+
if (Array.isArray(allRows)) {
|
|
264
|
+
for (var j = 0; j < allRows.length; j += 1) {
|
|
265
|
+
var r = allRows[j];
|
|
266
|
+
if (r && r.status === "archived") {
|
|
267
|
+
out.push({
|
|
268
|
+
tenantId: r.tenantId,
|
|
269
|
+
archivedAt: r.archivedAt,
|
|
270
|
+
policy: r.archivePolicy || "default-archive",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Process-local cache (set on archive() in this process) for the
|
|
277
|
+
// common case of `archive→listArchived` within one boot before the
|
|
278
|
+
// backend list() index catches up.
|
|
279
|
+
ctx.archive.forEach(function (v) {
|
|
280
|
+
var found = false;
|
|
281
|
+
for (var k = 0; k < out.length; k += 1) {
|
|
282
|
+
if (out[k].tenantId === v.tenantId) { found = true; break; }
|
|
283
|
+
}
|
|
284
|
+
if (!found) out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy });
|
|
285
|
+
});
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
200
289
|
// ---- Cross-tenant gate ----------------------------------------------------
|
|
201
290
|
|
|
202
291
|
function _check(ctx, actor, agentTenantId) {
|
|
@@ -231,18 +320,78 @@ function _check(ctx, actor, agentTenantId) {
|
|
|
231
320
|
}
|
|
232
321
|
|
|
233
322
|
// ---- Per-tenant derived key -----------------------------------------------
|
|
323
|
+
//
|
|
324
|
+
// SUBSTRATE-5 — `namespaceHash(label, tenantId)` is a PUBLIC function
|
|
325
|
+
// over PUBLIC inputs; an attacker who learns `tenantId` (an account id
|
|
326
|
+
// surfaced in URLs / API responses) reconstructs every per-tenant key
|
|
327
|
+
// without any secret material. The defense the docstring promises —
|
|
328
|
+
// "cross-tenant decrypt refused at the vault boundary" — never bound
|
|
329
|
+
// to anything secret.
|
|
330
|
+
//
|
|
331
|
+
// NIST SP 800-108 r1 §5.1 (KDF in Counter / Feedback Mode): a key
|
|
332
|
+
// derivation function MUST consume a secret KDK (Key Derivation Key)
|
|
333
|
+
// alongside the public label + context. GDPR Art. 32 (Security of
|
|
334
|
+
// processing) requires "the pseudonymisation and encryption of
|
|
335
|
+
// personal data" — keys derived purely from public per-record
|
|
336
|
+
// identifiers are pseudonymisation-only, not encryption.
|
|
337
|
+
//
|
|
338
|
+
// New shape: SHAKE256(label || rootKey || tenantId || purpose), where
|
|
339
|
+
// rootKey is SHA3-512(vault.getKeysJson()). Same derivation `b.vault.aad`
|
|
340
|
+
// uses internally — the vault's master keypair PEM is the secret KDK.
|
|
341
|
+
// Rotating the vault passphrase / keypair (b.vaultRotate.rotate)
|
|
342
|
+
// changes rootKey, which changes every derived tenant key — operator
|
|
343
|
+
// intent for rotation IS re-seal.
|
|
344
|
+
//
|
|
345
|
+
// `derivedKey` returns a hex-encoded 32-byte key (64 chars) to keep
|
|
346
|
+
// the wire shape compatible with prior callers. Internal callers that
|
|
347
|
+
// need the raw key use `_tenantFieldKey` directly.
|
|
234
348
|
|
|
235
|
-
function
|
|
349
|
+
function _vaultRootBytes() {
|
|
350
|
+
// Vault.getKeysJson() throws when the vault hasn't been init'd. That
|
|
351
|
+
// is the right secure-by-default posture: tenant-derived keys cannot
|
|
352
|
+
// be produced before the operator has bootstrapped the vault. The
|
|
353
|
+
// error reaches the caller (sealField / register) so an operator
|
|
354
|
+
// mis-ordering boot (start agents before vault.init) sees a clear
|
|
355
|
+
// refusal rather than getting weakened-but-deterministic keys.
|
|
356
|
+
var keysJson;
|
|
357
|
+
try { keysJson = vault().getKeysJson(); }
|
|
358
|
+
catch (e) {
|
|
359
|
+
throw new AgentTenantError("agent-tenant/vault-not-initialized",
|
|
360
|
+
"derivedKey: vault must be initialized before per-tenant keys can be " +
|
|
361
|
+
"derived (vault.getKeysJson threw: " + (e && e.message ? e.message : String(e)) + ")");
|
|
362
|
+
}
|
|
363
|
+
return Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _deriveTenantKeyBytes(tenantId, purpose) {
|
|
236
367
|
guardTenantId.validate(tenantId);
|
|
237
368
|
if (typeof purpose !== "string" || purpose.length === 0) {
|
|
238
369
|
throw new AgentTenantError("agent-tenant/bad-purpose",
|
|
239
370
|
"derivedKey: purpose required (e.g. 'seal' / 'audit' / 'session')");
|
|
240
371
|
}
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
372
|
+
// Domain-separated KDF input. NUL separators between fields prevent
|
|
373
|
+
// (label, tenantId="x\0y", purpose="z") colliding with
|
|
374
|
+
// (label, tenantId="x", purpose="y\0z") — same byte concatenation,
|
|
375
|
+
// different logical context.
|
|
376
|
+
var rootBytes = _vaultRootBytes();
|
|
377
|
+
var input = Buffer.concat([
|
|
378
|
+
Buffer.from(TENANT_KDF_LABEL, "utf8"),
|
|
379
|
+
Buffer.from([0x00]),
|
|
380
|
+
rootBytes,
|
|
381
|
+
Buffer.from([0x00]),
|
|
382
|
+
Buffer.from(tenantId, "utf8"),
|
|
383
|
+
Buffer.from([0x00]),
|
|
384
|
+
Buffer.from(purpose, "utf8"),
|
|
385
|
+
]);
|
|
386
|
+
return bCrypto.kdf(input, TENANT_KEY_BYTES);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function _derivedKey(tenantId, purpose) {
|
|
390
|
+
// Public API — returns hex so the existing wire shape (operators
|
|
391
|
+
// storing the derived key string in their DB) is unchanged. Internal
|
|
392
|
+
// AEAD callers use `_deriveTenantKeyBytes` directly to skip the
|
|
393
|
+
// hex/Buffer round-trip.
|
|
394
|
+
return _deriveTenantKeyBytes(tenantId, purpose).toString("hex");
|
|
246
395
|
}
|
|
247
396
|
|
|
248
397
|
// ---- Per-tenant audit -----------------------------------------------------
|
|
@@ -323,11 +472,11 @@ function _auditFor(ctx, tenantId) {
|
|
|
323
472
|
var TENANT_FIELD_PREFIX = "tnt-v1:";
|
|
324
473
|
|
|
325
474
|
function _tenantFieldKey(tenantId, table) {
|
|
326
|
-
// 32-byte symmetric key for XChaCha20-Poly1305.
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
return
|
|
475
|
+
// 32-byte symmetric key for XChaCha20-Poly1305. _deriveTenantKeyBytes
|
|
476
|
+
// returns the raw key bound to the vault master + tenantId + purpose
|
|
477
|
+
// — see SUBSTRATE-5 commentary above _derivedKey for the threat
|
|
478
|
+
// model that drove this away from public-input-only derivation.
|
|
479
|
+
return _deriveTenantKeyBytes(tenantId, "cryptoField:" + table);
|
|
331
480
|
}
|
|
332
481
|
|
|
333
482
|
function _tenantFieldAad(tenantId, table, field) {
|
|
@@ -400,7 +549,7 @@ function _sealRowForTenant(tenantId, table, row) {
|
|
|
400
549
|
return out;
|
|
401
550
|
}
|
|
402
551
|
|
|
403
|
-
function _unsealRowForTenant(tenantId, table, row) {
|
|
552
|
+
function _unsealRowForTenant(ctx, tenantId, table, row) {
|
|
404
553
|
if (!row) return row;
|
|
405
554
|
guardTenantId.validate(tenantId);
|
|
406
555
|
var cf = cryptoField();
|
|
@@ -415,10 +564,17 @@ function _unsealRowForTenant(tenantId, table, row) {
|
|
|
415
564
|
var f = fields[i];
|
|
416
565
|
if (out[f] !== undefined && out[f] !== null) {
|
|
417
566
|
try { out[f] = _unsealField(tenantId, table, f, out[f]); }
|
|
418
|
-
catch (
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
567
|
+
catch (e) {
|
|
568
|
+
// BUG-4 — null-on-decrypt-failure was silent; the docstring
|
|
569
|
+
// promised "audit chain surfaces the failure" but no emit ever
|
|
570
|
+
// ran. Cross-tenant ciphertext replay / tampered row / wrong-
|
|
571
|
+
// prefix all hit this path; operator audit pipelines need the
|
|
572
|
+
// signal to alert. CWE-778 (Insufficient Logging) — defense-
|
|
573
|
+
// in-depth that the field nulled silently.
|
|
574
|
+
agentAudit.safeAudit(ctx.audit, "agent.tenant.cross_tenant_decrypt_refused", null, {
|
|
575
|
+
tenantId: tenantId, table: table, field: f,
|
|
576
|
+
reason: (e && e.message) || String(e),
|
|
577
|
+
});
|
|
422
578
|
out[f] = null;
|
|
423
579
|
}
|
|
424
580
|
}
|
package/lib/agent-trace.js
CHANGED
|
@@ -63,11 +63,25 @@
|
|
|
63
63
|
var lazyRequire = require("./lazy-require");
|
|
64
64
|
var { defineClass } = require("./framework-error");
|
|
65
65
|
var guardTraceContext = require("./guard-trace-context");
|
|
66
|
+
var agentAudit = require("./agent-audit");
|
|
66
67
|
|
|
67
68
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
68
69
|
|
|
69
70
|
var AgentTraceError = defineClass("AgentTraceError", { alwaysPermanent: true });
|
|
70
71
|
|
|
72
|
+
// SUBSTRATE-24 — once-per-process audit emit on the first tracer
|
|
73
|
+
// failure each install fires. Operators get the signal even when
|
|
74
|
+
// individual span calls are best-effort suppressed.
|
|
75
|
+
var _failureAuditEmittedFor = Object.create(null);
|
|
76
|
+
function _emitFirstFailureAudit(auditImpl, kind, message) {
|
|
77
|
+
if (_failureAuditEmittedFor[kind]) return;
|
|
78
|
+
_failureAuditEmittedFor[kind] = true;
|
|
79
|
+
agentAudit.safeAudit(auditImpl, "agent.trace.tracer_failure", null, {
|
|
80
|
+
kind: kind, message: message ? String(message).slice(0, 256) : "", // allow:raw-byte-literal — audit-message char cap
|
|
81
|
+
rateLimited: "first-occurrence-only",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
/**
|
|
72
86
|
* @primitive b.agent.trace.create
|
|
73
87
|
* @signature b.agent.trace.create(opts)
|
|
@@ -103,18 +117,22 @@ function create(opts) {
|
|
|
103
117
|
var auditImpl = opts.audit || audit();
|
|
104
118
|
|
|
105
119
|
return {
|
|
106
|
-
startSpan: function (name, sopts) { return _startSpan(opts.tracing, name, sopts || {}); },
|
|
120
|
+
startSpan: function (name, sopts) { return _startSpan(opts.tracing, name, sopts || {}, auditImpl); },
|
|
107
121
|
injectIntoEnvelope: function (envelope, span) { return _injectIntoEnvelope(opts.tracing, envelope, span); },
|
|
108
122
|
extractFromEnvelope: function (envelope) { return _extractFromEnvelope(envelope); },
|
|
109
|
-
recordResult: function (span, result, error) { return _recordResult(span, result, error); },
|
|
110
|
-
shouldSample
|
|
123
|
+
recordResult: function (span, result, error) { return _recordResult(span, result, error, auditImpl); },
|
|
124
|
+
// SUBSTRATE-17 — `shouldSample` now takes a traceId so the same
|
|
125
|
+
// trace gets the same decision across hops. Operator-supplied
|
|
126
|
+
// traceId comes from the W3C `traceparent` header at request-
|
|
127
|
+
// entry; absent that, falls back to Math.random (start of trace).
|
|
128
|
+
shouldSample: function (method, traceId) { return _shouldSample(sampleRate, perMethod, method, traceId); },
|
|
111
129
|
formatAttributes: function (info) { return _formatAttributes(info); },
|
|
112
130
|
AgentTraceError: AgentTraceError,
|
|
113
131
|
_ctx: { sampleRate: sampleRate, perMethod: perMethod, audit: auditImpl },
|
|
114
132
|
};
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
function _startSpan(tracing, name, sopts) {
|
|
135
|
+
function _startSpan(tracing, name, sopts, auditImpl) {
|
|
118
136
|
if (typeof name !== "string" || name.length === 0) {
|
|
119
137
|
throw new AgentTraceError("agent-trace/bad-span-name",
|
|
120
138
|
"startSpan: name required");
|
|
@@ -123,19 +141,36 @@ function _startSpan(tracing, name, sopts) {
|
|
|
123
141
|
// on the registry stack so tracing.contextHeaders() / currentSpan()
|
|
124
142
|
// see it, then exposes end() so the agent boundary controls
|
|
125
143
|
// lifetime across publish → consume.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
try {
|
|
145
|
+
if (typeof tracing.manualSpan === "function") {
|
|
146
|
+
return tracing.manualSpan(name, sopts);
|
|
147
|
+
}
|
|
148
|
+
if (typeof tracing.startSpan === "function") {
|
|
149
|
+
return tracing.startSpan(name, sopts);
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// SUBSTRATE-24 — tracer failures should not crash the agent's
|
|
153
|
+
// method call; surface the first failure to the audit chain
|
|
154
|
+
// (rate-limited) so operators get the signal.
|
|
155
|
+
_emitFirstFailureAudit(auditImpl, "startSpan", e && e.message);
|
|
156
|
+
return _noopSpan();
|
|
133
157
|
}
|
|
134
158
|
throw new AgentTraceError("agent-trace/bad-tracing",
|
|
135
159
|
"startSpan: opts.tracing must expose manualSpan() (b.tracing.create()) " +
|
|
136
160
|
"or startSpan() (raw OTel tracer); neither found");
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
function _noopSpan() {
|
|
164
|
+
// Returned when the tracer threw — caller can still call
|
|
165
|
+
// recordException / setStatus / end without further errors.
|
|
166
|
+
return {
|
|
167
|
+
end: function () {},
|
|
168
|
+
setStatus: function () {},
|
|
169
|
+
recordException: function () {},
|
|
170
|
+
isNoop: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
139
174
|
function _injectIntoEnvelope(tracing, envelope, span) {
|
|
140
175
|
if (!envelope || typeof envelope !== "object") {
|
|
141
176
|
throw new AgentTraceError("agent-trace/bad-envelope",
|
|
@@ -168,32 +203,59 @@ function _extractFromEnvelope(envelope) {
|
|
|
168
203
|
};
|
|
169
204
|
}
|
|
170
205
|
|
|
171
|
-
function _recordResult(span, result, error) {
|
|
206
|
+
function _recordResult(span, result, error, auditImpl) {
|
|
172
207
|
if (!span || typeof span !== "object") return;
|
|
208
|
+
// SUBSTRATE-24 — surface first occurrence of each span-op failure
|
|
209
|
+
// via audit so the operator gets the signal. Subsequent failures
|
|
210
|
+
// stay silent (best-effort) per the operational spec.
|
|
173
211
|
if (error) {
|
|
174
212
|
if (typeof span.recordException === "function") {
|
|
175
|
-
try { span.recordException(error); }
|
|
213
|
+
try { span.recordException(error); }
|
|
214
|
+
catch (e) { _emitFirstFailureAudit(auditImpl, "recordException", e && e.message); }
|
|
176
215
|
}
|
|
177
216
|
if (typeof span.setStatus === "function") {
|
|
178
217
|
try { span.setStatus({ code: 2, message: error.message || String(error) }); }
|
|
179
|
-
catch (
|
|
218
|
+
catch (e) { _emitFirstFailureAudit(auditImpl, "setStatus", e && e.message); }
|
|
180
219
|
}
|
|
181
220
|
} else if (typeof span.setStatus === "function") {
|
|
182
|
-
try { span.setStatus({ code: 1 }); }
|
|
221
|
+
try { span.setStatus({ code: 1 }); }
|
|
222
|
+
catch (e) { _emitFirstFailureAudit(auditImpl, "setStatus", e && e.message); }
|
|
183
223
|
}
|
|
184
224
|
if (typeof span.end === "function") {
|
|
185
|
-
try { span.end(); }
|
|
225
|
+
try { span.end(); }
|
|
226
|
+
catch (e) { _emitFirstFailureAudit(auditImpl, "end", e && e.message); }
|
|
186
227
|
}
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
|
|
230
|
+
// SUBSTRATE-17 — deterministic sampling per W3C Trace Context §3.2.3.1.
|
|
231
|
+
// `Math.random` makes child-vs-parent sampling decisions non-coherent:
|
|
232
|
+
// a parent span sampled OUT can still have child spans sampled IN,
|
|
233
|
+
// producing orphaned spans operators can't correlate. Hashing the
|
|
234
|
+
// 16-byte trace-id deterministically routes every span in a trace to
|
|
235
|
+
// the same decision. When traceId is absent (start of a trace at
|
|
236
|
+
// request-entry boundary) we still use Math.random as the seeding
|
|
237
|
+
// roll; downstream callers pass the resulting traceId so children
|
|
238
|
+
// inherit the decision.
|
|
239
|
+
function _shouldSample(globalRate, perMethod, method, traceId) {
|
|
240
|
+
var rate = globalRate;
|
|
190
241
|
if (typeof method === "string" && Object.prototype.hasOwnProperty.call(perMethod, method)) {
|
|
191
242
|
var r = perMethod[method];
|
|
192
|
-
if (typeof r === "number" && isFinite(r) && r >= 0 && r <= 1)
|
|
193
|
-
|
|
194
|
-
|
|
243
|
+
if (typeof r === "number" && isFinite(r) && r >= 0 && r <= 1) rate = r;
|
|
244
|
+
}
|
|
245
|
+
if (rate <= 0) return false;
|
|
246
|
+
if (rate >= 1) return true;
|
|
247
|
+
if (typeof traceId === "string" && /^[0-9a-fA-F]{32}$/.test(traceId)) {
|
|
248
|
+
// Use the low 32 bits of the trace-id as the sampling roll
|
|
249
|
+
// (W3C-compatible). Hash modulo 1e9 → divide by 1e9 puts the
|
|
250
|
+
// result in [0,1) deterministically.
|
|
251
|
+
var lo = parseInt(traceId.slice(-8), 16); // allow:raw-byte-literal — low 32 bits of trace-id hex
|
|
252
|
+
var roll = (lo >>> 0) / 0x100000000; // allow:raw-byte-literal — 2^32 normalization divisor
|
|
253
|
+
return roll < rate;
|
|
195
254
|
}
|
|
196
|
-
|
|
255
|
+
// No trace-id supplied — start of a new trace. Operators wire
|
|
256
|
+
// shouldSample(method, ctx.traceId) on every downstream hop so
|
|
257
|
+
// children inherit the decision deterministically.
|
|
258
|
+
return Math.random() < rate; // allow:math-random-noncrypto — start-of-trace seed only; downstream hops pass traceId for deterministic propagation
|
|
197
259
|
}
|
|
198
260
|
|
|
199
261
|
function _formatAttributes(info) {
|
|
@@ -215,4 +277,5 @@ module.exports = {
|
|
|
215
277
|
guards: {
|
|
216
278
|
context: guardTraceContext,
|
|
217
279
|
},
|
|
280
|
+
_resetForTest: function () { _failureAuditEmittedFor = Object.create(null); },
|
|
218
281
|
};
|
package/lib/auth/aal.js
CHANGED
|
@@ -136,12 +136,19 @@ function meets(actualBand, requiredBand) {
|
|
|
136
136
|
// access tokens with AAL info typically use `acr` / `amr` (RFC 9068
|
|
137
137
|
// §3 / OpenID Connect Core §2). The framework leaves that wiring to
|
|
138
138
|
// the operator — but the constants make the AMR strings consistent.
|
|
139
|
+
// RFC 8176 §2 — registered AMR values. Pre-v0.9.x mapped WEBAUTHN to
|
|
140
|
+
// `fido-u2f`; that's the OLD U2F protocol AMR. Modern WebAuthn
|
|
141
|
+
// authenticators use the `hwk` ("proof-of-possession of a hardware-
|
|
142
|
+
// secured key") AMR — the same one HARDWARE uses, since WebAuthn IS
|
|
143
|
+
// a hardware-cryptographic-authenticator binding. PASSKEY remains a
|
|
144
|
+
// distinct AMR for the synced multi-device case (operators using the
|
|
145
|
+
// FIDO-published "passkey" AMR can disambiguate from one-device hwk).
|
|
139
146
|
var AMR = Object.freeze({
|
|
140
147
|
PASSWORD: "pwd",
|
|
141
148
|
PIN: "pin",
|
|
142
149
|
TOTP: "otp",
|
|
143
150
|
SMS: "sms",
|
|
144
|
-
WEBAUTHN: "
|
|
151
|
+
WEBAUTHN: "hwk",
|
|
145
152
|
PASSKEY: "passkey",
|
|
146
153
|
HARDWARE: "hwk",
|
|
147
154
|
MTLS: "mtls",
|
package/lib/auth/ciba.js
CHANGED
|
@@ -561,7 +561,12 @@ function create(opts) {
|
|
|
561
561
|
throw new AuthError("auth-ciba/bad-notification-req",
|
|
562
562
|
"ciba.parseNotification: req with headers required");
|
|
563
563
|
}
|
|
564
|
-
|
|
564
|
+
// Node's http module lowercases all incoming header names per RFC
|
|
565
|
+
// 7230 §3.2; the capital-A fallback is structurally dead code and a
|
|
566
|
+
// future-integration footgun (a middleware that bypasses
|
|
567
|
+
// node:http's normalization could re-introduce the unreachable
|
|
568
|
+
// branch). Read lowercase only.
|
|
569
|
+
var authzHeader = req.headers["authorization"];
|
|
565
570
|
if (!authzHeader || authzHeader.indexOf("Bearer ") !== 0) {
|
|
566
571
|
throw new AuthError("auth-ciba/missing-bearer",
|
|
567
572
|
"ciba.parseNotification: Authorization: Bearer header missing");
|
package/lib/auth/dpop.js
CHANGED
|
@@ -462,13 +462,18 @@ async function verify(proof, opts) {
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
// nonce — when caller supplies expected nonce, payload MUST match
|
|
465
|
+
// nonce — when caller supplies expected nonce, payload MUST match.
|
|
466
|
+
// Constant-time compare (audit 2026-05-15): the nonce is a server-
|
|
467
|
+
// issued secret-shaped value matched against attacker-controlled
|
|
468
|
+
// payload bytes. RFC 9449 §8 mandates the value be unpredictable;
|
|
469
|
+
// a leaking compare reveals prefix bytes over many attempts. ath
|
|
470
|
+
// already used timingSafeEqual; nonce now matches.
|
|
466
471
|
if (typeof opts.nonce === "string" && opts.nonce.length > 0) {
|
|
467
472
|
if (typeof payload.nonce !== "string" || payload.nonce.length === 0) {
|
|
468
473
|
throw new AuthError("auth-dpop/missing-nonce",
|
|
469
474
|
"nonce expected but proof has no nonce claim");
|
|
470
475
|
}
|
|
471
|
-
if (payload.nonce
|
|
476
|
+
if (!bCrypto.timingSafeEqual(payload.nonce, opts.nonce)) {
|
|
472
477
|
throw new AuthError("auth-dpop/nonce-mismatch",
|
|
473
478
|
"payload.nonce does not match expected");
|
|
474
479
|
}
|
package/lib/auth/fal.js
CHANGED
|
@@ -121,12 +121,13 @@ function meets(actualBand, requiredBand) {
|
|
|
121
121
|
* nonce / jti binding before back-channel can claim FAL2.
|
|
122
122
|
*
|
|
123
123
|
* @opts
|
|
124
|
-
* channel:
|
|
125
|
-
* encrypted:
|
|
126
|
-
* replayProtected:
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
124
|
+
* channel: "front" | "back", // REQUIRED
|
|
125
|
+
* encrypted: boolean, // assertion encrypted to RP
|
|
126
|
+
* replayProtected: boolean, // nonce / jti / iat binding present
|
|
127
|
+
* backChannelAuthenticated: boolean, // back-channel transport-auth'd (mTLS / signed) — required for FAL2 over plain back-channel
|
|
128
|
+
* hokBinding: "mtls" | "dpop" | "saml-hok" | null,
|
|
129
|
+
* // proof-of-possession binding present
|
|
130
|
+
* bearerOnly: boolean, // alias for hokBinding === null
|
|
130
131
|
*
|
|
131
132
|
* @example
|
|
132
133
|
* var fal = b.auth.fal.fromAssertion({
|
|
@@ -166,9 +167,17 @@ function fromAssertion(opts) {
|
|
|
166
167
|
return FAL3;
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
//
|
|
170
|
+
// AUTH-19 — FAL2 per NIST SP 800-63C-4 §5.2 requires "injection
|
|
171
|
+
// protection" on the back-channel: either the back-channel itself is
|
|
172
|
+
// encrypted-and-authenticated (mTLS / signed transport) OR the
|
|
173
|
+
// assertion is encrypted to the RP. A plain HTTP back-channel with
|
|
174
|
+
// only nonce/jti replay protection is FAL1 — `replayProtected` alone
|
|
175
|
+
// doesn't satisfy the §5.2 MUST. Operators using a plain
|
|
176
|
+
// back-channel set `backChannelAuthenticated: true` when their
|
|
177
|
+
// transport carries server-to-server mTLS / signed-JWT auth.
|
|
170
178
|
var replaySafe = opts.replayProtected === true;
|
|
171
|
-
|
|
179
|
+
var injectionProtected = opts.encrypted === true || opts.backChannelAuthenticated === true;
|
|
180
|
+
if (replaySafe && injectionProtected && (opts.channel === "back" || opts.encrypted === true)) {
|
|
172
181
|
return FAL2;
|
|
173
182
|
}
|
|
174
183
|
|