@mondaydotcomorg/z2h-shared-utils 0.4.3 → 0.5.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 +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -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 +1 -1
- 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
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './secrets';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
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,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC"}
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
export { decryptSecret, encryptSecret } from './secrets/secret-cipher.mjs';
|
|
@@ -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
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './secrets';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
1
2
|
|
|
3
|
+
const secrets_secretCipher = require('./secrets/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.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
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './secrets';
|
|
@@ -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
|
+
}
|