mcps-secure 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcps_secure-1.0.0/LICENSE +65 -0
- mcps_secure-1.0.0/PKG-INFO +102 -0
- mcps_secure-1.0.0/README.md +75 -0
- mcps_secure-1.0.0/mcp_secure/__init__.py +76 -0
- mcps_secure-1.0.0/mcp_secure/core.py +847 -0
- mcps_secure-1.0.0/mcps_secure.egg-info/PKG-INFO +102 -0
- mcps_secure-1.0.0/mcps_secure.egg-info/SOURCES.txt +10 -0
- mcps_secure-1.0.0/mcps_secure.egg-info/dependency_links.txt +1 -0
- mcps_secure-1.0.0/mcps_secure.egg-info/requires.txt +1 -0
- mcps_secure-1.0.0/mcps_secure.egg-info/top_level.txt +1 -0
- mcps_secure-1.0.0/pyproject.toml +42 -0
- mcps_secure-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Licensor: CyberSecAI Ltd
|
|
4
|
+
Licensed Work: mcp-secure v1.0.0 and later
|
|
5
|
+
Change Date: 2030-03-31
|
|
6
|
+
Change License: Apache License, Version 2.0
|
|
7
|
+
|
|
8
|
+
Additional Use Grant:
|
|
9
|
+
|
|
10
|
+
You may use the Licensed Work for non-commercial purposes, including:
|
|
11
|
+
- Personal security research and learning
|
|
12
|
+
- Academic and educational use
|
|
13
|
+
- Internal security scanning within your organisation
|
|
14
|
+
- Contributing to the Licensed Work
|
|
15
|
+
|
|
16
|
+
For commercial use (including offering the Licensed Work as a
|
|
17
|
+
hosted service, incorporating it into a commercial product, or
|
|
18
|
+
reselling it), you must obtain a commercial license from the
|
|
19
|
+
Licensor. Contact: contact@agentsign.dev
|
|
20
|
+
|
|
21
|
+
Terms
|
|
22
|
+
|
|
23
|
+
The Licensor hereby grants you the right to copy, modify, create
|
|
24
|
+
derivative works, redistribute, and make non-production use of the
|
|
25
|
+
Licensed Work. The Licensor may make an Additional Use Grant, above,
|
|
26
|
+
permitting limited production use.
|
|
27
|
+
|
|
28
|
+
Effective on the Change Date, or the fourth anniversary of the first
|
|
29
|
+
publicly available distribution of a specific version of the Licensed
|
|
30
|
+
Work under this License, whichever comes first, the Licensor hereby
|
|
31
|
+
grants you rights under the terms of the Change License, and the
|
|
32
|
+
rights granted in the paragraph above terminate.
|
|
33
|
+
|
|
34
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
35
|
+
currently in effect as described in this License, you must purchase a
|
|
36
|
+
commercial license from the Licensor, its affiliated entities, or
|
|
37
|
+
authorized resellers, or you must refrain from using the Licensed Work.
|
|
38
|
+
|
|
39
|
+
All copies of the original and modified Licensed Work, and derivative
|
|
40
|
+
works of the Licensed Work, are subject to this License. This License
|
|
41
|
+
applies separately for each version of the Licensed Work and the
|
|
42
|
+
Change Date may vary for each version of the Licensed Work released
|
|
43
|
+
by the Licensor.
|
|
44
|
+
|
|
45
|
+
You must conspicuously display this License on each original or
|
|
46
|
+
modified copy of the Licensed Work. If you receive the Licensed Work
|
|
47
|
+
in original or modified form from a third party, the terms and
|
|
48
|
+
conditions set forth in this License apply to your use of that work.
|
|
49
|
+
|
|
50
|
+
Any use of the Licensed Work in violation of this License will
|
|
51
|
+
automatically terminate your rights under this License for the
|
|
52
|
+
current and all other versions of the Licensed Work.
|
|
53
|
+
|
|
54
|
+
This License does not grant you any right in any trademark or logo of
|
|
55
|
+
the Licensor or its affiliates (provided that you may use a trademark
|
|
56
|
+
or logo of the Licensor as expressly required by this License).
|
|
57
|
+
|
|
58
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS
|
|
59
|
+
PROVIDED ON AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL
|
|
60
|
+
WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT
|
|
61
|
+
LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
62
|
+
PURPOSE, NON-INFRINGEMENT, AND TITLE.
|
|
63
|
+
|
|
64
|
+
CyberSecAI Ltd
|
|
65
|
+
cybersecai.co.uk
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcps-secure
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCPS -- MCP Secure. Cryptographic identity, message signing, and trust verification for the Model Context Protocol.
|
|
5
|
+
Author: CyberSecAI Ltd
|
|
6
|
+
License: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://mcp-secure.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/razashariff/mcps
|
|
9
|
+
Project-URL: Documentation, https://mcp-secure.dev
|
|
10
|
+
Keywords: mcp,mcps,mcp-secure,model-context-protocol,agent-security,agent-identity,passport,message-signing,ecdsa,trust,revocation,owasp,agentsign,secure-mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cryptography>=41.0.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# MCPS -- MCP Secure
|
|
29
|
+
|
|
30
|
+
**Cryptographic identity, message signing, and trust verification for the Model Context Protocol.**
|
|
31
|
+
|
|
32
|
+
The HTTPS of the agent era. MCP becomes MCPS.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install mcp-secure
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from mcp_secure import generate_key_pair, create_passport, sign_passport, verify_passport_signature
|
|
44
|
+
from mcp_secure import sign_message, verify_message, sign_tool, verify_tool
|
|
45
|
+
|
|
46
|
+
# Generate keys
|
|
47
|
+
keys = generate_key_pair()
|
|
48
|
+
|
|
49
|
+
# Create and sign a passport
|
|
50
|
+
passport = create_passport(
|
|
51
|
+
name="my-agent",
|
|
52
|
+
version="1.0.0",
|
|
53
|
+
public_key=keys["public_key"],
|
|
54
|
+
capabilities=["read", "write"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Trust Authority signs the passport
|
|
58
|
+
ta_keys = generate_key_pair()
|
|
59
|
+
signed = sign_passport(passport, ta_keys["private_key"])
|
|
60
|
+
assert verify_passport_signature(signed, ta_keys["public_key"])
|
|
61
|
+
|
|
62
|
+
# Sign MCP messages
|
|
63
|
+
envelope = sign_message(
|
|
64
|
+
{"jsonrpc": "2.0", "method": "tools/list", "id": 1},
|
|
65
|
+
signed["passport_id"],
|
|
66
|
+
keys["private_key"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Verify
|
|
70
|
+
result = verify_message(envelope, keys["public_key"])
|
|
71
|
+
assert result["valid"]
|
|
72
|
+
|
|
73
|
+
# Tool integrity
|
|
74
|
+
tool = {"name": "read_file", "description": "Read a file", "inputSchema": {"type": "object"}}
|
|
75
|
+
sig = sign_tool(tool, keys["private_key"])
|
|
76
|
+
assert verify_tool(tool, sig, keys["public_key"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What MCPS Adds to MCP
|
|
80
|
+
|
|
81
|
+
| Feature | Description |
|
|
82
|
+
|---------|-------------|
|
|
83
|
+
| Agent Passports | ECDSA P-256 signed identity credentials |
|
|
84
|
+
| Message Signing | Every JSON-RPC message wrapped in signed envelope |
|
|
85
|
+
| Tool Integrity | Signed tool definitions prevent poisoning |
|
|
86
|
+
| Replay Protection | Nonce + 5-min timestamp window |
|
|
87
|
+
| Revocation | Real-time passport revocation via Trust Authority |
|
|
88
|
+
| Trust Levels | L0 (unsigned) to L4 (audited) |
|
|
89
|
+
|
|
90
|
+
## OWASP MCP Top 10
|
|
91
|
+
|
|
92
|
+
Mitigates 8/10 OWASP MCP vulnerabilities: tool poisoning, supply chain attacks, auth bypass, shadow servers, and more.
|
|
93
|
+
|
|
94
|
+
## Links
|
|
95
|
+
|
|
96
|
+
- **Website**: [mcp-secure.dev](https://mcp-secure.dev)
|
|
97
|
+
- **npm**: [mcp-secure](https://www.npmjs.com/package/mcp-secure)
|
|
98
|
+
- **Spec**: [SPEC.md](https://github.com/razashariff/mcps/blob/main/SPEC.md)
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT -- CyberSecAI Ltd
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# MCPS -- MCP Secure
|
|
2
|
+
|
|
3
|
+
**Cryptographic identity, message signing, and trust verification for the Model Context Protocol.**
|
|
4
|
+
|
|
5
|
+
The HTTPS of the agent era. MCP becomes MCPS.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install mcp-secure
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from mcp_secure import generate_key_pair, create_passport, sign_passport, verify_passport_signature
|
|
17
|
+
from mcp_secure import sign_message, verify_message, sign_tool, verify_tool
|
|
18
|
+
|
|
19
|
+
# Generate keys
|
|
20
|
+
keys = generate_key_pair()
|
|
21
|
+
|
|
22
|
+
# Create and sign a passport
|
|
23
|
+
passport = create_passport(
|
|
24
|
+
name="my-agent",
|
|
25
|
+
version="1.0.0",
|
|
26
|
+
public_key=keys["public_key"],
|
|
27
|
+
capabilities=["read", "write"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Trust Authority signs the passport
|
|
31
|
+
ta_keys = generate_key_pair()
|
|
32
|
+
signed = sign_passport(passport, ta_keys["private_key"])
|
|
33
|
+
assert verify_passport_signature(signed, ta_keys["public_key"])
|
|
34
|
+
|
|
35
|
+
# Sign MCP messages
|
|
36
|
+
envelope = sign_message(
|
|
37
|
+
{"jsonrpc": "2.0", "method": "tools/list", "id": 1},
|
|
38
|
+
signed["passport_id"],
|
|
39
|
+
keys["private_key"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Verify
|
|
43
|
+
result = verify_message(envelope, keys["public_key"])
|
|
44
|
+
assert result["valid"]
|
|
45
|
+
|
|
46
|
+
# Tool integrity
|
|
47
|
+
tool = {"name": "read_file", "description": "Read a file", "inputSchema": {"type": "object"}}
|
|
48
|
+
sig = sign_tool(tool, keys["private_key"])
|
|
49
|
+
assert verify_tool(tool, sig, keys["public_key"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## What MCPS Adds to MCP
|
|
53
|
+
|
|
54
|
+
| Feature | Description |
|
|
55
|
+
|---------|-------------|
|
|
56
|
+
| Agent Passports | ECDSA P-256 signed identity credentials |
|
|
57
|
+
| Message Signing | Every JSON-RPC message wrapped in signed envelope |
|
|
58
|
+
| Tool Integrity | Signed tool definitions prevent poisoning |
|
|
59
|
+
| Replay Protection | Nonce + 5-min timestamp window |
|
|
60
|
+
| Revocation | Real-time passport revocation via Trust Authority |
|
|
61
|
+
| Trust Levels | L0 (unsigned) to L4 (audited) |
|
|
62
|
+
|
|
63
|
+
## OWASP MCP Top 10
|
|
64
|
+
|
|
65
|
+
Mitigates 8/10 OWASP MCP vulnerabilities: tool poisoning, supply chain attacks, auth bypass, shadow servers, and more.
|
|
66
|
+
|
|
67
|
+
## Links
|
|
68
|
+
|
|
69
|
+
- **Website**: [mcp-secure.dev](https://mcp-secure.dev)
|
|
70
|
+
- **npm**: [mcp-secure](https://www.npmjs.com/package/mcp-secure)
|
|
71
|
+
- **Spec**: [SPEC.md](https://github.com/razashariff/mcps/blob/main/SPEC.md)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT -- CyberSecAI Ltd
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCPS -- MCP Secure
|
|
3
|
+
Cryptographic identity, message signing, and trust verification for MCP.
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2026 CyberSecAI Ltd. All rights reserved.
|
|
6
|
+
License: MIT
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .core import (
|
|
10
|
+
MCPS_VERSION,
|
|
11
|
+
SUPPORTED_VERSIONS,
|
|
12
|
+
TRUST_LEVELS,
|
|
13
|
+
ERROR_CODES,
|
|
14
|
+
MAX_ISSUER_CHAIN_DEPTH,
|
|
15
|
+
MAX_PASSPORT_BYTES,
|
|
16
|
+
MAX_CAPABILITIES,
|
|
17
|
+
generate_key_pair,
|
|
18
|
+
public_key_to_jwk,
|
|
19
|
+
create_passport,
|
|
20
|
+
sign_passport,
|
|
21
|
+
verify_passport_signature,
|
|
22
|
+
is_passport_expired,
|
|
23
|
+
validate_passport_format,
|
|
24
|
+
validate_origin,
|
|
25
|
+
get_effective_trust_level,
|
|
26
|
+
verify_issuer_chain,
|
|
27
|
+
sign_message,
|
|
28
|
+
verify_message,
|
|
29
|
+
sign_tool,
|
|
30
|
+
verify_tool,
|
|
31
|
+
create_transcript_binding,
|
|
32
|
+
verify_transcript_binding,
|
|
33
|
+
create_transcript_mac,
|
|
34
|
+
verify_transcript_mac,
|
|
35
|
+
check_revocation,
|
|
36
|
+
negotiate_version,
|
|
37
|
+
secure_mcp,
|
|
38
|
+
secure_mcp_client,
|
|
39
|
+
NonceStore,
|
|
40
|
+
canonical_json,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "1.0.0"
|
|
44
|
+
__all__ = [
|
|
45
|
+
"MCPS_VERSION",
|
|
46
|
+
"SUPPORTED_VERSIONS",
|
|
47
|
+
"TRUST_LEVELS",
|
|
48
|
+
"ERROR_CODES",
|
|
49
|
+
"MAX_ISSUER_CHAIN_DEPTH",
|
|
50
|
+
"MAX_PASSPORT_BYTES",
|
|
51
|
+
"MAX_CAPABILITIES",
|
|
52
|
+
"generate_key_pair",
|
|
53
|
+
"public_key_to_jwk",
|
|
54
|
+
"create_passport",
|
|
55
|
+
"sign_passport",
|
|
56
|
+
"verify_passport_signature",
|
|
57
|
+
"is_passport_expired",
|
|
58
|
+
"validate_passport_format",
|
|
59
|
+
"validate_origin",
|
|
60
|
+
"get_effective_trust_level",
|
|
61
|
+
"verify_issuer_chain",
|
|
62
|
+
"sign_message",
|
|
63
|
+
"verify_message",
|
|
64
|
+
"sign_tool",
|
|
65
|
+
"verify_tool",
|
|
66
|
+
"create_transcript_binding",
|
|
67
|
+
"verify_transcript_binding",
|
|
68
|
+
"create_transcript_mac",
|
|
69
|
+
"verify_transcript_mac",
|
|
70
|
+
"check_revocation",
|
|
71
|
+
"negotiate_version",
|
|
72
|
+
"secure_mcp",
|
|
73
|
+
"secure_mcp_client",
|
|
74
|
+
"NonceStore",
|
|
75
|
+
"canonical_json",
|
|
76
|
+
]
|
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCPS -- MCP Secure (Core)
|
|
3
|
+
Cryptographic security layer for the Model Context Protocol.
|
|
4
|
+
|
|
5
|
+
Specification: SEP-2395 (Cryptographic Security Layer for MCP)
|
|
6
|
+
Canonicalization: RFC 8785 (JSON Canonicalization Scheme)
|
|
7
|
+
Signing: ECDSA P-256 (NIST FIPS 186-5, RFC 6979)
|
|
8
|
+
Signature Format: IEEE P1363 r||s fixed-length (RFC 7518 Section 3.4)
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 CyberSecAI Ltd. All rights reserved.
|
|
11
|
+
Patent: GB2604808.2
|
|
12
|
+
License: MIT
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.request
|
|
21
|
+
import urllib.error
|
|
22
|
+
from datetime import datetime, timezone, timedelta
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
|
|
25
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
26
|
+
from cryptography.exceptions import InvalidSignature
|
|
27
|
+
import base64
|
|
28
|
+
import math
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
MCPS_VERSION = "1.0"
|
|
32
|
+
SUPPORTED_VERSIONS = ["1.0"]
|
|
33
|
+
NONCE_BYTES = 16
|
|
34
|
+
TIMESTAMP_WINDOW_S = 5 * 60 # 5 minutes
|
|
35
|
+
DEFAULT_TRUST_AUTHORITY = "https://agentsign.dev"
|
|
36
|
+
PASSPORT_PREFIX = "asp_"
|
|
37
|
+
|
|
38
|
+
# P-256 curve order (FIPS 186-5) for low-S normalization
|
|
39
|
+
P256_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
|
40
|
+
P256_HALF_ORDER = P256_ORDER >> 1
|
|
41
|
+
P1363_SIG_LENGTH = 64 # 32 bytes r + 32 bytes s for P-256
|
|
42
|
+
|
|
43
|
+
# Passport size limits (DoS prevention)
|
|
44
|
+
MAX_ISSUER_CHAIN_DEPTH = 5
|
|
45
|
+
MAX_PASSPORT_BYTES = 8192
|
|
46
|
+
MAX_CAPABILITIES = 64
|
|
47
|
+
|
|
48
|
+
TRUST_LEVELS = {
|
|
49
|
+
"UNSIGNED": 0, # Plain MCP, no MCPS -- self-signed passports capped here
|
|
50
|
+
"IDENTIFIED": 1, # Passport signed by any Trust Authority
|
|
51
|
+
"VERIFIED": 2, # Passport signed by recognized TA (in verifier's trust store)
|
|
52
|
+
"SCANNED": 3, # Verified + TA verified origin ownership
|
|
53
|
+
"AUDITED": 4, # Scanned + TA performed security audit + revocation required
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Error codes: string code + JSON-RPC numeric code
|
|
57
|
+
# Numeric codes use -33xxx to avoid collision with JSON-RPC reserved range (-32000 to -32099)
|
|
58
|
+
ERROR_CODES = {
|
|
59
|
+
"INVALID_PASSPORT": {"code": "MCPS-001", "jsonrpc_code": -33001, "message": "Invalid passport format"},
|
|
60
|
+
"PASSPORT_EXPIRED": {"code": "MCPS-002", "jsonrpc_code": -33002, "message": "Passport expired"},
|
|
61
|
+
"PASSPORT_REVOKED": {"code": "MCPS-003", "jsonrpc_code": -33003, "message": "Passport revoked"},
|
|
62
|
+
"INVALID_SIGNATURE": {"code": "MCPS-004", "jsonrpc_code": -33004, "message": "Invalid message signature"},
|
|
63
|
+
"REPLAY_ATTACK": {"code": "MCPS-005", "jsonrpc_code": -33005, "message": "Replay attack detected"},
|
|
64
|
+
"TIMESTAMP_OUT_OF_WINDOW": {"code": "MCPS-006", "jsonrpc_code": -33006, "message": "Timestamp out of window"},
|
|
65
|
+
"AUTHORITY_UNREACHABLE": {"code": "MCPS-007", "jsonrpc_code": -33007, "message": "Trust authority unreachable"},
|
|
66
|
+
"TOOL_INTEGRITY_FAILED": {"code": "MCPS-008", "jsonrpc_code": -33008, "message": "Tool definition signature invalid or hash changed"},
|
|
67
|
+
"INSUFFICIENT_TRUST": {"code": "MCPS-009", "jsonrpc_code": -33009, "message": "Insufficient trust level"},
|
|
68
|
+
"RATE_LIMITED": {"code": "MCPS-010", "jsonrpc_code": -33010, "message": "Rate limit exceeded"},
|
|
69
|
+
"ORIGIN_MISMATCH": {"code": "MCPS-011", "jsonrpc_code": -33011, "message": "Passport origin does not match server URI"},
|
|
70
|
+
"CAPABILITY_MISMATCH": {"code": "MCPS-012", "jsonrpc_code": -33012, "message": "Transcript binding verification failed (downgrade detected)"},
|
|
71
|
+
"PASSPORT_TOO_LARGE": {"code": "MCPS-013", "jsonrpc_code": -33013, "message": "Passport exceeds maximum size"},
|
|
72
|
+
"CHAIN_TOO_DEEP": {"code": "MCPS-014", "jsonrpc_code": -33014, "message": "Issuer chain exceeds maximum depth"},
|
|
73
|
+
"VERSION_MISMATCH": {"code": "MCPS-015", "jsonrpc_code": -33015, "message": "No mutually supported MCPS version"},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── RFC 8785: JSON Canonicalization Scheme ──
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canonical_json(obj):
|
|
81
|
+
"""Deterministic JSON serialization per RFC 8785 (JCS).
|
|
82
|
+
|
|
83
|
+
Guarantees identical bytes across Python and Node.js for the same logical value.
|
|
84
|
+
|
|
85
|
+
Key properties:
|
|
86
|
+
- Object keys sorted lexicographically (Unicode code point order)
|
|
87
|
+
- No whitespace between tokens
|
|
88
|
+
- Numbers: ES2024 Number.toString() semantics (1.0 -> "1", no trailing zeros)
|
|
89
|
+
- Strings: minimal JSON escaping
|
|
90
|
+
- Recursive for nested objects
|
|
91
|
+
"""
|
|
92
|
+
if obj is None:
|
|
93
|
+
return "null"
|
|
94
|
+
if isinstance(obj, bool):
|
|
95
|
+
return "true" if obj else "false"
|
|
96
|
+
if isinstance(obj, int):
|
|
97
|
+
return str(obj)
|
|
98
|
+
if isinstance(obj, float):
|
|
99
|
+
if math.isinf(obj) or math.isnan(obj):
|
|
100
|
+
raise ValueError("Infinity/NaN not allowed in JCS")
|
|
101
|
+
if obj == 0.0:
|
|
102
|
+
return "0" # handles -0.0
|
|
103
|
+
# ES2024 Number.toString(): integers as integers, shortest float representation
|
|
104
|
+
if obj == int(obj) and abs(obj) < 2**53:
|
|
105
|
+
return str(int(obj))
|
|
106
|
+
return repr(obj)
|
|
107
|
+
if isinstance(obj, str):
|
|
108
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
109
|
+
if isinstance(obj, list):
|
|
110
|
+
return "[" + ",".join(canonical_json(item) for item in obj) + "]"
|
|
111
|
+
if isinstance(obj, dict):
|
|
112
|
+
keys = sorted(obj.keys())
|
|
113
|
+
return "{" + ",".join(
|
|
114
|
+
json.dumps(k, ensure_ascii=False) + ":" + canonical_json(obj[k]) for k in keys
|
|
115
|
+
) + "}"
|
|
116
|
+
return json.dumps(obj)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── ECDSA P1363 Signing with Low-S ──
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sign_bytes(data_bytes, private_key_pem):
|
|
123
|
+
"""Sign bytes with ECDSA P-256 SHA-256, IEEE P1363 format with low-S normalization.
|
|
124
|
+
|
|
125
|
+
Output: base64-encoded r||s (exactly 64 bytes for P-256).
|
|
126
|
+
Low-S normalization prevents signature malleability (BIP-0062).
|
|
127
|
+
Format per RFC 7518 Section 3.4.
|
|
128
|
+
"""
|
|
129
|
+
private_key = serialization.load_pem_private_key(
|
|
130
|
+
private_key_pem.encode(), password=None
|
|
131
|
+
)
|
|
132
|
+
der_sig = private_key.sign(data_bytes, ec.ECDSA(hashes.SHA256()))
|
|
133
|
+
r, s = decode_dss_signature(der_sig)
|
|
134
|
+
|
|
135
|
+
# Low-S normalization: if s > n/2, replace with n - s
|
|
136
|
+
if s > P256_HALF_ORDER:
|
|
137
|
+
s = P256_ORDER - s
|
|
138
|
+
|
|
139
|
+
# IEEE P1363: r||s, each 32 bytes for P-256
|
|
140
|
+
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
141
|
+
return base64.b64encode(raw_sig).decode()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _verify_bytes(data_bytes, signature_b64, public_key_pem):
|
|
145
|
+
"""Verify ECDSA P1363 signature with low-S normalization.
|
|
146
|
+
|
|
147
|
+
Accepts and normalizes high-S signatures for interoperability.
|
|
148
|
+
"""
|
|
149
|
+
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
|
150
|
+
try:
|
|
151
|
+
raw_sig = base64.b64decode(signature_b64)
|
|
152
|
+
if len(raw_sig) != P1363_SIG_LENGTH:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
# Parse IEEE P1363 format
|
|
156
|
+
r = int.from_bytes(raw_sig[:32], "big")
|
|
157
|
+
s = int.from_bytes(raw_sig[32:], "big")
|
|
158
|
+
|
|
159
|
+
# Low-S normalization for verification
|
|
160
|
+
if s > P256_HALF_ORDER:
|
|
161
|
+
s = P256_ORDER - s
|
|
162
|
+
|
|
163
|
+
# Convert to DER for cryptography library
|
|
164
|
+
der_sig = encode_dss_signature(r, s)
|
|
165
|
+
public_key.verify(der_sig, data_bytes, ec.ECDSA(hashes.SHA256()))
|
|
166
|
+
return True
|
|
167
|
+
except (InvalidSignature, Exception):
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Key Generation ──
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate_key_pair():
|
|
175
|
+
"""Generate an ECDSA P-256 key pair for MCPS signing."""
|
|
176
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
177
|
+
private_pem = private_key.private_bytes(
|
|
178
|
+
encoding=serialization.Encoding.PEM,
|
|
179
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
180
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
181
|
+
).decode()
|
|
182
|
+
public_pem = private_key.public_key().public_bytes(
|
|
183
|
+
encoding=serialization.Encoding.PEM,
|
|
184
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
185
|
+
).decode()
|
|
186
|
+
return {"public_key": public_pem, "private_key": private_pem}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _int_to_base64url(n, length):
|
|
190
|
+
"""Convert integer to base64url-encoded bytes of given length."""
|
|
191
|
+
b = n.to_bytes(length, byteorder="big")
|
|
192
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def public_key_to_jwk(public_key_pem):
|
|
196
|
+
"""Export public key as compact JWK for passport embedding."""
|
|
197
|
+
pub_key = serialization.load_pem_public_key(public_key_pem.encode())
|
|
198
|
+
numbers = pub_key.public_numbers()
|
|
199
|
+
return {
|
|
200
|
+
"kty": "EC",
|
|
201
|
+
"crv": "P-256",
|
|
202
|
+
"x": _int_to_base64url(numbers.x, 32),
|
|
203
|
+
"y": _int_to_base64url(numbers.y, 32),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Passport ──
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_passport(name, version="1.0.0", public_key=None, origin=None,
|
|
211
|
+
capabilities=None, scan_results=None, ttl_days=365,
|
|
212
|
+
issuer="self", issuer_chain=None, previous_key_hash=None):
|
|
213
|
+
"""Create an agent passport with origin binding.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
name: Agent name
|
|
217
|
+
version: Agent version
|
|
218
|
+
public_key: PEM public key string
|
|
219
|
+
origin: URI of the authorized server (e.g. https://api.example.com)
|
|
220
|
+
capabilities: List of agent capabilities (max 64)
|
|
221
|
+
scan_results: SDLC scan results dict
|
|
222
|
+
ttl_days: Days until expiration
|
|
223
|
+
issuer: Issuer identifier
|
|
224
|
+
issuer_chain: Chain of base64url-encoded intermediate TA passports (max depth 5)
|
|
225
|
+
previous_key_hash: SHA-256 hash of previous public key (for key rotation)
|
|
226
|
+
"""
|
|
227
|
+
passport_id = PASSPORT_PREFIX + os.urandom(16).hex()
|
|
228
|
+
now = datetime.now(timezone.utc)
|
|
229
|
+
expires = now + timedelta(days=ttl_days)
|
|
230
|
+
|
|
231
|
+
# Self-signed passports are ALWAYS L0 per SEP-2395 Section 6.1
|
|
232
|
+
is_self_signed = not issuer or issuer == "self"
|
|
233
|
+
if is_self_signed:
|
|
234
|
+
trust_level = TRUST_LEVELS["UNSIGNED"]
|
|
235
|
+
elif scan_results:
|
|
236
|
+
trust_level = TRUST_LEVELS["SCANNED"]
|
|
237
|
+
else:
|
|
238
|
+
trust_level = TRUST_LEVELS["IDENTIFIED"]
|
|
239
|
+
|
|
240
|
+
passport = {
|
|
241
|
+
"mcps_version": MCPS_VERSION,
|
|
242
|
+
"passport_id": passport_id,
|
|
243
|
+
"agent": {
|
|
244
|
+
"name": name,
|
|
245
|
+
"version": version,
|
|
246
|
+
"capabilities": (capabilities or [])[:MAX_CAPABILITIES],
|
|
247
|
+
},
|
|
248
|
+
"public_key": public_key_to_jwk(public_key) if public_key else None,
|
|
249
|
+
"origin": origin,
|
|
250
|
+
"scan": scan_results,
|
|
251
|
+
"trust_level": trust_level,
|
|
252
|
+
"issued_at": now.isoformat().replace("+00:00", "Z"),
|
|
253
|
+
"expires_at": expires.isoformat().replace("+00:00", "Z"),
|
|
254
|
+
"issuer": issuer,
|
|
255
|
+
"issuer_chain": (issuer_chain or [])[:MAX_ISSUER_CHAIN_DEPTH],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Key rotation: link to previous key for compromise recovery
|
|
259
|
+
if previous_key_hash:
|
|
260
|
+
passport["key_rotation"] = {
|
|
261
|
+
"previous_key_hash": previous_key_hash,
|
|
262
|
+
"rotated_at": now.isoformat().replace("+00:00", "Z"),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return passport
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def sign_passport(passport, authority_private_key):
|
|
269
|
+
"""Sign a passport with the Trust Authority's private key.
|
|
270
|
+
Uses RFC 8785 (JCS) for canonical serialization before signing.
|
|
271
|
+
Signature: IEEE P1363 r||s with low-S normalization."""
|
|
272
|
+
payload = canonical_json(passport).encode()
|
|
273
|
+
signature = _sign_bytes(payload, authority_private_key)
|
|
274
|
+
return {**passport, "signature": signature}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def verify_passport_signature(passport, authority_public_key):
|
|
278
|
+
"""Verify a passport's signature against the Trust Authority's public key."""
|
|
279
|
+
sig = passport.get("signature")
|
|
280
|
+
if not sig:
|
|
281
|
+
return False
|
|
282
|
+
rest = {k: v for k, v in passport.items() if k != "signature"}
|
|
283
|
+
payload = canonical_json(rest).encode()
|
|
284
|
+
return _verify_bytes(payload, sig, authority_public_key)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def is_passport_expired(passport):
|
|
288
|
+
"""Check if a passport has expired."""
|
|
289
|
+
expires = passport.get("expires_at", "")
|
|
290
|
+
if not expires:
|
|
291
|
+
return True
|
|
292
|
+
exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
293
|
+
return exp_dt < datetime.now(timezone.utc)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def validate_passport_format(passport):
|
|
297
|
+
"""Validate passport format including size limits."""
|
|
298
|
+
if not passport:
|
|
299
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_PASSPORT"]}
|
|
300
|
+
pid = passport.get("passport_id", "")
|
|
301
|
+
if not pid or not pid.startswith(PASSPORT_PREFIX):
|
|
302
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_PASSPORT"]}
|
|
303
|
+
if not passport.get("public_key"):
|
|
304
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_PASSPORT"]}
|
|
305
|
+
if not passport.get("issued_at") or not passport.get("expires_at"):
|
|
306
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_PASSPORT"]}
|
|
307
|
+
if is_passport_expired(passport):
|
|
308
|
+
return {"valid": False, "error": ERROR_CODES["PASSPORT_EXPIRED"]}
|
|
309
|
+
|
|
310
|
+
# Size limits (DoS prevention)
|
|
311
|
+
chain = passport.get("issuer_chain", [])
|
|
312
|
+
if len(chain) > MAX_ISSUER_CHAIN_DEPTH:
|
|
313
|
+
return {"valid": False, "error": ERROR_CODES["CHAIN_TOO_DEEP"]}
|
|
314
|
+
serialized = canonical_json(passport)
|
|
315
|
+
if len(serialized.encode("utf-8")) > MAX_PASSPORT_BYTES:
|
|
316
|
+
return {"valid": False, "error": ERROR_CODES["PASSPORT_TOO_LARGE"]}
|
|
317
|
+
|
|
318
|
+
return {"valid": True, "error": None}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def validate_origin(passport, expected_origin):
|
|
322
|
+
"""Validate that a passport's origin matches the expected server URI.
|
|
323
|
+
Origin comparison uses scheme + authority (host + port) per RFC 6454."""
|
|
324
|
+
passport_origin = passport.get("origin")
|
|
325
|
+
if not passport_origin:
|
|
326
|
+
return {"valid": False, "error": ERROR_CODES["ORIGIN_MISMATCH"]}
|
|
327
|
+
if not expected_origin:
|
|
328
|
+
return {"valid": True, "error": None}
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
p = urlparse(passport_origin)
|
|
332
|
+
e = urlparse(expected_origin)
|
|
333
|
+
match = (p.scheme == e.scheme and p.hostname == e.hostname
|
|
334
|
+
and (p.port or "") == (e.port or ""))
|
|
335
|
+
if match:
|
|
336
|
+
return {"valid": True, "error": None}
|
|
337
|
+
return {"valid": False, "error": ERROR_CODES["ORIGIN_MISMATCH"]}
|
|
338
|
+
except Exception:
|
|
339
|
+
return {"valid": False, "error": ERROR_CODES["ORIGIN_MISMATCH"]}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def get_effective_trust_level(passport, trusted_issuers=None):
|
|
343
|
+
"""Get the effective trust level for a passport.
|
|
344
|
+
Self-signed passports are ALWAYS capped at L0.
|
|
345
|
+
Unknown issuers are treated as L0."""
|
|
346
|
+
issuer = passport.get("issuer", "self")
|
|
347
|
+
if not issuer or issuer == "self":
|
|
348
|
+
return TRUST_LEVELS["UNSIGNED"]
|
|
349
|
+
if trusted_issuers is not None and issuer not in trusted_issuers:
|
|
350
|
+
return TRUST_LEVELS["UNSIGNED"]
|
|
351
|
+
return passport.get("trust_level", TRUST_LEVELS["UNSIGNED"])
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def verify_issuer_chain(passport, trust_store):
|
|
355
|
+
"""Verify an issuer chain from agent passport up to a trusted root.
|
|
356
|
+
|
|
357
|
+
Each issuer_chain entry is a base64-encoded JSON intermediate TA passport
|
|
358
|
+
containing its own signature, public_key, issuer, and other standard fields.
|
|
359
|
+
|
|
360
|
+
Chain verification walks: agent -> intermediate(s) -> root TA.
|
|
361
|
+
Max chain depth: MAX_ISSUER_CHAIN_DEPTH (5).
|
|
362
|
+
"""
|
|
363
|
+
issuer = passport.get("issuer")
|
|
364
|
+
if not issuer:
|
|
365
|
+
return {"valid": False, "chain_length": 0, "root_issuer": None}
|
|
366
|
+
|
|
367
|
+
chain = passport.get("issuer_chain", [])
|
|
368
|
+
if len(chain) > MAX_ISSUER_CHAIN_DEPTH:
|
|
369
|
+
return {"valid": False, "chain_length": len(chain), "root_issuer": None, "error": "chain_too_deep"}
|
|
370
|
+
|
|
371
|
+
# Direct trust: issuer is in trust store
|
|
372
|
+
if issuer in trust_store:
|
|
373
|
+
is_valid = verify_passport_signature(passport, trust_store[issuer])
|
|
374
|
+
return {"valid": is_valid, "chain_length": 1,
|
|
375
|
+
"root_issuer": issuer if is_valid else None}
|
|
376
|
+
|
|
377
|
+
if not chain:
|
|
378
|
+
return {"valid": False, "chain_length": 0, "root_issuer": None}
|
|
379
|
+
|
|
380
|
+
# Decode chain entries: each is base64-encoded JSON intermediate TA passport
|
|
381
|
+
intermediates = []
|
|
382
|
+
for entry in chain:
|
|
383
|
+
try:
|
|
384
|
+
decoded = base64.b64decode(entry).decode("utf-8")
|
|
385
|
+
intermediates.append(json.loads(decoded))
|
|
386
|
+
except Exception:
|
|
387
|
+
return {"valid": False, "chain_length": len(chain),
|
|
388
|
+
"root_issuer": None, "error": "invalid_chain_entry"}
|
|
389
|
+
|
|
390
|
+
# Walk chain: verify each link from agent passport up to root
|
|
391
|
+
current = passport
|
|
392
|
+
for i, intermediate in enumerate(intermediates):
|
|
393
|
+
pub_key_jwk = intermediate.get("public_key")
|
|
394
|
+
if not pub_key_jwk:
|
|
395
|
+
return {"valid": False, "chain_length": i,
|
|
396
|
+
"root_issuer": None, "error": "intermediate_missing_key"}
|
|
397
|
+
|
|
398
|
+
# Convert intermediate's JWK to PEM
|
|
399
|
+
try:
|
|
400
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers, SECP256R1
|
|
401
|
+
x = int.from_bytes(base64.urlsafe_b64decode(pub_key_jwk["x"] + "=="), "big")
|
|
402
|
+
y = int.from_bytes(base64.urlsafe_b64decode(pub_key_jwk["y"] + "=="), "big")
|
|
403
|
+
pub_numbers = EllipticCurvePublicNumbers(x, y, SECP256R1())
|
|
404
|
+
pub_obj = pub_numbers.public_key()
|
|
405
|
+
intermediate_pem = pub_obj.public_bytes(
|
|
406
|
+
serialization.Encoding.PEM,
|
|
407
|
+
serialization.PublicFormat.SubjectPublicKeyInfo
|
|
408
|
+
).decode()
|
|
409
|
+
except Exception:
|
|
410
|
+
return {"valid": False, "chain_length": i,
|
|
411
|
+
"root_issuer": None, "error": "invalid_intermediate_key"}
|
|
412
|
+
|
|
413
|
+
# Verify current passport was signed by this intermediate
|
|
414
|
+
if not verify_passport_signature(current, intermediate_pem):
|
|
415
|
+
return {"valid": False, "chain_length": i,
|
|
416
|
+
"root_issuer": None, "error": "signature_verification_failed"}
|
|
417
|
+
|
|
418
|
+
# Check if this intermediate's issuer is a trusted root
|
|
419
|
+
int_issuer = intermediate.get("issuer")
|
|
420
|
+
if int_issuer and int_issuer in trust_store:
|
|
421
|
+
if verify_passport_signature(intermediate, trust_store[int_issuer]):
|
|
422
|
+
return {"valid": True, "chain_length": i + 2,
|
|
423
|
+
"root_issuer": int_issuer}
|
|
424
|
+
|
|
425
|
+
current = intermediate
|
|
426
|
+
|
|
427
|
+
return {"valid": False, "chain_length": len(intermediates),
|
|
428
|
+
"root_issuer": None, "error": "no_trusted_root"}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ── Message Signing ──
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def sign_message(mcp_message, passport_id, private_key, channel_binding=None):
|
|
435
|
+
"""Wrap an MCP JSON-RPC message in an MCPS signed envelope.
|
|
436
|
+
|
|
437
|
+
Uses SHA-256 hash of JCS(message) to avoid double-canonicalization
|
|
438
|
+
fragility (embedding one JCS string inside another JCS object).
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
mcp_message: The original MCP JSON-RPC message dict
|
|
442
|
+
passport_id: The agent's passport ID
|
|
443
|
+
private_key: PEM private key string
|
|
444
|
+
channel_binding: Optional TLS channel binding token (RFC 9266 tls-exporter)
|
|
445
|
+
"""
|
|
446
|
+
nonce = os.urandom(NONCE_BYTES).hex()
|
|
447
|
+
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
448
|
+
|
|
449
|
+
# Hash message instead of nesting JCS strings (prevents double-canonicalization fragility)
|
|
450
|
+
message_hash = hashlib.sha256(canonical_json(mcp_message).encode()).hexdigest()
|
|
451
|
+
|
|
452
|
+
sig_payload_obj = {
|
|
453
|
+
"message_hash": message_hash,
|
|
454
|
+
"nonce": nonce,
|
|
455
|
+
"passport_id": passport_id,
|
|
456
|
+
"timestamp": timestamp,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# Optional TLS channel binding (RFC 9266 tls-exporter)
|
|
460
|
+
if channel_binding:
|
|
461
|
+
sig_payload_obj["channel_binding"] = channel_binding
|
|
462
|
+
|
|
463
|
+
sig_payload = canonical_json(sig_payload_obj).encode()
|
|
464
|
+
signature = _sign_bytes(sig_payload, private_key)
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
"mcps": {
|
|
468
|
+
"version": MCPS_VERSION,
|
|
469
|
+
"passport_id": passport_id,
|
|
470
|
+
"timestamp": timestamp,
|
|
471
|
+
"nonce": nonce,
|
|
472
|
+
"signature": signature,
|
|
473
|
+
},
|
|
474
|
+
**mcp_message,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def verify_message(envelope, public_key_pem, channel_binding=None):
|
|
479
|
+
"""Verify an MCPS envelope's signature."""
|
|
480
|
+
if not envelope or not isinstance(envelope.get("mcps"), dict):
|
|
481
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_SIGNATURE"]}
|
|
482
|
+
|
|
483
|
+
mcps_field = envelope["mcps"]
|
|
484
|
+
if not mcps_field.get("signature"):
|
|
485
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_SIGNATURE"]}
|
|
486
|
+
|
|
487
|
+
jsonrpc_message = {k: v for k, v in envelope.items() if k != "mcps"}
|
|
488
|
+
|
|
489
|
+
# Check timestamp window
|
|
490
|
+
try:
|
|
491
|
+
msg_time = datetime.fromisoformat(
|
|
492
|
+
mcps_field["timestamp"].replace("Z", "+00:00")
|
|
493
|
+
).timestamp()
|
|
494
|
+
except (KeyError, ValueError):
|
|
495
|
+
return {"valid": False, "error": ERROR_CODES["TIMESTAMP_OUT_OF_WINDOW"]}
|
|
496
|
+
|
|
497
|
+
now = time.time()
|
|
498
|
+
if abs(now - msg_time) > TIMESTAMP_WINDOW_S:
|
|
499
|
+
return {"valid": False, "error": ERROR_CODES["TIMESTAMP_OUT_OF_WINDOW"]}
|
|
500
|
+
|
|
501
|
+
# Reconstruct signing payload (message hash, not nested JCS)
|
|
502
|
+
message_hash = hashlib.sha256(canonical_json(jsonrpc_message).encode()).hexdigest()
|
|
503
|
+
|
|
504
|
+
sig_payload_obj = {
|
|
505
|
+
"message_hash": message_hash,
|
|
506
|
+
"nonce": mcps_field["nonce"],
|
|
507
|
+
"passport_id": mcps_field["passport_id"],
|
|
508
|
+
"timestamp": mcps_field["timestamp"],
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if channel_binding:
|
|
512
|
+
sig_payload_obj["channel_binding"] = channel_binding
|
|
513
|
+
|
|
514
|
+
sig_payload = canonical_json(sig_payload_obj).encode()
|
|
515
|
+
|
|
516
|
+
if _verify_bytes(sig_payload, mcps_field["signature"], public_key_pem):
|
|
517
|
+
return {"valid": True, "error": None}
|
|
518
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_SIGNATURE"]}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ── Nonce Store (Replay Protection) ──
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class NonceStore:
|
|
525
|
+
"""Thread-safe nonce store for replay attack detection."""
|
|
526
|
+
|
|
527
|
+
def __init__(self, window_s=TIMESTAMP_WINDOW_S):
|
|
528
|
+
self._nonces = {}
|
|
529
|
+
self._window_s = window_s
|
|
530
|
+
self._lock = threading.Lock()
|
|
531
|
+
self._timer = threading.Timer(window_s, self._gc)
|
|
532
|
+
self._timer.daemon = True
|
|
533
|
+
self._timer.start()
|
|
534
|
+
|
|
535
|
+
def check(self, nonce):
|
|
536
|
+
"""Return True if nonce is fresh (not a replay)."""
|
|
537
|
+
with self._lock:
|
|
538
|
+
if nonce in self._nonces:
|
|
539
|
+
return False
|
|
540
|
+
self._nonces[nonce] = time.time()
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
def _gc(self):
|
|
544
|
+
cutoff = time.time() - self._window_s
|
|
545
|
+
with self._lock:
|
|
546
|
+
self._nonces = {n: t for n, t in self._nonces.items() if t >= cutoff}
|
|
547
|
+
self._timer = threading.Timer(self._window_s, self._gc)
|
|
548
|
+
self._timer.daemon = True
|
|
549
|
+
self._timer.start()
|
|
550
|
+
|
|
551
|
+
def destroy(self):
|
|
552
|
+
self._timer.cancel()
|
|
553
|
+
with self._lock:
|
|
554
|
+
self._nonces.clear()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ── Tool Integrity ──
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def sign_tool(tool, private_key, author_origin=None):
|
|
561
|
+
"""Sign an MCP tool definition.
|
|
562
|
+
Hashes the ENTIRE tool object (name + description + inputSchema) using JCS.
|
|
563
|
+
Optionally binds to author_origin to prevent tool relaying attacks.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
tool: MCP tool dict with name, description, inputSchema
|
|
567
|
+
private_key: PEM private key string
|
|
568
|
+
author_origin: Author's server origin (binds tool to serving origin)
|
|
569
|
+
"""
|
|
570
|
+
canonical = canonical_json({
|
|
571
|
+
"author_origin": author_origin,
|
|
572
|
+
"description": tool["description"],
|
|
573
|
+
"inputSchema": tool.get("inputSchema", {}),
|
|
574
|
+
"name": tool["name"],
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
tool_hash = hashlib.sha256(canonical.encode()).hexdigest()
|
|
578
|
+
signature = _sign_bytes(canonical.encode(), private_key)
|
|
579
|
+
|
|
580
|
+
return {"signature": signature, "tool_hash": tool_hash}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def verify_tool(tool, signature, public_key_pem, pinned_hash=None, author_origin=None):
|
|
584
|
+
"""Verify an MCP tool definition's signature.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
tool: MCP tool dict
|
|
588
|
+
signature: Base64 P1363 signature string
|
|
589
|
+
public_key_pem: PEM public key string
|
|
590
|
+
pinned_hash: Previously pinned tool_hash for change detection
|
|
591
|
+
author_origin: Expected author origin
|
|
592
|
+
"""
|
|
593
|
+
canonical = canonical_json({
|
|
594
|
+
"author_origin": author_origin,
|
|
595
|
+
"description": tool["description"],
|
|
596
|
+
"inputSchema": tool.get("inputSchema", {}),
|
|
597
|
+
"name": tool["name"],
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
tool_hash = hashlib.sha256(canonical.encode()).hexdigest()
|
|
601
|
+
hash_changed = (pinned_hash is not None) and (tool_hash != pinned_hash)
|
|
602
|
+
|
|
603
|
+
valid = _verify_bytes(canonical.encode(), signature, public_key_pem)
|
|
604
|
+
return {"valid": valid, "tool_hash": tool_hash, "hash_changed": hash_changed}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ── Transcript Binding (Anti-Downgrade) ──
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def create_transcript_binding(client_init_params, server_init_result, private_key):
|
|
611
|
+
"""Create a transcript binding (formerly "transcript MAC").
|
|
612
|
+
Uses ECDSA signatures (asymmetric), NOT MAC (symmetric) -- hence the rename.
|
|
613
|
+
|
|
614
|
+
Both parties sign the agreed-upon security parameters to prevent an active
|
|
615
|
+
attacker from stripping MCPS capability during handshake.
|
|
616
|
+
|
|
617
|
+
IMPORTANT: transcript covers initialize `params` (client) and `result` (server),
|
|
618
|
+
NOT the full JSON-RPC envelope. This is the security-relevant boundary.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
client_init_params: Client's initialize params (capabilities, clientInfo)
|
|
622
|
+
server_init_result: Server's initialize result (capabilities, serverInfo)
|
|
623
|
+
private_key: Signer's PEM private key
|
|
624
|
+
"""
|
|
625
|
+
client_jcs = canonical_json(client_init_params)
|
|
626
|
+
server_jcs = canonical_json(server_init_result)
|
|
627
|
+
transcript_hash = hashlib.sha256((client_jcs + server_jcs).encode()).hexdigest()
|
|
628
|
+
|
|
629
|
+
signature = _sign_bytes(transcript_hash.encode(), private_key)
|
|
630
|
+
|
|
631
|
+
return {"transcript_hash": transcript_hash, "transcript_signature": signature}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def verify_transcript_binding(transcript_hash, transcript_signature, public_key_pem,
|
|
635
|
+
client_init_params, server_init_result):
|
|
636
|
+
"""Verify a transcript binding from the other party."""
|
|
637
|
+
client_jcs = canonical_json(client_init_params)
|
|
638
|
+
server_jcs = canonical_json(server_init_result)
|
|
639
|
+
expected_hash = hashlib.sha256((client_jcs + server_jcs).encode()).hexdigest()
|
|
640
|
+
|
|
641
|
+
if transcript_hash != expected_hash:
|
|
642
|
+
return {"valid": False, "error": ERROR_CODES["CAPABILITY_MISMATCH"]}
|
|
643
|
+
|
|
644
|
+
if _verify_bytes(transcript_hash.encode(), transcript_signature, public_key_pem):
|
|
645
|
+
return {"valid": True, "error": None}
|
|
646
|
+
return {"valid": False, "error": ERROR_CODES["CAPABILITY_MISMATCH"]}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# Backwards-compatible aliases (renamed from MAC to Binding)
|
|
650
|
+
create_transcript_mac = create_transcript_binding
|
|
651
|
+
verify_transcript_mac = verify_transcript_binding
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# ── Version Negotiation ──
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def negotiate_version(client_versions, server_versions=None):
|
|
658
|
+
"""Negotiate the highest mutually supported MCPS version.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
client_versions: Client's supported version(s) -- string or list
|
|
662
|
+
server_versions: Server's supported versions (defaults to SUPPORTED_VERSIONS)
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
str or None: Highest mutual version, or None if none
|
|
666
|
+
"""
|
|
667
|
+
if isinstance(client_versions, str):
|
|
668
|
+
client_versions = [client_versions]
|
|
669
|
+
if server_versions is None:
|
|
670
|
+
server_versions = SUPPORTED_VERSIONS
|
|
671
|
+
if isinstance(server_versions, str):
|
|
672
|
+
server_versions = [server_versions]
|
|
673
|
+
|
|
674
|
+
mutual = [v for v in client_versions if v in server_versions]
|
|
675
|
+
if not mutual:
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
# Sort by version descending (highest first)
|
|
679
|
+
def version_key(v):
|
|
680
|
+
parts = v.split(".")
|
|
681
|
+
return tuple(int(p) for p in parts)
|
|
682
|
+
|
|
683
|
+
mutual.sort(key=version_key, reverse=True)
|
|
684
|
+
return mutual[0]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ── Revocation Client ──
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def check_revocation(passport_id, trust_authority=DEFAULT_TRUST_AUTHORITY, ta_public_key=None):
|
|
691
|
+
"""Check passport revocation status against the Trust Authority.
|
|
692
|
+
Revocation endpoint is discovered from TA metadata, NOT from the verified party."""
|
|
693
|
+
url = f"{trust_authority.rstrip('/')}/api/verify/{passport_id}"
|
|
694
|
+
try:
|
|
695
|
+
req = urllib.request.Request(url, headers={"User-Agent": "mcps-python/1.0"})
|
|
696
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
697
|
+
data = json.loads(resp.read().decode())
|
|
698
|
+
|
|
699
|
+
verified = False
|
|
700
|
+
if ta_public_key and data.get("signature"):
|
|
701
|
+
sig = data["signature"]
|
|
702
|
+
rest = {k: v for k, v in data.items() if k != "signature"}
|
|
703
|
+
payload = canonical_json(rest).encode()
|
|
704
|
+
verified = _verify_bytes(payload, sig, ta_public_key)
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
"status": data.get("status", "UNKNOWN"),
|
|
708
|
+
"revoked": data.get("status") == "REVOKED",
|
|
709
|
+
"reason": data.get("reason"),
|
|
710
|
+
"revoked_at": data.get("revoked_at"),
|
|
711
|
+
"verified": verified,
|
|
712
|
+
}
|
|
713
|
+
except Exception:
|
|
714
|
+
return {"status": "UNREACHABLE", "revoked": False, "reason": "network_error", "verified": False}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# ── MCPS Middleware ──
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def secure_mcp(mcp_server, passport=None, private_key=None,
|
|
721
|
+
trust_authority=DEFAULT_TRUST_AUTHORITY, min_trust_level=2,
|
|
722
|
+
origin=None, trusted_issuers=None, trust_authorities=None,
|
|
723
|
+
on_revoked=None, on_audit=None):
|
|
724
|
+
"""Create an MCPS-secured wrapper around an MCP server.
|
|
725
|
+
Implements fail-closed by default when Trust Authority is unreachable.
|
|
726
|
+
Supports multiple Trust Authorities via trust_authorities list."""
|
|
727
|
+
return MCPSServer(mcp_server, passport, private_key, trust_authority,
|
|
728
|
+
min_trust_level, origin, trusted_issuers, trust_authorities,
|
|
729
|
+
on_revoked, on_audit)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
class MCPSServer:
|
|
733
|
+
def __init__(self, mcp_server, passport, private_key, trust_authority,
|
|
734
|
+
min_trust_level, origin, trusted_issuers, trust_authorities,
|
|
735
|
+
on_revoked, on_audit):
|
|
736
|
+
self._mcp_server = mcp_server
|
|
737
|
+
self._nonce_store = NonceStore()
|
|
738
|
+
self._passport_cache = {}
|
|
739
|
+
self._passport = passport
|
|
740
|
+
self._private_key = private_key
|
|
741
|
+
self._trust_authorities = trust_authorities or [trust_authority]
|
|
742
|
+
self._min_trust_level = min_trust_level
|
|
743
|
+
self._origin = origin
|
|
744
|
+
self._trusted_issuers = trusted_issuers
|
|
745
|
+
self._on_revoked = on_revoked
|
|
746
|
+
self._on_audit = on_audit
|
|
747
|
+
self.mcps_version = MCPS_VERSION
|
|
748
|
+
|
|
749
|
+
def _audit(self, event, data):
|
|
750
|
+
if self._on_audit:
|
|
751
|
+
entry = {"timestamp": datetime.now(timezone.utc).isoformat(), "event": event, **data}
|
|
752
|
+
self._on_audit(entry)
|
|
753
|
+
|
|
754
|
+
def handle_message(self, envelope):
|
|
755
|
+
if not envelope or not isinstance(envelope.get("mcps"), dict):
|
|
756
|
+
self._audit("rejected", {"reason": "invalid_format"})
|
|
757
|
+
return {"error": ERROR_CODES["INVALID_SIGNATURE"]}
|
|
758
|
+
|
|
759
|
+
mcps_field = envelope["mcps"]
|
|
760
|
+
passport_id = mcps_field.get("passport_id", "")
|
|
761
|
+
|
|
762
|
+
if not self._nonce_store.check(mcps_field.get("nonce", "")):
|
|
763
|
+
self._audit("rejected", {"reason": "replay_attack", "passport_id": passport_id})
|
|
764
|
+
return {"error": ERROR_CODES["REPLAY_ATTACK"]}
|
|
765
|
+
|
|
766
|
+
passport = self._passport_cache.get(passport_id)
|
|
767
|
+
if not passport:
|
|
768
|
+
# Try each trust authority (multi-TA support)
|
|
769
|
+
rev = None
|
|
770
|
+
for ta in self._trust_authorities:
|
|
771
|
+
rev = check_revocation(passport_id, ta)
|
|
772
|
+
if rev["status"] != "UNREACHABLE":
|
|
773
|
+
break
|
|
774
|
+
|
|
775
|
+
if rev["revoked"]:
|
|
776
|
+
self._audit("rejected", {"reason": "revoked", "passport_id": passport_id})
|
|
777
|
+
if self._on_revoked:
|
|
778
|
+
self._on_revoked(passport_id, rev["reason"])
|
|
779
|
+
return {"error": ERROR_CODES["PASSPORT_REVOKED"]}
|
|
780
|
+
if rev["status"] == "UNREACHABLE":
|
|
781
|
+
self._audit("rejected", {"reason": "authority_unreachable_fail_closed", "passport_id": passport_id})
|
|
782
|
+
return {"error": ERROR_CODES["AUTHORITY_UNREACHABLE"]}
|
|
783
|
+
passport = {"passport_id": passport_id, "trust_level": TRUST_LEVELS["VERIFIED"]}
|
|
784
|
+
self._passport_cache[passport_id] = passport
|
|
785
|
+
|
|
786
|
+
effective_level = get_effective_trust_level(passport, self._trusted_issuers)
|
|
787
|
+
if effective_level < self._min_trust_level:
|
|
788
|
+
self._audit("rejected", {"reason": "insufficient_trust", "passport_id": passport_id})
|
|
789
|
+
return {"error": ERROR_CODES["INSUFFICIENT_TRUST"]}
|
|
790
|
+
|
|
791
|
+
if self._origin and passport.get("origin"):
|
|
792
|
+
origin_result = validate_origin(passport, self._origin)
|
|
793
|
+
if not origin_result["valid"]:
|
|
794
|
+
self._audit("rejected", {"reason": "origin_mismatch", "passport_id": passport_id})
|
|
795
|
+
return {"error": ERROR_CODES["ORIGIN_MISMATCH"]}
|
|
796
|
+
|
|
797
|
+
jsonrpc_message = {k: v for k, v in envelope.items() if k != "mcps"}
|
|
798
|
+
|
|
799
|
+
self._audit("accepted", {"passport_id": passport_id, "method": jsonrpc_message.get("method")})
|
|
800
|
+
|
|
801
|
+
if self._mcp_server and hasattr(self._mcp_server, "handle_message"):
|
|
802
|
+
return self._mcp_server.handle_message(jsonrpc_message)
|
|
803
|
+
return {"result": "ok"}
|
|
804
|
+
|
|
805
|
+
def sign(self, mcp_message):
|
|
806
|
+
return sign_message(mcp_message, self._passport, self._private_key)
|
|
807
|
+
|
|
808
|
+
def destroy(self):
|
|
809
|
+
self._nonce_store.destroy()
|
|
810
|
+
self._passport_cache.clear()
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def secure_mcp_client(mcp_client, trust_authority=DEFAULT_TRUST_AUTHORITY, on_revoked=None):
|
|
814
|
+
"""Create an MCPS client wrapper for verifying server responses."""
|
|
815
|
+
return MCPSClient(mcp_client, trust_authority, on_revoked)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class MCPSClient:
|
|
819
|
+
def __init__(self, mcp_client, trust_authority, on_revoked):
|
|
820
|
+
self._mcp_client = mcp_client
|
|
821
|
+
self._nonce_store = NonceStore()
|
|
822
|
+
self._trust_authority = trust_authority
|
|
823
|
+
self._on_revoked = on_revoked
|
|
824
|
+
self.mcps_version = MCPS_VERSION
|
|
825
|
+
|
|
826
|
+
def send(self, mcp_message, passport_id, private_key):
|
|
827
|
+
envelope = sign_message(mcp_message, passport_id, private_key)
|
|
828
|
+
if self._mcp_client and hasattr(self._mcp_client, "send"):
|
|
829
|
+
return self._mcp_client.send(envelope)
|
|
830
|
+
return envelope
|
|
831
|
+
|
|
832
|
+
def verify(self, envelope):
|
|
833
|
+
if not envelope or not isinstance(envelope.get("mcps"), dict):
|
|
834
|
+
return {"valid": False, "error": ERROR_CODES["INVALID_SIGNATURE"]}
|
|
835
|
+
mcps_field = envelope["mcps"]
|
|
836
|
+
passport_id = mcps_field.get("passport_id", "")
|
|
837
|
+
rev = check_revocation(passport_id, self._trust_authority)
|
|
838
|
+
if rev["revoked"]:
|
|
839
|
+
if self._on_revoked:
|
|
840
|
+
self._on_revoked(passport_id, rev["reason"])
|
|
841
|
+
return {"valid": False, "error": ERROR_CODES["PASSPORT_REVOKED"]}
|
|
842
|
+
if not self._nonce_store.check(mcps_field.get("nonce", "")):
|
|
843
|
+
return {"valid": False, "error": ERROR_CODES["REPLAY_ATTACK"]}
|
|
844
|
+
return {"valid": True, "passport_id": passport_id}
|
|
845
|
+
|
|
846
|
+
def destroy(self):
|
|
847
|
+
self._nonce_store.destroy()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcps-secure
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCPS -- MCP Secure. Cryptographic identity, message signing, and trust verification for the Model Context Protocol.
|
|
5
|
+
Author: CyberSecAI Ltd
|
|
6
|
+
License: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://mcp-secure.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/razashariff/mcps
|
|
9
|
+
Project-URL: Documentation, https://mcp-secure.dev
|
|
10
|
+
Keywords: mcp,mcps,mcp-secure,model-context-protocol,agent-security,agent-identity,passport,message-signing,ecdsa,trust,revocation,owasp,agentsign,secure-mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cryptography>=41.0.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# MCPS -- MCP Secure
|
|
29
|
+
|
|
30
|
+
**Cryptographic identity, message signing, and trust verification for the Model Context Protocol.**
|
|
31
|
+
|
|
32
|
+
The HTTPS of the agent era. MCP becomes MCPS.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install mcp-secure
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from mcp_secure import generate_key_pair, create_passport, sign_passport, verify_passport_signature
|
|
44
|
+
from mcp_secure import sign_message, verify_message, sign_tool, verify_tool
|
|
45
|
+
|
|
46
|
+
# Generate keys
|
|
47
|
+
keys = generate_key_pair()
|
|
48
|
+
|
|
49
|
+
# Create and sign a passport
|
|
50
|
+
passport = create_passport(
|
|
51
|
+
name="my-agent",
|
|
52
|
+
version="1.0.0",
|
|
53
|
+
public_key=keys["public_key"],
|
|
54
|
+
capabilities=["read", "write"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Trust Authority signs the passport
|
|
58
|
+
ta_keys = generate_key_pair()
|
|
59
|
+
signed = sign_passport(passport, ta_keys["private_key"])
|
|
60
|
+
assert verify_passport_signature(signed, ta_keys["public_key"])
|
|
61
|
+
|
|
62
|
+
# Sign MCP messages
|
|
63
|
+
envelope = sign_message(
|
|
64
|
+
{"jsonrpc": "2.0", "method": "tools/list", "id": 1},
|
|
65
|
+
signed["passport_id"],
|
|
66
|
+
keys["private_key"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Verify
|
|
70
|
+
result = verify_message(envelope, keys["public_key"])
|
|
71
|
+
assert result["valid"]
|
|
72
|
+
|
|
73
|
+
# Tool integrity
|
|
74
|
+
tool = {"name": "read_file", "description": "Read a file", "inputSchema": {"type": "object"}}
|
|
75
|
+
sig = sign_tool(tool, keys["private_key"])
|
|
76
|
+
assert verify_tool(tool, sig, keys["public_key"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What MCPS Adds to MCP
|
|
80
|
+
|
|
81
|
+
| Feature | Description |
|
|
82
|
+
|---------|-------------|
|
|
83
|
+
| Agent Passports | ECDSA P-256 signed identity credentials |
|
|
84
|
+
| Message Signing | Every JSON-RPC message wrapped in signed envelope |
|
|
85
|
+
| Tool Integrity | Signed tool definitions prevent poisoning |
|
|
86
|
+
| Replay Protection | Nonce + 5-min timestamp window |
|
|
87
|
+
| Revocation | Real-time passport revocation via Trust Authority |
|
|
88
|
+
| Trust Levels | L0 (unsigned) to L4 (audited) |
|
|
89
|
+
|
|
90
|
+
## OWASP MCP Top 10
|
|
91
|
+
|
|
92
|
+
Mitigates 8/10 OWASP MCP vulnerabilities: tool poisoning, supply chain attacks, auth bypass, shadow servers, and more.
|
|
93
|
+
|
|
94
|
+
## Links
|
|
95
|
+
|
|
96
|
+
- **Website**: [mcp-secure.dev](https://mcp-secure.dev)
|
|
97
|
+
- **npm**: [mcp-secure](https://www.npmjs.com/package/mcp-secure)
|
|
98
|
+
- **Spec**: [SPEC.md](https://github.com/razashariff/mcps/blob/main/SPEC.md)
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT -- CyberSecAI Ltd
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mcp_secure/__init__.py
|
|
5
|
+
mcp_secure/core.py
|
|
6
|
+
mcps_secure.egg-info/PKG-INFO
|
|
7
|
+
mcps_secure.egg-info/SOURCES.txt
|
|
8
|
+
mcps_secure.egg-info/dependency_links.txt
|
|
9
|
+
mcps_secure.egg-info/requires.txt
|
|
10
|
+
mcps_secure.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=41.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_secure
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcps-secure"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "MCPS -- MCP Secure. Cryptographic identity, message signing, and trust verification for the Model Context Protocol."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "BUSL-1.1"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "CyberSecAI Ltd"}]
|
|
13
|
+
keywords = [
|
|
14
|
+
"mcp", "mcps", "mcp-secure", "model-context-protocol",
|
|
15
|
+
"agent-security", "agent-identity", "passport",
|
|
16
|
+
"message-signing", "ecdsa", "trust", "revocation",
|
|
17
|
+
"owasp", "agentsign", "secure-mcp",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: Other/Proprietary License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Security :: Cryptography",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"cryptography>=41.0.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://mcp-secure.dev"
|
|
38
|
+
Repository = "https://github.com/razashariff/mcps"
|
|
39
|
+
Documentation = "https://mcp-secure.dev"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
include = ["mcp_secure*"]
|