@onlineapps/conn-infra-secrets 1.0.0 → 1.1.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/README.md +14 -2
- package/jest.integration.config.js +18 -0
- package/package.json +3 -2
- package/src/crypto.js +81 -6
- package/src/index.js +20 -5
package/README.md
CHANGED
|
@@ -4,7 +4,15 @@ Resolution connector for `ctx.secrets.get(ref)`. Reads the encrypted Redis
|
|
|
4
4
|
projection written by `biz-meta` (SecretBox) and decrypts it in-process with the
|
|
5
5
|
injected AES-256-GCM master key. Authoritative store (`oagen_meta.secret`) is owned
|
|
6
6
|
by `biz-meta` and never touched here. Projection key contract:
|
|
7
|
-
`state:meta:secret:<tenant>:<workspace|->:<ref
|
|
7
|
+
`state:meta:secret:<tenant>:<workspace|->:<ref>` — tenant-wide is the `-` segment,
|
|
8
|
+
and `workspace_id` `0` (the writer's `TENANT_WIDE`), `'0'`, `null` and omitted all
|
|
9
|
+
resolve to it.
|
|
10
|
+
|
|
11
|
+
This package also owns the **sealing contract** (`src/crypto.js`): `seal()` /
|
|
12
|
+
`encrypt()` + `sealBlob()` for the writing side, `open()` for every reader, one
|
|
13
|
+
layout `base64( iv[12] || authTag[16] || ciphertext )`. Both directions in one
|
|
14
|
+
module so seal→open is tested across the real boundary, not against a
|
|
15
|
+
reimplementation.
|
|
8
16
|
|
|
9
17
|
Canonical design: [`api/docs/architecture/secretbox.md`](../../../docs/architecture/secretbox.md).
|
|
10
18
|
|
|
@@ -25,4 +33,8 @@ the invocation scope.
|
|
|
25
33
|
- `SECRET_SCOPE_MISSING` / `SECRET_REF_INVALID` — bad call.
|
|
26
34
|
|
|
27
35
|
## Test
|
|
28
|
-
`npm run test:unit` —
|
|
36
|
+
- `npm run test:unit` — seal/open round trip, byte compatibility with a blob
|
|
37
|
+
sealed by `biz-meta` own implementation, tamper/not-found/bad-key paths,
|
|
38
|
+
scope mapping, mock.
|
|
39
|
+
- `npm run test:integration` — real Redis. Needs `REDIS_HOST` + `REDIS_PORT`
|
|
40
|
+
(dev stack: `REDIS_HOST=127.0.0.1 REDIS_PORT=33030`, container `api_node_cache`).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Separate Jest runner for the integration tier: it carries the `globalSetup`
|
|
5
|
+
* that probes the live Redis and aborts the run when it is absent, so the tier
|
|
6
|
+
* can never report a result on an environment that cannot serve it.
|
|
7
|
+
*
|
|
8
|
+
* @see tests/integration/setup.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
testEnvironment: 'node',
|
|
13
|
+
testMatch: ['**/tests/integration/**/*.test.js'],
|
|
14
|
+
testTimeout: 30000,
|
|
15
|
+
globalSetup: './tests/integration/setup.js',
|
|
16
|
+
coverageDirectory: 'coverage-integration',
|
|
17
|
+
verbose: true
|
|
18
|
+
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-infra-secrets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
8
|
-
"test:unit": "jest tests/unit"
|
|
8
|
+
"test:unit": "jest tests/unit",
|
|
9
|
+
"test:integration": "jest --config=jest.integration.config.js"
|
|
9
10
|
},
|
|
10
11
|
"keywords": [
|
|
11
12
|
"secrets",
|
package/src/crypto.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* AES-256-GCM sealing contract of the SecretBox — this module owns it.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Both directions live here: `seal()`/`encrypt()` for the writer (biz-meta) and
|
|
7
|
+
* `open()` for every reader (`ctx.secrets.get(ref)`). One owner means seal→open
|
|
8
|
+
* is exercised across the real boundary in this package's tests instead of each
|
|
9
|
+
* side testing against its own reimplementation of the other.
|
|
10
|
+
*
|
|
11
|
+
* Wire blob (Redis projection): base64( iv[12] || authTag[16] || ciphertext ).
|
|
12
|
+
* In MySQL the same three parts are stored in separate columns, which is why
|
|
13
|
+
* `encrypt()` returns them individually and `sealBlob()` concatenates them.
|
|
14
|
+
* Layout is specified in api/docs/architecture/secretbox.md §4 — changing it
|
|
15
|
+
* makes every already-stored secret unreadable.
|
|
16
|
+
*
|
|
17
|
+
* The key is always injected (never read from env here): principle 1, DI.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
const crypto = require('crypto');
|
|
@@ -16,6 +24,11 @@ const KEY_BYTES = 32;
|
|
|
16
24
|
const IV_BYTES = 12;
|
|
17
25
|
const AUTH_TAG_BYTES = 16;
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Decode and validate a base64 master key.
|
|
29
|
+
* @param {string} masterKeyBase64
|
|
30
|
+
* @returns {Buffer} 32 raw bytes
|
|
31
|
+
*/
|
|
19
32
|
function loadMasterKey(masterKeyBase64) {
|
|
20
33
|
if (typeof masterKeyBase64 !== 'string' || masterKeyBase64.length === 0) {
|
|
21
34
|
throw new Error(
|
|
@@ -32,6 +45,57 @@ function loadMasterKey(masterKeyBase64) {
|
|
|
32
45
|
return key;
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** @private Fail fast on a key that would otherwise blow up inside node:crypto. */
|
|
49
|
+
function assertKey(key) {
|
|
50
|
+
if (!Buffer.isBuffer(key)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'[conn-infra-secrets] Master key must be a Buffer - Fix: pass the result of loadMasterKey().'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (key.length !== KEY_BYTES) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[conn-infra-secrets] Invalid master key length - got ${key.length} bytes, expected ${KEY_BYTES}.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encrypt to the three parts the authoritative store keeps in separate columns.
|
|
64
|
+
* @param {string} plaintext
|
|
65
|
+
* @param {Buffer} key
|
|
66
|
+
* @returns {{ iv: Buffer, authTag: Buffer, ciphertext: Buffer }}
|
|
67
|
+
*/
|
|
68
|
+
function encrypt(plaintext, key) {
|
|
69
|
+
if (typeof plaintext !== 'string') {
|
|
70
|
+
throw new Error('[conn-infra-secrets] seal requires a string plaintext.');
|
|
71
|
+
}
|
|
72
|
+
assertKey(key);
|
|
73
|
+
const iv = crypto.randomBytes(IV_BYTES);
|
|
74
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
75
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
76
|
+
const authTag = cipher.getAuthTag();
|
|
77
|
+
return { iv, authTag, ciphertext };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Concatenate the parts into the Redis projection blob.
|
|
82
|
+
* @param {{ iv: Buffer, authTag: Buffer, ciphertext: Buffer }} parts
|
|
83
|
+
* @returns {string} base64
|
|
84
|
+
*/
|
|
85
|
+
function sealBlob({ iv, authTag, ciphertext }) {
|
|
86
|
+
return Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encrypt straight to the projection blob (`encrypt` + `sealBlob`).
|
|
91
|
+
* @param {string} plaintext
|
|
92
|
+
* @param {Buffer} key
|
|
93
|
+
* @returns {string} base64( iv || authTag || ciphertext )
|
|
94
|
+
*/
|
|
95
|
+
function seal(plaintext, key) {
|
|
96
|
+
return sealBlob(encrypt(plaintext, key));
|
|
97
|
+
}
|
|
98
|
+
|
|
35
99
|
/**
|
|
36
100
|
* @param {string} blobBase64
|
|
37
101
|
* @param {Buffer} key
|
|
@@ -41,6 +105,7 @@ function open(blobBase64, key) {
|
|
|
41
105
|
if (typeof blobBase64 !== 'string' || blobBase64.length === 0) {
|
|
42
106
|
throw new Error('[conn-infra-secrets] open requires a non-empty base64 blob.');
|
|
43
107
|
}
|
|
108
|
+
assertKey(key);
|
|
44
109
|
const blob = Buffer.from(blobBase64, 'base64');
|
|
45
110
|
if (blob.length < IV_BYTES + AUTH_TAG_BYTES) {
|
|
46
111
|
throw new Error('[conn-infra-secrets] Blob too short - not a valid sealed secret.');
|
|
@@ -53,4 +118,14 @@ function open(blobBase64, key) {
|
|
|
53
118
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
54
119
|
}
|
|
55
120
|
|
|
56
|
-
module.exports = {
|
|
121
|
+
module.exports = {
|
|
122
|
+
loadMasterKey,
|
|
123
|
+
encrypt,
|
|
124
|
+
sealBlob,
|
|
125
|
+
seal,
|
|
126
|
+
open,
|
|
127
|
+
ALGORITHM,
|
|
128
|
+
KEY_BYTES,
|
|
129
|
+
IV_BYTES,
|
|
130
|
+
AUTH_TAG_BYTES
|
|
131
|
+
};
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,23 @@ const { loadMasterKey, open } = require('./crypto');
|
|
|
25
25
|
// Meta owns the projection; its StateConnector prefixes keys with `state:meta:`.
|
|
26
26
|
const DEFAULT_KEY_PREFIX = 'state:meta:secret:';
|
|
27
27
|
|
|
28
|
+
// biz-meta stores tenant-wide secrets with workspace_id 0 (`TENANT_WIDE = 0` in
|
|
29
|
+
// meta/src/handlers/secrets.js) and projects them under the `-` segment. The
|
|
30
|
+
// reader must apply the identical mapping — a caller that legitimately passes 0
|
|
31
|
+
// would otherwise read `:0:`, a key nothing ever writes. Omitted/null means the
|
|
32
|
+
// same thing on the writing side, so all four spellings collapse to `-`.
|
|
33
|
+
const TENANT_WIDE = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {number|string|null|undefined} workspaceId
|
|
37
|
+
* @returns {string} the key segment: `-` for tenant-wide, the id otherwise
|
|
38
|
+
*/
|
|
39
|
+
function workspaceSegment(workspaceId) {
|
|
40
|
+
if (workspaceId === null || workspaceId === undefined) return '-';
|
|
41
|
+
if (workspaceId === TENANT_WIDE || workspaceId === String(TENANT_WIDE)) return '-';
|
|
42
|
+
return String(workspaceId);
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
class SecretResolutionError extends Error {
|
|
29
46
|
constructor(code, message) {
|
|
30
47
|
super(message);
|
|
@@ -37,8 +54,7 @@ function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFI
|
|
|
37
54
|
if (tenantId === undefined || tenantId === null) {
|
|
38
55
|
throw new SecretResolutionError('SECRET_SCOPE_MISSING', '[conn-infra-secrets] tenant_id is required to resolve a secret.');
|
|
39
56
|
}
|
|
40
|
-
|
|
41
|
-
return `${keyPrefix}${tenantId}:${ws}:${ref}`;
|
|
57
|
+
return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
class SecretsConnector {
|
|
@@ -103,7 +119,7 @@ class SecretsConnector {
|
|
|
103
119
|
if (blob === null || blob === undefined) {
|
|
104
120
|
throw new SecretResolutionError(
|
|
105
121
|
'SECRET_NOT_FOUND',
|
|
106
|
-
`[conn-infra-secrets] Secret ref "${name}" not found for scope ${key} - Fix: set it via the
|
|
122
|
+
`[conn-infra-secrets] Secret ref "${name}" not found for scope ${key} - Fix: set it via the biz-meta set-secret operation.`
|
|
107
123
|
);
|
|
108
124
|
}
|
|
109
125
|
try {
|
|
@@ -125,8 +141,7 @@ class SecretsConnector {
|
|
|
125
141
|
class MockSecretsConnector {
|
|
126
142
|
constructor() { this.store = new Map(); this.connected = true; }
|
|
127
143
|
static _k(scope, name) {
|
|
128
|
-
|
|
129
|
-
return `secret:${scope.tenant_id}:${ws}:${name}`;
|
|
144
|
+
return `secret:${scope.tenant_id}:${workspaceSegment(scope.workspace_id)}:${name}`;
|
|
130
145
|
}
|
|
131
146
|
seed(scope, name, value) { this.store.set(MockSecretsConnector._k(scope, name), value); return this; }
|
|
132
147
|
async connect() { this.connected = true; return true; }
|