Search by

nouxwell / cipher-layer

nouxwell

Field-level encryption, blind-index search, streaming file encryption and key lifecycle tooling for PHP, with Doctrine and Symfony integration.

v0.2.1 2026-11-04 08:42 UTC

This package is auto-updated.

Last update: 2026-11-04 08:44:56 UTC


README

CI Packagist version PHP version License

Field-level encryption, exact-match search over encrypted fields, streaming file encryption and key lifecycle tooling for PHP, with Doctrine ORM and Symfony integration.

Selected personal data is encrypted before it reaches the database and decrypted when an entity is loaded. Keys and data stay in your own environment: there is no SaaS account, licence server, telemetry or required central service.

#[ORM\Entity]
class Customer
{
    #[Encrypted(context: 'customers.email', storedIn: 'emailEncrypted')]
    #[BlindIndex(name: 'email', storedIn: 'emailBidx', normalizer: EmailNormalizer::class, unique: true)]
    private ?string $email = null;                 // plaintext, not mapped by Doctrine

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $emailEncrypted = null;        // "cl1:d7f3a2c1e9:brng:…", no getter

    #[ORM\Column(length: 64, unique: true, nullable: true)]
    private ?string $emailBidx = null;             // keyed hash for exact search, no getter

    public function getEmail(): ?string { return $this->email; }
    public function setEmail(?string $email): void { $this->email = $email; }
}

$customer = $blindIndexQuery->findOne(Customer::class, 'email', 'AYSE@example.com');

cipher-layer is a technical safeguard. It does not make an application compliant with KVKK, GDPR or any other regulation on its own, and it makes no such claim.

Project status

  • 0.x. The PHP API may still change between minor versions until 1.0. The stored formats (cl1 field values, CLF1 files, keyring version: 1) are versioned; later releases will keep reading them.
  • The cryptography is not new: values are encrypted by CipherSweet and files by libsodium's secretstream. cipher-layer adds the envelope, key handling, Doctrine integration and tooling around them.
  • It has been tried in the development environment of one Symfony application. It has not yet run in production. Read the threat model before putting real data behind it.

How it works

  • An encrypted field has two properties: the plaintext one your code uses, which Doctrine does not map, and a mapped ciphertext column. A Doctrine listener encrypts on flush and decrypts on load, so plaintext never reaches an INSERT or UPDATE.
  • Each stored value is cl1:<key id>:<ciphertext>. The format version, key id, field context, tenant and an optional row binding are authenticated together with the value, so a ciphertext copied into another column, tenant or row fails to decrypt.
  • Searching uses blind indexes: keyed hashes of a normalized value, stored in their own column and computed with index keys that are separate from the data keys. Rotating a data key leaves indexes and UNIQUE constraints alone.
  • Keys live in a keyring file you mount read-only into the application. Every key has a purpose (data, index, file) and a state (pending → active → decrypt_only → retired), and the commands below move data between keys without downtime.

What it does

Feature Status
Authenticated field encryption (CipherSweet, XChaCha20 + BLAKE2b or AES-CTR + HMAC-SHA384) stable
Key id and format version in every value, bound together with context, tenant and optional row as associated data stable
Doctrine ORM integration: attributes, automatic encrypt/decrypt, no spurious UPDATEs stable
Exact-match blind indexes (also on derived values: last four digits, e-mail domain) stable
UNIQUE constraints on encrypted values (full-length index) stable
Tenant-separated indexes and ciphertexts stable
Manual encrypt/decrypt API for values outside entities stable
Streaming file encryption (libsodium secretstream), local filesystem storage stable
Key provider: keyring file (Docker secret / read-only mount) stable
Commands: key generate/list/activate/retire/delete, encrypt (migrate), reencrypt, index rebuild, verify, status stable
Bounded in-memory sorting of small, complete result sets stable
Prefix, word and contains search (#[SearchIndex], opt-in per field, results verified after decryption; see what each mode reveals in docs/SEARCH.md) new in 0.2, experimental
Exact sorting by an encrypted field for tables up to 5000 rows, decrypted in memory (EncryptedSort) new in 0.2
Approximate database sorting through a plaintext prefix column (#[SortKey], opt-in) new in 0.2
ORDER BY on the ciphertext itself, order-preserving encryption not offered
HTTP Range requests on encrypted files not supported
Flysystem / S3 / MinIO storage, KMS key providers, Laravel bridge, other language SDKs not implemented; interfaces exist

Requirements

  • PHP 8.3, 8.4 or 8.5 with ext-sodium
  • paragonie/ciphersweet ^4.10 (installed automatically, ISC licence)
  • Optional: Doctrine ORM ^3.3.3 with DBAL ^4, Symfony ^7.4 or ^8.0, symfony/lock, Twig, giggsey/libphonenumber-for-php, ext-intl

The test suite runs on MySQL 8.0 and SQLite against PHP 8.3 (lowest and highest dependencies), 8.4 and 8.5. Other databases supported by Doctrine DBAL should work but are not tested.

Quick start (Symfony + Doctrine)

1. Install

composer require nouxwell/cipher-layer

2. Register the bundle and point it at a keyring

// config/bundles.php
return [
    // ...
    CipherLayer\Bridge\Symfony\CipherLayerBundle::class => ['all' => true],
];
# config/packages/cipher_layer.yaml
cipher_layer:
    keyring_path: '%env(CIPHER_LAYER_KEYRING)%'   # e.g. /run/secrets/cipher_layer_keyring
    environment: '%kernel.environment%'           # refuse a keyring made for another environment
    normalizers:
        phone_default_country: '90'

3. Create the keyring in your management environment, never in the image or the repository, and mount it read-only into every web and worker container (see docs/KEYS.md):

CIPHER_LAYER_KEYRING=/secure/path/keyring.json bin/console data-protection:key:generate --environment=prod

4. Mark the fields as in the example at the top, add the ciphertext and index columns with a migration, and check the setup:

bin/console doctrine:migrations:diff
bin/console data-protection:status

5. Search through the blind index instead of the column:

$customer = $blindIndexQuery->findOne(Customer::class, 'email', $input);

$qb = $repository->createQueryBuilder('c')->andWhere('c.active = true');
$blindIndexQuery->where($qb, 'c', Customer::class, 'email', $input);

Encrypting a column that already holds plaintext takes a few more steps (dual writes, backfill, switching reads, dropping the old column); they are described in docs/MIGRATION.md.

Commands

All commands print counts, row ids and problem codes only, never values, ciphertexts or index hashes, and accept --format=json. The row commands (encrypt, reencrypt, index:rebuild, verify) work in keyset-paginated batches (--batch-size, --max-rows, --resume); the ones that write also take a per-field lock and --dry-run. The key commands that edit the keyring (activate, retire, delete) support --dry-run too.

Command Purpose
data-protection:status Keyring state, protected fields, column sizing and configuration warnings
data-protection:key:generate Create a keyring or add pending keys
data-protection:key:list Keys with purpose, state and fingerprint; --with-usage counts rows and files per key
data-protection:key:activate Make a pending (or decrypt-only) key the active one for its purpose
data-protection:key:retire Stop loading a key no scanned row or file uses any more (--undo reinstates it)
data-protection:key:delete Permanently remove a retired key after repeating its id
data-protection:encrypt Fill ciphertext and index columns from a legacy plaintext column
data-protection:reencrypt Move values from an old data key to the active one
data-protection:index:rebuild Recompute blind indexes, e.g. after an index key rotation
data-protection:verify Decrypt every row and compare it with its indexes and legacy column

Without Symfony

use CipherLayer\Crypto\CipherEngine;
use CipherLayer\Crypto\FieldProtector;
use CipherLayer\Key\FileKeyProvider;
use CipherLayer\Normalizer\EmailNormalizer;
use CipherLayer\Search\BlindIndexer;
use CipherLayer\Search\IndexDefinition;

$keys = new FileKeyProvider('/run/secrets/keyring.json', expectedEnvironment: 'prod');
$protector = new FieldProtector(new CipherEngine($keys));

$stored = $protector->encrypt('crm.notes', 'Called the customer about the invoice.');
$plain  = $protector->decrypt('crm.notes', $stored);

$index = (new BlindIndexer($keys))->compute('crm.contacts.email', new IndexDefinition('email', new EmailNormalizer()), 'Ayse@Example.com');

Documentation

Roadmap

  • data-protection:status reporting of search indexes, the token table and sort keys
  • Flysystem / S3 / MinIO storage, KMS key providers, a Laravel bridge and SDKs for other languages

Security

Please report vulnerabilities privately as described in SECURITY.md, not in public issues.

Licence

MIT, see LICENSE. CipherSweet is ISC licensed.