diff --git a/backend/package-lock.json b/backend/package-lock.json index 39ded77..1cbbb37 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", "@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth", "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", + "@bytelyst/field-encrypt": "file:../../learning_ai_common_plat/packages/field-encrypt", "fastify": "^5.2.1", "jose": "^6.0.8", "zod": "^3.24.2" @@ -158,6 +159,30 @@ } } }, + "../../learning_ai_common_plat/packages/field-encrypt": { + "name": "@bytelyst/field-encrypt", + "version": "0.1.0", + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.0.0", + "zod": "^3.24.0" + }, + "peerDependencies": { + "@azure/identity": ">=4.0.0", + "@azure/keyvault-keys": ">=4.8.0", + "zod": ">=3.22.0" + }, + "peerDependenciesMeta": { + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-keys": { + "optional": true + } + } + }, "node_modules/@azure-rest/core-client": { "version": "2.5.1", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure-rest/core-client/-/core-client-2.5.1.tgz", @@ -425,6 +450,10 @@ "resolved": "../../learning_ai_common_plat/packages/fastify-core", "link": true }, + "node_modules/@bytelyst/field-encrypt": { + "resolved": "../../learning_ai_common_plat/packages/field-encrypt", + "link": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", diff --git a/backend/package.json b/backend/package.json index b9bc530..b6a2058 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "@bytelyst/backend-flags": "file:../../learning_ai_common_plat/packages/backend-flags", "@bytelyst/backend-telemetry": "file:../../learning_ai_common_plat/packages/backend-telemetry", "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", + "@bytelyst/field-encrypt": "file:../../learning_ai_common_plat/packages/field-encrypt", "@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth", "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", "@azure/cosmos": "^4.2.0", diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index 50cfb8f..c075611 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -9,6 +9,10 @@ const envSchema = baseBackendConfigSchema.extend({ PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), TELEMETRY_ENABLED: z.coerce.boolean().default(false), FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false), + FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['memory', 'env', 'akv']).default('memory'), + FIELD_ENCRYPT_KEY: z.string().optional(), + FIELD_ENCRYPT_MEK_NAME: z.string().default('chronomind-mek'), + AZURE_KEYVAULT_URL: z.string().optional(), }); export const config = envSchema.parse(process.env); diff --git a/backend/src/lib/field-encrypt.ts b/backend/src/lib/field-encrypt.ts new file mode 100644 index 0000000..da2d2d4 --- /dev/null +++ b/backend/src/lib/field-encrypt.ts @@ -0,0 +1,31 @@ +/** + * Field-level encryption singleton for ChronoMind backend. + * + * Encrypts sensitive fields (e.g., timer/routine descriptions, webhook secrets) + * at rest using AES-256-GCM envelope encryption via @bytelyst/field-encrypt. + */ + +import { createFieldEncryptor, type FieldEncryptor } from '@bytelyst/field-encrypt'; +import { config } from './config.js'; + +let _encryptor: FieldEncryptor | null = null; + +export function getEncryptor(): FieldEncryptor { + if (_encryptor) return _encryptor; + + _encryptor = createFieldEncryptor({ + keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER, + encryptionKey: config.FIELD_ENCRYPT_KEY || undefined, + keyVaultUrl: config.AZURE_KEYVAULT_URL || undefined, + mekName: config.FIELD_ENCRYPT_MEK_NAME || undefined, + }); + + return _encryptor; +} + +/** @internal — for testing only. */ +export function _resetEncryptor(): void { + _encryptor = null; +} + +export { isEncryptedField } from '@bytelyst/field-encrypt'; diff --git a/backend/src/modules/routines/repository.ts b/backend/src/modules/routines/repository.ts index d2c89e5..120017e 100644 --- a/backend/src/modules/routines/repository.ts +++ b/backend/src/modules/routines/repository.ts @@ -5,6 +5,7 @@ */ import { getCollection } from '../../lib/datastore.js'; +import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js'; import type { RoutineDoc, RoutineQuery, BatchUpsertRoutinesResult } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; @@ -12,6 +13,60 @@ function collection() { return getCollection('routines', '/userId'); } +// ── Field encryption helpers ───────────────────────────────── + +async function encryptRoutineFields(doc: RoutineDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: 'routines' }; + let result = doc; + + if (result.description && typeof result.description === 'string' && result.description.length > 0) { + const encrypted = await enc.encrypt(result.description, ctx); + result = { ...result, description: encrypted as unknown as string }; + } + + if (result.steps?.length) { + const encSteps = await Promise.all(result.steps.map(async (step) => { + if (step.notes && typeof step.notes === 'string' && step.notes.length > 0) { + const encrypted = await enc.encrypt(step.notes, { ...ctx, context: 'routine-steps' }); + return { ...step, notes: encrypted as unknown as string }; + } + return step; + })); + result = { ...result, steps: encSteps }; + } + + return result; +} + +async function decryptRoutineFields(doc: RoutineDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: 'routines' }; + let result = doc; + + if (isEncryptedField(result.description)) { + const decrypted = await enc.decrypt(result.description as any, ctx); + result = { ...result, description: decrypted }; + } + + if (result.steps?.length) { + const decSteps = await Promise.all(result.steps.map(async (step) => { + if (isEncryptedField(step.notes)) { + const decrypted = await enc.decrypt(step.notes as any, { ...ctx, context: 'routine-steps' }); + return { ...step, notes: decrypted }; + } + return step; + })); + result = { ...result, steps: decSteps }; + } + + return result; +} + +async function decryptRoutineBatch(docs: RoutineDoc[]): Promise { + return Promise.all(docs.map(decryptRoutineFields)); +} + export async function listRoutines( userId: string, productId: string, @@ -34,15 +89,18 @@ export async function listRoutines( limit: query.limit, }); - return { items, total }; + return { items: await decryptRoutineBatch(items), total }; } export async function getRoutine(id: string, userId: string): Promise { - return collection().findById(id, userId); + const doc = await collection().findById(id, userId); + return doc ? decryptRoutineFields(doc) : null; } export async function createRoutine(doc: RoutineDoc): Promise { - return collection().create(doc); + const encrypted = await encryptRoutineFields(doc); + const created = await collection().create(encrypted); + return decryptRoutineFields(created); } export async function updateRoutine( @@ -66,8 +124,9 @@ export async function updateRoutine( syncVersion: expectedSyncVersion, lastSyncedAt: now, }; - const doc = await collection().upsert(merged); - return { doc, conflict: false }; + const encrypted = await encryptRoutineFields(merged); + const doc = await collection().upsert(encrypted); + return { doc: await decryptRoutineFields(doc), conflict: false }; } catch { return { doc: null, conflict: false }; } @@ -90,11 +149,12 @@ export async function getRoutinesSince( sinceTimestamp: string, limit: number ): Promise { - return collection().findMany({ + const docs = await collection().findMany({ filter: { userId, productId, lastSyncedAt: { $gte: sinceTimestamp } }, sort: { lastSyncedAt: 1 }, limit, }); + return decryptRoutineBatch(docs); } export async function batchUpsertRoutines( @@ -120,7 +180,8 @@ export async function batchUpsertRoutines( productId, lastSyncedAt: now, } as RoutineDoc; - await collection().upsert(merged); + const encrypted = await encryptRoutineFields(merged); + await collection().upsert(encrypted); synced.push(routine.id); } else { conflicts.push({ id: routine.id, serverVersion: existing.syncVersion }); @@ -132,7 +193,8 @@ export async function batchUpsertRoutines( productId, lastSyncedAt: now, } as RoutineDoc; - await collection().create(doc); + const encrypted = await encryptRoutineFields(doc); + await collection().create(encrypted); synced.push(routine.id); } } catch (err) { diff --git a/backend/src/modules/shared-timers/repository.ts b/backend/src/modules/shared-timers/repository.ts index 77cf7de..bc774b2 100644 --- a/backend/src/modules/shared-timers/repository.ts +++ b/backend/src/modules/shared-timers/repository.ts @@ -5,6 +5,7 @@ */ import { getCollection } from '../../lib/datastore.js'; +import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js'; import type { SharedTimerDoc, SharedTimerQuery } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; @@ -12,6 +13,34 @@ function collection() { return getCollection('shared_timers', '/householdId'); } +// ── Field encryption helpers ───────────────────────────────── + +async function encryptSharedTimerFields(doc: SharedTimerDoc): Promise { + if (doc.description && typeof doc.description === 'string' && doc.description.length > 0) { + const encrypted = await getEncryptor().encrypt(doc.description, { + userId: doc.createdBy, + context: 'shared-timers', + }); + return { ...doc, description: encrypted as unknown as string }; + } + return doc; +} + +async function decryptSharedTimerFields(doc: SharedTimerDoc): Promise { + if (isEncryptedField(doc.description)) { + const decrypted = await getEncryptor().decrypt(doc.description as any, { + userId: doc.createdBy, + context: 'shared-timers', + }); + return { ...doc, description: decrypted }; + } + return doc; +} + +async function decryptSharedTimerBatch(docs: SharedTimerDoc[]): Promise { + return Promise.all(docs.map(decryptSharedTimerFields)); +} + export async function listSharedTimers( householdId: string, productId: string, @@ -33,22 +62,27 @@ export async function listSharedTimers( limit: query.limit, }); - return { items, total }; + return { items: await decryptSharedTimerBatch(items), total }; } export async function getSharedTimer( id: string, householdId: string ): Promise { - return collection().findById(id, householdId); + const doc = await collection().findById(id, householdId); + return doc ? decryptSharedTimerFields(doc) : null; } export async function createSharedTimer(doc: SharedTimerDoc): Promise { - return collection().create(doc); + const encrypted = await encryptSharedTimerFields(doc); + const created = await collection().create(encrypted); + return decryptSharedTimerFields(created); } export async function replaceSharedTimer(doc: SharedTimerDoc): Promise { - return collection().upsert(doc); + const encrypted = await encryptSharedTimerFields(doc); + const saved = await collection().upsert(encrypted); + return decryptSharedTimerFields(saved); } export async function deleteSharedTimer(id: string, householdId: string): Promise { diff --git a/backend/src/modules/timers/repository.ts b/backend/src/modules/timers/repository.ts index f59faa8..31685d3 100644 --- a/backend/src/modules/timers/repository.ts +++ b/backend/src/modules/timers/repository.ts @@ -5,6 +5,7 @@ */ import { getCollection } from '../../lib/datastore.js'; +import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js'; import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; @@ -12,6 +13,34 @@ function collection() { return getCollection('timers', '/userId'); } +// ── Field encryption helpers ───────────────────────────────── + +async function encryptTimerFields(doc: TimerDoc): Promise { + if (doc.description && typeof doc.description === 'string' && doc.description.length > 0) { + const encrypted = await getEncryptor().encrypt(doc.description, { + userId: doc.userId, + context: 'timers', + }); + return { ...doc, description: encrypted as unknown as string }; + } + return doc; +} + +async function decryptTimerFields(doc: TimerDoc): Promise { + if (isEncryptedField(doc.description)) { + const decrypted = await getEncryptor().decrypt(doc.description as any, { + userId: doc.userId, + context: 'timers', + }); + return { ...doc, description: decrypted }; + } + return doc; +} + +async function decryptTimerBatch(docs: TimerDoc[]): Promise { + return Promise.all(docs.map(decryptTimerFields)); +} + export async function listTimers( userId: string, productId: string, @@ -35,15 +64,18 @@ export async function listTimers( limit: query.limit, }); - return { items, total }; + return { items: await decryptTimerBatch(items), total }; } export async function getTimer(id: string, userId: string): Promise { - return collection().findById(id, userId); + const doc = await collection().findById(id, userId); + return doc ? decryptTimerFields(doc) : null; } export async function createTimer(doc: TimerDoc): Promise { - return collection().create(doc); + const encrypted = await encryptTimerFields(doc); + const created = await collection().create(encrypted); + return decryptTimerFields(created); } export async function updateTimer( @@ -68,8 +100,9 @@ export async function updateTimer( syncVersion: expectedSyncVersion, lastSyncedAt: now, }; - const doc = await collection().upsert(merged); - return { doc, conflict: false }; + const encrypted = await encryptTimerFields(merged); + const doc = await collection().upsert(encrypted); + return { doc: await decryptTimerFields(doc), conflict: false }; } catch { return { doc: null, conflict: false }; } @@ -92,11 +125,12 @@ export async function getTimersSince( sinceTimestamp: string, limit: number ): Promise { - return collection().findMany({ + const docs = await collection().findMany({ filter: { userId, productId, lastSyncedAt: { $gte: sinceTimestamp } }, sort: { lastSyncedAt: 1 }, limit, }); + return decryptTimerBatch(docs); } export async function batchUpsert( @@ -123,7 +157,8 @@ export async function batchUpsert( productId, lastSyncedAt: now, }; - await collection().upsert(merged); + const encrypted = await encryptTimerFields(merged); + await collection().upsert(encrypted); synced.push(timer.id); } else { conflicts.push({ id: timer.id, serverVersion: existing.syncVersion }); @@ -136,7 +171,8 @@ export async function batchUpsert( productId, lastSyncedAt: now, } as TimerDoc; - await collection().create(doc); + const encrypted = await encryptTimerFields(doc); + await collection().create(encrypted); synced.push(timer.id); } } catch (err) { diff --git a/backend/src/modules/webhooks/repository.ts b/backend/src/modules/webhooks/repository.ts index f6a9337..e6e9ed9 100644 --- a/backend/src/modules/webhooks/repository.ts +++ b/backend/src/modules/webhooks/repository.ts @@ -1,4 +1,5 @@ import { getCollection } from '../../lib/datastore.js'; +import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js'; import { NotFoundError, ConflictError } from '@bytelyst/errors'; import type { WebhookSubscriptionDoc, @@ -16,16 +17,59 @@ function eventsCollection() { return getCollection('webhook_events', '/subscriptionId'); } +// ── Field encryption helpers ───────────────────────────────── + +async function encryptSubFields(doc: WebhookSubscriptionDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: 'webhooks' }; + let result = doc; + + if (result.secret && typeof result.secret === 'string' && result.secret.length > 0) { + const encrypted = await enc.encrypt(result.secret, ctx); + result = { ...result, secret: encrypted as unknown as string }; + } + + if (result.description && typeof result.description === 'string' && result.description.length > 0) { + const encrypted = await enc.encrypt(result.description, { ...ctx, context: 'webhook-desc' }); + result = { ...result, description: encrypted as unknown as string }; + } + + return result; +} + +async function decryptSubFields(doc: WebhookSubscriptionDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: 'webhooks' }; + let result = doc; + + if (isEncryptedField(result.secret)) { + const decrypted = await enc.decrypt(result.secret as any, ctx); + result = { ...result, secret: decrypted }; + } + + if (isEncryptedField(result.description)) { + const decrypted = await enc.decrypt(result.description as any, { ...ctx, context: 'webhook-desc' }); + result = { ...result, description: decrypted }; + } + + return result; +} + +async function decryptSubBatch(docs: WebhookSubscriptionDoc[]): Promise { + return Promise.all(docs.map(decryptSubFields)); +} + // ── Subscription CRUD ───────────────────────────────────────── export async function listSubscriptions( userId: string, productId: string ): Promise { - return subsCollection().findMany({ + const docs = await subsCollection().findMany({ filter: { userId, productId }, sort: { createdAt: -1 }, }); + return decryptSubBatch(docs); } export async function getSubscription(id: string, userId: string): Promise { @@ -33,7 +77,7 @@ export async function getSubscription(id: string, userId: string): Promise { @@ -101,7 +149,8 @@ export async function findSubscriptionsForEvent( const subs = await subsCollection().findMany({ filter: { userId, productId, active: true }, }); - return subs.filter(s => s.events.includes(eventType)); + const decrypted = await decryptSubBatch(subs); + return decrypted.filter(s => s.events.includes(eventType)); } // ── Increment Failure Count ───────────────────────────────────