Pop PHP
The Toolkit

Hashing & Encryption

Two different jobs live in pop-crypt. Hashing is one-way: you store a password hash and later check a guess against it. Encryption is two-way: you protect a value you need to read back. Passwords always take the first path; card numbers, API tokens and anything you hand back to a service take the second.

BASH
composer require popphp/pop-crypt

Hashing Passwords#

A hasher wraps PHP's password_hash() with the cost parameters bound to the object, so the call site stays a single make() no matter how you have tuned the algorithm.

PHP
use Pop\Crypt\Hashing\BcryptHasher;

$hasher = BcryptHasher::create();

$hashedValue = $hasher->make('password');
// $2y$12$bRm6Tbo57ZpPeXXYjwkpb.K0ysOJaROnzTchKgSenqP6oSkk.CEwy

That's the string you store. It's 60 characters for bcrypt, self-describing, and carries its own salt — you never generate or store a salt yourself.

Three algorithms ship, one class each: BcryptHasher, Argon2IHasher and Argon2IdHasher. Bcrypt takes a cost; the two Argon2 classes take memory_cost, time_cost and threads. Each accepts them as constructor arguments, or as an options array through create():

PHP
use Pop\Crypt\Hashing\Argon2IdHasher;
use Pop\Crypt\Hashing\BcryptHasher;

$bcrypt = new BcryptHasher(13);

$argon = Argon2IdHasher::create([
    'memory_cost' => 131072,
    'time_cost'   => 4,
    'threads'     => 1,
]);

The defaults are PHP's own — cost 12 for bcrypt, PASSWORD_ARGON2_DEFAULT_* for Argon2. Raise the cost until a single make() takes as long as you are willing to make a login wait, then leave it.

Hasher::create() builds whichever of the three matches a standard PASSWORD_* constant, which is what you want when the algorithm comes from config rather than from the code:

PHP
use Pop\Crypt\Hashing\Hasher;

$hasher = Hasher::create(PASSWORD_ARGON2ID, [
    'memory_cost' => 131072,
    'time_cost'   => 4,
]);

Anything other than PASSWORD_BCRYPT, PASSWORD_ARGON2I or PASSWORD_ARGON2ID throws Pop\Crypt\Hashing\Exception with "Error: Invalid hashing algorithm." — including a plain string like 'argon2id', which is not the same value as the constant.

make() and verify() take values up to 4096 bytes, which is well past any password worth hashing.

Verifying and Rehashing#

At login you have the plain-text guess and the stored hash. verify() compares them in constant time and returns a boolean — there's no "decrypt the stored hash and compare" step, because there's no decrypting a hash.

PHP
use Pop\Crypt\Hashing\BcryptHasher;

$hasher = new BcryptHasher();

$hashedValue = $hasher->make('secret');

$hasher->verify('secret', $hashedValue); // true
$hasher->verify('wrong', $hashedValue);  // false

A stored value that isn't a hash — a truncated column, a legacy MD5 string — returns false rather than throwing, so it reads as a failed login. getInfo() reports algoName as unknown for anything PHP doesn't recognize, if you need to tell the two apart.

The hasher a hash was made with does not have to be the hasher that verifies it. password_verify() reads the algorithm and parameters out of the hash string itself, so an Argon2IdHasher will verify a bcrypt hash. That is what makes migrating between algorithms a background job rather than a flag day.

requiresRehash() is the other half of that migration. It answers one question: was this hash made with the parameters this hasher is configured for right now?

PHP
use Pop\Crypt\Hashing\BcryptHasher;

$hasher = new BcryptHasher(12);

if ($hasher->verify($password, $user->password)) {
    if ($hasher->requiresRehash($user->password)) {
        $user->password = $hasher->make($password);
        $user->save();
    }
}

Raising the cost from 10 to 12, or switching the configured hasher from bcrypt to Argon2id, both make requiresRehash() return true for every hash written under the old settings. The check has to sit inside the successful-verify branch: that's the one moment you hold the plain-text password, and so the only moment a rehash is possible.

requiresRehash() reads the hasher's current configuration; needsRehash() takes an explicit algorithm and options array for when you want to test against a different one.

Encryption#

Two encrypters implement the same interface. Encrypter runs on openssl and supports aes-128-cbc, aes-256-cbc, aes-128-gcm and aes-256-gcm. SodiumEncrypter runs on sodium and supports one cipher, XChaCha20-Poly1305, so there's no cipher argument in its API. Both are authenticated, so an altered value fails loudly rather than decrypting to garbage.

create() generates a matching key and constructs the object in one step, which is the right shape for a throwaway or a test:

PHP
use Pop\Crypt\Encryption\Encrypter;
use Pop\Crypt\Encryption\SodiumEncrypter;

$aes    = Encrypter::create('aes-256-gcm');
$sodium = SodiumEncrypter::create();

$encrypted = $aes->encrypt('SENSITIVE_DATA');
$aes->decrypt($encrypted); // 'SENSITIVE_DATA'

encrypt() returns a base-64 string wrapping a JSON envelope of the IV, the ciphertext, and either a MAC (CBC) or an authentication tag (GCM and Sodium). It's safe in a TEXT column, and carries a few hundred bytes of overhead even for a short value.

A key generated inside create() dies with the process, so a real application generates one, stores it, and passes it in. That's where the one piece of ceremony lives:

PHP
use Pop\Crypt\Encryption\Encrypter;

$key = Encrypter::generateKey('aes-256-cbc', false);
// 'zSLOwbK67/T2ZJWS7jJdzA+Lh41dhmyGnpZfkWQAXEM='

$encrypter = new Encrypter($key, 'aes-256-cbc', false);

Every key method takes a $raw flag — false for base-64, true for raw bytes — so pass the one that matches the key you hold.

load() skips the wiring by reading APP_CIPHER_METHOD, APP_KEY and an optional comma-separated APP_PREVIOUS_KEYS out of $_ENV, treating them as base-64 by default:

PHP
use Pop\Crypt\Encryption\Encrypter;

$encrypter = Encrypter::load();

A missing APP_KEY or APP_CIPHER_METHOD throws Pop\Crypt\Encryption\Exception with "Error: The encryption properties could not be loaded." from load() itself, so a bad environment fails at bootstrap rather than at the first encrypted field.

setPreviousKeys() rotates a key without re-encrypting everything at once. On decrypt the current key is tried first, then each previous key in turn:

PHP
use Pop\Crypt\Encryption\Encrypter;

$encrypter = new Encrypter($currentKey, 'aes-256-cbc', false);
$encrypter->setPreviousKeys([$oldKey], false);

$encrypter->decrypt($valueEncryptedWithTheOldKey);

New values are always written with the current key, so rows re-encrypt themselves as they are touched. Drop the old key from the list once nothing decrypts under it.

Catch Pop\Crypt\Encryption\Exception rather than \Exception so decryption failures stay separable. A wrong key and a tampered payload both report "Error: Invalid MAC value." — authentication is checked before decryption, so the two are deliberately indistinguishable — and a string that is not a valid envelope reports "Error: The payload is not valid data."

aes-128-cbc and aes-256-cbc derive their encryption and MAC keys from your master key via HKDF-SHA256, so re-encrypt data written by an earlier version.

Signatures and Verifiers#

Pop\Crypt\Signature\Verifier answers a different question from the other two: not "can I read this" but "did the party I expect produce this, and has nobody touched it since". Webhooks are the everyday case — Stripe, GitHub and most other senders sign the request body and put the signature in a header.

It's a verifier, not a signer. Three static methods, no sign() — signing is the sender's job.

HMAC verification takes a shared secret. Both sides hold the same string:

PHP
use Pop\Crypt\Signature\Verifier;

$data      = 'The quick brown fox';
$secret    = 'my-secret-key';
$signature = hash_hmac('sha256', $data, $secret, true);

Verifier::hmac($data, $signature, $secret, 'sha256'); // true

The comparison runs through hash_equals(), so it's constant-time and a byte-by-byte timing attack does not recover the expected value.

Verifier::hmac() takes the signature as raw bytes, which is what hash_hmac()'s fourth argument of true produces.

RSA and ECDSA verification take the sender's public key instead of a shared secret, which is what you want when the sender must not be able to be impersonated by anyone holding the verification material:

PHP
use Pop\Crypt\Signature\Verifier;

$data      = 'The quick brown fox';
$publicKey = file_get_contents('/path/to/public.pem');
$signature = file_get_contents('/path/to/signature.bin');

try {
    if (Verifier::rsa($data, $signature, $publicKey, 'sha256')) {
        // the signature is genuine
    }
} catch (Pop\Crypt\Exception $e) {
    // the key material could not be used at all
}

Verifier::ec() is the same call for an EC public key. The two names document intent rather than enforcing anything: both delegate to openssl_verify(), which reads the key type out of the PEM, so rsa() will verify an ECDSA signature against an EC key and vice versa. Call the one that names what you expect.

false and an exception mean different things. false is a signature that didn't verify — wrong data, wrong key, an edited payload. A Pop\Crypt\Exception means the key material itself couldn't be parsed, and carries the OpenSSL error text. The first is a rejected request; the second is a configuration bug.

Key derivation, cipher-by-cipher payload layout and the full option surface on each hasher go past what this page covers — see the pop-crypt README.

See Also#