Skip to content

Repository files navigation

phpnomad/encryption

Encryption contracts for PHPNomad — bring your own cipher via an integration package. This package holds only the strategy and key-provider interfaces, the immutable encrypted-value model, the key providers, and the exception types. It makes no assumption about how you store or transport an encrypted value — no serialization format, no column shape. It has no cipher dependency (no ext-sodium, no framework, no ORM) — just PHP.

The default cipher lives in a separate integration: phpnomad/sodium-integration (libsodium XChaCha20-Poly1305 AEAD).

  • Contract-first.EncryptionStrategy and KeyProvider are the seams; swap ciphers or key sources without touching call sites.
  • Context binding. The contract requires ciphertext to be bound to a caller context (associated data), so a value copied elsewhere won't decrypt.
  • Key rotation built in. Keys are versioned; encrypt with the current version, decrypt against whatever version sealed the data.
  • Storage-agnostic.EncryptedValue is pure data — ciphertext, nonce, key version, and an opaque cipher tag. How you persist it is entirely yours.

Requirements

  • PHP >= 8.2
  • A cipher implementation — e.g. phpnomad/sodium-integration.

Install

composer require phpnomad/encryption phpnomad/sodium-integration

phpnomad/encryption gives you the contracts; phpnomad/sodium-integration gives you the SodiumEncryptionStrategy to wire in.

Quickstart

usePHPNomad\Encryption\Providers\ArrayKeyProvider;
usePHPNomad\Encryption\Models\EncryptedValue;
usePHPNomad\Sodium\EncryptionIntegration\Strategies\SodiumEncryptionStrategy;
// A key ring holding one 32-byte key at version 1.$keys = newArrayKeyProvider([1 => sodium_crypto_aead_xchacha20poly1305_ietf_keygen()]);
// The cipher comes from the integration package; everything else is this one.$encryption = newSodiumEncryptionStrategy($keys);
$sealed = $encryption->encrypt('sk-live-super-secret');
// Persist it however your storage layer prefers — the value is pure data, so the// shape is yours. For example, spread across columns (base64 for text columns):$row = [
'ciphertext' => base64_encode($sealed->getCiphertext()),
'nonce' => base64_encode($sealed->getNonce()),
'key_version' => $sealed->getKeyVersion(),
'cipher' => $sealed->getCipher(),
];
// Later — rebuild the value from your stored fields and decrypt:$restored = newEncryptedValue(
base64_decode($row['ciphertext']),
base64_decode($row['nonce']),
(int) $row['key_version'],
$row['cipher'],
);
$plaintext = $encryption->decrypt($restored);
// => "sk-live-super-secret"

Generate a base64 key for your environment:

php -r 'echo base64_encode(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()), "\n";'

Where keys come from — an env var, a file, a KMS — is your application's concern: implement Interfaces\KeyProvider (two methods) however you load them. ArrayKeyProvider is the in-memory primitive when you already hold the raw keys.

Associated data (AEAD context)

The second argument to encrypt()/decrypt() is associated data: authenticated but not encrypted. Use it to bind a ciphertext to where it lives. The same context must be supplied to decrypt.

$sealed = $encryption->encrypt($token, "tenant:42:column:access_token");
$encryption->decrypt($sealed, "tenant:42:column:access_token"); // ok$encryption->decrypt($sealed, "tenant:99:column:access_token"); // throws DecryptionFailedException

This turns an encrypted-value swap between rows or columns from a silent success into a hard failure. If you encrypt several fields on one record, give each its own context (e.g. "record:{id}:{field}") so ciphertexts can't be swapped between columns.

Key rotation

Keys are addressed by version. Keep every version still referenced by stored ciphertext; point the ring's current version at the newest key.

// v1 was current when old values were sealed. Now add v2 and make it current.$keys = newArrayKeyProvider([
1 => $oldKey, // retained so old ciphertext still decrypts2 => $newKey,
], currentVersion: 2);
$encryption = newSodiumEncryptionStrategy($keys);
$encryption->encrypt('x'); // sealed under v2$encryption->decrypt($oldValue); // still decrypts against v1

To fully migrate, decrypt each stored value and re-encrypt it (the new EncryptedValue carries keyVersion = 2), then retire the old key once nothing references it. Multiple keys can also live behind separate providers via KeyRing (one KeyProvider per version).

Writing a cipher integration

Implement Interfaces\EncryptionStrategy and return an EncryptedValue, stamping your own opaque cipher discriminator so decrypt-time can recognize it:

usePHPNomad\Encryption\Interfaces\EncryptionStrategy;
usePHPNomad\Encryption\Models\EncryptedValue;
finalclass MyCipherStrategy implements EncryptionStrategy
{
publicconstCIPHER = 'my-cipher-v1';
publicfunctionencrypt(string$plaintext, string$context = ''): EncryptedValue { /* ...return new EncryptedValue($ct, $nonce, $version, self::CIPHER) */ }
publicfunctiondecrypt(EncryptedValue$value, string$context = ''): string { /* ... */ }
}

The contract requires that decryption fail (throw DecryptionFailedException) on a wrong key, a mismatched $context, or tampered bytes, and that it decrypt against the key version recorded on the value. The cipher discriminator is owned by the strategy, not this package — the contract names no ciphers. See phpnomad/sodium-integration for the reference implementation.

API at a glance

TypeRole
Interfaces\EncryptionStrategyencrypt(string, context): EncryptedValue / decrypt(EncryptedValue, context): string
Interfaces\KeyProvidergetKey(version): string / currentVersion(): int
Models\EncryptedValueimmutable data: ciphertext + nonce + keyVersion + opaque cipher tag; getters only
Providers\ArrayKeyProviderin-memory versioned key ring
Providers\KeyRingcompose per-version providers
Exceptions\*EncryptionException, DecryptionFailedException, KeyNotFoundException
cipher strategyprovided by an integration, e.g. phpnomad/sodium-integration

Testing

composer install
composer test

The contract suite has no cipher dependency — it exercises the strategy contract against a small in-package reversible fake. The real libsodium cipher is tested in phpnomad/sodium-integration.

License

MIT © Novatorius / Alex Standiford

About

Reusable libsodium encryption (AEAD, key rotation, field-level encryption) for the PHPNomad ecosystem

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages