microsoft / microsoft/agent-governance-toolkit
MCP signature forgery: _build_canonical_string is not injective, so a valid envelope can be reframed across field boundaries
- Dominant language
- Python
- Stars
- 6.3k
- Forks
- 1.1k
- Avg merge
- 5d 11h
- Merged PRs (30d)
- 142
Description
### Description
`MCPMessageSigner._build_canonical_string` (`mcp_message_signer.py:275-284`) joins the signed fields with a plain separator:
```python
timestamp_ms = int(timestamp.timestamp() * 1000)
return f"{nonce}|{timestamp_ms}|{sender_id or ''}|{payload}"
```
`|` is legal inside an MCP payload and inside a sender id, so this encoding is **not injective**: distinct `(nonce, timestamp, sender_id, payload)` tuples collapse to the same canonical string, and therefore to the same HMAC.
An attacker holding one valid envelope can forge others **without the signing key**, by moving text across a field boundary and reusing the signature verbatim.
### Reproduction
```python
from agent_os.mcp_message_signer import MCPMessageSigner, MCPSignedEnvelope
KEY = b"k" * 32
signed = MCPMessageSigner(KEY).sign_message("alpha|beta", sender_id="alice")
forged = MCPSignedEnvelope(
payload="beta", # moved "alpha|" out of the payload
sender_id="alice|alpha", # ...and into the sender id
nonce=signed.nonce,
timestamp=signed.timestamp,
signature=signed.signature, # unchanged
)
# a receiver: same key, its own nonce store
print(MCPMessageSigner(KEY).verify_message(forged))
```
```
MCPVerificationResult(is_valid=True, payload='beta', sender_id='alice|alpha', failure_reason=None)
```
Both tuples canonicalize to `...|alice|alpha|beta`.
The reverse direction is the dangerous one, because it **prepends attacker-chosen text to the payload a consumer will act on**:
```python
signed = MCPMessageSigner(KEY).sign_message("x", sender_id="alice|INJECTED")
forged = MCPSignedEnvelope(
payload="INJECTED|x", sender_id="alice",
nonce=signed.nonce, timestamp=signed.timestamp, signature=signed.signature,
)
MCPMessageSigner(KEY).verify_message(forged).is_valid # True
```
A third variant: `sender_id or ''` erases the difference between an absent sender and an empty one, so an envelope signed with `sender_id=None` verifies as one sent by `""`:
```python
signed = MCPMessageSigner(KEY).sign_message("p", sender_id=None)
forged = MCPSignedEnvelope(payload="p", sender_id="",
nonce=signed.nonce, timestamp=signed.timestamp,
signature=signed.signature)
MCPMessageSigner(KEY).verify_message(forged).is_valid # True
```
### Why the existing controls do not stop this
The module's other defences are all sound and all miss this:
- **Replay protection** does not apply -- the forged envelope reuses the original's nonce, but a *receiver* has its own nonce store and has never seen it. (Even for one receiver, the forgery can be delivered *instead of* the original rather than after it.)
- **HMAC-before-nonce-commit** ordering is correct and deliberate, but the HMAC itself is what matches here.
- **`hmac.compare_digest`** is used correctly; the comparison is not the problem, the string being signed is.
- **`test_verify_detects_tampered_payload`** passes, because it changes the payload without compensating elsewhere. The forgery keeps the concatenation invariant.
This is the classic canonicalization / length-extension-of-fields flaw: the signature covers a string that more than one input can produce.
### Proposed fix
Length-prefix every field so the reader of each field is told how many characters it has before reading them, and give `None` a marker distinct from the empty string:
```
before: "n1|1785370631433|alice|alpha|beta"
after: "2:n1|13:1785370631433|5:alice|10:alpha|beta"
```
A separator inside a field is then just one of that field's characters and cannot be read as the end of it. I verified injectivity over 270 tuples built from separator-heavy values (`"|"`, `"a|"`, `"|a"`, `"a|b"`), values shaped like the length prefix itself (`"1:a"`, `"2:ab"`), and values equal to the absent-value marker (`"-"`): 0 collisions.
### Compatibility
The signature format changes, so a signer and a verifier must be upgraded together, and envelopes signed by an older version will not verify against a fixed one. That is unavoidable: the old format cannot be accepted as a fallback without keeping the forgery available.
`MCPMessageSigner` has no in-repo consumer -- it is exported from `agent_os/__init__.py` for external callers only, and nothing else in the tree calls `sign_message`/`verify_message` -- so the blast radius inside AGT is nil. `MCPMessageSigner` is also Python-only; there is no Rust, Go, .NET or TypeScript twin, so there is no cross-SDK parity change to make.
Happy to gate it behind a version marker in the envelope instead if maintainers prefer a migration path over a clean break.
### Environment
- `agent-governance-python/agent-os`, `main` @ 24d5725
- Python 3.12, Windows
I have a fix with 10 regression tests ready to open as a PR.
Contributor guide
Research direction
Start in mcp_message_signer.py:275-284 and trace how sign_message and verify_message use _build_canonical_string, then read test_verify_detects_tampered_payload. Add regression coverage for separator-heavy field-boundary forgery and the None-versus-empty sender case; done means these envelopes no longer verify while valid signed envelopes still do.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cryptography, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100