@blamejs/core 0.14.26 → 0.15.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/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +62 -4
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +249 -123
- package/lib/auth/openid-federation.js +108 -47
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +169 -4
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +474 -92
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/error-page.js +14 -1
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +14 -1
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +649 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +37 -9
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-server-jmap.js +117 -12
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +57 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/outbox.js +136 -82
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/retention.js +82 -39
- package/lib/router.js +212 -5
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +177 -34
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +35 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/api-key.js
CHANGED
|
@@ -43,6 +43,7 @@ var cluster = require("./cluster");
|
|
|
43
43
|
var cryptoField = require("./crypto-field");
|
|
44
44
|
var requestHelpers = require("./request-helpers");
|
|
45
45
|
var validateOpts = require("./validate-opts");
|
|
46
|
+
var sql = require("./sql");
|
|
46
47
|
var C = require("./constants");
|
|
47
48
|
var numericChecks = require("./numeric-checks");
|
|
48
49
|
var { ApiKeyError } = require("./framework-error");
|
|
@@ -53,16 +54,28 @@ function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
|
53
54
|
|
|
54
55
|
var _err = ApiKeyError.factory;
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// the
|
|
60
|
-
//
|
|
61
|
-
var
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
57
|
+
// Logical framework table name. Self-mapped in LOCAL_TO_EXTERNAL, so it is
|
|
58
|
+
// passed BARE to b.sql: clusterStorage.execute rewrites it to the configured
|
|
59
|
+
// prefix and placeholderizes the `?` markers, so one query text runs against
|
|
60
|
+
// the local SQLite single-node backend and the operator's external DB in
|
|
61
|
+
// cluster mode.
|
|
62
|
+
var TABLE = "_blamejs_api_keys"; // allow:hand-rolled-sql — bare logical name, passed to b.sql for clusterStorage rewrite
|
|
63
|
+
|
|
64
|
+
// b.sql opts for every _blamejs_api_keys statement: thread the ACTIVE backend
|
|
65
|
+
// dialect (clusterStorage.dialect() — "sqlite" single-node, "postgres" |
|
|
66
|
+
// "mysql" in cluster mode) so the emitted identifier quoting + dialect idioms
|
|
67
|
+
// match the backend the SQL dispatches to. Defaulting to "sqlite" works on
|
|
68
|
+
// Postgres only by accident (both double-quote identifiers) and emits the
|
|
69
|
+
// wrong quoting on MySQL, so this is the canonical resolver threaded into
|
|
70
|
+
// b.sql. clusterStorage.execute still rewrites the bare table name +
|
|
71
|
+
// translates `?` placeholders at dispatch; this controls only the builder-
|
|
72
|
+
// side quoting + idiom selection. The table name stays BARE (no quoteName)
|
|
73
|
+
// so clusterStorage's prefix rewrite still fires.
|
|
74
|
+
function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
|
|
75
|
+
|
|
76
|
+
// Column order used for INSERT — kept as a constant so the column list and
|
|
77
|
+
// the row object stay in sync. Must match _blamejs_api_keys' schema in
|
|
78
|
+
// db.js (single-node) and framework-schema.js (cluster mode).
|
|
66
79
|
var COLS = [
|
|
67
80
|
"id", "namespace", "ownerId", "ownerIdHash", "secretHash",
|
|
68
81
|
"secondarySecretHash", "secondaryExpiresAt",
|
|
@@ -305,10 +318,11 @@ function create(opts) {
|
|
|
305
318
|
);
|
|
306
319
|
}
|
|
307
320
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
321
|
+
// Fresh SELECT builder over the full column set. BARE logical table name
|
|
322
|
+
// (_blamejs_api_keys) — clusterStorage rewrites it to the configured
|
|
323
|
+
// prefix and placeholderizes. Callers chain the WHERE family + .toSql().
|
|
324
|
+
function _selectBuilder() {
|
|
325
|
+
return sql.select(TABLE, _sqlOpts()).columns(COLS); // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
312
326
|
}
|
|
313
327
|
|
|
314
328
|
function _scrubRecord(row) {
|
|
@@ -369,14 +383,13 @@ function create(opts) {
|
|
|
369
383
|
lastUsedAt: null,
|
|
370
384
|
prefix: prefix,
|
|
371
385
|
});
|
|
372
|
-
var
|
|
373
|
-
var
|
|
374
|
-
var
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
386
|
+
var insertRow = {};
|
|
387
|
+
for (var ci = 0; ci < COLS.length; ci++) insertRow[COLS[ci]] = sealed[COLS[ci]];
|
|
388
|
+
var insertBuilt = sql.insert(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
389
|
+
.columns(COLS)
|
|
390
|
+
.values(insertRow)
|
|
391
|
+
.toSql();
|
|
392
|
+
await clusterStorage.execute(insertBuilt.sql, insertBuilt.params);
|
|
380
393
|
|
|
381
394
|
_emit("apikey.issue", {
|
|
382
395
|
actor: _actor(issueOpts, issueOpts.ownerId),
|
|
@@ -402,10 +415,8 @@ function create(opts) {
|
|
|
402
415
|
if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
|
|
403
416
|
|
|
404
417
|
var compositeId = _composedId(namespace, parsed.idHex);
|
|
405
|
-
var
|
|
406
|
-
|
|
407
|
-
[compositeId]
|
|
408
|
-
);
|
|
418
|
+
var verifyBuilt = _selectBuilder().where("id", compositeId).toSql();
|
|
419
|
+
var row = await clusterStorage.executeOne(verifyBuilt.sql, verifyBuilt.params);
|
|
409
420
|
if (!row) {
|
|
410
421
|
if (auditFailures) {
|
|
411
422
|
_emit("apikey.verify", {
|
|
@@ -474,10 +485,11 @@ function create(opts) {
|
|
|
474
485
|
|
|
475
486
|
if (trackLastUsedAt && cluster.isLeader()) {
|
|
476
487
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
488
|
+
var touchBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
489
|
+
.set({ lastUsedAt: nowMs })
|
|
490
|
+
.where("id", compositeId)
|
|
491
|
+
.toSql();
|
|
492
|
+
await clusterStorage.execute(touchBuilt.sql, touchBuilt.params);
|
|
481
493
|
} catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
|
|
482
494
|
}
|
|
483
495
|
|
|
@@ -501,10 +513,12 @@ function create(opts) {
|
|
|
501
513
|
if (typeof idHex !== "string" || idHex.length === 0) return false;
|
|
502
514
|
var compositeId = _composedId(namespace, idHex);
|
|
503
515
|
var nowMs = clock();
|
|
504
|
-
var
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
516
|
+
var revokeBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
517
|
+
.set({ revokedAt: nowMs })
|
|
518
|
+
.where("id", compositeId)
|
|
519
|
+
.whereNull("revokedAt")
|
|
520
|
+
.toSql();
|
|
521
|
+
var result = await clusterStorage.execute(revokeBuilt.sql, revokeBuilt.params);
|
|
508
522
|
var changed = (result.rowCount || 0) > 0;
|
|
509
523
|
if (changed) {
|
|
510
524
|
_emit("apikey.revoke", {
|
|
@@ -542,10 +556,8 @@ function create(opts) {
|
|
|
542
556
|
}
|
|
543
557
|
|
|
544
558
|
var compositeId = _composedId(namespace, idHex);
|
|
545
|
-
var
|
|
546
|
-
|
|
547
|
-
[compositeId]
|
|
548
|
-
);
|
|
559
|
+
var rotateSelBuilt = _selectBuilder().where("id", compositeId).toSql();
|
|
560
|
+
var existing = await clusterStorage.executeOne(rotateSelBuilt.sql, rotateSelBuilt.params);
|
|
549
561
|
if (!existing) {
|
|
550
562
|
throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
|
|
551
563
|
}
|
|
@@ -558,19 +570,27 @@ function create(opts) {
|
|
|
558
570
|
|
|
559
571
|
if (gracePeriodMs > 0) {
|
|
560
572
|
// Move current hash → secondary slot, install new hash as primary.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
573
|
+
var graceBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
574
|
+
.set({
|
|
575
|
+
secretHash: newHash,
|
|
576
|
+
secondarySecretHash: existing.secretHash,
|
|
577
|
+
secondaryExpiresAt: nowMs + gracePeriodMs,
|
|
578
|
+
})
|
|
579
|
+
.where("id", compositeId)
|
|
580
|
+
.toSql();
|
|
581
|
+
await clusterStorage.execute(graceBuilt.sql, graceBuilt.params);
|
|
566
582
|
} else {
|
|
567
583
|
// Hard cutover — old secret stops working immediately. Clears
|
|
568
|
-
// any prior secondary slot too.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
584
|
+
// any prior secondary slot too (bound NULL via the set map).
|
|
585
|
+
var cutoverBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
586
|
+
.set({
|
|
587
|
+
secretHash: newHash,
|
|
588
|
+
secondarySecretHash: null,
|
|
589
|
+
secondaryExpiresAt: null,
|
|
590
|
+
})
|
|
591
|
+
.where("id", compositeId)
|
|
592
|
+
.toSql();
|
|
593
|
+
await clusterStorage.execute(cutoverBuilt.sql, cutoverBuilt.params);
|
|
574
594
|
}
|
|
575
595
|
|
|
576
596
|
_emit("apikey.rotate", {
|
|
@@ -598,17 +618,23 @@ function create(opts) {
|
|
|
598
618
|
var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
|
|
599
619
|
if (!lookup) {
|
|
600
620
|
throw _err("MISCONFIGURED",
|
|
601
|
-
"
|
|
621
|
+
TABLE + " schema is missing the ownerIdHash derived hash — framework misconfigured");
|
|
602
622
|
}
|
|
603
|
-
var
|
|
604
|
-
|
|
605
|
-
|
|
623
|
+
var listQb = _selectBuilder()
|
|
624
|
+
.where("namespace", namespace)
|
|
625
|
+
.where("ownerIdHash", lookup.value);
|
|
626
|
+
if (!includeRevoked) listQb.whereNull("revokedAt");
|
|
606
627
|
if (!includeExpired) {
|
|
607
|
-
|
|
608
|
-
|
|
628
|
+
var nowForExpiry = clock();
|
|
629
|
+
// (expiresAt IS NULL OR expiresAt >= now) — an OR group ANDed onto
|
|
630
|
+
// the chain so the optional clause keeps its own precedence.
|
|
631
|
+
listQb.whereGroup(function (g) {
|
|
632
|
+
g.whereNull("expiresAt").orWhereOp("expiresAt", ">=", nowForExpiry);
|
|
633
|
+
});
|
|
609
634
|
}
|
|
610
|
-
|
|
611
|
-
var
|
|
635
|
+
listQb.orderBy("createdAt", "desc");
|
|
636
|
+
var listBuilt = listQb.toSql();
|
|
637
|
+
var rows = await clusterStorage.execute(listBuilt.sql, listBuilt.params);
|
|
612
638
|
var list = (rows.rows || []).map(_scrubRecord);
|
|
613
639
|
_emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
|
|
614
640
|
// Read-access audit: "who listed whose keys at time T" — gated by
|
|
@@ -635,10 +661,8 @@ function create(opts) {
|
|
|
635
661
|
async function getById(idHex, getOpts) {
|
|
636
662
|
if (typeof idHex !== "string" || idHex.length === 0) return null;
|
|
637
663
|
var compositeId = _composedId(namespace, idHex);
|
|
638
|
-
var
|
|
639
|
-
|
|
640
|
-
[compositeId]
|
|
641
|
-
);
|
|
664
|
+
var getBuilt = _selectBuilder().where("id", compositeId).toSql();
|
|
665
|
+
var row = await clusterStorage.executeOne(getBuilt.sql, getBuilt.params);
|
|
642
666
|
var record = _scrubRecord(row);
|
|
643
667
|
_emitEvent("apikey.get", 1,
|
|
644
668
|
{ namespace: namespace, found: record !== null });
|
|
@@ -659,13 +683,24 @@ function create(opts) {
|
|
|
659
683
|
// Compliance auditors expect "key X was purged at time T" — a count-
|
|
660
684
|
// only audit is too coarse for forensic reconstruction. Cost is one
|
|
661
685
|
// extra round-trip per purge call which runs on a schedule (not
|
|
662
|
-
// request-rate), so the cost is irrelevant.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
686
|
+
// request-rate), so the cost is irrelevant. The purge predicate
|
|
687
|
+
// (namespace match + an OR of the two "past-threshold" age groups) is
|
|
688
|
+
// applied identically to the SELECT and the DELETE via _applyPurgeWhere.
|
|
689
|
+
function _applyPurgeWhere(qb) {
|
|
690
|
+
return qb
|
|
691
|
+
.where("namespace", namespace)
|
|
692
|
+
.whereGroup(function (g) {
|
|
693
|
+
g.whereGroup(function (a) {
|
|
694
|
+
a.whereNotNull("revokedAt").where("revokedAt", "<", threshold);
|
|
695
|
+
}).orWhereGroup(function (b2) {
|
|
696
|
+
b2.whereNotNull("expiresAt").where("expiresAt", "<", threshold);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
var purgeSelBuilt = _applyPurgeWhere(
|
|
701
|
+
sql.select(TABLE, _sqlOpts()).columns(["id"]) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
702
|
+
).toSql();
|
|
703
|
+
var idRows = await clusterStorage.execute(purgeSelBuilt.sql, purgeSelBuilt.params);
|
|
669
704
|
var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
|
|
670
705
|
|
|
671
706
|
if (purgedCompositeIds.length === 0) {
|
|
@@ -673,12 +708,10 @@ function create(opts) {
|
|
|
673
708
|
return 0;
|
|
674
709
|
}
|
|
675
710
|
|
|
676
|
-
var
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
[namespace, threshold, threshold]
|
|
681
|
-
);
|
|
711
|
+
var purgeDelBuilt = _applyPurgeWhere(
|
|
712
|
+
sql.delete(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
|
|
713
|
+
).toSql();
|
|
714
|
+
var result = await clusterStorage.execute(purgeDelBuilt.sql, purgeDelBuilt.params);
|
|
682
715
|
var count = result.rowCount || purgedCompositeIds.length;
|
|
683
716
|
|
|
684
717
|
_emit("apikey.purge", {
|
package/lib/atomic-file.js
CHANGED
|
@@ -167,6 +167,30 @@ function fsyncDir(dirPath) {
|
|
|
167
167
|
function _fsync(fd) { return fsync(fd); }
|
|
168
168
|
function _fsyncDir(dirPath) { return fsyncDir(dirPath); }
|
|
169
169
|
|
|
170
|
+
// Exclusive, no-follow create of the sibling temp file that every
|
|
171
|
+
// atomic write stages bytes into before the rename. CWE-377
|
|
172
|
+
// (insecure temporary file) / CWE-59 (symlink-following): the legacy
|
|
173
|
+
// "w" flag is O_WRONLY|O_CREAT|O_TRUNC — it happily opens (and
|
|
174
|
+
// truncates, or writes through) a file an attacker pre-created at the
|
|
175
|
+
// temp path, including a symlink pointing at a victim file the process
|
|
176
|
+
// can write but the attacker can't. O_EXCL makes the open fail with
|
|
177
|
+
// EEXIST if anything already exists at tmpPath, so a planted file /
|
|
178
|
+
// symlink / FIFO is refused instead of followed; O_NOFOLLOW rejects a
|
|
179
|
+
// symlink in the final path component on platforms that define it
|
|
180
|
+
// (Windows leaves it undefined, hence the `|| 0`). The temp name
|
|
181
|
+
// already carries a CSPRNG token (generateToken), so EEXIST is a
|
|
182
|
+
// hostile-collision signal, not a benign retry. The fd is returned for
|
|
183
|
+
// the caller to write + fsync; mode is applied at create time so the
|
|
184
|
+
// bytes are never world-readable even briefly.
|
|
185
|
+
function _openExclTemp(tmpPath, fileMode) {
|
|
186
|
+
return nodeFs.openSync(
|
|
187
|
+
tmpPath,
|
|
188
|
+
nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
|
|
189
|
+
nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0),
|
|
190
|
+
fileMode
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
170
194
|
/**
|
|
171
195
|
* @primitive b.atomicFile.ensureDir
|
|
172
196
|
* @signature b.atomicFile.ensureDir(dirPath, mode)
|
|
@@ -392,6 +416,34 @@ function conflictPath(originalPath, opts) {
|
|
|
392
416
|
* );
|
|
393
417
|
* // → { bytesWritten: 7, hash: "<sha3-512 hex>" }
|
|
394
418
|
*/
|
|
419
|
+
// Synchronous bounded sleep (writeSync is a sync primitive, so no await).
|
|
420
|
+
// Uses Atomics.wait on a throwaway shared buffer; falls back to a short spin
|
|
421
|
+
// if SharedArrayBuffer is unavailable.
|
|
422
|
+
function _sleepSync(ms) {
|
|
423
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); return; }
|
|
424
|
+
catch (_e) { /* fall through to spin */ }
|
|
425
|
+
var end = Date.now() + ms;
|
|
426
|
+
while (Date.now() < end) { /* spin */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Atomic rename with a bounded retry on Windows-transient lock errors. On
|
|
430
|
+
// Windows a rename target is briefly held by AV / the search indexer / a
|
|
431
|
+
// file-sync client (Dropbox, OneDrive), surfacing as EPERM / EACCES / EBUSY
|
|
432
|
+
// even though the freshly-written temp file is fine; the lock clears in a few
|
|
433
|
+
// ms. POSIX rename is atomic and never hits this, so the first attempt
|
|
434
|
+
// succeeds there. Surface the error if it is not transient or persists.
|
|
435
|
+
function _renameWithRetry(from, to) {
|
|
436
|
+
var delays = [0, 5, 15, 40, 100];
|
|
437
|
+
for (var i = 0; i < delays.length; i += 1) {
|
|
438
|
+
if (delays[i] > 0) _sleepSync(delays[i]);
|
|
439
|
+
try { nodeFs.renameSync(from, to); return; }
|
|
440
|
+
catch (e) {
|
|
441
|
+
var transient = e && (e.code === "EPERM" || e.code === "EACCES" || e.code === "EBUSY");
|
|
442
|
+
if (!transient || i === delays.length - 1) throw e;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
395
447
|
function writeSync(filepath, data, opts) {
|
|
396
448
|
opts = Object.assign({}, DEFAULTS, opts || {});
|
|
397
449
|
var buf = safeBuffer.toBuffer(data, {
|
|
@@ -406,7 +458,7 @@ function writeSync(filepath, data, opts) {
|
|
|
406
458
|
var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
|
|
407
459
|
var renamed = false;
|
|
408
460
|
try {
|
|
409
|
-
var fd =
|
|
461
|
+
var fd = _openExclTemp(tmpPath, opts.fileMode);
|
|
410
462
|
try {
|
|
411
463
|
var pos = 0;
|
|
412
464
|
while (pos < buf.length) {
|
|
@@ -416,7 +468,7 @@ function writeSync(filepath, data, opts) {
|
|
|
416
468
|
} finally {
|
|
417
469
|
try { nodeFs.closeSync(fd); } catch (_e) { /* already closed? */ }
|
|
418
470
|
}
|
|
419
|
-
|
|
471
|
+
_renameWithRetry(tmpPath, filepath);
|
|
420
472
|
renamed = true;
|
|
421
473
|
_fsyncDir(dir);
|
|
422
474
|
} finally {
|
|
@@ -530,7 +582,7 @@ async function write(filepath, data, opts) {
|
|
|
530
582
|
var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
|
|
531
583
|
var renamed = false;
|
|
532
584
|
try {
|
|
533
|
-
var fd =
|
|
585
|
+
var fd = _openExclTemp(tmpPath, opts.fileMode);
|
|
534
586
|
try {
|
|
535
587
|
var pos = 0;
|
|
536
588
|
while (pos < buf.length) {
|
|
@@ -659,9 +711,15 @@ function _readSyncCore(filepath, opts) {
|
|
|
659
711
|
// can't swap the file between size-check and read because the fd
|
|
660
712
|
// is anchored to the original inode. ENOENT surfaces from open()
|
|
661
713
|
// rather than the previous existsSync() pre-check.
|
|
714
|
+
//
|
|
715
|
+
// The third argument pins an owner-only mode (0o600). The flag is
|
|
716
|
+
// read-only ("r" → O_RDONLY, no O_CREAT) so the mode is inert on
|
|
717
|
+
// disk, but specifying it keeps this open out of the insecure-temp-
|
|
718
|
+
// file class (CWE-377): the read can never create a world/group-
|
|
719
|
+
// accessible file even when `filepath` is rooted under a temp dir.
|
|
662
720
|
var fd;
|
|
663
721
|
try {
|
|
664
|
-
fd = nodeFs.openSync(filepath, "r");
|
|
722
|
+
fd = nodeFs.openSync(filepath, "r", 0o600);
|
|
665
723
|
} catch (openErr) {
|
|
666
724
|
if (openErr && openErr.code === "ENOENT") {
|
|
667
725
|
var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
|
package/lib/audit-chain.js
CHANGED
|
@@ -35,8 +35,23 @@
|
|
|
35
35
|
*/
|
|
36
36
|
var canonicalJson = require("./canonical-json");
|
|
37
37
|
var C = require("./constants");
|
|
38
|
+
var clusterStorage = require("./cluster-storage");
|
|
39
|
+
var frameworkSchema = require("./framework-schema");
|
|
40
|
+
var sql = require("./sql");
|
|
38
41
|
var { sha3Hash } = require("./crypto");
|
|
39
42
|
|
|
43
|
+
// b.sql opts for the chain read SQL these primitives compose. The reader
|
|
44
|
+
// (queryAllAsync / queryOneAsync, normally clusterStorage.execute*) rewrites
|
|
45
|
+
// the bare framework table name + translates `?` placeholders at dispatch,
|
|
46
|
+
// but the IDENTIFIER QUOTING + ORDER-BY column reference are baked into the
|
|
47
|
+
// b.sql output at build time — so they must carry the ACTIVE backend dialect
|
|
48
|
+
// (clusterStorage.dialect() — "sqlite" single-node, "postgres" | "mysql" in
|
|
49
|
+
// cluster mode). Defaulting to "sqlite" double-quotes `monotonicCounter`,
|
|
50
|
+
// which MySQL reads as a STRING LITERAL: `ORDER BY '<constant>'` imposes no
|
|
51
|
+
// ordering, so verifyChain walks the rows out of order and falsely reports a
|
|
52
|
+
// chain break. Backtick-quoting on MySQL makes it an identifier again.
|
|
53
|
+
function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
|
|
54
|
+
|
|
40
55
|
// SHA3-512 outputs 64 bytes; routed through C.BYTES so the file's byte
|
|
41
56
|
// arithmetic has one source of truth. Hex-encoded width is twice the
|
|
42
57
|
// byte count.
|
|
@@ -140,11 +155,20 @@ function computeRowHash(prevHash, rowFields, nonce) {
|
|
|
140
155
|
* // → { prevHash: "<128-char hex>", counter: 4217 }
|
|
141
156
|
*/
|
|
142
157
|
async function getChainTip(queryOneAsync, tableName) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
158
|
+
// Emit a BARE logical table name — the operator-supplied reader routes
|
|
159
|
+
// through clusterStorage, which rewrites bare framework names to the
|
|
160
|
+
// configured-prefix form and placeholderizes. b.sql quotes the camelCase
|
|
161
|
+
// columns + runs the output validator.
|
|
162
|
+
var built = sql.select(tableName, _sqlOpts())
|
|
163
|
+
.columns(["rowHash", "monotonicCounter"])
|
|
164
|
+
.orderBy("monotonicCounter", "desc")
|
|
165
|
+
.limit(1)
|
|
166
|
+
.toSql();
|
|
167
|
+
var row = await queryOneAsync(built.sql, built.params);
|
|
147
168
|
if (!row) return { prevHash: ZERO_HASH, counter: 0 };
|
|
169
|
+
// Normalize driver shape (Postgres returns BIGINT monotonicCounter as a
|
|
170
|
+
// string) so callers get a numeric counter on every backend.
|
|
171
|
+
frameworkSchema.coerceRow(row);
|
|
148
172
|
return { prevHash: row.rowHash, counter: row.monotonicCounter };
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -186,10 +210,15 @@ async function verifyChain(queryAllAsync, tableName, opts) {
|
|
|
186
210
|
if (tableName === "audit_log") {
|
|
187
211
|
var anchor;
|
|
188
212
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
213
|
+
// External-only table whose LOGICAL name IS the `_blamejs_`-prefixed
|
|
214
|
+
// name (self-mapped in LOCAL_TO_EXTERNAL), passed bare so the reader's
|
|
215
|
+
// clusterStorage rewrites it; the 'audit' scope binds as a ? param.
|
|
216
|
+
// allow:hand-rolled-sql — bare logical key.
|
|
217
|
+
var anchorBuilt = sql.select("_blamejs_audit_purge_anchor", _sqlOpts()) // allow:hand-rolled-sql
|
|
218
|
+
.columns(["lastPurgedCounter", "lastPurgedRowHash"])
|
|
219
|
+
.where("scope", "audit")
|
|
220
|
+
.toSql();
|
|
221
|
+
anchor = await queryAllAsync(anchorBuilt.sql, anchorBuilt.params);
|
|
193
222
|
} catch (_e) {
|
|
194
223
|
// Anchor table may not exist on a deployment that has never been
|
|
195
224
|
// through a purge. Treat as no anchor.
|
|
@@ -201,9 +230,16 @@ async function verifyChain(queryAllAsync, tableName, opts) {
|
|
|
201
230
|
}
|
|
202
231
|
}
|
|
203
232
|
|
|
204
|
-
var
|
|
205
|
-
|
|
206
|
-
|
|
233
|
+
var rowsBuilt = sql.select(tableName, _sqlOpts())
|
|
234
|
+
.orderBy("monotonicCounter", "asc")
|
|
235
|
+
.toSql();
|
|
236
|
+
var rows = await queryAllAsync(rowsBuilt.sql, rowsBuilt.params);
|
|
237
|
+
// Normalize driver shape before hashing: node-postgres returns BIGINT
|
|
238
|
+
// columns (recordedAt / monotonicCounter) as strings, which would hash
|
|
239
|
+
// differently from the numbers the chain-writer signed — the chain only
|
|
240
|
+
// verified on SQLite without this. coerceRow makes the recompute
|
|
241
|
+
// type-stable across backends (no-op on already-numeric SQLite rows).
|
|
242
|
+
rows = frameworkSchema.coerceRows(rows);
|
|
207
243
|
if (skipBeforeCounter > 0) {
|
|
208
244
|
rows = rows.filter(function (r) {
|
|
209
245
|
return Number(r.monotonicCounter) > skipBeforeCounter;
|
package/lib/audit-sign.js
CHANGED
|
@@ -63,6 +63,7 @@ var nodePath = require("node:path");
|
|
|
63
63
|
var nodeCrypto = require("node:crypto");
|
|
64
64
|
var atomicFile = require("./atomic-file");
|
|
65
65
|
var { sha3Hash } = require("./crypto");
|
|
66
|
+
var frameworkFiles = require("./framework-files");
|
|
66
67
|
var { defineClass } = require("./framework-error");
|
|
67
68
|
var { boot } = require("./log");
|
|
68
69
|
var safeBuffer = require("./safe-buffer");
|
|
@@ -118,11 +119,73 @@ var log = boot("audit-sign");
|
|
|
118
119
|
function resolvePaths(dataDir) {
|
|
119
120
|
return {
|
|
120
121
|
dataDir: dataDir,
|
|
121
|
-
plaintext: nodePath.join(dataDir,
|
|
122
|
-
sealed: nodePath.join(dataDir, "
|
|
122
|
+
plaintext: nodePath.join(dataDir, frameworkFiles.fileName("auditSignKey")),
|
|
123
|
+
sealed: nodePath.join(dataDir, frameworkFiles.fileName("auditSignKey") + ".sealed"),
|
|
124
|
+
// Unsealed registry of rotated-out PUBLIC keys (public keys are not
|
|
125
|
+
// secret). It lets verify-time code (b.audit.verifyCheckpoints) resolve
|
|
126
|
+
// the public key for a checkpoint signed under a now-rotated key WITHOUT
|
|
127
|
+
// the old passphrase, so a rotation does not strand historical checkpoints.
|
|
128
|
+
publicHistory: nodePath.join(dataDir, "audit-sign.pubkeys.json"),
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Append a rotated-out public key to the unsealed public-key history. Public
|
|
133
|
+
// keys carry no secret, so storing them in the clear is safe and is what
|
|
134
|
+
// makes passphrase-free historical verification possible. De-duplicated by
|
|
135
|
+
// fingerprint; best-effort (a write failure must not abort the rotation, the
|
|
136
|
+
// sealed private-key history is the durable archive of record).
|
|
137
|
+
function _appendPublicHistory(entry) {
|
|
138
|
+
if (!paths || !paths.publicHistory) return;
|
|
139
|
+
var list = [];
|
|
140
|
+
try {
|
|
141
|
+
if (nodeFs.existsSync(paths.publicHistory)) {
|
|
142
|
+
var parsed = safeJson.parse(atomicFile.readSync(paths.publicHistory));
|
|
143
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
144
|
+
}
|
|
145
|
+
} catch (_e) { list = []; } // corrupt registry — rebuild from this entry
|
|
146
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
147
|
+
if (list[i] && list[i].fingerprint === entry.fingerprint) return; // already recorded
|
|
148
|
+
}
|
|
149
|
+
list.push(entry);
|
|
150
|
+
try {
|
|
151
|
+
atomicFile.writeSync(paths.publicHistory, JSON.stringify(list, null, 2), { fileMode: 0o600 });
|
|
152
|
+
} catch (_e) { /* best-effort */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @primitive b.auditSign.getPublicKeyByFingerprint
|
|
157
|
+
* @signature b.auditSign.getPublicKeyByFingerprint(fingerprint)
|
|
158
|
+
* @since 0.14.29
|
|
159
|
+
* @status stable
|
|
160
|
+
* @related b.auditSign.getPublicKey, b.auditSign.verify, b.auditSign.rotateSigningKey
|
|
161
|
+
*
|
|
162
|
+
* Resolve the audit-signing public key (SPKI PEM) for a fingerprint: the
|
|
163
|
+
* live key, or a rotated-out key recorded in the unsealed public-key history
|
|
164
|
+
* that `rotateSigningKey` maintains. Returns `null` when no key matches. Only
|
|
165
|
+
* public material is consulted, so no passphrase is needed - this is what
|
|
166
|
+
* lets `b.audit.verifyCheckpoints` verify a checkpoint signed under a
|
|
167
|
+
* now-rotated key without stranding history.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* var pem = b.auditSign.getPublicKeyByFingerprint(checkpoint.publicKeyFingerprint);
|
|
171
|
+
* // -> "-----BEGIN PUBLIC KEY-----\n..." (or null if the key is unknown)
|
|
172
|
+
*/
|
|
173
|
+
function getPublicKeyByFingerprint(fp) {
|
|
174
|
+
_requireInit();
|
|
175
|
+
if (fp === keys.fingerprint) return keys.publicKey;
|
|
176
|
+
if (!paths || !paths.publicHistory || !nodeFs.existsSync(paths.publicHistory)) return null;
|
|
177
|
+
var list;
|
|
178
|
+
try { list = safeJson.parse(atomicFile.readSync(paths.publicHistory)); }
|
|
179
|
+
catch (_e) { return null; }
|
|
180
|
+
if (!Array.isArray(list)) return null;
|
|
181
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
182
|
+
if (list[i] && list[i].fingerprint === fp && typeof list[i].publicKey === "string") {
|
|
183
|
+
return list[i].publicKey;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
126
189
|
function _computeFingerprint(publicKeyPem) {
|
|
127
190
|
return sha3Hash(publicKeyPem);
|
|
128
191
|
}
|
|
@@ -677,6 +740,17 @@ async function rotateSigningKey(rotOpts) {
|
|
|
677
740
|
catch (_e) { /* history copy is best-effort */ }
|
|
678
741
|
}
|
|
679
742
|
|
|
743
|
+
// Record the rotated-out PUBLIC key (unsealed) so b.audit.verifyCheckpoints
|
|
744
|
+
// can verify a checkpoint signed under it after rotation without the old
|
|
745
|
+
// passphrase. Without this the public key only lives inside the sealed
|
|
746
|
+
// history archive and verification of pre-rotation checkpoints is stranded.
|
|
747
|
+
_appendPublicHistory({
|
|
748
|
+
fingerprint: prevFingerprint,
|
|
749
|
+
publicKey: prevPublicKey,
|
|
750
|
+
algorithm: prevAlgorithm,
|
|
751
|
+
rotatedAt: new Date().toISOString(),
|
|
752
|
+
});
|
|
753
|
+
|
|
680
754
|
// Persist the new keypair through the same path as boot — sealed
|
|
681
755
|
// mode re-wraps with the operator's passphrase; plaintext mode
|
|
682
756
|
// writes JSON. We don't accept a passphrase override here; the
|
|
@@ -740,6 +814,7 @@ module.exports = {
|
|
|
740
814
|
reSignAll: reSignAll,
|
|
741
815
|
getPublicKey: getPublicKey,
|
|
742
816
|
getPublicKeyFingerprint: getPublicKeyFingerprint,
|
|
817
|
+
getPublicKeyByFingerprint: getPublicKeyByFingerprint,
|
|
743
818
|
getMode: getMode,
|
|
744
819
|
getAlgorithm: getAlgorithm,
|
|
745
820
|
DEFAULT_SIGNING_ALG: DEFAULT_SIGNING_ALG,
|