Skip to content

[Server] Rotate the requestState signing key with a key ring - #626

Open
BrocksiNet wants to merge 1 commit into
modelcontextprotocol:mainfrom
BrocksiNet:feat/request-state-key-ring
Open

BrocksiNet wants to merge 1 commit into
modelcontextprotocol:mainfrom
BrocksiNet:feat/request-state-key-ring

Conversation

@BrocksiNet

Copy link
Copy Markdown
Contributor

Summary

  • Builder::setRequestState() and RequestStateCodec::__construct() take string|list<string> as $key.
    A list is a key ring: the first key signs, every key verifies.
  • Every key is validated like the single key today (at least 32 bytes), and the ring must be a non-empty list of strings; a single key keeps its existing error message.
  • Verification computes the HMAC under each key and compares with hash_equals(), without an early exit.
  • Docs: "Rotating the key" in docs/handlers/input-required.md with the three rotation steps, and the builder example and table in docs/run/server-builder.md.

The 2026-07-28 specification requires integrity protection for a requestState that influences authorization or business logic (MRTR, Server Requirements).
With one key, rotating it fails every multi round-trip call in flight, and on several instances each refuses the others' states until all are switched.

Design decisions

  • No key id.
    A state names no key, and verification tries each key of the ring.
    The ring is two keys during a rotation and one otherwise, and an HMAC over a short body costs microseconds, so an O(1) lookup buys nothing measurable.
    A key id would change the wire format, and then instances still on an older SDK release could not verify what upgraded ones sign during a rolling deployment: the very situation rotation happens in.
    It also keeps "a single key is a ring of one" literally true (same wire value, asserted in a test).
  • Same method, union type, rather than a separate setRequestStateKeys().
    The ring replaces the key rather than adding to it, and two methods would need a rule for when both are called.
    The parameter keeps its name $key so named-argument callers do not break.
  • Three-step rotation documented.
    Moving the new key straight to the front works for one process; with several, instances that have not been updated yet would refuse the new states, so step 1 ([$current, $new]) distributes the new key before anyone signs with it.
    This matches the Python SDK's documented rotation.
  • No early exit when verifying.
    Not strictly needed, since key position is not secret in any useful way, but it keeps the timing independent of which key signed at no real cost.

Alternatives considered

  • Key id derived from the key (as the Python SDK does, 4 bytes of a hash): rejected for the wire format reasons above.
    If maintainers want it later, it can be added as a versioned prefix while still accepting the two-part format.
  • A RequestStateKeyProviderInterface fetching keys from a secret store: more flexible, but a list covers rotation, and a provider can be layered on top later.

Backward compatibility

  • A single string key behaves exactly as before, and the wire format is unchanged.
  • RequestStateCodec's constructor parameter widens from string to string|array; the class is final, so no subclass is affected.
  • States signed by a release without this change verify with a ring containing that key, and the other way round.

Precedents

Motivation: Shopware (https://github.com/shopware/shopware) plans URL-mode elicitation on the 2026-07-28 era and runs multi-instance deployments whose signing key comes from configuration and has to be rotatable.

Fixes #624

Test plan

  • RequestStateCodecTest, rotation walk-through with several keys at once: a state minted with the old key verifies after the new key is added and after it moves to the front; a state minted with the new key verifies on the ring and on an instance still on step 1; a state minted while learning verifies on a not yet updated single-key instance; after the old key is removed, its state is refused with mac
  • RequestStateCodecTest: a state signed by none of the ring's keys is refused; a single key and a ring of that key mint the same wire value and verify each other's states; an empty ring, a short key behind a good one, a non-list and a non-string key are refused at construction
  • StatelessProtocolTest (end to end through Builder): the retry of a call signed with the old key is served by a server configured with [$new, $old], and refused with -32602/400 once the ring is [$new]
  • Existing single-key tests unchanged and green
  • make tests (2959 tests, 8 skipped)
  • make phpstan
  • make cs
  • composer validate --strict

Relation to #625

This PR and #625 (binding requestState to its originating request) change the same setRequestState() signature and codec and compose: setRequestState(string|array $key, int $ttl = 600, ?RequestStateBinderInterface $binder = null), with the binding covered by the MAC under every key of the ring.
Whichever merges second needs a mechanical conflict resolution, which I have ready and tested (unit suite, phpstan, cs), including a test that a bound state survives a key rotation.

Builder::setRequestState() and RequestStateCodec take a list of keys:
the first key signs, every key verifies, so an operator can put a new
key in front and drop the old one once the states it signed expired,
without failing the multi round-trip calls in flight.

A state names no key: verification tries each key of the ring, so the
wire format is unchanged and instances of an older release still verify
the states a ring signs. A single key works as before.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T07:53:46.583708Z 0ba97ee PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ba97eede0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Server/Builder.php
Comment on lines +379 to +380
* To rotate, put the new key first and keep the old one behind it until
* the states it signed have expired, one TTL later.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the learning phase in the inline rotation steps

In a multi-process rolling deployment, following this instruction moves the first updated process straight to [$new, $old] while its peers still accept only $old; any new state minted there and retried on a peer is rejected. The detailed guide already requires first deploying [$old, $new] everywhere (docs/handlers/input-required.md lines 112–117), so this API documentation should include or link that learning phase instead of presenting the unsafe sequence.

Useful? React with 👍 / 👎.

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component enhancement Request for a new feature that's not currently supported 2026-07-28 All issues and PRs related to the spec release 2026-07-28 labels Oct 27, 2026
@chr-hertel
chr-hertel requested a balanced review from Copilot October 27, 2026 23:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The rotation recipe omits the cluster-wide key-preparation phase needed to prevent MAC failures during rollout and must tie the retirement TTL to when the last instance stops signing with the old key.

1 open issue
What changed in this PR

Adds signing-key rotation for requestState, allowing in-flight multi-round-trip calls to survive key changes while preserving the wire format and single-key compatibility.

Changes:

  • Accept a validated key ring in the builder and codec: the first key signs, and every key is checked during verification.
  • Add tests for rotation, invalid rings, compatibility, and retries across server configurations.
  • Document the rotation procedure and update the changelog.
File Description
tests/​Unit/​Server/​Stateless/​StatelessProtocolTest.php Tests retries after key rotation and retirement through the builder.
tests/​Unit/​Server/​Stateless/​RequestStateCodecTest.php Tests rotation stages, ring validation, and single-key compatibility.
src/​Server/​Stateless/​RequestStateCodec.php Validates key rings, signs with the first key, and verifies against every key.
src/​Server/​Builder.php Widens request-state configuration to accept key rings.
docs/​run/​server-builder.md Documents key-ring support in the builder reference.
docs/​handlers/​input-required.md Adds a three-step key-rotation guide.
CHANGELOG.md Records key rotation and backward compatibility.

Review effort: Balanced

Comment thread src/Server/Builder.php
Comment on lines +379 to +380
* To rotate, put the new key first and keep the old one behind it until
* the states it signed have expired, one TTL later.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2026-07-28 All issues and PRs related to the spec release 2026-07-28 enhancement Request for a new feature that's not currently supported Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rotate the requestState signing key without failing the calls in flight

3 participants