diff --git a/.env.example b/.env.example index f029c0a..6c10afa 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ RATE_LIMIT_WRITE=10 RATE_LIMIT_READ_ANON=100 RATE_LIMIT_READ_AUTH=300 +# Encryption (KEK for sensitive data at rest -- PLC DID keys, BYOK API keys) +# Generate with: openssl rand -hex 32 +AI_ENCRYPTION_KEY=your-encryption-key-minimum-32-characters-long + # OAuth (AT Protocol) OAUTH_CLIENT_ID=http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback OAUTH_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback diff --git a/src/app.ts b/src/app.ts index 7c655dd..ae6eac1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -186,7 +186,7 @@ export async function buildApp(env: Env) { // PLC DID service + Setup service const plcDidService = createPlcDidService(app.log) - const setupService = createSetupService(db, app.log, plcDidService) + const setupService = createSetupService(db, app.log, env.AI_ENCRYPTION_KEY, plcDidService) app.decorate('setupService', setupService) // Admin middleware diff --git a/src/config/env.ts b/src/config/env.ts index 918cf97..c27a1f9 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -46,6 +46,9 @@ const baseEnvSchema = z.object({ RATE_LIMIT_READ_ANON: intFromString('100'), RATE_LIMIT_READ_AUTH: intFromString('300'), + // Encryption (KEK for sensitive data at rest) + AI_ENCRYPTION_KEY: z.string().min(32), + // OAuth OAUTH_CLIENT_ID: z.string().min(1), OAUTH_REDIRECT_URI: z.string().min(1), diff --git a/src/lib/encryption.ts b/src/lib/encryption.ts new file mode 100644 index 0000000..5e03cfc --- /dev/null +++ b/src/lib/encryption.ts @@ -0,0 +1,72 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' + +/** + * HKDF info string for community key encryption. + * Binds derived keys to this specific use case. + */ +const HKDF_INFO = 'barazo:community-keys' + +/** + * Derive a 256-bit AES key from the KEK using HKDF (SHA-256). + */ +function deriveKey(kek: string): Buffer { + return Buffer.from(hkdfSync('sha256', kek, '', HKDF_INFO, 32)) +} + +/** + * Encrypt plaintext using AES-256-GCM. + * + * @param plaintext - The string to encrypt + * @param kek - Key Encryption Key (minimum 32 characters, from AI_ENCRYPTION_KEY env var) + * @returns Base64-encoded string in format `iv:ciphertext:tag` + */ +export function encrypt(plaintext: string, kek: string): string { + const key = deriveKey(kek) + const iv = randomBytes(12) + + const cipher = createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + + return `${iv.toString('base64')}:${encrypted.toString('base64')}:${tag.toString('base64')}` +} + +/** + * Decrypt a string encrypted with {@link encrypt}. + * + * @param encrypted - Base64-encoded string in format `iv:ciphertext:tag` + * @param kek - The same KEK used during encryption + * @returns The original plaintext + * @throws If the data is corrupted, tampered with, or the wrong key is used + */ +export function decrypt(encrypted: string, kek: string): string { + const parts = encrypted.split(':') + if (parts.length !== 3) { + throw new Error('Invalid encrypted data format: expected iv:ciphertext:tag') + } + + const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string] + + if (!ivB64 || !tagB64) { + throw new Error('Invalid encrypted data format: empty component') + } + + const iv = Buffer.from(ivB64, 'base64') + const ciphertext = Buffer.from(ciphertextB64, 'base64') + const tag = Buffer.from(tagB64, 'base64') + + if (iv.length !== 12) { + throw new Error('Invalid IV length: expected 12 bytes') + } + + if (tag.length !== 16) { + throw new Error('Invalid auth tag length: expected 16 bytes') + } + + const key = deriveKey(kek) + + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8') +} diff --git a/src/setup/service.ts b/src/setup/service.ts index cac5fe9..c9fdd7c 100644 --- a/src/setup/service.ts +++ b/src/setup/service.ts @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm' import { communitySettings } from '../db/schema/community-settings.js' import type { Database } from '../db/index.js' +import { encrypt } from '../lib/encryption.js' import type { Logger } from '../lib/logger.js' import type { PlcDidService } from '../services/plc-did.js' @@ -58,12 +59,14 @@ const DEFAULT_COMMUNITY_NAME = 'Barazo Community' * * @param db - Drizzle database instance * @param logger - Pino logger instance + * @param encryptionKey - KEK for encrypting sensitive data (AI_ENCRYPTION_KEY) * @param plcDidService - Optional PLC DID service for DID generation * @returns SetupService with getStatus and initialize methods */ export function createSetupService( db: Database, logger: Logger, + encryptionKey: string, plcDidService?: PlcDidService ): SetupService { /** @@ -126,8 +129,8 @@ export function createSetupService( }) communityDid = didResult.did - signingKeyHex = didResult.signingKey - rotationKeyHex = didResult.rotationKey + signingKeyHex = encrypt(didResult.signingKey, encryptionKey) + rotationKeyHex = encrypt(didResult.rotationKey, encryptionKey) logger.info({ communityDid, handle }, 'PLC DID generated successfully') } else if (handle && serviceEndpoint && !plcDidService) { diff --git a/tests/unit/config/env.test.ts b/tests/unit/config/env.test.ts index 78d3a64..050af93 100644 --- a/tests/unit/config/env.test.ts +++ b/tests/unit/config/env.test.ts @@ -12,6 +12,7 @@ describe('envSchema', () => { 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback', OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', SESSION_SECRET: 'a-very-long-session-secret-that-is-at-least-32-characters', + AI_ENCRYPTION_KEY: 'a-very-long-encryption-key-that-is-at-least-32-characters', HOST: '0.0.0.0', PORT: '3000', LOG_LEVEL: 'info', @@ -97,6 +98,7 @@ describe('envSchema', () => { OAUTH_CLIENT_ID: validEnv.OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI: validEnv.OAUTH_REDIRECT_URI, SESSION_SECRET: validEnv.SESSION_SECRET, + AI_ENCRYPTION_KEY: validEnv.AI_ENCRYPTION_KEY, COMMUNITY_DID: validEnv.COMMUNITY_DID, }) expect(result.success).toBe(true) @@ -218,6 +220,7 @@ describe('COMMUNITY_DID validation', () => { 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback', OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', SESSION_SECRET: 'a-very-long-session-secret-that-is-at-least-32-characters', + AI_ENCRYPTION_KEY: 'a-very-long-encryption-key-that-is-at-least-32-characters', } it('rejects single mode without COMMUNITY_DID', () => { @@ -263,6 +266,51 @@ describe('getCommunityDid', () => { }) }) +describe('AI_ENCRYPTION_KEY validation', () => { + const baseEnv = { + DATABASE_URL: 'postgresql://barazo:barazo_dev@localhost:5432/barazo', + VALKEY_URL: 'redis://localhost:6379', + TAP_URL: 'http://localhost:2480', + TAP_ADMIN_PASSWORD: 'tap_dev_secret', + OAUTH_CLIENT_ID: + 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fcallback', + OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', + SESSION_SECRET: 'a-very-long-session-secret-that-is-at-least-32-characters', + COMMUNITY_DID: 'did:plc:testcommunity123', + AI_ENCRYPTION_KEY: 'a'.repeat(32), + } + + it('rejects missing AI_ENCRYPTION_KEY', () => { + const { AI_ENCRYPTION_KEY: _, ...env } = baseEnv + const result = envSchema.safeParse(env) + expect(result.success).toBe(false) + }) + + it('rejects AI_ENCRYPTION_KEY shorter than 32 characters', () => { + const result = envSchema.safeParse({ + ...baseEnv, + AI_ENCRYPTION_KEY: 'too-short', + }) + expect(result.success).toBe(false) + }) + + it('accepts AI_ENCRYPTION_KEY of exactly 32 characters', () => { + const result = envSchema.safeParse({ + ...baseEnv, + AI_ENCRYPTION_KEY: 'a'.repeat(32), + }) + expect(result.success).toBe(true) + }) + + it('accepts AI_ENCRYPTION_KEY longer than 32 characters', () => { + const result = envSchema.safeParse({ + ...baseEnv, + AI_ENCRYPTION_KEY: 'a'.repeat(64), + }) + expect(result.success).toBe(true) + }) +}) + describe('parseEnv', () => { it('throws on invalid environment', () => { expect(() => parseEnv({})).toThrow() diff --git a/tests/unit/lib/encryption.test.ts b/tests/unit/lib/encryption.test.ts new file mode 100644 index 0000000..c4ffe11 --- /dev/null +++ b/tests/unit/lib/encryption.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { encrypt, decrypt } from '../../../src/lib/encryption.js' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const TEST_KEK = 'a'.repeat(32) // Minimum 32 characters +const TEST_PLAINTEXT = 'deadbeef'.repeat(8) // 64-char hex string (like a signing key) + +/** Split encrypted string into [iv, ciphertext, tag] with type safety. */ +function splitEncrypted(encrypted: string): [string, string, string] { + const [iv, ciphertext, tag] = encrypted.split(':') + if (iv === undefined || ciphertext === undefined || tag === undefined) { + throw new Error('Expected 3 colon-separated parts') + } + return [iv, ciphertext, tag] +} + +// --------------------------------------------------------------------------- +// encrypt / decrypt roundtrip +// --------------------------------------------------------------------------- + +describe('encrypt', () => { + it('returns a base64-encoded string with three colon-separated parts (iv:ciphertext:tag)', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + + const parts = encrypted.split(':') + expect(parts).toHaveLength(3) + + // Each part should be valid base64 + for (const part of parts) { + expect(() => Buffer.from(part, 'base64')).not.toThrow() + expect(part.length).toBeGreaterThan(0) + } + }) + + it('produces different ciphertext on each call (unique IV)', () => { + const encrypted1 = encrypt(TEST_PLAINTEXT, TEST_KEK) + const encrypted2 = encrypt(TEST_PLAINTEXT, TEST_KEK) + + expect(encrypted1).not.toBe(encrypted2) + + // IVs should differ + const [iv1] = splitEncrypted(encrypted1) + const [iv2] = splitEncrypted(encrypted2) + expect(iv1).not.toBe(iv2) + }) + + it('uses a 12-byte IV', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const [ivBase64] = splitEncrypted(encrypted) + const ivBytes = Buffer.from(ivBase64, 'base64') + expect(ivBytes.length).toBe(12) + }) + + it('produces a 16-byte auth tag', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const [, , tagBase64] = splitEncrypted(encrypted) + const tagBytes = Buffer.from(tagBase64, 'base64') + expect(tagBytes.length).toBe(16) + }) +}) + +describe('decrypt', () => { + it('recovers the original plaintext', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const decrypted = decrypt(encrypted, TEST_KEK) + + expect(decrypted).toBe(TEST_PLAINTEXT) + }) + + it('handles empty string plaintext', () => { + const encrypted = encrypt('', TEST_KEK) + const decrypted = decrypt(encrypted, TEST_KEK) + + expect(decrypted).toBe('') + }) + + it('handles unicode plaintext', () => { + const unicode = 'hello world \u{1F600} \u00E9\u00E8\u00EA' + const encrypted = encrypt(unicode, TEST_KEK) + const decrypted = decrypt(encrypted, TEST_KEK) + + expect(decrypted).toBe(unicode) + }) +}) + +// --------------------------------------------------------------------------- +// Wrong key +// --------------------------------------------------------------------------- + +describe('decrypt with wrong key', () => { + it('throws when decrypting with a different KEK', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const wrongKek = 'b'.repeat(32) + + expect(() => decrypt(encrypted, wrongKek)).toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// Corrupted data +// --------------------------------------------------------------------------- + +describe('decrypt with corrupted data', () => { + it('throws when ciphertext is corrupted', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const [iv, ciphertextB64, tag] = splitEncrypted(encrypted) + + // Corrupt the ciphertext by flipping bits + const ciphertextBytes = Buffer.from(ciphertextB64, 'base64') + ciphertextBytes[0] = (ciphertextBytes[0] ?? 0) ^ 0xff + const corrupted = `${iv}:${ciphertextBytes.toString('base64')}:${tag}` + + expect(() => decrypt(corrupted, TEST_KEK)).toThrow() + }) + + it('throws when auth tag is corrupted', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const [iv, ciphertext, tagB64] = splitEncrypted(encrypted) + + // Corrupt the auth tag + const tagBytes = Buffer.from(tagB64, 'base64') + tagBytes[0] = (tagBytes[0] ?? 0) ^ 0xff + const corrupted = `${iv}:${ciphertext}:${tagBytes.toString('base64')}` + + expect(() => decrypt(corrupted, TEST_KEK)).toThrow() + }) + + it('throws when IV is corrupted', () => { + const encrypted = encrypt(TEST_PLAINTEXT, TEST_KEK) + const [ivB64, ciphertext, tag] = splitEncrypted(encrypted) + + // Corrupt the IV + const ivBytes = Buffer.from(ivB64, 'base64') + ivBytes[0] = (ivBytes[0] ?? 0) ^ 0xff + const corrupted = `${ivBytes.toString('base64')}:${ciphertext}:${tag}` + + expect(() => decrypt(corrupted, TEST_KEK)).toThrow() + }) + + it('throws when encrypted string has wrong format (missing parts)', () => { + expect(() => decrypt('onlyonepart', TEST_KEK)).toThrow() + expect(() => decrypt('two:parts', TEST_KEK)).toThrow() + }) + + it('throws when encrypted string has empty parts', () => { + expect(() => decrypt('::', TEST_KEK)).toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// HKDF key derivation +// --------------------------------------------------------------------------- + +describe('key derivation', () => { + it('derives different encryption keys from different KEKs', () => { + const kek1 = 'a'.repeat(32) + const kek2 = 'b'.repeat(32) + + const encrypted1 = encrypt(TEST_PLAINTEXT, kek1) + const encrypted2 = encrypt(TEST_PLAINTEXT, kek2) + + // Can decrypt with matching key + expect(decrypt(encrypted1, kek1)).toBe(TEST_PLAINTEXT) + expect(decrypt(encrypted2, kek2)).toBe(TEST_PLAINTEXT) + + // Cannot cross-decrypt + expect(() => decrypt(encrypted1, kek2)).toThrow() + expect(() => decrypt(encrypted2, kek1)).toThrow() + }) +}) diff --git a/tests/unit/setup/service.test.ts b/tests/unit/setup/service.test.ts index 4f9d7c3..d75c94d 100644 --- a/tests/unit/setup/service.test.ts +++ b/tests/unit/setup/service.test.ts @@ -3,6 +3,7 @@ import { createSetupService } from '../../../src/setup/service.js' import type { SetupService } from '../../../src/setup/service.js' import type { PlcDidService, GenerateDidResult } from '../../../src/services/plc-did.js' import type { Logger } from '../../../src/lib/logger.js' +import { decrypt } from '../../../src/lib/encryption.js' // --------------------------------------------------------------------------- // Mock helpers @@ -79,6 +80,7 @@ const TEST_SERVICE_ENDPOINT = 'https://community.barazo.forum' const TEST_COMMUNITY_DID = 'did:plc:communityabc123456' const TEST_SIGNING_KEY = 'a'.repeat(64) const TEST_ROTATION_KEY = 'b'.repeat(64) +const TEST_ENCRYPTION_KEY = 'c'.repeat(32) // --------------------------------------------------------------------------- // Test suite @@ -95,7 +97,7 @@ describe('SetupService', () => { mocks = m mockLogger = createMockLogger() mockPlcDidService = createMockPlcDidService() - service = createSetupService(db as never, mockLogger, mockPlcDidService) + service = createSetupService(db as never, mockLogger, TEST_ENCRYPTION_KEY, mockPlcDidService) }) // ========================================================================= @@ -321,6 +323,38 @@ describe('SetupService', () => { expect(result).not.toHaveProperty('communityDid') }) + it('encrypts signing and rotation keys before storing in DB', async () => { + mockPlcDidService.generateDid.mockResolvedValueOnce({ + did: TEST_COMMUNITY_DID, + signingKey: TEST_SIGNING_KEY, + rotationKey: TEST_ROTATION_KEY, + }) + mocks.returningFn.mockResolvedValueOnce([ + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, + ]) + + await service.initialize({ + did: TEST_DID, + handle: TEST_HANDLE, + serviceEndpoint: TEST_SERVICE_ENDPOINT, + }) + + // Extract the values passed to the DB insert + const callArgs = mocks.valuesFn.mock.calls[0] + expect(callArgs).toBeDefined() + const insertValues = (callArgs as unknown[][])[0] as Record + + // Keys should NOT be plaintext + expect(insertValues.signingKey).not.toBe(TEST_SIGNING_KEY) + expect(insertValues.rotationKey).not.toBe(TEST_ROTATION_KEY) + + // Keys should be decryptable back to the originals + expect(decrypt(insertValues.signingKey as string, TEST_ENCRYPTION_KEY)).toBe(TEST_SIGNING_KEY) + expect(decrypt(insertValues.rotationKey as string, TEST_ENCRYPTION_KEY)).toBe( + TEST_ROTATION_KEY + ) + }) + it('propagates PLC DID generation errors', async () => { mockPlcDidService.generateDid.mockRejectedValueOnce( new Error('PLC directory returned 500: Internal Server Error') @@ -371,7 +405,7 @@ describe('SetupService', () => { // Create service without PlcDidService const { db, mocks: m } = createMockDb() const logger = createMockLogger() - const serviceWithoutPlc = createSetupService(db as never, logger) + const serviceWithoutPlc = createSetupService(db as never, logger, TEST_ENCRYPTION_KEY) m.returningFn.mockResolvedValueOnce([ { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null },