diff --git a/CHANGELOG.md b/CHANGELOG.md index b95e1b3..9c51992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# Version 3.2 + +## Changes + +* **`Mac`**, message authentication with a shared key: HMAC-SHA256, returned as URL-safe Base64. + Until now the only thing a `SharedKey` could do was encrypt. Every MAC is made under a required + **context**, and the MAC key is derived from the shared key with HKDF-SHA256 under that context, + so one key can serve Crypt and any number of MAC purposes without one being able to forge for + another. +* **`KeyRing`**, a map from key ID to `SharedKey` for rotation, when old and new clients call the + same server for a while. `KeyRing::fromPairs()` reads pairs straight from configuration and + **skips a slot that is not filled** rather than holding an empty key: a MAC under an empty key can + be forged by anyone, which is an easy mistake to make with an unused "previous key" variable. +* **Request signing**: `Request\RequestSigner` and `Request\RequestVerifier` authenticate a whole + HTTP request — method, path, timestamp and body — without ever sending the secret, which suits a + credential both sides hold (a secret built into an app, or shared between two services) where a + SplitToken, being a bearer token, does not. They take and return plain strings, so there is no + framework dependency and no fixed header names. The verifier reports why it refused through the + `RequestVerificationFailure` enum, meant for the log, never for the response. The acceptance + window is 300 seconds by default; there is no nonce, which the README states plainly. +* **Test vectors** for clients in other languages, computed outside PHP: + `tests/fixtures/request-signing-vectors.json`. The signed string and the key derivation are + specified in the README. + +Nothing existing changes: this release only adds classes. + # Version 3.1 ## Security diff --git a/CLAUDE.md b/CLAUDE.md index 89fe683..53987d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -Iridium is a security library for PHP providing authenticated encryption, password hashing, split token authentication, and URL-safe Base64 encoding. It is a mature, production library (v3.0) with zero runtime dependencies. Package name: `oire/iridium`. +Iridium is a security library for PHP providing authenticated encryption, password hashing, split token authentication, message and HTTP request authentication (MAC), and URL-safe Base64 encoding. It is a mature, production library (v3.2) with zero runtime dependencies. Package name: `oire/iridium`. ## Quick Reference @@ -40,12 +40,20 @@ Always run all three checks before committing. src/ Base64.php # URL-safe Base64 encoding/decoding Crypt.php # AES-256-GCM authenticated encryption (with legacy v1 AES-256-CTR + HMAC-SHA384 support) + Mac.php # HMAC-SHA256 under an HKDF-derived, per-context key Password.php # Password hashing (Argon2id/Bcrypt wrapper) SplitToken.php # Split token pattern for secure token auth Exception/ # Exception hierarchy (all extend IridiumException) Key/ SharedKey.php # 32-byte shared encryption key wrapper DerivedKeys.php # Derived encryption + authentication keys via HKDF + KeyRing.php # Key ID -> SharedKey for rotation; an unfilled slot does not exist + Request/ + CanonicalRequest.php # The five-line string a request signature covers + RequestSigner.php # Client side: method + path + body -> SignedRequest + RequestVerifier.php # Server side: returns null or a RequestVerificationFailure + RequestVerificationFailure.php # Enum of refusal reasons, for the log only + SignedRequest.php # Readonly value object: keyId, timestamp, signature Storage/ TokenStorageInterface.php # Interface for token persistence backends ListableTokenStorageInterface.php # Optional extension: listing, usage tracking, cutoff sweeps @@ -54,6 +62,8 @@ src/ DoctrineDbalTokenStorage.php # Doctrine DBAL implementation (dbal is a dev/suggest dep) tests/ *Test.php # One test class per source module + RequestSigningVectors.php # Typed loader of the golden vectors + fixtures/request-signing-vectors.json # Byte-exact vectors, computed outside PHP, for other-language clients ``` ## Coding Conventions @@ -105,3 +115,6 @@ This is a cryptographic library. When making changes: default outcome was that a revoked token authenticated. - Key material is zeroed via `sodium_memzero()` in destructors. Do not make key properties `readonly`. - Do not introduce timing side channels. +- **`Mac` never uses a `SharedKey` directly.** The MAC key is HKDF-SHA256(raw key, empty salt, 32 bytes, info `Iridium|Mac|V1|` + context), and the context is required. Both the info prefix and the canonical request string (`CanonicalRequest`) are a **wire contract with clients in other languages**, pinned by `tests/fixtures/request-signing-vectors.json`: changing either, or a vector, breaks every deployed client. A new scheme gets a new prefix (`V2`), never an edit in place. Regenerate vectors outside PHP, never from Iridium's own output. +- `KeyRing::fromPairs()` skips an unfilled slot. Never turn that into an empty key on the ring: a MAC under the empty key is forgeable by anyone. +- `RequestVerifier` checks the signature **before** the timestamp's age, so `StaleTimestamp` only ever describes a correctly signed request. Keep that order. diff --git a/README.md b/README.md index 2dbc2eb..2f32ebe 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ [![Psalm coverage](https://shepherd.dev/github/Oire/Iridium-php/coverage.svg?)](https://shepherd.dev/github/Oire/Iridium-php) [![Psalm level](https://shepherd.dev/github/Oire/Iridium-php/level.svg?)](https://psalm.dev/) -Welcome to Iridium, a security library for encrypting data, hashing passwords and managing secure tokens! -This library consists of several classes, or modules, and can be used for hashing and verifying passwords, encrypting and decrypting data, as well as for managing secure tokens suitable for authentication cookies, password reset, API access and various other tasks. +Welcome to Iridium, a security library for encrypting data, hashing passwords, managing secure tokens and signing API requests! +This library consists of several classes, or modules, and can be used for hashing and verifying passwords, encrypting and decrypting data, for managing secure tokens suitable for authentication cookies, password reset, API access and various other tasks, as well as for authenticating messages and whole HTTP requests with a shared key. ## Requirements @@ -458,6 +458,118 @@ Below all of the SplitToken public methods are outlined. * `static revokeBySelector(TokenStorageInterface $storage, string $selector, bool $deleteToken = false): void` — Revoke a token identified by its selector. This is the only revocation route open to a token whose plaintext nobody holds. * `static clearExpiredTokens(TokenStorageInterface $storage): int` — Delete all expired tokens from the database. Receives the storage instance as parameter. Returns the number of deleted tokens, as integer. Note that this deletes revoked tokens too. +## ✍ Mac and Request Signing + +As of v3.2, a shared key can also *authenticate*: prove that a message, or a whole HTTP request, comes from someone who holds the key and was not changed on the way. Nothing is encrypted and the key never travels. + +**Which one do I need?** A SplitToken is a *bearer* credential: whoever presents it is let in, the server stores only a hash of it, and it suits many revocable credentials that each belong to a user. A signed request suits a credential that both sides hold, such as a secret built into an app or shared between two services: the secret is never sent, so a proxy or a request log cannot harvest it, and a captured request cannot be replayed against another endpoint or with another body. + +### Mac + +```php +use Oire\Iridium\Key\SharedKey; +use Oire\Iridium\Mac; + +$sharedKey = new SharedKey($keyFromYourEnvFile); + +$mac = Mac::sign('The message', $sharedKey, 'myapp-webhooks-v1'); + +if (!Mac::verify('The message', $mac, $sharedKey, 'myapp-webhooks-v1')) { + // Not authentic +} +``` + +The third argument is the **context**: a short label saying what this MAC is for. It is required, and it is what makes one shared key safe for several jobs. The shared key is never used directly: the MAC key is derived from it with HKDF-SHA256 (empty salt, 32 bytes, info `Iridium|Mac|V1|` followed by the context). A MAC made under one context is therefore worthless under another, and no MAC can ever be confused with anything the Crypt module does with the same key. Put a version in the context (`-v1`), so that you can change your message format later without the old and the new being interchangeable. + +The MAC itself is HMAC-SHA256, 32 bytes, returned as URL-safe Base64 without padding. Verification is constant-time, and a MAC that is malformed or of the wrong length is simply not valid: `verify()` returns `false` and throws only when the context is empty (`MacException`). + +#### Mac Methods + +* `static sign(string $message, SharedKey $key, string $context): string` — Returns the MAC in readable form. +* `static verify(string $message, string $mac, SharedKey $key, string $context): bool` — Checks a MAC returned by `sign()`. +* `static signRaw(string $message, SharedKey $key, string $context): string` and `static verifyRaw(string $message, string $rawMac, SharedKey $key, string $context): bool` — The same with the MAC as 32 raw bytes. +* `static deriveKey(SharedKey $key, string $context): string` — The derived 32-byte MAC key. You do not need it to sign or verify; it is there so that a client written in another language can be checked against it. + +### Key Ring + +Keys get rotated, and for a while old and new clients call the same server. A `KeyRing` maps a **key ID** to its key, so each request says which key signed it: + +```php +use Oire\Iridium\Key\KeyRing; + +$keyRing = KeyRing::fromPairs([ + [$_ENV['API_KEY_ID'], $_ENV['API_KEY']], + [$_ENV['API_KEY_ID_PREVIOUS'] ?? null, $_ENV['API_KEY_PREVIOUS'] ?? null], +]); +``` + +**A slot that is not filled does not exist.** A pair whose ID or key is null or empty is skipped, never put on the ring as an empty key. This is the whole point of the class: a MAC under an empty key can be forged by anyone, so an unused rotation slot must not be comparable at all. A key that *is* present must be a valid shared key (`SharedKeyException`), and an ID may appear only once (`KeyRingException`). + +* `static fromPairs(iterable $pairs): self` — Builds a ring from `[key ID, key]` pairs, skipping unfilled slots. +* `add(string $keyId, SharedKey $key): self` — Puts a key on the ring. Returns `$this` for chainability. +* `find(string $keyId): SharedKey|null` — The key an ID selects, or null. +* `isEmpty(): bool`, `getKeyIds(): array` — For checking your configuration and for logging. + +### Signing a Request + +The client signs the method, the path and the body, and sends three extra values with the request. Iridium does not name the headers; use whatever your API uses. + +```php +use Oire\Iridium\Request\RequestSigner; + +$signer = new RequestSigner($keyId, $sharedKey, 'myapp-api-v1'); +$signed = $signer->sign('POST', '/api/orders', $body); + +// $signed->keyId, $signed->timestamp, $signed->signature +// e.g. X-MyApp-Key-Id, X-MyApp-Timestamp, X-MyApp-Signature +``` + +### Verifying a Request + +```php +use Oire\Iridium\Request\RequestVerifier; + +$verifier = new RequestVerifier($keyRing, 'myapp-api-v1'); + +$failure = $verifier->verify( + $method, + $path, // as received: percent-encoded, no base path, no query string + $rawBody, + $keyIdHeader, // null when the header is absent + $timestampHeader, + $signatureHeader, +); + +if ($failure !== null) { + $logger->warning('Request rejected', ['failure' => $failure->value, 'keyId' => $keyIdHeader]); + + // Answer every failure the same way +} +``` + +`verify()` returns `null` for an accepted request and a `RequestVerificationFailure` otherwise: `MissingFields`, `MalformedField`, `UnknownKeyId`, `BadSignature` or `StaleTimestamp`. **The reason is for your log, not for your response**: answer every failure identically, or the endpoint tells a stranger which key IDs exist. Log the key ID and the failure, never the signature. The signature is checked before the age of the timestamp, so `StaleTimestamp` always means a correctly signed request from a machine whose clock is off, and nothing else; you may want to send your server time with the rejection, so that such a client can correct its next timestamp (`sign()` accepts one). + +The timestamp must be within the **acceptance window** of the server clock, either way: 300 seconds unless you pass another value as the third constructor argument. There is no nonce, so an identical request is accepted again for as long as its timestamp stays inside the window. That makes the window your replay window: keep it short, and make the operations behind it safe to repeat. + +### The Signed String + +For clients in other languages. The signature is `Mac::sign()` over five lines joined by a single line feed, with no trailing line feed: + +``` +myapp-api-v1 +POST +/api/orders +1753900000 + +``` + +* The first line is the context, which is also the label the MAC key is derived under. +* The method is upper-cased. The path is signed exactly as it travels: percent-encoded, without scheme, host or query string. Both sides must agree on it byte for byte, so mind base paths and trailing slashes. +* The timestamp is Unix seconds as decimal text, at most ten digits, without leading zeros, signed exactly as sent. +* An empty body hashes the empty string; the line is never left out. + +[`tests/fixtures/request-signing-vectors.json`](https://github.com/Oire/Iridium-php/blob/master/tests/fixtures/request-signing-vectors.json) holds byte-exact test vectors — the derived MAC key, plain MACs and whole signed requests — computed outside PHP. Check your client against them. + ## Changes and Bugfixes See [changelog](https://github.com/Oire/Iridium-php/blob/master/CHANGELOG.md). diff --git a/src/Exception/KeyRingException.php b/src/Exception/KeyRingException.php new file mode 100644 index 0000000..1336fed --- /dev/null +++ b/src/Exception/KeyRingException.php @@ -0,0 +1,44 @@ + */ + private array $keys = []; + + /** + * Build a ring from stored pairs of key ID and key, typically read from configuration: + * the current pair first, then the outgoing one kept during a rotation. + * + * A pair whose ID or key is null or empty is a slot that does not exist, and is skipped. + * It is never put on the ring as an empty key: a MAC under an empty key can be forged by + * anyone, so an unused rotation slot must not be comparable at all. + * + * @param iterable $pairs Each pair is [key ID, key in readable form] + * + * @throws KeyRingException If a key ID is repeated + * @throws SharedKeyException If a key that is present is not a valid shared key + */ + public static function fromPairs(#[SensitiveParameter] iterable $pairs): self + { + $keyRing = new self(); + + foreach ($pairs as [$keyId, $key]) { + if ($keyId === null || $keyId === '' || $key === null || $key === '') { + continue; + } + + $keyRing->add($keyId, new SharedKey($key)); + } + + return $keyRing; + } + + /** + * Put a key on the ring. + * + * @throws KeyRingException If the ID is empty or already on the ring + * + * @psalm-external-mutation-free + */ + public function add(string $keyId, SharedKey $key): self + { + if ($keyId === '') { + throw KeyRingException::emptyKeyId(); + } + + if ($this->find($keyId) !== null) { + throw KeyRingException::duplicateKeyId($keyId); + } + + $this->keys[] = [$keyId, $key]; + + return $this; + } + + /** + * Find the key an ID selects. A key ID is not a secret, but it is compared in constant time anyway. + * + * @psalm-mutation-free + */ + public function find(string $keyId): ?SharedKey + { + foreach ($this->keys as [$candidateId, $key]) { + if (hash_equals($candidateId, $keyId)) { + return $key; + } + } + + return null; + } + + /** @psalm-mutation-free */ + public function isEmpty(): bool + { + return $this->keys === []; + } + + /** + * @psalm-mutation-free + * @return list + */ + public function getKeyIds(): array + { + return array_map(static fn(array $entry): string => $entry[0], $this->keys); + } +} diff --git a/src/Mac.php b/src/Mac.php new file mode 100644 index 0000000..60636c2 --- /dev/null +++ b/src/Mac.php @@ -0,0 +1,123 @@ +getRawKey(), self::MAC_SIZE, self::DERIVATION_INFO_PREFIX . $context); + } +} diff --git a/src/Request/CanonicalRequest.php b/src/Request/CanonicalRequest.php new file mode 100644 index 0000000..af59f5c --- /dev/null +++ b/src/Request/CanonicalRequest.php @@ -0,0 +1,73 @@ + $context, 'method' => $method, 'path' => $path, 'timestamp' => $timestamp] as $field => $value) { + if (preg_match('/[\\r\\n]/', $value) === 1) { + throw RequestSigningException::multilineField($field); + } + } + + return implode("\n", [ + $context, + mb_strtoupper($method), + $path, + $timestamp, + hash(self::BODY_HASH_FUNCTION, $body), + ]); + } +} diff --git a/src/Request/RequestSigner.php b/src/Request/RequestSigner.php new file mode 100644 index 0000000..ddce3b4 --- /dev/null +++ b/src/Request/RequestSigner.php @@ -0,0 +1,82 @@ + self::MAXIMUM_TIMESTAMP) { + throw RequestSigningException::invalidTimestamp($timestamp); + } + + $timestampText = (string) $timestamp; + $canonical = CanonicalRequest::build($this->context, $method, $path, $timestampText, $body); + + return new SignedRequest($this->keyId, $timestampText, Mac::sign($canonical, $this->key, $this->context)); + } +} diff --git a/src/Request/RequestVerificationFailure.php b/src/Request/RequestVerificationFailure.php new file mode 100644 index 0000000..9c41865 --- /dev/null +++ b/src/Request/RequestVerificationFailure.php @@ -0,0 +1,36 @@ +keyRing->find($keyId); + + if ($key === null) { + return RequestVerificationFailure::UnknownKeyId; + } + + try { + $canonical = CanonicalRequest::build($this->context, $method, $path, $timestamp, $body); + } catch (RequestSigningException) { + return RequestVerificationFailure::MalformedField; + } + + if (!Mac::verifyRaw($canonical, $rawSignature, $key, $this->context)) { + return RequestVerificationFailure::BadSignature; + } + + if (abs(($now ?? time()) - (int) $timestamp) > $this->acceptanceWindow) { + return RequestVerificationFailure::StaleTimestamp; + } + + return null; + } +} diff --git a/src/Request/SignedRequest.php b/src/Request/SignedRequest.php new file mode 100644 index 0000000..bdae9a8 --- /dev/null +++ b/src/Request/SignedRequest.php @@ -0,0 +1,45 @@ +add('current', $current)->add('previous', $previous); + + self::assertSame($current, $keyRing->find('current')); + self::assertSame($previous, $keyRing->find('previous')); + self::assertNull($keyRing->find('unknown')); + self::assertSame(['current', 'previous'], $keyRing->getKeyIds()); + self::assertFalse($keyRing->isEmpty()); + } + + public function testBuildsFromStoredPairs(): void + { + $current = new SharedKey(); + $previous = new SharedKey(); + $keyRing = KeyRing::fromPairs([['current', $current->getKey()], ['previous', $previous->getKey()]]); + + self::assertSame($current->getRawKey(), $keyRing->find('current')?->getRawKey()); + self::assertSame($previous->getRawKey(), $keyRing->find('previous')?->getRawKey()); + } + + public function testASlotThatIsNotFilledDoesNotExist(): void + { + $key = (new SharedKey())->getKey(); + $keyRing = KeyRing::fromPairs([ + ['current', $key], + ['', ''], + [null, null], + ['id-without-a-key', ''], + ['id-with-a-null-key', null], + ['', $key], + [null, $key], + ]); + + self::assertSame(['current'], $keyRing->getKeyIds()); + self::assertNull($keyRing->find('')); + self::assertNull($keyRing->find('id-without-a-key')); + self::assertNull($keyRing->find('id-with-a-null-key')); + } + + public function testARingWithNoFilledSlotIsEmpty(): void + { + self::assertTrue(KeyRing::fromPairs([['', ''], [null, null]])->isEmpty()); + self::assertTrue((new KeyRing())->isEmpty()); + } + + public function testAKeyThatIsPresentMustBeValid(): void + { + $this->expectException(SharedKeyException::class); + + KeyRing::fromPairs([['current', 'too-short']]); + } + + public function testAnEmptyIdCannotBeAdded(): void + { + $this->expectException(KeyRingException::class); + + (new KeyRing())->add('', new SharedKey()); + } + + public function testAnIdCannotBeAddedTwice(): void + { + $this->expectException(KeyRingException::class); + + (new KeyRing())->add('current', new SharedKey())->add('current', new SharedKey()); + } + + public function testARepeatedStoredIdIsRefused(): void + { + $this->expectException(KeyRingException::class); + + KeyRing::fromPairs([['same', (new SharedKey())->getKey()], ['same', (new SharedKey())->getKey()]]); + } +} diff --git a/tests/MacTest.php b/tests/MacTest.php new file mode 100644 index 0000000..01b1038 --- /dev/null +++ b/tests/MacTest.php @@ -0,0 +1,136 @@ +getRawKey(), $macKey); + self::assertNotSame(hash_hmac(Mac::HASH_FUNCTION, 'message', $key->getRawKey(), true), Mac::signRaw('message', $key, self::CONTEXT)); + } + + public function testTheSharedKeySurvivesSigning(): void + { + $key = new SharedKey(); + $rawKey = $key->getRawKey(); + + Mac::sign('message', $key, self::CONTEXT); + + self::assertSame($rawKey, $key->getRawKey()); + self::assertSame(SharedKey::KEY_SIZE, mb_strlen($key->getRawKey(), Crypt::STRING_ENCODING_8BIT)); + } + + public function testAMalformedMacIsNotValid(): void + { + $key = new SharedKey(); + + self::assertFalse(Mac::verify('message', '!!! not base64 !!!', $key, self::CONTEXT)); + self::assertFalse(Mac::verify('message', '', $key, self::CONTEXT)); + self::assertFalse(Mac::verify('message', Base64::encode(str_repeat('a', Mac::MAC_SIZE - 1)), $key, self::CONTEXT)); + self::assertFalse(Mac::verify('message', Base64::encode(str_repeat('a', Mac::MAC_SIZE + 1)), $key, self::CONTEXT)); + self::assertFalse(Mac::verifyRaw('message', '', $key, self::CONTEXT)); + } + + public function testSigningNeedsAContext(): void + { + $this->expectException(MacException::class); + + Mac::sign('message', new SharedKey(), ''); + } + + public function testVerifyingNeedsAContextEvenForAMalformedMac(): void + { + $this->expectException(MacException::class); + + Mac::verify('message', '!!! not base64 !!!', new SharedKey(), ''); + } +} diff --git a/tests/RequestSigningTest.php b/tests/RequestSigningTest.php new file mode 100644 index 0000000..1da669a --- /dev/null +++ b/tests/RequestSigningTest.php @@ -0,0 +1,360 @@ +currentKey = new SharedKey(); + $this->previousKey = new SharedKey(); + $this->verifier = new RequestVerifier( + (new KeyRing())->add('current', $this->currentKey)->add('previous', $this->previousKey), + self::CONTEXT, + ); + } + + public function testMatchesTheGoldenVectors(): void + { + $vectors = RequestSigningVectors::load(); + $key = new SharedKey($vectors['key']['sharedKey']); + $keyId = $vectors['key']['keyId']; + $context = $vectors['context']; + $signer = new RequestSigner($keyId, $key, $context); + $verifier = new RequestVerifier((new KeyRing())->add($keyId, $key), $context); + + self::assertSame($vectors['key']['sharedKeyHex'], bin2hex($key->getRawKey())); + + foreach ($vectors['requests'] as $vector) { + $name = $vector['name']; + $method = $vector['method']; + $path = $vector['path']; + $body = $vector['body']; + $timestamp = $vector['timestamp']; + + self::assertSame($vector['bodySha256Hex'], hash('sha256', $body), $name); + self::assertSame($vector['canonical'], CanonicalRequest::build($context, $method, $path, $timestamp, $body), $name); + + $signed = $signer->sign($method, $path, $body, (int) $timestamp); + + self::assertSame($timestamp, $signed->timestamp, $name); + self::assertSame($vector['signature'], $signed->signature, $name); + self::assertNull($verifier->verify($method, $path, $body, $keyId, $timestamp, $vector['signature'], (int) $timestamp), $name); + } + } + + public function testTheCanonicalStringHasFiveLinesAndNoTrailingLineFeed(): void + { + self::assertSame( + "ctx\nPOST\n/p\n12\n" . hash('sha256', ''), + CanonicalRequest::build('ctx', 'post', '/p', '12', ''), + ); + } + + public function testAcceptsARequestSignedWithTheCurrentKey(): void + { + self::assertNull($this->verify($this->sign())); + } + + public function testAcceptsARequestSignedWithThePreviousKey(): void + { + self::assertNull($this->verify($this->sign(keyId: 'previous', key: $this->previousKey))); + } + + public function testAKeyIdSelectsItsOwnKeyOnly(): void + { + $signed = $this->sign(keyId: 'previous', key: $this->currentKey); + + self::assertSame(RequestVerificationFailure::BadSignature, $this->verify($signed)); + } + + public function testRejectsAnUnknownKeyId(): void + { + self::assertSame(RequestVerificationFailure::UnknownKeyId, $this->verify($this->sign(keyId: 'stranger'))); + } + + public function testAnEmptyRotationSlotCannotBeForgedAgainst(): void + { + $verifier = new RequestVerifier(KeyRing::fromPairs([['current', $this->currentKey->getKey()], ['', '']]), self::CONTEXT); + $canonical = CanonicalRequest::build(self::CONTEXT, 'POST', self::PATH, (string) self::NOW, self::BODY); + $forged = Base64::encode(hash_hmac(Mac::HASH_FUNCTION, $canonical, '', true)); + + self::assertSame( + RequestVerificationFailure::UnknownKeyId, + $verifier->verify('POST', self::PATH, self::BODY, '', (string) self::NOW, $forged, self::NOW), + ); + } + + public function testRejectsATamperedBody(): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::BadSignature, + $this->verifier->verify('POST', self::PATH, '{"item":"yacht"}', $signed->keyId, $signed->timestamp, $signed->signature, self::NOW), + ); + } + + public function testRejectsATamperedPath(): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::BadSignature, + $this->verifier->verify('POST', '/api/refunds', self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, self::NOW), + ); + } + + public function testRejectsATamperedMethod(): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::BadSignature, + $this->verifier->verify('DELETE', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, self::NOW), + ); + } + + public function testTheMethodIsCaseInsensitive(): void + { + $signed = $this->sign(); + + self::assertNull($this->verifier->verify('post', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, self::NOW)); + } + + public function testRejectsASignatureMadeUnderAnotherContext(): void + { + $signed = (new RequestSigner('current', $this->currentKey, 'another-context'))->sign('POST', self::PATH, self::BODY, self::NOW); + + self::assertSame(RequestVerificationFailure::BadSignature, $this->verify($signed)); + } + + #[DataProvider('provideTimestampsAtTheBoundary')] + public function testTheWindowIsInclusiveOnBothSides(int $offset, ?RequestVerificationFailure $expected): void + { + self::assertSame($expected, $this->verify($this->sign(timestamp: self::NOW + $offset))); + } + + /** + * @return iterable + * + * @psalm-mutation-free + */ + public static function provideTimestampsAtTheBoundary(): iterable + { + yield 'exactly the window old' => [-300, null]; + yield 'one second older' => [-301, RequestVerificationFailure::StaleTimestamp]; + yield 'exactly the window ahead' => [300, null]; + yield 'one second further ahead' => [301, RequestVerificationFailure::StaleTimestamp]; + } + + public function testTheWindowIsConfigurable(): void + { + $verifier = new RequestVerifier((new KeyRing())->add('current', $this->currentKey), self::CONTEXT, 10); + $signed = $this->sign(timestamp: self::NOW - 11); + + self::assertSame( + RequestVerificationFailure::StaleTimestamp, + $verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, self::NOW), + ); + } + + public function testAStaleTimestampUnderAWrongKeyIsABadSignature(): void + { + $signed = $this->sign(key: new SharedKey(), timestamp: 1000); + + self::assertSame(RequestVerificationFailure::BadSignature, $this->verify($signed)); + } + + public function testAnIdenticalRequestReplaysInsideTheWindow(): void + { + $signed = $this->sign(); + + self::assertNull($this->verify($signed)); + self::assertNull($this->verify($signed, self::NOW + 299)); + self::assertSame(RequestVerificationFailure::StaleTimestamp, $this->verify($signed, self::NOW + 301)); + } + + public function testRejectsMissingFields(): void + { + $signed = $this->sign(); + + self::assertSame(RequestVerificationFailure::MissingFields, $this->verifier->verify('POST', self::PATH, self::BODY, null, $signed->timestamp, $signed->signature, self::NOW)); + self::assertSame(RequestVerificationFailure::MissingFields, $this->verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, null, $signed->signature, self::NOW)); + self::assertSame(RequestVerificationFailure::MissingFields, $this->verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, null, self::NOW)); + } + + #[DataProvider('provideMalformedTimestamps')] + public function testRejectsAMalformedTimestamp(string $timestamp): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::MalformedField, + $this->verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, $timestamp, $signed->signature, self::NOW), + ); + } + + /** + * @return iterable + * + * @psalm-mutation-free + */ + public static function provideMalformedTimestamps(): iterable + { + yield 'empty' => ['']; + yield 'a leading zero' => ['01753900000']; + yield 'a short leading zero' => ['0123']; + yield 'eleven digits' => ['17539000000']; + yield 'signed' => ['+1753900000']; + yield 'negative' => ['-1']; + yield 'decimal' => ['1753900000.0']; + yield 'padded' => [' 1753900000']; + yield 'a trailing line feed' => ["1753900000\n"]; + yield 'hexadecimal' => ['0x6889']; + } + + #[DataProvider('provideMalformedSignatures')] + public function testRejectsAMalformedSignature(string $signature): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::MalformedField, + $this->verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, $signature, self::NOW), + ); + } + + /** + * @return iterable + * + * @psalm-mutation-free + */ + public static function provideMalformedSignatures(): iterable + { + yield 'empty' => ['']; + yield 'not Base64' => ['!!! not base64 !!!']; + yield 'too short' => [Base64::encode(str_repeat('a', Mac::MAC_SIZE - 1))]; + yield 'too long' => [Base64::encode(str_repeat('a', Mac::MAC_SIZE + 1))]; + } + + public function testAPathWithALineBreakIsMalformedNotAnException(): void + { + $signed = $this->sign(); + + self::assertSame( + RequestVerificationFailure::MalformedField, + $this->verifier->verify('POST', "/api/orders\nGET", self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, self::NOW), + ); + } + + public function testTheTimestampDefaultsToNow(): void + { + $signed = (new RequestSigner('current', $this->currentKey, self::CONTEXT))->sign('GET', self::PATH); + + self::assertEqualsWithDelta(time(), (int) $signed->timestamp, 5); + self::assertNull($this->verifier->verify('GET', self::PATH, '', $signed->keyId, $signed->timestamp, $signed->signature)); + } + + public function testTheSignerRefusesALineBreak(): void + { + $this->expectException(RequestSigningException::class); + + (new RequestSigner('current', $this->currentKey, self::CONTEXT))->sign('POST', "/api\r\n/orders"); + } + + public function testTheSignerRefusesAnEmptyKeyId(): void + { + $this->expectException(RequestSigningException::class); + + new RequestSigner('', $this->currentKey, self::CONTEXT); + } + + public function testTheSignerRefusesAnEmptyContext(): void + { + $this->expectException(RequestSigningException::class); + + new RequestSigner('current', $this->currentKey, ''); + } + + public function testTheSignerRefusesATimestampItCannotWrite(): void + { + $this->expectException(RequestSigningException::class); + + (new RequestSigner('current', $this->currentKey, self::CONTEXT))->sign('GET', self::PATH, '', 10_000_000_000); + } + + public function testTheSignerRefusesANegativeTimestamp(): void + { + $this->expectException(RequestSigningException::class); + + (new RequestSigner('current', $this->currentKey, self::CONTEXT))->sign('GET', self::PATH, '', -1); + } + + public function testTheVerifierRefusesAnEmptyContext(): void + { + $this->expectException(RequestSigningException::class); + + new RequestVerifier(new KeyRing(), ''); + } + + public function testTheVerifierRefusesAWindowThatIsNotPositive(): void + { + $this->expectException(RequestSigningException::class); + + new RequestVerifier(new KeyRing(), self::CONTEXT, 0); + } + + private function sign(string $keyId = 'current', ?SharedKey $key = null, int $timestamp = self::NOW): SignedRequest + { + return (new RequestSigner($keyId, $key ?? $this->currentKey, self::CONTEXT))->sign('POST', self::PATH, self::BODY, $timestamp); + } + + private function verify(SignedRequest $signed, int $now = self::NOW): ?RequestVerificationFailure + { + return $this->verifier->verify('POST', self::PATH, self::BODY, $signed->keyId, $signed->timestamp, $signed->signature, $now); + } +} diff --git a/tests/RequestSigningVectors.php b/tests/RequestSigningVectors.php new file mode 100644 index 0000000..fad2eee --- /dev/null +++ b/tests/RequestSigningVectors.php @@ -0,0 +1,50 @@ +, requests: non-empty-list} + */ +final class RequestSigningVectors +{ + /** + * The shape is pinned by the tests that read it. + * + * @psalm-suppress MixedReturnStatement + * @return Vectors + */ + public static function load(): array + { + $json = file_get_contents(__DIR__ . '/fixtures/request-signing-vectors.json'); + + if ($json === false) { + throw new RuntimeException('The request signing vectors are missing.'); + } + + return json_decode($json, true, 32, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/fixtures/request-signing-vectors.json b/tests/fixtures/request-signing-vectors.json new file mode 100644 index 0000000..acf8cfa --- /dev/null +++ b/tests/fixtures/request-signing-vectors.json @@ -0,0 +1,63 @@ +{ + "_readme": [ + "Golden vectors for Iridium request signing (Oire\\Iridium\\Mac and Oire\\Iridium\\Request). Byte-exact, for clients written in other languages.", + "Computed outside PHP (Python hmac/hashlib with a hand-written RFC 5869 HKDF), so the PHP tests pit two implementations against each other.", + "macKey = HKDF-SHA256(ikm = the 32 raw bytes of the shared key, salt = empty (32 zero bytes per RFC 5869), info = \"Iridium|Mac|V1|\" + context, length = 32).", + "canonical = five lines joined by a single LF, no trailing LF: context, METHOD in upper case, path as it travels (percent-encoded, no query string), timestamp text, lowercase hex SHA-256 of the raw body. An empty body hashes the empty string.", + "signature = URL-safe Base64 without padding of HMAC-SHA256(macKey, canonical).", + "timestamp = unix seconds as decimal text without leading zeros, at most 10 digits, signed exactly as sent." + ], + "key": { + "keyId": "0123456789ab", + "sharedKey": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "sharedKeyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "note": "A throwaway test key (bytes 0x00 to 0x1f). Never a real key." + }, + "context": "example-api-v1", + "derivationInfo": "Iridium|Mac|V1|example-api-v1", + "macKeyHex": "524669c1f6526173c90c628bfea50fe0ba40ed9b8a7ccab6a599cca5b26fb27d", + "macs": [ + { + "name": "a plain message", + "message": "Iridium", + "mac": "p3XwA9G96DzHX0G0qEqZ6QhEZTi5FJ7k-igsoeW1Uv0" + }, + { + "name": "the empty message", + "message": "", + "mac": "-mA4zmJABqjhCwuguNlsRRvdPC7oS-O50hY0U11W-MU" + } + ], + "requests": [ + { + "name": "POST with a JSON body", + "method": "POST", + "path": "/api/orders", + "timestamp": "1753900000", + "body": "{\"item\":\"café\",\"quantity\":2}", + "bodySha256Hex": "47e15c78edae54019979909774ff6c98f58016ff7e1ce62236adf08ae4c0ae37", + "canonical": "example-api-v1\nPOST\n/api/orders\n1753900000\n47e15c78edae54019979909774ff6c98f58016ff7e1ce62236adf08ae4c0ae37", + "signature": "s6k2GnAoUzPGpCETZmNGYXnYXpzSEgmJcvTLZmSQk6g" + }, + { + "name": "GET with an empty body", + "method": "GET", + "path": "/api/orders/42", + "timestamp": "1753900000", + "body": "", + "bodySha256Hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "canonical": "example-api-v1\nGET\n/api/orders/42\n1753900000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "L2xIVJhN_Yd7f7FucdYIguDbrRQeBYxYXElJQIqufzc" + }, + { + "name": "a percent-encoded path", + "method": "DELETE", + "path": "/api/files/r%C3%A9sum%C3%A9.pdf", + "timestamp": "1753900000", + "body": "", + "bodySha256Hex": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "canonical": "example-api-v1\nDELETE\n/api/files/r%C3%A9sum%C3%A9.pdf\n1753900000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "signature": "hksMnM7j5PJ1hQOQs84oPhr_UoMHwVWnos2dh94hscQ" + } + ] +}