caeligo/field-encryption-bundle
This document covers the encryption of text/string fields in Doctrine entities using AES-256-CBC.
String field encryption is designed for sensitive text data like emails, names, phone numbers, etc. The bundle uses:
The bundle uses HKDF (HMAC-based Key Derivation Function) to derive separate keys for different purposes:
Master Key
├── HKDF("field-encryption-v1") → Encryption Purpose Key
│ └── HMAC(entity_id) → Entity-specific Encryption Key
│
└── HKDF("field-hashing-v1") → Hashing Purpose Key
└── HMAC(value) → Searchable Hash
This provides cryptographic separation - compromising one derived key doesn't compromise others.
The bundle provides timing-safe hash comparison to prevent timing attacks:
// In your code
$encryptionService = $this->container->get(FieldEncryptionService::class);
// Verify a value against a stored hash (timing-safe)
if ($encryptionService->verifyHash($userInput, $storedHash)) {
// Value matches
}
// Or compare two hashes directly
if ($encryptionService->hashEquals($hash1, $hash2)) {
// Hashes match
}
The bundle provides two hashing methods:
| Method | Algorithm | Key Required | Use Case |
|---|---|---|---|
hash() |
SHA-256 | No | Backward compatible, existing databases |
secureHash() |
HMAC-SHA256 | Yes (derived) | New projects, higher security |
// Plain SHA-256 (default, backward compatible)
$hash = $encryptionService->hash($email);
// HMAC-SHA256 (more secure, requires same key to verify)
$secureHash = $encryptionService->secureHash($email);
// Verification
$encryptionService->verifyHash($email, $hash); // for hash()
$encryptionService->verifySecureHash($email, $secureHash); // for secureHash()
<?php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Caeligo\FieldEncryptionBundle\Attribute\Encrypted;
use Caeligo\FieldEncryptionBundle\Attribute\EncryptedEntity;
#[ORM\Entity]
#[EncryptedEntity]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'ulid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private ?Ulid $id = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Encrypted(hashField: true, hashProperty: 'emailHash')]
private ?string $email = null;
#[ORM\Column(type: Types::TEXT, nullable: true, unique: true)]
private ?string $emailHash = null;
private ?string $plainEmail = null; // Transient, auto-populated
public function getEmail(): ?string
{
return $this->plainEmail;
}
public function setEmail(?string $email): self
{
$this->plainEmail = $email;
return $this;
}
}
For each encrypted field, your entity needs:
| Component | ORM Mapping | Description |
|---|---|---|
| Encrypted property | [@Column](https://github.com/Column) |
Stores encrypted data in database |
| Plain property | None (transient) | Used by your application code |
| Hash property (optional) | [@Column](https://github.com/Column) |
SHA-256 hash for searching |
#[EncryptedEntity]Class-level attribute to configure entity-wide encryption settings.
#[EncryptedEntity] // Uses 'id' property by default
class User { ... }
#[EncryptedEntity(idProperty: 'ulid')] // Custom ID property
class Customer { ... }
| Parameter | Type | Default | Description |
|---|---|---|---|
idProperty |
string | 'id' |
Property name containing the ULID/UUID for key derivation |
#[Encrypted]Property-level attribute to mark a field for encryption.
#[Encrypted(
encryptedProperty: 'email', // Optional: defaults to property name
plainProperty: 'plainEmail', // Optional: defaults to 'plain' + PropertyName
hashField: true, // Optional: create searchable hash
hashProperty: 'emailHash' // Optional: property for the hash
)]
private ?string $email = null;
| Parameter | Type | Default | Description |
|---|---|---|---|
encryptedProperty |
string|null | Same as property name | DB column storing encrypted value |
plainProperty |
string|null | 'plain' + ucfirst(name) |
Transient property for decrypted value |
hashField |
bool | false |
Whether to compute SHA-256 hash |
hashProperty |
string|null | name + 'Hash' |
Property storing the hash |
If you follow these conventions, you can use #[Encrypted] without parameters:
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Encrypted] // Uses defaults
private ?string $email = null; // encryptedProperty: 'email'
private ?string $plainEmail = null; // plainProperty: 'plainEmail' (auto-detected)
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $emailHash = null; // hashProperty: 'emailHash' (if hashField: true)
When hashField: true is set, the bundle creates a SHA-256 hash of the normalized value (lowercase, trimmed). This allows you to:
// In your repository
public function findByEmail(string $email): ?User
{
$hash = hash('sha256', mb_strtolower(trim($email)));
return $this->findOneBy(['emailHash' => $hash]);
}
If your entity uses auto-increment integer as primary key, you need a separate ULID/UUID property for key derivation:
#[ORM\Entity]
#[EncryptedEntity(idProperty: 'ulid')] // Use getUlid() instead of getId()
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null; // Integer primary key
#[ORM\Column(type: 'ulid', unique: true)]
private ?Ulid $ulid = null; // ULID for encryption key derivation
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Encrypted(hashField: true)]
private ?string $phone = null;
private ?string $plainPhone = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $phoneHash = null;
public function __construct()
{
$this->ulid = new Ulid();
}
}
<?php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Ulid;
use Doctrine\ORM\Mapping\CustomIdGenerator;
use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Caeligo\FieldEncryptionBundle\Attribute\Encrypted;
use Caeligo\FieldEncryptionBundle\Attribute\EncryptedEntity;
#[ORM\Entity]
#[EncryptedEntity]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'ulid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[CustomIdGenerator(class: UlidGenerator::class)]
private ?Ulid $id = null;
#[ORM\Column(length: 180, unique: true)]
private ?string $username = null;
// ==================== ENCRYPTED: email ====================
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Encrypted(hashField: true, hashProperty: 'emailHash')]
private ?string $email = null;
#[ORM\Column(type: Types::TEXT, nullable: true, unique: true)]
private ?string $emailHash = null;
private ?string $plainEmail = null;
// ==================== ENCRYPTED: firstName ====================
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Encrypted]
private ?string $firstName = null;
private ?string $plainFirstName = null;
// ==================== ID ====================
public function getId(): ?Ulid
{
return $this->id;
}
// ==================== EMAIL ====================
public function getEmail(): ?string
{
return $this->plainEmail;
}
public function setEmail(?string $email): self
{
$this->plainEmail = $email;
return $this;
}
public function getEmailHash(): ?string
{
return $this->emailHash;
}
// ==================== FIRST NAME ====================
public function getFirstName(): ?string
{
return $this->plainFirstName;
}
public function setFirstName(?string $firstName): self
{
$this->plainFirstName = $firstName;
return $this;
}
}
plain* property, encrypts it with AES-256-CBC using a derived key, and stores it in the encrypted propertyplain* property{"iv": "base64...", "value": "encrypted..."}
HMAC-SHA256(entity_ulid, master_key) ensures unique keys per entitySHA-256(lowercase(trim(value))) for searchable hashHow can I help you explore Laravel packages today?