@onlineapps/conn-infra-secrets 1.1.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 +239 -15
- package/package.json +6 -3
- package/src/crypto.js +462 -62
- package/src/index.js +92 -31
- package/src/readability.js +147 -0
- package/src/rotation.js +206 -0
- package/src/workspaceId.js +38 -0
package/src/index.js
CHANGED
|
@@ -9,50 +9,77 @@
|
|
|
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
|
+
class SecretResolutionError extends Error {
|
|
43
|
+
constructor(code, message, options) {
|
|
44
|
+
super(message, options);
|
|
45
|
+
this.name = 'SecretResolutionError';
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The rule "which workspace_id can address a secret" moved into its own module,
|
|
51
|
+
// src/workspaceId.js: src/crypto.js needs the same normalization for the bound
|
|
52
|
+
// context (AAD), and a second copy of the rule is exactly what
|
|
53
|
+
// .claude/rules/change-discipline.md § One rail per concern forbids. It stays
|
|
54
|
+
// part of this package's public API — re-exported at the bottom of this file —
|
|
55
|
+
// so the writing side keeps importing it from here.
|
|
34
56
|
|
|
35
57
|
/**
|
|
36
|
-
* @param {
|
|
37
|
-
* @returns {string} the key segment
|
|
58
|
+
* @param {*} workspaceId
|
|
59
|
+
* @returns {string} the key segment for one concrete workspace
|
|
60
|
+
* @throws {SecretResolutionError} SECRET_SCOPE_MISSING for any unusable value
|
|
38
61
|
*/
|
|
39
62
|
function workspaceSegment(workspaceId) {
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.code = code;
|
|
63
|
+
const ws = normalizeWorkspaceId(workspaceId);
|
|
64
|
+
if (ws === null) {
|
|
65
|
+
throw new SecretResolutionError(
|
|
66
|
+
'SECRET_SCOPE_MISSING',
|
|
67
|
+
`[conn-infra-secrets] Invalid workspace_id "${workspaceId}" - Expected a positive integer naming `
|
|
68
|
+
+ 'the workspace the secret belongs to. Fix: pass the concrete workspace_id; the tenant-wide '
|
|
69
|
+
+ 'scope was retired by owner decision 2026-09-03 '
|
|
70
|
+
+ '(api/docs/governance/confirmations/secretbox-tenant-wide-scope.md 001).'
|
|
71
|
+
);
|
|
50
72
|
}
|
|
73
|
+
return String(ws);
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
|
|
54
77
|
if (tenantId === undefined || tenantId === null) {
|
|
55
|
-
throw new SecretResolutionError(
|
|
78
|
+
throw new SecretResolutionError(
|
|
79
|
+
'SECRET_SCOPE_MISSING',
|
|
80
|
+
'[conn-infra-secrets] tenant_id is required to resolve a secret - Expected the tenant the secret '
|
|
81
|
+
+ 'belongs to. Fix: pass scope.tenant_id from the operation context.'
|
|
82
|
+
);
|
|
56
83
|
}
|
|
57
84
|
return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
|
|
58
85
|
}
|
|
@@ -61,7 +88,8 @@ class SecretsConnector {
|
|
|
61
88
|
/**
|
|
62
89
|
* @param {Object} config
|
|
63
90
|
* @param {string} config.redisUrl - redis://host:port (required)
|
|
64
|
-
* @param {string} config.
|
|
91
|
+
* @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
|
|
92
|
+
* (required; the value of SECRETS_MASTER_KEYS, first entry = the active key)
|
|
65
93
|
* @param {string} [config.password] - Redis password
|
|
66
94
|
* @param {number} [config.db=0] - Redis database index
|
|
67
95
|
*/
|
|
@@ -69,7 +97,7 @@ class SecretsConnector {
|
|
|
69
97
|
if (!config.redisUrl) {
|
|
70
98
|
throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
|
|
71
99
|
}
|
|
72
|
-
this.
|
|
100
|
+
this._keyring = loadMasterKeys(config.masterKeys);
|
|
73
101
|
this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
|
|
74
102
|
|
|
75
103
|
let host = config.redisUrl;
|
|
@@ -104,15 +132,23 @@ class SecretsConnector {
|
|
|
104
132
|
/**
|
|
105
133
|
* Resolve a secret ref to plaintext for the given invocation scope.
|
|
106
134
|
* @param {string} name - opaque secret ref
|
|
107
|
-
* @param {{ tenant_id: number|string, workspace_id
|
|
135
|
+
* @param {{ tenant_id: number|string, workspace_id: number|string }} scope
|
|
136
|
+
* Both ids are required; `workspace_id` must be a positive integer.
|
|
108
137
|
* @returns {Promise<string>}
|
|
109
138
|
*/
|
|
110
139
|
async get(name, scope = {}) {
|
|
111
140
|
if (typeof name !== 'string' || name.length === 0) {
|
|
112
|
-
throw new SecretResolutionError(
|
|
141
|
+
throw new SecretResolutionError(
|
|
142
|
+
'SECRET_REF_INVALID',
|
|
143
|
+
'[conn-infra-secrets] secret ref must be a non-empty string - Expected the ref name declared in '
|
|
144
|
+
+ 'the SecretBox. Fix: pass the ref, not the secret value.'
|
|
145
|
+
);
|
|
113
146
|
}
|
|
114
147
|
if (!this.client || !this.connected) {
|
|
115
|
-
throw new SecretResolutionError(
|
|
148
|
+
throw new SecretResolutionError(
|
|
149
|
+
'SECRET_STORE_UNAVAILABLE',
|
|
150
|
+
'[conn-infra-secrets] not connected - Expected an open Redis connection. Fix: call connect() first.'
|
|
151
|
+
);
|
|
116
152
|
}
|
|
117
153
|
const key = projectionKey(scope.tenant_id, scope.workspace_id, name, this._keyPrefix);
|
|
118
154
|
const blob = await this.client.get(key);
|
|
@@ -123,11 +159,29 @@ class SecretsConnector {
|
|
|
123
159
|
);
|
|
124
160
|
}
|
|
125
161
|
try {
|
|
126
|
-
|
|
162
|
+
// The projection key carries no version, so the version comes from the blob
|
|
163
|
+
// and is bound into the context with it — src/crypto.js says what that does
|
|
164
|
+
// and does not prove.
|
|
165
|
+
return openWithDeclaredVersion(blob, this._keyring, {
|
|
166
|
+
tenant_id: scope.tenant_id,
|
|
167
|
+
workspace_id: scope.workspace_id,
|
|
168
|
+
ref: name
|
|
169
|
+
});
|
|
127
170
|
} catch (err) {
|
|
171
|
+
if (err instanceof SecretCryptoError && err.code === 'SECRET_KEY_UNKNOWN') {
|
|
172
|
+
throw new SecretResolutionError(
|
|
173
|
+
'SECRET_KEY_UNKNOWN',
|
|
174
|
+
`[conn-infra-secrets] Unknown master key for secret "${name}" at ${key} - ${err.message}`,
|
|
175
|
+
{ cause: err }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
128
178
|
throw new SecretResolutionError(
|
|
129
179
|
'SECRET_DECRYPT_FAILED',
|
|
130
|
-
`[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} -
|
|
180
|
+
`[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - the blob does not `
|
|
181
|
+
+ 'open under the master key it names: it is corrupt, or it was sealed for another tenant, '
|
|
182
|
+
+ `workspace or ref. Fix: check that ${MASTER_KEYS_ENV} holds the key this value was sealed `
|
|
183
|
+
+ 'with, then re-seal the value via the biz-meta set-secret operation.',
|
|
184
|
+
{ cause: err }
|
|
131
185
|
);
|
|
132
186
|
}
|
|
133
187
|
}
|
|
@@ -149,7 +203,13 @@ class MockSecretsConnector {
|
|
|
149
203
|
isConnected() { return this.connected; }
|
|
150
204
|
async get(name, scope = {}) {
|
|
151
205
|
const v = this.store.get(MockSecretsConnector._k(scope, name));
|
|
152
|
-
if (v === undefined)
|
|
206
|
+
if (v === undefined) {
|
|
207
|
+
throw new SecretResolutionError(
|
|
208
|
+
'SECRET_NOT_FOUND',
|
|
209
|
+
`[conn-infra-secrets] Secret ref "${name}" not found in MockSecretsConnector - Expected a value `
|
|
210
|
+
+ 'seeded for this scope. Fix: call seed(scope, name, value) before get() in the test.'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
153
213
|
return v;
|
|
154
214
|
}
|
|
155
215
|
}
|
|
@@ -159,4 +219,5 @@ module.exports.SecretsConnector = SecretsConnector;
|
|
|
159
219
|
module.exports.MockSecretsConnector = MockSecretsConnector;
|
|
160
220
|
module.exports.SecretResolutionError = SecretResolutionError;
|
|
161
221
|
module.exports.projectionKey = projectionKey;
|
|
222
|
+
module.exports.normalizeWorkspaceId = normalizeWorkspaceId;
|
|
162
223
|
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 };
|
package/src/rotation.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Re-encryption — the half of a master-key rotation that carries the rows across.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT EXISTS. Re-encrypting the stored rows is a NECESSARY PART of rotating the
|
|
7
|
+
* master key, not a follow-up (owner decision 2026-09-09,
|
|
8
|
+
* api/docs/governance/confirmations/secret-leak-rotation.md 001), and with a list
|
|
9
|
+
* of keys it is the step that lets the outgoing key be dropped at all. There is
|
|
10
|
+
* deliberately no lazy re-encryption on write to lean on: a row nobody writes to
|
|
11
|
+
* would stay on the old key forever, so "zero rows on the old key" would never
|
|
12
|
+
* hold (owner decision 2026-09-14, `secretbox-master-keys` 001). The August 2026
|
|
13
|
+
* rotation is what that costs: the key was replaced, the rows were left alone, and
|
|
14
|
+
* six of ten rows of `oagen_meta.secret` were unreadable for two weeks.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT DOES NOT DO. It owns no store. The rows come from an injected repository
|
|
17
|
+
* and go back through it (principle 1, DI) — this package has no database and must
|
|
18
|
+
* not grow one; the adapter over `oagen_<svc>.secret` belongs to the service that
|
|
19
|
+
* owns those rows.
|
|
20
|
+
*
|
|
21
|
+
* THE STORE IS CLASSIFIED BEFORE THE FIRST WRITE. A store where some rows answer
|
|
22
|
+
* to the outgoing key and some to the incoming one is worse than one that answers
|
|
23
|
+
* wholly to either, so everything that can be refused while nothing has been
|
|
24
|
+
* written is refused there — a malformed row, a missing identity, a blob that is
|
|
25
|
+
* not a blob. Classification needs no key: the blob names its own.
|
|
26
|
+
*
|
|
27
|
+
* THE PLAINTEXT LIVES BETWEEN ONE open() AND THE seal() ON THE NEXT LINE. Nothing
|
|
28
|
+
* this module returns, renders or throws carries a value or a key — pinned by a
|
|
29
|
+
* control case in tests/unit/rotation.test.js rather than left to a reader's care.
|
|
30
|
+
*
|
|
31
|
+
* @see src/crypto.js — the sealing contract and the key list
|
|
32
|
+
* @see src/readability.js — the check that has to be green before a key is dropped
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { MasterKeyring, SecretCryptoError, open, seal, parseBlob } = require('./crypto');
|
|
36
|
+
|
|
37
|
+
const IDENTITY_FIELDS = ['tenant_id', 'workspace_id', 'ref', 'version'];
|
|
38
|
+
|
|
39
|
+
function refuse(code, problem, detail) {
|
|
40
|
+
throw new SecretCryptoError(code, `[conn-infra-secrets] ${problem} - Fix: ${detail}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The repository this tool needs, checked before it reads anything.
|
|
45
|
+
*
|
|
46
|
+
* `listSecrets()` returns every stored record — an array or an async iterable, so
|
|
47
|
+
* a store that streams does not have to materialize its table.
|
|
48
|
+
* `rewriteSecret(record, blob)` writes the new blob onto THAT row: same tenant,
|
|
49
|
+
* same workspace, same ref, SAME version. The blob is bound to the record's
|
|
50
|
+
* version (the AAD covers it), so a store that answered by creating a new version
|
|
51
|
+
* would write a blob bound to the wrong one.
|
|
52
|
+
*/
|
|
53
|
+
function assertRepository(repository) {
|
|
54
|
+
if (repository === null || typeof repository !== 'object') {
|
|
55
|
+
refuse(
|
|
56
|
+
'SECRET_ROTATION_INVALID',
|
|
57
|
+
`Missing repository - a store exposing listSecrets() and rewriteSecret() is required, got `
|
|
58
|
+
+ `${repository === null ? 'null' : typeof repository}`,
|
|
59
|
+
'inject the adapter over the table that holds the sealed rows; this package owns no store.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
for (const method of ['listSecrets', 'rewriteSecret']) {
|
|
63
|
+
if (typeof repository[method] !== 'function') {
|
|
64
|
+
refuse(
|
|
65
|
+
'SECRET_ROTATION_INVALID',
|
|
66
|
+
`Repository cannot serve the rotation - ${method}() is missing`,
|
|
67
|
+
`implement ${method}() on the injected repository: listSecrets() yields every stored record `
|
|
68
|
+
+ '{ tenant_id, workspace_id, ref, version, blob }, rewriteSecret(record, blob) writes the new '
|
|
69
|
+
+ 'blob onto that same row and version.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertKeyring(keyring) {
|
|
76
|
+
if (!(keyring instanceof MasterKeyring)) {
|
|
77
|
+
refuse(
|
|
78
|
+
'SECRET_KEYS_INVALID',
|
|
79
|
+
`Missing keyring - a MasterKeyring is required, got ${keyring === null ? 'null' : typeof keyring}`,
|
|
80
|
+
'pass loadMasterKeys(SECRETS_MASTER_KEYS); its FIRST key is the one every row is rewritten under.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The identity of one row, in words. Metadata only, never a value. */
|
|
86
|
+
function identity(record) {
|
|
87
|
+
return `tenant ${record.tenant_id} / workspace ${record.workspace_id} "${record.ref}" (v${record.version})`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertRecord(record, index) {
|
|
91
|
+
if (record === null || typeof record !== 'object') {
|
|
92
|
+
refuse(
|
|
93
|
+
'SECRET_ROTATION_INVALID',
|
|
94
|
+
`Unusable record at position ${index} - got ${record === null ? 'null' : typeof record}`,
|
|
95
|
+
'listSecrets() yields objects { tenant_id, workspace_id, ref, version, blob }.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const field of IDENTITY_FIELDS) {
|
|
99
|
+
if (record[field] === undefined || record[field] === null || String(record[field]).trim() === '') {
|
|
100
|
+
refuse(
|
|
101
|
+
'SECRET_ROTATION_INVALID',
|
|
102
|
+
`Record at position ${index} carries no ${field} - every row has to name the tenant, workspace, `
|
|
103
|
+
+ 'ref and version it belongs to, because all four are part of the bound context the value is '
|
|
104
|
+
+ 'sealed under',
|
|
105
|
+
`select ${field} alongside the sealed blob. The run is refused now, before anything is written: `
|
|
106
|
+
+ 'a refusal halfway through leaves the store half rotated.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (typeof record.blob !== 'string' || record.blob === '') {
|
|
111
|
+
refuse(
|
|
112
|
+
'SECRET_ROTATION_INVALID',
|
|
113
|
+
`Record ${identity(record)} carries no blob - the sealed value is required`,
|
|
114
|
+
'select the sealed blob for that row; a store keeping the parts in separate columns joins them '
|
|
115
|
+
+ 'with sealBlob() in its adapter.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Re-encrypt every row that is not yet on the active key.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} deps
|
|
124
|
+
* @param {{ listSecrets: Function, rewriteSecret: Function }} deps.repository injected store
|
|
125
|
+
* @param {MasterKeyring} deps.keyring the list; its first key is the one rows are rewritten under
|
|
126
|
+
* @returns {Promise<{
|
|
127
|
+
* total: number,
|
|
128
|
+
* active_key_id: string,
|
|
129
|
+
* rewritten: Array<{tenant_id, workspace_id, ref, version, from_key_id, to_key_id}>,
|
|
130
|
+
* skipped: Array<{tenant_id, workspace_id, ref, version, key_id}>,
|
|
131
|
+
* unreadable: Array<{tenant_id, workspace_id, ref, version, key_id}>
|
|
132
|
+
* }>}
|
|
133
|
+
*/
|
|
134
|
+
async function reencryptSecrets({ repository, keyring } = {}) {
|
|
135
|
+
assertRepository(repository);
|
|
136
|
+
assertKeyring(keyring);
|
|
137
|
+
|
|
138
|
+
// ---- Phase 1: classify the whole store. Nothing is written here. ----------
|
|
139
|
+
const listed = await repository.listSecrets();
|
|
140
|
+
if (listed === null || typeof listed !== 'object') {
|
|
141
|
+
refuse(
|
|
142
|
+
'SECRET_ROTATION_INVALID',
|
|
143
|
+
`listSecrets() returned ${listed === null ? 'null' : typeof listed} - an array or an async `
|
|
144
|
+
+ 'iterable of records is required',
|
|
145
|
+
'return the rows; an empty store returns an empty array, never null.'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const plan = { rotate: [], skipped: [], unreadable: [] };
|
|
150
|
+
let index = 0;
|
|
151
|
+
for await (const record of listed) {
|
|
152
|
+
assertRecord(record, index);
|
|
153
|
+
index += 1;
|
|
154
|
+
// The blob names its own key, so classification needs no key at all — and no
|
|
155
|
+
// decryption, so no plaintext exists during this phase.
|
|
156
|
+
const { keyId } = parseBlob(record.blob);
|
|
157
|
+
const row = {
|
|
158
|
+
tenant_id: record.tenant_id,
|
|
159
|
+
workspace_id: record.workspace_id,
|
|
160
|
+
ref: record.ref,
|
|
161
|
+
version: record.version,
|
|
162
|
+
key_id: keyId
|
|
163
|
+
};
|
|
164
|
+
if (keyId === keyring.activeKeyId) plan.skipped.push(row);
|
|
165
|
+
else if (keyring.has(keyId)) plan.rotate.push({ row, blob: record.blob });
|
|
166
|
+
else plan.unreadable.push(row);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Phase 2: the only phase that writes. ---------------------------------
|
|
170
|
+
const rewritten = [];
|
|
171
|
+
for (const { row, blob } of plan.rotate) {
|
|
172
|
+
const context = {
|
|
173
|
+
tenant_id: row.tenant_id, workspace_id: row.workspace_id, ref: row.ref, version: row.version
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
// The plaintext exists here and nowhere else: opened on one line, sealed on
|
|
177
|
+
// the next, out of scope after the write.
|
|
178
|
+
const value = open(blob, keyring, context);
|
|
179
|
+
// eslint-disable-next-line no-await-in-loop
|
|
180
|
+
await repository.rewriteSecret({ ...context }, seal(value, keyring, context));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new SecretCryptoError(
|
|
183
|
+
'SECRET_ROTATION_STOPPED',
|
|
184
|
+
`[conn-infra-secrets] Re-encryption stopped at ${identity(row)} - the store is now PARTLY `
|
|
185
|
+
+ `rotated: ${rewritten.length} row(s) already carry "${keyring.activeKeyId}", this row and `
|
|
186
|
+
+ 'everything after it still carry the key they had. The run is idempotent: fix the fault and '
|
|
187
|
+
+ 'run it again - a row already rewritten is skipped, so a second run finishes exactly what is '
|
|
188
|
+
+ `left. Do NOT drop any key from SECRETS_MASTER_KEYS meanwhile. Underlying fault: ${error.message}`,
|
|
189
|
+
{ cause: error }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
rewritten.push({ ...row, from_key_id: row.key_id, to_key_id: keyring.activeKeyId });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const entry of rewritten) delete entry.key_id;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
total: plan.rotate.length + plan.skipped.length + plan.unreadable.length,
|
|
199
|
+
active_key_id: keyring.activeKeyId,
|
|
200
|
+
rewritten,
|
|
201
|
+
skipped: plan.skipped,
|
|
202
|
+
unreadable: plan.unreadable
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { reencryptSecrets };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which `workspace_id` can address a secret — the rule, in one place.
|
|
5
|
+
*
|
|
6
|
+
* Every secret belongs to ONE concrete workspace. `workspace_id = 0` is not a
|
|
7
|
+
* value, and the "tenant-wide" scope it used to stand for — projected under a
|
|
8
|
+
* `-` segment — is retired (owner decision 2026-09-03, confirmation
|
|
9
|
+
* secretbox-tenant-wide-scope-001, resolving the doc conflict in favour of
|
|
10
|
+
* api/docs/biz/20-tenancy/multitenancy.md §1.6).
|
|
11
|
+
*
|
|
12
|
+
* Reader and writer apply ONE rule, and this module is where it lives: it is
|
|
13
|
+
* exported from the package's index as part of the public API, so the writing
|
|
14
|
+
* side imports it rather than keeping a second copy (owner decision 2026-09-04 —
|
|
15
|
+
* one rail per concern, .claude/rules/change-discipline.md). A connector library
|
|
16
|
+
* cannot depend on a biz service, so the dependency runs this way round: the
|
|
17
|
+
* library owns the rule, its consumers import it.
|
|
18
|
+
*
|
|
19
|
+
* It sits in its own module rather than in `index.js` because `crypto.js` needs
|
|
20
|
+
* it too: the workspace id is one of the four fields of the bound context (AAD),
|
|
21
|
+
* and a context built with `'3'` on the writing side and `3` on the reading side
|
|
22
|
+
* would be two different contexts — the blob would simply stop opening. One rule,
|
|
23
|
+
* one normalization, both sides.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {*} value
|
|
28
|
+
* @returns {number|null} the normalized id, or null if the value is unusable
|
|
29
|
+
*/
|
|
30
|
+
function normalizeWorkspaceId(value) {
|
|
31
|
+
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
|
32
|
+
if (typeof value === 'string' && value.trim() === '') return null;
|
|
33
|
+
const n = Number(value);
|
|
34
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { normalizeWorkspaceId };
|