@blamejs/core 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/audit.js +1 -1
- package/lib/break-glass.js +261 -8
- package/lib/crypto.js +12 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,10 @@ 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.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
|
|
12
|
+
|
|
9
13
|
## v0.4.x
|
|
10
14
|
|
|
11
15
|
- **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,6 +51,8 @@ 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"); });
|
|
55
57
|
|
|
56
58
|
// Errors — all 14 codes documented in the spec. `permanent: true`
|
|
@@ -97,6 +99,208 @@ function _ensureFactorLockout() {
|
|
|
97
99
|
return _factorLockout;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
// ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
|
|
103
|
+
//
|
|
104
|
+
// Each policy in cryptographic mode has a per-policy DEK (data
|
|
105
|
+
// encryption key) generated at first use. The DEK is vault-sealed so
|
|
106
|
+
// it survives restarts. At cell encrypt time, the framework derives a
|
|
107
|
+
// per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
|
|
108
|
+
// every (table, rowId, column) triple gets a unique key. Encryption
|
|
109
|
+
// uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
|
|
110
|
+
// — the AEAD tag itself is bound to the encryption context, so a
|
|
111
|
+
// ciphertext from row A literally cannot be decrypted as row B even
|
|
112
|
+
// with the same DEK.
|
|
113
|
+
//
|
|
114
|
+
// THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
|
|
115
|
+
// keys + encryption-context binding (cross-cell tampering / accidental
|
|
116
|
+
// row-swap fails closed). It does NOT defend against vault-key
|
|
117
|
+
// compromise alone — the DEK is still vault-recoverable. True
|
|
118
|
+
// second-factor cryptographic gating ships in v0.5.2 with passkey
|
|
119
|
+
// integration (the passkey private key lives on the YubiKey, not in
|
|
120
|
+
// the framework, so a vault leak alone can't unwrap).
|
|
121
|
+
|
|
122
|
+
// In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
|
|
123
|
+
var dekCache = new Map();
|
|
124
|
+
|
|
125
|
+
function _aadFor(table, rowId, column) {
|
|
126
|
+
return sha3Hash(table + "|" + String(rowId) + "|" + column);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _kCell(dek, table, rowId, column) {
|
|
130
|
+
return kdf(Buffer.concat([
|
|
131
|
+
Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
|
|
132
|
+
Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
|
|
133
|
+
]), 32);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function _ensureDek(table) {
|
|
137
|
+
if (dekCache.has(table)) return dekCache.get(table);
|
|
138
|
+
// The DEK lives in the policy row's serviceAccountBypassJson slot
|
|
139
|
+
// (repurposed in Model A; v0.5.0 explicitly rejected the slot, so
|
|
140
|
+
// it's unused in Model A rows). For Model B rows, we vault-seal the
|
|
141
|
+
// DEK and store it there. Pre-v1 schema: clean and simple.
|
|
142
|
+
var rows = await clusterStorage.executeAll(
|
|
143
|
+
"SELECT serviceAccountBypassJson 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].serviceAccountBypassJson;
|
|
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 serviceAccountBypassJson = ? 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) {
|
|
@@ -193,6 +404,7 @@ function _validatePolicySet(table, opts) {
|
|
|
193
404
|
"policy.set: serviceAccountBypass ships in v0.5.2 — leave unset for v0.5.0");
|
|
194
405
|
}
|
|
195
406
|
return {
|
|
407
|
+
cryptographic: opts.cryptographic === true,
|
|
196
408
|
grantTtl: grantTtl,
|
|
197
409
|
maxRowsPerGrant: maxRows,
|
|
198
410
|
reasonRequired: opts.reasonRequired !== false,
|
|
@@ -212,7 +424,7 @@ async function policySet(table, opts, callerOpts) {
|
|
|
212
424
|
tableName: table,
|
|
213
425
|
columnsJson: JSON.stringify(opts.columns),
|
|
214
426
|
factorsJson: JSON.stringify(opts.factors),
|
|
215
|
-
cryptographic: 0,
|
|
427
|
+
cryptographic: validated.cryptographic ? 1 : 0,
|
|
216
428
|
grantTtlMs: validated.grantTtl,
|
|
217
429
|
maxRowsPerGrant: validated.maxRowsPerGrant,
|
|
218
430
|
reasonRequired: validated.reasonRequired ? 1 : 0,
|
|
@@ -592,7 +804,11 @@ async function unsealRow(grantHandle, table, rowId) {
|
|
|
592
804
|
}
|
|
593
805
|
void updateRes;
|
|
594
806
|
|
|
595
|
-
// Fetch + unseal the target row through
|
|
807
|
+
// Fetch + unseal the target row. Model A goes straight through
|
|
808
|
+
// cryptoField; Model B reads the row, lets cryptoField unseal the
|
|
809
|
+
// non-glass-locked columns, and then decryptCell handles the
|
|
810
|
+
// glass-locked columns separately (their ciphertext was written
|
|
811
|
+
// by encryptCell at app-write time, not by cryptoField.sealRow).
|
|
596
812
|
var rows = await clusterStorage.executeAll(
|
|
597
813
|
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
598
814
|
[String(rowId)]
|
|
@@ -601,12 +817,43 @@ async function unsealRow(grantHandle, table, rowId) {
|
|
|
601
817
|
throw new BreakGlassError("breakglass/row-not-found",
|
|
602
818
|
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
603
819
|
}
|
|
604
|
-
var
|
|
820
|
+
var policy = await policyGet(table);
|
|
821
|
+
var unsealedRow;
|
|
822
|
+
if (policy && policy.cryptographic) {
|
|
823
|
+
// Snapshot the raw glass-locked column ciphertexts BEFORE
|
|
824
|
+
// cryptoField.unsealRow runs — cryptoField doesn't know about the
|
|
825
|
+
// bgcell: format and would no-op (or error) on it. Then unseal the
|
|
826
|
+
// rest of the row, then decrypt the glass-locked columns via
|
|
827
|
+
// decryptCell with encryption-context binding.
|
|
828
|
+
var rawCipher = {};
|
|
829
|
+
for (var c = 0; c < policy.columns.length; c++) {
|
|
830
|
+
rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
|
|
831
|
+
}
|
|
832
|
+
var rowMinusLocked = Object.assign({}, rows[0]);
|
|
833
|
+
for (var c2 = 0; c2 < policy.columns.length; c2++) {
|
|
834
|
+
delete rowMinusLocked[policy.columns[c2]];
|
|
835
|
+
}
|
|
836
|
+
unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
|
|
837
|
+
for (var c3 = 0; c3 < policy.columns.length; c3++) {
|
|
838
|
+
var col = policy.columns[c3];
|
|
839
|
+
if (rawCipher[col] == null) continue;
|
|
840
|
+
try {
|
|
841
|
+
unsealedRow[col] = await decryptCell(rawCipher[col],
|
|
842
|
+
{ table: table, rowId: String(rowId), column: col });
|
|
843
|
+
} catch (e) {
|
|
844
|
+
throw new BreakGlassError("breakglass/cell-decrypt-failed",
|
|
845
|
+
"unsealRow: cell decrypt failed for " + table + "[" + rowId +
|
|
846
|
+
"]." + col + " (was the row migrated to Model B?): " +
|
|
847
|
+
((e && e.message) || String(e)), true);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
unsealedRow = cryptoField.unsealRow(table, rows[0]);
|
|
852
|
+
}
|
|
605
853
|
|
|
606
854
|
// Per-row audit. The grant's reasonSealed is already cleartext after
|
|
607
855
|
// unsealRow on the grant; pass it into the audit row honoring the
|
|
608
856
|
// policy's auditReasonStorage mode.
|
|
609
|
-
var policy = await policyGet(table);
|
|
610
857
|
var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
|
|
611
858
|
policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
|
|
612
859
|
audit.safeEmit({
|
|
@@ -731,6 +978,12 @@ module.exports = {
|
|
|
731
978
|
unsealRow: unsealRow,
|
|
732
979
|
revoke: revoke,
|
|
733
980
|
listActive: listActive,
|
|
981
|
+
// Cryptographic mode (Model B) — per-cell encryption with context
|
|
982
|
+
// binding. Operators in cryptographic mode call encryptCell at write
|
|
983
|
+
// time; unsealRow auto-routes to decryptCell for glass-locked columns.
|
|
984
|
+
encryptCell: encryptCell,
|
|
985
|
+
decryptCell: decryptCell,
|
|
986
|
+
migrate: migrate,
|
|
734
987
|
BreakGlassError: BreakGlassError,
|
|
735
988
|
|
|
736
989
|
// 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
|
|