From e85cfeb0f1b4a45054f7f808a3a879ca2b0057f2 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 21 Mar 2026 09:29:44 -0700 Subject: [PATCH] feat(notelett): encrypt note body with @bytelyst/field-encrypt - Add field-encrypt dependency + config env vars (FIELD_ENCRYPT_*) - Create backend/src/lib/field-encrypt.ts encryptor singleton - Update notes repository: encrypt body on create/update, decrypt on read - Backward-compatible: isEncryptedField guard handles plaintext during migration - All 86 tests passing --- backend/package-lock.json | 29 ++++++++++++++++ backend/package.json | 1 + backend/src/lib/config.ts | 5 +++ backend/src/lib/field-encrypt.ts | 26 ++++++++++++++ backend/src/modules/notes/repository.ts | 46 ++++++++++++++++++++++--- 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 backend/src/lib/field-encrypt.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 3249c86..018a325 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", "@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger", "fastify": "^5.2.1", "jose": "^6.0.8", @@ -160,6 +161,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 + } + } + }, "../../learning_ai_common_plat/packages/logger": { "name": "@bytelyst/logger", "version": "0.1.0" @@ -449,6 +474,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/@bytelyst/logger": { "resolved": "../../learning_ai_common_plat/packages/logger", "link": true diff --git a/backend/package.json b/backend/package.json index b3de137..da770ae 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,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", "@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger", diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index 5d8464f..0d0bfa3 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -14,6 +14,11 @@ const envSchema = baseBackendConfigSchema.extend({ MCP_SERVER_URL: z.string().default('http://localhost:4007'), TELEMETRY_ENABLED: z.coerce.boolean().default(false), FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false), + // ── Field Encryption (@bytelyst/field-encrypt) ── + FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'), + FIELD_ENCRYPT_KEY: z.string().default(''), + FIELD_ENCRYPT_MEK_NAME: z.string().default('notelett-mek'), + AZURE_KEYVAULT_URL: z.string().default(''), }); 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..bf967f8 --- /dev/null +++ b/backend/src/lib/field-encrypt.ts @@ -0,0 +1,26 @@ +/** + * Field encryption singleton for NoteLett backend. + */ + +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; +} diff --git a/backend/src/modules/notes/repository.ts b/backend/src/modules/notes/repository.ts index d7053cc..5ef4739 100644 --- a/backend/src/modules/notes/repository.ts +++ b/backend/src/modules/notes/repository.ts @@ -1,11 +1,38 @@ import { getCollection } from '../../lib/datastore.js'; +import { getEncryptor } from '../../lib/field-encrypt.js'; +import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt'; import type { NoteDoc, ListNotesQuery } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; +const ENCRYPT_CONTEXT = 'notes'; + function collection() { return getCollection('notes', '/workspaceId'); } +/** Encrypt body before storage. */ +async function encryptFields(doc: NoteDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: ENCRYPT_CONTEXT }; + const encrypted = await enc.encrypt(doc.body, ctx); + return { ...doc, body: encrypted as unknown as string }; +} + +/** Decrypt body after read. Handles plaintext (pre-migration) gracefully. */ +async function decryptFields(doc: NoteDoc): Promise { + const enc = getEncryptor(); + const ctx = { userId: doc.userId, context: ENCRYPT_CONTEXT }; + let body = doc.body; + if (isEncryptedField(body)) { + body = await enc.decrypt(body as unknown as EncryptedField, ctx); + } + return { ...doc, body }; +} + +async function decryptBatch(docs: NoteDoc[]): Promise { + return Promise.all(docs.map(decryptFields)); +} + export async function listNotes( userId: string, productId: string, @@ -26,7 +53,7 @@ export async function listNotes( limit: query.limit, }); - return { items, total }; + return { items: await decryptBatch(items), total }; } export async function countNotesByWorkspaces( @@ -43,11 +70,15 @@ export async function countNotesByWorkspaces( } export async function getNote(id: string, workspaceId: string): Promise { - return collection().findById(id, workspaceId); + const doc = await collection().findById(id, workspaceId); + if (!doc) return null; + return decryptFields(doc); } export async function createNote(doc: NoteDoc): Promise { - return collection().create(doc); + const encrypted = await encryptFields(doc); + const created = await collection().create(encrypted); + return decryptFields(created); } export async function updateNote( @@ -60,8 +91,11 @@ export async function updateNote( return null; } + // Decrypt existing body first so merge works correctly + const decryptedExisting = await decryptFields(existing); + const merged: NoteDoc = { - ...existing, + ...decryptedExisting, ...updates, id: existing.id, workspaceId: existing.workspaceId, @@ -69,5 +103,7 @@ export async function updateNote( productId: existing.productId, }; - return collection().upsert(merged); + const encrypted = await encryptFields(merged); + const result = await collection().upsert(encrypted); + return decryptFields(result); }