diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index f358027cae..d9d529f75c 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -29,8 +29,9 @@ process.env.BAN_INTERVAL = '20'; process.env.CI = 'true'; process.env.JWT_SECRET = 'test'; process.env.JWT_REFRESH_SECRET = 'test'; -process.env.CREDS_KEY = 'test'; -process.env.CREDS_IV = 'test'; +// Startup rejects malformed explicitly configured AES credentials. Use valid test-only hex fixtures. +process.env.CREDS_KEY = '0123456789abcdef'.repeat(4); +process.env.CREDS_IV = '0123456789abcdef'.repeat(2); process.env.ALLOW_EMAIL_LOGIN = 'true'; // Set global test timeout to 30 seconds diff --git a/packages/api/src/credentials.spec.ts b/packages/api/src/credentials.spec.ts index 180d57d24b..230047b0f8 100644 --- a/packages/api/src/credentials.spec.ts +++ b/packages/api/src/credentials.spec.ts @@ -109,6 +109,57 @@ describe('credentials', () => { } }); + it.each([ + ['passphrase', 'not-a-hex-key'], + ['short AES-128 key', 'a'.repeat(32)], + ['short AES-192 key', 'a'.repeat(48)], + ['odd-length key', 'a'.repeat(63)], + ['trailing non-hex', `${'a'.repeat(64)}!`], + ['whitespace', ` ${'a'.repeat(64)}`], + ])('refuses startup for a configured %s without logging the key', (_case, value) => { + process.env.CREDS_KEY = value; + + expect(() => bootstrapCredentials()).toThrow( + '[credentials] CREDS_KEY must be exactly 64 hexadecimal characters (32 bytes).', + ); + expect(() => bootstrapCredentials()).not.toThrow(value); + expect(getCredentialRuntimeState()).toBeUndefined(); + expect(fs.existsSync(tempFile)).toBe(false); + expect(process.env.CREDS_KEY).toBe(value); + }); + + it.each([ + ['passphrase', 'not-hex'], + ['short IV', 'b'.repeat(30)], + ['overlong IV', 'b'.repeat(64)], + ['trailing non-hex', `${'b'.repeat(32)}!`], + ])( + 'refuses startup for a configured %s even when temporary credentials exist', + (_case, value) => { + fs.writeFileSync(tempFile, `CREDS_IV=${'b'.repeat(32)}\n`); + process.env.CREDS_IV = value; + + expect(() => bootstrapCredentials()).toThrow( + '[credentials] CREDS_IV must be exactly 32 hexadecimal characters (16 bytes).', + ); + expect(getCredentialRuntimeState()).toBeUndefined(); + expect(process.env.CREDS_KEY).toBeUndefined(); + expect(process.env.CREDS_IV).toBe(value); + }, + ); + + it('accepts exact-length uppercase hex credentials without replacing them', () => { + process.env.CREDS_KEY = 'A'.repeat(64); + process.env.CREDS_IV = 'B'.repeat(32); + + const state = bootstrapCredentials(); + + expect(state.sources.CREDS_KEY).toBe('environment'); + expect(state.sources.CREDS_IV).toBe('environment'); + expect(process.env.CREDS_KEY).toBe('A'.repeat(64)); + expect(process.env.CREDS_IV).toBe('B'.repeat(32)); + }); + it('preserves explicitly configured JWT secrets for backward compatibility', () => { process.env.JWT_SECRET = 'short-but-explicit'; process.env.JWT_REFRESH_SECRET = 'another-explicit-value'; diff --git a/packages/api/src/credentials.ts b/packages/api/src/credentials.ts index 2b2f0e261f..a611e0b537 100644 --- a/packages/api/src/credentials.ts +++ b/packages/api/src/credentials.ts @@ -279,6 +279,19 @@ export function bootstrapCredentials(): CredentialRuntimeState { return existingState; } + // Validate explicit credentials before generating or adopting any missing values. + for (const name of ['CREDS_KEY', 'CREDS_IV'] as const) { + const value = process.env[name]; + if (isConfiguredCredential(value) && !isUsableTemporaryCredential(name, value)) { + const bytes = name === 'CREDS_KEY' ? 32 : 16; + throw new Error( + `[credentials] ${name} must be exactly ${bytes * 2} hexadecimal characters (${bytes} bytes). ` + + 'Refusing startup to prevent weak encryption or late crypto failures. ' + + 'Restore the original credential if it protects existing data; plan a controlled migration before rotating it.', + ); + } + } + const filePath = getCredentialPath(); const file = readCredentialFile(filePath); const sources = {} as Record;