feat(field-encrypt): add CosmosDekStore for production DEK persistence (6 tests)

- dek-store-cosmos.ts: Cosmos DB-backed DekStore implementation
  - Uses _encryption_keys container with dekId partition key
  - Upsert semantics, idempotent delete, query-based listIds
- index.ts: export CosmosDekStore
- index.test.ts: 6 new tests with mock container (56 total)

This completes E2EE Phase 3 — production multi-instance DEK storage.
Previously only MemoryDekStore was available, losing DEKs on restart.
This commit is contained in:
saravanakumardb1 2026-04-14 11:29:23 -07:00
parent d83641c5ee
commit acace0cdc5
4 changed files with 198 additions and 5 deletions

View File

@ -1,9 +1,9 @@
Last refresh: 2026-04-13T06:13:49Z (2026-04-12 23:13:49 PDT)
Cascade conversations: 50 (421M)
Memories: 130
Last refresh: 2026-04-14T06:00:06Z (2026-04-13 23:00:06 PDT)
Cascade conversations: 50 (428M)
Memories: 132
Implicit context: 20
Code tracker dirs: 108
File edit history: 4591 entries
Code tracker dirs: 151
File edit history: 4640 entries
Workspace storage: 41 workspaces
Repo docs: 7 files across 2 repos
Repo workflows: 55 files across 12 repos

View File

@ -0,0 +1,75 @@
/**
* @bytelyst/field-encrypt Cosmos DB DEK store
*
* Production DEK store backed by Azure Cosmos DB.
* Container: `_encryption_keys` (partition key: /dekId)
*
* Each WrappedDek is stored as a document with dekId as both id and partition key.
*/
import type { Container } from '@azure/cosmos';
import type { DekStore, WrappedDek } from './types.js';
interface CosmosDekDoc {
id: string;
dekId: string;
wrappedKey: string;
mekVersion: string;
createdAt: string;
}
export class CosmosDekStore implements DekStore {
constructor(private readonly container: Container) {}
async get(dekId: string): Promise<WrappedDek | null> {
try {
const { resource } = await this.container.item(dekId, dekId).read<CosmosDekDoc>();
if (!resource) return null;
return {
dekId: resource.dekId,
wrappedKey: resource.wrappedKey,
mekVersion: resource.mekVersion,
createdAt: resource.createdAt,
};
} catch (err: unknown) {
if (isNotFound(err)) return null;
throw err;
}
}
async put(dek: WrappedDek): Promise<void> {
const doc: CosmosDekDoc = {
id: dek.dekId,
dekId: dek.dekId,
wrappedKey: dek.wrappedKey,
mekVersion: dek.mekVersion,
createdAt: dek.createdAt,
};
await this.container.items.upsert(doc);
}
async listIds(): Promise<string[]> {
const { resources } = await this.container.items
.query<{ dekId: string }>('SELECT c.dekId FROM c')
.fetchAll();
return resources.map(r => r.dekId);
}
async delete(dekId: string): Promise<void> {
try {
await this.container.item(dekId, dekId).delete();
} catch (err: unknown) {
if (isNotFound(err)) return; // Already deleted — idempotent
throw err;
}
}
}
function isNotFound(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: number }).code === 404
);
}

View File

@ -20,6 +20,7 @@ import {
rewrapAllDeks,
DekCache,
MemoryDekStore,
CosmosDekStore,
MemoryKeyProvider,
EnvKeyProvider,
migrateDocuments,
@ -489,3 +490,119 @@ describe('migrateDocuments', () => {
expect(writeCount).toBe(0);
});
});
// ── CosmosDekStore ──────────────────────────────────
describe('CosmosDekStore', () => {
function createMockContainer() {
const docs = new Map<string, Record<string, unknown>>();
const container = {
item: (id: string, _pk: string) => ({
read: async <T>() => {
const resource = docs.get(id) as T | undefined;
if (!resource) {
const err = new Error('Not found') as Error & { code: number };
err.code = 404;
throw err;
}
return { resource };
},
delete: async () => {
if (!docs.has(id)) {
const err = new Error('Not found') as Error & { code: number };
err.code = 404;
throw err;
}
docs.delete(id);
},
}),
items: {
upsert: async (doc: Record<string, unknown>) => {
docs.set(doc.id as string, doc);
},
query: (_sql: string) => ({
fetchAll: async () => ({
resources: [...docs.values()].map(d => ({ dekId: d.dekId })),
}),
}),
},
};
return container as unknown as import('@azure/cosmos').Container;
}
it('put and get a DEK', async () => {
const store = new CosmosDekStore(createMockContainer());
const dek = {
dekId: 'dek_test',
wrappedKey: 'aabbcc',
mekVersion: 'v1',
createdAt: '2026-01-01T00:00:00Z',
};
await store.put(dek);
const got = await store.get('dek_test');
expect(got).toEqual(dek);
});
it('get returns null for missing DEK', async () => {
const store = new CosmosDekStore(createMockContainer());
const got = await store.get('nonexistent');
expect(got).toBeNull();
});
it('listIds returns all stored DEK IDs', async () => {
const store = new CosmosDekStore(createMockContainer());
await store.put({
dekId: 'dek_a',
wrappedKey: '11',
mekVersion: 'v1',
createdAt: '2026-01-01T00:00:00Z',
});
await store.put({
dekId: 'dek_b',
wrappedKey: '22',
mekVersion: 'v1',
createdAt: '2026-01-01T00:00:00Z',
});
const ids = await store.listIds();
expect(ids).toContain('dek_a');
expect(ids).toContain('dek_b');
expect(ids.length).toBe(2);
});
it('delete removes a DEK', async () => {
const store = new CosmosDekStore(createMockContainer());
await store.put({
dekId: 'dek_del',
wrappedKey: '33',
mekVersion: 'v1',
createdAt: '2026-01-01T00:00:00Z',
});
await store.delete('dek_del');
expect(await store.get('dek_del')).toBeNull();
});
it('delete is idempotent (no error on missing)', async () => {
const store = new CosmosDekStore(createMockContainer());
await expect(store.delete('nonexistent')).resolves.toBeUndefined();
});
it('put overwrites existing DEK (upsert)', async () => {
const store = new CosmosDekStore(createMockContainer());
await store.put({
dekId: 'dek_up',
wrappedKey: 'old',
mekVersion: 'v1',
createdAt: '2026-01-01T00:00:00Z',
});
await store.put({
dekId: 'dek_up',
wrappedKey: 'new',
mekVersion: 'v2',
createdAt: '2026-01-01T00:00:00Z',
});
const got = await store.get('dek_up');
expect(got?.wrappedKey).toBe('new');
expect(got?.mekVersion).toBe('v2');
});
});

View File

@ -46,6 +46,7 @@ export { encryptField, decryptField, generateAesKey } from './aes-gcm.js';
export { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js';
export { DekCache } from './key-cache.js';
export { MemoryDekStore } from './dek-store-memory.js';
export { CosmosDekStore } from './dek-store-cosmos.js';
// ── Key providers (for direct use / testing) ────────
export { MemoryKeyProvider } from './key-provider-memory.js';