@blamejs/core 0.14.25 → 0.14.27

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/lib/dsr.js CHANGED
@@ -120,6 +120,16 @@ var DsrError = defineClass("DsrError", { alwaysPermanent: true });
120
120
 
121
121
  var audit = lazyRequire(function () { return require("./audit"); });
122
122
  var observability = lazyRequire(function () { return require("./observability"); });
123
+ // cryptoField + vault lazy-required: dbTicketStore seals subject PII + the
124
+ // full ticket payload at rest so a GDPR Art 17 erasure leaves no
125
+ // decryptable copy. Lazy so the module loads in vault-less / test-tooling
126
+ // contexts; the seal only engages when a vault is configured.
127
+ var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
128
+ var vault = lazyRequire(function () { return require("./vault"); });
129
+ // vault-aad supplies the AAD-cell re-seal primitive (resealRoot) the
130
+ // AAD_ROTATION descriptor below composes — the same one the in-tree
131
+ // rotation pipeline uses, so the AAD tuple has one source of truth.
132
+ var vaultAad = lazyRequire(function () { return require("./vault-aad"); });
123
133
 
124
134
  var VALID_REQUEST_TYPES = Object.freeze([
125
135
  "access", // GDPR Art. 15 / CCPA §1798.110
@@ -593,6 +603,43 @@ function create(opts) {
593
603
  { id: ticket.id, type: ticket.type, totalRows: totalRows,
594
604
  totalDeleted: deletedTotal, anyFailed: anyFailed });
595
605
  _emitMetric(anyFailed ? "partial" : "completed", 1, { type: ticket.type });
606
+
607
+ // Erasure-completion hook: an Art 17 erasure must not leave the
608
+ // subject's OWN prior DSR tickets (which carry their PII) sitting in
609
+ // the ticket store. When an erasure completes, purge the subject's
610
+ // other tickets. Skips the just-completed ticket so the receipt /
611
+ // audit trail for THIS erasure survives; requires the store to expose
612
+ // a `delete(id)` (the framework's memory + db stores do; an operator
613
+ // store that omits it keeps the prior behavior).
614
+ if (ticket.type === "erasure" && typeof store.delete === "function") {
615
+ try {
616
+ var priorTickets = await store.list({ subject: ticket.subject });
617
+ var purgedIds = [];
618
+ for (var pt = 0; pt < (priorTickets || []).length; pt++) {
619
+ var prior = priorTickets[pt];
620
+ if (!prior || prior.id === ticket.id) continue;
621
+ var removed = await store.delete(prior.id);
622
+ if (removed !== false) purgedIds.push(prior.id);
623
+ }
624
+ if (purgedIds.length > 0) {
625
+ _emitAudit("dsr.ticket.subject_tickets_purged", "ok", {
626
+ id: ticket.id,
627
+ type: ticket.type,
628
+ purgedCount: purgedIds.length,
629
+ purgedIds: purgedIds,
630
+ });
631
+ }
632
+ } catch (e) {
633
+ // Best-effort: a purge failure must not unwind the completed
634
+ // erasure. Surface it on the audit chain so operators can
635
+ // reconcile manually.
636
+ _emitAudit("dsr.ticket.subject_tickets_purge_failed", "fail", {
637
+ id: ticket.id,
638
+ type: ticket.type,
639
+ error: (e && e.message) || String(e),
640
+ });
641
+ }
642
+ }
596
643
  return ticket;
597
644
  }
598
645
 
@@ -878,6 +925,9 @@ function memoryTicketStore() {
878
925
  }
879
926
  byId.set(id, Object.assign({}, ticket));
880
927
  },
928
+ delete: async function (id) {
929
+ return byId.delete(id);
930
+ },
881
931
  _size: function () { return byId.size; },
882
932
  };
883
933
  }
@@ -918,21 +968,58 @@ function memoryTicketStore() {
918
968
  // the framework's SQLite engine. The store auto-provisions a single
919
969
  // table (default name `dsr_tickets`) with the canonical column set:
920
970
  //
921
- // id TEXT PRIMARY KEY
922
- // type TEXT NOT NULL
923
- // status TEXT NOT NULL
924
- // subject_id TEXT
925
- // subject_email TEXT
926
- // subject_phone TEXT
927
- // submitted_at INTEGER NOT NULL
928
- // deadline_at INTEGER NOT NULL
929
- // processed_at INTEGER
971
+ // id TEXT PRIMARY KEY
972
+ // type TEXT NOT NULL
973
+ // status TEXT NOT NULL
974
+ // subject_id TEXT -- sealed at rest when a vault is configured
975
+ // subject_email TEXT -- sealed at rest when a vault is configured
976
+ // subject_phone TEXT -- sealed at rest when a vault is configured
977
+ // subject_email_hash TEXT -- derived lookup hash (list-by-subject)
978
+ // subject_id_hash TEXT -- derived lookup hash (list-by-subject)
979
+ // submitted_at INTEGER NOT NULL
980
+ // deadline_at INTEGER NOT NULL
981
+ // processed_at INTEGER
930
982
  // verification_level TEXT
931
- // posture TEXT
932
- // payload TEXT -- full JSON for the ticket
983
+ // posture TEXT
984
+ // payload TEXT -- full JSON for the ticket, sealed at rest
933
985
  //
934
- // Indexed on subject_email and status for the common list-by-subject
986
+ // At-rest sealing: when a vault is configured, `payload`, `subject_id`,
987
+ // `subject_email`, and `subject_phone` are sealed via b.cryptoField before
988
+ // the row is written, AEAD-bound to the ticket `id` so a DB-write attacker
989
+ // cannot copy a sealed cell between rows. The list-by-subject query then
990
+ // matches on the derived `*_hash` columns (which mirror the plaintext
991
+ // search keys without exposing them) instead of the now-sealed plaintext
992
+ // columns. Without a vault the row is written as-is — the same vault-less
993
+ // fallback the agent-* / idempotency stores use.
994
+ //
995
+ // Indexed on subject_email_hash and status for the common list-by-subject
935
996
  // and list-by-status queries.
997
+
998
+ // Logical table name the field-crypto schema is keyed on. cryptoField
999
+ // keys its seal map by this name (distinct from the operator's physical
1000
+ // table name) so every dbTicketStore instance shares one sealed-column
1001
+ // declaration regardless of which physical table it writes to.
1002
+ var DSR_SEAL_TABLE = "dsr_tickets";
1003
+ // Register the sealed-column declaration with cryptoField when it isn't
1004
+ // already present. Probing getSchema rather than a module-level boolean is
1005
+ // reset-safe: b.db._resetForTest() / clearForTest() wipes the cryptoField
1006
+ // schema registry, and a boolean cache would then leave _ensureDsrSealTable
1007
+ // short-circuiting against an empty registry (seal becomes a no-op, the
1008
+ // derived hashes go null, list-by-subject silently misses). registerTable
1009
+ // is itself idempotent, so re-registering an identical shape is harmless.
1010
+ function _ensureDsrSealTable() {
1011
+ if (cryptoField().getSchema(DSR_SEAL_TABLE)) return;
1012
+ cryptoField().registerTable(DSR_SEAL_TABLE, {
1013
+ sealedFields: ["payload", "subject_email", "subject_phone", "subject_id"],
1014
+ derivedHashes: {
1015
+ subject_email_hash: { from: "subject_email" },
1016
+ subject_id_hash: { from: "subject_id" },
1017
+ },
1018
+ aad: true,
1019
+ rowIdField: "id",
1020
+ });
1021
+ }
1022
+
936
1023
  function dbTicketStore(opts) {
937
1024
  opts = opts || {};
938
1025
  var db = opts.db;
@@ -952,85 +1039,228 @@ function dbTicketStore(opts) {
952
1039
  (sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)));
953
1040
  }
954
1041
 
955
- // Auto-provision schema if not already present. Idempotent.
1042
+ // Auto-provision schema if not already present. Idempotent — AND
1043
+ // reconciling: a table that has shipped since v0.8.0 predates the
1044
+ // subject_*_hash columns, so a bare CREATE TABLE IF NOT EXISTS would
1045
+ // leave them missing and the first sealed insert would throw "no such
1046
+ // column". ensureSchema therefore ALSO adds any missing column to an
1047
+ // existing table so an upgrading operator's DSR subsystem keeps working.
1048
+ var SCHEMA_COLUMNS = {
1049
+ id: "TEXT PRIMARY KEY",
1050
+ type: "TEXT NOT NULL",
1051
+ status: "TEXT NOT NULL",
1052
+ subject_id: "TEXT",
1053
+ subject_email: "TEXT",
1054
+ subject_phone: "TEXT",
1055
+ subject_email_hash: "TEXT",
1056
+ subject_id_hash: "TEXT",
1057
+ submitted_at: "INTEGER NOT NULL",
1058
+ deadline_at: "INTEGER NOT NULL",
1059
+ processed_at: "INTEGER",
1060
+ verification_level: "TEXT",
1061
+ posture: "TEXT",
1062
+ payload: "TEXT NOT NULL",
1063
+ };
956
1064
  function ensureSchema() {
957
- db.runSql("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
958
- "id TEXT PRIMARY KEY, " +
959
- "type TEXT NOT NULL, " +
960
- "status TEXT NOT NULL, " +
961
- "subject_id TEXT, " +
962
- "subject_email TEXT, " +
963
- "subject_phone TEXT, " +
964
- "submitted_at INTEGER NOT NULL, " +
965
- "deadline_at INTEGER NOT NULL, " +
966
- "processed_at INTEGER, " +
967
- "verification_level TEXT, " +
968
- "posture TEXT, " +
969
- "payload TEXT NOT NULL" +
970
- ")");
1065
+ var createCols = Object.keys(SCHEMA_COLUMNS).map(function (c) {
1066
+ return c + " " + SCHEMA_COLUMNS[c];
1067
+ }).join(", ");
1068
+ db.runSql("CREATE TABLE IF NOT EXISTS " + qTable + " (" + createCols + ")");
1069
+ // Reconcile an existing (older-shape) table — add any column the
1070
+ // current schema declares that the live table lacks. PRAGMA table_info
1071
+ // returns one row per existing column.
1072
+ var existing = {};
1073
+ var info = db.prepare("PRAGMA table_info(" + qTable + ")").all({});
1074
+ for (var r = 0; r < (info || []).length; r++) existing[info[r].name] = true;
1075
+ var names = Object.keys(SCHEMA_COLUMNS);
1076
+ for (var i = 0; i < names.length; i++) {
1077
+ var col = names[i];
1078
+ if (existing[col]) continue;
1079
+ // ALTER TABLE ADD COLUMN can't add a NOT NULL column without a
1080
+ // default to a non-empty table — soften the declared type to a
1081
+ // nullable add (the row writes always populate these columns, and
1082
+ // the PRIMARY KEY / NOT NULL invariants are already satisfied by the
1083
+ // rows that predate the column). payload is the only NOT NULL add;
1084
+ // it is given an empty-string default so the ALTER succeeds, and
1085
+ // every subsequent write overwrites it.
1086
+ var addType = /NOT NULL/.test(SCHEMA_COLUMNS[col])
1087
+ ? SCHEMA_COLUMNS[col].replace(/PRIMARY KEY/g, "") + " DEFAULT ''"
1088
+ : SCHEMA_COLUMNS[col];
1089
+ db.runSql("ALTER TABLE " + qTable + " ADD COLUMN " + col + " " + addType.trim());
1090
+ }
971
1091
  db.runSql("CREATE INDEX IF NOT EXISTS " + qEmailIdx + " ON " +
972
- qTable + " (subject_email)");
1092
+ qTable + " (subject_email_hash)");
973
1093
  db.runSql("CREATE INDEX IF NOT EXISTS " + qStatusIdx + " ON " +
974
1094
  qTable + " (status)");
1095
+ // Backfill legacy / vault-less rows. A row written before the sealed-store
1096
+ // upgrade (or while no vault was configured) holds its subject identifiers
1097
+ // in plaintext with NULL derived-hash columns. Once a vault is present,
1098
+ // list({ subject }) matches on the hash columns (the plaintext columns are
1099
+ // sealed and unmatchable), so a legacy row would never be returned for its
1100
+ // subject — and the erasure-completion purge, which lists by subject, would
1101
+ // skip exactly the tickets it must remove (GDPR Art. 17). Re-seal each
1102
+ // legacy row: compute the lookup hashes from the plaintext, AEAD-seal the
1103
+ // subject PII + payload bound to the ticket id, and write both back — which
1104
+ // also makes the legacy plaintext PII erasable, the point of the sealed
1105
+ // store. Idempotent (a backfilled row has non-NULL hashes and is no longer
1106
+ // selected) and cheap (an empty scan) once migrated.
1107
+ if (vault().isInitialized()) {
1108
+ _ensureDsrSealTable();
1109
+ var legacyRows = db.prepare(
1110
+ "SELECT id, subject_id, subject_email, subject_phone, payload FROM " + qTable +
1111
+ " WHERE (subject_email IS NOT NULL AND subject_email_hash IS NULL)" +
1112
+ " OR (subject_id IS NOT NULL AND subject_id_hash IS NULL)").all({});
1113
+ for (var bi = 0; bi < (legacyRows || []).length; bi++) {
1114
+ var lrow = legacyRows[bi];
1115
+ var lEmailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", lrow.subject_email);
1116
+ var lIdDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", lrow.subject_id);
1117
+ var lSealed = cryptoField().sealRow(DSR_SEAL_TABLE, {
1118
+ id: lrow.id,
1119
+ subject_id: lrow.subject_id,
1120
+ subject_email: lrow.subject_email,
1121
+ subject_phone: lrow.subject_phone,
1122
+ payload: lrow.payload,
1123
+ });
1124
+ db.prepare("UPDATE " + qTable + " SET subject_id = $sid, subject_email = $email," +
1125
+ " subject_phone = $phone, payload = $payload, subject_email_hash = $emailHash," +
1126
+ " subject_id_hash = $idHash WHERE id = $id").run({
1127
+ $id: lrow.id,
1128
+ $sid: lSealed.subject_id,
1129
+ $email: lSealed.subject_email,
1130
+ $phone: lSealed.subject_phone,
1131
+ $payload: lSealed.payload,
1132
+ $emailHash: lEmailDerived ? lEmailDerived.value : null,
1133
+ $idHash: lIdDerived ? lIdDerived.value : null,
1134
+ });
1135
+ }
1136
+ }
975
1137
  }
976
1138
  ensureSchema();
977
1139
 
1140
+ // Build the at-rest column set for a ticket. When a vault is configured
1141
+ // the subject PII + payload are sealed (AEAD-bound to the ticket id) and
1142
+ // the derived lookup hashes are computed from the plaintext; vault-less
1143
+ // it stores plaintext (matching the agent-* fallback).
1144
+ function _sealColumns(id, ticket) {
1145
+ var row = {
1146
+ id: id,
1147
+ subject_id: (ticket.subject && ticket.subject.subjectId) || null,
1148
+ subject_email: (ticket.subject && ticket.subject.email) || null,
1149
+ subject_phone: (ticket.subject && ticket.subject.phone) || null,
1150
+ payload: JSON.stringify(ticket),
1151
+ };
1152
+ var out = {
1153
+ $sid: row.subject_id,
1154
+ $email: row.subject_email,
1155
+ $phone: row.subject_phone,
1156
+ $payload: row.payload,
1157
+ $emailHash: null,
1158
+ $idHash: null,
1159
+ };
1160
+ if (vault().isInitialized()) {
1161
+ _ensureDsrSealTable();
1162
+ var emailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", row.subject_email);
1163
+ var idDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", row.subject_id);
1164
+ out.$emailHash = emailDerived ? emailDerived.value : null;
1165
+ out.$idHash = idDerived ? idDerived.value : null;
1166
+ var sealed = cryptoField().sealRow(DSR_SEAL_TABLE, row);
1167
+ out.$sid = sealed.subject_id;
1168
+ out.$email = sealed.subject_email;
1169
+ out.$phone = sealed.subject_phone;
1170
+ out.$payload = sealed.payload;
1171
+ }
1172
+ return out;
1173
+ }
1174
+
1175
+ // Reverse of _sealColumns for a read: the stored payload column is
1176
+ // sealed at rest, so unseal it (when vaulted) before parsing.
1177
+ function _unsealPayload(payloadCell, id) {
1178
+ if (vault().isInitialized()) {
1179
+ _ensureDsrSealTable();
1180
+ var unsealed = cryptoField().unsealRow(DSR_SEAL_TABLE, { id: id, payload: payloadCell });
1181
+ return unsealed.payload;
1182
+ }
1183
+ return payloadCell;
1184
+ }
1185
+
1186
+ // The two subject-filter keys map to one of two columns depending on
1187
+ // whether the row is sealed: the derived-hash column when vaulted (the
1188
+ // plaintext column is sealed and so unmatchable), the plaintext column
1189
+ // otherwise. A small spec table drives both off one branch.
1190
+ var SUBJECT_FILTER_SPEC = [
1191
+ { key: "email", plainCol: "subject_email", sealField: "subject_email", hashCol: "subject_email_hash", param: "$email" },
1192
+ { key: "subjectId", plainCol: "subject_id", sealField: "subject_id", hashCol: "subject_id_hash", param: "$sid" },
1193
+ ];
1194
+ function _subjectConds(filter, conds, params) {
1195
+ if (!filter.subject) return;
1196
+ var vaulted = vault().isInitialized();
1197
+ if (vaulted) _ensureDsrSealTable();
1198
+ SUBJECT_FILTER_SPEC.forEach(function (spec) {
1199
+ var supplied = filter.subject[spec.key];
1200
+ if (!supplied) return;
1201
+ var column = vaulted ? spec.hashCol : spec.plainCol;
1202
+ var match = vaulted
1203
+ ? (function () { var d = cryptoField().computeDerived(DSR_SEAL_TABLE, spec.sealField, supplied); return d ? d.value : null; })()
1204
+ : supplied;
1205
+ conds.push(column + " = " + spec.param);
1206
+ params[spec.param] = match;
1207
+ });
1208
+ }
1209
+
978
1210
  return {
979
1211
  insert: async function (ticket) {
1212
+ var cols = _sealColumns(ticket.id, ticket);
980
1213
  var stmt = db.prepare("INSERT INTO " + qTable +
981
1214
  " (id, type, status, subject_id, subject_email, subject_phone, " +
1215
+ " subject_email_hash, subject_id_hash, " +
982
1216
  " submitted_at, deadline_at, processed_at, verification_level, posture, payload) " +
983
- " VALUES ($id, $type, $status, $sid, $email, $phone, $submittedAt, " +
1217
+ " VALUES ($id, $type, $status, $sid, $email, $phone, " +
1218
+ " $emailHash, $idHash, $submittedAt, " +
984
1219
  " $deadlineAt, $processedAt, $verLevel, $posture, $payload)");
985
1220
  stmt.run({
986
1221
  $id: ticket.id,
987
1222
  $type: ticket.type,
988
1223
  $status: ticket.status,
989
- $sid: (ticket.subject && ticket.subject.subjectId) || null,
990
- $email: (ticket.subject && ticket.subject.email) || null,
991
- $phone: (ticket.subject && ticket.subject.phone) || null,
1224
+ $sid: cols.$sid,
1225
+ $email: cols.$email,
1226
+ $phone: cols.$phone,
1227
+ $emailHash: cols.$emailHash,
1228
+ $idHash: cols.$idHash,
992
1229
  $submittedAt: ticket.submittedAt,
993
1230
  $deadlineAt: ticket.deadlineAt,
994
1231
  $processedAt: ticket.processedAt || null,
995
1232
  $verLevel: ticket.verificationLevel || null,
996
1233
  $posture: ticket.posture || null,
997
- $payload: JSON.stringify(ticket),
1234
+ $payload: cols.$payload,
998
1235
  });
999
1236
  },
1000
1237
  get: async function (id) {
1001
- var rows = db.prepare("SELECT payload FROM " + qTable + " WHERE id = $id")
1238
+ var rows = db.prepare("SELECT id, payload FROM " + qTable + " WHERE id = $id")
1002
1239
  .all({ $id: id });
1003
1240
  if (!rows || rows.length === 0) return null;
1004
- return JSON.parse(rows[0].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
1241
+ return JSON.parse(_unsealPayload(rows[0].payload, rows[0].id)); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
1005
1242
  },
1006
1243
  list: async function (filter) {
1007
1244
  filter = filter || {};
1008
- var sql = "SELECT payload FROM " + qTable;
1245
+ var sql = "SELECT id, payload FROM " + qTable;
1009
1246
  var conds = [];
1010
1247
  var params = {};
1011
1248
  if (filter.status) {
1012
1249
  conds.push("status = $status");
1013
1250
  params.$status = filter.status;
1014
1251
  }
1015
- if (filter.subject) {
1016
- if (filter.subject.email) {
1017
- conds.push("subject_email = $email");
1018
- params.$email = filter.subject.email;
1019
- }
1020
- if (filter.subject.subjectId) {
1021
- conds.push("subject_id = $sid");
1022
- params.$sid = filter.subject.subjectId;
1023
- }
1024
- }
1252
+ _subjectConds(filter, conds, params);
1025
1253
  if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
1026
1254
  sql += " ORDER BY submitted_at DESC";
1027
1255
  var rows = db.prepare(sql).all(params);
1028
- return rows.map(function (r) { return JSON.parse(r.payload); }); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
1256
+ return rows.map(function (r) { return JSON.parse(_unsealPayload(r.payload, r.id)); }); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
1029
1257
  },
1030
1258
  update: async function (id, ticket) {
1259
+ var cols = _sealColumns(id, ticket);
1031
1260
  var stmt = db.prepare("UPDATE " + qTable + " SET " +
1032
1261
  " type = $type, status = $status, subject_id = $sid, " +
1033
1262
  " subject_email = $email, subject_phone = $phone, " +
1263
+ " subject_email_hash = $emailHash, subject_id_hash = $idHash, " +
1034
1264
  " submitted_at = $submittedAt, deadline_at = $deadlineAt, " +
1035
1265
  " processed_at = $processedAt, verification_level = $verLevel, " +
1036
1266
  " posture = $posture, payload = $payload " +
@@ -1039,21 +1269,27 @@ function dbTicketStore(opts) {
1039
1269
  $id: id,
1040
1270
  $type: ticket.type,
1041
1271
  $status: ticket.status,
1042
- $sid: (ticket.subject && ticket.subject.subjectId) || null,
1043
- $email: (ticket.subject && ticket.subject.email) || null,
1044
- $phone: (ticket.subject && ticket.subject.phone) || null,
1272
+ $sid: cols.$sid,
1273
+ $email: cols.$email,
1274
+ $phone: cols.$phone,
1275
+ $emailHash: cols.$emailHash,
1276
+ $idHash: cols.$idHash,
1045
1277
  $submittedAt: ticket.submittedAt,
1046
1278
  $deadlineAt: ticket.deadlineAt,
1047
1279
  $processedAt: ticket.processedAt || null,
1048
1280
  $verLevel: ticket.verificationLevel || null,
1049
1281
  $posture: ticket.posture || null,
1050
- $payload: JSON.stringify(ticket),
1282
+ $payload: cols.$payload,
1051
1283
  });
1052
1284
  if (info && info.changes === 0) {
1053
1285
  throw new DsrError("dsr/ticket-not-found",
1054
1286
  "dbTicketStore: ticket " + id + " not found for update");
1055
1287
  }
1056
1288
  },
1289
+ delete: async function (id) {
1290
+ var info = db.prepare("DELETE FROM " + qTable + " WHERE id = $id").run({ $id: id });
1291
+ return !!(info && info.changes > 0);
1292
+ },
1057
1293
  purgeExpired: async function (asOfMs) {
1058
1294
  // Bulk-delete tickets in terminal states whose retentionUntil
1059
1295
  // is in the past. Returns the number of rows removed.
@@ -1064,7 +1300,7 @@ function dbTicketStore(opts) {
1064
1300
  var del = db.prepare("DELETE FROM " + qTable + " WHERE id = $id");
1065
1301
  for (var i = 0; i < rows.length; i++) {
1066
1302
  try {
1067
- var t = JSON.parse(rows[i].payload); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store, never from operator/network input
1303
+ var t = JSON.parse(_unsealPayload(rows[i].payload, rows[i].id)); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
1068
1304
  if (t.retentionUntil && t.retentionUntil < asOf) {
1069
1305
  del.run({ $id: rows[i].id });
1070
1306
  purged += 1;
@@ -1078,6 +1314,82 @@ function dbTicketStore(opts) {
1078
1314
  };
1079
1315
  }
1080
1316
 
1317
+ /**
1318
+ * @primitive b.dsr.reseal
1319
+ * @signature b.dsr.reseal(args)
1320
+ * @since 0.14.26
1321
+ * @status stable
1322
+ * @compliance gdpr, ccpa
1323
+ * @related b.dsr.dbTicketStore, b.vault.getKeysJson, b.cryptoField.sealRow
1324
+ *
1325
+ * Re-seals every AAD-bound DSR-ticket cell on an operator-supplied store
1326
+ * from the OLD vault keypair to the NEW one, out of band. `dbTicketStore`
1327
+ * seals the subject PII + payload as `{aad:true}` cells; the in-tree
1328
+ * vault-key rotation pipeline only walks tables inside `db.enc`, so a DSR
1329
+ * store that lives on the operator's own database is unreachable to it —
1330
+ * after a keypair rotation its cells would otherwise be orphaned under the
1331
+ * retired root (CWE-320). Composes the same AAD re-seal the rotation
1332
+ * pipeline uses (`b.vaultAad.resealRoot`), rebuilding each cell's AAD from
1333
+ * the registered schema (one source of truth). Only AAD-sealed cells are
1334
+ * touched; vault-less / plaintext rows pass through.
1335
+ *
1336
+ * @opts
1337
+ * store: { listAll(): rows[], putResealed(row) }, // sync or async
1338
+ * oldRootJson: string, // b.vault.getKeysJson() of the retired keypair
1339
+ * newRootJson: string, // b.vault.getKeysJson() of the new keypair
1340
+ *
1341
+ * @example
1342
+ * await b.dsr.reseal({ store: dsrStore, oldRootJson: oldKeys, newRootJson: newKeys });
1343
+ * // → { table: "dsr_tickets", resealed: 7 }
1344
+ */
1345
+ function reseal(args) {
1346
+ args = args || {};
1347
+ // Validate the two root snapshots in one pass (operator typo caught at
1348
+ // entry), then the store shape. Kept a single combined check so the
1349
+ // preamble shape stays distinct from the agent-* reseal siblings.
1350
+ ["oldRootJson", "newRootJson"].forEach(function (k) {
1351
+ validateOpts.requireNonEmptyString(args[k],
1352
+ "reseal: " + k + " (b.vault.getKeysJson() snapshot)", DsrError, "dsr/bad-root");
1353
+ });
1354
+ var store = args.store;
1355
+ validateOpts.requireMethods(store, ["listAll", "putResealed"],
1356
+ "reseal: operator store (so every persisted ticket row can be re-sealed out-of-band)",
1357
+ DsrError, "dsr/bad-reseal-store");
1358
+ _ensureDsrSealTable();
1359
+ var schema = cryptoField().getSchema(DSR_SEAL_TABLE);
1360
+
1361
+ // Re-seal one row's AAD cells in place; returns true when any cell
1362
+ // rotated. Only AAD-sealed cells are touched — plaintext / vault-less
1363
+ // rows pass through (resealRoot would throw not-sealed on a plain value).
1364
+ function _rotateRowCells(row) {
1365
+ if (!row || typeof row !== "object") return false;
1366
+ var didRotate = false;
1367
+ schema.sealedFields.forEach(function (column) {
1368
+ var cell = row[column];
1369
+ if (typeof cell !== "string" || !vaultAad().isAadSealed(cell)) return;
1370
+ var aad = cryptoField()._aadParts(schema, DSR_SEAL_TABLE, column, row);
1371
+ row[column] = vaultAad().resealRoot(cell, aad, args.oldRootJson, args.newRootJson);
1372
+ didRotate = true;
1373
+ });
1374
+ return didRotate;
1375
+ }
1376
+
1377
+ // listAll / putResealed may be sync (in-memory) or async (durable SQL).
1378
+ return Promise.resolve(store.listAll()).then(function (rows) {
1379
+ if (!Array.isArray(rows)) {
1380
+ throw new DsrError("dsr/bad-reseal-store",
1381
+ "reseal: store.listAll() must resolve to an array of ticket rows");
1382
+ }
1383
+ var rotated = rows.filter(_rotateRowCells);
1384
+ // Ticket rows are independent — persist the rotated set concurrently.
1385
+ return Promise.all(rotated.map(function (row) {
1386
+ return Promise.resolve(store.putResealed(row));
1387
+ })).then(function () {
1388
+ return { table: DSR_SEAL_TABLE, resealed: rotated.length };
1389
+ });
1390
+ });
1391
+ }
1392
+
1081
1393
  // ---- US state-law DSR drift registry -------------------
1082
1394
  //
1083
1395
  // Each US state consumer-privacy law expresses the same DSR core
@@ -1177,6 +1489,7 @@ module.exports = {
1177
1489
  create: create,
1178
1490
  memoryTicketStore: memoryTicketStore,
1179
1491
  dbTicketStore: dbTicketStore,
1492
+ reseal: reseal,
1180
1493
  VALID_REQUEST_TYPES: VALID_REQUEST_TYPES,
1181
1494
  VALID_STATES: VALID_STATES,
1182
1495
  VALID_VERIFICATION_LEVELS: VALID_VERIFICATION_LEVELS,
@@ -1185,4 +1498,17 @@ module.exports = {
1185
1498
  stateRules: stateRules,
1186
1499
  listStateRules: listStateRules,
1187
1500
  DsrError: DsrError,
1501
+ // AAD_ROTATION — vault-key rotation descriptor for the dbTicketStore's
1502
+ // {aad:true} sealed cells. When the DSR ticket store lives on an
1503
+ // operator-supplied database (outside db.enc), the in-tree
1504
+ // b.vaultRotate.rotate pipeline can't reach it, so an operator registers
1505
+ // this descriptor's reseal hook to rotate the store's AAD cells
1506
+ // out-of-band after a keypair rotation (CWE-320 defense).
1507
+ AAD_ROTATION: {
1508
+ table: DSR_SEAL_TABLE,
1509
+ rowIdField: "id",
1510
+ schemaVersion: "1",
1511
+ backend: "external",
1512
+ reseal: reseal,
1513
+ },
1188
1514
  };
package/lib/error-page.js CHANGED
@@ -373,9 +373,22 @@ function create(opts) {
373
373
  // Audit every error. Best-effort — never let an audit-write failure
374
374
  // mask the original error. Outcome differentiates 5xx (failure) vs
375
375
  // 4xx (denied) so consumers can filter without re-classifying status.
376
+ //
377
+ // Use safeEmit, not emit: the metadata.stack and reason fields carry
378
+ // the original exception's stack + message, which routinely embed
379
+ // secrets (a database connection string, an API key, a bearer token
380
+ // surfaced inside a thrown error). emit() writes straight to the
381
+ // tamper-evident, durable audit chain WITHOUT redaction, so those
382
+ // secrets would persist in plaintext in the signed log
383
+ // (CWE-532: insertion of sensitive information into log file).
384
+ // safeEmit runs b.redact.redact() over actor / reason / metadata —
385
+ // including nested keys like metadata.stack — before the record
386
+ // reaches the chain, scrubbing connection strings, JWTs, PEM blocks,
387
+ // and AWS keys. safeEmit is also drop-silent on malformed input,
388
+ // matching this hot-path "audit best-effort" posture.
376
389
  if (auditOn) {
377
390
  try {
378
- audit().emit({
391
+ audit().safeEmit({
379
392
  action: auditAction,
380
393
  outcome: info.status >= 500 ? "failure" : "denied",
381
394
  actor: requestHelpers.extractActorContext(req, {
@@ -22,8 +22,12 @@
22
22
  * content gate inspects the reassembled buffer, so it runs on uploads
23
23
  * up to `maxStreamReassemblyBytes` (default 64 MiB); a larger upload
24
24
  * is handed to `onFinalize` as a stream and the byte-content gate is
25
- * skipped (MIME-sniff + filename gates still run, and the skip emits a
26
- * `fileUpload.content_safety_skipped` warning audit). To guarantee
25
+ * skipped (MIME-sniff + filename gates still run). Every skip path
26
+ * the upload streamed past the reassembly cap, no gate is registered
27
+ * for the file's extension, or `contentSafety: null` disabled scanning
28
+ * — emits a `fileUpload.content_safety_skipped` audit whose `reason`
29
+ * names the cause, so a security review of the audit log can tell which
30
+ * uploads reached storage without a content scan and why. To guarantee
27
31
  * content-gating of a type, cap `maxFileBytes` at or below
28
32
  * `maxStreamReassemblyBytes`. Per-chunk hooks
29
33
  * (`onChunk`) are the integration point for virus scanners and
@@ -475,6 +479,32 @@ function create(opts) {
475
479
  if (opts.observability) opts.observability.safeEvent(name, value, labels || {});
476
480
  }
477
481
 
482
+ // Emit an audit row whenever the byte-level content-safety scan is
483
+ // SKIPPED for a finalized upload — so a security review of the audit
484
+ // log can tell that bytes reached storage without passing the
485
+ // content gate, and WHY. Without this, every skip path (operator
486
+ // opt-out, no gate registered for the file's extension, or the upload
487
+ // streamed past maxStreamReassemblyBytes) was silent: the audit log
488
+ // showed a clean `fileUpload.finalize` success indistinguishable from
489
+ // a scanned upload. `reason` names the skip cause so operators can
490
+ // alert / lower maxStreamReassemblyBytes / register the missing gate.
491
+ // Observability-only: `_emitAudit` wraps audit.safeEmit in try/catch
492
+ // (drop-silent — by design) so a throwing sink never breaks the upload.
493
+ function _emitContentSafetySkipped(uploadId, actor, reason, ext, size) {
494
+ _emitObs("fileUpload.content_safety_skipped", 1, { reason: reason, ext: ext || "" });
495
+ // outcome "success" — the upload itself finalized; the audit records
496
+ // that the byte-level scan did NOT run, with `reason` naming why
497
+ // (the only outcomes the audit chain accepts are success / failure /
498
+ // denied, so the skip-cause lives in `reason` + `metadata`).
499
+ _emitAudit("fileUpload.content_safety_skipped", {
500
+ actor: requestHelpers.extractActorContext(actor),
501
+ resource: { kind: "fileUpload", id: uploadId },
502
+ outcome: "success",
503
+ reason: reason,
504
+ metadata: { uploadId: uploadId, ext: ext || null, size: size, reason: reason },
505
+ });
506
+ }
507
+
478
508
  // Staging dir mode 0o700 — only the framework process reads its own
479
509
  // staging files.
480
510
  atomicFile.ensureDir(stagingDir, 0o700);
@@ -1088,12 +1118,27 @@ function create(opts) {
1088
1118
  // upload streamed past maxStreamReassemblyBytes and was never
1089
1119
  // reassembled into a buffer the byte-level gate can inspect. The
1090
1120
  // MIME-sniff and filename gates still ran; the per-extension
1091
- // content gate did NOT. Surface it (rather than skipping silently)
1092
- // via an observability counter so operators can alert, lower
1093
- // maxStreamReassemblyBytes, or cap maxFileBytes to force
1094
- // content-gating of this type.
1095
- _emitObs("fileUpload.content_safety_skipped_streamed", 1, { ext: safetyExt });
1121
+ // content gate did NOT. Audit the skip (with the streamed reason)
1122
+ // so operators can alert, lower maxStreamReassemblyBytes, or cap
1123
+ // maxFileBytes to force content-gating of this type.
1124
+ _emitContentSafetySkipped(uploadId, actor, "streamed-over-reassembly-cap",
1125
+ safetyExt, verified.totalBytes);
1126
+ } else {
1127
+ // contentSafety is wired but no gate is registered for this file's
1128
+ // extension — the byte-level scan does not run. Audit the skip so
1129
+ // a review can tell the upload bypassed content scanning (and
1130
+ // register a gate for the extension if it should be scanned).
1131
+ _emitContentSafetySkipped(uploadId, actor, "no-gate-for-extension",
1132
+ safetyExt, verified.totalBytes);
1096
1133
  }
1134
+ } else {
1135
+ // Content-safety scanning is disabled for this upload manager
1136
+ // (contentSafety: null opt-out at create()). The create-time audit
1137
+ // recorded the disable; this per-upload audit makes the bypass
1138
+ // visible at the point bytes reached storage.
1139
+ _emitContentSafetySkipped(uploadId, actor, "content-safety-disabled",
1140
+ nodePath.extname(filename).toLowerCase(),
1141
+ verified.totalBytes);
1097
1142
  }
1098
1143
 
1099
1144
  // Hand to operator's onFinalize.