SDK Toolbox docs
Integrity and webhooks
PHP ^8.316 pages
Docs for v0.0.1, from GitHub.
Pages in these docs
Content-Digest
Digests adds a Content-Digest (RFC 9530) to every request with a body, and checks the digest on every response that has one:
$connector->withMiddleware(new Digests); // sha-256$connector->withMiddleware(new Digests(DigestAlgorithm::Sha512, verifyResponses: false));- A response whose body doesn’t match throws
DigestMismatchException. It was changed or cut short on the way. - Only sha-256 and sha-512, the algorithms RFC 9530 lists as Active. The deprecated ones (md5, sha, crc32c…) are ignored when they appear.
- A response with a
Content-Encodingother than identity isn’t checked. An HTTP client that decompressed it has handed over different bytes from the ones the digest was made over.
ContentDigest::header($content) and ContentDigest::verify($message) work on any PSR-7 message, outside the pipeline.
HTTP Message Signatures
RFC 9421 signs chosen parts of a request (the method, path, authority, chosen headers) so the receiver can check who sent it and that those parts weren’t changed.
Signing requests
$connector->withMiddleware( new Digests, new SignRequests( SigningKey::ed25519('client-42', $privateKeyPem), components: ['@method', '@authority', '@path', '@query'], tag: 'acme-v1', expiresIn: 300, ),);- Keys:
SigningKey::hmac($id, $secret)(hmac-sha256) orSigningKey::ed25519($id, $key). The Ed25519 key can be a 32-byte seed, a 64-byte secret or a PKCS #8 PEM. - Parameters: it adds
createdandkeyid, plusexpiresandtagwhen given. - The body: when
Digestsruns first,content-digestis covered too, so the body is signed (passcoverDigest: falsenot to). - Correctness: the toolbox’s tests reproduce RFC 9421 Appendix B’s own signatures byte for byte.
Authentication runs after middleware, so Authorization can’t be covered.
Verifying signatures
RFC 9421 leaves the important choices to the application: what a signature must cover, how old it may be, which keys count. MessageVerifier makes you state them:
$verifier = new MessageVerifier( keys: fn (string $keyId): ?VerificationKey => $keyId === 'partner-1' ? VerificationKey::ed25519('partner-1', $publicKeyPem) : null, required: ['@method', '@path', 'content-digest'], maxAge: 300, tag: 'acme-v1',);
$signature = $verifier->verify($request); // or ->verify($request, $response) for a signed response$signature->keyId;It throws InvalidSignatureException for any of these:
- an unknown key;
- a missing required component;
- a
createdolder thanmaxAge, or more than a minute in the future; - an
expiresthat has passed; - the wrong tag;
- an
algthat isn’t the key’s own algorithm (§3.2, step 6.5); - a signature that simply doesn’t match.
Supported components are header fields and the derived components @method, @target-uri, @authority, @scheme, @request-target, @path, @query and, on a response, @status. Component parameters (;sf, ;key, ;bs, ;req, @query-param;name=) aren’t supported yet. A signature that uses them is refused, never checked against the wrong base. RSA and ECDSA keys aren’t supported yet either.
Webhooks
Every verifier takes the PSR-7 request as it arrived (your framework’s ServerRequest is fine), before anything parses or re-encodes the body, and throws InvalidSignatureException.
Standard Webhooks
$verifier = new StandardWebhooks('whsec_...'); // or 'whpk_...' for the Ed25519 scheme
$id = $verifier->check($request); // the webhook-id, to drop repeats- What’s signed:
webhook-id.webhook-timestamp.body, with HMAC-SHA256 (v1) or Ed25519 (v1a). - Freshness: the timestamp has to be within
toleranceseconds of now (300 by default), either way. - Key rotation:
webhook-signaturecan hold several signatures, and any one valid one is enough. - Replays: keep the
webhook-idscheck()returns, and drop repeats. - Testing your own handlers:
StandardWebhooks::sign($secret, $id, $timestamp, $body)makes the header.
The tests use the official Standard Webhooks PHP library’s own test vector.
An HMAC of the body
The scheme most APIs used before Standard Webhooks. GitHub’s, for example:
new HmacWebhook($secret, header: 'X-Hub-Signature-256', prefix: 'sha256=')algorithm: is sha256 or sha512, and encoding: is hex or base64. There’s no timestamp in this scheme, so it can’t stop a replay on its own; prefer Standard Webhooks where the API offers it. The tests use GitHub’s documented example.
RFC 9421 signed webhooks
new SignedWebhook($messageVerifier)This requires the signature to cover content-digest, and checks the digest against the body (RFC 9530 §6.3 pairs the two for exactly this). Without both, the body would be unsigned.