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:
parent
d83641c5ee
commit
acace0cdc5
@ -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
|
||||
|
||||
75
packages/field-encrypt/src/dek-store-cosmos.ts
Normal file
75
packages/field-encrypt/src/dek-store-cosmos.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user