Skip to content

Commit 63fb8e9

Browse files
Split off the libsodium impl into phpnomad/sodium-integration
phpnomad/encryption is now the contract only — interfaces, EncryptedValue, FieldEncrypter, key providers — with no ext-sodium dependency. The SodiumEncryptionStrategy (XChaCha20-Poly1305 AEAD) + legacy secretbox strategy move to phpnomad/sodium-integration, matching the ecosystem's contract + *-integration convention (fetch / guzzle-fetch). Contract tests run against an in-package reversible fake, so the package needs no cipher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc4edc4 commit 63fb8e9

10 files changed

Lines changed: 297 additions & 461 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ jobs:
2121
uses: shivammathur/setup-php@v2
2222
with:
2323
php-version: ${{ matrix.php }}
24-
extensions: sodium
2524
coverage: none
2625

2726
- name: Validate composer.json

README.md

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,49 @@
11
# phpnomad/encryption
22

3-
Interface-driven [libsodium](https://www.php.net/manual/en/book.sodium.php) encryption primitives for PHP: authenticated encryption (AEAD), versioned keys with rotation, and framework-agnostic field-level encryption. No framework, ORM, or datastore dependency — just PHP and `ext-sodium`.
4-
5-
- **Authenticated by default.** Every ciphertext is sealed with XChaCha20-Poly1305 AEAD, so tampering is detected on decrypt.
6-
- **Context binding.** Pin a ciphertext to the row, column, or tenant it belongs to. A value copied elsewhere won't decrypt.
7-
- **Key rotation built in.** Keys are versioned; encrypt with the current version, decrypt against whatever version sealed the data.
8-
- **Field-level helper.** Encrypt-on-write / decrypt-on-read for marked fields of a plain array — wire it into any storage layer.
9-
- **Migration-friendly.** Can read legacy `sodium_crypto_secretbox` data while you move to AEAD.
3+
Encryption **contracts** and framework-agnostic **field-level encryption** for
4+
PHPNomad — bring your own cipher via an integration package. This package holds
5+
only interfaces, value objects, key providers, and the field/attribute helpers.
6+
It has no cipher dependency (no `ext-sodium`, no framework, no ORM) — just PHP.
7+
8+
The default cipher lives in a separate integration:
9+
**[`phpnomad/sodium-integration`](https://github.com/phpnomad/sodium-integration)**
10+
(libsodium XChaCha20-Poly1305 AEAD).
11+
12+
- **Contract-first.** `EncryptionStrategy` and `KeyProvider` are the seams; swap
13+
ciphers or key sources without touching call sites.
14+
- **Context binding.** The contract requires ciphertext to be bound to a caller
15+
context (associated data), so a value copied elsewhere won't decrypt.
16+
- **Key rotation built in.** Keys are versioned; encrypt with the current
17+
version, decrypt against whatever version sealed the data.
18+
- **Field-level helper.** `FieldEncrypter` / `EncryptsFields` do
19+
encrypt-on-write / decrypt-on-read for marked fields of a plain array — wire it
20+
into any storage layer, over any strategy.
1021

1122
## Requirements
1223

1324
- PHP >= 8.2
14-
- `ext-sodium` (bundled with PHP 7.2+)
25+
- A cipher implementation — e.g. `phpnomad/sodium-integration`.
1526

1627
## Install
1728

1829
```bash
19-
composer require phpnomad/encryption
30+
composer require phpnomad/encryption phpnomad/sodium-integration
2031
```
2132

33+
`phpnomad/encryption` gives you the contracts and helpers;
34+
`phpnomad/sodium-integration` gives you the `SodiumEncryptionStrategy` to wire in.
35+
2236
## Quickstart
2337

2438
```php
2539
use PHPNomad\Encryption\Providers\ArrayKeyProvider;
26-
use PHPNomad\Encryption\Strategies\SodiumEncryptionStrategy;
2740
use PHPNomad\Encryption\ValueObjects\EncryptedValue;
41+
use PHPNomad\Sodium\EncryptionIntegration\Strategies\SodiumEncryptionStrategy;
2842

2943
// A key ring holding one 32-byte key at version 1.
3044
$keys = new ArrayKeyProvider([1 => sodium_crypto_aead_xchacha20poly1305_ietf_keygen()]);
3145

46+
// The cipher comes from the integration package; everything else is this one.
3247
$encryption = new SodiumEncryptionStrategy($keys);
3348

3449
$sealed = $encryption->encrypt('sk-live-super-secret');
@@ -53,6 +68,7 @@ php -r 'echo base64_encode(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()),
5368

5469
```php
5570
use PHPNomad\Encryption\Providers\Base64EnvKeyProvider;
71+
use PHPNomad\Sodium\EncryptionIntegration\Strategies\SodiumEncryptionStrategy;
5672

5773
// Reads a base64-encoded 32-byte key from APP_MASTER_KEY (file fallback optional).
5874
$keys = new Base64EnvKeyProvider('APP_MASTER_KEY', __DIR__ . '/.master_key');
@@ -61,7 +77,9 @@ $encryption = new SodiumEncryptionStrategy($keys);
6177

6278
## Associated data (AEAD context)
6379

64-
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.
80+
The second argument to `encrypt()`/`decrypt()` is **associated data**:
81+
authenticated but not encrypted. Use it to bind a ciphertext to where it lives.
82+
The *same* context must be supplied to decrypt.
6583

6684
```php
6785
$sealed = $encryption->encrypt($token, "tenant:42:column:access_token");
@@ -70,11 +88,15 @@ $encryption->decrypt($sealed, "tenant:42:column:access_token"); // ok
7088
$encryption->decrypt($sealed, "tenant:99:column:access_token"); // throws DecryptionFailedException
7189
```
7290

73-
This turns an encrypted-value swap between rows or columns from a silent success into a hard failure.
91+
This turns an encrypted-value swap between rows or columns from a silent success
92+
into a hard failure.
7493

7594
## Field-level encryption
7695

77-
`FieldEncrypter` transparently encrypts a fixed set of fields on an associative array and decrypts them on the way back — no storage coupling. Each field is bound to its own AEAD context (`"{context}:{field}"`), so values can't be swapped between columns.
96+
`FieldEncrypter` transparently encrypts a fixed set of fields on an associative
97+
array and decrypts them on the way back — no storage coupling, and cipher-agnostic
98+
(pass any `EncryptionStrategy`). Each field is bound to its own AEAD context
99+
(`"{context}:{field}"`), so values can't be swapped between columns.
78100

79101
```php
80102
use PHPNomad\Encryption\Services\FieldEncrypter;
@@ -93,9 +115,11 @@ $row = $fields->encryptRow([
93115
$row = $fields->decryptRow($row, context: "connection:5");
94116
```
95117

96-
`encryptRow()` is idempotent (already-encrypted and `null` values are left alone), so it's safe on partial updates.
118+
`encryptRow()` is idempotent (already-encrypted and `null` values are left
119+
alone), so it's safe on partial updates.
97120

98-
Prefer a trait? `EncryptsFields` wires the same behavior into a datastore adapter or repository:
121+
Prefer a trait? `EncryptsFields` wires the same behavior into a datastore adapter
122+
or repository:
99123

100124
```php
101125
use PHPNomad\Encryption\Interfaces\EncryptionStrategy;
@@ -120,7 +144,8 @@ final class TokenRepository
120144

121145
## Key rotation
122146

123-
Keys are addressed by version. Keep every version still referenced by stored ciphertext; point the ring's current version at the newest key.
147+
Keys are addressed by version. Keep every version still referenced by stored
148+
ciphertext; point the ring's current version at the newest key.
124149

125150
```php
126151
// v1 was current when old values were sealed. Now add v2 and make it current.
@@ -135,25 +160,30 @@ $encryption->encrypt('x'); // sealed under v2
135160
$encryption->decrypt($oldValue); // still decrypts against v1
136161
```
137162

138-
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` (e.g. one `Base64EnvKeyProvider` per env var).
163+
To fully migrate, decrypt each stored value and re-encrypt it (the new
164+
`EncryptedValue` carries `keyVersion = 2`), then retire the old key once nothing
165+
references it. Multiple keys can also live behind separate providers via
166+
`KeyRing` (e.g. one `Base64EnvKeyProvider` per env var).
139167

140-
## Reading legacy `secretbox` data
168+
## Writing a cipher integration
141169

142-
If you're adopting this library over data previously encrypted with `sodium_crypto_secretbox`, enable the fallback so unmarked values are tried as AEAD first and then as secretbox:
170+
Implement `Interfaces\EncryptionStrategy` and return an `EncryptedValue`:
143171

144172
```php
145-
$encryption = new SodiumEncryptionStrategy($keys, allowLegacySecretboxFallback: true);
146-
```
147-
148-
New writes are always AEAD; old secretbox values keep decrypting until you re-encrypt them. A standalone `LegacySecretboxEncryptionStrategy` is also provided for read-only or explicit secretbox handling.
173+
use PHPNomad\Encryption\Interfaces\EncryptionStrategy;
174+
use PHPNomad\Encryption\ValueObjects\EncryptedValue;
149175

150-
## Security notes
176+
final class MyCipherStrategy implements EncryptionStrategy
177+
{
178+
public function encrypt(string $plaintext, string $context = ''): EncryptedValue { /* ... */ }
179+
public function decrypt(EncryptedValue $value, string $context = ''): string { /* ... */ }
180+
}
181+
```
151182

152-
- **XChaCha20-Poly1305** is used because its 24-byte nonce is large enough to pick at random per message without collision worries — no nonce counter to persist. Keys are 32 bytes.
153-
- Keys are wiped from memory with `sodium_memzero` after each operation.
154-
- Decryption failure (wrong key, wrong context, or tampering) always raises `DecryptionFailedException` — never a partial or forged plaintext.
155-
- Associated data is authenticated, **not** encrypted. Don't put secrets in the context.
156-
- This library encrypts values; it does **not** manage where your keys come from or how they're stored. Keep keys out of source control (environment variables, a secrets manager, or a KMS).
183+
The contract requires that decryption fail (throw `DecryptionFailedException`) on
184+
a wrong key, a mismatched `$context`, or tampered bytes, and that it decrypt
185+
against the key version recorded on the value. See `phpnomad/sodium-integration`
186+
for the reference implementation.
157187

158188
## API at a glance
159189

@@ -162,13 +192,13 @@ New writes are always AEAD; old secretbox values keep decrypting until you re-en
162192
| `Interfaces\EncryptionStrategy` | `encrypt(string, context): EncryptedValue` / `decrypt(EncryptedValue, context): string` |
163193
| `Interfaces\KeyProvider` | `getKey(version): string` / `currentVersion(): int` |
164194
| `ValueObjects\EncryptedValue` | ciphertext + nonce + keyVersion + cipher; `toArray`/`fromArray`, `toString`/`fromString` |
165-
| `Strategies\SodiumEncryptionStrategy` | XChaCha20-Poly1305 AEAD (default), optional secretbox fallback |
166-
| `Strategies\LegacySecretboxEncryptionStrategy` | read/write `sodium_crypto_secretbox` |
167195
| `Providers\ArrayKeyProvider` | in-memory versioned key ring |
168196
| `Providers\Base64EnvKeyProvider` | base64 key from env var / file |
169197
| `Providers\KeyRing` | compose per-version providers |
170198
| `Services\FieldEncrypter` | encrypt/decrypt marked array fields |
171199
| `Traits\EncryptsFields` | field encryption mixin for repositories/adapters |
200+
| `Exceptions\*` | `EncryptionException`, `DecryptionFailedException`, `KeyNotFoundException` |
201+
| *cipher strategy* | provided by an integration, e.g. `phpnomad/sodium-integration` |
172202

173203
## Testing
174204

@@ -177,6 +207,10 @@ composer install
177207
composer test
178208
```
179209

210+
The contract suite has no cipher dependency — it exercises the strategy contract
211+
against a small in-package reversible fake. The real libsodium cipher is tested
212+
in `phpnomad/sodium-integration`.
213+
180214
## License
181215

182216
MIT © Novatorius / Alex Standiford

composer.json

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
{
22
"name": "phpnomad/encryption",
3-
"description": "Interface-driven libsodium encryption primitives for PHP — AEAD (XChaCha20-Poly1305) authenticated encryption, versioned keys with rotation, and framework-agnostic field-level encryption.",
3+
"description": "encryption contracts + field-level encryption for PHPNomad (bring your own cipher via an integration)",
44
"type": "library",
55
"keywords": [
66
"encryption",
7-
"libsodium",
8-
"sodium",
9-
"aead",
10-
"xchacha20poly1305",
7+
"contracts",
118
"cryptography",
129
"field-encryption",
1310
"key-rotation",
@@ -23,8 +20,7 @@
2320
}
2421
],
2522
"require": {
26-
"php": ">=8.2",
27-
"ext-sodium": "*"
23+
"php": ">=8.2"
2824
},
2925
"require-dev": {
3026
"phpunit/phpunit": "^10.5 || ^11.0"

lib/Strategies/LegacySecretboxEncryptionStrategy.php

Lines changed: 0 additions & 70 deletions
This file was deleted.

0 commit comments

Comments
 (0)