italix / crypto
Signed expiring tokens, authenticated encryption and fixed-window rate limiting, with key rotation built in
Requires
- php: >=7.4
- ext-hash: *
- ext-json: *
- italix/contracts: ^2.0
Requires (Dev)
- italix/testing: ^2.0
Suggests
- ext-pdo: Required by PdoStore, the only limiter store that survives a request
- ext-sodium: Required by Cipher; bundled with PHP >= 7.2
This package is not auto-updated.
Last update: 2026-08-31 06:19:03 UTC
README
Signed expiring tokens, authenticated encryption, and rate limiting. Key rotation is built in rather than bolted on, because a key you cannot replace is a key you will not replace.
Zero Composer dependencies: ext-hash and ext-json are always present, ext-sodium ships with
PHP ≥ 7.2 and is only needed for Cipher.
php src/Libs/Italix/Crypto/tests/SignerTest.php php src/Libs/Italix/Crypto/tests/LimiterTest.php
Setting up
php bin/ix crypto:key # prints APP_KEY=base64:… — paste into .env php bin/ix crypto:install # creates the ix_kv table the limiter counts in
The limiter needs no key. Signer and Cipher do, and the DI entries are lazy, so an application
that only rate-limits never has to set one.
Rate limiting
$verdict = $this->limiter->hit('login:' . mb_strtolower($email), 5, '15 minutes'); if ($verdict->is_exceeded()) { return $this->show('pages/login', [ 'error' => 'rate_limited', 'retry_after_n' => $verdict->retry_after_n(), ]); }
Count before you check the password. A wrong guess must cost an attempt whether or not the account exists, otherwise the limiter itself becomes an oracle for which addresses are registered.
Reset after a successful login, or a user who mistypes four times and then succeeds is one typo from a lockout tomorrow.
Use two keys. They defend against different attacks and neither substitutes for the other:
| Key | Stops | Costs |
|---|---|---|
login:{email} |
one account being guessed | nothing — an attacker just moves to the next account |
login-ip:{ip} |
one host working through many accounts | everyone behind one NAT shares the budget |
A typical login applies both: 5 per account and 20 per IP, in a 15-minute window.
peek() reads the counter without incrementing — for showing the state on a GET without the page
view itself counting.
The trade-off, stated up front
The window is fixed, not sliding: it opens at the first hit and lasts the full duration. Five
attempts at 14:59 and five more at 15:01 are ten in two minutes against a limit of five per fifteen.
That is asserted in the test suite so nobody discovers it by surprise. For making credential
stuffing expensive it does not change the economics; if it ever matters, KeyValueStore is where a
token bucket goes.
Keys are hashed before storage — the store is a table that outlives the request and may be dumped, and an e-mail address has no business being in it.
Signed tokens
$token_c = $signer->sign(['user_id' => 12], 'password_reset', '+2 hours'); $claims = $signer->verify($token_c, 'password_reset'); if (!$claims->is_valid()) { // error_code(): malformed | unknown_key | tampered | expired | wrong_purpose } $user_id = (int) $claims->get('user_id');
The purpose is not decoration. Without it, a token minted to confirm an e-mail address can be
replayed to reset a password. sign() refuses an empty purpose.
The payload is readable, and that is expected. Signing proves nobody changed it; it does not hide it. Put nothing in a token you would not put in the URL — because it is in the URL.
Verification order is signature → expiry → purpose, and it matters: deciding whether a token has
expired by reading an unverified payload is trusting data the attacker wrote. A token that is both
expired and tampered reports tampered.
When not to use this
A signed token is stateless: nothing is stored, and nothing can revoke it before it expires. For a password reset that is usually the wrong shape — the link should die the moment it is used, which needs a row.
The usual bin2hex(random_bytes(32)) tokens, stored against a row and cleared on use,
are the right design for that and should stay. This is for the cases with nothing to revoke: an
unsubscribe link, a signed download URL, state carried through a third-party redirect.
Encryption
$stored = $cipher->encrypt($iban); $iban = $cipher->decrypt($stored); // null when it cannot be authenticated
libsodium crypto_secretbox — XSalsa20 plus Poly1305, one call, no mode or IV to choose wrong. Two
encryptions of the same value differ. decrypt() returns the same null for every failure: a
caller able to distinguish "wrong key" from "corrupt" is an oracle, and there is nothing different
to do anyway.
Encrypted at rest protects against a stolen database dump and nothing else — the key is in
.env on the same machine. Worth doing for an IBAN; not a reason to keep data that could be
discarded. Never for passwords: those are password_hash(), and reversibility is the wrong
property.
Key rotation
APP_KEY=base64:new… # signs and encrypts APP_KEY_PREVIOUS=base64:old… # still verifies and decrypts
Sign with the primary, verify against all. Keep the old key until everything signed with it has
expired, then delete the line — Claims::key_id() tells you which key verified a token, so a log
shows when the old one stops being used. For encrypted columns, needs_rotation() identifies values
still on the old key so they can be re-encrypted in a background pass.
Dropping the old key genuinely invalidates its tokens; there is a test asserting that, because a rotation that leaves the old key working has not rotated anything.
Deliberately not
- No sliding window or token bucket — see the trade-off above.
- No key management service, no envelope encryption, no CAPTCHA.
- No password hashing.
- No trust in
X-Forwarded-For. A limiter keyed on a header the caller controls is not a limiter; behind a real proxy that is deployment configuration.