italix/crypto

Signed expiring tokens, authenticated encryption and fixed-window rate limiting, with key rotation built in

Maintainers

Package info

github.com/italix-net/crypto

pkg:composer/italix/crypto

Transparency log

Statistics

Installs: 2

Dependents: 2

Suggesters: 1

Stars: 0

Open Issues: 0

2.0.0 2026-08-30 07:31 UTC

This package is not auto-updated.

Last update: 2026-08-31 06:19:03 UTC


README

PHP Version License

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.