timefrontiers / php-data
PHP Data utilities - encryption, hashing, random generation
Requires
- php: >=8.5
- ext-json: *
- ext-openssl: *
Requires (Dev)
- php-parallel-lint/php-parallel-lint: ^1.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
README
Security-focused data primitives for authenticated encryption, password hashing, random generation, signing, and byte conversion.
Requirements
- PHP 8.5+
- ext-openssl
- ext-json
composer require timefrontiers/php-data
Authenticated encryption
Encryption writes a versioned AES-256-GCM envelope containing the algorithm,
key ID, random 96-bit nonce, authentication tag, and ciphertext. An optional
purpose is authenticated as additional data, so a value encrypted for one
field or protocol cannot be replayed in another.
use TimeFrontiers\Data\Encryption; use TimeFrontiers\Data\KeyRing; $keys = KeyRing::fromBase64('2026-08', [ '2026-08' => $_ENV['DATA_KEY_2026_08'], '2026-05' => $_ENV['DATA_KEY_2026_05'], // retained for reads ]); $encryption = new Encryption($keys); $envelope = $encryption->encrypt('alice@example.test', purpose: 'customer.email'); $result = $encryption->decryptResult($envelope, purpose: 'customer.email'); if ($result !== null) { $plaintext = $result->plaintext(); if ($result->needsReEncryption()) { $envelope = $encryption->encrypt($plaintext, purpose: 'customer.email'); } }
KeyRing accepts exact 32-byte raw keys, canonical base64 keys through
fromBase64(), or explicit file paths through fromFiles(). A key file must
contain one canonical base64-encoded 32-byte key. Generate a new base64 key with
Encryption::generateKey() and store it in a secret manager or a permissioned
file. A key ring is a privileged cryptographic capability: it can encrypt with
the active key and decrypt with known keys, but has no public raw-key accessor,
cannot write with retired keys, and never exports file-loaded key bytes. The
capability generates every GCM nonce internally; callers cannot select or reuse
one through the public encryption operation.
For process-wide use, configure exactly once during bootstrap:
Encryption::configure(KeyRing::fromFiles('primary', [ 'primary' => '/run/secrets/data-primary.key', ])); $envelope = Encryption::enc($data, purpose: 'session.payload'); $data = Encryption::dec($envelope, purpose: 'session.payload');
fromRawKey(), fromBase64Key(), and fromKeyFile() are explicit single-key
entry points. decrypt() returns null for malformed, unknown-key, wrong-
purpose, or tampered input. Do not treat decryption failure as empty plaintext.
Legacy v1.0 base64-ciphertext::base64-iv AES-CBC values remain read-only
during the v1.1 migration window. They are accepted only without a purpose and
decryptResult() marks them as legacy and needing re-encryption. New writes
never emit CBC. Validate legacy plaintext against the owning domain before
repair because CBC could not authenticate it. See UPGRADING.md
for the migration procedure. Historical keys of any v1.0-accepted decoded
length—including an explicitly inventoried empty key—belong only in KeyRing's
third, unkeyed legacy argument. That path reproduces v1.0 OpenSSL key
padding/truncation for reads and can never create a new envelope. Active and
retired envelope keys are never tried implicitly by the CBC reader: omitting
the third argument disables legacy CBC completely. If the same historical
32-byte key must read both formats, list it explicitly in both collections.
Remove all legacy keys before v1.2.0 or 2026-12-31, whichever comes first; the
legacy reader is removed in v1.2.0.
Signing
Signer writes a length-safe, versioned HMAC-SHA256 envelope and supports
purpose binding and key rotation. Signing keys must contain at least 32 raw
bytes.
use TimeFrontiers\Data\Signer; use TimeFrontiers\Data\SigningKeyRing; $signingKeys = SigningKeyRing::fromBase64('2026-08', [ '2026-08' => $_ENV['SIGNING_KEY_2026_08'], '2026-05' => $_ENV['SIGNING_KEY_2026_05'], ]); Signer::configure($signingKeys); // exactly once, at bootstrap $signed = Signer::sign('user_id=123', purpose: 'download-token'); $original = Signer::verify($signed, purpose: 'download-token'); // string|false $result = Signer::verifyResult($signed, purpose: 'download-token');
SignatureVerificationResult::needsResign() identifies legacy signatures and
envelopes produced with a non-active key. Old data--hex-hmac signatures are
verify-only and cannot be used with a purpose. Verification uses constant-time
signature comparison. Non-empty v1.0 signing keys shorter than 32 bytes may be
placed only in SigningKeyRing's third legacy argument; they can verify old
signatures but cannot sign versioned envelopes. Signing rings expose sign/verify
capabilities, never raw keys. Versioned signing keys are not implicitly legacy
verification keys; an empty third argument disables delimiter-token
verification, and a shared historical key must be listed in both collections.
Passwords
Argon2id is the default when the running PHP build provides it; otherwise the
unconfigured default falls back to bcrypt. Explicitly selecting an unavailable
Argon algorithm is rejected. Configuration is validated and freezes on the
first configure(), hash(), or needsRehash() call, so bootstrap order
cannot silently weaken a live process.
use TimeFrontiers\Data\Password; Password::configure(Password::ALGO_ARGON2ID, [ 'memory_cost' => 131072, 'time_cost' => 4, ]); $hash = Password::hash($password); if (Password::verify($password, $hash) && Password::needsRehash($hash)) { $hash = Password::hash($password); } $verification = Password::verifyAndRehash($password, $storedHash);
Unsafe bcrypt/Argon costs, unknown options, and unavailable algorithms are rejected before the process-wide policy freezes. Operational ranges are:
| Option | Minimum | Maximum |
|---|---|---|
bcrypt cost |
12 | 15 |
Argon2 memory_cost |
65536 KiB | 262144 KiB |
Argon2 time_cost |
4 | 10 |
Argon2 threads |
1 | 4 |
The maxima cap one hash at an intentionally bounded platform policy rather than
the algorithms' theoretical integer limits. The threads option is accepted
only when PASSWORD_ARGON2_PROVIDER is standard (libargon2); it is rejected
for the sodium provider, where PHP does not support it. Password parameters are
marked sensitive so PHP stack traces redact their values.
Random values
All randomness comes from random_bytes() or random_int(). Encoded methods
return the exact requested output length; length zero returns an empty string,
negative lengths and empty custom alphabets are rejected.
use TimeFrontiers\Data\Random; $raw = Random::bytes(32); $hex = Random::hex(64); $token = Random::base64(43); // URL-safe, exactly 43 characters $code = Random::numeric(6); $friendly = Random::alphanumeric(16, ambiguous: false); $uuid = Random::uuid(); $item = Random::pick(['red', 'green', 'blue']);
Byte conversion
Units are binary. KB, MB, GB, and TB remain deprecated compatibility aliases for
KiB, MiB, GiB, and TiB. Parsing consumes the entire input, uses exact
decimal arithmetic with half-up rounding, and rejects negative, non-finite,
malformed, or overflowing values.
use TimeFrontiers\Data\ByteConverter; ByteConverter::toBytes(1.5, 'MiB'); // 1572864 ByteConverter::parse('1.5 GiB'); // 1610612736 ByteConverter::fromBytes(10485760, 'MiB'); // 10.0 ByteConverter::format(1536000); // "1.46 MiB" ByteConverter::compare('1024 KiB', '1 MiB'); // 0
Compatibility notes
Encryption::setKeyFile()is deprecated but remains an explicit file-only adapter. The deprecatedgetKey()andkeyBackup()methods always throw.- String constructor and per-operation encryption key compatibility accepts canonical base64 only; values are never guessed to be file paths.
Signer::setKey()is deprecated; use an injected or configured key ring.- String manipulation, HTTP responses, and phone utilities live in
timefrontiers/php-core.
Quality gates
composer validate --strict composer audit composer check