@delicity/field-encryption
v1.0.0
Published
Versioned field-level encryption for database columns (v1.<ciphertext>) — AES-256-GCM, built-in key rotation, zero dependencies. Bun & Node.js.
Maintainers
Readme
@delicity/field-encryption
Versioned field-level encryption for database columns — encrypt sensitive values (IBANs, TOTP secrets, API keys…) so that a database leak alone exposes nothing.
- Format:
v1.<base64url(iv || ciphertext || tag)>— the key version travels with the value, so key rotation is built in. - Cipher: AES-256-GCM (authenticated: tampered values are rejected on decrypt).
- Zero dependencies — native
node:crypto. Works on Node.js ≥ 16 and Bun. - Cross-language: byte-for-byte compatible with the PHP package
delicity/field-encryption. A value encrypted in PHP decrypts in JS and vice versa — both test suites share the same test vectors.
Install
npm install @delicity/field-encryption # or: bun add @delicity/field-encryptionSetup
Generate a 32-byte key and put it in the environment (one keyring per environment — never reuse dev keys in prod):
openssl rand -base64 32FIELD_ENCRYPTION_KEYS="v1:<base64 32 bytes>"Usage
import { encryptField, decryptField, isEncryptedField } from "@delicity/field-encryption";
const stored = encryptField("FR7630006000011234567890189");
// → "v1.9PferqOKi1ykAYrVGPP00J3yTI_x-QWPkUGETIyI1Dqf..."
const iban = decryptField(stored);
// → "FR7630006000011234567890189"
isEncryptedField(stored); // → true (handy for progressive migration of existing columns)Optional AAD (additional authenticated data) binds a ciphertext to its context — a value copied to another row/column then fails to decrypt:
const stored = encryptField(iban, `couriers.iban:${courierId}`);
const iban = decryptField(stored, `couriers.iban:${courierId}`);For tests or non-env key sources:
import { createFieldEncryption } from "@delicity/field-encryption";
const fe = createFieldEncryption("v1:...base64...");
fe.encryptField("secret");Key rotation
The keyring holds every key version, comma-separated. The highest version encrypts; every listed version can still decrypt:
FIELD_ENCRYPTION_KEYS="v1:<old key>,v2:<new key>"- Add
v2to the keyring and redeploy — new writes arev2.…, oldv1.…rows still read fine. - Re-encrypt at your own pace (batch: read → decrypt → encrypt → write).
- Remove
v1from the keyring once nov1.value remains.
No restart mechanics needed: the env variable is re-read on every call (parsed only when it changes).
Errors
decryptField throws FieldEncryptionError with a code: MISSING_KEYRING, INVALID_KEYRING, UNKNOWN_KEY_VERSION, INVALID_FORMAT, or DECRYPT_FAILED (tampered value, wrong key, or AAD mismatch).
Security notes
- Protects against database-side leaks (SQL dumps, stolen backups, leaked DB credentials). Does not protect against a compromised application server — the keys live in its environment.
- Losing the keys means the data is gone forever. Back them up in a vault.
- An encrypted column can no longer be used in
WHEREclauses or indexes. If you need exact-match lookup, store a blind index (HMAC-SHA256(dedicated key, normalized value)) in a separate column. - Random 96-bit IVs are safe for up to ~2³² encryptions per key version (NIST SP 800-38D) — rotation resets that budget.
Wire format
v<N>.base64url( iv[12] ‖ ciphertext ‖ tag[16] )v<N> names the keyring entry. The IV is public by design; the tag authenticates ciphertext + AAD. Test vectors pinning the format live in test-vectors.json and are verified by both language implementations.
