@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/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 };
|