Skip to content

fix(agent-os): forgeable MCP signature via ambiguous canonical string - #3508

Closed
LHMQ878 wants to merge 5 commits into
microsoft:mainfrom
LHMQ878:fix/mcp-signer-canonical-ambiguity
Closed

LHMQ878 wants to merge 5 commits into
microsoft:mainfrom
LHMQ878:fix/mcp-signer-canonical-ambiguity

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3507

Problem

_build_canonical_string joined the signed fields with a plain separator:

return f"{nonce}|{timestamp_ms}|{sender_id or ''}|{payload}"

| is legal inside an MCP payload and inside a sender id, so the 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.

sender_id payload Before After
signed alice alpha|beta valid valid
forged alice|alpha beta valid rejected
signed alice|INJECTED x valid valid
forged alice INJECTED|x valid rejected
signed None p valid valid
forged "" p valid rejected

The second pair is the dangerous direction: it prepends attacker-chosen text to the payload a consumer will act on. The third is a separate collision -- sender_id or '' erased the difference between an absent sender and an empty one.

The module's other defences are all sound and all miss this. Replay protection does not apply, because a receiver has its own nonce store and has never seen the reused nonce (and the forgery can be delivered instead of the original rather than after it). The HMAC-before-nonce-commit ordering is correct and deliberate. hmac.compare_digest is used correctly -- the comparison is not the problem, the string being signed is. And test_verify_detects_tampered_payload passes because it changes the payload without compensating elsewhere; the forgery preserves the concatenation invariant.

Change

Every field is length-prefixed, and None gets a marker distinct from the empty string:

before: "n1|1785370631433|alice|alpha|beta"
after:  "2:n1|13:1785370631433|5:alice|10:alpha|beta"

The reader of each field is told how many characters it has before reading them, so a separator inside a field is just one of that field's characters and cannot be read as the end of it. The timestamp is rendered before framing so it is covered the same way as the rest.

Compatibility

This changes the signature format. A signer and a verifier must be upgraded together; envelopes signed by an older version will not verify against this one. That is unavoidable -- the old format cannot be accepted as a fallback without keeping the forgery available.

The blast radius inside AGT is nil: MCPMessageSigner is exported from agent_os/__init__.py for external callers only, and nothing else in the tree calls sign_message/verify_message. It is also Python-only -- no Rust, Go, .NET or TypeScript twin exists -- so there is no cross-SDK parity change to make.

BREAKING_CHANGES.md entry added in d98e13c7. It records that the old plain-concatenation format cannot be accepted as a verification fallback: the ambiguity is the exploit, and the attacker chooses which format their envelope claims to be, so a verifier that still honours old signatures remains forgeable. The API surface is unchanged -- a stale signature comes back as MCPVerificationResult.failed("Invalid signature.") rather than raising.

Verification

  • Injectivity, the property the fix rests on: 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 ("-") -- 270 distinct canonical strings, 0 collisions. Pinned as a test.
  • Regression value: 4 of the 19 tests in test_mcp_message_signer.py fail against unmodified mcp_message_signer.py. All 19 pass with the change.
  • Round-trip preservation: signing and verifying still succeeds for payloads and sender ids containing the separator, content shaped like the framing ("3:abc", "5:hello"), content equal to the marker ("-"), and non-ASCII content.
  • MCP suite: all 360 MCP tests pass (-k mcp), 0 failed.
  • Full suite: 4489 passed / 257 failed vs. base 4464 / 257 -- +10 new tests here (the other +15 are unrelated and not in this branch), 0 new failures.
  • Lint: ruff check output on the two files is identical to base apart from shifted line numbers (8 pre-existing findings, unchanged). ruff format --check flags the same one file on base and on the branch; the test file stays clean.
  • Spell check: clean on all 142 added lines.

Checklist

  • I have added tests that prove my fix is effective
  • I have run the relevant existing tests and they pass
  • My commit is signed off (DCO)

Copilot AI review requested due to automatic review settings July 30, 2026 00:23
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added tests size/M Medium PR (< 200 lines) labels Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: security-scanner — Security Review

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Security Review

Severity Finding Fix
High Policy engine circumvention: The original _build_canonical_string implementation allowed field boundary ambiguity, enabling attackers to forge valid signatures by moving text across boundaries. The fix introduces length-prefixed fields and a distinct marker for None, ensuring injectivity and preventing boundary ambiguity.
High Trust chain weakness: The old signature format allowed attackers to prepend arbitrary text to payloads, potentially leading to unauthorized actions by consumers. The updated canonical string format prevents such manipulations by making the encoding injective.
Medium Backward compatibility risk: The change breaks compatibility with older signature formats, requiring synchronized upgrades of signers and verifiers. Document the breaking change clearly and provide migration guidance if necessary.

No other security issues found.

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High Changed the signature format in MCPMessageSigner._build_canonical_string. Signers and verifiers must be upgraded together; older signatures will not verify.

@github-actions

Copy link
Copy Markdown
🤖 AI Agent: contributor-guide — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Welcome, and thank you for tackling this critical security issue!

Great job on the detailed explanation and comprehensive test coverage.

Before merging:

  1. Add a note to BREAKING_CHANGES.md about the signature format change for external users.
  2. Ensure the new tests are fully integrated into the CI pipeline.

Refer to CONTRIBUTING.md for guidance.

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `agent-os/src/agent_os/mcp_message_signer.py`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

agent-os/src/agent_os/mcp_message_signer.py

  • test_verify_rejects_invalid_length_prefix -- Validate that a malformed length prefix in the canonical string is rejected during signature verification.
  • test_verify_rejects_partial_field_read -- Ensure that incomplete fields (e.g., truncated payloads) in the canonical string are rejected.
  • test_verify_rejects_extra_fields -- Verify that additional unexpected fields in the canonical string result in a signature verification failure.

agent-os/tests/test_mcp_message_signer.py

  • test_verify_rejects_invalid_length_prefix -- Test that a canonical string with an invalid length prefix fails verification.
  • test_verify_rejects_partial_field_read -- Test that truncated fields in the canonical string fail verification.
  • test_verify_rejects_extra_fields -- Test that canonical strings with extra fields fail verification.

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 1 warning. [Security-critical bug fix with breaking changes; clean implementation but requires migration planning.]

# Sev Issue Where
1 Warn Breaking change to signature format; migration path not defined agent-os/src/agent_os/mcp_message_signer.py

Action items:

  1. Define and document a migration strategy for the breaking change in signature format.

Warnings:

# Issue Fine as follow-up PRs
1 Breaking change to signature format; migration path not defined Yes

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • MCPMessageSigner._canonical_field() in mcp_message_signer.py -- missing docstring
  • README.md -- no evidence of updates reflecting the breaking change in signature format
  • CHANGELOG -- missing entry for the breaking change in signature format

@github-actions

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions

Copy link
Copy Markdown

🔴 Contributor Check: HIGH

Check Result
Profile HIGH
Credential LOW
Overall HIGH

Automated check by AGT Contributor Check.

@github-actions github-actions Bot added the needs-review:HIGH Contributor reputation check flagged HIGH risk label Jul 30, 2026
@LHMQ878 LHMQ878 changed the title fix(agent-os): MCP signature forgery via ambiguous canonical string fix(agent-os): forgeable MCP signature via ambiguous canonical string Jul 30, 2026

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.

Pull request overview

Hardens agent_os’s Python-only MCPMessageSigner against signature forgery by making the signed canonical string injective (field-boundary-safe) via length-prefixed framing and a distinct None marker, and adds targeted regression tests covering the known collision/forgery cases.

TL;DR: 0 blockers, 2 warnings. Fine as follow-ups.

# Sev Issue Where
1 Warn Docstring slightly overstates “each field is length-prefixed” even though None uses a marker mcp_message_signer.py:_canonical_field
2 Warn New tests add more is True / is False boolean assertions (repo guidance prefers truthiness checks) test_mcp_message_signer.py

Changes:

  • Replace ambiguous |-joined canonicalization with framed fields to prevent cross-field reframing attacks.
  • Introduce _canonical_field() and update _build_canonical_string() to use framed components (including a None marker).
  • Add regression + injectivity tests that fail on the vulnerable implementation and pass with the fix.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
agent-governance-python/agent-os/src/agent_os/mcp_message_signer.py Implements length-prefixed canonicalization to prevent signature collisions/forgery.
agent-governance-python/agent-os/tests/test_mcp_message_signer.py Adds focused tests for boundary-shift forgeries, None vs "", and injectivity.
Comments suppressed due to low confidence (2)

agent-governance-python/agent-os/tests/test_mcp_message_signer.py:99

  • Avoid is False for boolean assertions; use not <expr> so the assertion is about truthiness rather than object identity (aligns with the repo's Python CodeQL guidance).
        assert result.is_valid is False
        assert result.payload is None

agent-governance-python/agent-os/tests/test_mcp_message_signer.py:107

  • Avoid is False for boolean assertions; use not <expr> so the assertion is about truthiness rather than object identity (aligns with the repo's Python CodeQL guidance).
        assert self._signer().verify_message(forged).is_valid is False

Comment on lines +279 to +283
Each field is prefixed with its own length, so a separator appearing
inside a field is just one of that field's characters and cannot be read
as the end of it. ``None`` gets a distinct marker rather than being
folded into the empty string, so an absent sender and an empty sender
do not sign identically.
Comment on lines +87 to +88
assert result.is_valid is False
assert "Invalid signature" in result.failure_reason
signer = self._signer()
result = signer.verify_message(signer.sign_message(payload, sender_id=sender_id))

assert result.is_valid is True
@LHMQ878
LHMQ878 force-pushed the fix/mcp-signer-canonical-ambiguity branch from 07335d6 to 605c1d8 Compare July 30, 2026 00:28
Copilot AI review requested due to automatic review settings July 30, 2026 18:35

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 30, 2026 19:28
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 30, 2026
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

BREAKING_CHANGES.md entry added in feac2b2d — that was the right ask, and the file is where this repo already records changes of this kind.

Three of the findings converge on the same point (security-scanner's Medium "backward compatibility risk", breaking-change-detector's High, code-reviewer's one warning "migration path not defined", and contributor-guide's item 1), so the entry answers all of them in one place. What it says:

The old format cannot be a verification fallback. This is the part worth being explicit about, because "provide a migration path" usually means accepting both formats for a release. Here that would keep the vulnerability: the ambiguity is the exploit, and an attacker chooses which format their envelope claims to be, so a verifier that accepts old signatures is still forgeable. There is no migration window available — the change is to what a signature means, not to how it is encoded.

The API surface is unchanged. sign_message and verify_message keep their parameters and return types, so nothing a caller wrote stops compiling. A stale signature comes back as MCPVerificationResult.failed("Invalid signature.") rather than raising, so the break shows up as a verification failure on an existing code path, not an exception the caller has to start handling. Checked the source rather than assuming: verify_message raises only for envelope is None; every other rejection returns a failed result.

What a consumer does: upgrade signer and verifier together, and re-sign anything stored signed and verified later (there is no way to convert an old signature without the key).

On the specific note that there is no in-repo consumer, so this affects external callers only: MCPMessageSigner is exported from agent_os and nothing inside the repo signs or verifies with it, which is why the entry is written for external callers rather than as an internal migration.

On the test-generator suggestions

The three proposed tests (test_verify_rejects_invalid_length_prefix, test_verify_rejects_partial_field_read, test_verify_rejects_extra_fields) describe a parser this change does not have. Length prefixes are only written, on the signing side; verification recomputes the canonical string from the envelope's own fields and compares HMACs with hmac.compare_digest. Nothing ever parses a canonical string, so there is no length prefix to malform, no partial read to attempt and no extra field to inject — an envelope whose canonical form differs in any way simply produces a different digest and fails.

That is deliberate, and it is the reason the fix takes this shape: a canonical encoder with no decoder has no parser to attack. Writing those three tests would mean asserting behaviour of code that does not exist.

What is covered instead, in the 10 tests already on the branch: the forgery in both directions, sender_id=None versus "", separator-heavy and prefix-shaped field values, and cross-field boundary moves. 4 of the file's 19 tests fail without the source change.

@github-actions github-actions Bot added size/L Large PR (< 500 lines) and removed size/M Medium PR (< 200 lines) labels Jul 30, 2026
`_build_canonical_string` joined the signed fields with a plain separator:

    f"{nonce}|{timestamp_ms}|{sender_id or ''}|{payload}"

`|` is legal inside an MCP payload and inside a sender id, so the 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:

    signed:  sender_id="alice",        payload="alpha|beta"
    forged:  sender_id="alice|alpha",  payload="beta"          -> verifies

The dangerous direction is the reverse, which prepends attacker-chosen
text to the payload a consumer will act on:

    signed:  sender_id="alice|INJECTED", payload="x"
    forged:  sender_id="alice",          payload="INJECTED|x"  -> verifies

`sender_id or ''` also erased the distinction between an absent sender and
an empty one, so an envelope signed with `sender_id=None` verified as one
sent by `""`.

Every field is now length-prefixed, and `None` gets a marker distinct from
the empty string. The reader of each field is told how many characters it
has before reading them, so no boundary can move and the encoding is
injective. Verified over 270 tuples built from separator-heavy, marker-
shaped and prefix-shaped field values: 0 collisions.

The signature format changes, so a signer and a verifier must be upgraded
together; envelopes signed by an older version will not verify against
this one. There is no in-repo consumer -- `MCPMessageSigner` is exported
from `agent_os` for external callers only -- and the previous format
cannot be kept as a fallback without keeping the forgery.

10 regression tests: 4 of the 19 tests in the file fail without the
change.

Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>
`injective` is the precise term for the property the fix rests on -- the
length-prefixed encoding maps distinct field tuples to distinct strings --
so it goes in the dictionary rather than being paraphrased away.
`neighbours` becomes `neighbors`, matching the spelling already used
elsewhere in the tree.

The job's other reported words come from files this branch does not
touch; that is a defect in how the changed-line set is computed, fixed
separately in microsoft#3530.

Signed-off-by: LHMQ878 <230791102+LHMQ878@users.noreply.github.com>
The signature format change is consumer-visible and this file is where the repo
records those, so the entry says what an external caller has to do rather than
only that something broke.

Two things it states explicitly. That the old format cannot be accepted as a
verification fallback: the ambiguity is what the forgery exploits, and an
attacker chooses which format their envelope claims to be, so a compatibility
window would keep the vulnerability rather than ease the migration. And that the
Python API is unchanged -- `sign_message` and `verify_message` keep their
signatures and return types, and a stale signature comes back as
`MCPVerificationResult.failed("Invalid signature.")` rather than raising, so the
break surfaces as a verification failure and not an exception a caller has to
start handling.

Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>

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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

agent-governance-python/agent-os/src/agent_os/mcp_message_signer.py:283

  • The docstring says each field is length-prefixed, but None is encoded as a "-" marker (not length-prefixed). Tweaking the wording avoids a misleading statement about the framing scheme.
        Each field is prefixed with its own length, so a separator appearing
        inside a field is just one of that field's characters and cannot be read
        as the end of it. ``None`` gets a distinct marker rather than being
        folded into the empty string, so an absent sender and an empty sender
        do not sign identically.

BREAKING_CHANGES.md:30

  • This entry states “Every field is now length-prefixed” but None is represented by a "-" marker instead. Updating the wording makes the breaking-change note precisely match the implementation.
`|` is legal inside a payload and inside a sender id, so the encoding was not
injective — distinct field tuples produced the same canonical string and
therefore the same HMAC. Every field is now length-prefixed and `None` gets a
marker distinct from the empty string.

Copilot AI review requested due to automatic review settings July 30, 2026 19:32
@LHMQ878
LHMQ878 force-pushed the fix/mcp-signer-canonical-ambiguity branch from feac2b2 to d98e13c Compare July 30, 2026 19:32

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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

agent-governance-python/agent-os/src/agent_os/mcp_message_signer.py:283

  • The docstring says “Each field is prefixed with its own length”, but None is encoded as the sentinel "-" (not length-prefixed). Tightening this wording helps avoid confusion for anyone re-implementing the canonicalization in another language.
        """Return *value* framed so it cannot be confused with its neighbors.

        Each field is prefixed with its own length, so a separator appearing
        inside a field is just one of that field's characters and cannot be read
        as the end of it. ``None`` gets a distinct marker rather than being
        folded into the empty string, so an absent sender and an empty sender
        do not sign identically.

BREAKING_CHANGES.md:30

  • This paragraph says “Every field is now length-prefixed”, but the implementation encodes None as a sentinel marker ("-") rather than a length-prefixed value. Clarifying the wording here will make the breaking-change note precisely match the new format.
`|` is legal inside a payload and inside a sender id, so the encoding was not
injective — distinct field tuples produced the same canonical string and
therefore the same HMAC. Every field is now length-prefixed and `None` gets a
marker distinct from the empty string.

@liamcrumm

Copy link
Copy Markdown
Contributor

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:591 still specifies canonical = payload + nonce + timestamp + (sender_id or ""). That's plain concatenation with no separator, so it has the same ambiguity this PR fixes. Since other SDKs implement against the spec, could you update section 7.4 to the length-prefixed encoding here? The signature encoding in that section is hex where the implementation uses base64.

The encoding change itself is fine. I checked it for collisions, including None and empty sender_id.

Separately, the PR body says no BREAKING_CHANGES.md entry was added, but d98e13c7 adds one.

…coding

Section 7.4 still specified `canonical = payload + nonce + timestamp +
(sender_id or "")`, the plain concatenation this branch replaced. Other
SDKs implement against the spec, so leaving it would keep publishing the
ambiguity as the interoperable format even after the Python
implementation stopped using it -- and the text was already
self-contradictory, promising a separator it then omitted.

7.4 now gives the length-framed encoding, the field order the
implementation actually uses, the null-sender marker, the
milliseconds-since-epoch rendering of the timestamp, a worked example,
and an explicit prohibition on accepting the old form (accepting both
preserves the forgery, since the attacker picks which form to claim).

Two adjacent errors in 7.3 fixed while here: the signature is
base64-encoded, not hex, and `timestamp` is a timezone-aware instant
rather than a string.

The spec could drift this far because no test read it. Three
conformance assertions now pin 7.4 against the literals in the spec text
rather than against whatever the code emits.

Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 21:52
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both right, and the spec point is the more important of the two. Fixed in a9712330.

Section 7.4

You are right that leaving it would keep publishing the ambiguity as the interoperable format — other SDKs sign against the spec, so the Python implementation quietly moving off it is worse than either one alone. The text was also self-contradictory as written: "concatenating the following fields with a separator", followed by a formula with no separator in it.

7.4 now specifies the length-framed encoding, and states the injectivity requirement as the normative property rather than just showing a formula that happens to satisfy it — a future editor changing the framing needs to know what the framing is for:

field(v)  = "-"                    if v is null
          = len(v) + ":" + v       otherwise

canonical = field(nonce) + "|" + field(timestamp) + "|" +
            field(sender_id) + "|" + field(payload)

signature = HMAC-SHA256(signing_key, UTF-8(canonical))

with the worked example 2:n1|13:1785448158059|5:alice|7:{"a":1}, the null-sender marker called out, the timestamp's milliseconds-since-epoch rendering made explicit, and the prohibition on accepting the old form written into the spec rather than left to BREAKING_CHANGES.md.

Two more divergences in the same area

Checking 7.4 against the implementation turned up two things beyond the encoding, both in §7.3 and both pre-dating this branch:

§7.3 said Actual
signature is hex-encoded base64 — base64.b64encode(digest).decode("ascii"), mcp_message_signer.py:273
timestamp is an ISO 8601 string datetime on the dataclass; there is no serializer in the module

Verified rather than read off the source: the signature round-trips through b64decode/b64encode unchanged and is 44 characters, which is base64 of a 32-byte digest — hex would be 64. The field order also differed (spec said payload + nonce + timestamp + sender_id; the code has signed nonce | timestamp | sender_id | payload since before this branch), so 7.4 now matches the code there too. I fixed these since an SDK author reading 7.3 and 7.4 together would get the encoding right and the digest encoding wrong.

Why it drifted

Nothing in test_spec_mcp_gateway_conformance.py read the canonical string or the signature encoding — test_envelope_fields only asserts the fields are not None. That is what let §7.4 describe a format the code had stopped using. Three assertions added, written against the literals in the spec text rather than against whatever the implementation returns, so the test fails if either side moves:

  • test_canonical_string_matches_the_spec — the exact 2:n1|13:... string
  • test_absent_and_empty_sender_sign_differently — - vs 0:
  • test_signature_is_base64_encoded — round trip plus length, since a hex digest is also a valid base64-alphabet string, so a character-set check would not distinguish them

Discrimination: substituting the old f"{nonce}|{ms}|{sender_id or ''}|{payload}" back in while keeping everything else fails the first two —

AssertionError: 'n1|1785448158059|alice|{"a":1}' != '2:n1|13:1785448158059|5:alice|7:{"a":1}'
AssertionError: 'n|1767225600000||p'             != '1:n|13:1767225600000|-|1:p'

One caveat on verification: test_spec_mcp_gateway_conformance.py cannot be collected on my machine — ImportError: cannot import name '_native' from partially initialized module 'agent_control_specification', which needs a compiled extension I do not have. It fails identically on the base commit with my changes stashed, so it is environmental, but it does mean I ran the three new test bodies standalone against the real MCPMessageSigner (3 ok) rather than through the suite. Worth a second look in CI.

PR body

Corrected — thanks for catching it. That paragraph was written before d98e13c7 and I updated the commit but not the description.

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

agent-governance-python/agent-os/src/agent_os/mcp_message_signer.py:318

  • timestamp_ms = int(timestamp.timestamp() * 1000) goes through a floating-point POSIX timestamp, which can introduce off-by-1ms rounding on some platforms/values (and can break cross-implementation/spec conformance). Derive epoch milliseconds using integer timedelta arithmetic instead to make the canonical string deterministic.
        timestamp_ms = int(timestamp.timestamp() * 1000)
        return "|".join(
            cls._canonical_field(field)
            for field in (nonce, str(timestamp_ms), sender_id, payload)
        )

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:600

  • The spec says fields are prefixed with their length “in characters”, but that’s ambiguous for Unicode across languages (e.g., UTF-16 code units in JS vs Unicode code points in Python). Please define the length unit precisely so independent implementations compute identical canonical strings.
Each field MUST therefore be framed with its own length in characters,
and the fields MUST be joined in the order below:

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:622

  • “base64-encoded” is underspecified and can lead to interop failures (standard base64 vs base64url; padded vs unpadded). Since the Python implementation produces RFC 4648 standard base64 with '=' padding, the spec should nail this down explicitly.
The signature MUST be base64-encoded. **[Pure Specification]**

Conflict in BREAKING_CHANGES.md is a slot collision, not a semantic one: main
added the HostSession snapshot-shape entry where this branch added the signer
canonical-string entry, both at the top of a newest-first file. Kept as two
separate entries, this branch's first.
Copilot AI review requested due to automatic review settings August 2, 2026 13:39

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (4)

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:604

  • The pseudocode should match the clarified definition of length by using the UTF-8 byte length of the field value (not a language-native string length).
          = len(v) + ":" + v       otherwise

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:599

  • “length in characters” is ambiguous for a multi-language spec (UTF-16 code units vs Unicode code points vs UTF-8 bytes) and will lead to signature mismatches across implementations. Since the HMAC input is explicitly UTF-8(canonical), define the length as the number of bytes in the field’s UTF-8 encoding.

This issue also appears on line 604 of the same file.

Each field MUST therefore be framed with its own length in characters,

docs/specs/MCP-SECURITY-GATEWAY-1.0.md:622

  • “base64-encoded” is underspecified (standard vs urlsafe alphabet, with/without padding). For interoperability, specify RFC 4648 standard base64 with '=' padding (matching Python base64.b64encode()).
The signature MUST be base64-encoded. **[Pure Specification]**

agent-governance-python/agent-os/tests/test_spec_mcp_gateway_conformance.py:333

  • This test constructs the timestamp from a decimal float; 1785448158.059 is not exactly representable as a binary float, so datetime.fromtimestamp(...) can round to an adjacent millisecond and make this assertion flaky across platforms/Python builds. Build the timestamp using integer seconds + timedelta(milliseconds=59) instead.
            timestamp=datetime.fromtimestamp(1785448158.059, tz=timezone.utc),

@MohammadHaroonAbuomar

Copy link
Copy Markdown
Collaborator

Closing per maintainer decision; this repository is not accepting submissions from this account.

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

Labels

documentation Improvements or additions to documentation needs-review:HIGH Contributor reputation check flagged HIGH risk size/L Large PR (< 500 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

5 participants