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.

Maintainers

Package info

gitlab.com/delicity-public/field-encryption-php

Issues

pkg:composer/delicity/field-encryption

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

v1.1.0 2026-08-26 20:46 UTC

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 dependenciesext-openssl only. 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>"
  1. Add v2 to the keyring and redeploy — new writes are v2.…, old v1.… rows still read fine.
  2. Re-encrypt at your own pace (batch: read → decrypt → encrypt → write).
  3. Remove v1 from the keyring once no v1. 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 WHERE clauses 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

License

MIT