HMAC, hashing & HKDF
Hash\Hmac signs and verifies keyed MACs — the building block for webhook signature checks. Secrets are passed per call and marked #[\SensitiveParameter]; the class holds no key of its own:
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;
// GitHub-style "sha256=…" header check:
$expected = 'sha256='.Crypto::hmac(HashAlgorithm::Sha256)->signHex($request->getContent(), $webhookSecret);
$ok = Crypto::constantTimeEquals($expected, $request->header('X-Hub-Signature-256', ''));
// Deterministic digest → an indexable lookup column:
$lookupKey = Crypto::digest()->hex($emailLowercased);
// A per-purpose subkey from one root secret (HKDF):
$fingerprintKey = Crypto::hkdf()->derive($rootKey, 32, 'app/fingerprint');Or call the classes the facade fronts directly:
use RoundlyConsulting\Crypto\Hash\Hmac;
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;
$hmac = new Hmac(HashAlgorithm::Sha256);
// GitHub-style "sha256=…" header check:
$expected = 'sha256='.$hmac->signHex($request->getContent(), $webhookSecret);
$ok = hash_equals($expected, $request->header('X-Hub-Signature-256', ''));
// Or verify raw signatures directly (constant-time):
$ok = $hmac->verify($payload, $signature, $webhookSecret);| Method | Returns |
|---|---|
sign($message, $key) | The raw-bytes HMAC. |
signHex($message, $key) | The lower-case hex HMAC. |
verify($message, $signature, $key) | bool — a constant-time check of a raw-bytes signature. |
Deterministic digests
Hash\Digest produces the same output for the same input — which is what makes an indexed equality lookup on hashed data possible, such as a blind index over an email or a token:
use RoundlyConsulting\Crypto\Hash\Digest;
$digest = new Digest; // SHA-256
$lookupKey = $digest->hex($emailLowercased); // deterministic → indexable column
$peppered = $digest->withPepper($token, config('app.pepper')); // HMAC when a pepper is set
$raw = $digest->raw($data); // raw byteswithPepper() makes the plain-versus-keyed choice explicit: only null selects the plain digest. Any non-null pepper — even whitespace or an empty string — is used verbatim as the HMAC key, so a blank value can never silently downgrade a keyed digest. The data passed to raw(), hex() and withPepper() — tokens, passwords, personal data — is #[\SensitiveParameter] too.
HKDF — per-purpose subkeys
Hash\Hkdf implements RFC 5869 extract-then-expand key derivation: one root secret, independent subkeys per purpose, so the key that seals values never also MACs a fingerprint:
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;
$fingerprintKey = Crypto::hkdf()->derive($rootKey, 32, 'app/fingerprint'); // no salt
$sealKey = Crypto::hkdf(HashAlgorithm::Sha512)->derive($rootKey, 64, 'app/seal', 'app.hkdf/1');Or call the class the facade fronts directly:
use RoundlyConsulting\Crypto\Hash\Hkdf;
$subkey = (new Hkdf)->derive($rootKey, 32, 'app/fingerprint'); // SHA-256 by defaultderive(string $ikm, int $length, string $info = '', string $salt = '') returns raw bytes:
- $ikm, the input keying material, is #[\SensitiveParameter] and must not be empty.
- $length is required: 1 to 255 × the hash length (8160 bytes for SHA-256). PHP’s hash_hkdf() reads 0 as “one hash length”; here 0 is refused, so a derived key’s size is never implicit.
- $info names the purpose. Separate its fields with a byte no field can contain, such as NUL.
- $salt is a non-secret value (RFC 5869 §3.1); empty means “not provided” — HashLen zero bytes.
- The output is byte-identical to hash_hkdf(), pinned by the RFC 5869 Appendix A vectors (SHA-256 and SHA-1). SHA-1 stays available for interop only (HashAlgorithm::isLegacy()).
- An empty $ikm or an out-of-range $length throws Hash\InvalidHkdfParameterException — a programming error, never a property of the data.
Constant-time comparison
ConstantTime::equals() wraps hash_equals so every secret, tag and MAC comparison routes through one place and never leaks, through timing, how much of a value matched. Both inputs are #[\SensitiveParameter], as in PHP’s own hash_equals(): the expected value is the secret, MAC, token or OTP itself, so neither ever reaches a stack trace:
use RoundlyConsulting\Crypto\Hash\ConstantTime;
// $known is the trusted value, $user the attacker-influenced one:
$ok = ConstantTime::equals($knownExpected, $userSupplied);Hash algorithms
| Case | Value | Status |
|---|---|---|
HashAlgorithm::Sha1 | sha1 | Legacy — collision-broken, interop only; isLegacy() returns true. |
HashAlgorithm::Sha256 | sha256 | Current (the default). |
HashAlgorithm::Sha384 | sha384 | Current. |
HashAlgorithm::Sha512 | sha512 | Current. |
OTP is separate: RFC 6238 mandates SHA-1 and authenticator apps default to it, so Otp\OtpAlgorithm keeps SHA-1 first-class.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.