delicity / field-encryption
Versioned field-level encryption for database columns (v1.<ciphertext>) — AES-256-GCM, built-in key rotation, zero dependencies. PHP 7.4+, optional Laravel helpers.
Requires
- php: >=7.4
- ext-openssl: *
Suggests
- illuminate/database: Enables the Laravel helpers: EncryptedField cast (Laravel 9.32+) and HasEncryptedFields trait (Laravel 6+)
This package is not auto-updated.
Last update: 2026-08-26 21:52:43 UTC
README
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 —
ext-opensslonly. PHP 7.4 → 8.4. - Cross-language: byte-for-byte compatible with the JS package
@delicity/field-encryption(Node.js & Bun). A value encrypted in PHP decrypts in JS and vice versa — both test suites share the same test vectors.
Install
composer require delicity/field-encryption
Setup
Generate a 32-byte key and put it in the environment (one keyring per environment — never reuse dev keys in prod):
openssl rand -base64 32
FIELD_ENCRYPTION_KEYS="v1:<base64 32 bytes>"
Usage
use Delicity\FieldEncryption\FieldEncryption;
$stored = FieldEncryption::encrypt('FR7630006000011234567890189');
// → "v1.9PferqOKi1ykAYrVGPP00J3yTI_x-QWPkUGETIyI1Dqf..."
$iban = FieldEncryption::decrypt($stored);
// → "FR7630006000011234567890189"
FieldEncryption::isEncrypted($stored); // → true (handy for progressive migration)
Optional AAD (additional authenticated data) binds a ciphertext to its context — a value copied to another row/column then fails to decrypt:
$stored = FieldEncryption::encrypt($iban, "couriers.iban:{$courierId}");
$iban = FieldEncryption::decrypt($stored, "couriers.iban:{$courierId}");
For tests or non-env key sources: new FieldEncryption('v1:...base64...') then ->encryptValue() / ->decryptValue().
Laravel
Laravel 9.32+ — custom cast:
use Delicity\FieldEncryption\Laravel\EncryptedField;
class Courier extends Model
{
protected $casts = ['iban' => EncryptedField::class];
}
Laravel 6–8 — trait (custom casts did not exist yet):
use Delicity\FieldEncryption\Laravel\HasEncryptedFields;
class Courier extends Model
{
use HasEncryptedFields;
protected $encrypted = ['iban', 'google_authenticator_secret'];
}
Both are migration-friendly: legacy plaintext rows are returned as-is on read and encrypted on their next write, so you can enable them on an existing column and migrate progressively. Make sure the column is long enough (TEXT or VARCHAR ≥ plaintext × 2 + 60).
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.
Errors
decrypt() throws FieldEncryptionException; getErrorCode() returns MISSING_KEYRING, INVALID_KEYRING, UNKNOWN_KEY_VERSION, INVALID_FORMAT, ENCRYPT_FAILED 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 (generated by the JS repo) and are verified by both language implementations.
Tests
php tests/run.php # dependency-free, no composer install needed