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.
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.
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():
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:
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.
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?
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:
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:
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:
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:
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:
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:
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#
- Authentication — where password hashes are actually checked
- Sessions & Cookies — the other place secrets get written down
- pop-crypt README — the full API surface