@onlineapps/conn-infra-secrets 1.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/README.md +28 -0
- package/package.json +32 -0
- package/src/crypto.js +56 -0
- package/src/index.js +147 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @onlineapps/conn-infra-secrets
|
|
2
|
+
|
|
3
|
+
Resolution connector for `ctx.secrets.get(ref)`. Reads the encrypted Redis
|
|
4
|
+
projection written by `biz-meta` (SecretBox) and decrypts it in-process with the
|
|
5
|
+
injected AES-256-GCM master key. Authoritative store (`oagen_meta.secret`) is owned
|
|
6
|
+
by `biz-meta` and never touched here. Projection key contract:
|
|
7
|
+
`state:meta:secret:<tenant>:<workspace|->:<ref>`.
|
|
8
|
+
|
|
9
|
+
Canonical design: [`api/docs/architecture/secretbox.md`](../../../docs/architecture/secretbox.md).
|
|
10
|
+
|
|
11
|
+
## Usage (wired by ServiceWrapper)
|
|
12
|
+
```js
|
|
13
|
+
const SecretsConnector = require('@onlineapps/conn-infra-secrets');
|
|
14
|
+
const secrets = new SecretsConnector({ redisUrl: process.env.REDIS_URL, masterKeyBase64: process.env.SECRETS_MASTER_KEY });
|
|
15
|
+
await secrets.connect();
|
|
16
|
+
// ContextBuilder facade calls: secrets.get(ref, { tenant_id, workspace_id })
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Handlers only ever call `ctx.secrets.get(ref)`; the ContextBuilder facade injects
|
|
20
|
+
the invocation scope.
|
|
21
|
+
|
|
22
|
+
## Errors (no fallbacks)
|
|
23
|
+
- `SECRET_NOT_FOUND` — no projection for the ref/scope.
|
|
24
|
+
- `SECRET_DECRYPT_FAILED` — master key mismatch or corrupted blob.
|
|
25
|
+
- `SECRET_SCOPE_MISSING` / `SECRET_REF_INVALID` — bad call.
|
|
26
|
+
|
|
27
|
+
## Test
|
|
28
|
+
`npm run test:unit` — decrypt round-trip, tamper/not-found, scope, mock.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onlineapps/conn-infra-secrets",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Secret resolution connector for ctx.secrets.get(ref) — reads the SecretBox Redis projection and decrypts in-process (AES-256-GCM)",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:unit": "jest tests/unit"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"secrets",
|
|
12
|
+
"secretbox",
|
|
13
|
+
"connector",
|
|
14
|
+
"redis",
|
|
15
|
+
"oa-drive"
|
|
16
|
+
],
|
|
17
|
+
"author": "OA Drive Team",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ioredis": "^5.3.2"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"jest": "^29.7.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=14.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal AES-256-GCM open() for the secrets connector.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately a small, self-contained copy of the sealing contract owned by
|
|
7
|
+
* api_secrets (`src/lib/crypto.js`) — the two packages cannot import each other.
|
|
8
|
+
* Blob layout MUST stay identical: base64( iv[12] || authTag[16] || ciphertext ).
|
|
9
|
+
* See api/docs/architecture/secretbox.md §4.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
15
|
+
const KEY_BYTES = 32;
|
|
16
|
+
const IV_BYTES = 12;
|
|
17
|
+
const AUTH_TAG_BYTES = 16;
|
|
18
|
+
|
|
19
|
+
function loadMasterKey(masterKeyBase64) {
|
|
20
|
+
if (typeof masterKeyBase64 !== 'string' || masterKeyBase64.length === 0) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'[conn-infra-secrets] Missing master key - base64-encoded 32-byte key required. ' +
|
|
23
|
+
'Fix: set SECRETS_MASTER_KEY (or pass explicit key).'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const key = Buffer.from(masterKeyBase64, 'base64');
|
|
27
|
+
if (key.length !== KEY_BYTES) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`[conn-infra-secrets] Invalid master key length - decoded ${key.length} bytes, expected ${KEY_BYTES}.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} blobBase64
|
|
37
|
+
* @param {Buffer} key
|
|
38
|
+
* @returns {string} plaintext
|
|
39
|
+
*/
|
|
40
|
+
function open(blobBase64, key) {
|
|
41
|
+
if (typeof blobBase64 !== 'string' || blobBase64.length === 0) {
|
|
42
|
+
throw new Error('[conn-infra-secrets] open requires a non-empty base64 blob.');
|
|
43
|
+
}
|
|
44
|
+
const blob = Buffer.from(blobBase64, 'base64');
|
|
45
|
+
if (blob.length < IV_BYTES + AUTH_TAG_BYTES) {
|
|
46
|
+
throw new Error('[conn-infra-secrets] Blob too short - not a valid sealed secret.');
|
|
47
|
+
}
|
|
48
|
+
const iv = blob.subarray(0, IV_BYTES);
|
|
49
|
+
const authTag = blob.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
|
|
50
|
+
const ciphertext = blob.subarray(IV_BYTES + AUTH_TAG_BYTES);
|
|
51
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
52
|
+
decipher.setAuthTag(authTag);
|
|
53
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { loadMasterKey, open, ALGORITHM, KEY_BYTES, IV_BYTES, AUTH_TAG_BYTES };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @onlineapps/conn-infra-secrets — resolves secrets for `ctx.secrets.get(ref)`.
|
|
5
|
+
*
|
|
6
|
+
* Reads the encrypted Redis projection written by biz-meta (SecretBox) and
|
|
7
|
+
* decrypts it in-process with the injected master key. This mirrors the platform
|
|
8
|
+
* hot-read pattern (entitlements/credits via Redis connector); the authoritative
|
|
9
|
+
* store (oagen_meta.secret) is owned by biz-meta and never touched on this path.
|
|
10
|
+
*
|
|
11
|
+
* Redis projection (see secretbox.md §5) — flat key (meta StateConnector has no
|
|
12
|
+
* hash ops), value = base64( iv || authTag || ciphertext ):
|
|
13
|
+
* state:meta:secret:<tenant_id>:<workspace_id|->:<ref>
|
|
14
|
+
*
|
|
15
|
+
* Contract: get(name, scope) => Promise<string>. The ContextBuilder facade passes
|
|
16
|
+
* the invocation's { tenant_id, workspace_id } as scope. No fallbacks: a missing
|
|
17
|
+
* projection throws SECRET_NOT_FOUND; a bad blob throws SECRET_DECRYPT_FAILED.
|
|
18
|
+
*
|
|
19
|
+
* @see api/docs/architecture/secretbox.md
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const Redis = require('ioredis');
|
|
23
|
+
const { loadMasterKey, open } = require('./crypto');
|
|
24
|
+
|
|
25
|
+
// Meta owns the projection; its StateConnector prefixes keys with `state:meta:`.
|
|
26
|
+
const DEFAULT_KEY_PREFIX = 'state:meta:secret:';
|
|
27
|
+
|
|
28
|
+
class SecretResolutionError extends Error {
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'SecretResolutionError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
|
|
37
|
+
if (tenantId === undefined || tenantId === null) {
|
|
38
|
+
throw new SecretResolutionError('SECRET_SCOPE_MISSING', '[conn-infra-secrets] tenant_id is required to resolve a secret.');
|
|
39
|
+
}
|
|
40
|
+
const ws = (workspaceId === null || workspaceId === undefined) ? '-' : String(workspaceId);
|
|
41
|
+
return `${keyPrefix}${tenantId}:${ws}:${ref}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class SecretsConnector {
|
|
45
|
+
/**
|
|
46
|
+
* @param {Object} config
|
|
47
|
+
* @param {string} config.redisUrl - redis://host:port (required)
|
|
48
|
+
* @param {string} config.masterKeyBase64 - base64 32-byte AES-256-GCM key (required)
|
|
49
|
+
* @param {string} [config.password] - Redis password
|
|
50
|
+
* @param {number} [config.db=0] - Redis database index
|
|
51
|
+
*/
|
|
52
|
+
constructor(config = {}) {
|
|
53
|
+
if (!config.redisUrl) {
|
|
54
|
+
throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
|
|
55
|
+
}
|
|
56
|
+
this._key = loadMasterKey(config.masterKeyBase64);
|
|
57
|
+
this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
|
|
58
|
+
|
|
59
|
+
let host = config.redisUrl;
|
|
60
|
+
let port = 6379;
|
|
61
|
+
if (config.redisUrl.startsWith('redis://')) {
|
|
62
|
+
const parsed = new URL(config.redisUrl);
|
|
63
|
+
host = parsed.hostname;
|
|
64
|
+
port = parseInt(parsed.port, 10) || 6379;
|
|
65
|
+
}
|
|
66
|
+
this._redisOptions = { host, port, password: config.password, db: config.db || 0, lazyConnect: true };
|
|
67
|
+
this.client = null;
|
|
68
|
+
this.connected = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async connect() {
|
|
72
|
+
if (this.connected) return true;
|
|
73
|
+
this.client = new Redis(this._redisOptions);
|
|
74
|
+
this.client.on('error', (err) => { /* surfaced on get() */ this._lastError = err; });
|
|
75
|
+
await this.client.connect();
|
|
76
|
+
this.connected = true;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async disconnect() {
|
|
81
|
+
if (this.client) {
|
|
82
|
+
await this.client.quit();
|
|
83
|
+
this.client = null;
|
|
84
|
+
this.connected = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a secret ref to plaintext for the given invocation scope.
|
|
90
|
+
* @param {string} name - opaque secret ref
|
|
91
|
+
* @param {{ tenant_id: number|string, workspace_id?: number|string|null }} [scope]
|
|
92
|
+
* @returns {Promise<string>}
|
|
93
|
+
*/
|
|
94
|
+
async get(name, scope = {}) {
|
|
95
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
96
|
+
throw new SecretResolutionError('SECRET_REF_INVALID', '[conn-infra-secrets] secret ref must be a non-empty string.');
|
|
97
|
+
}
|
|
98
|
+
if (!this.client || !this.connected) {
|
|
99
|
+
throw new SecretResolutionError('SECRET_STORE_UNAVAILABLE', '[conn-infra-secrets] not connected - call connect() first.');
|
|
100
|
+
}
|
|
101
|
+
const key = projectionKey(scope.tenant_id, scope.workspace_id, name, this._keyPrefix);
|
|
102
|
+
const blob = await this.client.get(key);
|
|
103
|
+
if (blob === null || blob === undefined) {
|
|
104
|
+
throw new SecretResolutionError(
|
|
105
|
+
'SECRET_NOT_FOUND',
|
|
106
|
+
`[conn-infra-secrets] Secret ref "${name}" not found for scope ${key} - Fix: set it via the api_secrets admin API.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return open(blob, this._key);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new SecretResolutionError(
|
|
113
|
+
'SECRET_DECRYPT_FAILED',
|
|
114
|
+
`[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - master key mismatch or corrupted blob.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
isConnected() { return this.connected; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* In-memory mock for tests — no Redis, no crypto. Seed with plain values.
|
|
124
|
+
*/
|
|
125
|
+
class MockSecretsConnector {
|
|
126
|
+
constructor() { this.store = new Map(); this.connected = true; }
|
|
127
|
+
static _k(scope, name) {
|
|
128
|
+
const ws = (scope.workspace_id === null || scope.workspace_id === undefined) ? '-' : String(scope.workspace_id);
|
|
129
|
+
return `secret:${scope.tenant_id}:${ws}:${name}`;
|
|
130
|
+
}
|
|
131
|
+
seed(scope, name, value) { this.store.set(MockSecretsConnector._k(scope, name), value); return this; }
|
|
132
|
+
async connect() { this.connected = true; return true; }
|
|
133
|
+
async disconnect() { this.connected = false; }
|
|
134
|
+
isConnected() { return this.connected; }
|
|
135
|
+
async get(name, scope = {}) {
|
|
136
|
+
const v = this.store.get(MockSecretsConnector._k(scope, name));
|
|
137
|
+
if (v === undefined) throw new SecretResolutionError('SECRET_NOT_FOUND', `mock: ${name} not found`);
|
|
138
|
+
return v;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = SecretsConnector;
|
|
143
|
+
module.exports.SecretsConnector = SecretsConnector;
|
|
144
|
+
module.exports.MockSecretsConnector = MockSecretsConnector;
|
|
145
|
+
module.exports.SecretResolutionError = SecretResolutionError;
|
|
146
|
+
module.exports.projectionKey = projectionKey;
|
|
147
|
+
module.exports.create = (config) => new SecretsConnector(config);
|