Skip to content

Mac, KeyRing and request signing (3.2) - #104

Merged
Menelion merged 4 commits into
masterfrom
request-signing
Sep 20, 2026
Merged

Menelion merged 4 commits into
masterfrom
request-signing

Conversation

@Menelion

Copy link
Copy Markdown
Contributor

What

Three additive modules, so a SharedKey can authenticate as well as encrypt. Nothing existing changes: 3.2.0, not 4.0.

  • Mac — HMAC-SHA256, URL-safe Base64. The shared key is never used directly: the MAC key is HKDF-SHA256(raw key, empty salt, 32 bytes, info = "Iridium|Mac|V1|" + context), and the context is required. One key can serve Crypt and several MAC purposes without one being able to forge for another.
  • Key\KeyRing — key ID → SharedKey, for rotation. fromPairs() reads pairs straight from configuration and skips a slot that is not filled instead of holding an empty key, under which anyone could forge a MAC.
  • Request\RequestSigner / RequestVerifier — authenticate method, path, timestamp and body without ever sending the secret. Plain strings in and out: no framework types, no fixed header names. RequestVerificationFailure (enum) says why a request was refused, for the log only.

Why

Split tokens are bearer tokens with a hashed verifier: right for many revocable per-user credentials, and unable by construction to verify an HMAC (the server never holds the secret). A credential both sides hold — a secret built into an app, or shared between two services — wants the opposite shape. Tessera's desktop API (plan 020) is the first consumer; its ApiRequestAuthenticator was the reference implementation this was extracted from.

Decisions worth a look

  • SHA-256, not the library's SHA-384, for the MAC and the body hash: this is a wire format for clients in other languages (Tessera's is C++), and HMAC-SHA256 is what they all have. It is a new primitive, so nothing is weakened.
  • The context is both the first signed line and the HKDF label. One string to agree on, and a -v2 context separates a future format at both layers.
  • Signature checked before the timestamp's age, so StaleTimestamp always means a correctly signed request from a skewed clock.
  • Timestamps refuse leading zeros (0|[1-9]\d{0,9}): the text is signed as sent, so there is exactly one spelling of each instant.
  • No nonce store. An identical request replays inside the window (300 s by default); the README says so plainly. A NonceStorageInterface beside TokenStorageInterface would be a natural 3.3.
  • Base64::decode() is lenient about alphabet and padding, so a signature in standard Base64 that decodes to the right 32 bytes is accepted. Harmless (the bytes are what is compared), but noted.

Wire contract

Specified in the README ("The Signed String") and pinned by tests/fixtures/request-signing-vectors.json, computed outside PHP (Python hmac/hashlib with a hand-written RFC 5869 HKDF, itself checked against RFC 5869 test case 3), so the PHP tests pit two implementations against each other. CLAUDE.md records that the info prefix, the canonical string and the vectors are never edited in place.

Checks

PHPUnit 131 tests green (62 new), Psalm level 1 clean, php-cs-fixer clean.

After merging: tag v3.2.0.

🤖 Generated with Claude Code

Menelion and others added 4 commits September 21, 2026 01:16
A SharedKey could only encrypt. Mac authenticates a message with it, never
using the key directly: the MAC key is HKDF-SHA256(key, info = Iridium|Mac|V1|
+ context), and the context is required, so one shared key can serve Crypt and
several MAC purposes without one forging for another. The golden vectors were
computed outside PHP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fromPairs() reads pairs from configuration and skips a slot that is not
filled instead of holding an empty key, under which anyone could forge a MAC.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Authenticates method, path, timestamp and body without sending the secret.
Plain strings in and out: no framework types, no fixed header names. The
verifier checks the signature before the age of the timestamp, so a stale
timestamp always describes a correctly signed request, and reports its reason
through an enum meant for the log only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Menelion
Menelion merged commit 503de36 into master Sep 20, 2026
7 checks passed
@Menelion
Menelion deleted the request-signing branch September 20, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant