@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/CHANGELOG.md +22 -0
- package/README.md +241 -5
- package/jest.integration.config.js +18 -0
- package/package.json +8 -4
- package/src/crypto.js +504 -29
- package/src/index.js +96 -20
- package/src/readability.js +147 -0
- package/src/rotation.js +206 -0
- package/src/workspaceId.js +38 -0
package/src/crypto.js
CHANGED
|
@@ -1,56 +1,531 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* AES-256-GCM sealing contract of the SecretBox — this module owns it.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
38
|
-
* @
|
|
397
|
+
* @returns {{ keyId: string, version: number, iv: Buffer, authTag: Buffer, ciphertext: Buffer }}
|
|
398
|
+
* @throws {SecretCryptoError} SECRET_BLOB_INVALID
|
|
39
399
|
*/
|
|
40
|
-
function
|
|
400
|
+
function parseBlob(blobBase64) {
|
|
41
401
|
if (typeof blobBase64 !== 'string' || blobBase64.length === 0) {
|
|
42
|
-
throw new
|
|
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
|
|
45
|
-
if (
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 = {
|
|
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
|
+
};
|