@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Changelog — @onlineapps/conn-infra-secrets
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [3.0.0] — 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Changed — BREAKING
|
|
10
|
+
|
|
11
|
+
- **The Redis credential and topology come from `redisUrl`, and from nothing else.** The constructor reads `username` (Redis 6 ACL), `password`, host, port and the database index off the URL — `redis://[user:password@]host:port[/db]`, `rediss://` for TLS — decoding percent-escapes exactly once. An absent userinfo means no AUTH at all: `username`/`password` are then not passed to ioredis, never as `''`, which would be sent as `AUTH ""`.
|
|
12
|
+
Measured reason (INFRA, dev, 2026-09-15 21:20Z): the constructor took `hostname`/`port` only and expected the password beside the URL, so a URL carrying userinfo lost it — ioredis connected without AUTH, the gateway went into a crash loop (RestartCount 51) and the dispatcher and ingest failed on their first secret read. `REDIS_URL` is the platform's one rail for this fact (INFRA-DOCS 2026-09-14; `service-common` 3.0.2 hands the whole URL to node-redis, `mq-client-core` 3.2.0 reads the credential off the URL), so a value beside it was a second rail — `.claude/rules/change-discipline.md` § One rail per concern.
|
|
13
|
+
- **A `redisUrl` that is not a `redis:`/`rediss:` URL is refused at construction**, as is one without a host or without a port. The silent branch that treated such a value as the hostname and defaulted the port to `6379` is gone: it hardcoded topology (principle 2) behind a fallback (principle 3), and it is what let a misspelt value reach a live boot. No message echoes the URL or the refused value — both carry the password.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- **`config.password`** — refused by name, not ignored (a retired option that is silently dropped leaves the caller believing a password reached the server). Four questions, `.claude/rules/change-discipline.md` § Removing: (1) it came to exist because the constructor read only host and port off the URL, so a Redis with `requirepass` needed the password from somewhere — `ServiceWrapper` began setting it on 2026-09-11; (2) no part of the concept carried it: the platform decision is that `REDIS_URL` carries endpoint AND credential, and it never sanctioned a second input; (3) after this change nothing reads it — the two callers that set it (`api/shared/connector/service-wrapper/src/ServiceWrapper.js`, `api_biz/ingest/scripts/lib/build-operator-context.js`) each re-derive the value from the very same URL, a copy of the parse rather than a second source; (4) the replacement is more conceptual — one rail, one parse, and a URL with userinfo can no longer lose its password without a word.
|
|
18
|
+
- **`config.db`** — refused by name; the database index is the URL's path. Four questions: (1) it came to exist as `config.db || 0`, so a caller could select a database; (2) nothing in the concept carried it — no platform document declares a non-default database for the SecretBox projection; (3) zero callers pass a value: only `ServiceWrapper` forwards `secretsConfig.db`, which no service config declares (`api_biz/ingest/config/service/config.json` has `"secrets": { "enabled": true }`); (4) the path is more conceptual — the database belongs to the address, and `|| 0` additionally swallowed a malformed declaration into a 0 nobody asked for. With no path declared the connector passes no `db` at all and ioredis selects its own default, database 0 — named here rather than re-implemented.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `tests/integration/redisAuthFromUrl.integration.test.js` — a throwaway `redis:7-alpine` with `requirepass` on an ephemeral loopback port (the shared dev cache runs without one and cannot answer this question): the happy path reads a real sealed secret back through an authenticated connection, the failure path shows the same URL without the password refused with `NOAUTH`, and the control case proves the server still required AUTH after the green run.
|
|
23
|
+
|
|
24
|
+
### Changed — a failed `connect()` carries the server's verdict (d.566)
|
|
25
|
+
|
|
26
|
+
`connect()` handed the caller ioredis's end-of-socket sentence — `Connection is closed.`,
|
|
27
|
+
no context, no `cause`, no verdict — while the server's own answer reached `_lastError`
|
|
28
|
+
and the log alone. Measured against a `redis:7-alpine --requirepass` on 2026-09-16: a URL
|
|
29
|
+
without the password (`NOAUTH Authentication required.`), a URL with the wrong password
|
|
30
|
+
(`WRONGPASS invalid username-password pair or user is disabled.`) and a port with no
|
|
31
|
+
listener (`connect ECONNREFUSED …`) produced byte-identical caller errors. That is why dev
|
|
32
|
+
incident B88 (gateway crash loop, RestartCount 51) could not be read from the error at all.
|
|
33
|
+
|
|
34
|
+
The caller is now told which of two things happened:
|
|
35
|
+
|
|
36
|
+
- the server **ANSWERED and refused** — `NOAUTH`, `WRONGPASS`, `NOPERM`. The message
|
|
37
|
+
quotes the server's sentence and names the credential in `REDIS_URL` as the fix, and the
|
|
38
|
+
server's error travels as `cause`.
|
|
39
|
+
- the server **never answered** — the message says exactly that, quotes what the client
|
|
40
|
+
rejected with, and adds that a refused credential answers NOAUTH/WRONGPASS/NOPERM and is
|
|
41
|
+
reported as such, so this is not one. That error travels as `cause`.
|
|
42
|
+
|
|
43
|
+
It is the rule `@onlineapps/mq-client-core` d.295 states for a broker refusal (403/530/406),
|
|
44
|
+
on the same reasoning: an ANSWERED refusal is permanent, because the next attempt asks the
|
|
45
|
+
same question and gets the same answer.
|
|
46
|
+
|
|
47
|
+
So a refusal also **ends the attempts**, and a failed attempt takes its client with it.
|
|
48
|
+
Measured on all three failure paths: ioredis was left in status `reconnecting` after
|
|
49
|
+
`connect()` rejected, so a credential the server had already refused was asked again on a
|
|
50
|
+
loop, and the connector — which never sets `connected` on a later reconnect — would never
|
|
51
|
+
have used that socket anyway. A refusal on the `error` channel now disconnects the client
|
|
52
|
+
at once, and every failed `connect()` drops it.
|
|
53
|
+
|
|
54
|
+
Only the endpoint (`host:port`) is named in these messages, never the URL: this package
|
|
55
|
+
keeps the parsed fields and drops the value precisely because the value carries the
|
|
56
|
+
password.
|
|
57
|
+
|
|
58
|
+
### Added (d.566)
|
|
59
|
+
|
|
60
|
+
- `tests/unit/connectVerdict.test.js` — both sentences asserted whole, the `cause` as a
|
|
61
|
+
value, no part of the credential in either, the refusal that ends the attempts, and two
|
|
62
|
+
controls (an accepted `connect()` still resolves `true` and keeps its client; a
|
|
63
|
+
transport error after the connection is open is NOT treated as a refusal).
|
|
64
|
+
- `tests/integration/redisAuthFromUrl.integration.test.js` grew the two failure paths this
|
|
65
|
+
change is about — a wrong password named `WRONGPASS` with no part of it echoed, and a
|
|
66
|
+
port with no listener reported as "no verdict" with `cause.code` `ECONNREFUSED` — and
|
|
67
|
+
its NOAUTH path now asserts the verdict the caller gets instead of `Connection is
|
|
68
|
+
closed.`. The error-message census moved 31 → 33.
|
|
69
|
+
|
|
70
|
+
### Fixed — a store that comes back is read again (d.573)
|
|
71
|
+
|
|
72
|
+
`connected` was written once, when `connect()` resolved, and never read off the client
|
|
73
|
+
again. The connector therefore had no idea what the client was doing: a Redis that went
|
|
74
|
+
away mid-run left the flag `true`, so `get()` handed the read to a client with no socket
|
|
75
|
+
instead of naming the missing store — and nothing could ever put the flag back, because
|
|
76
|
+
the reconnection ioredis performs on its own was not watched. One outage decided the fate
|
|
77
|
+
of every secret read for the rest of the process's life.
|
|
78
|
+
|
|
79
|
+
`connected` is now the client's READY state — socket open, AUTH and SELECT done — one rail
|
|
80
|
+
with `@onlineapps/conn-base-cache` d.572: `ready` sets it, `close` and `end` take it down,
|
|
81
|
+
the next `ready` puts it back. Every handler acts only for the client this connector still
|
|
82
|
+
holds, so the verdict of d.566 stays exactly as terminal as it was: a client the server
|
|
83
|
+
refused is discarded, and nothing it emits afterwards can put the connector back in
|
|
84
|
+
service behind the caller's back.
|
|
85
|
+
|
|
86
|
+
### Tests (d.573)
|
|
87
|
+
|
|
88
|
+
Unit 177 → 182, integration 26 → 28. New `tests/unit/connectionLifecycle.test.js` asserts
|
|
89
|
+
the state machine as values: the read that reaches the store answers `SECRET_NOT_FOUND`,
|
|
90
|
+
the one after a `close` answers `SECRET_STORE_UNAVAILABLE` and never touches the client,
|
|
91
|
+
and the flag through an outage reads `[true, false, true]` (it read `[true, true, true]`
|
|
92
|
+
before the fix), with `end` as a second way down and a refusal that survives a `ready`
|
|
93
|
+
from its discarded client as the control. New
|
|
94
|
+
`tests/integration/reconnect.integration.test.js` asks a real server: its own
|
|
95
|
+
`redis:7` container on a fixed loopback port, a value sealed and read back, `docker stop`
|
|
96
|
+
→ `SECRET_STORE_UNAVAILABLE`, `docker start` → the same read succeeds, each step waited
|
|
97
|
+
for on the client's own `close`/`ready` event rather than on a guessed delay, with a
|
|
98
|
+
`WRONGPASS` refusal as the control that stays terminal.
|
|
99
|
+
|
|
100
|
+
## [2.0.0] — 2026-09-14
|
|
101
|
+
|
|
102
|
+
### Changed — BREAKING
|
|
103
|
+
|
|
104
|
+
- **`SECRETS_MASTER_KEYS` replaces `SECRETS_MASTER_KEY`**: the package reads an ordered LIST of master keys (`<key_id>:<base64 key>[,…]`). The first key seals every new write; every key in the list opens what it sealed, selected by the blob's `key_id`. A blob naming a key that is not in the list is refused with `SECRET_KEY_UNKNOWN` — there is no "try every key" pass. `SecretsConnector` takes `masterKeys` instead of `masterKeyBase64` (owner decision 2026-09-14, `docs/governance/confirmations/secretbox-master-keys.md` 001).
|
|
105
|
+
- **New blob layout** `format[1] | keyIdLen[1] | key_id | version[4] | iv[12] | authTag[16] | ciphertext`, with the bound context (AAD) `tenant_id | workspace_id | ref | version`. A blob lifted into another tenant, workspace, ref or version no longer opens. The previous layout is not read — a blob without the format byte is refused (owner decision 2026-09-03, `docs/governance/confirmations/secretbox-blob-format.md` 001).
|
|
106
|
+
- `open()` requires the row's version and compares it with the blob's; the Redis projection reader, which has no version, uses the separately named `openWithDeclaredVersion()`.
|
|
107
|
+
|
|
108
|
+
### Added
|
|
109
|
+
|
|
110
|
+
- **`reencryptSecrets({ repository, keyring })`** (`src/rotation.js`) — rewrites every stored row that is not yet on the active key, through an injected store adapter (`listSecrets()` / `rewriteSecret(record, blob)`; this package owns no database). Idempotent; the whole store is classified before the first write; a row sealed with a key nobody holds is reported, never written, and does not stop the run. No secret value and no key material reaches its result or its messages.
|
|
111
|
+
- **`checkReadability({ records, keyring })`** (`src/readability.js`) — the mandatory step before a key leaves the list: it OPENS every stored blob with the keys that would remain and reports every row that would not open, with the reason (`key_not_in_list`, `does_not_open`, `malformed_blob`). A blob that cannot be parsed is a finding, not an exception — the point is the complete list. `MasterKeyring.without(id)` builds the list a removal is checked against and refuses to drop the active key.
|
|
112
|
+
- `loadMasterKeys()` + `MasterKeyring` (`activeKeyId`, `keyIds`, `has`, `keyFor`, `activeKey`, `without`), `bindingContext()`, `parseBlob()`, `SecretCryptoError`, and `MASTER_KEYS_ENV` — the one declaration of the configuration key's name and format.
|
|
113
|
+
- `normalizeWorkspaceId` moved to `src/workspaceId.js` (the bound context needs the same rule as the projection key); it stays exported from the package index, unchanged.
|
|
114
|
+
|
|
115
|
+
## [1.1.0] — 2026-08-29
|
|
116
|
+
|
|
117
|
+
- Changelog started with this release; the history before it is in git (`git log -- shared/connector/conn-infra-secrets`).
|
package/README.md
CHANGED
|
@@ -1,25 +1,94 @@
|
|
|
1
|
+
> Status: current
|
|
2
|
+
> Owns: the resolution of `ctx.secrets.get(ref)` — reading the SecretBox Redis projection and decrypting it in-process
|
|
3
|
+
|
|
4
|
+
<!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
|
|
5
|
+
Uniform: [library/connector](../conn-orch-validator/manifests/library.manifest.json)
|
|
6
|
+
|
|
7
|
+
Duty sections that apply:
|
|
8
|
+
|
|
9
|
+
- `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
|
|
10
|
+
- `connector`: L-CONNECTOR-ENV
|
|
11
|
+
<!-- END GENERATED: library-uniform -->
|
|
12
|
+
|
|
1
13
|
# @onlineapps/conn-infra-secrets
|
|
2
14
|
|
|
3
15
|
Resolution connector for `ctx.secrets.get(ref)`. Reads the encrypted Redis
|
|
4
16
|
projection written by `biz-meta` (SecretBox) and decrypts it in-process with the
|
|
5
17
|
injected AES-256-GCM master key. Authoritative store (`oagen_meta.secret`) is owned
|
|
6
18
|
by `biz-meta` and never touched here. Projection key contract:
|
|
7
|
-
`state:meta:secret:<tenant>:<workspace
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
`state:meta:secret:<tenant>:<workspace>:<ref>`.
|
|
20
|
+
|
|
21
|
+
**Every secret belongs to one concrete workspace.** A usable `workspace_id` is an
|
|
22
|
+
integer >= 1 (as a number or its decimal string); `0`, `'0'`, `null` and an omitted
|
|
23
|
+
value are all **refused** with `SECRET_SCOPE_MISSING`, before any Redis round trip.
|
|
24
|
+
There is no tenant-wide scope and no `-` segment: `workspace_id = 0` is not a value
|
|
25
|
+
(owner decision 2026-09-03,
|
|
26
|
+
[`secretbox-tenant-wide-scope.md`](../../../docs/governance/confirmations/secretbox-tenant-wide-scope.md)
|
|
27
|
+
001, resolving the doc conflict in favour of
|
|
28
|
+
[`multitenancy.md`](../../../docs/biz/20-tenancy/multitenancy.md) §1.6). Reader and
|
|
29
|
+
writer apply ONE rule, and this package owns it: `normalizeWorkspaceId` is exported
|
|
30
|
+
(see **Public API** below) so the writing side imports it instead of keeping a second
|
|
31
|
+
copy (owner decision 2026-09-04 — one rail per concern,
|
|
32
|
+
[`change-discipline.md`](../../../../.claude/rules/change-discipline.md)). A key this
|
|
33
|
+
reader refuses to build is therefore a key nothing writes.
|
|
10
34
|
|
|
11
35
|
This package also owns the **sealing contract** (`src/crypto.js`): `seal()` /
|
|
12
|
-
`encrypt()` + `sealBlob()` for the writing side, `open()` for every reader
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
`encrypt()` + `sealBlob()` for the writing side, `open()` for every reader. Both
|
|
37
|
+
directions in one module so seal→open is tested across the real boundary, not
|
|
38
|
+
against a reimplementation.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
format[1]=0x02 | keyIdLen[1] | key_id | version[4 BE] | iv[12] | authTag[16] | ciphertext
|
|
42
|
+
AAD = "<tenant_id>|<workspace_id>|<ref>|<version>"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The blob **names the key that sealed it**, so a list of keys opens it without
|
|
46
|
+
guessing, and the **bound context (AAD)** ties the ciphertext to the one row it
|
|
47
|
+
belongs to: a blob lifted into another tenant, workspace, ref or version does not
|
|
48
|
+
open, even with the right key (owner decision 2026-09-03,
|
|
49
|
+
[`secretbox-blob-format.md`](../../../docs/governance/confirmations/secretbox-blob-format.md)
|
|
50
|
+
001). The layout before `key_id` is **not read** — a blob without the format byte
|
|
51
|
+
is refused, never guessed at; no reader for it survived the migration.
|
|
52
|
+
|
|
53
|
+
## Master keys — a list, and a rotation that is a switch
|
|
54
|
+
|
|
55
|
+
`SECRETS_MASTER_KEYS` is an **ordered list**, and this package owns both its name
|
|
56
|
+
and its format (owner decision 2026-09-14,
|
|
57
|
+
[`secretbox-master-keys.md`](../../../docs/governance/confirmations/secretbox-master-keys.md)
|
|
58
|
+
001):
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
SECRETS_MASTER_KEYS="k3:<base64 32-byte key>,k2:<base64 32-byte key>"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- the **first** entry is the active key — every new write is sealed with it;
|
|
65
|
+
- **every** entry decrypts, selected by the blob's own `key_id`;
|
|
66
|
+
- a blob whose `key_id` is not in the list is **refused** (`SECRET_KEY_UNKNOWN`).
|
|
67
|
+
There is no "try every key" pass: it would be the silent fallback
|
|
68
|
+
[`architecture-principles.md`](../../../../.claude/rules/architecture-principles.md)
|
|
69
|
+
§3 forbids, and it would leave the readability check with nothing to measure.
|
|
70
|
+
|
|
71
|
+
So a rotation is a **switch of the list**, not a jump: put the incoming key first,
|
|
72
|
+
re-encrypt (`reencryptSecrets`), check that everything still opens without the
|
|
73
|
+
outgoing key (`checkReadability`), and only then drop it. There is deliberately
|
|
74
|
+
**no lazy re-encryption on write** — a row nobody writes to would stay on the old
|
|
75
|
+
key forever, so "zero unreadable rows" would never hold. What that costs when it
|
|
76
|
+
is skipped is measured: the August 2026 rotation replaced the key and left the
|
|
77
|
+
rows alone, and six of ten rows of `oagen_meta.secret` were unreadable for two
|
|
78
|
+
weeks before anyone noticed.
|
|
79
|
+
|
|
80
|
+
The keys themselves are **injected, never read from the environment here**
|
|
81
|
+
(principle 1, and the connector duty `L-CONNECTOR-ENV`): the consumer reads
|
|
82
|
+
`SECRETS_MASTER_KEYS` and hands the string to `loadMasterKeys()` or to the
|
|
83
|
+
connector's `masterKeys` option. `MASTER_KEYS_ENV` is exported so the name lives
|
|
84
|
+
in one place.
|
|
16
85
|
|
|
17
86
|
Canonical design: [`api/docs/architecture/secretbox.md`](../../../docs/architecture/secretbox.md).
|
|
18
87
|
|
|
19
88
|
## Usage (wired by ServiceWrapper)
|
|
20
89
|
```js
|
|
21
90
|
const SecretsConnector = require('@onlineapps/conn-infra-secrets');
|
|
22
|
-
const secrets = new SecretsConnector({ redisUrl: process.env.REDIS_URL,
|
|
91
|
+
const secrets = new SecretsConnector({ redisUrl: process.env.REDIS_URL, masterKeys: process.env.SECRETS_MASTER_KEYS });
|
|
23
92
|
await secrets.connect();
|
|
24
93
|
// ContextBuilder facade calls: secrets.get(ref, { tenant_id, workspace_id })
|
|
25
94
|
```
|
|
@@ -27,14 +96,187 @@ await secrets.connect();
|
|
|
27
96
|
Handlers only ever call `ctx.secrets.get(ref)`; the ContextBuilder facade injects
|
|
28
97
|
the invocation scope.
|
|
29
98
|
|
|
99
|
+
**The Redis credential lives in the URL and nowhere else.** `redisUrl` carries the
|
|
100
|
+
endpoint, the optional Redis 6 ACL user, the password and the database index —
|
|
101
|
+
`redis://[user:password@]host:port[/db]`, `rediss://` for TLS — exactly as
|
|
102
|
+
`service-common` and `mq-client-core` read it. Percent-encode a password that
|
|
103
|
+
contains `@`, `:` or `/`. The options `password` and `db` beside the URL are
|
|
104
|
+
**refused by name**: they were a second rail for a fact the URL already carries,
|
|
105
|
+
and while they existed a URL with userinfo lost its password silently (INFRA, dev,
|
|
106
|
+
2026-09-15: ioredis connected without AUTH, gateway crash loop, RestartCount 51).
|
|
107
|
+
A value that is not a `redis:`/`rediss:` URL, or one without a host or a port, is
|
|
108
|
+
refused at construction — this package declares no topology default, not even 6379.
|
|
109
|
+
|
|
110
|
+
**A failed `connect()` says what the server answered.** A server that ANSWERED and
|
|
111
|
+
refused the credential — `NOAUTH`, `WRONGPASS`, `NOPERM` — is permanent: the message
|
|
112
|
+
quotes the server's sentence, names `REDIS_URL` as the fix, carries the original error
|
|
113
|
+
as `cause`, and the attempts END there rather than looping on a question already
|
|
114
|
+
answered. A server that never answered is reported as exactly that and may be retried.
|
|
115
|
+
Only `host:port` appears in these messages; the URL carries the password.
|
|
116
|
+
|
|
117
|
+
## Public API
|
|
118
|
+
```js
|
|
119
|
+
const SecretsConnector = require('@onlineapps/conn-infra-secrets'); // default = the connector
|
|
120
|
+
const {
|
|
121
|
+
SecretsConnector, MockSecretsConnector, SecretResolutionError,
|
|
122
|
+
projectionKey, normalizeWorkspaceId, create,
|
|
123
|
+
} = require('@onlineapps/conn-infra-secrets');
|
|
124
|
+
|
|
125
|
+
// the sealing contract and the key list
|
|
126
|
+
const {
|
|
127
|
+
loadMasterKeys, MasterKeyring, SecretCryptoError, MASTER_KEYS_ENV,
|
|
128
|
+
bindingContext, seal, open, openWithDeclaredVersion,
|
|
129
|
+
encrypt, decrypt, sealBlob, parseBlob,
|
|
130
|
+
} = require('@onlineapps/conn-infra-secrets/src/crypto');
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `loadMasterKeys(spec) => MasterKeyring`
|
|
134
|
+
Parses `SECRETS_MASTER_KEYS`. The keyring is immutable and answers
|
|
135
|
+
`activeKeyId`, `keyIds`, `size`, `has(id)`, `keyFor(id)`, `activeKey()` and
|
|
136
|
+
`without(id)` — the last one is the list a key removal is checked against, and it
|
|
137
|
+
refuses to drop the active key. Every refusal names the problem and the fix, and
|
|
138
|
+
**no message ever carries key material**.
|
|
139
|
+
|
|
140
|
+
### `seal(value, keyring, context)` / `open(blob, keyring, context)`
|
|
141
|
+
`context` is the row: `{ tenant_id, workspace_id, ref, version }`. `open()`
|
|
142
|
+
compares the context's version with the blob's and refuses a mismatch — so
|
|
143
|
+
renumbering a version is a re-encryption, the cost accepted in
|
|
144
|
+
`secretbox-blob-format` 001.
|
|
145
|
+
|
|
146
|
+
### `openWithDeclaredVersion(blob, keyring, { tenant_id, workspace_id, ref })`
|
|
147
|
+
For the Redis projection, whose key (`…:<tenant>:<workspace>:<ref>`) carries no
|
|
148
|
+
version: the version is taken from the blob and bound into the context with it.
|
|
149
|
+
It is a separate name, not an optional argument, because it proves strictly less
|
|
150
|
+
— the tenant, workspace and ref are still bound, the version is the blob's own
|
|
151
|
+
claim.
|
|
152
|
+
|
|
153
|
+
### `normalizeWorkspaceId(value) => number|null`
|
|
154
|
+
The rule "which `workspace_id` can address a secret", in one place. Accepts an
|
|
155
|
+
integer >= 1 as a number or as its decimal string and returns it **as a number**;
|
|
156
|
+
every other value — `0`, `'0'`, negative, fractional, non-numeric, empty/blank
|
|
157
|
+
string, `null`, `undefined`, boolean, `NaN`, `Infinity`, object, array — is
|
|
158
|
+
unusable and yields `null`.
|
|
159
|
+
|
|
160
|
+
It **never throws**: refusal is a return value, so each caller decides what
|
|
161
|
+
refusing means. This connector turns it into `SECRET_SCOPE_MISSING` before any
|
|
162
|
+
Redis round trip (`projectionKey`); a writer of the projection refuses to build
|
|
163
|
+
the key at all. Import it — do not reimplement it: a second copy of this rule is
|
|
164
|
+
a defect by default ([`change-discipline.md`](../../../../.claude/rules/change-discipline.md)
|
|
165
|
+
§ One rail per concern).
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const { normalizeWorkspaceId } = require('@onlineapps/conn-infra-secrets');
|
|
169
|
+
normalizeWorkspaceId(7); // 7
|
|
170
|
+
normalizeWorkspaceId('10'); // 10
|
|
171
|
+
normalizeWorkspaceId('0'); // null → the caller must refuse
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Re-encryption — `reencryptSecrets({ repository, keyring })`
|
|
175
|
+
|
|
176
|
+
The step that makes the outgoing key droppable. It reads every stored record,
|
|
177
|
+
rewrites the ones still on an old key under the **active** key, and reports the
|
|
178
|
+
ones it cannot open.
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const { loadMasterKeys } = require('@onlineapps/conn-infra-secrets/src/crypto');
|
|
182
|
+
const { reencryptSecrets } = require('@onlineapps/conn-infra-secrets/src/rotation');
|
|
183
|
+
|
|
184
|
+
const result = await reencryptSecrets({
|
|
185
|
+
keyring: loadMasterKeys(process.env.SECRETS_MASTER_KEYS),
|
|
186
|
+
repository, // the store adapter — see below
|
|
187
|
+
});
|
|
188
|
+
// { total, active_key_id, rewritten: [...], skipped: [...], unreadable: [...] }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**The store is injected** (principle 1): this package owns no database, and the
|
|
192
|
+
adapter over `oagen_<service>.secret` belongs to the service that owns those rows.
|
|
193
|
+
It has to provide exactly two methods:
|
|
194
|
+
|
|
195
|
+
| method | contract |
|
|
196
|
+
|---|---|
|
|
197
|
+
| `listSecrets()` | every stored record `{ tenant_id, workspace_id, ref, version, blob }` — an array or an async iterable, so a store that streams need not materialize its table |
|
|
198
|
+
| `rewriteSecret(record, blob)` | write the new blob onto **that same row and version**. The blob is bound to the record's version (the AAD covers it), so a store that answered by creating a NEW version would write a blob bound to the wrong one |
|
|
199
|
+
|
|
200
|
+
Properties, each pinned by a test:
|
|
201
|
+
|
|
202
|
+
- **Idempotent** — a second run writes nothing; rows already on the active key are
|
|
203
|
+
skipped, so a run interrupted by a failing store is finished simply by running
|
|
204
|
+
it again.
|
|
205
|
+
- **The whole store is classified before the first write** — a malformed record or
|
|
206
|
+
an unreadable blob refuses the run while nothing has been written, because a
|
|
207
|
+
half-rotated store answers wholly to no key.
|
|
208
|
+
- **A row on a key nobody holds is reported, never written, and does not stop the
|
|
209
|
+
run** — it was already unreadable before the rotation started, and it needs its
|
|
210
|
+
value rewritten from its source. (The six such rows of `oagen_meta.secret` are
|
|
211
|
+
`biz-meta`'s data decision.)
|
|
212
|
+
- **Nothing it returns or throws carries a secret value or key material.**
|
|
213
|
+
|
|
214
|
+
## Readability — `checkReadability({ records, keyring })`
|
|
215
|
+
|
|
216
|
+
The mandatory step **before a key is removed from the list**: it answers, for a
|
|
217
|
+
given key list, whether every stored blob still opens.
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const { checkReadability } = require('@onlineapps/conn-infra-secrets/src/readability');
|
|
221
|
+
|
|
222
|
+
const keyring = loadMasterKeys(process.env.SECRETS_MASTER_KEYS);
|
|
223
|
+
const result = await checkReadability({ records, keyring: keyring.without('k1') });
|
|
224
|
+
// { total, readable, unreadable: [{ tenant_id, workspace_id, ref, version, key_id, reason }] }
|
|
225
|
+
// drop "k1" only while result.unreadable is empty
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`records` are the same records `listSecrets()` yields. The check **opens** every
|
|
229
|
+
blob rather than only comparing its `key_id`, so a row whose key is listed but
|
|
230
|
+
whose blob is corrupt or bound to another context is reported too. A blob that
|
|
231
|
+
cannot be parsed at all is a finding (`malformed_blob`), never an exception: the
|
|
232
|
+
point is the complete list of what will not open. Nothing it returns carries a
|
|
233
|
+
value or a key.
|
|
234
|
+
|
|
235
|
+
The order of a rotation, then, with nothing left to remember:
|
|
236
|
+
|
|
237
|
+
1. put the incoming key FIRST in `SECRETS_MASTER_KEYS`, keep the outgoing one;
|
|
238
|
+
2. `reencryptSecrets(...)` — every row moves to the incoming key;
|
|
239
|
+
3. `checkReadability({ records, keyring: keyring.without('<outgoing id>') })` —
|
|
240
|
+
it must report `unreadable: []`;
|
|
241
|
+
4. only then remove the outgoing key from `SECRETS_MASTER_KEYS`.
|
|
242
|
+
|
|
243
|
+
Step 3 is what was missing in August 2026, and skipping it is what left six rows
|
|
244
|
+
of `oagen_meta.secret` sealed with a key that no longer exists anywhere.
|
|
245
|
+
|
|
30
246
|
## Errors (no fallbacks)
|
|
31
247
|
- `SECRET_NOT_FOUND` — no projection for the ref/scope.
|
|
32
|
-
- `
|
|
33
|
-
|
|
248
|
+
- `SECRET_KEY_UNKNOWN` — the blob names a `key_id` that is not in
|
|
249
|
+
`SECRETS_MASTER_KEYS`. The row is still on a key this service does not hold:
|
|
250
|
+
put it back and re-encrypt, or rewrite the value.
|
|
251
|
+
- `SECRET_DECRYPT_FAILED` — the blob does not open under the key it names:
|
|
252
|
+
corrupt, or sealed for another tenant, workspace or ref.
|
|
253
|
+
- `SECRET_SCOPE_MISSING` — the scope cannot address one concrete secret: missing
|
|
254
|
+
`tenant_id`, or a `workspace_id` that is not a positive integer (`0`, `'0'`,
|
|
255
|
+
`null`, omitted).
|
|
256
|
+
- `SECRET_REF_INVALID` — bad call.
|
|
257
|
+
|
|
258
|
+
Every message opens with `[conn-infra-secrets]`, names the problem and ends with
|
|
259
|
+
the fix — the format of `.claude/rules/architecture-principles.md` §5,
|
|
260
|
+
`[Context] Problem - Expected/Fix`. `SECRET_DECRYPT_FAILED` additionally carries
|
|
261
|
+
the `node:crypto` error as `cause`, which is what separates a wrong master key
|
|
262
|
+
from a truncated blob; the other codes have no cause because nothing under them
|
|
263
|
+
failed.
|
|
34
264
|
|
|
35
265
|
## Test
|
|
36
|
-
- `npm run test:unit` —
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
266
|
+
- `npm run test:unit` — the key list (parsing, refusals, immutability), seal/open
|
|
267
|
+
round trip against a frozen layout fixture built by an outside generator, the
|
|
268
|
+
bound context (a blob moved to another tenant / workspace / ref / version does
|
|
269
|
+
not open), key selection by `key_id` and the refusal of an unlisted one,
|
|
270
|
+
tamper/not-found paths, scope refusal, the exported `normalizeWorkspaceId`
|
|
271
|
+
contract, mock.
|
|
272
|
+
- `npm run test:unit` (rotation/readability) — re-encryption and the readability
|
|
273
|
+
check against REAL crypto over an injected in-memory store: idempotence, the
|
|
274
|
+
classify-before-write order, the row on a dropped key, the failed write, and
|
|
275
|
+
controls that no value and no key material reaches a result or a message.
|
|
276
|
+
- `npm run test:integration` — real Redis. The re-encryption run uses the live
|
|
277
|
+
projection as its store and reads the rewritten rows back through
|
|
278
|
+
`SecretsConnector.get()` with the key list that remains. Needs `REDIS_HOST` +
|
|
279
|
+
`REDIS_PORT` (dev stack: `REDIS_HOST=127.0.0.1 REDIS_PORT=33030`, container
|
|
280
|
+
`api_node_cache`). **NOT RUN here:** the authoritative store — the rows live in
|
|
281
|
+
`oagen_<service>.secret` (MySQL) and this package has no database dependency by
|
|
282
|
+
design, so that adapter and its integration test belong to the owning service.
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-infra-secrets",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Secret resolution connector for ctx.secrets.get(ref) — reads the SecretBox Redis projection and decrypts in-process (AES-256-GCM)",
|
|
5
|
+
"oa": {
|
|
6
|
+
"category": "connector"
|
|
7
|
+
},
|
|
5
8
|
"main": "src/index.js",
|
|
6
9
|
"scripts": {
|
|
7
|
-
"test": "
|
|
10
|
+
"test": "npm run test:unit && npm run test:integration",
|
|
8
11
|
"test:unit": "jest tests/unit",
|
|
9
12
|
"test:integration": "jest --config=jest.integration.config.js"
|
|
10
13
|
},
|
|
@@ -24,7 +27,7 @@
|
|
|
24
27
|
"jest": "^29.7.0"
|
|
25
28
|
},
|
|
26
29
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
30
|
+
"node": ">=24.0.0 <25"
|
|
28
31
|
},
|
|
29
32
|
"publishConfig": {
|
|
30
33
|
"access": "public",
|