@mondaydotcomorg/z2h-shared-utils 0.4.3 → 0.6.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/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.mjs +2 -1
- package/dist/esm/integrations.d.ts +15 -0
- package/dist/esm/integrations.d.ts.map +1 -0
- package/dist/esm/integrations.mjs +14 -0
- package/dist/esm/observability/events.d.ts +1 -0
- package/dist/esm/observability/events.d.ts.map +1 -1
- package/dist/esm/observability/events.mjs +1 -0
- package/dist/esm/secrets/__tests__/secret-cipher.test.d.ts +2 -0
- package/dist/esm/secrets/__tests__/secret-cipher.test.d.ts.map +1 -0
- package/dist/esm/secrets/index.d.ts +2 -0
- package/dist/esm/secrets/index.d.ts.map +1 -0
- package/dist/esm/secrets/index.mjs +1 -0
- package/dist/esm/secrets/secret-cipher.d.ts +3 -0
- package/dist/esm/secrets/secret-cipher.d.ts.map +1 -0
- package/dist/esm/secrets/secret-cipher.mjs +99 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/integrations.d.ts +15 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +17 -0
- package/dist/observability/events.d.ts +1 -0
- package/dist/observability/events.d.ts.map +1 -1
- package/dist/observability/events.js +1 -0
- package/dist/secrets/__tests__/secret-cipher.test.d.ts +2 -0
- package/dist/secrets/__tests__/secret-cipher.test.d.ts.map +1 -0
- package/dist/secrets/index.d.ts +2 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +8 -0
- package/dist/secrets/secret-cipher.d.ts +3 -0
- package/dist/secrets/secret-cipher.d.ts.map +1 -0
- package/dist/secrets/secret-cipher.js +102 -0
- package/package.json +1 -1
- package/src/index.ts +7 -1
- package/src/integrations.ts +15 -0
- package/src/observability/events.ts +1 -0
- package/src/secrets/__tests__/secret-cipher.test.ts +48 -0
- package/src/secrets/index.ts +1 -0
- package/src/secrets/secret-cipher.ts +104 -0
package/dist/esm/index.d.ts
CHANGED
package/dist/esm/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,cAAc,WAAW,CAAC"}
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from './integrations.mjs';
|
|
2
|
+
export { decryptSecret, encryptSecret } from './secrets/secret-cipher.mjs';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend channels surfaced as `ctx.api.v1.<name>`.
|
|
3
|
+
*
|
|
4
|
+
* Always-on channels need no OAuth and are never declared — the runner builds
|
|
5
|
+
* them for every app regardless of what its handlers touch.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ALWAYS_ON_CHANNELS: readonly ["db", "llm", "snowflake"];
|
|
8
|
+
export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
|
|
9
|
+
/**
|
|
10
|
+
* Declared (OAuth-gated) integrations: closed set the CLI detects from handler
|
|
11
|
+
* sources and the broker validates on register/update.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DECLARED_INTEGRATIONS: readonly ["monday"];
|
|
14
|
+
export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
|
|
15
|
+
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../../src/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,qCAAsC,CAAC;AACtE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,qBAAsB,CAAC;AACzD,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend channels surfaced as `ctx.api.v1.<name>`.
|
|
3
|
+
*
|
|
4
|
+
* Always-on channels need no OAuth and are never declared — the runner builds
|
|
5
|
+
* them for every app regardless of what its handlers touch.
|
|
6
|
+
*/
|
|
7
|
+
const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'];
|
|
8
|
+
/**
|
|
9
|
+
* Declared (OAuth-gated) integrations: closed set the CLI detects from handler
|
|
10
|
+
* sources and the broker validates on register/update.
|
|
11
|
+
*/
|
|
12
|
+
const DECLARED_INTEGRATIONS = ['monday'];
|
|
13
|
+
|
|
14
|
+
export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;;CAGf,CAAC"}
|
|
@@ -72,6 +72,7 @@ const Z2hAgentEvents = {
|
|
|
72
72
|
/** BI events tracked in `z2h-cli`. */
|
|
73
73
|
const Z2hCliEvents = {
|
|
74
74
|
error: 'z2h_cli_error',
|
|
75
|
+
handlerParseError: 'z2h_cli_handler_parse_error',
|
|
75
76
|
};
|
|
76
77
|
|
|
77
78
|
export { BackendRunnerEvents, Z2hAgentEvents, Z2hCliEvents, Z2hServiceEvents };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.test.d.ts","sourceRoot":"","sources":["../../../../src/secrets/__tests__/secret-cipher.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/secrets/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { decryptSecret, encryptSecret } from './secret-cipher.mjs';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../../src/secrets/secret-cipher.ts"],"names":[],"mappings":"AAqEA,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAavD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBlD"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash, scryptSync } from 'node:crypto';
|
|
2
|
+
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
|
|
6
|
+
* (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
|
|
7
|
+
* prefix leaves room to rotate the scheme without breaking stored blobs.
|
|
8
|
+
* Decryption happens server-side only, and a decrypted value never crosses
|
|
9
|
+
* into an isolate.
|
|
10
|
+
*
|
|
11
|
+
* Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
|
|
12
|
+
* (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
|
|
13
|
+
* this must stay byte-identical across the two callers.
|
|
14
|
+
*/
|
|
15
|
+
/** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
|
|
16
|
+
const KEY_CONFIG = 'z2h_secrets_key';
|
|
17
|
+
const CURRENT_VERSION = 'v1';
|
|
18
|
+
const SALT_LENGTH = 16;
|
|
19
|
+
const SCRYPT_KEY_LENGTH = 32;
|
|
20
|
+
/** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
|
|
21
|
+
* forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
|
|
22
|
+
const GCM_TAG_LENGTH = 16;
|
|
23
|
+
/** Bounds the derived-key cache below — one entry per distinct (passphrase,
|
|
24
|
+
* salt) pair ever seen, so a long-lived process can't grow this unbounded. */
|
|
25
|
+
const MAX_CACHED_KEYS = 256;
|
|
26
|
+
/**
|
|
27
|
+
* The passphrase from the trident `configurationVariables` secret, a
|
|
28
|
+
* `{ key: "…" }` object.
|
|
29
|
+
*/
|
|
30
|
+
function passphraseSource() {
|
|
31
|
+
const cfg = Api.getPart('configurationVariables')?.get(KEY_CONFIG);
|
|
32
|
+
return cfg?.key || undefined;
|
|
33
|
+
}
|
|
34
|
+
function passphrase() {
|
|
35
|
+
const configured = passphraseSource();
|
|
36
|
+
if (!configured) {
|
|
37
|
+
throw new Error(`${KEY_CONFIG} must be set`);
|
|
38
|
+
}
|
|
39
|
+
return configured;
|
|
40
|
+
}
|
|
41
|
+
// Each blob carries its own random salt (see encryptSecret), so the key derived
|
|
42
|
+
// for it is cached by (passphrase, salt) rather than by passphrase alone — a
|
|
43
|
+
// repeated read of the same still-live blob skips re-running scrypt.
|
|
44
|
+
const derivedKeyCache = new Map();
|
|
45
|
+
function deriveKey(source, salt) {
|
|
46
|
+
// Hash the passphrase for the cache key so the plaintext secret isn't held
|
|
47
|
+
// in memory for the cache's lifetime (rather than just for this call).
|
|
48
|
+
const sourceDigest = createHash('sha256').update(source).digest('base64');
|
|
49
|
+
const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
|
|
50
|
+
const cached = derivedKeyCache.get(cacheKey);
|
|
51
|
+
if (cached) {
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
const derived = scryptSync(source, salt, SCRYPT_KEY_LENGTH);
|
|
55
|
+
if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
|
|
56
|
+
const oldest = derivedKeyCache.keys().next().value;
|
|
57
|
+
if (oldest !== undefined) {
|
|
58
|
+
derivedKeyCache.delete(oldest);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
derivedKeyCache.set(cacheKey, derived);
|
|
62
|
+
return derived;
|
|
63
|
+
}
|
|
64
|
+
function encryptSecret(plaintext) {
|
|
65
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
66
|
+
const iv = randomBytes(12);
|
|
67
|
+
const cipher = createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
|
|
68
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
69
|
+
const tag = cipher.getAuthTag();
|
|
70
|
+
return [
|
|
71
|
+
CURRENT_VERSION,
|
|
72
|
+
iv.toString('base64'),
|
|
73
|
+
salt.toString('base64'),
|
|
74
|
+
tag.toString('base64'),
|
|
75
|
+
enc.toString('base64'),
|
|
76
|
+
].join(':');
|
|
77
|
+
}
|
|
78
|
+
function decryptSecret(blob) {
|
|
79
|
+
const parts = blob.split(':');
|
|
80
|
+
if (parts.length !== 5) {
|
|
81
|
+
throw new Error('unrecognized secret format');
|
|
82
|
+
}
|
|
83
|
+
const [version, ivB64, saltB64, tagB64, dataB64] = parts;
|
|
84
|
+
if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
|
|
85
|
+
throw new Error('unrecognized secret format');
|
|
86
|
+
}
|
|
87
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
88
|
+
if (tag.length !== GCM_TAG_LENGTH) {
|
|
89
|
+
throw new Error('unrecognized secret format');
|
|
90
|
+
}
|
|
91
|
+
const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
|
|
92
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
|
|
93
|
+
authTagLength: GCM_TAG_LENGTH,
|
|
94
|
+
});
|
|
95
|
+
decipher.setAuthTag(tag);
|
|
96
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { decryptSecret, encryptSecret };
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,cAAc,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
1
2
|
|
|
3
|
+
const integrations = require('./integrations.js');
|
|
4
|
+
const secrets_secretCipher = require('./secrets/secret-cipher.js');
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
exports.ALWAYS_ON_CHANNELS = integrations.ALWAYS_ON_CHANNELS;
|
|
9
|
+
exports.DECLARED_INTEGRATIONS = integrations.DECLARED_INTEGRATIONS;
|
|
10
|
+
exports.decryptSecret = secrets_secretCipher.decryptSecret;
|
|
11
|
+
exports.encryptSecret = secrets_secretCipher.encryptSecret;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend channels surfaced as `ctx.api.v1.<name>`.
|
|
3
|
+
*
|
|
4
|
+
* Always-on channels need no OAuth and are never declared — the runner builds
|
|
5
|
+
* them for every app regardless of what its handlers touch.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ALWAYS_ON_CHANNELS: readonly ["db", "llm", "snowflake"];
|
|
8
|
+
export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
|
|
9
|
+
/**
|
|
10
|
+
* Declared (OAuth-gated) integrations: closed set the CLI detects from handler
|
|
11
|
+
* sources and the broker validates on register/update.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DECLARED_INTEGRATIONS: readonly ["monday"];
|
|
14
|
+
export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
|
|
15
|
+
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../src/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,qCAAsC,CAAC;AACtE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,qBAAsB,CAAC;AACzD,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backend channels surfaced as `ctx.api.v1.<name>`.
|
|
5
|
+
*
|
|
6
|
+
* Always-on channels need no OAuth and are never declared — the runner builds
|
|
7
|
+
* them for every app regardless of what its handlers touch.
|
|
8
|
+
*/
|
|
9
|
+
const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'];
|
|
10
|
+
/**
|
|
11
|
+
* Declared (OAuth-gated) integrations: closed set the CLI detects from handler
|
|
12
|
+
* sources and the broker validates on register/update.
|
|
13
|
+
*/
|
|
14
|
+
const DECLARED_INTEGRATIONS = ['monday'];
|
|
15
|
+
|
|
16
|
+
exports.ALWAYS_ON_CHANNELS = ALWAYS_ON_CHANNELS;
|
|
17
|
+
exports.DECLARED_INTEGRATIONS = DECLARED_INTEGRATIONS;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/observability/events.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCtB,CAAC;AAEX,iEAAiE;AACjE,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;CAmBnB,CAAC;AAEX,oDAAoD;AACpD,eAAO,MAAM,cAAc;;;;;;;;CAQjB,CAAC;AAEX,sCAAsC;AACtC,eAAO,MAAM,YAAY;;;CAGf,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.test.d.ts","sourceRoot":"","sources":["../../../src/secrets/__tests__/secret-cipher.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/secrets/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
const secrets_secretCipher = require('./secret-cipher.js');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
exports.decryptSecret = secrets_secretCipher.decryptSecret;
|
|
8
|
+
exports.encryptSecret = secrets_secretCipher.encryptSecret;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../src/secrets/secret-cipher.ts"],"names":[],"mappings":"AAqEA,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAavD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBlD"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
const node_crypto = require('node:crypto');
|
|
4
|
+
const tridentBackendApi = require('@mondaydotcomorg/trident-backend-api');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
|
|
8
|
+
* (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
|
|
9
|
+
* prefix leaves room to rotate the scheme without breaking stored blobs.
|
|
10
|
+
* Decryption happens server-side only, and a decrypted value never crosses
|
|
11
|
+
* into an isolate.
|
|
12
|
+
*
|
|
13
|
+
* Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
|
|
14
|
+
* (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
|
|
15
|
+
* this must stay byte-identical across the two callers.
|
|
16
|
+
*/
|
|
17
|
+
/** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
|
|
18
|
+
const KEY_CONFIG = 'z2h_secrets_key';
|
|
19
|
+
const CURRENT_VERSION = 'v1';
|
|
20
|
+
const SALT_LENGTH = 16;
|
|
21
|
+
const SCRYPT_KEY_LENGTH = 32;
|
|
22
|
+
/** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
|
|
23
|
+
* forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
|
|
24
|
+
const GCM_TAG_LENGTH = 16;
|
|
25
|
+
/** Bounds the derived-key cache below — one entry per distinct (passphrase,
|
|
26
|
+
* salt) pair ever seen, so a long-lived process can't grow this unbounded. */
|
|
27
|
+
const MAX_CACHED_KEYS = 256;
|
|
28
|
+
/**
|
|
29
|
+
* The passphrase from the trident `configurationVariables` secret, a
|
|
30
|
+
* `{ key: "…" }` object.
|
|
31
|
+
*/
|
|
32
|
+
function passphraseSource() {
|
|
33
|
+
const cfg = tridentBackendApi.Api.getPart('configurationVariables')?.get(KEY_CONFIG);
|
|
34
|
+
return cfg?.key || undefined;
|
|
35
|
+
}
|
|
36
|
+
function passphrase() {
|
|
37
|
+
const configured = passphraseSource();
|
|
38
|
+
if (!configured) {
|
|
39
|
+
throw new Error(`${KEY_CONFIG} must be set`);
|
|
40
|
+
}
|
|
41
|
+
return configured;
|
|
42
|
+
}
|
|
43
|
+
// Each blob carries its own random salt (see encryptSecret), so the key derived
|
|
44
|
+
// for it is cached by (passphrase, salt) rather than by passphrase alone — a
|
|
45
|
+
// repeated read of the same still-live blob skips re-running scrypt.
|
|
46
|
+
const derivedKeyCache = new Map();
|
|
47
|
+
function deriveKey(source, salt) {
|
|
48
|
+
// Hash the passphrase for the cache key so the plaintext secret isn't held
|
|
49
|
+
// in memory for the cache's lifetime (rather than just for this call).
|
|
50
|
+
const sourceDigest = node_crypto.createHash('sha256').update(source).digest('base64');
|
|
51
|
+
const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
|
|
52
|
+
const cached = derivedKeyCache.get(cacheKey);
|
|
53
|
+
if (cached) {
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
56
|
+
const derived = node_crypto.scryptSync(source, salt, SCRYPT_KEY_LENGTH);
|
|
57
|
+
if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
|
|
58
|
+
const oldest = derivedKeyCache.keys().next().value;
|
|
59
|
+
if (oldest !== undefined) {
|
|
60
|
+
derivedKeyCache.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
derivedKeyCache.set(cacheKey, derived);
|
|
64
|
+
return derived;
|
|
65
|
+
}
|
|
66
|
+
function encryptSecret(plaintext) {
|
|
67
|
+
const salt = node_crypto.randomBytes(SALT_LENGTH);
|
|
68
|
+
const iv = node_crypto.randomBytes(12);
|
|
69
|
+
const cipher = node_crypto.createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
|
|
70
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
71
|
+
const tag = cipher.getAuthTag();
|
|
72
|
+
return [
|
|
73
|
+
CURRENT_VERSION,
|
|
74
|
+
iv.toString('base64'),
|
|
75
|
+
salt.toString('base64'),
|
|
76
|
+
tag.toString('base64'),
|
|
77
|
+
enc.toString('base64'),
|
|
78
|
+
].join(':');
|
|
79
|
+
}
|
|
80
|
+
function decryptSecret(blob) {
|
|
81
|
+
const parts = blob.split(':');
|
|
82
|
+
if (parts.length !== 5) {
|
|
83
|
+
throw new Error('unrecognized secret format');
|
|
84
|
+
}
|
|
85
|
+
const [version, ivB64, saltB64, tagB64, dataB64] = parts;
|
|
86
|
+
if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
|
|
87
|
+
throw new Error('unrecognized secret format');
|
|
88
|
+
}
|
|
89
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
90
|
+
if (tag.length !== GCM_TAG_LENGTH) {
|
|
91
|
+
throw new Error('unrecognized secret format');
|
|
92
|
+
}
|
|
93
|
+
const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
|
|
94
|
+
const decipher = node_crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
|
|
95
|
+
authTagLength: GCM_TAG_LENGTH,
|
|
96
|
+
});
|
|
97
|
+
decipher.setAuthTag(tag);
|
|
98
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
exports.decryptSecret = decryptSecret;
|
|
102
|
+
exports.encryptSecret = encryptSecret;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend channels surfaced as `ctx.api.v1.<name>`.
|
|
3
|
+
*
|
|
4
|
+
* Always-on channels need no OAuth and are never declared — the runner builds
|
|
5
|
+
* them for every app regardless of what its handlers touch.
|
|
6
|
+
*/
|
|
7
|
+
export const ALWAYS_ON_CHANNELS = ['db', 'llm', 'snowflake'] as const;
|
|
8
|
+
export type AlwaysOnChannel = (typeof ALWAYS_ON_CHANNELS)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Declared (OAuth-gated) integrations: closed set the CLI detects from handler
|
|
12
|
+
* sources and the broker validates on register/update.
|
|
13
|
+
*/
|
|
14
|
+
export const DECLARED_INTEGRATIONS = ['monday'] as const;
|
|
15
|
+
export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { decryptSecret, encryptSecret } from '../secret-cipher';
|
|
4
|
+
|
|
5
|
+
const getMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock('@mondaydotcomorg/trident-backend-api', () => ({
|
|
8
|
+
Api: { getPart: vi.fn(() => ({ get: getMock })) },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('secret-cipher', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
getMock.mockReset();
|
|
14
|
+
getMock.mockReturnValue({ key: 'test-secrets-key' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('round-trips a plaintext value', () => {
|
|
18
|
+
const blob = encryptSecret('super-secret-pat');
|
|
19
|
+
expect(decryptSecret(blob)).toBe('super-secret-pat');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('produces a different blob (different iv/salt) for the same plaintext each time', () => {
|
|
23
|
+
const a = encryptSecret('same-value');
|
|
24
|
+
const b = encryptSecret('same-value');
|
|
25
|
+
expect(a).not.toBe(b);
|
|
26
|
+
expect(decryptSecret(a)).toBe('same-value');
|
|
27
|
+
expect(decryptSecret(b)).toBe('same-value');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects a tampered blob (flipped byte fails GCM auth)', () => {
|
|
31
|
+
const blob = encryptSecret('super-secret-pat');
|
|
32
|
+
const parts = blob.split(':');
|
|
33
|
+
const dataBuf = Buffer.from(parts[4], 'base64');
|
|
34
|
+
dataBuf[0] ^= 0xff;
|
|
35
|
+
parts[4] = dataBuf.toString('base64');
|
|
36
|
+
expect(() => decryptSecret(parts.join(':'))).toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects an unrecognized blob format', () => {
|
|
40
|
+
expect(() => decryptSecret('not-a-real-blob')).toThrow('unrecognized secret format');
|
|
41
|
+
expect(() => decryptSecret('v2:a:b:c:d')).toThrow('unrecognized secret format');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws when the passphrase is not configured', () => {
|
|
45
|
+
getMock.mockReturnValue(undefined);
|
|
46
|
+
expect(() => encryptSecret('x')).toThrow('z2h_secrets_key must be set');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './secret-cipher';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'node:crypto';
|
|
2
|
+
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Symmetric encryption for secrets at rest. AES-256-GCM with a key derived
|
|
6
|
+
* (via scrypt, salted per blob) from the `z2h_secrets_key` secret. The `v1:`
|
|
7
|
+
* prefix leaves room to rotate the scheme without breaking stored blobs.
|
|
8
|
+
* Decryption happens server-side only, and a decrypted value never crosses
|
|
9
|
+
* into an isolate.
|
|
10
|
+
*
|
|
11
|
+
* Shared between `bigbrain-zth` (encrypts on write) and `zth-backend-runner`
|
|
12
|
+
* (decrypts on use) — both must read the *same* `z2h_secrets_key` value, so
|
|
13
|
+
* this must stay byte-identical across the two callers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Config-variables key (how mm + the cluster inject it), stored as {"key":"…"}. */
|
|
17
|
+
const KEY_CONFIG = 'z2h_secrets_key';
|
|
18
|
+
const CURRENT_VERSION = 'v1' as const;
|
|
19
|
+
const SALT_LENGTH = 16;
|
|
20
|
+
const SCRYPT_KEY_LENGTH = 32;
|
|
21
|
+
/** Full-length GCM tag. Enforced on decrypt so a truncated tag can't weaken the
|
|
22
|
+
* forgery margin (a short tag shrinks it from 2^128 toward 2^32). */
|
|
23
|
+
const GCM_TAG_LENGTH = 16;
|
|
24
|
+
/** Bounds the derived-key cache below — one entry per distinct (passphrase,
|
|
25
|
+
* salt) pair ever seen, so a long-lived process can't grow this unbounded. */
|
|
26
|
+
const MAX_CACHED_KEYS = 256;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The passphrase from the trident `configurationVariables` secret, a
|
|
30
|
+
* `{ key: "…" }` object.
|
|
31
|
+
*/
|
|
32
|
+
function passphraseSource(): string | undefined {
|
|
33
|
+
const cfg = Api.getPart('configurationVariables')?.get(KEY_CONFIG) as { key?: string } | undefined;
|
|
34
|
+
return cfg?.key || undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function passphrase(): string {
|
|
38
|
+
const configured = passphraseSource();
|
|
39
|
+
if (!configured) {
|
|
40
|
+
throw new Error(`${KEY_CONFIG} must be set`);
|
|
41
|
+
}
|
|
42
|
+
return configured;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Each blob carries its own random salt (see encryptSecret), so the key derived
|
|
46
|
+
// for it is cached by (passphrase, salt) rather than by passphrase alone — a
|
|
47
|
+
// repeated read of the same still-live blob skips re-running scrypt.
|
|
48
|
+
const derivedKeyCache = new Map<string, Buffer>();
|
|
49
|
+
|
|
50
|
+
function deriveKey(source: string, salt: Buffer): Buffer {
|
|
51
|
+
// Hash the passphrase for the cache key so the plaintext secret isn't held
|
|
52
|
+
// in memory for the cache's lifetime (rather than just for this call).
|
|
53
|
+
const sourceDigest = createHash('sha256').update(source).digest('base64');
|
|
54
|
+
const cacheKey = `${sourceDigest}:${salt.toString('base64')}`;
|
|
55
|
+
const cached = derivedKeyCache.get(cacheKey);
|
|
56
|
+
if (cached) {
|
|
57
|
+
return cached;
|
|
58
|
+
}
|
|
59
|
+
const derived = scryptSync(source, salt, SCRYPT_KEY_LENGTH);
|
|
60
|
+
if (derivedKeyCache.size >= MAX_CACHED_KEYS) {
|
|
61
|
+
const oldest = derivedKeyCache.keys().next().value;
|
|
62
|
+
if (oldest !== undefined) {
|
|
63
|
+
derivedKeyCache.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
derivedKeyCache.set(cacheKey, derived);
|
|
67
|
+
return derived;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function encryptSecret(plaintext: string): string {
|
|
71
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
72
|
+
const iv = randomBytes(12);
|
|
73
|
+
const cipher = createCipheriv('aes-256-gcm', deriveKey(passphrase(), salt), iv);
|
|
74
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
75
|
+
const tag = cipher.getAuthTag();
|
|
76
|
+
return [
|
|
77
|
+
CURRENT_VERSION,
|
|
78
|
+
iv.toString('base64'),
|
|
79
|
+
salt.toString('base64'),
|
|
80
|
+
tag.toString('base64'),
|
|
81
|
+
enc.toString('base64'),
|
|
82
|
+
].join(':');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function decryptSecret(blob: string): string {
|
|
86
|
+
const parts = blob.split(':');
|
|
87
|
+
if (parts.length !== 5) {
|
|
88
|
+
throw new Error('unrecognized secret format');
|
|
89
|
+
}
|
|
90
|
+
const [version, ivB64, saltB64, tagB64, dataB64] = parts;
|
|
91
|
+
if (version !== CURRENT_VERSION || !ivB64 || !saltB64 || !tagB64 || dataB64 === undefined) {
|
|
92
|
+
throw new Error('unrecognized secret format');
|
|
93
|
+
}
|
|
94
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
95
|
+
if (tag.length !== GCM_TAG_LENGTH) {
|
|
96
|
+
throw new Error('unrecognized secret format');
|
|
97
|
+
}
|
|
98
|
+
const key = deriveKey(passphrase(), Buffer.from(saltB64, 'base64'));
|
|
99
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64'), {
|
|
100
|
+
authTagLength: GCM_TAG_LENGTH,
|
|
101
|
+
});
|
|
102
|
+
decipher.setAuthTag(tag);
|
|
103
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
104
|
+
}
|