libxa/secure

Encryption with key rotation, audit logging and threat detection for LibxaFrame.

Maintainers

Package info

github.com/libxa-framework/secure

pkg:composer/libxa/secure

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-08-12 20:59 UTC

This package is auto-updated.

Last update: 2026-08-12 21:00:14 UTC


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.