feat(backend): encrypt sensitive fields across all modules

- timers: description
- routines: description, steps[].notes
- shared-timers: description
- webhooks: secret, description
- Add @bytelyst/field-encrypt dep + chronomind-mek config
- field-encrypt singleton (getEncryptor lazy pattern)
- Encrypt on create/update/batchUpsert, decrypt on read/list/since
- Backward-compatible via isEncryptedField guard
- 182/182 tests passing, typecheck clean
This commit is contained in:
saravanakumardb1 2026-03-21 12:02:53 -07:00
parent 7a1be12ef5
commit 253a9db0ea
8 changed files with 271 additions and 25 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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';

View File

@ -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<RoutineDoc>('routines', '/userId');
}
// ── Field encryption helpers ─────────────────────────────────
async function encryptRoutineFields(doc: RoutineDoc): Promise<RoutineDoc> {
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<RoutineDoc> {
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<RoutineDoc[]> {
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<RoutineDoc | null> {
return collection().findById(id, userId);
const doc = await collection().findById(id, userId);
return doc ? decryptRoutineFields(doc) : null;
}
export async function createRoutine(doc: RoutineDoc): Promise<RoutineDoc> {
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<RoutineDoc[]> {
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) {

View File

@ -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<SharedTimerDoc>('shared_timers', '/householdId');
}
// ── Field encryption helpers ─────────────────────────────────
async function encryptSharedTimerFields(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
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<SharedTimerDoc> {
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<SharedTimerDoc[]> {
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<SharedTimerDoc | null> {
return collection().findById(id, householdId);
const doc = await collection().findById(id, householdId);
return doc ? decryptSharedTimerFields(doc) : null;
}
export async function createSharedTimer(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
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<SharedTimerDoc> {
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<boolean> {

View File

@ -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<TimerDoc>('timers', '/userId');
}
// ── Field encryption helpers ─────────────────────────────────
async function encryptTimerFields(doc: TimerDoc): Promise<TimerDoc> {
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<TimerDoc> {
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<TimerDoc[]> {
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<TimerDoc | null> {
return collection().findById(id, userId);
const doc = await collection().findById(id, userId);
return doc ? decryptTimerFields(doc) : null;
}
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
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<TimerDoc[]> {
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) {

View File

@ -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<WebhookEventDoc>('webhook_events', '/subscriptionId');
}
// ── Field encryption helpers ─────────────────────────────────
async function encryptSubFields(doc: WebhookSubscriptionDoc): Promise<WebhookSubscriptionDoc> {
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<WebhookSubscriptionDoc> {
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<WebhookSubscriptionDoc[]> {
return Promise.all(docs.map(decryptSubFields));
}
// ── Subscription CRUD ─────────────────────────────────────────
export async function listSubscriptions(
userId: string,
productId: string
): Promise<WebhookSubscriptionDoc[]> {
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<WebhookSubscriptionDoc> {
@ -33,7 +77,7 @@ export async function getSubscription(id: string, userId: string): Promise<Webho
if (!doc) {
throw new NotFoundError(`Webhook subscription '${id}' not found`);
}
return doc;
return decryptSubFields(doc);
}
export async function createSubscription(
@ -59,7 +103,9 @@ export async function createSubscription(
};
try {
return await subsCollection().create(doc);
const encrypted = await encryptSubFields(doc);
const created = await subsCollection().create(encrypted);
return decryptSubFields(created);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('already exists')) {
throw new ConflictError(`Subscription '${id}' already exists`);
@ -81,7 +127,9 @@ export async function updateSubscription(
updatedAt: new Date().toISOString(),
};
return subsCollection().upsert(updated);
const encrypted = await encryptSubFields(updated);
const saved = await subsCollection().upsert(encrypted);
return decryptSubFields(saved);
}
export async function deleteSubscription(id: string, userId: string): Promise<void> {
@ -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 ───────────────────────────────────