@shipfox/api-secrets 14.0.0 → 17.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.
@@ -1,2 +1,2 @@
1
1
  $ shipfox-swc
2
- Successfully compiled: 53 files with swc (524.16ms)
2
+ Successfully compiled: 53 files with swc (319.1ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @shipfox/api-secrets
2
2
 
3
+ ## 17.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 9f898d9: Persists encrypted agent session transcripts to object storage, adds a retention sweep that deletes expired sessions, and adds KEK rotation for envelope-encrypted artifacts. Deployments must set `AGENT_SESSION_ENCRYPTION_KEK` to a unique base64-encoded 32-byte key before upgrading.
8
+ - Updated dependencies [a4f56ff]
9
+ - Updated dependencies [a591e8a]
10
+ - Updated dependencies [9f898d9]
11
+ - Updated dependencies [9f898d9]
12
+ - @shipfox/api-auth-context@17.0.0
13
+ - @shipfox/node-postgres@0.5.1
14
+ - @shipfox/node-envelope-encryption@0.2.0
15
+ - @shipfox/node-outbox@0.2.6
16
+
17
+ ## 15.0.0
18
+
19
+ ### Patch Changes
20
+
21
+ - @shipfox/node-opentelemetry@0.6.5
22
+ - @shipfox/api-projects-dto@15.0.0
23
+ - @shipfox/node-fastify@0.4.3
24
+ - @shipfox/node-module@1.0.7
25
+ - @shipfox/api-auth-context@15.0.0
26
+
3
27
  ## 14.0.0
4
28
 
5
29
  ### Patch Changes
package/README.md CHANGED
@@ -135,6 +135,8 @@ Secret management routes should keep secret values write-only at the HTTP bounda
135
135
 
136
136
  Secret values use envelope encryption.
137
137
 
138
+ `@shipfox/node-envelope-encryption` owns the shared cipher, key-provider, cache, and rotation mechanics. This module owns its KEK configuration, AAD, tables, metrics, and domain errors.
139
+
138
140
  - Each workspace has one 32-byte data-encryption key (DEK).
139
141
  - The DEK is generated with `crypto.randomBytes(32)` on first use.
140
142
  - The DEK is wrapped with the configured key-encryption key (KEK) and stored in `secrets_data_keys`.
package/dist/config.js CHANGED
@@ -4,7 +4,7 @@ import { decodeBase64Key } from '#core/crypto.js';
4
4
  export const MAX_VALUE_BYTES = 64 * 1024;
5
5
  export const config = createConfig({
6
6
  SECRETS_ENCRYPTION_KEK: str({
7
- desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.'
7
+ desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. Do not reuse AGENT_SESSION_ENCRYPTION_KEK. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.'
8
8
  }),
9
9
  SECRETS_ENCRYPTION_KEK_PREVIOUS: str({
10
10
  desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {SECRETS_MAX_LIST_LIMIT} from '@shipfox/api-secrets-dto';\nimport {createConfig, num, str} from '@shipfox/config';\nimport {decodeBase64Key} from '#core/crypto.js';\n\nexport const MAX_VALUE_BYTES = 64 * 1024;\n\nexport const config = createConfig({\n SECRETS_ENCRYPTION_KEK: str({\n desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.',\n }),\n SECRETS_ENCRYPTION_KEK_PREVIOUS: str({\n desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',\n default: undefined,\n }),\n SECRETS_MAX_PER_WORKSPACE: num({\n desc: 'Maximum number of secrets and variables allowed per workspace across all project scopes and namespaces.',\n default: 10000,\n }),\n SECRETS_SHORT_VALUE_WARN_LENGTH: num({\n desc: 'Secret values shorter than this many characters are classified for a write warning by management routes.',\n default: 12,\n }),\n});\n\ndecodeBase64Key(config.SECRETS_ENCRYPTION_KEK, 'SECRETS_ENCRYPTION_KEK');\nif (config.SECRETS_ENCRYPTION_KEK_PREVIOUS) {\n decodeBase64Key(config.SECRETS_ENCRYPTION_KEK_PREVIOUS, 'SECRETS_ENCRYPTION_KEK_PREVIOUS');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE < 1) {\n throw new Error('SECRETS_MAX_PER_WORKSPACE must be greater than 0.');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE > SECRETS_MAX_LIST_LIMIT) {\n // The settings UI lists the whole bounded set in one call (limit = SECRETS_MAX_LIST_LIMIT).\n // A cap above that would let the list silently truncate, so fail fast instead.\n throw new Error(\n `SECRETS_MAX_PER_WORKSPACE (${config.SECRETS_MAX_PER_WORKSPACE}) cannot exceed SECRETS_MAX_LIST_LIMIT (${SECRETS_MAX_LIST_LIMIT}).`,\n );\n}\nif (config.SECRETS_SHORT_VALUE_WARN_LENGTH < 1) {\n throw new Error('SECRETS_SHORT_VALUE_WARN_LENGTH must be greater than 0.');\n}\n"],"names":["SECRETS_MAX_LIST_LIMIT","createConfig","num","str","decodeBase64Key","MAX_VALUE_BYTES","config","SECRETS_ENCRYPTION_KEK","desc","SECRETS_ENCRYPTION_KEK_PREVIOUS","default","undefined","SECRETS_MAX_PER_WORKSPACE","SECRETS_SHORT_VALUE_WARN_LENGTH","Error"],"mappings":"AAAA,SAAQA,sBAAsB,QAAO,2BAA2B;AAChE,SAAQC,YAAY,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AACvD,SAAQC,eAAe,QAAO,kBAAkB;AAEhD,OAAO,MAAMC,kBAAkB,KAAK,KAAK;AAEzC,OAAO,MAAMC,SAASL,aAAa;IACjCM,wBAAwBJ,IAAI;QAC1BK,MAAM;IACR;IACAC,iCAAiCN,IAAI;QACnCK,MAAM;QACNE,SAASC;IACX;IACAC,2BAA2BV,IAAI;QAC7BM,MAAM;QACNE,SAAS;IACX;IACAG,iCAAiCX,IAAI;QACnCM,MAAM;QACNE,SAAS;IACX;AACF,GAAG;AAEHN,gBAAgBE,OAAOC,sBAAsB,EAAE;AAC/C,IAAID,OAAOG,+BAA+B,EAAE;IAC1CL,gBAAgBE,OAAOG,+BAA+B,EAAE;AAC1D;AACA,IAAIH,OAAOM,yBAAyB,GAAG,GAAG;IACxC,MAAM,IAAIE,MAAM;AAClB;AACA,IAAIR,OAAOM,yBAAyB,GAAGZ,wBAAwB;IAC7D,4FAA4F;IAC5F,+EAA+E;IAC/E,MAAM,IAAIc,MACR,CAAC,2BAA2B,EAAER,OAAOM,yBAAyB,CAAC,wCAAwC,EAAEZ,uBAAuB,EAAE,CAAC;AAEvI;AACA,IAAIM,OAAOO,+BAA+B,GAAG,GAAG;IAC9C,MAAM,IAAIC,MAAM;AAClB"}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {SECRETS_MAX_LIST_LIMIT} from '@shipfox/api-secrets-dto';\nimport {createConfig, num, str} from '@shipfox/config';\nimport {decodeBase64Key} from '#core/crypto.js';\n\nexport const MAX_VALUE_BYTES = 64 * 1024;\n\nexport const config = createConfig({\n SECRETS_ENCRYPTION_KEK: str({\n desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. Do not reuse AGENT_SESSION_ENCRYPTION_KEK. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.',\n }),\n SECRETS_ENCRYPTION_KEK_PREVIOUS: str({\n desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',\n default: undefined,\n }),\n SECRETS_MAX_PER_WORKSPACE: num({\n desc: 'Maximum number of secrets and variables allowed per workspace across all project scopes and namespaces.',\n default: 10000,\n }),\n SECRETS_SHORT_VALUE_WARN_LENGTH: num({\n desc: 'Secret values shorter than this many characters are classified for a write warning by management routes.',\n default: 12,\n }),\n});\n\ndecodeBase64Key(config.SECRETS_ENCRYPTION_KEK, 'SECRETS_ENCRYPTION_KEK');\nif (config.SECRETS_ENCRYPTION_KEK_PREVIOUS) {\n decodeBase64Key(config.SECRETS_ENCRYPTION_KEK_PREVIOUS, 'SECRETS_ENCRYPTION_KEK_PREVIOUS');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE < 1) {\n throw new Error('SECRETS_MAX_PER_WORKSPACE must be greater than 0.');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE > SECRETS_MAX_LIST_LIMIT) {\n // The settings UI lists the whole bounded set in one call (limit = SECRETS_MAX_LIST_LIMIT).\n // A cap above that would let the list silently truncate, so fail fast instead.\n throw new Error(\n `SECRETS_MAX_PER_WORKSPACE (${config.SECRETS_MAX_PER_WORKSPACE}) cannot exceed SECRETS_MAX_LIST_LIMIT (${SECRETS_MAX_LIST_LIMIT}).`,\n );\n}\nif (config.SECRETS_SHORT_VALUE_WARN_LENGTH < 1) {\n throw new Error('SECRETS_SHORT_VALUE_WARN_LENGTH must be greater than 0.');\n}\n"],"names":["SECRETS_MAX_LIST_LIMIT","createConfig","num","str","decodeBase64Key","MAX_VALUE_BYTES","config","SECRETS_ENCRYPTION_KEK","desc","SECRETS_ENCRYPTION_KEK_PREVIOUS","default","undefined","SECRETS_MAX_PER_WORKSPACE","SECRETS_SHORT_VALUE_WARN_LENGTH","Error"],"mappings":"AAAA,SAAQA,sBAAsB,QAAO,2BAA2B;AAChE,SAAQC,YAAY,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AACvD,SAAQC,eAAe,QAAO,kBAAkB;AAEhD,OAAO,MAAMC,kBAAkB,KAAK,KAAK;AAEzC,OAAO,MAAMC,SAASL,aAAa;IACjCM,wBAAwBJ,IAAI;QAC1BK,MAAM;IACR;IACAC,iCAAiCN,IAAI;QACnCK,MAAM;QACNE,SAASC;IACX;IACAC,2BAA2BV,IAAI;QAC7BM,MAAM;QACNE,SAAS;IACX;IACAG,iCAAiCX,IAAI;QACnCM,MAAM;QACNE,SAAS;IACX;AACF,GAAG;AAEHN,gBAAgBE,OAAOC,sBAAsB,EAAE;AAC/C,IAAID,OAAOG,+BAA+B,EAAE;IAC1CL,gBAAgBE,OAAOG,+BAA+B,EAAE;AAC1D;AACA,IAAIH,OAAOM,yBAAyB,GAAG,GAAG;IACxC,MAAM,IAAIE,MAAM;AAClB;AACA,IAAIR,OAAOM,yBAAyB,GAAGZ,wBAAwB;IAC7D,4FAA4F;IAC5F,+EAA+E;IAC/E,MAAM,IAAIc,MACR,CAAC,2BAA2B,EAAER,OAAOM,yBAAyB,CAAC,wCAAwC,EAAEZ,uBAAuB,EAAE,CAAC;AAEvI;AACA,IAAIM,OAAOO,+BAA+B,GAAG,GAAG;IAC9C,MAAM,IAAIC,MAAM;AAClB"}
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/core/crypto.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAS3D;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CA4B3D;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAelF;AAED,wBAAgB,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEzE;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAIhE"}
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/core/crypto.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAM3D;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAOlF;AAED,wBAAgB,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEzE;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAIhE"}
@@ -1,61 +1,22 @@
1
- import crypto from 'node:crypto';
1
+ import { decodeBase64Key as decodeEnvelopeKey, KeyConfigurationError, openEnvelopeText, sealEnvelopeText } from '@shipfox/node-envelope-encryption';
2
2
  import { KekConfigurationError, SecretDecryptionError } from './errors.js';
3
- const CIPHER = 'aes-256-gcm';
4
- const ENCODED_PREFIX = 'v1:';
5
- const IV_BYTES = 12;
6
- const AUTH_TAG_BYTES = 16;
7
- const KEY_BYTES = 32;
8
- const BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
9
- const BASE64_PADDING_SUFFIX = /=+$/;
10
3
  export function aesGcmSeal(params) {
11
- const iv = crypto.randomBytes(IV_BYTES);
12
- const cipher = crypto.createCipheriv(CIPHER, params.key, iv);
13
- cipher.setAAD(Buffer.from(params.aad, 'utf8'));
14
- const ciphertext = Buffer.concat([
15
- cipher.update(params.plaintext),
16
- cipher.final()
17
- ]);
18
- const authTag = cipher.getAuthTag();
19
- return `${ENCODED_PREFIX}${Buffer.concat([
20
- iv,
21
- authTag,
22
- ciphertext
23
- ]).toString('base64')}`;
4
+ return sealEnvelopeText(params);
24
5
  }
25
6
  export function aesGcmOpen(params) {
26
- if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();
27
- const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);
28
- const payload = Buffer.from(encodedPayload, 'base64');
29
- const canonical = payload.toString('base64');
30
- if (!BASE64_KEY_PATTERN.test(encodedPayload) || canonical.replace(BASE64_PADDING_SUFFIX, '') !== encodedPayload.replace(BASE64_PADDING_SUFFIX, '')) {
31
- throw new SecretDecryptionError();
32
- }
33
- if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();
34
7
  try {
35
- const iv = payload.subarray(0, IV_BYTES);
36
- const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
37
- const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);
38
- const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);
39
- decipher.setAAD(Buffer.from(params.aad, 'utf8'));
40
- decipher.setAuthTag(authTag);
41
- return Buffer.concat([
42
- decipher.update(ciphertext),
43
- decipher.final()
44
- ]);
45
- } catch (error) {
46
- if (error instanceof SecretDecryptionError) throw error;
8
+ return openEnvelopeText(params);
9
+ } catch {
47
10
  throw new SecretDecryptionError();
48
11
  }
49
12
  }
50
13
  export function decodeBase64Key(encoded, label) {
51
- if (!encoded) {
52
- throw new KekConfigurationError(`${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`);
53
- }
54
- const key = Buffer.from(encoded, 'base64');
55
- if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {
56
- throw new KekConfigurationError(`${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`);
14
+ try {
15
+ return decodeEnvelopeKey(encoded, label);
16
+ } catch (error) {
17
+ if (error instanceof KeyConfigurationError) throw new KekConfigurationError(error.message);
18
+ throw error;
57
19
  }
58
- return key;
59
20
  }
60
21
  export function aadForDek(workspaceId, kekVersion) {
61
22
  return JSON.stringify([
@@ -78,8 +39,5 @@ export function aadForValue(params) {
78
39
  params.key
79
40
  ]);
80
41
  }
81
- function isCanonicalBase64Key(encoded, key) {
82
- return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;
83
- }
84
42
 
85
43
  //# sourceMappingURL=crypto.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/crypto.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport {KekConfigurationError, SecretDecryptionError} from './errors.js';\n\nconst CIPHER = 'aes-256-gcm';\nconst ENCODED_PREFIX = 'v1:';\nconst IV_BYTES = 12;\nconst AUTH_TAG_BYTES = 16;\nconst KEY_BYTES = 32;\nconst BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;\nconst BASE64_PADDING_SUFFIX = /=+$/;\n\nexport interface AesGcmSealParams {\n key: Buffer;\n plaintext: Buffer;\n aad: string;\n}\n\nexport interface AesGcmOpenParams {\n key: Buffer;\n encoded: string;\n aad: string;\n}\n\nexport interface SecretScope {\n projectId?: string | null | undefined;\n}\n\nexport interface SecretValueAadParams {\n workspaceId: string;\n scope?: SecretScope | undefined;\n namespace: string;\n key: string;\n}\n\nexport function aesGcmSeal(params: AesGcmSealParams): string {\n const iv = crypto.randomBytes(IV_BYTES);\n const cipher = crypto.createCipheriv(CIPHER, params.key, iv);\n cipher.setAAD(Buffer.from(params.aad, 'utf8'));\n\n const ciphertext = Buffer.concat([cipher.update(params.plaintext), cipher.final()]);\n const authTag = cipher.getAuthTag();\n\n return `${ENCODED_PREFIX}${Buffer.concat([iv, authTag, ciphertext]).toString('base64')}`;\n}\n\nexport function aesGcmOpen(params: AesGcmOpenParams): Buffer {\n if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();\n\n const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);\n const payload = Buffer.from(encodedPayload, 'base64');\n const canonical = payload.toString('base64');\n if (\n !BASE64_KEY_PATTERN.test(encodedPayload) ||\n canonical.replace(BASE64_PADDING_SUFFIX, '') !==\n encodedPayload.replace(BASE64_PADDING_SUFFIX, '')\n ) {\n throw new SecretDecryptionError();\n }\n if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();\n\n try {\n const iv = payload.subarray(0, IV_BYTES);\n const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);\n const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);\n const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);\n decipher.setAAD(Buffer.from(params.aad, 'utf8'));\n decipher.setAuthTag(authTag);\n\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n } catch (error) {\n if (error instanceof SecretDecryptionError) throw error;\n throw new SecretDecryptionError();\n }\n}\n\nexport function decodeBase64Key(encoded: string | undefined, label: string): Buffer {\n if (!encoded) {\n throw new KekConfigurationError(\n `${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`,\n );\n }\n\n const key = Buffer.from(encoded, 'base64');\n if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {\n throw new KekConfigurationError(\n `${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`,\n );\n }\n\n return key;\n}\n\nexport function aadForDek(workspaceId: string, kekVersion: string): string {\n return JSON.stringify([workspaceId, kekVersion]);\n}\n\nexport function aadForValue(params: SecretValueAadParams): string {\n const projectId = params.scope?.projectId ?? null;\n const scopeTuple = projectId !== null ? ['project', projectId] : ['workspace'];\n return JSON.stringify([params.workspaceId, scopeTuple, params.namespace, params.key]);\n}\n\nfunction isCanonicalBase64Key(encoded: string, key: Buffer): boolean {\n return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;\n}\n"],"names":["crypto","KekConfigurationError","SecretDecryptionError","CIPHER","ENCODED_PREFIX","IV_BYTES","AUTH_TAG_BYTES","KEY_BYTES","BASE64_KEY_PATTERN","BASE64_PADDING_SUFFIX","aesGcmSeal","params","iv","randomBytes","cipher","createCipheriv","key","setAAD","Buffer","from","aad","ciphertext","concat","update","plaintext","final","authTag","getAuthTag","toString","aesGcmOpen","encoded","startsWith","encodedPayload","slice","length","payload","canonical","test","replace","subarray","decipher","createDecipheriv","setAuthTag","error","decodeBase64Key","label","isCanonicalBase64Key","aadForDek","workspaceId","kekVersion","JSON","stringify","aadForValue","projectId","scope","scopeTuple","namespace"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/crypto.ts"],"sourcesContent":["import {\n decodeBase64Key as decodeEnvelopeKey,\n KeyConfigurationError,\n openEnvelopeText,\n sealEnvelopeText,\n} from '@shipfox/node-envelope-encryption';\nimport {KekConfigurationError, SecretDecryptionError} from './errors.js';\n\nexport interface AesGcmSealParams {\n key: Buffer;\n plaintext: Buffer;\n aad: string;\n}\n\nexport interface AesGcmOpenParams {\n key: Buffer;\n encoded: string;\n aad: string;\n}\n\nexport interface SecretScope {\n projectId?: string | null | undefined;\n}\n\nexport interface SecretValueAadParams {\n workspaceId: string;\n scope?: SecretScope | undefined;\n namespace: string;\n key: string;\n}\n\nexport function aesGcmSeal(params: AesGcmSealParams): string {\n return sealEnvelopeText(params);\n}\n\nexport function aesGcmOpen(params: AesGcmOpenParams): Buffer {\n try {\n return openEnvelopeText(params);\n } catch {\n throw new SecretDecryptionError();\n }\n}\n\nexport function decodeBase64Key(encoded: string | undefined, label: string): Buffer {\n try {\n return decodeEnvelopeKey(encoded, label);\n } catch (error) {\n if (error instanceof KeyConfigurationError) throw new KekConfigurationError(error.message);\n throw error;\n }\n}\n\nexport function aadForDek(workspaceId: string, kekVersion: string): string {\n return JSON.stringify([workspaceId, kekVersion]);\n}\n\nexport function aadForValue(params: SecretValueAadParams): string {\n const projectId = params.scope?.projectId ?? null;\n const scopeTuple = projectId !== null ? ['project', projectId] : ['workspace'];\n return JSON.stringify([params.workspaceId, scopeTuple, params.namespace, params.key]);\n}\n"],"names":["decodeBase64Key","decodeEnvelopeKey","KeyConfigurationError","openEnvelopeText","sealEnvelopeText","KekConfigurationError","SecretDecryptionError","aesGcmSeal","params","aesGcmOpen","encoded","label","error","message","aadForDek","workspaceId","kekVersion","JSON","stringify","aadForValue","projectId","scope","scopeTuple","namespace","key"],"mappings":"AAAA,SACEA,mBAAmBC,iBAAiB,EACpCC,qBAAqB,EACrBC,gBAAgB,EAChBC,gBAAgB,QACX,oCAAoC;AAC3C,SAAQC,qBAAqB,EAAEC,qBAAqB,QAAO,cAAc;AAyBzE,OAAO,SAASC,WAAWC,MAAwB;IACjD,OAAOJ,iBAAiBI;AAC1B;AAEA,OAAO,SAASC,WAAWD,MAAwB;IACjD,IAAI;QACF,OAAOL,iBAAiBK;IAC1B,EAAE,OAAM;QACN,MAAM,IAAIF;IACZ;AACF;AAEA,OAAO,SAASN,gBAAgBU,OAA2B,EAAEC,KAAa;IACxE,IAAI;QACF,OAAOV,kBAAkBS,SAASC;IACpC,EAAE,OAAOC,OAAO;QACd,IAAIA,iBAAiBV,uBAAuB,MAAM,IAAIG,sBAAsBO,MAAMC,OAAO;QACzF,MAAMD;IACR;AACF;AAEA,OAAO,SAASE,UAAUC,WAAmB,EAAEC,UAAkB;IAC/D,OAAOC,KAAKC,SAAS,CAAC;QAACH;QAAaC;KAAW;AACjD;AAEA,OAAO,SAASG,YAAYX,MAA4B;IACtD,MAAMY,YAAYZ,OAAOa,KAAK,EAAED,aAAa;IAC7C,MAAME,aAAaF,cAAc,OAAO;QAAC;QAAWA;KAAU,GAAG;QAAC;KAAY;IAC9E,OAAOH,KAAKC,SAAS,CAAC;QAACV,OAAOO,WAAW;QAAEO;QAAYd,OAAOe,SAAS;QAAEf,OAAOgB,GAAG;KAAC;AACtF"}
@@ -1,9 +1,4 @@
1
1
  import type { KeyProvider } from './key-provider.js';
2
- /**
3
- * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
4
- * every access. Node cannot reliably zeroize Buffers, so residency is bounded by
5
- * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
6
- */
7
2
  export declare class DekManager {
8
3
  #private;
9
4
  constructor(keyProvider: KeyProvider, options: {
@@ -1 +1 @@
1
- {"version":3,"file":"dek-manager.d.ts","sourceRoot":"","sources":["../../src/core/dek-manager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAInD;;;;GAIG;AACH,qBAAa,UAAU;;IAKrB,YAAY,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,EAGjF;IAEK,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0D1D;IAED,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAEpC;CAaF"}
1
+ {"version":3,"file":"dek-manager.d.ts","sourceRoot":"","sources":["../../src/core/dek-manager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAEnD,qBAAa,UAAU;;IAGrB,YAAY,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,EAkBjF;IAEK,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAa1D;IAED,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAEpC;CACF"}
@@ -1,63 +1,35 @@
1
- import crypto from 'node:crypto';
1
+ import { DataKeyManager } from '@shipfox/node-envelope-encryption';
2
2
  import { getDataKey, insertDataKeyIfAbsent } from '#db/index.js';
3
3
  import { classifyDekAccessError, recordSecretsDekAccess } from '#metrics/instance.js';
4
- const DEK_BYTES = 32;
5
- /**
6
- * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
7
- * every access. Node cannot reliably zeroize Buffers, so residency is bounded by
8
- * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
9
- */ export class DekManager {
10
- #cache;
11
- #keyProvider;
12
- #options;
4
+ export class DekManager {
5
+ #manager;
13
6
  constructor(keyProvider, options){
14
- this.#cache = new Map();
15
- this.#keyProvider = keyProvider;
16
- this.#options = options;
7
+ this.#manager = new DataKeyManager(keyProvider, {
8
+ async get (workspaceId) {
9
+ const record = await getDataKey(workspaceId);
10
+ return record ? {
11
+ keyId: workspaceId,
12
+ ...record
13
+ } : undefined;
14
+ },
15
+ insertIfAbsent (record) {
16
+ return insertDataKeyIfAbsent({
17
+ workspaceId: record.keyId,
18
+ wrappedDek: record.wrappedDek,
19
+ kekVersion: record.kekVersion
20
+ });
21
+ }
22
+ }, options);
17
23
  }
18
24
  async getPlaintextDek(workspaceId) {
19
25
  const startedAt = Date.now();
20
26
  try {
21
- const cached = this.#cache.get(workspaceId);
22
- if (cached && cached.expiresAt > Date.now()) {
23
- this.#cache.delete(workspaceId);
24
- this.#cache.set(workspaceId, cached);
25
- recordSecretsDekAccess({
26
- outcome: 'cache_hit',
27
- durationMs: Date.now() - startedAt
28
- });
29
- return Buffer.from(cached.dek);
30
- }
31
- const hadExpiredCache = Boolean(cached);
32
- if (cached) this.#cache.delete(workspaceId);
33
- const existing = await getDataKey(workspaceId);
34
- if (existing) {
35
- const dek = this.#keyProvider.unwrapDek(workspaceId, existing.wrappedDek, existing.kekVersion);
36
- this.#set(workspaceId, dek);
37
- recordSecretsDekAccess({
38
- outcome: hadExpiredCache ? 'cache_expired' : 'db_unwrapped',
39
- durationMs: Date.now() - startedAt
40
- });
41
- return Buffer.from(dek);
42
- }
43
- const generatedDek = crypto.randomBytes(DEK_BYTES);
44
- const wrapped = this.#keyProvider.wrapDek(workspaceId, generatedDek);
45
- const inserted = await insertDataKeyIfAbsent({
46
- workspaceId,
47
- wrappedDek: wrapped.wrappedDek,
48
- kekVersion: wrapped.kekVersion
49
- });
50
- // The DEK row commits before value writes. If concurrent first-use inserts race,
51
- // the primary key decides the winner and every caller re-reads the persisted row.
52
- const persisted = await getDataKey(workspaceId);
53
- if (!persisted) throw new Error(`Data key was not persisted for workspace ${workspaceId}`);
54
- const dek = this.#keyProvider.unwrapDek(workspaceId, persisted.wrappedDek, persisted.kekVersion);
55
- this.#set(workspaceId, dek);
27
+ const result = await this.#manager.getPlaintextDataKey(workspaceId);
56
28
  recordSecretsDekAccess({
57
- outcome: inserted ? 'generated' : 'db_unwrapped',
29
+ outcome: result.outcome,
58
30
  durationMs: Date.now() - startedAt
59
31
  });
60
- return Buffer.from(dek);
32
+ return result.dek;
61
33
  } catch (error) {
62
34
  recordSecretsDekAccess({
63
35
  outcome: classifyDekAccessError(error),
@@ -67,18 +39,7 @@ const DEK_BYTES = 32;
67
39
  }
68
40
  }
69
41
  invalidate(workspaceId) {
70
- this.#cache.delete(workspaceId);
71
- }
72
- #set(workspaceId, dek) {
73
- this.#cache.set(workspaceId, {
74
- dek: Buffer.from(dek),
75
- expiresAt: Date.now() + this.#options.ttlMs
76
- });
77
- while(this.#cache.size > this.#options.maxEntries){
78
- const oldest = this.#cache.keys().next().value;
79
- if (!oldest) break;
80
- this.#cache.delete(oldest);
81
- }
42
+ this.#manager.invalidate(workspaceId);
82
43
  }
83
44
  }
84
45
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/dek-manager.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport {getDataKey, insertDataKeyIfAbsent} from '#db/index.js';\nimport {classifyDekAccessError, recordSecretsDekAccess} from '#metrics/instance.js';\nimport type {KeyProvider} from './key-provider.js';\n\nconst DEK_BYTES = 32;\n\n/**\n * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on\n * every access. Node cannot reliably zeroize Buffers, so residency is bounded by\n * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.\n */\nexport class DekManager {\n readonly #cache = new Map<string, {dek: Buffer; expiresAt: number}>();\n readonly #keyProvider: KeyProvider;\n readonly #options: {maxEntries: number; ttlMs: number};\n\n constructor(keyProvider: KeyProvider, options: {maxEntries: number; ttlMs: number}) {\n this.#keyProvider = keyProvider;\n this.#options = options;\n }\n\n async getPlaintextDek(workspaceId: string): Promise<Buffer> {\n const startedAt = Date.now();\n try {\n const cached = this.#cache.get(workspaceId);\n if (cached && cached.expiresAt > Date.now()) {\n this.#cache.delete(workspaceId);\n this.#cache.set(workspaceId, cached);\n recordSecretsDekAccess({outcome: 'cache_hit', durationMs: Date.now() - startedAt});\n return Buffer.from(cached.dek);\n }\n const hadExpiredCache = Boolean(cached);\n if (cached) this.#cache.delete(workspaceId);\n\n const existing = await getDataKey(workspaceId);\n if (existing) {\n const dek = this.#keyProvider.unwrapDek(\n workspaceId,\n existing.wrappedDek,\n existing.kekVersion,\n );\n this.#set(workspaceId, dek);\n recordSecretsDekAccess({\n outcome: hadExpiredCache ? 'cache_expired' : 'db_unwrapped',\n durationMs: Date.now() - startedAt,\n });\n return Buffer.from(dek);\n }\n\n const generatedDek = crypto.randomBytes(DEK_BYTES);\n const wrapped = this.#keyProvider.wrapDek(workspaceId, generatedDek);\n const inserted = await insertDataKeyIfAbsent({\n workspaceId,\n wrappedDek: wrapped.wrappedDek,\n kekVersion: wrapped.kekVersion,\n });\n\n // The DEK row commits before value writes. If concurrent first-use inserts race,\n // the primary key decides the winner and every caller re-reads the persisted row.\n const persisted = await getDataKey(workspaceId);\n if (!persisted) throw new Error(`Data key was not persisted for workspace ${workspaceId}`);\n const dek = this.#keyProvider.unwrapDek(\n workspaceId,\n persisted.wrappedDek,\n persisted.kekVersion,\n );\n this.#set(workspaceId, dek);\n recordSecretsDekAccess({\n outcome: inserted ? 'generated' : 'db_unwrapped',\n durationMs: Date.now() - startedAt,\n });\n return Buffer.from(dek);\n } catch (error) {\n recordSecretsDekAccess({\n outcome: classifyDekAccessError(error),\n durationMs: Date.now() - startedAt,\n });\n throw error;\n }\n }\n\n invalidate(workspaceId: string): void {\n this.#cache.delete(workspaceId);\n }\n\n #set(workspaceId: string, dek: Buffer): void {\n this.#cache.set(workspaceId, {\n dek: Buffer.from(dek),\n expiresAt: Date.now() + this.#options.ttlMs,\n });\n while (this.#cache.size > this.#options.maxEntries) {\n const oldest = this.#cache.keys().next().value as string | undefined;\n if (!oldest) break;\n this.#cache.delete(oldest);\n }\n }\n}\n"],"names":["crypto","getDataKey","insertDataKeyIfAbsent","classifyDekAccessError","recordSecretsDekAccess","DEK_BYTES","DekManager","keyProvider","options","Map","getPlaintextDek","workspaceId","startedAt","Date","now","cached","get","expiresAt","delete","set","outcome","durationMs","Buffer","from","dek","hadExpiredCache","Boolean","existing","unwrapDek","wrappedDek","kekVersion","generatedDek","randomBytes","wrapped","wrapDek","inserted","persisted","Error","error","invalidate","ttlMs","size","maxEntries","oldest","keys","next","value"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/dek-manager.ts"],"sourcesContent":["import {DataKeyManager} from '@shipfox/node-envelope-encryption';\nimport {getDataKey, insertDataKeyIfAbsent} from '#db/index.js';\nimport {classifyDekAccessError, recordSecretsDekAccess} from '#metrics/instance.js';\nimport type {KeyProvider} from './key-provider.js';\n\nexport class DekManager {\n readonly #manager: DataKeyManager;\n\n constructor(keyProvider: KeyProvider, options: {maxEntries: number; ttlMs: number}) {\n this.#manager = new DataKeyManager(\n keyProvider,\n {\n async get(workspaceId) {\n const record = await getDataKey(workspaceId);\n return record ? {keyId: workspaceId, ...record} : undefined;\n },\n insertIfAbsent(record) {\n return insertDataKeyIfAbsent({\n workspaceId: record.keyId,\n wrappedDek: record.wrappedDek,\n kekVersion: record.kekVersion,\n });\n },\n },\n options,\n );\n }\n\n async getPlaintextDek(workspaceId: string): Promise<Buffer> {\n const startedAt = Date.now();\n try {\n const result = await this.#manager.getPlaintextDataKey(workspaceId);\n recordSecretsDekAccess({outcome: result.outcome, durationMs: Date.now() - startedAt});\n return result.dek;\n } catch (error) {\n recordSecretsDekAccess({\n outcome: classifyDekAccessError(error),\n durationMs: Date.now() - startedAt,\n });\n throw error;\n }\n }\n\n invalidate(workspaceId: string): void {\n this.#manager.invalidate(workspaceId);\n }\n}\n"],"names":["DataKeyManager","getDataKey","insertDataKeyIfAbsent","classifyDekAccessError","recordSecretsDekAccess","DekManager","keyProvider","options","get","workspaceId","record","keyId","undefined","insertIfAbsent","wrappedDek","kekVersion","getPlaintextDek","startedAt","Date","now","result","getPlaintextDataKey","outcome","durationMs","dek","error","invalidate"],"mappings":"AAAA,SAAQA,cAAc,QAAO,oCAAoC;AACjE,SAAQC,UAAU,EAAEC,qBAAqB,QAAO,eAAe;AAC/D,SAAQC,sBAAsB,EAAEC,sBAAsB,QAAO,uBAAuB;AAGpF,OAAO,MAAMC;IACF,CAAA,OAAQ,CAAiB;IAElC,YAAYC,WAAwB,EAAEC,OAA4C,CAAE;QAClF,IAAI,CAAC,CAAA,OAAQ,GAAG,IAAIP,eAClBM,aACA;YACE,MAAME,KAAIC,WAAW;gBACnB,MAAMC,SAAS,MAAMT,WAAWQ;gBAChC,OAAOC,SAAS;oBAACC,OAAOF;oBAAa,GAAGC,MAAM;gBAAA,IAAIE;YACpD;YACAC,gBAAeH,MAAM;gBACnB,OAAOR,sBAAsB;oBAC3BO,aAAaC,OAAOC,KAAK;oBACzBG,YAAYJ,OAAOI,UAAU;oBAC7BC,YAAYL,OAAOK,UAAU;gBAC/B;YACF;QACF,GACAR;IAEJ;IAEA,MAAMS,gBAAgBP,WAAmB,EAAmB;QAC1D,MAAMQ,YAAYC,KAAKC,GAAG;QAC1B,IAAI;YACF,MAAMC,SAAS,MAAM,IAAI,CAAC,CAAA,OAAQ,CAACC,mBAAmB,CAACZ;YACvDL,uBAAuB;gBAACkB,SAASF,OAAOE,OAAO;gBAAEC,YAAYL,KAAKC,GAAG,KAAKF;YAAS;YACnF,OAAOG,OAAOI,GAAG;QACnB,EAAE,OAAOC,OAAO;YACdrB,uBAAuB;gBACrBkB,SAASnB,uBAAuBsB;gBAChCF,YAAYL,KAAKC,GAAG,KAAKF;YAC3B;YACA,MAAMQ;QACR;IACF;IAEAC,WAAWjB,WAAmB,EAAQ;QACpC,IAAI,CAAC,CAAA,OAAQ,CAACiB,UAAU,CAACjB;IAC3B;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"key-provider.d.ts","sourceRoot":"","sources":["../../src/core/key-provider.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,UAAU,CAAC;IAC/D,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;CAChF;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,sBAAsB,GAAG,WAAW,CAyClF;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGzD"}
1
+ {"version":3,"file":"key-provider.d.ts","sourceRoot":"","sources":["../../src/core/key-provider.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,UAAU,CAAC;IAC/D,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;CAChF;AAED,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,sBAAsB,GAAG,WAAW,CAsBlF;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEzD"}
@@ -1,45 +1,34 @@
1
- import crypto from 'node:crypto';
2
- import { aadForDek, aesGcmOpen, aesGcmSeal } from './crypto.js';
1
+ import { createLocalKeyProvider as createEnvelopeKeyProvider, DataKeyUnwrapError, DataKeyWrapError, deriveLocalKeyVersion } from '@shipfox/node-envelope-encryption';
3
2
  import { DekUnwrapError, DekWrapError } from './errors.js';
4
3
  const KEK_VERSION_DOMAIN = 'shipfox-secrets-kek-version';
5
4
  export function createLocalKeyProvider(params) {
6
- const currentKeyVersion = deriveLocalKekVersion(params.currentKek);
7
- const previousKeyVersion = params.previousKek ? deriveLocalKekVersion(params.previousKek) : null;
5
+ const provider = createEnvelopeKeyProvider({
6
+ ...params,
7
+ keyVersionDomain: KEK_VERSION_DOMAIN
8
+ });
8
9
  return {
9
- currentKeyVersion,
10
- previousKeyVersion,
10
+ currentKeyVersion: provider.currentKeyVersion,
11
+ previousKeyVersion: provider.previousKeyVersion,
11
12
  wrapDek (workspaceId, plaintextDek) {
12
13
  try {
13
- return {
14
- wrappedDek: aesGcmSeal({
15
- key: params.currentKek,
16
- plaintext: plaintextDek,
17
- aad: aadForDek(workspaceId, currentKeyVersion)
18
- }),
19
- kekVersion: currentKeyVersion
20
- };
21
- } catch {
22
- throw new DekWrapError();
14
+ return provider.wrapDek(workspaceId, plaintextDek);
15
+ } catch (error) {
16
+ if (error instanceof DataKeyWrapError) throw new DekWrapError();
17
+ throw error;
23
18
  }
24
19
  },
25
20
  unwrapDek (workspaceId, wrappedDek, kekVersion) {
26
- const key = kekVersion === currentKeyVersion ? params.currentKek : kekVersion === previousKeyVersion ? params.previousKek : undefined;
27
- if (!key) throw new DekUnwrapError();
28
21
  try {
29
- return aesGcmOpen({
30
- key,
31
- encoded: wrappedDek,
32
- aad: aadForDek(workspaceId, kekVersion)
33
- });
34
- } catch {
35
- throw new DekUnwrapError();
22
+ return provider.unwrapDek(workspaceId, wrappedDek, kekVersion);
23
+ } catch (error) {
24
+ if (error instanceof DataKeyUnwrapError) throw new DekUnwrapError();
25
+ throw error;
36
26
  }
37
27
  }
38
28
  };
39
29
  }
40
30
  export function deriveLocalKekVersion(kek) {
41
- const hash = crypto.createHash('sha256').update(KEK_VERSION_DOMAIN).update(kek).digest('hex');
42
- return `local:${hash.slice(0, 16)}`;
31
+ return deriveLocalKeyVersion(kek, KEK_VERSION_DOMAIN);
43
32
  }
44
33
 
45
34
  //# sourceMappingURL=key-provider.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/key-provider.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport {aadForDek, aesGcmOpen, aesGcmSeal} from './crypto.js';\nimport {DekUnwrapError, DekWrapError} from './errors.js';\n\nconst KEK_VERSION_DOMAIN = 'shipfox-secrets-kek-version';\n\nexport interface WrappedDek {\n wrappedDek: string;\n kekVersion: string;\n}\n\nexport interface KeyProvider {\n readonly currentKeyVersion: string;\n readonly previousKeyVersion: string | null;\n wrapDek(workspaceId: string, plaintextDek: Buffer): WrappedDek;\n unwrapDek(workspaceId: string, wrappedDek: string, kekVersion: string): Buffer;\n}\n\nexport interface LocalKeyProviderParams {\n currentKek: Buffer;\n previousKek?: Buffer | undefined;\n}\n\nexport function createLocalKeyProvider(params: LocalKeyProviderParams): KeyProvider {\n const currentKeyVersion = deriveLocalKekVersion(params.currentKek);\n const previousKeyVersion = params.previousKek ? deriveLocalKekVersion(params.previousKek) : null;\n\n return {\n currentKeyVersion,\n previousKeyVersion,\n wrapDek(workspaceId, plaintextDek) {\n try {\n return {\n wrappedDek: aesGcmSeal({\n key: params.currentKek,\n plaintext: plaintextDek,\n aad: aadForDek(workspaceId, currentKeyVersion),\n }),\n kekVersion: currentKeyVersion,\n };\n } catch {\n throw new DekWrapError();\n }\n },\n unwrapDek(workspaceId, wrappedDek, kekVersion) {\n const key =\n kekVersion === currentKeyVersion\n ? params.currentKek\n : kekVersion === previousKeyVersion\n ? params.previousKek\n : undefined;\n if (!key) throw new DekUnwrapError();\n\n try {\n return aesGcmOpen({\n key,\n encoded: wrappedDek,\n aad: aadForDek(workspaceId, kekVersion),\n });\n } catch {\n throw new DekUnwrapError();\n }\n },\n };\n}\n\nexport function deriveLocalKekVersion(kek: Buffer): string {\n const hash = crypto.createHash('sha256').update(KEK_VERSION_DOMAIN).update(kek).digest('hex');\n return `local:${hash.slice(0, 16)}`;\n}\n"],"names":["crypto","aadForDek","aesGcmOpen","aesGcmSeal","DekUnwrapError","DekWrapError","KEK_VERSION_DOMAIN","createLocalKeyProvider","params","currentKeyVersion","deriveLocalKekVersion","currentKek","previousKeyVersion","previousKek","wrapDek","workspaceId","plaintextDek","wrappedDek","key","plaintext","aad","kekVersion","unwrapDek","undefined","encoded","kek","hash","createHash","update","digest","slice"],"mappings":"AAAA,OAAOA,YAAY,cAAc;AACjC,SAAQC,SAAS,EAAEC,UAAU,EAAEC,UAAU,QAAO,cAAc;AAC9D,SAAQC,cAAc,EAAEC,YAAY,QAAO,cAAc;AAEzD,MAAMC,qBAAqB;AAmB3B,OAAO,SAASC,uBAAuBC,MAA8B;IACnE,MAAMC,oBAAoBC,sBAAsBF,OAAOG,UAAU;IACjE,MAAMC,qBAAqBJ,OAAOK,WAAW,GAAGH,sBAAsBF,OAAOK,WAAW,IAAI;IAE5F,OAAO;QACLJ;QACAG;QACAE,SAAQC,WAAW,EAAEC,YAAY;YAC/B,IAAI;gBACF,OAAO;oBACLC,YAAYd,WAAW;wBACrBe,KAAKV,OAAOG,UAAU;wBACtBQ,WAAWH;wBACXI,KAAKnB,UAAUc,aAAaN;oBAC9B;oBACAY,YAAYZ;gBACd;YACF,EAAE,OAAM;gBACN,MAAM,IAAIJ;YACZ;QACF;QACAiB,WAAUP,WAAW,EAAEE,UAAU,EAAEI,UAAU;YAC3C,MAAMH,MACJG,eAAeZ,oBACXD,OAAOG,UAAU,GACjBU,eAAeT,qBACbJ,OAAOK,WAAW,GAClBU;YACR,IAAI,CAACL,KAAK,MAAM,IAAId;YAEpB,IAAI;gBACF,OAAOF,WAAW;oBAChBgB;oBACAM,SAASP;oBACTG,KAAKnB,UAAUc,aAAaM;gBAC9B;YACF,EAAE,OAAM;gBACN,MAAM,IAAIjB;YACZ;QACF;IACF;AACF;AAEA,OAAO,SAASM,sBAAsBe,GAAW;IAC/C,MAAMC,OAAO1B,OAAO2B,UAAU,CAAC,UAAUC,MAAM,CAACtB,oBAAoBsB,MAAM,CAACH,KAAKI,MAAM,CAAC;IACvF,OAAO,CAAC,MAAM,EAAEH,KAAKI,KAAK,CAAC,GAAG,KAAK;AACrC"}
1
+ {"version":3,"sources":["../../src/core/key-provider.ts"],"sourcesContent":["import {\n createLocalKeyProvider as createEnvelopeKeyProvider,\n DataKeyUnwrapError,\n DataKeyWrapError,\n deriveLocalKeyVersion,\n} from '@shipfox/node-envelope-encryption';\nimport {DekUnwrapError, DekWrapError} from './errors.js';\n\nconst KEK_VERSION_DOMAIN = 'shipfox-secrets-kek-version';\n\nexport interface WrappedDek {\n wrappedDek: string;\n kekVersion: string;\n}\n\nexport interface KeyProvider {\n readonly currentKeyVersion: string;\n readonly previousKeyVersion: string | null;\n wrapDek(workspaceId: string, plaintextDek: Buffer): WrappedDek;\n unwrapDek(workspaceId: string, wrappedDek: string, kekVersion: string): Buffer;\n}\n\nexport interface LocalKeyProviderParams {\n currentKek: Buffer;\n previousKek?: Buffer | undefined;\n}\n\nexport function createLocalKeyProvider(params: LocalKeyProviderParams): KeyProvider {\n const provider = createEnvelopeKeyProvider({...params, keyVersionDomain: KEK_VERSION_DOMAIN});\n return {\n currentKeyVersion: provider.currentKeyVersion,\n previousKeyVersion: provider.previousKeyVersion,\n wrapDek(workspaceId, plaintextDek) {\n try {\n return provider.wrapDek(workspaceId, plaintextDek);\n } catch (error) {\n if (error instanceof DataKeyWrapError) throw new DekWrapError();\n throw error;\n }\n },\n unwrapDek(workspaceId, wrappedDek, kekVersion) {\n try {\n return provider.unwrapDek(workspaceId, wrappedDek, kekVersion);\n } catch (error) {\n if (error instanceof DataKeyUnwrapError) throw new DekUnwrapError();\n throw error;\n }\n },\n };\n}\n\nexport function deriveLocalKekVersion(kek: Buffer): string {\n return deriveLocalKeyVersion(kek, KEK_VERSION_DOMAIN);\n}\n"],"names":["createLocalKeyProvider","createEnvelopeKeyProvider","DataKeyUnwrapError","DataKeyWrapError","deriveLocalKeyVersion","DekUnwrapError","DekWrapError","KEK_VERSION_DOMAIN","params","provider","keyVersionDomain","currentKeyVersion","previousKeyVersion","wrapDek","workspaceId","plaintextDek","error","unwrapDek","wrappedDek","kekVersion","deriveLocalKekVersion","kek"],"mappings":"AAAA,SACEA,0BAA0BC,yBAAyB,EACnDC,kBAAkB,EAClBC,gBAAgB,EAChBC,qBAAqB,QAChB,oCAAoC;AAC3C,SAAQC,cAAc,EAAEC,YAAY,QAAO,cAAc;AAEzD,MAAMC,qBAAqB;AAmB3B,OAAO,SAASP,uBAAuBQ,MAA8B;IACnE,MAAMC,WAAWR,0BAA0B;QAAC,GAAGO,MAAM;QAAEE,kBAAkBH;IAAkB;IAC3F,OAAO;QACLI,mBAAmBF,SAASE,iBAAiB;QAC7CC,oBAAoBH,SAASG,kBAAkB;QAC/CC,SAAQC,WAAW,EAAEC,YAAY;YAC/B,IAAI;gBACF,OAAON,SAASI,OAAO,CAACC,aAAaC;YACvC,EAAE,OAAOC,OAAO;gBACd,IAAIA,iBAAiBb,kBAAkB,MAAM,IAAIG;gBACjD,MAAMU;YACR;QACF;QACAC,WAAUH,WAAW,EAAEI,UAAU,EAAEC,UAAU;YAC3C,IAAI;gBACF,OAAOV,SAASQ,SAAS,CAACH,aAAaI,YAAYC;YACrD,EAAE,OAAOH,OAAO;gBACd,IAAIA,iBAAiBd,oBAAoB,MAAM,IAAIG;gBACnD,MAAMW;YACR;QACF;IACF;AACF;AAEA,OAAO,SAASI,sBAAsBC,GAAW;IAC/C,OAAOjB,sBAAsBiB,KAAKd;AACpC"}
@@ -1 +1 @@
1
- {"version":3,"file":"rotate-kek.d.ts","sourceRoot":"","sources":["../../src/core/rotate-kek.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAInD,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,8BAA8B;IAC7C,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CACrC;AAED,wBAAsB,mCAAmC,CACvD,WAAW,EAAE,WAAW,EACxB,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CAqExC"}
1
+ {"version":3,"file":"rotate-kek.d.ts","sourceRoot":"","sources":["../../src/core/rotate-kek.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAEnD,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,8BAA8B;IAC7C,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CACrC;AAED,wBAAgB,mCAAmC,CACjD,WAAW,EAAE,WAAW,EACxB,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,6BAA6B,CAAC,CA4BxC"}
@@ -1,94 +1,40 @@
1
+ import { rotateDataKeysWithTelemetry } from '@shipfox/node-envelope-encryption';
1
2
  import { listDataKeysPage, listDataKeyVersions, updateDataKeyWrapCas } from '#db/index.js';
2
3
  import { classifyKekRotationError, recordSecretsKekRotation } from '#metrics/instance.js';
3
4
  import { KekVersionStrandedError } from './errors.js';
4
- const PAGE_SIZE = 100;
5
- export async function rotateWorkspaceDataKeysWithProvider(keyProvider, options = {}) {
6
- const startedAt = Date.now();
7
- try {
8
- const knownVersions = [
9
- keyProvider.currentKeyVersion,
10
- keyProvider.previousKeyVersion
11
- ].filter((version)=>Boolean(version));
12
- const unknownVersions = await listDataKeyVersions(knownVersions, {
13
- workspaceIds: options.workspaceIds
14
- });
15
- if (unknownVersions.length > 0) throw new KekVersionStrandedError(unknownVersions[0]);
16
- let rotated = 0;
17
- let skipped = 0;
18
- let skippedCurrent = 0;
19
- let skippedRace = 0;
20
- let afterWorkspaceId;
21
- while(true){
22
- const page = await listDataKeysPage({
23
- afterWorkspaceId,
24
- limit: PAGE_SIZE,
25
- workspaceIds: options.workspaceIds
26
- });
27
- if (page.length === 0) break;
28
- for (const row of page){
29
- afterWorkspaceId = row.workspaceId;
30
- if (row.kekVersion === keyProvider.currentKeyVersion) {
31
- skipped += 1;
32
- skippedCurrent += 1;
33
- continue;
34
- }
35
- const plaintextDek = keyProvider.unwrapDek(row.workspaceId, row.wrappedDek, row.kekVersion);
36
- try {
37
- const wrapped = keyProvider.wrapDek(row.workspaceId, plaintextDek);
38
- const updated = await updateDataKeyWrapCas({
39
- workspaceId: row.workspaceId,
40
- oldKekVersion: row.kekVersion,
41
- wrappedDek: wrapped.wrappedDek,
42
- kekVersion: wrapped.kekVersion
43
- });
44
- if (updated) rotated += 1;
45
- else {
46
- skipped += 1;
47
- skippedRace += 1;
48
- }
49
- } finally{
50
- plaintextDek.fill(0);
51
- }
5
+ export function rotateWorkspaceDataKeysWithProvider(keyProvider, options = {}) {
6
+ return rotateDataKeysWithTelemetry({
7
+ keyProvider,
8
+ repository: {
9
+ listUnknownKeyVersions (knownVersions) {
10
+ return listDataKeyVersions(knownVersions, {
11
+ workspaceIds: options.workspaceIds
12
+ });
13
+ },
14
+ async listPage (params) {
15
+ const rows = await listDataKeysPage({
16
+ afterWorkspaceId: params.afterKeyId,
17
+ limit: params.limit,
18
+ workspaceIds: options.workspaceIds
19
+ });
20
+ return rows.map((row)=>({
21
+ keyId: row.workspaceId,
22
+ ...row
23
+ }));
24
+ },
25
+ updateWrapCas (params) {
26
+ return updateDataKeyWrapCas({
27
+ workspaceId: params.keyId,
28
+ oldKekVersion: params.oldKekVersion,
29
+ wrappedDek: params.wrappedDek,
30
+ kekVersion: params.kekVersion
31
+ });
52
32
  }
53
- }
54
- recordSecretsKekRotation({
55
- outcome: 'rotated',
56
- count: rotated
57
- });
58
- recordSecretsKekRotation({
59
- outcome: 'skipped_current',
60
- count: skippedCurrent
61
- });
62
- recordSecretsKekRotation({
63
- outcome: 'skipped_race',
64
- count: skippedRace
65
- });
66
- recordSecretsKekRotation({
67
- outcome: rotationDurationOutcome({
68
- rotated,
69
- skippedCurrent,
70
- skippedRace
71
- }),
72
- count: 0,
73
- durationMs: Date.now() - startedAt
74
- });
75
- return {
76
- rotated,
77
- skipped
78
- };
79
- } catch (error) {
80
- recordSecretsKekRotation({
81
- outcome: classifyKekRotationError(error),
82
- durationMs: Date.now() - startedAt
83
- });
84
- throw error;
85
- }
86
- }
87
- function rotationDurationOutcome(params) {
88
- if (params.rotated > 0) return 'rotated';
89
- if (params.skippedRace > 0) return 'skipped_race';
90
- if (params.skippedCurrent === 0) return 'none';
91
- return 'skipped_current';
33
+ },
34
+ record: recordSecretsKekRotation,
35
+ classifyError: classifyKekRotationError,
36
+ strandedError: (keyVersion)=>new KekVersionStrandedError(keyVersion)
37
+ });
92
38
  }
93
39
 
94
40
  //# sourceMappingURL=rotate-kek.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/rotate-kek.ts"],"sourcesContent":["import {listDataKeysPage, listDataKeyVersions, updateDataKeyWrapCas} from '#db/index.js';\nimport {classifyKekRotationError, recordSecretsKekRotation} from '#metrics/instance.js';\nimport {KekVersionStrandedError} from './errors.js';\nimport type {KeyProvider} from './key-provider.js';\n\nconst PAGE_SIZE = 100;\n\nexport interface RotateWorkspaceDataKeysResult {\n rotated: number;\n skipped: number;\n}\n\nexport interface RotateWorkspaceDataKeysOptions {\n workspaceIds?: string[] | undefined;\n}\n\nexport async function rotateWorkspaceDataKeysWithProvider(\n keyProvider: KeyProvider,\n options: RotateWorkspaceDataKeysOptions = {},\n): Promise<RotateWorkspaceDataKeysResult> {\n const startedAt = Date.now();\n try {\n const knownVersions = [keyProvider.currentKeyVersion, keyProvider.previousKeyVersion].filter(\n (version): version is string => Boolean(version),\n );\n const unknownVersions = await listDataKeyVersions(knownVersions, {\n workspaceIds: options.workspaceIds,\n });\n if (unknownVersions.length > 0) throw new KekVersionStrandedError(unknownVersions[0] as string);\n\n let rotated = 0;\n let skipped = 0;\n let skippedCurrent = 0;\n let skippedRace = 0;\n let afterWorkspaceId: string | undefined;\n\n while (true) {\n const page = await listDataKeysPage({\n afterWorkspaceId,\n limit: PAGE_SIZE,\n workspaceIds: options.workspaceIds,\n });\n if (page.length === 0) break;\n\n for (const row of page) {\n afterWorkspaceId = row.workspaceId;\n if (row.kekVersion === keyProvider.currentKeyVersion) {\n skipped += 1;\n skippedCurrent += 1;\n continue;\n }\n\n const plaintextDek = keyProvider.unwrapDek(row.workspaceId, row.wrappedDek, row.kekVersion);\n try {\n const wrapped = keyProvider.wrapDek(row.workspaceId, plaintextDek);\n const updated = await updateDataKeyWrapCas({\n workspaceId: row.workspaceId,\n oldKekVersion: row.kekVersion,\n wrappedDek: wrapped.wrappedDek,\n kekVersion: wrapped.kekVersion,\n });\n if (updated) rotated += 1;\n else {\n skipped += 1;\n skippedRace += 1;\n }\n } finally {\n plaintextDek.fill(0);\n }\n }\n }\n\n recordSecretsKekRotation({outcome: 'rotated', count: rotated});\n recordSecretsKekRotation({outcome: 'skipped_current', count: skippedCurrent});\n recordSecretsKekRotation({outcome: 'skipped_race', count: skippedRace});\n recordSecretsKekRotation({\n outcome: rotationDurationOutcome({rotated, skippedCurrent, skippedRace}),\n count: 0,\n durationMs: Date.now() - startedAt,\n });\n return {rotated, skipped};\n } catch (error) {\n recordSecretsKekRotation({\n outcome: classifyKekRotationError(error),\n durationMs: Date.now() - startedAt,\n });\n throw error;\n }\n}\n\nfunction rotationDurationOutcome(params: {\n rotated: number;\n skippedCurrent: number;\n skippedRace: number;\n}) {\n if (params.rotated > 0) return 'rotated';\n if (params.skippedRace > 0) return 'skipped_race';\n if (params.skippedCurrent === 0) return 'none';\n return 'skipped_current';\n}\n"],"names":["listDataKeysPage","listDataKeyVersions","updateDataKeyWrapCas","classifyKekRotationError","recordSecretsKekRotation","KekVersionStrandedError","PAGE_SIZE","rotateWorkspaceDataKeysWithProvider","keyProvider","options","startedAt","Date","now","knownVersions","currentKeyVersion","previousKeyVersion","filter","version","Boolean","unknownVersions","workspaceIds","length","rotated","skipped","skippedCurrent","skippedRace","afterWorkspaceId","page","limit","row","workspaceId","kekVersion","plaintextDek","unwrapDek","wrappedDek","wrapped","wrapDek","updated","oldKekVersion","fill","outcome","count","rotationDurationOutcome","durationMs","error","params"],"mappings":"AAAA,SAAQA,gBAAgB,EAAEC,mBAAmB,EAAEC,oBAAoB,QAAO,eAAe;AACzF,SAAQC,wBAAwB,EAAEC,wBAAwB,QAAO,uBAAuB;AACxF,SAAQC,uBAAuB,QAAO,cAAc;AAGpD,MAAMC,YAAY;AAWlB,OAAO,eAAeC,oCACpBC,WAAwB,EACxBC,UAA0C,CAAC,CAAC;IAE5C,MAAMC,YAAYC,KAAKC,GAAG;IAC1B,IAAI;QACF,MAAMC,gBAAgB;YAACL,YAAYM,iBAAiB;YAAEN,YAAYO,kBAAkB;SAAC,CAACC,MAAM,CAC1F,CAACC,UAA+BC,QAAQD;QAE1C,MAAME,kBAAkB,MAAMlB,oBAAoBY,eAAe;YAC/DO,cAAcX,QAAQW,YAAY;QACpC;QACA,IAAID,gBAAgBE,MAAM,GAAG,GAAG,MAAM,IAAIhB,wBAAwBc,eAAe,CAAC,EAAE;QAEpF,IAAIG,UAAU;QACd,IAAIC,UAAU;QACd,IAAIC,iBAAiB;QACrB,IAAIC,cAAc;QAClB,IAAIC;QAEJ,MAAO,KAAM;YACX,MAAMC,OAAO,MAAM3B,iBAAiB;gBAClC0B;gBACAE,OAAOtB;gBACPc,cAAcX,QAAQW,YAAY;YACpC;YACA,IAAIO,KAAKN,MAAM,KAAK,GAAG;YAEvB,KAAK,MAAMQ,OAAOF,KAAM;gBACtBD,mBAAmBG,IAAIC,WAAW;gBAClC,IAAID,IAAIE,UAAU,KAAKvB,YAAYM,iBAAiB,EAAE;oBACpDS,WAAW;oBACXC,kBAAkB;oBAClB;gBACF;gBAEA,MAAMQ,eAAexB,YAAYyB,SAAS,CAACJ,IAAIC,WAAW,EAAED,IAAIK,UAAU,EAAEL,IAAIE,UAAU;gBAC1F,IAAI;oBACF,MAAMI,UAAU3B,YAAY4B,OAAO,CAACP,IAAIC,WAAW,EAAEE;oBACrD,MAAMK,UAAU,MAAMnC,qBAAqB;wBACzC4B,aAAaD,IAAIC,WAAW;wBAC5BQ,eAAeT,IAAIE,UAAU;wBAC7BG,YAAYC,QAAQD,UAAU;wBAC9BH,YAAYI,QAAQJ,UAAU;oBAChC;oBACA,IAAIM,SAASf,WAAW;yBACnB;wBACHC,WAAW;wBACXE,eAAe;oBACjB;gBACF,SAAU;oBACRO,aAAaO,IAAI,CAAC;gBACpB;YACF;QACF;QAEAnC,yBAAyB;YAACoC,SAAS;YAAWC,OAAOnB;QAAO;QAC5DlB,yBAAyB;YAACoC,SAAS;YAAmBC,OAAOjB;QAAc;QAC3EpB,yBAAyB;YAACoC,SAAS;YAAgBC,OAAOhB;QAAW;QACrErB,yBAAyB;YACvBoC,SAASE,wBAAwB;gBAACpB;gBAASE;gBAAgBC;YAAW;YACtEgB,OAAO;YACPE,YAAYhC,KAAKC,GAAG,KAAKF;QAC3B;QACA,OAAO;YAACY;YAASC;QAAO;IAC1B,EAAE,OAAOqB,OAAO;QACdxC,yBAAyB;YACvBoC,SAASrC,yBAAyByC;YAClCD,YAAYhC,KAAKC,GAAG,KAAKF;QAC3B;QACA,MAAMkC;IACR;AACF;AAEA,SAASF,wBAAwBG,MAIhC;IACC,IAAIA,OAAOvB,OAAO,GAAG,GAAG,OAAO;IAC/B,IAAIuB,OAAOpB,WAAW,GAAG,GAAG,OAAO;IACnC,IAAIoB,OAAOrB,cAAc,KAAK,GAAG,OAAO;IACxC,OAAO;AACT"}
1
+ {"version":3,"sources":["../../src/core/rotate-kek.ts"],"sourcesContent":["import {rotateDataKeysWithTelemetry} from '@shipfox/node-envelope-encryption';\nimport {listDataKeysPage, listDataKeyVersions, updateDataKeyWrapCas} from '#db/index.js';\nimport {classifyKekRotationError, recordSecretsKekRotation} from '#metrics/instance.js';\nimport {KekVersionStrandedError} from './errors.js';\nimport type {KeyProvider} from './key-provider.js';\n\nexport interface RotateWorkspaceDataKeysResult {\n rotated: number;\n skipped: number;\n}\n\nexport interface RotateWorkspaceDataKeysOptions {\n workspaceIds?: string[] | undefined;\n}\n\nexport function rotateWorkspaceDataKeysWithProvider(\n keyProvider: KeyProvider,\n options: RotateWorkspaceDataKeysOptions = {},\n): Promise<RotateWorkspaceDataKeysResult> {\n return rotateDataKeysWithTelemetry({\n keyProvider,\n repository: {\n listUnknownKeyVersions(knownVersions) {\n return listDataKeyVersions(knownVersions, {workspaceIds: options.workspaceIds});\n },\n async listPage(params) {\n const rows = await listDataKeysPage({\n afterWorkspaceId: params.afterKeyId,\n limit: params.limit,\n workspaceIds: options.workspaceIds,\n });\n return rows.map((row) => ({keyId: row.workspaceId, ...row}));\n },\n updateWrapCas(params) {\n return updateDataKeyWrapCas({\n workspaceId: params.keyId,\n oldKekVersion: params.oldKekVersion,\n wrappedDek: params.wrappedDek,\n kekVersion: params.kekVersion,\n });\n },\n },\n record: recordSecretsKekRotation,\n classifyError: classifyKekRotationError,\n strandedError: (keyVersion) => new KekVersionStrandedError(keyVersion),\n });\n}\n"],"names":["rotateDataKeysWithTelemetry","listDataKeysPage","listDataKeyVersions","updateDataKeyWrapCas","classifyKekRotationError","recordSecretsKekRotation","KekVersionStrandedError","rotateWorkspaceDataKeysWithProvider","keyProvider","options","repository","listUnknownKeyVersions","knownVersions","workspaceIds","listPage","params","rows","afterWorkspaceId","afterKeyId","limit","map","row","keyId","workspaceId","updateWrapCas","oldKekVersion","wrappedDek","kekVersion","record","classifyError","strandedError","keyVersion"],"mappings":"AAAA,SAAQA,2BAA2B,QAAO,oCAAoC;AAC9E,SAAQC,gBAAgB,EAAEC,mBAAmB,EAAEC,oBAAoB,QAAO,eAAe;AACzF,SAAQC,wBAAwB,EAAEC,wBAAwB,QAAO,uBAAuB;AACxF,SAAQC,uBAAuB,QAAO,cAAc;AAYpD,OAAO,SAASC,oCACdC,WAAwB,EACxBC,UAA0C,CAAC,CAAC;IAE5C,OAAOT,4BAA4B;QACjCQ;QACAE,YAAY;YACVC,wBAAuBC,aAAa;gBAClC,OAAOV,oBAAoBU,eAAe;oBAACC,cAAcJ,QAAQI,YAAY;gBAAA;YAC/E;YACA,MAAMC,UAASC,MAAM;gBACnB,MAAMC,OAAO,MAAMf,iBAAiB;oBAClCgB,kBAAkBF,OAAOG,UAAU;oBACnCC,OAAOJ,OAAOI,KAAK;oBACnBN,cAAcJ,QAAQI,YAAY;gBACpC;gBACA,OAAOG,KAAKI,GAAG,CAAC,CAACC,MAAS,CAAA;wBAACC,OAAOD,IAAIE,WAAW;wBAAE,GAAGF,GAAG;oBAAA,CAAA;YAC3D;YACAG,eAAcT,MAAM;gBAClB,OAAOZ,qBAAqB;oBAC1BoB,aAAaR,OAAOO,KAAK;oBACzBG,eAAeV,OAAOU,aAAa;oBACnCC,YAAYX,OAAOW,UAAU;oBAC7BC,YAAYZ,OAAOY,UAAU;gBAC/B;YACF;QACF;QACAC,QAAQvB;QACRwB,eAAezB;QACf0B,eAAe,CAACC,aAAe,IAAIzB,wBAAwByB;IAC7D;AACF"}