@blamejs/core 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/lib/audit.js +1 -1
- package/lib/break-glass.js +549 -12
- package/lib/crypto.js +12 -4
- package/lib/db.js +1 -0
- package/lib/framework-schema.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.5.x
|
|
10
|
+
|
|
11
|
+
- **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
|
|
12
|
+
- **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
|
|
13
|
+
|
|
9
14
|
## v0.4.x
|
|
10
15
|
|
|
11
16
|
- **0.4.29** (2026-04-30) — primitive-drift sweep: second-pass remediation
|
package/lib/audit.js
CHANGED
|
@@ -201,7 +201,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
201
201
|
// Per-primitive namespaces — keep alphabetical
|
|
202
202
|
"apikey", // b.apiKey
|
|
203
203
|
"backup", // b.backup
|
|
204
|
-
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth
|
|
204
|
+
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
205
205
|
"cache", // b.cache
|
|
206
206
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
207
207
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
package/lib/break-glass.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
var audit = require("./audit");
|
|
41
41
|
var C = require("./constants");
|
|
42
42
|
var clusterStorage = require("./cluster-storage");
|
|
43
|
-
var { generateToken, sha3Hash } = require("./crypto");
|
|
43
|
+
var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
|
|
44
44
|
var cryptoField = require("./crypto-field");
|
|
45
45
|
var lazyRequire = require("./lazy-require");
|
|
46
46
|
var observability = require("./observability");
|
|
@@ -51,7 +51,10 @@ var totp = require("./totp");
|
|
|
51
51
|
var validateOpts = require("./validate-opts");
|
|
52
52
|
var { defineClass } = require("./framework-error");
|
|
53
53
|
|
|
54
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
55
|
+
|
|
54
56
|
var lockout = lazyRequire(function () { return require("./auth/lockout"); });
|
|
57
|
+
var passkey = lazyRequire(function () { return require("./auth/passkey"); });
|
|
55
58
|
|
|
56
59
|
// Errors — all 14 codes documented in the spec. `permanent: true`
|
|
57
60
|
// means caller's input is bad (Tier-A); `permanent: false` means
|
|
@@ -65,7 +68,7 @@ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
|
|
|
65
68
|
var DEFAULT_REASON_MIN_LEN = 12;
|
|
66
69
|
var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
|
|
67
70
|
var DEFAULT_AUDIT_REASON = "cleartext";
|
|
68
|
-
var ALLOWED_FACTORS = ["totp"];
|
|
71
|
+
var ALLOWED_FACTORS = ["totp", "passkey"];
|
|
69
72
|
var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
|
|
70
73
|
|
|
71
74
|
// In-memory policy cache. Cluster-shared via the policies table; the
|
|
@@ -97,6 +100,207 @@ function _ensureFactorLockout() {
|
|
|
97
100
|
return _factorLockout;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
|
|
104
|
+
//
|
|
105
|
+
// Each policy in cryptographic mode has a per-policy DEK (data
|
|
106
|
+
// encryption key) generated at first use. The DEK is vault-sealed so
|
|
107
|
+
// it survives restarts. At cell encrypt time, the framework derives a
|
|
108
|
+
// per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
|
|
109
|
+
// every (table, rowId, column) triple gets a unique key. Encryption
|
|
110
|
+
// uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
|
|
111
|
+
// — the AEAD tag itself is bound to the encryption context, so a
|
|
112
|
+
// ciphertext from row A literally cannot be decrypted as row B even
|
|
113
|
+
// with the same DEK.
|
|
114
|
+
//
|
|
115
|
+
// THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
|
|
116
|
+
// keys + encryption-context binding (cross-cell tampering / accidental
|
|
117
|
+
// row-swap fails closed). It does NOT defend against vault-key
|
|
118
|
+
// compromise alone — the DEK is still vault-recoverable. True
|
|
119
|
+
// second-factor cryptographic gating ships in v0.5.2 with passkey
|
|
120
|
+
// integration (the passkey private key lives on the YubiKey, not in
|
|
121
|
+
// the framework, so a vault leak alone can't unwrap).
|
|
122
|
+
|
|
123
|
+
// In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
|
|
124
|
+
var dekCache = new Map();
|
|
125
|
+
|
|
126
|
+
function _aadFor(table, rowId, column) {
|
|
127
|
+
return sha3Hash(table + "|" + String(rowId) + "|" + column);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _kCell(dek, table, rowId, column) {
|
|
131
|
+
return kdf(Buffer.concat([
|
|
132
|
+
Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
|
|
133
|
+
Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
|
|
134
|
+
]), 32);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function _ensureDek(table) {
|
|
138
|
+
if (dekCache.has(table)) return dekCache.get(table);
|
|
139
|
+
// DEK is vault-sealed and stored in the policy row's `dekSealed`
|
|
140
|
+
// column. Generated lazily on first use of cryptographic-mode for
|
|
141
|
+
// the table. Cached in-memory after first read.
|
|
142
|
+
var rows = await clusterStorage.executeAll(
|
|
143
|
+
"SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
|
|
144
|
+
[table]
|
|
145
|
+
);
|
|
146
|
+
if (!rows || rows.length === 0) {
|
|
147
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
148
|
+
"_ensureDek: no policy for table '" + table + "'", true);
|
|
149
|
+
}
|
|
150
|
+
var sealed = rows[0].dekSealed;
|
|
151
|
+
var dek;
|
|
152
|
+
if (sealed) {
|
|
153
|
+
dek = Buffer.from(vault().unseal(sealed), "base64");
|
|
154
|
+
} else {
|
|
155
|
+
dek = generateBytes(32);
|
|
156
|
+
var sealedDek = vault().seal(dek.toString("base64"));
|
|
157
|
+
await clusterStorage.execute(
|
|
158
|
+
"UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
|
|
159
|
+
[sealedDek, table]
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
dekCache.set(table, dek);
|
|
163
|
+
return dek;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Encrypt a single cell value with encryption context binding. Operators
|
|
167
|
+
// in cryptographic mode use this at write time INSTEAD of letting
|
|
168
|
+
// cryptoField.sealRow seal the column.
|
|
169
|
+
async function encryptCell(plaintext, ctx) {
|
|
170
|
+
_requireInit();
|
|
171
|
+
if (!ctx || typeof ctx !== "object" ||
|
|
172
|
+
typeof ctx.table !== "string" || ctx.table.length === 0 ||
|
|
173
|
+
ctx.rowId === undefined || ctx.rowId === null ||
|
|
174
|
+
typeof ctx.column !== "string" || ctx.column.length === 0) {
|
|
175
|
+
throw new BreakGlassError("breakglass/bad-cell-ctx",
|
|
176
|
+
"encryptCell: ctx must be { table, rowId, column }");
|
|
177
|
+
}
|
|
178
|
+
var policy = await policyGet(ctx.table);
|
|
179
|
+
if (!policy || !policy.cryptographic) {
|
|
180
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
181
|
+
"encryptCell: table '" + ctx.table + "' is not in cryptographic mode " +
|
|
182
|
+
"(set policy.cryptographic = true to opt in)", true);
|
|
183
|
+
}
|
|
184
|
+
if (policy.columns.indexOf(ctx.column) === -1) {
|
|
185
|
+
throw new BreakGlassError("breakglass/grant-column-mismatch",
|
|
186
|
+
"encryptCell: column '" + ctx.column + "' is not glass-locked on '" + ctx.table + "'", true);
|
|
187
|
+
}
|
|
188
|
+
var dek = await _ensureDek(ctx.table);
|
|
189
|
+
var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
|
|
190
|
+
var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
|
|
191
|
+
var pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
|
|
192
|
+
var packed = encryptPacked(pt, kCell, aad);
|
|
193
|
+
return "bgcell:1:" + packed.toString("base64");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Decrypt a cell value. Caller must hold a valid grant covering the
|
|
197
|
+
// (table, column) — caller passes through unsealRow which gates this.
|
|
198
|
+
// The encryption context (table, rowId, column) is passed both to the
|
|
199
|
+
// key derivation AND the AAD; if the caller passes the wrong rowId
|
|
200
|
+
// trying to "swap" ciphertexts between rows, decryption fails closed.
|
|
201
|
+
async function decryptCell(ciphertext, ctx) {
|
|
202
|
+
_requireInit();
|
|
203
|
+
if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
|
|
204
|
+
throw new BreakGlassError("breakglass/bad-ciphertext",
|
|
205
|
+
"decryptCell: expected 'bgcell:1:<base64>' format");
|
|
206
|
+
}
|
|
207
|
+
if (!ctx || typeof ctx !== "object") {
|
|
208
|
+
throw new BreakGlassError("breakglass/bad-cell-ctx",
|
|
209
|
+
"decryptCell: ctx must be { table, rowId, column }");
|
|
210
|
+
}
|
|
211
|
+
var dek = await _ensureDek(ctx.table);
|
|
212
|
+
var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
|
|
213
|
+
var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
|
|
214
|
+
var packed = Buffer.from(ciphertext.slice("bgcell:1:".length), "base64");
|
|
215
|
+
return decryptPacked(packed, kCell, aad).toString("utf8");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- Migration support ----
|
|
219
|
+
//
|
|
220
|
+
// Operator runs `b.breakGlass.migrate(table, opts)` (or the CLI
|
|
221
|
+
// equivalent) to convert existing Model-A-sealed rows into Model B
|
|
222
|
+
// per-cell-encrypted form. Each row's glass-locked columns are
|
|
223
|
+
// unsealed via cryptoField, re-encrypted via encryptCell, and written
|
|
224
|
+
// back. The migration is idempotent — a row already in Model B form
|
|
225
|
+
// (column starts with "bgcell:") is skipped.
|
|
226
|
+
|
|
227
|
+
async function migrate(table, opts) {
|
|
228
|
+
_requireInit();
|
|
229
|
+
opts = opts || {};
|
|
230
|
+
validateOpts(opts, ["batchSize", "callerOpts"], "breakGlass.migrate");
|
|
231
|
+
var policy = await policyGet(table);
|
|
232
|
+
if (!policy) {
|
|
233
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
234
|
+
"migrate: no policy for table '" + table + "'", true);
|
|
235
|
+
}
|
|
236
|
+
if (!policy.cryptographic) {
|
|
237
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
238
|
+
"migrate: policy must be cryptographic: true to migrate to Model B", true);
|
|
239
|
+
}
|
|
240
|
+
var batchSize = opts.batchSize || 100;
|
|
241
|
+
var totalRows = 0;
|
|
242
|
+
var migratedRows = 0;
|
|
243
|
+
var skippedRows = 0;
|
|
244
|
+
var lastId = "";
|
|
245
|
+
// Iterate via _id-keyset paging so we don't load the whole table into memory.
|
|
246
|
+
while (true) {
|
|
247
|
+
var rows = await clusterStorage.executeAll(
|
|
248
|
+
"SELECT * FROM " + table + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
|
|
249
|
+
[lastId, batchSize]
|
|
250
|
+
);
|
|
251
|
+
if (!rows || rows.length === 0) break;
|
|
252
|
+
for (var i = 0; i < rows.length; i++) {
|
|
253
|
+
totalRows++;
|
|
254
|
+
var row = rows[i];
|
|
255
|
+
var unsealed = cryptoField.unsealRow(table, row);
|
|
256
|
+
var anyChanged = false;
|
|
257
|
+
var update = { _id: row._id };
|
|
258
|
+
for (var c = 0; c < policy.columns.length; c++) {
|
|
259
|
+
var col = policy.columns[c];
|
|
260
|
+
var current = unsealed[col];
|
|
261
|
+
if (current == null) continue;
|
|
262
|
+
if (typeof current === "string" && current.indexOf("bgcell:") === 0) {
|
|
263
|
+
continue; // already migrated
|
|
264
|
+
}
|
|
265
|
+
var encrypted = await encryptCell(current, { table: table, rowId: row._id, column: col });
|
|
266
|
+
update[col] = encrypted;
|
|
267
|
+
anyChanged = true;
|
|
268
|
+
}
|
|
269
|
+
if (anyChanged) {
|
|
270
|
+
// Write Model B ciphertext directly — bypassing cryptoField so
|
|
271
|
+
// the cell ciphertext stays as a literal string, not double-sealed.
|
|
272
|
+
var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
|
|
273
|
+
if (setCols.length > 0) {
|
|
274
|
+
var setSql = setCols.map(function (k) { return k + " = ?"; }).join(", ");
|
|
275
|
+
var vals = setCols.map(function (k) { return update[k]; });
|
|
276
|
+
vals.push(row._id);
|
|
277
|
+
await clusterStorage.execute(
|
|
278
|
+
"UPDATE " + table + " SET " + setSql + " WHERE _id = ?",
|
|
279
|
+
vals
|
|
280
|
+
);
|
|
281
|
+
migratedRows++;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
skippedRows++;
|
|
285
|
+
}
|
|
286
|
+
lastId = row._id;
|
|
287
|
+
}
|
|
288
|
+
if (rows.length < batchSize) break;
|
|
289
|
+
}
|
|
290
|
+
audit.safeEmit({
|
|
291
|
+
action: "breakglass.migrate",
|
|
292
|
+
outcome: "success",
|
|
293
|
+
actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
|
|
294
|
+
metadata: {
|
|
295
|
+
table: table,
|
|
296
|
+
totalRows: totalRows,
|
|
297
|
+
migratedRows: migratedRows,
|
|
298
|
+
skippedRows: skippedRows,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return { table: table, totalRows: totalRows, migratedRows: migratedRows, skippedRows: skippedRows };
|
|
302
|
+
}
|
|
303
|
+
|
|
100
304
|
// ---- init ----
|
|
101
305
|
|
|
102
306
|
function init(opts) {
|
|
@@ -110,6 +314,7 @@ function init(opts) {
|
|
|
110
314
|
function _resetForTest() {
|
|
111
315
|
initialized = false;
|
|
112
316
|
policyCache.clear();
|
|
317
|
+
dekCache.clear();
|
|
113
318
|
if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
|
|
114
319
|
try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
|
|
115
320
|
}
|
|
@@ -163,10 +368,16 @@ function _validatePolicySet(table, opts) {
|
|
|
163
368
|
" (passkey lands in v0.5.2)");
|
|
164
369
|
}
|
|
165
370
|
}
|
|
166
|
-
|
|
371
|
+
// Model B (cryptographic mode) ships in v0.5.1. When enabled,
|
|
372
|
+
// glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
|
|
373
|
+
// at write time (the framework can't auto-encrypt at write because
|
|
374
|
+
// policy-set may post-date existing data; operators run the migration
|
|
375
|
+
// CLI to convert existing rows). At unseal time, the row's
|
|
376
|
+
// glass-locked columns are decrypted via decryptCell with encryption
|
|
377
|
+
// context binding (table, rowId, column).
|
|
378
|
+
if (opts.cryptographic !== undefined && typeof opts.cryptographic !== "boolean") {
|
|
167
379
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
168
|
-
"policy.set: cryptographic
|
|
169
|
-
"set cryptographic: false (default) or omit for v0.5.0");
|
|
380
|
+
"policy.set: cryptographic must be a boolean");
|
|
170
381
|
}
|
|
171
382
|
var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
|
|
172
383
|
if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
|
|
@@ -188,11 +399,38 @@ function _validatePolicySet(table, opts) {
|
|
|
188
399
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
189
400
|
"policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
|
|
190
401
|
}
|
|
402
|
+
// Service-account bypass: explicit opt-in per table. Operators
|
|
403
|
+
// declare the apiKey ids that may bypass + a required role; the
|
|
404
|
+
// framework requires BOTH to grant the bypass. Without this opt set,
|
|
405
|
+
// there is NO bypass path — every read of a glass-locked column
|
|
406
|
+
// requires a fresh grant.
|
|
407
|
+
var serviceAccountBypass = null;
|
|
191
408
|
if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
|
|
192
|
-
|
|
193
|
-
|
|
409
|
+
var sab = opts.serviceAccountBypass;
|
|
410
|
+
if (!sab || typeof sab !== "object") {
|
|
411
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
412
|
+
"policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
|
|
413
|
+
}
|
|
414
|
+
if (sab.enabled !== true) {
|
|
415
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
416
|
+
"policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
|
|
417
|
+
}
|
|
418
|
+
if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
|
|
419
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
420
|
+
"policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
|
|
421
|
+
}
|
|
422
|
+
if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
|
|
423
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
424
|
+
"policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
|
|
425
|
+
}
|
|
426
|
+
serviceAccountBypass = {
|
|
427
|
+
enabled: true,
|
|
428
|
+
apiKeyIds: sab.apiKeyIds.slice(),
|
|
429
|
+
requireRole: sab.requireRole,
|
|
430
|
+
};
|
|
194
431
|
}
|
|
195
432
|
return {
|
|
433
|
+
cryptographic: opts.cryptographic === true,
|
|
196
434
|
grantTtl: grantTtl,
|
|
197
435
|
maxRowsPerGrant: maxRows,
|
|
198
436
|
reasonRequired: opts.reasonRequired !== false,
|
|
@@ -202,6 +440,7 @@ function _validatePolicySet(table, opts) {
|
|
|
202
440
|
onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
|
|
203
441
|
requireScope: opts.requireScope != null ? opts.requireScope : null,
|
|
204
442
|
auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
|
|
443
|
+
serviceAccountBypass: serviceAccountBypass,
|
|
205
444
|
};
|
|
206
445
|
}
|
|
207
446
|
|
|
@@ -212,7 +451,7 @@ async function policySet(table, opts, callerOpts) {
|
|
|
212
451
|
tableName: table,
|
|
213
452
|
columnsJson: JSON.stringify(opts.columns),
|
|
214
453
|
factorsJson: JSON.stringify(opts.factors),
|
|
215
|
-
cryptographic: 0,
|
|
454
|
+
cryptographic: validated.cryptographic ? 1 : 0,
|
|
216
455
|
grantTtlMs: validated.grantTtl,
|
|
217
456
|
maxRowsPerGrant: validated.maxRowsPerGrant,
|
|
218
457
|
reasonRequired: validated.reasonRequired ? 1 : 0,
|
|
@@ -221,7 +460,9 @@ async function policySet(table, opts, callerOpts) {
|
|
|
221
460
|
sessionPin: validated.sessionPin ? 1 : 0,
|
|
222
461
|
onLockedAccess: validated.onLockedAccess,
|
|
223
462
|
requireScope: validated.requireScope,
|
|
224
|
-
serviceAccountBypassJson:
|
|
463
|
+
serviceAccountBypassJson: validated.serviceAccountBypass
|
|
464
|
+
? JSON.stringify(validated.serviceAccountBypass)
|
|
465
|
+
: null,
|
|
225
466
|
auditReasonStorage: validated.auditReasonStorage,
|
|
226
467
|
updatedAt: Date.now(),
|
|
227
468
|
};
|
|
@@ -280,6 +521,9 @@ async function policyGet(table) {
|
|
|
280
521
|
sessionPin: unsealed.sessionPin === 1,
|
|
281
522
|
onLockedAccess: unsealed.onLockedAccess,
|
|
282
523
|
requireScope: unsealed.requireScope,
|
|
524
|
+
serviceAccountBypass: unsealed.serviceAccountBypassJson
|
|
525
|
+
? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
|
|
526
|
+
: null,
|
|
283
527
|
auditReasonStorage: unsealed.auditReasonStorage,
|
|
284
528
|
updatedAt: Number(unsealed.updatedAt),
|
|
285
529
|
};
|
|
@@ -330,6 +574,34 @@ function _verifyTotpFactor(factor) {
|
|
|
330
574
|
return { ok: verified !== false, step: verified };
|
|
331
575
|
}
|
|
332
576
|
|
|
577
|
+
// Passkey factor — operator presents a WebAuthn assertion plus the
|
|
578
|
+
// challenge/origin/RPID + the previously-enrolled credential record.
|
|
579
|
+
// Phishing-resistant; the private key lives on the YubiKey, not in
|
|
580
|
+
// the framework's vault. v0.5.2 uses passkey for identity verification
|
|
581
|
+
// to gate grant issuance; PRF-derived per-policy DEK material (which
|
|
582
|
+
// would give true vault-key-alone-doesn't-decrypt defense) is a
|
|
583
|
+
// follow-up.
|
|
584
|
+
async function _verifyPasskeyFactor(factor) {
|
|
585
|
+
if (!factor || typeof factor !== "object") return { ok: false };
|
|
586
|
+
if (!factor.response || !factor.expectedChallenge ||
|
|
587
|
+
!factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
|
|
588
|
+
return { ok: false };
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
var result = await passkey().verifyAuthentication({
|
|
592
|
+
response: factor.response,
|
|
593
|
+
expectedChallenge: factor.expectedChallenge,
|
|
594
|
+
expectedOrigin: factor.expectedOrigin,
|
|
595
|
+
expectedRPID: factor.expectedRPID,
|
|
596
|
+
credential: factor.credential,
|
|
597
|
+
requireUserVerification: factor.requireUserVerification !== false,
|
|
598
|
+
});
|
|
599
|
+
return { ok: result && result.verified === true };
|
|
600
|
+
} catch (_e) {
|
|
601
|
+
return { ok: false };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
333
605
|
async function grant(opts) {
|
|
334
606
|
_requireInit();
|
|
335
607
|
if (!opts || typeof opts !== "object") {
|
|
@@ -401,6 +673,8 @@ async function grant(opts) {
|
|
|
401
673
|
var factorOk = false;
|
|
402
674
|
if (factorType === "totp") {
|
|
403
675
|
factorOk = _verifyTotpFactor(opts.factor).ok;
|
|
676
|
+
} else if (factorType === "passkey") {
|
|
677
|
+
factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
|
|
404
678
|
}
|
|
405
679
|
|
|
406
680
|
if (!factorOk) {
|
|
@@ -592,7 +866,11 @@ async function unsealRow(grantHandle, table, rowId) {
|
|
|
592
866
|
}
|
|
593
867
|
void updateRes;
|
|
594
868
|
|
|
595
|
-
// Fetch + unseal the target row through
|
|
869
|
+
// Fetch + unseal the target row. Model A goes straight through
|
|
870
|
+
// cryptoField; Model B reads the row, lets cryptoField unseal the
|
|
871
|
+
// non-glass-locked columns, and then decryptCell handles the
|
|
872
|
+
// glass-locked columns separately (their ciphertext was written
|
|
873
|
+
// by encryptCell at app-write time, not by cryptoField.sealRow).
|
|
596
874
|
var rows = await clusterStorage.executeAll(
|
|
597
875
|
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
598
876
|
[String(rowId)]
|
|
@@ -601,12 +879,43 @@ async function unsealRow(grantHandle, table, rowId) {
|
|
|
601
879
|
throw new BreakGlassError("breakglass/row-not-found",
|
|
602
880
|
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
603
881
|
}
|
|
604
|
-
var
|
|
882
|
+
var policy = await policyGet(table);
|
|
883
|
+
var unsealedRow;
|
|
884
|
+
if (policy && policy.cryptographic) {
|
|
885
|
+
// Snapshot the raw glass-locked column ciphertexts BEFORE
|
|
886
|
+
// cryptoField.unsealRow runs — cryptoField doesn't know about the
|
|
887
|
+
// bgcell: format and would no-op (or error) on it. Then unseal the
|
|
888
|
+
// rest of the row, then decrypt the glass-locked columns via
|
|
889
|
+
// decryptCell with encryption-context binding.
|
|
890
|
+
var rawCipher = {};
|
|
891
|
+
for (var c = 0; c < policy.columns.length; c++) {
|
|
892
|
+
rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
|
|
893
|
+
}
|
|
894
|
+
var rowMinusLocked = Object.assign({}, rows[0]);
|
|
895
|
+
for (var c2 = 0; c2 < policy.columns.length; c2++) {
|
|
896
|
+
delete rowMinusLocked[policy.columns[c2]];
|
|
897
|
+
}
|
|
898
|
+
unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
|
|
899
|
+
for (var c3 = 0; c3 < policy.columns.length; c3++) {
|
|
900
|
+
var col = policy.columns[c3];
|
|
901
|
+
if (rawCipher[col] == null) continue;
|
|
902
|
+
try {
|
|
903
|
+
unsealedRow[col] = await decryptCell(rawCipher[col],
|
|
904
|
+
{ table: table, rowId: String(rowId), column: col });
|
|
905
|
+
} catch (e) {
|
|
906
|
+
throw new BreakGlassError("breakglass/cell-decrypt-failed",
|
|
907
|
+
"unsealRow: cell decrypt failed for " + table + "[" + rowId +
|
|
908
|
+
"]." + col + " (was the row migrated to Model B?): " +
|
|
909
|
+
((e && e.message) || String(e)), true);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
unsealedRow = cryptoField.unsealRow(table, rows[0]);
|
|
914
|
+
}
|
|
605
915
|
|
|
606
916
|
// Per-row audit. The grant's reasonSealed is already cleartext after
|
|
607
917
|
// unsealRow on the grant; pass it into the audit row honoring the
|
|
608
918
|
// policy's auditReasonStorage mode.
|
|
609
|
-
var policy = await policyGet(table);
|
|
610
919
|
var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
|
|
611
920
|
policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
|
|
612
921
|
audit.safeEmit({
|
|
@@ -690,6 +999,220 @@ async function listActive(opts) {
|
|
|
690
999
|
return out;
|
|
691
1000
|
}
|
|
692
1001
|
|
|
1002
|
+
// ---- Service-account bypass ----
|
|
1003
|
+
//
|
|
1004
|
+
// Some legitimate workloads (nightly de-identification, scheduled
|
|
1005
|
+
// compliance reports) need PHI access without a human at the
|
|
1006
|
+
// keyboard. The framework refuses to silently bypass — operators
|
|
1007
|
+
// declare explicit service-account bypasses per-table with an apiKey
|
|
1008
|
+
// allowlist + required role. Both must match (verified apiKey id is
|
|
1009
|
+
// in the allowlist AND the apiKey carries the required role/scope)
|
|
1010
|
+
// before the bypass is granted. Each bypass emits its own distinct
|
|
1011
|
+
// audit row so post-incident review can distinguish operator-initiated
|
|
1012
|
+
// from service-initiated reads.
|
|
1013
|
+
|
|
1014
|
+
async function unsealRowAsService(req, table, rowId, opts) {
|
|
1015
|
+
_requireInit();
|
|
1016
|
+
opts = opts || {};
|
|
1017
|
+
if (!req || typeof req !== "object") {
|
|
1018
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
1019
|
+
"unsealRowAsService: req is required (with verified req.apiKey)");
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
1022
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
1023
|
+
"unsealRowAsService: table must be a non-empty string");
|
|
1024
|
+
}
|
|
1025
|
+
var policy = await policyGet(table);
|
|
1026
|
+
if (!policy) {
|
|
1027
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
1028
|
+
"unsealRowAsService: no break-glass policy for table '" + table + "'", true);
|
|
1029
|
+
}
|
|
1030
|
+
if (!policy.serviceAccountBypass) {
|
|
1031
|
+
throw new BreakGlassError("breakglass/bypass-not-configured",
|
|
1032
|
+
"unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
|
|
1033
|
+
}
|
|
1034
|
+
var apiKeyOnReq = req.apiKey;
|
|
1035
|
+
if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
|
|
1036
|
+
throw new BreakGlassError("breakglass/bypass-no-apikey",
|
|
1037
|
+
"unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
|
|
1038
|
+
}
|
|
1039
|
+
if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
|
|
1040
|
+
audit.safeEmit({
|
|
1041
|
+
action: "breakglass.grant.bypass",
|
|
1042
|
+
outcome: "denied",
|
|
1043
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1044
|
+
reason: "apikey-not-in-allowlist",
|
|
1045
|
+
metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
|
|
1046
|
+
});
|
|
1047
|
+
throw new BreakGlassError("breakglass/bypass-unauthorized",
|
|
1048
|
+
"unsealRowAsService: apiKey '" + apiKeyOnReq.id +
|
|
1049
|
+
"' is not in the bypass allowlist for '" + table + "'", true);
|
|
1050
|
+
}
|
|
1051
|
+
// Role check — actor must carry policy.serviceAccountBypass.requireRole
|
|
1052
|
+
// either as a direct scope or via b.permissions.check resolution.
|
|
1053
|
+
var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
|
|
1054
|
+
Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
|
|
1055
|
+
[];
|
|
1056
|
+
var requiredRole = policy.serviceAccountBypass.requireRole;
|
|
1057
|
+
var hasRole = actorScopes.indexOf(requiredRole) !== -1;
|
|
1058
|
+
if (!hasRole) {
|
|
1059
|
+
audit.safeEmit({
|
|
1060
|
+
action: "breakglass.grant.bypass",
|
|
1061
|
+
outcome: "denied",
|
|
1062
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1063
|
+
reason: "missing-role",
|
|
1064
|
+
metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
|
|
1065
|
+
});
|
|
1066
|
+
throw new BreakGlassError("breakglass/bypass-unauthorized",
|
|
1067
|
+
"unsealRowAsService: apiKey '" + apiKeyOnReq.id +
|
|
1068
|
+
"' lacks required role '" + requiredRole + "'", true);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Fetch + unseal the row (Model A or Model B path, same as
|
|
1072
|
+
// operator-initiated unsealRow).
|
|
1073
|
+
var rows = await clusterStorage.executeAll(
|
|
1074
|
+
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
1075
|
+
[String(rowId)]
|
|
1076
|
+
);
|
|
1077
|
+
if (!rows || rows.length === 0) {
|
|
1078
|
+
throw new BreakGlassError("breakglass/row-not-found",
|
|
1079
|
+
"unsealRowAsService: " + table + "[" + rowId + "] not found", true);
|
|
1080
|
+
}
|
|
1081
|
+
var unsealedRow;
|
|
1082
|
+
if (policy.cryptographic) {
|
|
1083
|
+
var rawCipher = {};
|
|
1084
|
+
for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
|
|
1085
|
+
var rowMinusLocked = Object.assign({}, rows[0]);
|
|
1086
|
+
for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
|
|
1087
|
+
unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
|
|
1088
|
+
for (var c3 = 0; c3 < policy.columns.length; c3++) {
|
|
1089
|
+
var col = policy.columns[c3];
|
|
1090
|
+
if (rawCipher[col] == null) continue;
|
|
1091
|
+
unsealedRow[col] = await decryptCell(rawCipher[col],
|
|
1092
|
+
{ table: table, rowId: String(rowId), column: col });
|
|
1093
|
+
}
|
|
1094
|
+
} else {
|
|
1095
|
+
unsealedRow = cryptoField.unsealRow(table, rows[0]);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
audit.safeEmit({
|
|
1099
|
+
action: "breakglass.grant.bypass",
|
|
1100
|
+
outcome: "success",
|
|
1101
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1102
|
+
reason: typeof opts.reason === "string" ? opts.reason : null,
|
|
1103
|
+
metadata: {
|
|
1104
|
+
table: table,
|
|
1105
|
+
rowId: String(rowId),
|
|
1106
|
+
apiKeyId: apiKeyOnReq.id,
|
|
1107
|
+
requiredRole: requiredRole,
|
|
1108
|
+
columns: policy.columns.slice(),
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
observability.event("breakglass.grant.bypass", { table: table });
|
|
1112
|
+
return unsealedRow;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- Admin tools ----
|
|
1116
|
+
//
|
|
1117
|
+
// `listActiveAll` returns every active grant (across all actors) —
|
|
1118
|
+
// for security-team dashboards and offboarding workflows.
|
|
1119
|
+
// `revokeAll` mass-revokes grants matching criteria — used by IR
|
|
1120
|
+
// teams when an account is suspected compromised. Both require
|
|
1121
|
+
// admin scope (operator wires via opts.requireScope or their own gate).
|
|
1122
|
+
|
|
1123
|
+
async function listActiveAll(opts) {
|
|
1124
|
+
_requireInit();
|
|
1125
|
+
opts = opts || {};
|
|
1126
|
+
var nowMs = Date.now();
|
|
1127
|
+
var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
|
|
1128
|
+
var params = [nowMs];
|
|
1129
|
+
if (opts.table) {
|
|
1130
|
+
clauses.push("scopeTable = ?");
|
|
1131
|
+
params.push(opts.table);
|
|
1132
|
+
}
|
|
1133
|
+
if (opts.since) {
|
|
1134
|
+
clauses.push("issuedAt >= ?");
|
|
1135
|
+
params.push(opts.since);
|
|
1136
|
+
}
|
|
1137
|
+
var rows = await clusterStorage.executeAll(
|
|
1138
|
+
"SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
|
|
1139
|
+
" ORDER BY issuedAt DESC",
|
|
1140
|
+
params
|
|
1141
|
+
);
|
|
1142
|
+
var out = [];
|
|
1143
|
+
for (var i = 0; i < (rows || []).length; i++) {
|
|
1144
|
+
var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
|
|
1145
|
+
out.push({
|
|
1146
|
+
id: u._id,
|
|
1147
|
+
issuedToActorId: u.issuedToActorId,
|
|
1148
|
+
scopeTable: u.scopeTable,
|
|
1149
|
+
scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
|
|
1150
|
+
factorType: u.factorType,
|
|
1151
|
+
issuedAt: Number(u.issuedAt),
|
|
1152
|
+
expiresAt: Number(u.expiresAt),
|
|
1153
|
+
rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
audit.safeEmit({
|
|
1157
|
+
action: "breakglass.admin.listactiveall",
|
|
1158
|
+
outcome: "success",
|
|
1159
|
+
actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
|
|
1160
|
+
metadata: { resultCount: out.length, filterTable: opts.table || null },
|
|
1161
|
+
});
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function revokeAll(criteria, opts) {
|
|
1166
|
+
_requireInit();
|
|
1167
|
+
if (!criteria || typeof criteria !== "object") {
|
|
1168
|
+
throw new BreakGlassError("breakglass/bad-revoke-criteria",
|
|
1169
|
+
"revokeAll: criteria is required ({ actorId?, table? })");
|
|
1170
|
+
}
|
|
1171
|
+
if (!criteria.actorId && !criteria.table) {
|
|
1172
|
+
throw new BreakGlassError("breakglass/bad-revoke-criteria",
|
|
1173
|
+
"revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
|
|
1174
|
+
}
|
|
1175
|
+
opts = opts || {};
|
|
1176
|
+
var clauses = ["revokedAt IS NULL"];
|
|
1177
|
+
var params = [];
|
|
1178
|
+
if (criteria.actorId) {
|
|
1179
|
+
var derived = cryptoField.computeDerived(
|
|
1180
|
+
"_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
|
|
1181
|
+
);
|
|
1182
|
+
if (derived) {
|
|
1183
|
+
clauses.push("issuedToActorHash = ?");
|
|
1184
|
+
params.push(derived.value);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (criteria.table) {
|
|
1188
|
+
clauses.push("scopeTable = ?");
|
|
1189
|
+
params.push(criteria.table);
|
|
1190
|
+
}
|
|
1191
|
+
// Snapshot the to-be-revoked grant ids first so audit captures specifics.
|
|
1192
|
+
var ids = await clusterStorage.executeAll(
|
|
1193
|
+
"SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
|
|
1194
|
+
params
|
|
1195
|
+
);
|
|
1196
|
+
var nowMs = Date.now();
|
|
1197
|
+
await clusterStorage.execute(
|
|
1198
|
+
"UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
|
|
1199
|
+
[nowMs].concat(params)
|
|
1200
|
+
);
|
|
1201
|
+
audit.safeEmit({
|
|
1202
|
+
action: "breakglass.admin.revokeall",
|
|
1203
|
+
outcome: "success",
|
|
1204
|
+
actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
|
|
1205
|
+
reason: typeof criteria.reason === "string" ? criteria.reason : null,
|
|
1206
|
+
metadata: {
|
|
1207
|
+
filterActorId: criteria.actorId || null,
|
|
1208
|
+
filterTable: criteria.table || null,
|
|
1209
|
+
revokedCount: (ids || []).length,
|
|
1210
|
+
revokedIds: (ids || []).map(function (r) { return r._id; }),
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
return { revokedCount: (ids || []).length };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
693
1216
|
// ---- Sweep (best-effort cleanup of expired grants) ----
|
|
694
1217
|
|
|
695
1218
|
async function _sweepExpired(opts) {
|
|
@@ -731,6 +1254,20 @@ module.exports = {
|
|
|
731
1254
|
unsealRow: unsealRow,
|
|
732
1255
|
revoke: revoke,
|
|
733
1256
|
listActive: listActive,
|
|
1257
|
+
// Cryptographic mode (Model B) — per-cell encryption with context
|
|
1258
|
+
// binding. Operators in cryptographic mode call encryptCell at write
|
|
1259
|
+
// time; unsealRow auto-routes to decryptCell for glass-locked columns.
|
|
1260
|
+
encryptCell: encryptCell,
|
|
1261
|
+
decryptCell: decryptCell,
|
|
1262
|
+
migrate: migrate,
|
|
1263
|
+
// Service-account bypass — operator-declared per-table, gated by
|
|
1264
|
+
// (apiKey id in allowlist) AND (apiKey carries required role).
|
|
1265
|
+
unsealRowAsService: unsealRowAsService,
|
|
1266
|
+
// Admin tools — for security-team dashboards (listActiveAll) and
|
|
1267
|
+
// incident-response offboarding (revokeAll). Operators wire their
|
|
1268
|
+
// own gate (requireScope or middleware) on the calling routes.
|
|
1269
|
+
listActiveAll: listActiveAll,
|
|
1270
|
+
revokeAll: revokeAll,
|
|
734
1271
|
BreakGlassError: BreakGlassError,
|
|
735
1272
|
|
|
736
1273
|
// Test-only / sweep — operators with active grant volume wire this
|
package/lib/crypto.js
CHANGED
|
@@ -206,9 +206,16 @@ function decryptEnvelope(packed, privateKeys) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// ---- Symmetric buffer encrypt/decrypt (for storage) ----
|
|
209
|
-
|
|
209
|
+
//
|
|
210
|
+
// Optional `aad` (additional authenticated data) is mixed into the
|
|
211
|
+
// Poly1305 tag — encrypt-time and decrypt-time AAD must match exactly
|
|
212
|
+
// or decrypt fails. Used by primitives that want encryption-context
|
|
213
|
+
// binding (b.breakGlass.encryptCell binds (table, rowId, column) so a
|
|
214
|
+
// ciphertext from row A literally cannot decrypt as row B even with
|
|
215
|
+
// the same key).
|
|
216
|
+
function encryptPacked(buffer, key, aad) {
|
|
210
217
|
var nonce = random(24);
|
|
211
|
-
var ct = xchacha20poly1305(key, nonce).encrypt(buffer);
|
|
218
|
+
var ct = xchacha20poly1305(key, nonce, aad ? Buffer.from(aad) : undefined).encrypt(buffer);
|
|
212
219
|
return Buffer.concat([
|
|
213
220
|
Buffer.from([C.FORMAT.XCHACHA20_POLY1305]),
|
|
214
221
|
Buffer.from(nonce),
|
|
@@ -216,12 +223,13 @@ function encryptPacked(buffer, key) {
|
|
|
216
223
|
]);
|
|
217
224
|
}
|
|
218
225
|
|
|
219
|
-
function decryptPacked(packed, key) {
|
|
226
|
+
function decryptPacked(packed, key, aad) {
|
|
220
227
|
if (packed[0] !== C.FORMAT.XCHACHA20_POLY1305) {
|
|
221
228
|
throw new Error("Invalid packed format: unsupported version");
|
|
222
229
|
}
|
|
223
230
|
return Buffer.from(
|
|
224
|
-
xchacha20poly1305(key, packed.subarray(1, 25)
|
|
231
|
+
xchacha20poly1305(key, packed.subarray(1, 25), aad ? Buffer.from(aad) : undefined)
|
|
232
|
+
.decrypt(packed.subarray(25))
|
|
225
233
|
);
|
|
226
234
|
}
|
|
227
235
|
|
package/lib/db.js
CHANGED
|
@@ -446,6 +446,7 @@ var FRAMEWORK_SCHEMA = [
|
|
|
446
446
|
onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
|
|
447
447
|
requireScope: "TEXT",
|
|
448
448
|
serviceAccountBypassJson: "TEXT",
|
|
449
|
+
dekSealed: "TEXT",
|
|
449
450
|
auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
|
|
450
451
|
updatedAt: "INTEGER NOT NULL",
|
|
451
452
|
},
|
package/lib/framework-schema.js
CHANGED
|
@@ -584,6 +584,7 @@ function _breakGlassPoliciesDDL(dialect) {
|
|
|
584
584
|
" onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
|
|
585
585
|
" requireScope TEXT," +
|
|
586
586
|
" serviceAccountBypassJson TEXT," +
|
|
587
|
+
" dekSealed TEXT," +
|
|
587
588
|
" auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
|
|
588
589
|
" updatedAt " + t.INT + " NOT NULL" +
|
|
589
590
|
")",
|