@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-secrets",
3
3
  "license": "MIT",
4
- "version": "14.0.0",
4
+ "version": "17.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,17 +24,18 @@
24
24
  "dependencies": {
25
25
  "drizzle-orm": "^0.45.2",
26
26
  "zod": "^4.4.3",
27
- "@shipfox/api-auth-context": "12.2.0",
28
- "@shipfox/api-projects-dto": "14.0.0",
27
+ "@shipfox/api-auth-context": "17.0.0",
28
+ "@shipfox/api-projects-dto": "15.0.0",
29
29
  "@shipfox/api-secrets-dto": "12.0.0",
30
30
  "@shipfox/config": "1.2.4",
31
31
  "@shipfox/inter-module": "0.2.3",
32
32
  "@shipfox/node-drizzle": "0.3.5",
33
- "@shipfox/node-fastify": "0.4.2",
34
- "@shipfox/node-module": "1.0.6",
35
- "@shipfox/node-opentelemetry": "0.6.4",
33
+ "@shipfox/node-envelope-encryption": "0.2.0",
34
+ "@shipfox/node-fastify": "0.4.3",
35
+ "@shipfox/node-module": "1.0.7",
36
+ "@shipfox/node-opentelemetry": "0.6.5",
36
37
  "@shipfox/node-outbox": "0.2.6",
37
- "@shipfox/node-postgres": "0.5.0"
38
+ "@shipfox/node-postgres": "0.5.1"
38
39
  },
39
40
  "imports": {
40
41
  "#*": "./dist/*"
package/src/config.ts CHANGED
@@ -6,7 +6,7 @@ export const MAX_VALUE_BYTES = 64 * 1024;
6
6
 
7
7
  export const config = createConfig({
8
8
  SECRETS_ENCRYPTION_KEK: str({
9
- 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.',
9
+ 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.',
10
10
  }),
11
11
  SECRETS_ENCRYPTION_KEK_PREVIOUS: str({
12
12
  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,14 +1,11 @@
1
- import crypto from 'node:crypto';
1
+ import {
2
+ decodeBase64Key as decodeEnvelopeKey,
3
+ KeyConfigurationError,
4
+ openEnvelopeText,
5
+ sealEnvelopeText,
6
+ } from '@shipfox/node-envelope-encryption';
2
7
  import {KekConfigurationError, SecretDecryptionError} from './errors.js';
3
8
 
4
- const CIPHER = 'aes-256-gcm';
5
- const ENCODED_PREFIX = 'v1:';
6
- const IV_BYTES = 12;
7
- const AUTH_TAG_BYTES = 16;
8
- const KEY_BYTES = 32;
9
- const BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
10
- const BASE64_PADDING_SUFFIX = /=+$/;
11
-
12
9
  export interface AesGcmSealParams {
13
10
  key: Buffer;
14
11
  plaintext: Buffer;
@@ -33,61 +30,24 @@ export interface SecretValueAadParams {
33
30
  }
34
31
 
35
32
  export function aesGcmSeal(params: AesGcmSealParams): string {
36
- const iv = crypto.randomBytes(IV_BYTES);
37
- const cipher = crypto.createCipheriv(CIPHER, params.key, iv);
38
- cipher.setAAD(Buffer.from(params.aad, 'utf8'));
39
-
40
- const ciphertext = Buffer.concat([cipher.update(params.plaintext), cipher.final()]);
41
- const authTag = cipher.getAuthTag();
42
-
43
- return `${ENCODED_PREFIX}${Buffer.concat([iv, authTag, ciphertext]).toString('base64')}`;
33
+ return sealEnvelopeText(params);
44
34
  }
45
35
 
46
36
  export function aesGcmOpen(params: AesGcmOpenParams): Buffer {
47
- if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();
48
-
49
- const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);
50
- const payload = Buffer.from(encodedPayload, 'base64');
51
- const canonical = payload.toString('base64');
52
- if (
53
- !BASE64_KEY_PATTERN.test(encodedPayload) ||
54
- canonical.replace(BASE64_PADDING_SUFFIX, '') !==
55
- encodedPayload.replace(BASE64_PADDING_SUFFIX, '')
56
- ) {
57
- throw new SecretDecryptionError();
58
- }
59
- if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();
60
-
61
37
  try {
62
- const iv = payload.subarray(0, IV_BYTES);
63
- const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
64
- const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);
65
- const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);
66
- decipher.setAAD(Buffer.from(params.aad, 'utf8'));
67
- decipher.setAuthTag(authTag);
68
-
69
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
70
- } catch (error) {
71
- if (error instanceof SecretDecryptionError) throw error;
38
+ return openEnvelopeText(params);
39
+ } catch {
72
40
  throw new SecretDecryptionError();
73
41
  }
74
42
  }
75
43
 
76
44
  export function decodeBase64Key(encoded: string | undefined, label: string): Buffer {
77
- if (!encoded) {
78
- throw new KekConfigurationError(
79
- `${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`,
80
- );
81
- }
82
-
83
- const key = Buffer.from(encoded, 'base64');
84
- if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {
85
- throw new KekConfigurationError(
86
- `${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`,
87
- );
45
+ try {
46
+ return decodeEnvelopeKey(encoded, label);
47
+ } catch (error) {
48
+ if (error instanceof KeyConfigurationError) throw new KekConfigurationError(error.message);
49
+ throw error;
88
50
  }
89
-
90
- return key;
91
51
  }
92
52
 
93
53
  export function aadForDek(workspaceId: string, kekVersion: string): string {
@@ -99,7 +59,3 @@ export function aadForValue(params: SecretValueAadParams): string {
99
59
  const scopeTuple = projectId !== null ? ['project', projectId] : ['workspace'];
100
60
  return JSON.stringify([params.workspaceId, scopeTuple, params.namespace, params.key]);
101
61
  }
102
-
103
- function isCanonicalBase64Key(encoded: string, key: Buffer): boolean {
104
- return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;
105
- }
@@ -1,76 +1,37 @@
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
4
  import type {KeyProvider} from './key-provider.js';
5
5
 
6
- const DEK_BYTES = 32;
7
-
8
- /**
9
- * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
10
- * every access. Node cannot reliably zeroize Buffers, so residency is bounded by
11
- * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
12
- */
13
6
  export class DekManager {
14
- readonly #cache = new Map<string, {dek: Buffer; expiresAt: number}>();
15
- readonly #keyProvider: KeyProvider;
16
- readonly #options: {maxEntries: number; ttlMs: number};
7
+ readonly #manager: DataKeyManager;
17
8
 
18
9
  constructor(keyProvider: KeyProvider, options: {maxEntries: number; ttlMs: number}) {
19
- this.#keyProvider = keyProvider;
20
- this.#options = options;
10
+ this.#manager = new DataKeyManager(
11
+ keyProvider,
12
+ {
13
+ async get(workspaceId) {
14
+ const record = await getDataKey(workspaceId);
15
+ return record ? {keyId: workspaceId, ...record} : undefined;
16
+ },
17
+ insertIfAbsent(record) {
18
+ return insertDataKeyIfAbsent({
19
+ workspaceId: record.keyId,
20
+ wrappedDek: record.wrappedDek,
21
+ kekVersion: record.kekVersion,
22
+ });
23
+ },
24
+ },
25
+ options,
26
+ );
21
27
  }
22
28
 
23
29
  async getPlaintextDek(workspaceId: string): Promise<Buffer> {
24
30
  const startedAt = Date.now();
25
31
  try {
26
- const cached = this.#cache.get(workspaceId);
27
- if (cached && cached.expiresAt > Date.now()) {
28
- this.#cache.delete(workspaceId);
29
- this.#cache.set(workspaceId, cached);
30
- recordSecretsDekAccess({outcome: 'cache_hit', durationMs: Date.now() - startedAt});
31
- return Buffer.from(cached.dek);
32
- }
33
- const hadExpiredCache = Boolean(cached);
34
- if (cached) this.#cache.delete(workspaceId);
35
-
36
- const existing = await getDataKey(workspaceId);
37
- if (existing) {
38
- const dek = this.#keyProvider.unwrapDek(
39
- workspaceId,
40
- existing.wrappedDek,
41
- existing.kekVersion,
42
- );
43
- this.#set(workspaceId, dek);
44
- recordSecretsDekAccess({
45
- outcome: hadExpiredCache ? 'cache_expired' : 'db_unwrapped',
46
- durationMs: Date.now() - startedAt,
47
- });
48
- return Buffer.from(dek);
49
- }
50
-
51
- const generatedDek = crypto.randomBytes(DEK_BYTES);
52
- const wrapped = this.#keyProvider.wrapDek(workspaceId, generatedDek);
53
- const inserted = await insertDataKeyIfAbsent({
54
- workspaceId,
55
- wrappedDek: wrapped.wrappedDek,
56
- kekVersion: wrapped.kekVersion,
57
- });
58
-
59
- // The DEK row commits before value writes. If concurrent first-use inserts race,
60
- // the primary key decides the winner and every caller re-reads the persisted row.
61
- const persisted = await getDataKey(workspaceId);
62
- if (!persisted) throw new Error(`Data key was not persisted for workspace ${workspaceId}`);
63
- const dek = this.#keyProvider.unwrapDek(
64
- workspaceId,
65
- persisted.wrappedDek,
66
- persisted.kekVersion,
67
- );
68
- this.#set(workspaceId, dek);
69
- recordSecretsDekAccess({
70
- outcome: inserted ? 'generated' : 'db_unwrapped',
71
- durationMs: Date.now() - startedAt,
72
- });
73
- return Buffer.from(dek);
32
+ const result = await this.#manager.getPlaintextDataKey(workspaceId);
33
+ recordSecretsDekAccess({outcome: result.outcome, durationMs: Date.now() - startedAt});
34
+ return result.dek;
74
35
  } catch (error) {
75
36
  recordSecretsDekAccess({
76
37
  outcome: classifyDekAccessError(error),
@@ -81,18 +42,6 @@ export class DekManager {
81
42
  }
82
43
 
83
44
  invalidate(workspaceId: string): void {
84
- this.#cache.delete(workspaceId);
85
- }
86
-
87
- #set(workspaceId: string, dek: Buffer): void {
88
- this.#cache.set(workspaceId, {
89
- dek: Buffer.from(dek),
90
- expiresAt: Date.now() + this.#options.ttlMs,
91
- });
92
- while (this.#cache.size > this.#options.maxEntries) {
93
- const oldest = this.#cache.keys().next().value as string | undefined;
94
- if (!oldest) break;
95
- this.#cache.delete(oldest);
96
- }
45
+ this.#manager.invalidate(workspaceId);
97
46
  }
98
47
  }
@@ -1,5 +1,9 @@
1
- import crypto from 'node:crypto';
2
- import {aadForDek, aesGcmOpen, aesGcmSeal} from './crypto.js';
1
+ import {
2
+ createLocalKeyProvider as createEnvelopeKeyProvider,
3
+ DataKeyUnwrapError,
4
+ DataKeyWrapError,
5
+ deriveLocalKeyVersion,
6
+ } from '@shipfox/node-envelope-encryption';
3
7
  import {DekUnwrapError, DekWrapError} from './errors.js';
4
8
 
5
9
  const KEK_VERSION_DOMAIN = 'shipfox-secrets-kek-version';
@@ -22,49 +26,29 @@ export interface LocalKeyProviderParams {
22
26
  }
23
27
 
24
28
  export function createLocalKeyProvider(params: LocalKeyProviderParams): KeyProvider {
25
- const currentKeyVersion = deriveLocalKekVersion(params.currentKek);
26
- const previousKeyVersion = params.previousKek ? deriveLocalKekVersion(params.previousKek) : null;
27
-
29
+ const provider = createEnvelopeKeyProvider({...params, keyVersionDomain: KEK_VERSION_DOMAIN});
28
30
  return {
29
- currentKeyVersion,
30
- previousKeyVersion,
31
+ currentKeyVersion: provider.currentKeyVersion,
32
+ previousKeyVersion: provider.previousKeyVersion,
31
33
  wrapDek(workspaceId, plaintextDek) {
32
34
  try {
33
- return {
34
- wrappedDek: aesGcmSeal({
35
- key: params.currentKek,
36
- plaintext: plaintextDek,
37
- aad: aadForDek(workspaceId, currentKeyVersion),
38
- }),
39
- kekVersion: currentKeyVersion,
40
- };
41
- } catch {
42
- throw new DekWrapError();
35
+ return provider.wrapDek(workspaceId, plaintextDek);
36
+ } catch (error) {
37
+ if (error instanceof DataKeyWrapError) throw new DekWrapError();
38
+ throw error;
43
39
  }
44
40
  },
45
41
  unwrapDek(workspaceId, wrappedDek, kekVersion) {
46
- const key =
47
- kekVersion === currentKeyVersion
48
- ? params.currentKek
49
- : kekVersion === previousKeyVersion
50
- ? params.previousKek
51
- : undefined;
52
- if (!key) throw new DekUnwrapError();
53
-
54
42
  try {
55
- return aesGcmOpen({
56
- key,
57
- encoded: wrappedDek,
58
- aad: aadForDek(workspaceId, kekVersion),
59
- });
60
- } catch {
61
- throw new DekUnwrapError();
43
+ return provider.unwrapDek(workspaceId, wrappedDek, kekVersion);
44
+ } catch (error) {
45
+ if (error instanceof DataKeyUnwrapError) throw new DekUnwrapError();
46
+ throw error;
62
47
  }
63
48
  },
64
49
  };
65
50
  }
66
51
 
67
52
  export function deriveLocalKekVersion(kek: Buffer): string {
68
- const hash = crypto.createHash('sha256').update(KEK_VERSION_DOMAIN).update(kek).digest('hex');
69
- return `local:${hash.slice(0, 16)}`;
53
+ return deriveLocalKeyVersion(kek, KEK_VERSION_DOMAIN);
70
54
  }
@@ -1,10 +1,9 @@
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
5
  import type {KeyProvider} from './key-provider.js';
5
6
 
6
- const PAGE_SIZE = 100;
7
-
8
7
  export interface RotateWorkspaceDataKeysResult {
9
8
  rotated: number;
10
9
  skipped: number;
@@ -14,87 +13,35 @@ export interface RotateWorkspaceDataKeysOptions {
14
13
  workspaceIds?: string[] | undefined;
15
14
  }
16
15
 
17
- export async function rotateWorkspaceDataKeysWithProvider(
16
+ export function rotateWorkspaceDataKeysWithProvider(
18
17
  keyProvider: KeyProvider,
19
18
  options: RotateWorkspaceDataKeysOptions = {},
20
19
  ): Promise<RotateWorkspaceDataKeysResult> {
21
- const startedAt = Date.now();
22
- try {
23
- const knownVersions = [keyProvider.currentKeyVersion, keyProvider.previousKeyVersion].filter(
24
- (version): version is string => Boolean(version),
25
- );
26
- const unknownVersions = await listDataKeyVersions(knownVersions, {
27
- workspaceIds: options.workspaceIds,
28
- });
29
- if (unknownVersions.length > 0) throw new KekVersionStrandedError(unknownVersions[0] as string);
30
-
31
- let rotated = 0;
32
- let skipped = 0;
33
- let skippedCurrent = 0;
34
- let skippedRace = 0;
35
- let afterWorkspaceId: string | undefined;
36
-
37
- while (true) {
38
- const page = await listDataKeysPage({
39
- afterWorkspaceId,
40
- limit: PAGE_SIZE,
41
- workspaceIds: options.workspaceIds,
42
- });
43
- if (page.length === 0) break;
44
-
45
- for (const row of page) {
46
- afterWorkspaceId = row.workspaceId;
47
- if (row.kekVersion === keyProvider.currentKeyVersion) {
48
- skipped += 1;
49
- skippedCurrent += 1;
50
- continue;
51
- }
52
-
53
- const plaintextDek = keyProvider.unwrapDek(row.workspaceId, row.wrappedDek, row.kekVersion);
54
- try {
55
- const wrapped = keyProvider.wrapDek(row.workspaceId, plaintextDek);
56
- const updated = await updateDataKeyWrapCas({
57
- workspaceId: row.workspaceId,
58
- oldKekVersion: row.kekVersion,
59
- wrappedDek: wrapped.wrappedDek,
60
- kekVersion: wrapped.kekVersion,
61
- });
62
- if (updated) rotated += 1;
63
- else {
64
- skipped += 1;
65
- skippedRace += 1;
66
- }
67
- } finally {
68
- plaintextDek.fill(0);
69
- }
70
- }
71
- }
72
-
73
- recordSecretsKekRotation({outcome: 'rotated', count: rotated});
74
- recordSecretsKekRotation({outcome: 'skipped_current', count: skippedCurrent});
75
- recordSecretsKekRotation({outcome: 'skipped_race', count: skippedRace});
76
- recordSecretsKekRotation({
77
- outcome: rotationDurationOutcome({rotated, skippedCurrent, skippedRace}),
78
- count: 0,
79
- durationMs: Date.now() - startedAt,
80
- });
81
- return {rotated, skipped};
82
- } catch (error) {
83
- recordSecretsKekRotation({
84
- outcome: classifyKekRotationError(error),
85
- durationMs: Date.now() - startedAt,
86
- });
87
- throw error;
88
- }
89
- }
90
-
91
- function rotationDurationOutcome(params: {
92
- rotated: number;
93
- skippedCurrent: number;
94
- skippedRace: number;
95
- }) {
96
- if (params.rotated > 0) return 'rotated';
97
- if (params.skippedRace > 0) return 'skipped_race';
98
- if (params.skippedCurrent === 0) return 'none';
99
- return 'skipped_current';
20
+ return rotateDataKeysWithTelemetry({
21
+ keyProvider,
22
+ repository: {
23
+ listUnknownKeyVersions(knownVersions) {
24
+ return listDataKeyVersions(knownVersions, {workspaceIds: options.workspaceIds});
25
+ },
26
+ async listPage(params) {
27
+ const rows = await listDataKeysPage({
28
+ afterWorkspaceId: params.afterKeyId,
29
+ limit: params.limit,
30
+ workspaceIds: options.workspaceIds,
31
+ });
32
+ return rows.map((row) => ({keyId: row.workspaceId, ...row}));
33
+ },
34
+ updateWrapCas(params) {
35
+ return updateDataKeyWrapCas({
36
+ workspaceId: params.keyId,
37
+ oldKekVersion: params.oldKekVersion,
38
+ wrappedDek: params.wrappedDek,
39
+ kekVersion: params.kekVersion,
40
+ });
41
+ },
42
+ },
43
+ record: recordSecretsKekRotation,
44
+ classifyError: classifyKekRotationError,
45
+ strandedError: (keyVersion) => new KekVersionStrandedError(keyVersion),
46
+ });
100
47
  }