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
This commit is contained in:
parent
d12c5bfa02
commit
e85cfeb0f1
29
backend/package-lock.json
generated
29
backend/package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
|
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
|
||||||
"@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth",
|
"@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth",
|
||||||
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
|
"@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",
|
"@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"jose": "^6.0.8",
|
"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": {
|
"../../learning_ai_common_plat/packages/logger": {
|
||||||
"name": "@bytelyst/logger",
|
"name": "@bytelyst/logger",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
@ -449,6 +474,10 @@
|
|||||||
"resolved": "../../learning_ai_common_plat/packages/fastify-core",
|
"resolved": "../../learning_ai_common_plat/packages/fastify-core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@bytelyst/field-encrypt": {
|
||||||
|
"resolved": "../../learning_ai_common_plat/packages/field-encrypt",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@bytelyst/logger": {
|
"node_modules/@bytelyst/logger": {
|
||||||
"resolved": "../../learning_ai_common_plat/packages/logger",
|
"resolved": "../../learning_ai_common_plat/packages/logger",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"@bytelyst/backend-flags": "file:../../learning_ai_common_plat/packages/backend-flags",
|
"@bytelyst/backend-flags": "file:../../learning_ai_common_plat/packages/backend-flags",
|
||||||
"@bytelyst/backend-telemetry": "file:../../learning_ai_common_plat/packages/backend-telemetry",
|
"@bytelyst/backend-telemetry": "file:../../learning_ai_common_plat/packages/backend-telemetry",
|
||||||
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
|
"@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-auth": "file:../../learning_ai_common_plat/packages/fastify-auth",
|
||||||
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
|
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
|
||||||
"@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger",
|
"@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger",
|
||||||
|
|||||||
@ -14,6 +14,11 @@ const envSchema = baseBackendConfigSchema.extend({
|
|||||||
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
|
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
|
||||||
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
||||||
FEATURE_FLAGS_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);
|
export const config = envSchema.parse(process.env);
|
||||||
|
|||||||
26
backend/src/lib/field-encrypt.ts
Normal file
26
backend/src/lib/field-encrypt.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -1,11 +1,38 @@
|
|||||||
import { getCollection } from '../../lib/datastore.js';
|
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 { NoteDoc, ListNotesQuery } from './types.js';
|
||||||
import type { FilterMap } from '@bytelyst/datastore';
|
import type { FilterMap } from '@bytelyst/datastore';
|
||||||
|
|
||||||
|
const ENCRYPT_CONTEXT = 'notes';
|
||||||
|
|
||||||
function collection() {
|
function collection() {
|
||||||
return getCollection<NoteDoc>('notes', '/workspaceId');
|
return getCollection<NoteDoc>('notes', '/workspaceId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Encrypt body before storage. */
|
||||||
|
async function encryptFields(doc: NoteDoc): Promise<NoteDoc> {
|
||||||
|
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<NoteDoc> {
|
||||||
|
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<NoteDoc[]> {
|
||||||
|
return Promise.all(docs.map(decryptFields));
|
||||||
|
}
|
||||||
|
|
||||||
export async function listNotes(
|
export async function listNotes(
|
||||||
userId: string,
|
userId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
@ -26,7 +53,7 @@ export async function listNotes(
|
|||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items, total };
|
return { items: await decryptBatch(items), total };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countNotesByWorkspaces(
|
export async function countNotesByWorkspaces(
|
||||||
@ -43,11 +70,15 @@ export async function countNotesByWorkspaces(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getNote(id: string, workspaceId: string): Promise<NoteDoc | null> {
|
export async function getNote(id: string, workspaceId: string): Promise<NoteDoc | null> {
|
||||||
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<NoteDoc> {
|
export async function createNote(doc: NoteDoc): Promise<NoteDoc> {
|
||||||
return collection().create(doc);
|
const encrypted = await encryptFields(doc);
|
||||||
|
const created = await collection().create(encrypted);
|
||||||
|
return decryptFields(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNote(
|
export async function updateNote(
|
||||||
@ -60,8 +91,11 @@ export async function updateNote(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt existing body first so merge works correctly
|
||||||
|
const decryptedExisting = await decryptFields(existing);
|
||||||
|
|
||||||
const merged: NoteDoc = {
|
const merged: NoteDoc = {
|
||||||
...existing,
|
...decryptedExisting,
|
||||||
...updates,
|
...updates,
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
workspaceId: existing.workspaceId,
|
workspaceId: existing.workspaceId,
|
||||||
@ -69,5 +103,7 @@ export async function updateNote(
|
|||||||
productId: existing.productId,
|
productId: existing.productId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return collection().upsert(merged);
|
const encrypted = await encryptFields(merged);
|
||||||
|
const result = await collection().upsert(encrypted);
|
||||||
|
return decryptFields(result);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user