libxa / secure
Encryption with key rotation, audit logging and threat detection for LibxaFrame.
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- libxa/framework: ^0.10.2 || ^0.11.0
Requires (Dev)
- phpunit/phpunit: ^11.0 || ^12.0
README
Encryption with key rotation, an audit trail, and threat detection for LibxaFrame.
composer require libxa/secure php libxa migrate php libxa secure:status
Requires LibxaFrame ^0.10.2 and PHP 8.3.
Encryption you can rotate
The framework's encrypter takes one key, which makes rotating it an
all-or-nothing event: the moment APP_KEY changes, everything already
encrypted with it is unreadable. In practice that means the key is never
rotated, which is the opposite of what having a key is for.
This keeps a list. Values carry the id of the key that wrote them.
$vault = app('secure.vault'); $payload = $vault->encrypt($card); // uses the current key $vault->decrypt($payload); // uses whichever key wrote it
Rotating is two steps, and they can be weeks apart:
php libxa secure:key --id=v2
SECURE_KEY_APP=base64:... # keep it: it still decrypts what it wrote SECURE_KEY_V2=base64:... # the new one SECURE_KEY_CURRENT=v2 # new values use it from now on
Then re-encrypt at your own pace, and drop the old key only when nothing references it:
if (! $vault->isCurrent($row->secret)) { $row->secret = $vault->rotate($row->secret); }
Removing a key too early is the one unrecoverable mistake here, so decrypting a value whose key has gone says exactly that:
This value was encrypted with key [v1], which is no longer configured.
AES-256-GCM, so a payload that has been altered is refused rather than decrypted into something plausible. The key id is authenticated too: pointing a payload at a different key fails the tag check instead of quietly decrypting under it.
An audit trail
Application logs answer "what happened". This answers "who did it, to which record, and what did it look like before": a different question, and the one asked months later by someone who is not a developer.
$audit = app('secure.audit'); $audit->record('invoice.sent', 'Invoice', $invoice->id, ['to' => $email], actorId: $user->id); $audit->recordChange('user.updated', 'User', $user->id, $before, $after, actorId: $user->id);
recordChange stores only the fields that differ. Whole rows make the trail
large and the diff invisible, and the question is what changed.
Anything whose field name looks sensitive is replaced before it is written,
recursively and case-insensitively, so a nested user.password inside a
request payload is caught too:
['email' => 'a@b.c', 'password' => 'hunter2'] // stored as {"email":"a@b.c","password":"[redacted]"}
Writes never throw. A trail that can take the request down turns a logging problem into an outage, and that failure appears under exactly the load where the trail matters most. Failures go to the application log instead.
Pruning is a command you schedule yourself:
php libxa secure:prune --days=365
Nothing prunes automatically. Deleting an audit trail on a schedule nobody set up is the thing an audit trail exists to make impossible.
Threat detection
Counts events per source against thresholds you set, in a sliding window.
$detector = app('secure.threat'); $verdict = $detector->record('login.failed', $request->ip()); if ($verdict->blocked) { logger()->warning($verdict->reason()); // "10.0.0.1 blocked: 10 occurrences of login.failed, threshold 10." }
Deliberately not clever. Detection nobody can explain produces blocks nobody can justify, and the first false positive locks out a real customer with no way to say why. Every decision reads back as a sentence.
Enforcement is a middleware, kept separate from detection so the rules live in one readable place:
$router->group(['middleware' => [ThreatMiddleware::class]], function ($router) { // 429 with Retry-After for a blocked source });
The window slides: activity that keeps happening keeps the count alive. A fixed window lets someone wait for the boundary and start again from zero.
The counters need a cache shared between processes. Without one they are
per process, so with four workers a threshold of ten is really forty.
secure:status says which you have.
Commands
secure:key |
Generate a key and print where to put it |
secure:status |
What is configured, including what is quietly wrong |
secure:prune |
Delete audit entries past their retention |
secure:status exists because every setting here can be wrong in a way that
produces no error: counters that are per process, an audit table nobody
migrated, one key doing the work of two.
Configuration
php libxa vendor:publish --tag=secure-config
Everything works unconfigured: encryption falls back to APP_KEY, so
composer require produces something that runs. A package that refuses to boot
without its own configuration is a package people uninstall.
Tests
composer test
40 tests. The ones worth knowing about: an old key still decrypts what it wrote, altered ciphertext is refused rather than returning rubbish, the sliding window cannot be gamed at the boundary, and a write failure in the audit logger does not escape.
Licence
MIT.