@onlineapps/conn-infra-secrets 1.1.0 → 3.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 +117 -0
- package/README.md +257 -15
- package/package.json +6 -3
- package/src/crypto.js +462 -62
- package/src/index.js +346 -41
- package/src/readability.js +147 -0
- package/src/rotation.js +206 -0
- package/src/workspaceId.js +38 -0
package/src/index.js
CHANGED
|
@@ -9,86 +9,358 @@
|
|
|
9
9
|
* store (oagen_meta.secret) is owned by biz-meta and never touched on this path.
|
|
10
10
|
*
|
|
11
11
|
* Redis projection (see secretbox.md §5) — flat key (meta StateConnector has no
|
|
12
|
-
* hash ops), value =
|
|
13
|
-
* state:meta:secret:<tenant_id>:<workspace_id
|
|
12
|
+
* hash ops), value = the sealed blob described in src/crypto.js:
|
|
13
|
+
* state:meta:secret:<tenant_id>:<workspace_id>:<ref>
|
|
14
|
+
*
|
|
15
|
+
* The master keys arrive as the declared ORDERED LIST (`SECRETS_MASTER_KEYS`,
|
|
16
|
+
* owner decision 2026-09-14, confirmation secretbox-master-keys 001): the first
|
|
17
|
+
* key seals every new write, every key in the list opens what it sealed, selected
|
|
18
|
+
* by the blob's own `key_id`. That is what makes a rotation a switch rather than a
|
|
19
|
+
* jump — while both keys are listed, rows on either of them are readable.
|
|
14
20
|
*
|
|
15
21
|
* Contract: get(name, scope) => Promise<string>. The ContextBuilder facade passes
|
|
16
22
|
* the invocation's { tenant_id, workspace_id } as scope. No fallbacks: a missing
|
|
17
|
-
* projection throws SECRET_NOT_FOUND; a
|
|
23
|
+
* projection throws SECRET_NOT_FOUND; a blob sealed with a key that is not in the
|
|
24
|
+
* list throws SECRET_KEY_UNKNOWN (never a "try every key" pass); a blob that does
|
|
25
|
+
* not open under its own key — tampered, or lifted out of the tenant/workspace/ref
|
|
26
|
+
* it was bound to — throws SECRET_DECRYPT_FAILED; a scope that cannot address one
|
|
27
|
+
* concrete workspace throws SECRET_SCOPE_MISSING before any Redis round trip.
|
|
18
28
|
*
|
|
19
29
|
* @see api/docs/architecture/secretbox.md
|
|
30
|
+
* @see api/docs/governance/confirmations/secretbox-tenant-wide-scope.md
|
|
20
31
|
*/
|
|
21
32
|
|
|
22
33
|
const Redis = require('ioredis');
|
|
23
|
-
const {
|
|
34
|
+
const {
|
|
35
|
+
loadMasterKeys, openWithDeclaredVersion, SecretCryptoError, MASTER_KEYS_ENV
|
|
36
|
+
} = require('./crypto');
|
|
37
|
+
const { normalizeWorkspaceId } = require('./workspaceId');
|
|
24
38
|
|
|
25
39
|
// Meta owns the projection; its StateConnector prefixes keys with `state:meta:`.
|
|
26
40
|
const DEFAULT_KEY_PREFIX = 'state:meta:secret:';
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The sentences with which a Redis server ANSWERS a connection attempt and
|
|
44
|
+
* REFUSES it — an answer, not a silence:
|
|
45
|
+
*
|
|
46
|
+
* NOAUTH the server requires AUTH and this connection sent none
|
|
47
|
+
* WRONGPASS the credential the URL carries is not the server's
|
|
48
|
+
* NOPERM the ACL user the URL names may not run the command
|
|
49
|
+
*
|
|
50
|
+
* The distinction is the one `@onlineapps/mq-client-core` draws for a broker
|
|
51
|
+
* (`_isBrokerRefusal()`, reply codes 403/530/406, d.295): an ANSWERED refusal is
|
|
52
|
+
* permanent — the next attempt asks the same question and gets the same answer,
|
|
53
|
+
* so the attempts end and the caller is told what the server said. Anything else
|
|
54
|
+
* is transient by design: nobody answered, and giving up forever on a server that
|
|
55
|
+
* may come back is a different defect.
|
|
56
|
+
*
|
|
57
|
+
* The list holds only sentences a live Redis 6/7 actually sends on this path:
|
|
58
|
+
* NOAUTH and WRONGPASS were measured against `redis:7-alpine --requirepass`
|
|
59
|
+
* (2026-09-16), NOPERM is the same class for the ACL user the URL may name. Redis
|
|
60
|
+
* 5's `ERR invalid password` is deliberately absent — the platform runs Redis 7,
|
|
61
|
+
* and a ban nothing can trigger is silence pretending to be coverage
|
|
62
|
+
* (`automation-gates.md` §5). `ERR Client sent AUTH, but no password is set` is
|
|
63
|
+
* absent for the same reason: ioredis warns on it and does not fail the
|
|
64
|
+
* connection (`ioredis/built/redis/event_handler.js`, `connectHandler`).
|
|
65
|
+
*/
|
|
66
|
+
const SERVER_REFUSAL_PREFIXES = ['NOAUTH', 'WRONGPASS', 'NOPERM'];
|
|
34
67
|
|
|
35
68
|
/**
|
|
36
|
-
*
|
|
37
|
-
* @
|
|
69
|
+
* Did the server answer and refuse?
|
|
70
|
+
* @param {Error|null|undefined} err
|
|
71
|
+
* @returns {boolean}
|
|
38
72
|
*/
|
|
39
|
-
function
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
return String(workspaceId);
|
|
73
|
+
function isServerRefusal(err) {
|
|
74
|
+
if (!err || typeof err.message !== 'string') return false;
|
|
75
|
+
return SERVER_REFUSAL_PREFIXES.some((prefix) => err.message.startsWith(prefix));
|
|
43
76
|
}
|
|
44
77
|
|
|
45
78
|
class SecretResolutionError extends Error {
|
|
46
|
-
constructor(code, message) {
|
|
47
|
-
super(message);
|
|
79
|
+
constructor(code, message, options) {
|
|
80
|
+
super(message, options);
|
|
48
81
|
this.name = 'SecretResolutionError';
|
|
49
82
|
this.code = code;
|
|
50
83
|
}
|
|
51
84
|
}
|
|
52
85
|
|
|
86
|
+
// The rule "which workspace_id can address a secret" moved into its own module,
|
|
87
|
+
// src/workspaceId.js: src/crypto.js needs the same normalization for the bound
|
|
88
|
+
// context (AAD), and a second copy of the rule is exactly what
|
|
89
|
+
// .claude/rules/change-discipline.md § One rail per concern forbids. It stays
|
|
90
|
+
// part of this package's public API — re-exported at the bottom of this file —
|
|
91
|
+
// so the writing side keeps importing it from here.
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {*} workspaceId
|
|
95
|
+
* @returns {string} the key segment for one concrete workspace
|
|
96
|
+
* @throws {SecretResolutionError} SECRET_SCOPE_MISSING for any unusable value
|
|
97
|
+
*/
|
|
98
|
+
function workspaceSegment(workspaceId) {
|
|
99
|
+
const ws = normalizeWorkspaceId(workspaceId);
|
|
100
|
+
if (ws === null) {
|
|
101
|
+
throw new SecretResolutionError(
|
|
102
|
+
'SECRET_SCOPE_MISSING',
|
|
103
|
+
`[conn-infra-secrets] Invalid workspace_id "${workspaceId}" - Expected a positive integer naming `
|
|
104
|
+
+ 'the workspace the secret belongs to. Fix: pass the concrete workspace_id; the tenant-wide '
|
|
105
|
+
+ 'scope was retired by owner decision 2026-09-03 '
|
|
106
|
+
+ '(api/docs/governance/confirmations/secretbox-tenant-wide-scope.md 001).'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return String(ws);
|
|
110
|
+
}
|
|
111
|
+
|
|
53
112
|
function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
|
|
54
113
|
if (tenantId === undefined || tenantId === null) {
|
|
55
|
-
throw new SecretResolutionError(
|
|
114
|
+
throw new SecretResolutionError(
|
|
115
|
+
'SECRET_SCOPE_MISSING',
|
|
116
|
+
'[conn-infra-secrets] tenant_id is required to resolve a secret - Expected the tenant the secret '
|
|
117
|
+
+ 'belongs to. Fix: pass scope.tenant_id from the operation context.'
|
|
118
|
+
);
|
|
56
119
|
}
|
|
57
120
|
return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
|
|
58
121
|
}
|
|
59
122
|
|
|
123
|
+
// `REDIS_URL` is the platform's single rail for the Redis endpoint AND its
|
|
124
|
+
// credential (`service-common` 3.0.2 hands the whole URL to node-redis;
|
|
125
|
+
// `mq-client-core` 3.2.0 reads the credential off the URL). Anything else this
|
|
126
|
+
// package might accept beside it would be a second rail for the same fact.
|
|
127
|
+
const REDIS_URL_PROTOCOLS = new Set(['redis:', 'rediss:']);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Options beside `redisUrl` that used to carry a fact the URL already carries.
|
|
131
|
+
* They are REFUSED by name rather than ignored: an ignored option looks accepted
|
|
132
|
+
* and leaves the caller believing a password reached the server
|
|
133
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern; principle 11,
|
|
134
|
+
* clean break — no shim that keeps reading them).
|
|
135
|
+
*/
|
|
136
|
+
const RETIRED_OPTIONS = {
|
|
137
|
+
password: 'the Redis password comes from the userinfo of redisUrl (redis://user:password@host:port)',
|
|
138
|
+
db: 'the Redis database index comes from the path of redisUrl (redis://host:port/3)'
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function refuseRetiredOptions(config) {
|
|
142
|
+
for (const [name, replacement] of Object.entries(RETIRED_OPTIONS)) {
|
|
143
|
+
if (config[name] === undefined) continue;
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[conn-infra-secrets] Option "${name}" is not accepted - ${replacement}. `
|
|
146
|
+
+ `Fix: drop "${name}" from the SecretsConnector config and put the value in REDIS_URL `
|
|
147
|
+
+ '(api/config/env-active/shared.env); a value beside the URL is a second rail for a fact '
|
|
148
|
+
+ 'the URL already carries. The refused value is not echoed here.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The database index the URL declares, or `null` when it declares none.
|
|
155
|
+
*
|
|
156
|
+
* No path (`redis://h:6379`) and an empty path (`redis://h:6379/`) declare
|
|
157
|
+
* nothing, and the connector then passes no `db` at all — ioredis selects its own
|
|
158
|
+
* default, database 0. That default belongs to ioredis and is named here rather
|
|
159
|
+
* than re-implemented as `config.db || 0`, which would also swallow a malformed
|
|
160
|
+
* declaration into a 0 nobody asked for (principle 3, No Fallbacks).
|
|
161
|
+
*/
|
|
162
|
+
function databaseFromPath(pathname) {
|
|
163
|
+
const declared = String(pathname || '').replace(/^\//, '');
|
|
164
|
+
if (declared === '') return null;
|
|
165
|
+
if (!/^\d+$/.test(declared)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[conn-infra-secrets] redisUrl declares database "${declared}" - Expected a non-negative `
|
|
168
|
+
+ 'integer as the URL path, e.g. redis://host:6379/3. Fix: correct the path of REDIS_URL in '
|
|
169
|
+
+ 'shared.env, or drop it to use the default database.'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return Number.parseInt(declared, 10);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the ioredis options from the URL — endpoint, ACL user and password alike.
|
|
177
|
+
*
|
|
178
|
+
* Measured reason this function exists (INFRA, dev, 2026-09-15 21:20Z): the
|
|
179
|
+
* constructor used to take `hostname`/`port` only and expect the password in a
|
|
180
|
+
* separate `config.password`, so a URL carrying userinfo lost it and ioredis
|
|
181
|
+
* connected without AUTH — gateway crash loop (RestartCount 51), dispatcher and
|
|
182
|
+
* ingest failing on the first secret read.
|
|
183
|
+
*
|
|
184
|
+
* Nothing here echoes the URL or any part of its userinfo: these messages reach
|
|
185
|
+
* logs, and the value carries the password.
|
|
186
|
+
*/
|
|
187
|
+
function redisOptionsFromUrl(redisUrl) {
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = new URL(redisUrl);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
'[conn-infra-secrets] redisUrl is not a URL - Expected redis://[user:password@]host:port[/db] '
|
|
194
|
+
+ '(or rediss:// for TLS), the one rail that carries both the endpoint and the credential. '
|
|
195
|
+
+ 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full URL; a bare host:port '
|
|
196
|
+
+ 'is not accepted. The value is not echoed here because it carries the password.',
|
|
197
|
+
{ cause: err }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!REDIS_URL_PROTOCOLS.has(parsed.protocol)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`[conn-infra-secrets] redisUrl has scheme "${parsed.protocol}" - Expected redis: or rediss:. `
|
|
203
|
+
+ 'Fix: set REDIS_URL in api/config/env-active/shared.env to redis://host:port (rediss:// for TLS).'
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (!parsed.hostname) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'[conn-infra-secrets] redisUrl declares no host - Expected redis://host:port. '
|
|
209
|
+
+ 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full endpoint.'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!parsed.port) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'[conn-infra-secrets] redisUrl declares no port - Expected redis://host:port; the port is '
|
|
215
|
+
+ 'topology and this package declares no default for it. Fix: set REDIS_URL in '
|
|
216
|
+
+ 'api/config/env-active/shared.env to the full endpoint, e.g. redis://api_node_cache:6379.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const options = {
|
|
221
|
+
host: parsed.hostname,
|
|
222
|
+
port: Number.parseInt(parsed.port, 10),
|
|
223
|
+
lazyConnect: true
|
|
224
|
+
};
|
|
225
|
+
// Redis 6 ACL: the URL may name a user as well as a password. An absent one is
|
|
226
|
+
// ABSENT — never `''`, which ioredis would send as `AUTH ""`.
|
|
227
|
+
if (parsed.username) options.username = decodeURIComponent(parsed.username);
|
|
228
|
+
if (parsed.password) options.password = decodeURIComponent(parsed.password);
|
|
229
|
+
const db = databaseFromPath(parsed.pathname);
|
|
230
|
+
if (db !== null) options.db = db;
|
|
231
|
+
return options;
|
|
232
|
+
}
|
|
233
|
+
|
|
60
234
|
class SecretsConnector {
|
|
61
235
|
/**
|
|
62
236
|
* @param {Object} config
|
|
63
|
-
* @param {string} config.redisUrl - redis://host:port
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
237
|
+
* @param {string} config.redisUrl - `redis://[user:password@]host:port[/db]` or `rediss://…`
|
|
238
|
+
* (required). The URL is the ONLY rail: endpoint, ACL user, password and
|
|
239
|
+
* database index all come off it. `config.password` and `config.db` are
|
|
240
|
+
* refused by name.
|
|
241
|
+
* @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
|
|
242
|
+
* (required; the value of SECRETS_MASTER_KEYS, first entry = the active key)
|
|
243
|
+
* @param {string} [config.keyPrefix] - projection key prefix (defaults to the one meta writes)
|
|
67
244
|
*/
|
|
68
245
|
constructor(config = {}) {
|
|
69
246
|
if (!config.redisUrl) {
|
|
70
247
|
throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
|
|
71
248
|
}
|
|
72
|
-
|
|
249
|
+
refuseRetiredOptions(config);
|
|
250
|
+
this._keyring = loadMasterKeys(config.masterKeys);
|
|
73
251
|
this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
|
|
74
252
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
this._redisOptions = { host, port, password: config.password, db: config.db || 0, lazyConnect: true };
|
|
253
|
+
this._redisOptions = redisOptionsFromUrl(config.redisUrl);
|
|
254
|
+
// What a message of this package may say about the store: host and port,
|
|
255
|
+
// never the URL. The connector keeps the parsed fields and drops the value
|
|
256
|
+
// precisely because the value carries the password, so there is no URL here
|
|
257
|
+
// to redact — and nothing to redact it with (this package declares one
|
|
258
|
+
// dependency, ioredis).
|
|
259
|
+
this._endpoint = `${this._redisOptions.host}:${this._redisOptions.port}`;
|
|
83
260
|
this.client = null;
|
|
261
|
+
// Whether the CLIENT is ready — socket open, AUTH and SELECT done. It is not
|
|
262
|
+
// a latch set once by connect(): it follows the client through an outage and
|
|
263
|
+
// back (d.573), one rail with `@onlineapps/conn-base-cache` d.572.
|
|
84
264
|
this.connected = false;
|
|
265
|
+
this._lastError = null;
|
|
85
266
|
}
|
|
86
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Open the connection, or say what the server answered.
|
|
270
|
+
*
|
|
271
|
+
* Until d.566 this method handed the caller ioredis's end-of-socket sentence —
|
|
272
|
+
* `Connection is closed.`, no `cause`, no verdict — while the server's actual
|
|
273
|
+
* answer reached `_lastError` and nothing else. That is how dev incident B88
|
|
274
|
+
* (gateway crash loop, RestartCount 51) could not be read from the error at
|
|
275
|
+
* all: the log said the socket closed, and the `NOAUTH` that closed it was a
|
|
276
|
+
* field nobody printed.
|
|
277
|
+
*
|
|
278
|
+
* Two outcomes now, told apart by `isServerRefusal()` and said in the caller's
|
|
279
|
+
* error: the server ANSWERED and refused (permanent — the attempts end here),
|
|
280
|
+
* or it never answered (transient — the caller may try again). Either way the
|
|
281
|
+
* original error travels as `cause`, so the stack survives the sentence.
|
|
282
|
+
*
|
|
283
|
+
* A failed attempt also takes its client with it. ioredis keeps retrying in the
|
|
284
|
+
* background after `connect()` rejects — measured 2026-09-16: status
|
|
285
|
+
* `reconnecting` on all three failure paths — so a connector that gives up
|
|
286
|
+
* without disconnecting leaves a socket loop nobody owns, and this one never
|
|
287
|
+
* reports `connected` again for it: the lifecycle handlers below act only for
|
|
288
|
+
* the client this connector still holds, so nothing that client emits after it
|
|
289
|
+
* has been discarded can put the connector back in service.
|
|
290
|
+
*
|
|
291
|
+
* What `connected` means afterwards is the client's own READY state, watched
|
|
292
|
+
* rather than assumed (d.573): a Redis that goes away mid-run takes the flag
|
|
293
|
+
* down, so `get()` names the missing store instead of walking into a client
|
|
294
|
+
* with no socket, and the reconnection ioredis performs on its own puts the
|
|
295
|
+
* flag back. Before d.573 the flag was written once and never read off the
|
|
296
|
+
* client again, so an outage decided the fate of every secret read that
|
|
297
|
+
* followed it.
|
|
298
|
+
*
|
|
299
|
+
* @returns {Promise<true>}
|
|
300
|
+
* @throws {Error} naming the server's verdict, with the original error as `cause`
|
|
301
|
+
*/
|
|
87
302
|
async connect() {
|
|
88
303
|
if (this.connected) return true;
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
|
|
304
|
+
this._lastError = null;
|
|
305
|
+
const client = new Redis(this._redisOptions);
|
|
306
|
+
this.client = client;
|
|
307
|
+
|
|
308
|
+
// `ready`, not `connect`: ioredis emits `connect` when the socket opens,
|
|
309
|
+
// which is before AUTH and before SELECT, so it would report a usable store
|
|
310
|
+
// for a connection the server is about to refuse (the same rule as
|
|
311
|
+
// `@onlineapps/conn-base-cache` d.572). Every handler checks that this is
|
|
312
|
+
// still THE client of this connector: a discarded one keeps emitting for a
|
|
313
|
+
// while, and its events belong to nobody.
|
|
314
|
+
const mine = () => this.client === client;
|
|
315
|
+
client.on('ready', () => { if (mine()) this.connected = true; });
|
|
316
|
+
// `close` is the socket going away with a reconnection still to come; `end`
|
|
317
|
+
// is the client giving up for good. Both mean the store is not readable now.
|
|
318
|
+
client.on('close', () => { if (mine()) this.connected = false; });
|
|
319
|
+
client.on('end', () => { if (mine()) this.connected = false; });
|
|
320
|
+
|
|
321
|
+
client.on('error', (err) => {
|
|
322
|
+
// Surfaced on get(), and read below as the verdict of a failed connect().
|
|
323
|
+
this._lastError = err;
|
|
324
|
+
// A refusal the server has already spoken does not change on the next
|
|
325
|
+
// attempt: end the reconnection rather than ask again in a loop.
|
|
326
|
+
if (isServerRefusal(err)) client.disconnect();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
await client.connect();
|
|
331
|
+
} catch (rejection) {
|
|
332
|
+
// The verdict comes from whichever error carries it: ioredis delivers the
|
|
333
|
+
// server's sentence on the `error` channel and rejects connect() with the
|
|
334
|
+
// state of the socket, but a rejection that carries the verdict itself is
|
|
335
|
+
// read the same way.
|
|
336
|
+
const refusal = [this._lastError, rejection].find(isServerRefusal) || null;
|
|
337
|
+
const reported = refusal || this._lastError || rejection;
|
|
338
|
+
|
|
339
|
+
client.disconnect();
|
|
340
|
+
this.client = null;
|
|
341
|
+
this.connected = false;
|
|
342
|
+
|
|
343
|
+
if (refusal) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server `
|
|
346
|
+
+ `answered and refused it with "${refusal.message}". Fix: correct the credential in `
|
|
347
|
+
+ 'REDIS_URL (api/config/env-active/shared.env) — the ACL user too, if the URL names '
|
|
348
|
+
+ 'one; a server that answers this way refuses every retry, so the connector stopped '
|
|
349
|
+
+ 'instead of looping. Only the endpoint is named here; the URL carries the password.',
|
|
350
|
+
{ cause: refusal }
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
throw new Error(
|
|
354
|
+
`[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server sent `
|
|
355
|
+
+ `no verdict; the client rejected with "${reported.message}". Fix: check that Redis is `
|
|
356
|
+
+ 'listening at that endpoint and that the host and port in REDIS_URL '
|
|
357
|
+
+ '(api/config/env-active/shared.env) are the right ones. A refused credential answers '
|
|
358
|
+
+ 'NOAUTH, WRONGPASS or NOPERM and is reported as such, so this is not one. Only the '
|
|
359
|
+
+ 'endpoint is named here; the URL carries the password.',
|
|
360
|
+
{ cause: reported }
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
92
364
|
this.connected = true;
|
|
93
365
|
return true;
|
|
94
366
|
}
|
|
@@ -104,15 +376,23 @@ class SecretsConnector {
|
|
|
104
376
|
/**
|
|
105
377
|
* Resolve a secret ref to plaintext for the given invocation scope.
|
|
106
378
|
* @param {string} name - opaque secret ref
|
|
107
|
-
* @param {{ tenant_id: number|string, workspace_id
|
|
379
|
+
* @param {{ tenant_id: number|string, workspace_id: number|string }} scope
|
|
380
|
+
* Both ids are required; `workspace_id` must be a positive integer.
|
|
108
381
|
* @returns {Promise<string>}
|
|
109
382
|
*/
|
|
110
383
|
async get(name, scope = {}) {
|
|
111
384
|
if (typeof name !== 'string' || name.length === 0) {
|
|
112
|
-
throw new SecretResolutionError(
|
|
385
|
+
throw new SecretResolutionError(
|
|
386
|
+
'SECRET_REF_INVALID',
|
|
387
|
+
'[conn-infra-secrets] secret ref must be a non-empty string - Expected the ref name declared in '
|
|
388
|
+
+ 'the SecretBox. Fix: pass the ref, not the secret value.'
|
|
389
|
+
);
|
|
113
390
|
}
|
|
114
391
|
if (!this.client || !this.connected) {
|
|
115
|
-
throw new SecretResolutionError(
|
|
392
|
+
throw new SecretResolutionError(
|
|
393
|
+
'SECRET_STORE_UNAVAILABLE',
|
|
394
|
+
'[conn-infra-secrets] not connected - Expected an open Redis connection. Fix: call connect() first.'
|
|
395
|
+
);
|
|
116
396
|
}
|
|
117
397
|
const key = projectionKey(scope.tenant_id, scope.workspace_id, name, this._keyPrefix);
|
|
118
398
|
const blob = await this.client.get(key);
|
|
@@ -123,11 +403,29 @@ class SecretsConnector {
|
|
|
123
403
|
);
|
|
124
404
|
}
|
|
125
405
|
try {
|
|
126
|
-
|
|
406
|
+
// The projection key carries no version, so the version comes from the blob
|
|
407
|
+
// and is bound into the context with it — src/crypto.js says what that does
|
|
408
|
+
// and does not prove.
|
|
409
|
+
return openWithDeclaredVersion(blob, this._keyring, {
|
|
410
|
+
tenant_id: scope.tenant_id,
|
|
411
|
+
workspace_id: scope.workspace_id,
|
|
412
|
+
ref: name
|
|
413
|
+
});
|
|
127
414
|
} catch (err) {
|
|
415
|
+
if (err instanceof SecretCryptoError && err.code === 'SECRET_KEY_UNKNOWN') {
|
|
416
|
+
throw new SecretResolutionError(
|
|
417
|
+
'SECRET_KEY_UNKNOWN',
|
|
418
|
+
`[conn-infra-secrets] Unknown master key for secret "${name}" at ${key} - ${err.message}`,
|
|
419
|
+
{ cause: err }
|
|
420
|
+
);
|
|
421
|
+
}
|
|
128
422
|
throw new SecretResolutionError(
|
|
129
423
|
'SECRET_DECRYPT_FAILED',
|
|
130
|
-
`[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} -
|
|
424
|
+
`[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - the blob does not `
|
|
425
|
+
+ 'open under the master key it names: it is corrupt, or it was sealed for another tenant, '
|
|
426
|
+
+ `workspace or ref. Fix: check that ${MASTER_KEYS_ENV} holds the key this value was sealed `
|
|
427
|
+
+ 'with, then re-seal the value via the biz-meta set-secret operation.',
|
|
428
|
+
{ cause: err }
|
|
131
429
|
);
|
|
132
430
|
}
|
|
133
431
|
}
|
|
@@ -149,7 +447,13 @@ class MockSecretsConnector {
|
|
|
149
447
|
isConnected() { return this.connected; }
|
|
150
448
|
async get(name, scope = {}) {
|
|
151
449
|
const v = this.store.get(MockSecretsConnector._k(scope, name));
|
|
152
|
-
if (v === undefined)
|
|
450
|
+
if (v === undefined) {
|
|
451
|
+
throw new SecretResolutionError(
|
|
452
|
+
'SECRET_NOT_FOUND',
|
|
453
|
+
`[conn-infra-secrets] Secret ref "${name}" not found in MockSecretsConnector - Expected a value `
|
|
454
|
+
+ 'seeded for this scope. Fix: call seed(scope, name, value) before get() in the test.'
|
|
455
|
+
);
|
|
456
|
+
}
|
|
153
457
|
return v;
|
|
154
458
|
}
|
|
155
459
|
}
|
|
@@ -159,4 +463,5 @@ module.exports.SecretsConnector = SecretsConnector;
|
|
|
159
463
|
module.exports.MockSecretsConnector = MockSecretsConnector;
|
|
160
464
|
module.exports.SecretResolutionError = SecretResolutionError;
|
|
161
465
|
module.exports.projectionKey = projectionKey;
|
|
466
|
+
module.exports.normalizeWorkspaceId = normalizeWorkspaceId;
|
|
162
467
|
module.exports.create = (config) => new SecretsConnector(config);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Readability — the question "is anything still sealed with a key I am about to
|
|
5
|
+
* drop", asked on purpose instead of by accident.
|
|
6
|
+
*
|
|
7
|
+
* Owner decision 2026-09-14, confirmation
|
|
8
|
+
* api/docs/governance/confirmations/secretbox-master-keys.md 001: a readability
|
|
9
|
+
* check — every stored blob opens with the keys that would REMAIN — is a mandatory
|
|
10
|
+
* step BEFORE a key is removed from `SECRETS_MASTER_KEYS`.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS TO SKIP IT, measured: in August 2026 the master key was replaced
|
|
13
|
+
* and the old one simply stopped being used. Nobody asked what was still sealed
|
|
14
|
+
* with it. Six of ten rows of `oagen_meta.secret` became unreadable and stayed
|
|
15
|
+
* that way for two weeks, until someone noticed while checking something else.
|
|
16
|
+
* The old key no longer exists anywhere, so those rows are unrecoverable.
|
|
17
|
+
*
|
|
18
|
+
* IT OPENS EVERY BLOB, it does not merely read the `key_id` off the header. A row
|
|
19
|
+
* whose key is listed but whose blob is corrupt, or bound to another tenant,
|
|
20
|
+
* workspace, ref or version, is just as unreadable — and a check that trusted the
|
|
21
|
+
* header would report it as fine.
|
|
22
|
+
*
|
|
23
|
+
* IT NEVER STOPS HALFWAY. A blob that cannot even be parsed is reported as a
|
|
24
|
+
* finding, not raised as an exception: the whole point is the complete list of
|
|
25
|
+
* what will not open, and a check that dies on the first bad row cannot give one.
|
|
26
|
+
* What it DOES refuse is a record it cannot ask the question about at all — one
|
|
27
|
+
* that does not name its tenant, workspace, ref or version — because that is a
|
|
28
|
+
* defect in the caller's query, not a finding about the store.
|
|
29
|
+
*
|
|
30
|
+
* @see src/rotation.js — the run that makes the list shorter safely
|
|
31
|
+
* @see src/crypto.js — the sealing contract; `keyring.without(id)` builds the list
|
|
32
|
+
* a removal is checked against
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { MasterKeyring, SecretCryptoError, open, parseBlob } = require('./crypto');
|
|
36
|
+
|
|
37
|
+
const IDENTITY_FIELDS = ['tenant_id', 'workspace_id', 'ref', 'version'];
|
|
38
|
+
|
|
39
|
+
/** Why a row does not open. Three causes, three different repairs. */
|
|
40
|
+
const REASONS = Object.freeze({
|
|
41
|
+
/** The blob names a key that is not in the list — put it back, or rewrite the value. */
|
|
42
|
+
KEY_NOT_IN_LIST: 'key_not_in_list',
|
|
43
|
+
/** The key is there and the blob still does not open — corrupt, or bound to another row. */
|
|
44
|
+
DOES_NOT_OPEN: 'does_not_open',
|
|
45
|
+
/** Not a blob of the current format at all — rewrite the value from its source. */
|
|
46
|
+
MALFORMED_BLOB: 'malformed_blob'
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function refuse(code, problem, detail) {
|
|
50
|
+
throw new SecretCryptoError(code, `[conn-infra-secrets] ${problem} - Fix: ${detail}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertRecord(record, index) {
|
|
54
|
+
if (record === null || typeof record !== 'object') {
|
|
55
|
+
refuse(
|
|
56
|
+
'SECRET_READABILITY_INVALID',
|
|
57
|
+
`Unusable record at position ${index} - got ${record === null ? 'null' : typeof record}`,
|
|
58
|
+
'pass the records listSecrets() yields: { tenant_id, workspace_id, ref, version, blob }.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const field of IDENTITY_FIELDS) {
|
|
62
|
+
if (record[field] === undefined || record[field] === null || String(record[field]).trim() === '') {
|
|
63
|
+
refuse(
|
|
64
|
+
'SECRET_READABILITY_INVALID',
|
|
65
|
+
`Record at position ${index} carries no ${field} - the check cannot ask whether a row opens `
|
|
66
|
+
+ 'without knowing which row it is: all four fields are part of the bound context the value is '
|
|
67
|
+
+ 'sealed under',
|
|
68
|
+
`select ${field} alongside the sealed blob.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (typeof record.blob !== 'string' || record.blob === '') {
|
|
73
|
+
refuse(
|
|
74
|
+
'SECRET_READABILITY_INVALID',
|
|
75
|
+
`Record at position ${index} carries no blob - there is nothing to open`,
|
|
76
|
+
'select the sealed blob for that row; a store keeping the parts in separate columns joins them '
|
|
77
|
+
+ 'with sealBlob() in its adapter.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Does every stored blob open with this key list?
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} deps
|
|
86
|
+
* @param {Array<Object>|AsyncIterable<Object>} deps.records the stored records
|
|
87
|
+
* @param {MasterKeyring} deps.keyring the list to ask about — typically
|
|
88
|
+
* `keyring.without('<the key you want to drop>')`
|
|
89
|
+
* @returns {Promise<{ total: number, readable: number, unreadable: Array<{
|
|
90
|
+
* tenant_id, workspace_id, ref, version, key_id: string|null, reason: string }> }>}
|
|
91
|
+
*/
|
|
92
|
+
async function checkReadability({ records, keyring } = {}) {
|
|
93
|
+
if (!(keyring instanceof MasterKeyring)) {
|
|
94
|
+
refuse(
|
|
95
|
+
'SECRET_KEYS_INVALID',
|
|
96
|
+
`Missing keyring - a MasterKeyring is required, got ${keyring === null ? 'null' : typeof keyring}`,
|
|
97
|
+
'pass the list you intend to keep, e.g. loadMasterKeys(SECRETS_MASTER_KEYS).without("<key id>").'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (records === null || records === undefined || typeof records !== 'object') {
|
|
101
|
+
refuse(
|
|
102
|
+
'SECRET_READABILITY_INVALID',
|
|
103
|
+
`Missing records - an array or an async iterable of stored records is required, got `
|
|
104
|
+
+ `${records === null ? 'null' : typeof records}`,
|
|
105
|
+
'pass what the store adapter listSecrets() yields; an empty store is an empty array.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let total = 0;
|
|
110
|
+
let readable = 0;
|
|
111
|
+
const unreadable = [];
|
|
112
|
+
|
|
113
|
+
for await (const record of records) {
|
|
114
|
+
assertRecord(record, total);
|
|
115
|
+
total += 1;
|
|
116
|
+
const identity = {
|
|
117
|
+
tenant_id: record.tenant_id,
|
|
118
|
+
workspace_id: record.workspace_id,
|
|
119
|
+
ref: record.ref,
|
|
120
|
+
version: record.version
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let keyId = null;
|
|
124
|
+
try {
|
|
125
|
+
keyId = parseBlob(record.blob).keyId;
|
|
126
|
+
} catch {
|
|
127
|
+
unreadable.push({ ...identity, key_id: null, reason: REASONS.MALFORMED_BLOB });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!keyring.has(keyId)) {
|
|
131
|
+
unreadable.push({ ...identity, key_id: keyId, reason: REASONS.KEY_NOT_IN_LIST });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
// Opened, not merely inspected: this is the only answer that counts. The
|
|
136
|
+
// plaintext is discarded on the same line it is produced.
|
|
137
|
+
open(record.blob, keyring, identity);
|
|
138
|
+
readable += 1;
|
|
139
|
+
} catch {
|
|
140
|
+
unreadable.push({ ...identity, key_id: keyId, reason: REASONS.DOES_NOT_OPEN });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { total, readable, unreadable };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { checkReadability, REASONS };
|