Libxa Secure 0.1.0: encryption you can actually rotate
A single encryption key makes rotation an all-or-nothing event, which is why nobody ever rotates. Secure keeps a list, so an old key goes on decrypting what it wrote while new values use the current one. Plus an audit trail and threat detection.
Libxa Secure is out. Three things: encryption you can rotate, an audit trail, and threat detection.
composer require libxa/secure
php libxa migrate
php libxa secure:status
The key problem
The framework's encrypter takes one key. That sounds fine until you want to change it.
The moment APP_KEY changes, every value already encrypted with it is
unreadable. Not "harder to read": gone. So in practice nobody rotates, and the
key that was generated on the day the project started is still there years
later, having been copied into a CI secret, a staging .env, three developer
machines and at least one Slack message.
A key you cannot rotate is a key you cannot recover from leaking, which is most of what having a key is for.
Keeping a list instead
Secure keeps several keys alive at once. Every encrypted value carries the id of the key that wrote it:
{"v":1,"k":"v1","iv":"...","ct":"...","tag":"..."}
New values use the current key. Old values are decrypted with whichever key wrote them, for as long as that key is still listed.
Rotation becomes two steps that 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 whatever pace suits, and drop the old key only once nothing references it:
if (! $vault->isCurrent($row->secret)) {
$row->secret = $vault->rotate($row->secret);
}
Removing a key too early is the one unrecoverable mistake available here, so the error says exactly that rather than something generic:
This value was encrypted with key [v1], which is no longer configured.
The alternative message, "decryption failed", would send somebody hunting a corrupted database for an afternoon.
The key id is authenticated
AES-256-GCM, so a payload that has been altered is refused rather than decrypted into something plausible. That part is standard.
Less obvious: the key id travels outside the ciphertext, because you have to read it before you can decrypt. Left unprotected, an attacker could edit it and choose which key their payload is decrypted under.
So it is passed as additional authenticated data. It is not encrypted, but it is covered by the tag, and changing it fails the check:
$parts['k'] = 'v2'; // was v1
$vault->decrypt(base64_encode(json_encode($parts)));
// RuntimeException: failed authentication and may have been tampered with
There is a test for exactly that.
The audit trail
Application logs answer "what happened". They are terrible at "who changed this customer's email address in March, and what was it before", which is the question that actually gets asked, usually by somebody who is not a developer.
$audit->recordChange('user.updated', 'User', $user->id, $before, $after, actorId: $actor->id);
Only the fields that differ are stored. Whole rows make the trail large and the diff invisible, and the diff is the point.
Anything whose field name looks sensitive is replaced before it is written, recursively and case-insensitively, so a password nested three levels into a request payload is caught as readily as a top-level one:
{"email":"a@b.c","password":"[redacted]"}
That is matched on the name rather than the value, deliberately. Guessing which
values look like secrets is a losing game; the field is called password for
a reason.
It cannot take a request down
An audit trail that throws turns a logging problem into an outage, and it does so under exactly the load where the trail matters most.
So writes are wrapped and failures go to the application log instead. Worth
mentioning because the first version of that guarantee was broken, and the
tests caught it: logger() returns null when there is no application, so
logger()->error(...) threw from inside the catch block that was supposed
to swallow everything. The whole guarantee died on the one line meant to
implement it.
Threat detection, deliberately not clever
Anomaly 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.
So this counts things, against thresholds you set, in a sliding window:
$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."
}
Every decision reads back as a sentence. That matters when somebody escalates a block and asks why it happened.
The window slides rather than resetting on a boundary, because a fixed window lets an attacker pace themselves around the edge forever. And the defaults are forgiving: someone who has genuinely forgotten a password fails five times in a row, and blocking them is a support ticket rather than a security win.
Enforcement is a separate middleware. Detection decides nothing; the application records events, the middleware acts on the result. Keeping them apart means the rules live in one readable place rather than being scattered through whatever code happened to notice something.
One thing to get right
The counters need a cache shared between processes. Without one they count per process, so on a machine running four workers a threshold of ten is really forty.
That is invisible: nothing errors, the limit simply is not the limit. So
secure:status says which you have, along with the other settings that can be
quietly wrong.
Requires 0.10.2
Building this found a framework bug worth its own article: a package could not ship a migration at all. Secure's audit table simply never appeared, and nothing anywhere said why.