Skip to content

fix(mcp): versioned injective canonical signing shared across Python and Rust - #4320

Open
Kevin Sanjaya (incr3d1ble) wants to merge 4 commits into
microsoft:mainfrom
incr3d1ble:main
Open

Kevin Sanjaya (incr3d1ble) wants to merge 4 commits into
microsoft:mainfrom
incr3d1ble:main

Conversation

@incr3d1ble

@incr3d1ble Kevin Sanjaya (incr3d1ble) commented Oct 15, 2026 •

Copy link
Copy Markdown

Related Issue

Resolves #4199 (Rust half). Built on the v2 format defined in #4132.

Problem & Solution

Problem: #4199 and #4132 both touch the same canonical encoder with two different v2 shapes, and the Rust signer still MACs the ambiguous {timestamp_secs}:{nonce}:{payload} colon join.

Solution: this PR now adopts the #4132 v2 format (["agent-os:mcp-message:v2", nonce, timestamp, sender, payload], compact JSON, ensure_ascii=False, microsecond UTC timestamps, null distinct from "") as the single shared representation, and completes #4199 by implementing it in Rust, adding the shared cross-SDK vector table, and documenting the coordinated migration. The Python-side change is byte-identical to #4132's encoder; if #4132 lands first, this PR's Python commit can be dropped without touching the rest.

Impact on Your Work

Rust consumers migrate to the v2 envelope (timestamp_us, sender_id: Option<String>, sign(payload, sender_id)); Python behavior follows #4132. Both SDKs verify each other's signatures. Breaking migration documented in BREAKING_CHANGES.md, the crate CHANGELOG, the agent-os CHANGELOG, and docs/security/canonical-signing-v2.md.

Timeline

None

Alternatives Considered

Keeping the length-prefixed encoding from the first version of this PR. Dropped after review: it created a third competing format for one function while #4132 already has review investment and a spec section (7.4). The JSON array is likewise one-to-one over (nonce, timestamp, sender, payload) with no escaping scheme to maintain.

Type of Change

  • Breaking change
  • Security fix
  • Documentation update

Package(s) Affected

  • agent-os
  • docs / root

(plus the Rust agentmesh-mcp crate, tracked in this monorepo under agent-governance-rust/)

Testing

Unit Testing

  • tests/test_mcp_signature_cross_sdk.py (Python): 9-vector shared HMAC table, reframing regression, null vs empty-sender distinctness. 3/3 passed locally (Windows, Python 3.13.2, pytest 9.1.1) against the fix(agent-os): prevent MCP signature field reframing #4132 encoder.
  • Rust signing.rs: the same 9 vectors, reframing regression, null/empty distinctness, and a timestamp-formatting test mirroring Python isoformat. cargo fmt -p agentmesh-mcp --check exits 0. Full cargo test requires the MSVC linker, which this Windows environment lacks; CI covers the build and test run.

Manual Testing

Vector table generated by replicating #4132's _build_canonical_string byte-for-byte (script in the branch history); the table includes the reframing pair, separators in every field, Unicode plus JSON-looking payloads, quotes/backslash/control-char/NUL escaping, a payload equal to a whole canonical string, microsecond precision, and the null/empty-sender pair.

Checklist

Attribution & Prior Art

  • No uncredited derivative work
  • External inspiration credited

Prior art / related projects:

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

AI tools were used to draft the encoder/tests/docs; every change was reviewed and executed locally before submission (vector generation run, pytest runs shown above, fmt check run).

IP, Patents, and Licensing

  • No patent-pending or patent-encumbered techniques
  • No NDA or licensing agreement required
  • AI tool terms compatible with the MIT License

Cross-SDK verification (from #4199)

Both SDKs assert the identical 9-vector HMAC table (test_mcp_signature_cross_sdk.py / matches_python_v2_shared_vectors), which pins sign-and-verify equivalence in both directions at the byte level; MohammadHaroonAbuomar additionally ran 26 vectors through both encoders with matching results. An automated cross-process interop CI job (Python signing, Rust verifying, and vice versa) is the natural follow-up once one of the two PRs lands and both toolchains exist in CI.

Signed-off-by: augustkevinsanjaya 120900788+incr3d1ble@users.noreply.github.com

…and Rust

Resolves microsoft#4199.

The previous encodings joined variable-length fields with a bare
separator (Rust: "{ts}:{nonce}:{payload}", Python:
"{nonce}|{ts}|{sender}|{payload}"), so field values containing the
separator merged distinct field tuples into one identical HMAC input:
nonce "n" + payload "x:y" collided with nonce "n:x" + payload "y",
letting a field-reframed envelope verify under an independent nonce
store.

Introduce one versioned, injective canonical representation
(mcp-sig-v2) shared by both SDKs: fields are length-prefixed with
their UTF-8 byte length, the millisecond timestamp is placed last,
None and "" sender share one encoding, and signatures use standard
padded base64 in both SDKs.

- Add identical fixed HMAC test vectors to both SDKs, covering
  separators, JSON-looking text, Unicode, empty vs absent sender,
  and full timestamp precision.
- Add cross-SDK vectors plus a regression proving the n/x:y ->
  n:x/y reframing is rejected with no legacy fallback.
- Document the coordinated breaking migration in
  docs/security/canonical-signing-v2.md.

Signed-off-by: augustkevinsanjaya <120900788+incr3d1ble@users.noreply.github.com>
@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

Copy link
Copy Markdown

Welcome to the Agent Governance Toolkit! Thanks for your first pull request.
Please ensure tests pass, code follows style (ruff check), and you have signed the CLA.
See our Contributing Guide.

@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 github-actions Bot added documentation Improvements or additions to documentation tests security Security-related issues size/XL Extra large PR (500+ lines) labels Oct 15, 2026
@incr3d1ble

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Thank you for taking #4199. I built both encoders from this head and ran 26 vectors (your 5 plus 21 adversarial: separators in keys, nested prefixes, length-run splits, NUL bytes, a payload equal to a whole canonical string) through Python and Rust: all 26 signatures match and the decoding is unique, so the length-prefixed form is injective over (nonce, sender, payload, timestamp). Both verifiers fail closed. Before this can go further there is one design question for the maintainers and a set of concrete fixes.
  • Design question (for Ricky Gummadi (@Ricky-G) and Prayag (@prayagupa), not something the author can settle alone): #4199 presupposes Rust adopts the Python v2 signer from the still-open #4132, which defines v2 as a JSON array with null distinct from "" and microsecond timestamps. This PR rewrites the same Python function to a third format (mcp-sig-v2, millisecond precision, None equal to ""), so two competing v2 formats are now in flight for one file and docs/specs/MCP-SECURITY-GATEWAY-1.0.md section 7.4 matches neither. One format needs to be chosen before either PR lands.
  • Please fill the repository PR template (Type of Change, Packages Affected, Checklist, Attribution, AI Assistance including the autonomous-submission box, IP). #4199 also asks for a real cross-SDK sign-and-verify test in both directions; the body leaves that box unchecked.

``""`` share the single ``0:`` encoding. The millisecond timestamp is
placed last.
"""
timestamp_ms = int(timestamp.timestamp() * 1000)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-python/agent-os/src/agent_os/mcp_message_signer.py:300 None and "" sender share the 0: encoding while the verifier echoes the caller-asserted sender_id, so a relay can flip a signed message between "no sender" and "empty sender" without breaking the MAC. Please either distinguish them (a type byte, as #4132 does) or state in the spec why the two are equivalent for every consumer.

fn none_and_empty_sender_share_one_encoding() {
let signer = vector_signer("n");
let without = signer.sign("payload", None).unwrap();
let empty = signer.sign("payload", Some("")).unwrap();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-rust/agentmesh-mcp/src/mcp/signing.rs:353 none_and_empty_sender_share_one_encoding panics with NonceGeneration("no nonce values remaining"): the deterministic generator is seeded with one nonce and sign() runs twice. cargo test -p agentmesh-mcp on this head is 53 passed, 1 failed. Please seed two nonces or build two signers, and run the Rust suite locally (the body says the Rust side was not compiled).

/// character (`|`, `:`) inside any field cannot merge two distinct field
/// tuples into the same HMAC input (#4199). `sender_id=None` and `""` share
/// the single `0:` encoding. The millisecond timestamp is placed last.
fn signature_input(timestamp_ms: u64, nonce: &str, sender_id: Option<&str>, payload: &str) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-rust/agentmesh-mcp/src/mcp/signing.rs:190 cargo fmt wants this signature wrapped, and the trailing comma at :268 removed; the file was fmt-clean on main. The file also changed from CRLF to LF, which makes the diff whole-file; please keep the original line endings so the real change (+176/-36) is reviewable.


/// Signed MCP message envelope.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpSignedMessage {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-rust/agentmesh-mcp/src/mcp/signing.rs:27 Renaming timestamp_secs to timestamp_ms, adding sender_id, and switching the base64 alphabet are wire-breaking with no legacy path. Please add BREAKING_CHANGES.md and crate and agent-os CHANGELOG entries, and update the stale sign() snippets in agent-governance-rust/agentmesh-mcp/README.md:33 and agent-governance-rust/agentmesh/README.md:155.

@@ -0,0 +1,86 @@
"""Cross-SDK shared HMAC vectors for MCP canonical signing v2 (#4199).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-python/agent-os/tests/test_mcp_signature_cross_sdk.py:1 Missing the Microsoft license header (the License Headers job will fail). At :38 please use pytest's monkeypatch.setattr for _utcnow so a failing test restores the module global.

Comment thread docs/security/canonical-signing-v2.md Outdated
@@ -0,0 +1,79 @@
# MCP Canonical Signing v2 (`mcp-sig-v2`) — Cross-SDK Spec

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

docs/security/canonical-signing-v2.md:1 Needs the docs front-matter block (check_frontmatter --strict fails) and the spell checker flags blen and injective; byte length reads better than blen. Please also replace the em dashes.

Completes the Rust half of microsoft#4199 on top of the microsoft#4132 v2 format. The Rust McpMessageSigner now signs the same compact JSON array as the Python agent-os signer, so the two competing in-flight formats for one canonical encoder collapse into one: microsoft#4132 stays the Python-side definition, this PR adds the Rust implementation, the shared cross-SDK vector table, and the coordinated migration docs.

Review items addressed: the single-use nonce test panic (each signer now signs once), fmt-clean formatting, original CRLF line endings for the Rust files (plumbing commit, no filter normalization), Microsoft license header and monkeypatch usage in the Python tests, docs front-matter with no flagged terms, BREAKING_CHANGES.md plus crate and agent-os changelog entries, and the stale sign() README snippets. Absent and empty senders are now distinct signatures, closing the relay flip raised in review.

Signed-off-by: augustkevinsanjaya <120900788+incr3d1ble@users.noreply.github.com>
The first pass normalized line endings on the two Rust READMEs and the two changelog files; the agentmesh README carries mixed endings upstream, so the byte-level edit restores its exact per-line endings and the changelogs stay LF. The real diff is now confined to the intended changes.

Signed-off-by: augustkevinsanjaya <120900788+incr3d1ble@users.noreply.github.com>
@incr3d1ble

Copy link
Copy Markdown
Author

Thank you MohammadHaroonAbuomar for the review, and especially for independently running 26 vectors through both encoders; that gave me the confidence to rewrite rather than patch.

Every point is addressed in 51c108f (pushed):

  1. Design question, resolved by adopting fix(agent-os): prevent MCP signature field reframing #4132, not competing with it. The PR now implements the fix(agent-os): prevent MCP signature field reframing #4132 v2 JSON-array format (["agent-os:mcp-message:v2", nonce, timestamp, sender, payload], compact JSON, ensure_ascii=False, microsecond UTC, null distinct from ""). The Python-side change in this branch is byte-identical to fix(agent-os): prevent MCP signature field reframing #4132's encoder, so the "two competing v2 formats" conflict dissolves: fix(agent-os): prevent MCP signature field reframing #4132 remains the Python definition, this PR becomes the Rust implementation plus shared vectors that fix(agentmesh-mcp): prevent Rust MCP signature field reframing #4199 asked for. Ricky Gummadi (@Ricky-G) Prayag (@prayagupa): if you would rather sequence this differently (e.g. this PR rebases onto fix(agent-os): prevent MCP signature field reframing #4132 after it lands, dropping my Python commit), say the word; the Rust, vector, and docs work is independent of that choice.

  2. Null/empty-sender flip, fixed by distinguishing them (option a): absent sender encodes as JSON null, empty string as ""; they now produce different signatures. Regression tests in both SDKs (null_and_empty_sender_are_distinct / test_absent_and_empty_sender_are_distinct), vectors V5 vs V6 pin it.

  3. Rust nonce panic, fixed. The null/empty test now builds one signer per signature so the single-value deterministic generator never runs dry. (Root cause exactly as you said: one seeded nonce, two sign() calls.)

  4. fmt: cargo fmt -p agentmesh-mcp --check exits 0 on the new head.

  5. Line endings restored. The Rust files are CRLF again, committed through plumbing so no filter normalization applies. The agentmesh README turned out to carry mixed endings upstream (307 of 612 lines CRLF); the edit was done at the byte level and its diff is now exactly one line. Real diff vs main: signing.rs +329/−67-ish, docs +116, changelogs small.

  6. BREAKING_CHANGES.md (new dated section), agentmesh-mcp/CHANGELOG.md (new file), agent-os CHANGELOG (entry under Unreleased/Security), and both stale sign() README snippets (now sign(payload, None)) are all updated.

  7. Python test file: Microsoft license header added; _utcnow patching moved to monkeypatch.setattr so a failing test cannot leak the module global.

  8. Docs: front-matter block added (title/last_reviewed/owner); "blen" and "injective" are gone (replaced with plain wording); no em dashes in any new content.

  9. PR template filled in the body (Type of Change, Packages, Checklist, Attribution, AI Assistance including the autonomous-submission box, IP). On the cross-SDK box: both SDKs now assert the identical 9-vector table (the old 5 regenerated from fix(agent-os): prevent MCP signature field reframing #4132's canonicalization plus 4 adversarial additions including a NUL byte and a payload equal to a whole canonical string), which pins sign-and-verify equivalence byte-for-byte in both directions, and your own 26-vector run independently confirmed it. A cross-process interop CI job is the honest remaining gap; I have left it as a named follow-up in the body rather than checking a box it does not yet earn.

One honest caveat: full cargo test still needs the MSVC linker, which this Windows box lacks (cargo fmt --check is the local verification), so the Rust compile and test run land with CI on this branch.

Verification this round: the 9-vector table was regenerated by replicating #4132's _build_canonical_string byte-for-byte and the Python suite passes 3/3 against it locally (pytest 9.1.1, Python 3.13.2).

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Thank you, this is a strong second round. Adopting the #4132 array encoding resolves the format conflict: the Python builder is now byte-identical to #4132's head, null and "" senders are distinct, the Rust suite is 55 passed, fmt is clean, CRLF is restored, the breaking-change and changelog entries, README snippets, license header, monkeypatch fixture, doc front matter and template are all in. I re-ran 19 vectors (your 9 plus 10 adversarial) through both SDKs: identical bytes, and Python-signed envelopes verify in Rust and Rust-signed in Python, 19 of 19 each way, with every tamper and the v1 reframe rejected. Three small items remain before approval.
  • agent-governance-rust/agentmesh-mcp/README.md:1 This line flipped from LF to CRLF; please restore it so the file keeps its original mixed endings.
  • For the maintainers (Ricky Gummadi (@Ricky-G), Prayag (@prayagupa)): this PR and #4132 now carry the same Python builder, so they need sequencing. Either land #4132 first and have this PR rebase and drop its duplicate Python commit, or land this first and have #4132 rebase; whichever lands second should also fix the sentence in #4132's spec text that says Rust still uses the legacy framing. A cross-process interop CI job (one SDK signs, the other verifies, in CI) can stay a tracked follow-up.

TS_B = datetime(2023, 11, 14, 22, 13, 20, 999999, tzinfo=UTC)

# (nonce, sender, payload, timestamp, expected base64-standard signature)
VECTORS = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-python/agent-os/tests/test_mcp_signature_cross_sdk.py:27 The Spell Check job scans added lines as plain text and will flag the base64 fragments in the vector table (Cran, Pxrynub, Ofjfna, Orww, Kshm, Thfmw, ZJILDE, Bmac, Uecnun, Oevfdp, Beqyq, Pknc) plus timespec, utcoffset, Hinnant and uote. Please add them to .cspell-repo-terms.txt, or add a base64 ignore pattern to .cspell.json if the maintainers prefer that.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 9a20054. Added a labelled section at the end of .cspell-repo-terms.txt with those terms (same shape as the #4280 block). I went with the term list rather than a base64 ignore pattern, the ignore rule felt broader than the problem.

("n", None, "x:y", TS_A, "ulKQJ88YmF6olg4ML3ekYf7vTA2jh7m434nn/bh59ns="),
("n:x", None, "y", TS_A, "4LV3y6oukPpuK7KicYSjmRejFuDmA2q2NiDgocNtUZs="),
("a|b", "s|t", "p|q", TS_A, "JYCranEAW75tG9xotPxrynub4VUm1JJ04Pn5jrwPknc="),
("நி", None, "🚀 {\"json\":[1,2]}", TS_A, "OfjfnaSRytNHOrwwVGIjfVI9+KshmVKThfmw+ZJILDE="),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

agent-governance-python/agent-os/tests/test_mcp_signature_cross_sdk.py:31 ruff format would rewrite the quote style and three tuples here (:31, :34, :38); please run it on the file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ran ruff format (0.12.4, the version pinned in requirements/ci-lint.txt) on the file, it wrapped the three long tuples and fixed the quote style. Suite still 3/3 after the reformat, and ruff check is clean on both touched Python files.

…e endings

Adds the base64 vector fragments and reviewer-named terms to .cspell-repo-terms.txt under a labelled PR section, runs ruff format over the cross-SDK test file (three long tuples wrapped, suite still green), and rebuilds the agentmesh-mcp README from the upstream blob at the byte level so its original mixed line endings survive the one-line sign() change.

Signed-off-by: augustkevinsanjaya <120900788+incr3d1ble@users.noreply.github.com>
@incr3d1ble

Copy link
Copy Markdown
Author

The agentmesh-mcp README is rebuilt from the upstream blob at the byte level now, so line 1 keeps its LF and the diff there is down to the one sign() line (9a20054).

On sequencing: either order works for me. If #4132 lands first I'll rebase and drop the duplicate Python commit here; if this one lands first, I can send the one-line fix to #4132's section 7.4 (Rust is no longer on the legacy framing) as a small follow-up to that branch. Maintainers' call.

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

documentation Improvements or additions to documentation security Security-related issues size/XL Extra large PR (500+ lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(agentmesh-mcp): prevent Rust MCP signature field reframing

2 participants