microsoft / microsoft/agent-governance-toolkit

MCP signature forgery: _build_canonical_string is not injective, so a valid envelope can be reframed across field boundaries

Open
#3,507 2 comments 0 reactions 0 assignees View on GitHub
needs-review:HIGH
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.