timefrontiers/php-data

PHP Data utilities - encryption, hashing, random generation

Maintainers

Package info

github.com/timefrontiers/php-data

pkg:composer/timefrontiers/php-data

Transparency log

Statistics

Installs: 54

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-08-20 17:40 UTC

This package is auto-updated.

Last update: 2026-08-20 17:41:50 UTC


README

Security-focused data primitives for authenticated encryption, password hashing, random generation, signing, and byte conversion.

PHP Version License

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 deprecated getKey() and keyBackup() 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

License

MIT License