Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
116 changes: 114 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
<lowercase hex SHA-256 of the raw body>
```

* 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).
Expand Down
44 changes: 44 additions & 0 deletions src/Exception/KeyRingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Oire\Iridium\Exception;

/**
* Iridium, a security library for hashing passwords, encrypting data and managing secure tokens
* Copyright © 2021-2026 André Polykanine, Oire Software, https://oire.org/
* Copyright © 2016 Scott Arciszewski, Paragon Initiative Enterprises, https://paragonie.com.
* Portions copyright © 2016 Taylor Hornby, Defuse Security Research and Development, https://defuse.ca.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class KeyRingException extends IridiumException
{
/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function emptyKeyId(): self
{
return new self('A key ID cannot be empty.');
}

/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function duplicateKeyId(string $keyId): self
{
return new self(sprintf('The key ID "%s" is already on the ring.', $keyId));
}
}
35 changes: 35 additions & 0 deletions src/Exception/MacException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Oire\Iridium\Exception;

/**
* Iridium, a security library for hashing passwords, encrypting data and managing secure tokens
* Copyright © 2021-2026 André Polykanine, Oire Software, https://oire.org/
* Copyright © 2016 Scott Arciszewski, Paragon Initiative Enterprises, https://paragonie.com.
* Portions copyright © 2016 Taylor Hornby, Defuse Security Research and Development, https://defuse.ca.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class MacException extends IridiumException
{
/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function emptyContext(): self
{
return new self('A MAC context cannot be empty: it is what separates this use of the key from every other.');
}
}
71 changes: 71 additions & 0 deletions src/Exception/RequestSigningException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace Oire\Iridium\Exception;

/**
* Iridium, a security library for hashing passwords, encrypting data and managing secure tokens
* Copyright © 2021-2026 André Polykanine, Oire Software, https://oire.org/
* Copyright © 2016 Scott Arciszewski, Paragon Initiative Enterprises, https://paragonie.com.
* Portions copyright © 2016 Taylor Hornby, Defuse Security Research and Development, https://defuse.ca.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class RequestSigningException extends IridiumException
{
/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function emptyContext(): self
{
return new self('A request signing context cannot be empty.');
}

/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function multilineField(string $field): self
{
return new self(sprintf('The %s cannot contain a line break: the signed string is line-separated.', $field));
}

/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function emptyKeyId(): self
{
return new self('A key ID cannot be empty.');
}

/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function invalidTimestamp(int $timestamp): self
{
return new self(sprintf('The timestamp %d cannot be signed: it must be between 0 and 9999999999.', $timestamp));
}

/**
* @psalm-pure
* @psalm-suppress PossiblyUnusedReturnValue
*/
public static function invalidAcceptanceWindow(int $seconds): self
{
return new self(sprintf('The acceptance window must be a positive number of seconds, %d given.', $seconds));
}
}
Loading
Loading