- 10 source files: types, aes-gcm, 3 key providers (memory/env/akv), envelope, key-cache, dek-store, guards, migration, factory - 42 Vitest tests: AES-GCM roundtrips, tamper detection, unicode, 100KB payloads, key providers, DEK cache TTL/LRU, envelope lifecycle, migration (dry-run + idempotent), config validation - AKV MEK creation script (scripts/create-encryption-keys.sh) for 10 product MEKs - .env.example updated with FIELD_ENCRYPT_* vars
111 lines
3.0 KiB
TypeScript
111 lines
3.0 KiB
TypeScript
/**
|
|
* @bytelyst/field-encrypt — Migration helpers
|
|
*
|
|
* Utilities for encrypting existing plaintext fields in-place.
|
|
* Idempotent — skips already-encrypted fields via __encrypted sentinel.
|
|
*/
|
|
|
|
import type { EncryptedField } from './types.js';
|
|
import { isEncryptedField } from './guards.js';
|
|
|
|
/** Result of a migration run. */
|
|
export interface MigrationResult {
|
|
/** Total documents scanned. */
|
|
scanned: number;
|
|
/** Documents encrypted in this run. */
|
|
encrypted: number;
|
|
/** Documents skipped (already encrypted). */
|
|
skipped: number;
|
|
/** Documents that failed to encrypt. */
|
|
errors: number;
|
|
/** Error details (first 10). */
|
|
errorDetails: Array<{ id: string; error: string }>;
|
|
}
|
|
|
|
/** Options for migrateDocuments(). */
|
|
export interface MigrateDocumentsOptions<T> {
|
|
/** Fetch a batch of documents. Return empty array when done. */
|
|
fetchBatch: (offset: number, batchSize: number) => Promise<T[]>;
|
|
/** Get the document ID for logging. */
|
|
getId: (doc: T) => string;
|
|
/** Get the field value to check/encrypt. */
|
|
getField: (doc: T) => unknown;
|
|
/** Encrypt the plaintext value. Returns the EncryptedField. */
|
|
encryptValue: (plaintext: string, doc: T) => Promise<EncryptedField>;
|
|
/** Write the encrypted value back to the store. */
|
|
writeBack: (doc: T, encrypted: EncryptedField) => Promise<void>;
|
|
/** Batch size (default: 100). */
|
|
batchSize?: number;
|
|
/** If true, don't write — just count. */
|
|
dryRun?: boolean;
|
|
/** Progress callback. */
|
|
onProgress?: (result: MigrationResult) => void;
|
|
}
|
|
|
|
/**
|
|
* Migrate plaintext fields to encrypted fields in batches.
|
|
*
|
|
* Idempotent: skips documents where the field is already an EncryptedField.
|
|
*/
|
|
export async function migrateDocuments<T>(
|
|
options: MigrateDocumentsOptions<T>
|
|
): Promise<MigrationResult> {
|
|
const batchSize = options.batchSize ?? 100;
|
|
const result: MigrationResult = {
|
|
scanned: 0,
|
|
encrypted: 0,
|
|
skipped: 0,
|
|
errors: 0,
|
|
errorDetails: [],
|
|
};
|
|
|
|
let offset = 0;
|
|
let batch: T[];
|
|
|
|
do {
|
|
batch = await options.fetchBatch(offset, batchSize);
|
|
|
|
for (const doc of batch) {
|
|
result.scanned++;
|
|
const fieldValue = options.getField(doc);
|
|
|
|
// Skip already-encrypted
|
|
if (isEncryptedField(fieldValue)) {
|
|
result.skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Skip null/undefined
|
|
if (fieldValue == null) {
|
|
result.skipped++;
|
|
continue;
|
|
}
|
|
|
|
const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue);
|
|
|
|
try {
|
|
const encrypted = await options.encryptValue(plaintext, doc);
|
|
|
|
if (!options.dryRun) {
|
|
await options.writeBack(doc, encrypted);
|
|
}
|
|
|
|
result.encrypted++;
|
|
} catch (err) {
|
|
result.errors++;
|
|
if (result.errorDetails.length < 10) {
|
|
result.errorDetails.push({
|
|
id: options.getId(doc),
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
offset += batch.length;
|
|
options.onProgress?.(result);
|
|
} while (batch.length === batchSize);
|
|
|
|
return result;
|
|
}
|