NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Crypto for Laravel

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 checksum of a file of any size, read in chunks:
$checksum = Crypto::digest()->file($path);

// 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);
MethodReturns
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 bytes

withPepper() 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.

MethodReturns
raw($data)The raw-bytes digest.
hex($data)The lower-case hex digest.
withPepper($data, $pepper)The hex digest, an HMAC keyed with the pepper unless the pepper is null.
file($path, $pepper = null)The hex digest of a file, read in chunks.
stream($stream, $pepper = null)The hex digest of a stream from its current position, read in chunks.

File and stream checksums

file() and stream() digest input of any size in flat memory: they feed a hash context in fixed-size chunks and never load the whole input. Use them for upload checksums, file dedupe keys and ETags:

use Illuminate\Support\Facades\Storage;
use RoundlyConsulting\Crypto\Facades\Crypto;

$checksum = Crypto::digest()->file($request->file('csv')->getRealPath());   // sha-256 hex

// A Laravel disk (local, S3, …): hand over its read stream.
$stream = Storage::disk('imports')->readStream($path);
$checksum = Crypto::digest()->stream($stream);
fclose($stream);                                                           // still yours to close
  • Hex only. Both return the lower-case hex digest — the form a char(64) checksum column, an ETag or a dedupe key stores — so there is no raw variant. For raw bytes, hex-decode the result (Crypto::hexDecode()).
  • The same value as the string digests: for the same bytes, file() and stream() equal hex() without a pepper and withPepper() with one, for every HashAlgorithm.
  • The pepper follows the withPepper() rule: null (the default) gives the plain digest; any other string, even an empty one, keys an HMAC with it verbatim. The pepper is #[\SensitiveParameter]; a path or a stream is not, since neither carries the bytes into a trace.
  • file($path) opens anything fopen() reads — a local path or a stream-wrapper URL — and closes its own handle, even when it throws. For a Laravel disk, pass its readStream() to stream() instead.
  • stream($stream) reads from the stream’s current position to its end and never rewinds: a caller that already consumed a header gets the digest of the rest, and one that wants the whole stream rewinds first. It never closes the stream and leaves it at its end; a stream already at its end digests as empty input.

Every way the read can come up short throws Hash\UnreadableInputException (a CryptoException) instead of returning a digest of half the bytes: an empty path or one with a NUL byte, a missing file, a directory, a file that cannot be opened, a value that is not an open stream (a string, a closed resource, a stream context), a directory handle, a write-only stream (it would read as empty and digest to the hash of nothing) and a read that stops before the end:

use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Crypto\Hash\HashAlgorithm;
use RoundlyConsulting\Crypto\Hash\UnreadableInputException;

// Another algorithm and a pepper: the value withPepper() gives for the same bytes.
$keyed = Crypto::digest(HashAlgorithm::Sha512)->stream($stream, $pepper);

try {
    $checksum = Crypto::digest()->file($path);
} catch (UnreadableInputException $e) {
    // missing, a directory, unreadable or cut short — never a partial digest
}

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 default

derive(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

CaseValueStatus
HashAlgorithm::Sha1sha1Legacy — collision-broken, interop only; isLegacy() returns true.
HashAlgorithm::Sha256sha256Current (the default).
HashAlgorithm::Sha384sha384Current.
HashAlgorithm::Sha512sha512Current.

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 crypto

By 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.