@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 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` — decrypt round-trip, tamper/not-found, scope, mock.
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.0.0",
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
- * Minimal AES-256-GCM open() for the secrets connector.
4
+ * AES-256-GCM sealing contract of the SecretBox — this module owns it.
5
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.
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 = { loadMasterKey, open, ALGORITHM, KEY_BYTES, IV_BYTES, AUTH_TAG_BYTES };
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
- const ws = (workspaceId === null || workspaceId === undefined) ? '-' : String(workspaceId);
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 api_secrets admin API.`
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
- const ws = (scope.workspace_id === null || scope.workspace_id === undefined) ? '-' : String(scope.workspace_id);
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; }