@onlineapps/conn-infra-secrets 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/crypto.js CHANGED
@@ -1,56 +1,531 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Minimal AES-256-GCM open() for the secrets connector.
4
+ * AES-256-GCM sealing contract of the SecretBox — this module owns it.
5
5
  *
6
- * Deliberately a small, self-contained copy of the sealing contract owned by
7
- * api_secrets (`src/lib/crypto.js`) — the two packages cannot import each other.
8
- * Blob layout MUST stay identical: base64( iv[12] || authTag[16] || ciphertext ).
9
- * See api/docs/architecture/secretbox.md §4.
6
+ * Both directions live here: `seal()`/`encrypt()` for the writer (biz-meta) and
7
+ * `open()` for every reader (`ctx.secrets.get(ref)`). One owner means seal→open
8
+ * is exercised across the real boundary in this package's tests instead of each
9
+ * side testing against its own reimplementation of the other.
10
+ *
11
+ * ## The blob names its key and its row
12
+ *
13
+ * format[1]=0x02 | keyIdLen[1] | key_id | version[4 BE] | iv[12] | tag[16] | ciphertext
14
+ * AAD = utf8("<tenant_id>|<workspace_id>|<ref>|<version>")
15
+ *
16
+ * Two owner decisions meet in that layout:
17
+ *
18
+ * - `secretbox-blob-format` 001 (2026-09-03) — the blob carries `key_id`, the
19
+ * bound context is `tenant_id | workspace_id | ref | version`, and no reader of
20
+ * the previous format survives the migration: a blob without the format byte
21
+ * is refused, never guessed at.
22
+ * - `secretbox-master-keys` 001 (2026-09-14) — the library holds an ORDERED LIST
23
+ * of master keys (`SECRETS_MASTER_KEYS`). The first key seals every new write;
24
+ * every key in the list opens what it sealed, selected by the blob's `key_id`.
25
+ * A blob whose `key_id` is not in the list is REFUSED. There is deliberately no
26
+ * "try every key" pass: it would be the silent fallback §3 of
27
+ * architecture-principles.md forbids, and it would leave the readability check
28
+ * (src/readability.js) with nothing to measure — a key could be dropped from the
29
+ * list and rows would still open by accident, which is how the August 2026
30
+ * rotation lost six rows of `oagen_meta.secret` for two weeks.
31
+ *
32
+ * ### Why the version travels inside the blob
33
+ *
34
+ * The AAD binds the row's `version`, and the two readers know it from different
35
+ * places. A tool reading the authoritative store has the `version` column and
36
+ * passes it: `open()` compares it with the blob's own and refuses a mismatch, so
37
+ * renumbering a version is a re-encryption (the cost accepted in `secretbox-blob-
38
+ * format` 001). The Redis projection key is
39
+ * `state:meta:secret:<tenant>:<workspace>:<ref>` — no version in it — so the
40
+ * projection reader has no independent version to pass and uses
41
+ * `openWithDeclaredVersion()`, which takes it from the blob. Two named entry
42
+ * points rather than one optional argument: each says out loud where its version
43
+ * came from, and therefore what it does and does not prove.
44
+ *
45
+ * Keys are always injected, never read from `process.env` here: principle 1 (DI)
46
+ * and the connector duty L-CONNECTOR-ENV. `MASTER_KEYS_ENV` below is the ONE
47
+ * declaration of the variable's name and format; consumers read the variable and
48
+ * hand the string to `loadMasterKeys()`.
10
49
  */
11
50
 
12
51
  const crypto = require('crypto');
52
+ const { normalizeWorkspaceId } = require('./workspaceId');
13
53
 
14
54
  const ALGORITHM = 'aes-256-gcm';
15
55
  const KEY_BYTES = 32;
16
56
  const IV_BYTES = 12;
17
57
  const AUTH_TAG_BYTES = 16;
18
58
 
19
- function loadMasterKey(masterKeyBase64) {
20
- if (typeof masterKeyBase64 !== 'string' || masterKeyBase64.length === 0) {
21
- throw new Error(
22
- '[conn-infra-secrets] Missing master key - base64-encoded 32-byte key required. ' +
23
- 'Fix: set SECRETS_MASTER_KEY (or pass explicit key).'
59
+ /** Blob format marker. 1 was the pre-`key_id` layout and is not read (see above). */
60
+ const BLOB_FORMAT = 2;
61
+
62
+ /** The ONE declaration of the configuration key that carries the list. */
63
+ const MASTER_KEYS_ENV = 'SECRETS_MASTER_KEYS';
64
+
65
+ /**
66
+ * `SECRETS_MASTER_KEYS=<key_id>:<base64 32-byte key>[,<key_id>:<key>…]`
67
+ *
68
+ * Neither separator can occur inside a base64 key, so the spec parses without
69
+ * escaping; a `key_id` is restricted to the same alphabet for the same reason,
70
+ * and because it is written into every blob.
71
+ */
72
+ const ENTRY_SEPARATOR = ',';
73
+ const ID_SEPARATOR = ':';
74
+ const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
75
+
76
+ /** The AAD's field separator — refused inside a `ref` so the context is unambiguous. */
77
+ const CONTEXT_SEPARATOR = '|';
78
+
79
+ const SPEC_SHAPE = `${MASTER_KEYS_ENV}="<key_id>:<base64 32-byte key>[,<key_id>:<key>]" `
80
+ + '(the FIRST entry is the active one, the one every new write is sealed with)';
81
+
82
+ /**
83
+ * Every refusal this module raises carries a machine code, so a caller can react
84
+ * to "this key is not in my list" differently from "this blob is corrupt".
85
+ */
86
+ class SecretCryptoError extends Error {
87
+ constructor(code, message, options) {
88
+ super(message, options);
89
+ this.name = 'SecretCryptoError';
90
+ this.code = code;
91
+ }
92
+ }
93
+
94
+ /** An ordered, immutable list of master keys. First seals, all open. */
95
+ class MasterKeyring {
96
+ /**
97
+ * @param {Array<{ keyId: string, key: Buffer }>} entries ordered, already validated
98
+ */
99
+ constructor(entries) {
100
+ if (!Array.isArray(entries) || entries.length === 0) {
101
+ throw new SecretCryptoError(
102
+ 'SECRET_KEYS_INVALID',
103
+ `[conn-infra-secrets] Empty master key list - at least one key is required, because the first entry is `
104
+ + `the key every new write is sealed with. Fix: build the keyring with loadMasterKeys(${MASTER_KEYS_ENV}).`
105
+ );
106
+ }
107
+ this._entries = entries.map(({ keyId, key }) => Object.freeze({ keyId, key }));
108
+ this._byId = new Map(this._entries.map((e) => [e.keyId, e.key]));
109
+ Object.freeze(this._entries);
110
+ Object.freeze(this);
111
+ }
112
+
113
+ /** The key every new write is sealed with — the first in the declared order. */
114
+ get activeKeyId() { return this._entries[0].keyId; }
115
+
116
+ /** A copy: the list a keyring holds cannot be widened after it is loaded. */
117
+ get keyIds() { return this._entries.map((e) => e.keyId); }
118
+
119
+ get size() { return this._entries.length; }
120
+
121
+ has(keyId) { return this._byId.has(keyId); }
122
+
123
+ activeKey() { return this._entries[0].key; }
124
+
125
+ /**
126
+ * @param {string} keyId as written in the blob
127
+ * @returns {Buffer} the 32 raw bytes
128
+ * @throws {SecretCryptoError} SECRET_KEY_UNKNOWN — never a "try them all" pass
129
+ */
130
+ keyFor(keyId) {
131
+ const key = this._byId.get(keyId);
132
+ if (key === undefined) {
133
+ throw new SecretCryptoError(
134
+ 'SECRET_KEY_UNKNOWN',
135
+ `[conn-infra-secrets] Unknown master key id "${keyId}" - the blob was sealed with a key that is not in `
136
+ + `${MASTER_KEYS_ENV}, which holds ${this.keyIds.join(', ')}. `
137
+ + `Fix: put that key back into ${MASTER_KEYS_ENV} and re-encrypt the rows still on it `
138
+ + '(reencryptSecrets), or rewrite the value from its source. A key is only ever removed from '
139
+ + 'the list after checkReadability() reports zero unreadable rows without it.'
140
+ );
141
+ }
142
+ return key;
143
+ }
144
+
145
+ /**
146
+ * The same list without one key — what a key removal is checked against.
147
+ * @param {string} keyId
148
+ * @returns {MasterKeyring}
149
+ */
150
+ without(keyId) {
151
+ if (!this.has(keyId)) {
152
+ throw new SecretCryptoError(
153
+ 'SECRET_KEY_UNKNOWN',
154
+ `[conn-infra-secrets] Cannot drop key id "${keyId}" - it is not in the list, which holds `
155
+ + `${this.keyIds.join(', ')}. Fix: name one of those, or leave the list as it is.`
156
+ );
157
+ }
158
+ if (keyId === this.activeKeyId) {
159
+ throw new SecretCryptoError(
160
+ 'SECRET_KEYS_INVALID',
161
+ `[conn-infra-secrets] Cannot drop the active key "${keyId}" - it is the first entry, the key every new `
162
+ + 'write is sealed with, so a list without it could not write at all. '
163
+ + `Fix: put the incoming key first in ${MASTER_KEYS_ENV}, re-encrypt, and only then drop the `
164
+ + 'outgoing one.'
165
+ );
166
+ }
167
+ return new MasterKeyring(this._entries.filter((e) => e.keyId !== keyId).map((e) => ({ ...e })));
168
+ }
169
+ }
170
+
171
+ function refuseSpec(problem, detail) {
172
+ throw new SecretCryptoError(
173
+ 'SECRET_KEYS_INVALID',
174
+ `[conn-infra-secrets] Invalid ${MASTER_KEYS_ENV} - ${problem}. Expected ${SPEC_SHAPE}. Fix: ${detail}`
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Parse the declared list into a keyring. No key material ever reaches a message.
180
+ *
181
+ * @param {string} spec the value of SECRETS_MASTER_KEYS
182
+ * @returns {MasterKeyring}
183
+ * @throws {SecretCryptoError} SECRET_KEYS_INVALID
184
+ */
185
+ function loadMasterKeys(spec) {
186
+ if (typeof spec !== 'string' || spec.trim() === '') {
187
+ refuseSpec(
188
+ `the value is ${spec === null ? 'null' : typeof spec === 'string' ? 'blank' : typeof spec}`,
189
+ `set ${MASTER_KEYS_ENV} in env-active/*.env, or pass the string explicitly.`
190
+ );
191
+ }
192
+
193
+ const entries = [];
194
+ const seen = new Set();
195
+
196
+ for (const rawEntry of spec.split(ENTRY_SEPARATOR)) {
197
+ const entry = rawEntry.trim();
198
+ if (entry === '') {
199
+ refuseSpec('an entry is empty', `remove the stray "${ENTRY_SEPARATOR}" from the list.`);
200
+ }
201
+ const at = entry.indexOf(ID_SEPARATOR);
202
+ if (at < 0) {
203
+ refuseSpec(
204
+ 'an entry carries no key id',
205
+ `write every entry as <key_id>${ID_SEPARATOR}<base64 key>; the id is what the blob records, `
206
+ + 'so a key without one cannot be selected on read.'
207
+ );
208
+ }
209
+ const keyId = entry.slice(0, at).trim();
210
+ const material = entry.slice(at + 1).trim();
211
+
212
+ if (!KEY_ID_PATTERN.test(keyId)) {
213
+ refuseSpec(
214
+ `the key id "${keyId}" is not usable`,
215
+ 'use 1-64 characters of [A-Za-z0-9._-] starting with a letter or digit; the id is written into '
216
+ + 'every blob, so it has to be stable and printable.'
217
+ );
218
+ }
219
+ if (seen.has(keyId)) {
220
+ refuseSpec(
221
+ `the key id "${keyId}" appears twice`,
222
+ 'give every key its own id - two keys under one id cannot both be selected, and the blobs would '
223
+ + 'name a key nobody can identify.'
224
+ );
225
+ }
226
+ if (material === '') {
227
+ refuseSpec(`the key id "${keyId}" has no key`, 'write the base64-encoded 32-byte key after the colon.');
228
+ }
229
+ const key = Buffer.from(material, 'base64');
230
+ if (key.length !== KEY_BYTES) {
231
+ refuseSpec(
232
+ `the key "${keyId}" decodes to ${key.length} bytes, expected ${KEY_BYTES}`,
233
+ 'generate it with `openssl rand -base64 32`.'
234
+ );
235
+ }
236
+
237
+ seen.add(keyId);
238
+ entries.push({ keyId, key });
239
+ }
240
+
241
+ return new MasterKeyring(entries);
242
+ }
243
+
244
+ function assertKeyring(keyring) {
245
+ if (!(keyring instanceof MasterKeyring)) {
246
+ throw new SecretCryptoError(
247
+ 'SECRET_KEYS_INVALID',
248
+ `[conn-infra-secrets] Missing keyring - a MasterKeyring is required, got `
249
+ + `${keyring === null ? 'null' : typeof keyring}. `
250
+ + `Fix: pass loadMasterKeys(${MASTER_KEYS_ENV}); a bare key Buffer is not accepted, because the `
251
+ + 'blob has to record WHICH key sealed it.'
252
+ );
253
+ }
254
+ }
255
+
256
+ function refuseContext(problem, detail) {
257
+ throw new SecretCryptoError(
258
+ 'SECRET_CONTEXT_INVALID',
259
+ `[conn-infra-secrets] Invalid bound context - ${problem}. Expected { tenant_id, workspace_id, ref, version } `
260
+ + 'naming the ONE row this value belongs to (owner decision 2026-09-03, '
261
+ + `api/docs/governance/confirmations/secretbox-blob-format.md 001). Fix: ${detail}`
262
+ );
263
+ }
264
+
265
+ /**
266
+ * The bound context (AAD), in one place: `tenant | workspace | ref | version`.
267
+ *
268
+ * Normalizing here is what keeps writer and reader on one string: a workspace id
269
+ * arrives as a number from one driver and as its decimal string from another, and
270
+ * two spellings would seal a blob nobody can open.
271
+ *
272
+ * @param {{ tenant_id: number|string, workspace_id: number|string, ref: string, version: number|string }} context
273
+ * @returns {string}
274
+ */
275
+ function bindingContext(context) {
276
+ if (context === null || typeof context !== 'object') {
277
+ refuseContext(
278
+ `got ${context === null ? 'null' : typeof context}`,
279
+ 'pass the four fields of the row the secret belongs to.'
280
+ );
281
+ }
282
+ const { tenant_id: tenantId, workspace_id: workspaceId, ref, version } = context;
283
+
284
+ if (tenantId === undefined || tenantId === null || String(tenantId).trim() === '') {
285
+ refuseContext('tenant_id is missing', 'pass scope.tenant_id from the operation context.');
286
+ }
287
+ const workspace = normalizeWorkspaceId(workspaceId);
288
+ if (workspace === null) {
289
+ refuseContext(
290
+ `workspace_id "${workspaceId}" is not a positive integer`,
291
+ 'pass the concrete workspace_id; the tenant-wide scope was retired by owner decision 2026-09-03 '
292
+ + '(api/docs/governance/confirmations/secretbox-tenant-wide-scope.md 001).'
293
+ );
294
+ }
295
+ if (typeof ref !== 'string' || ref.trim() === '') {
296
+ refuseContext('ref is missing or blank', 'pass the secret ref, not the secret value.');
297
+ }
298
+ if (ref.includes(CONTEXT_SEPARATOR)) {
299
+ refuseContext(
300
+ `the ref "${ref}" contains "${CONTEXT_SEPARATOR}"`,
301
+ `rename the ref: "${CONTEXT_SEPARATOR}" separates the four fields of the bound context, so a ref `
302
+ + 'carrying one could bind two different rows to the same context.'
303
+ );
304
+ }
305
+ const versionNumber = Number(version);
306
+ if (typeof version === 'boolean' || version === null || version === undefined
307
+ || String(version).trim() === '' || !Number.isInteger(versionNumber) || versionNumber < 1) {
308
+ refuseContext(
309
+ `version "${version}" is not a positive integer`,
310
+ 'pass the row\'s version column; it is part of the bound context, so renumbering a version is a '
311
+ + 're-encryption (the cost accepted in secretbox-blob-format 001).'
312
+ );
313
+ }
314
+
315
+ return [String(tenantId).trim(), String(workspace), ref, String(versionNumber)].join(CONTEXT_SEPARATOR);
316
+ }
317
+
318
+ /** The version a context names, normalized — the blob header stores it as uint32. */
319
+ function contextVersion(context) {
320
+ return Number(bindingContext(context).split(CONTEXT_SEPARATOR).pop());
321
+ }
322
+
323
+ /**
324
+ * Encrypt to the parts the authoritative store keeps in its own columns.
325
+ *
326
+ * @param {string} plaintext
327
+ * @param {MasterKeyring} keyring first entry seals
328
+ * @param {Object} context the bound context, see bindingContext()
329
+ * @returns {{ keyId: string, version: number, iv: Buffer, authTag: Buffer, ciphertext: Buffer }}
330
+ */
331
+ function encrypt(plaintext, keyring, context) {
332
+ if (typeof plaintext !== 'string') {
333
+ throw new SecretCryptoError(
334
+ 'SECRET_PLAINTEXT_INVALID',
335
+ `[conn-infra-secrets] seal requires a string plaintext - Expected the secret value as a string, got `
336
+ + `${plaintext === null ? 'null' : typeof plaintext}. Fix: stringify the value before sealing it.`
24
337
  );
25
338
  }
26
- const key = Buffer.from(masterKeyBase64, 'base64');
27
- if (key.length !== KEY_BYTES) {
28
- throw new Error(
29
- `[conn-infra-secrets] Invalid master key length - decoded ${key.length} bytes, expected ${KEY_BYTES}.`
339
+ assertKeyring(keyring);
340
+ const aad = Buffer.from(bindingContext(context), 'utf8');
341
+ const version = contextVersion(context);
342
+ const iv = crypto.randomBytes(IV_BYTES);
343
+ const cipher = crypto.createCipheriv(ALGORITHM, keyring.activeKey(), iv, { authTagLength: AUTH_TAG_BYTES });
344
+ cipher.setAAD(aad);
345
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
346
+ return { keyId: keyring.activeKeyId, version, iv, authTag: cipher.getAuthTag(), ciphertext };
347
+ }
348
+
349
+ /**
350
+ * Concatenate the parts into the wire blob (Redis projection).
351
+ * @param {{ keyId: string, version: number, iv: Buffer, authTag: Buffer, ciphertext: Buffer }} parts
352
+ * @returns {string} base64
353
+ */
354
+ function sealBlob({ keyId, version, iv, authTag, ciphertext }) {
355
+ if (!KEY_ID_PATTERN.test(String(keyId))) {
356
+ throw new SecretCryptoError(
357
+ 'SECRET_BLOB_INVALID',
358
+ `[conn-infra-secrets] Cannot seal a blob under key id "${keyId}" - Expected 1-64 characters of `
359
+ + '[A-Za-z0-9._-]. Fix: seal with a keyring from loadMasterKeys(), which refuses any other id.'
360
+ );
361
+ }
362
+ if (!Number.isInteger(version) || version < 1 || version > 0xffffffff) {
363
+ throw new SecretCryptoError(
364
+ 'SECRET_BLOB_INVALID',
365
+ `[conn-infra-secrets] Cannot seal a blob for version "${version}" - Expected a positive 32-bit integer, `
366
+ + 'the row version the value belongs to. Fix: pass the parts encrypt() returned, unchanged.'
30
367
  );
31
368
  }
32
- return key;
369
+ const id = Buffer.from(String(keyId), 'utf8');
370
+ const versionBytes = Buffer.alloc(4);
371
+ versionBytes.writeUInt32BE(version);
372
+ return Buffer.concat([
373
+ Buffer.from([BLOB_FORMAT]), Buffer.from([id.length]), id, versionBytes, iv, authTag, ciphertext
374
+ ]).toString('base64');
33
375
  }
34
376
 
35
377
  /**
378
+ * Encrypt straight to the wire blob (`encrypt` + `sealBlob`).
379
+ * @returns {string} base64
380
+ */
381
+ function seal(plaintext, keyring, context) {
382
+ return sealBlob(encrypt(plaintext, keyring, context));
383
+ }
384
+
385
+ function refuseBlob(problem, detail) {
386
+ throw new SecretCryptoError(
387
+ 'SECRET_BLOB_INVALID',
388
+ `[conn-infra-secrets] Unreadable sealed blob - ${problem}. Fix: ${detail}`
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Split a wire blob into its parts WITHOUT decrypting it — this is what tells a
394
+ * caller which key it needs before it needs it.
395
+ *
36
396
  * @param {string} blobBase64
37
- * @param {Buffer} key
38
- * @returns {string} plaintext
397
+ * @returns {{ keyId: string, version: number, iv: Buffer, authTag: Buffer, ciphertext: Buffer }}
398
+ * @throws {SecretCryptoError} SECRET_BLOB_INVALID
39
399
  */
40
- function open(blobBase64, key) {
400
+ function parseBlob(blobBase64) {
41
401
  if (typeof blobBase64 !== 'string' || blobBase64.length === 0) {
42
- throw new Error('[conn-infra-secrets] open requires a non-empty base64 blob.');
402
+ throw new SecretCryptoError(
403
+ 'SECRET_BLOB_INVALID',
404
+ `[conn-infra-secrets] open requires a non-empty base64 blob - Expected the sealed value read from the `
405
+ + 'SecretBox. Fix: check that the row or projection key exists and holds the sealed blob.'
406
+ );
43
407
  }
44
- const blob = Buffer.from(blobBase64, 'base64');
45
- if (blob.length < IV_BYTES + AUTH_TAG_BYTES) {
46
- throw new Error('[conn-infra-secrets] Blob too short - not a valid sealed secret.');
408
+ const raw = Buffer.from(blobBase64, 'base64');
409
+ if (raw.length < 2) {
410
+ refuseBlob(`the blob is ${raw.length} byte(s) long`, 'it is truncated; re-read it from the store.');
47
411
  }
48
- const iv = blob.subarray(0, IV_BYTES);
49
- const authTag = blob.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
50
- const ciphertext = blob.subarray(IV_BYTES + AUTH_TAG_BYTES);
51
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
52
- decipher.setAuthTag(authTag);
53
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
412
+ if (raw[0] !== BLOB_FORMAT) {
413
+ refuseBlob(
414
+ `the blob opens with format byte 0x${raw[0].toString(16).padStart(2, '0')}, expected `
415
+ + `0x${BLOB_FORMAT.toString(16).padStart(2, '0')}`,
416
+ 'this is not a SecretBox blob of the current format. The pre-key_id layout is not read - no '
417
+ + 'reader for it survived the migration (owner decision 2026-09-03, '
418
+ + 'api/docs/governance/confirmations/secretbox-blob-format.md 001). Rewrite the value with '
419
+ + 'set-secret, or run the re-encryption tool.'
420
+ );
421
+ }
422
+ const idLength = raw[1];
423
+ if (idLength < 1) {
424
+ refuseBlob('the blob declares a zero-length key id', 'rewrite the value; it names no key.');
425
+ }
426
+ const fixed = 2 + idLength + 4 + IV_BYTES + AUTH_TAG_BYTES;
427
+ if (raw.length < fixed) {
428
+ refuseBlob(
429
+ `the blob is ${raw.length} bytes, shorter than the ${fixed} bytes its own header describes`,
430
+ 'it is truncated; re-read it from the store, or rewrite the value.'
431
+ );
432
+ }
433
+ const keyId = raw.subarray(2, 2 + idLength).toString('utf8');
434
+ if (!KEY_ID_PATTERN.test(keyId)) {
435
+ refuseBlob(`the blob declares an unusable key id "${keyId}"`, 'rewrite the value; the blob is corrupt.');
436
+ }
437
+ let offset = 2 + idLength;
438
+ const version = raw.readUInt32BE(offset); offset += 4;
439
+ const iv = raw.subarray(offset, offset + IV_BYTES); offset += IV_BYTES;
440
+ const authTag = raw.subarray(offset, offset + AUTH_TAG_BYTES); offset += AUTH_TAG_BYTES;
441
+ return { keyId, version, iv, authTag, ciphertext: raw.subarray(offset) };
442
+ }
443
+
444
+ /**
445
+ * Decrypt the parts of one sealed value.
446
+ *
447
+ * @param {{ keyId: string, version: number, iv: Buffer, authTag: Buffer, ciphertext: Buffer }} parts
448
+ * @param {MasterKeyring} keyring
449
+ * @param {Object} context the bound context; its version must be the one the parts carry
450
+ * @returns {string} plaintext
451
+ */
452
+ function decrypt(parts, keyring, context) {
453
+ assertKeyring(keyring);
454
+ if (parts === null || typeof parts !== 'object') {
455
+ refuseBlob(
456
+ `got ${parts === null ? 'null' : typeof parts} instead of the sealed parts`,
457
+ 'pass what parseBlob() or encrypt() returned.'
458
+ );
459
+ }
460
+ const expected = contextVersion(context);
461
+ if (parts.version !== expected) {
462
+ throw new SecretCryptoError(
463
+ 'SECRET_VERSION_MISMATCH',
464
+ `[conn-infra-secrets] Version mismatch - the blob was sealed for version ${parts.version}, the context `
465
+ + `names version ${expected}. The version is part of the bound context, so the two have to be the `
466
+ + 'same value. Fix: read the version from the same row as the blob; if the row was renumbered, '
467
+ + 're-encrypt it (renumbering a version is a re-encryption - secretbox-blob-format 001).'
468
+ );
469
+ }
470
+ const key = keyring.keyFor(parts.keyId);
471
+ const aad = Buffer.from(bindingContext(context), 'utf8');
472
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, parts.iv, { authTagLength: AUTH_TAG_BYTES });
473
+ decipher.setAAD(aad);
474
+ decipher.setAuthTag(parts.authTag);
475
+ return Buffer.concat([decipher.update(parts.ciphertext), decipher.final()]).toString('utf8');
476
+ }
477
+
478
+ /**
479
+ * Open a wire blob against a context that names the version — the reader that
480
+ * has the authoritative row, so the blob's version is CHECKED, not trusted.
481
+ *
482
+ * @param {string} blobBase64
483
+ * @param {MasterKeyring} keyring
484
+ * @param {{ tenant_id, workspace_id, ref, version }} context
485
+ * @returns {string} plaintext
486
+ */
487
+ function open(blobBase64, keyring, context) {
488
+ assertKeyring(keyring);
489
+ return decrypt(parseBlob(blobBase64), keyring, context);
490
+ }
491
+
492
+ /**
493
+ * Open a wire blob whose version the caller cannot know — the Redis projection
494
+ * reader, whose key is `…:<tenant>:<workspace>:<ref>` with no version in it.
495
+ *
496
+ * The version is taken from the blob and bound into the context, so the blob is
497
+ * still tied to its tenant, workspace and ref; what this entry point does NOT
498
+ * prove is that the version is the one the authoritative row carries. Nothing on
499
+ * this path could prove it, and saying so here is the point of having two names.
500
+ *
501
+ * @param {string} blobBase64
502
+ * @param {MasterKeyring} keyring
503
+ * @param {{ tenant_id, workspace_id, ref }} scope
504
+ * @returns {string} plaintext
505
+ */
506
+ function openWithDeclaredVersion(blobBase64, keyring, scope) {
507
+ assertKeyring(keyring);
508
+ const parts = parseBlob(blobBase64);
509
+ return decrypt(parts, keyring, { ...scope, version: parts.version });
54
510
  }
55
511
 
56
- module.exports = { loadMasterKey, open, ALGORITHM, KEY_BYTES, IV_BYTES, AUTH_TAG_BYTES };
512
+ module.exports = {
513
+ MasterKeyring,
514
+ SecretCryptoError,
515
+ loadMasterKeys,
516
+ bindingContext,
517
+ encrypt,
518
+ decrypt,
519
+ sealBlob,
520
+ seal,
521
+ parseBlob,
522
+ open,
523
+ openWithDeclaredVersion,
524
+ MASTER_KEYS_ENV,
525
+ ALGORITHM,
526
+ KEY_BYTES,
527
+ IV_BYTES,
528
+ AUTH_TAG_BYTES,
529
+ BLOB_FORMAT,
530
+ CONTEXT_SEPARATOR
531
+ };