Tool Signing Specification v1¶
Status: Draft (January 2026) Scope: Local ed25519 signing for MCP tool definitions
1. Overview¶
This specification defines the x-assay-sig extension field for cryptographically signing MCP tool definitions. It enables:
- Integrity - Detect tampering of tool definitions
- Provenance - Verify who signed the tool
- Trust policies - Enforce organizational signing requirements
Design Principles¶
- Deterministic - Same tool definition always produces same signing input
- Offline-verifiable - Verification requires only a trusted key source (policy file or explicitly allowed embedded key), no network
- DSSE-aligned - Compatible with future Sigstore/in-toto migration
- Minimal - No external dependencies for basic verification
2. Signing Domain¶
2.1 Signing Input¶
The signing input is the JCS-canonicalized tool definition with the x-assay-sig field removed.
JCS (JSON Canonicalization Scheme, RFC 8785): - Keys sorted lexicographically (per JCS sorting rules) - No whitespace between tokens - Numbers serialized per ECMAScript/IEEE 754 double constraints - Unicode preserved as-is (no normalization; lone surrogates are invalid and MUST cause an error)
2.2 What Is Signed¶
| Field | Included in Signing Input |
|---|---|
name | Yes |
description | Yes |
inputSchema | Yes |
x-assay-sig | No (removed before canonicalization) |
2.3 Payload Type Binding¶
To prevent type confusion attacks, the signature binds to a payload type using DSSE Pre-Authentication Encoding (PAE):
Where: - SP = space character (0x20) - LEN(s) = ASCII decimal byte length of UTF-8 encoding, no leading zeros - type = "application/vnd.assay.tool+json;v=1" (exactly 35 bytes UTF-8) - payload = UTF-8 bytes of JCS-canonicalized tool definition (without x-assay-sig)
Normative example:
PAE("application/vnd.assay.tool+json;v=1", "{}") =
"DSSEv1 35 application/vnd.assay.tool+json;v=1 2 {}"
Note: The PAE format follows the DSSE specification exactly for future Sigstore/in-toto compatibility.
3. Signature Format¶
3.1 x-assay-sig Object¶
{
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.tool+json;v=1",
"payload_digest": "sha256:abc123def456...",
"key_id": "sha256:789xyz...",
"signature": "base64-encoded-ed25519-signature",
"signed_at": "2026-01-28T12:00:00Z",
"public_key": "base64-encoded-spki-pubkey"
}
3.2 Field Definitions¶
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | Yes | Schema version. Must be 1. |
algorithm | string | Yes | Signature algorithm. Must be "ed25519" for v1. |
payload_type | string | Yes | Content type of signed payload. Must be "application/vnd.assay.tool+json;v=1". |
payload_digest | string | Yes | SHA-256 of canonical payload: sha256:<lowercase-hex>. |
key_id | string | Yes | SHA-256 of SPKI-encoded public key: sha256:<lowercase-hex>. |
signature | string | Yes | Standard base64 (RFC 4648) encoded ed25519 signature over PAE, with padding. |
signed_at | string | Yes | ISO 8601 timestamp of signing (UTC). Not part of signed content. |
public_key | string | No | Standard base64 (RFC 4648) encoded SPKI public key, with padding. Optional; for development/testing only. Producers SHOULD omit this field (not set to null) when not embedding. |
Encoding conventions (normative): - key_id: sha256: prefix + 64 lowercase hex chars (SHA-256 of SPKI DER bytes) - payload_digest: sha256: prefix + 64 lowercase hex chars - Base64: standard alphabet with padding (RFC 4648 Section 4) - Hex MUST be lowercase with no separators (e.g., sha256:e3b0c44298fc1c14...) - Parsers MAY accept base64 without padding, but producers MUST include padding
3.3 Key ID Computation¶
Where spki_bytes is the DER-encoded SubjectPublicKeyInfo.
4. Key Format¶
4.1 Private Key¶
- Format: PKCS#8 PEM
- Header:
-----BEGIN PRIVATE KEY----- - File permissions:
0600(owner read/write only) - File extension:
.pem
4.2 Public Key¶
- Format: SPKI PEM (SubjectPublicKeyInfo)
- Header:
-----BEGIN PUBLIC KEY----- - File extension:
.pem
4.3 Example Key Generation¶
# Using assay CLI
assay tool keygen --out ~/.assay/keys/
# Output:
# ~/.assay/keys/private_key.pem (PKCS#8, mode 0600)
# ~/.assay/keys/public_key.pem (SPKI)
# key_id: sha256:abc123def456...
5. Signing Process¶
5.1 Algorithm¶
1. Parse tool definition as JSON object T
2. Remove T["x-assay-sig"] if present
3. Compute canonical = JCS(T)
4. Compute payload_type = "application/vnd.assay.tool+json;v=1"
5. Compute PAE = DSSEv1_PAE(payload_type, canonical)
6. Sign: signature = ed25519_sign(private_key, PAE)
7. Compute payload_digest = "sha256:" + hex(SHA256(canonical))
8. Compute key_id = "sha256:" + hex(SHA256(public_key_spki))
9. Build x-assay-sig object
10. Set T["x-assay-sig"] = x-assay-sig
11. Output T
5.2 PAE Encoding (DSSE-compatible)¶
PAE(type, payload) =
"DSSEv1" + SP +
LEN(type) + SP + type + SP +
LEN(payload) + SP + payload
Where:
SP = 0x20 (space character)
LEN(s) = ASCII decimal byte length of s, no leading zeros
+ = concatenation
Example: For type = "application/vnd.assay.tool+json;v=1" (38 bytes) and a 150-byte payload:
Note: payload is the raw UTF-8 bytes of the JCS-canonicalized JSON, not a string.
6. Verification Process¶
6.1 Algorithm¶
1. Parse tool definition as JSON object T
2. Extract sig = T["x-assay-sig"]
3. If sig is missing:
- If policy requires signature: FAIL (exit 2)
- Else: PASS (unsigned allowed)
4. Validate sig.version == 1
5. Validate sig.algorithm == "ed25519"
6. Validate sig.payload_type == "application/vnd.assay.tool+json;v=1"
7. Remove T["x-assay-sig"]
8. Compute canonical = JCS(T)
9. Verify: payload_digest == "sha256:" + hex(SHA256(canonical))
10. Compute PAE = DSSEv1_PAE(sig.payload_type, canonical)
11. Obtain public key:
- From trust policy by key_id, OR
- From sig.public_key if --allow-embedded-key
12. Verify: ed25519_verify(public_key, PAE, base64_decode(sig.signature))
13. If signature invalid: FAIL (exit 4)
14. Compute actual_key_id from public key
15. If actual_key_id != sig.key_id: FAIL (exit 4)
16. Check trust policy:
- If key_id in trusted_key_ids: PASS
- If key_id matches trusted_keys[].key_id: PASS
- Else: FAIL (exit 3)
17. PASS (exit 0)
6.2 Exit Codes¶
| Code | Meaning | When |
|---|---|---|
| 0 | Success | Signature valid and key trusted |
| 1 | Error | I/O error, malformed JSON, invalid format |
| 2 | Unsigned | No signature when policy requires one |
| 3 | Untrusted | Valid signature but key not in trust policy |
| 4 | Invalid | Bad signature, wrong payload_type, digest mismatch |
7. Trust Policy¶
7.1 Format (YAML)¶
# Require all tools to be signed
require_signed: true
# Simple list of trusted key IDs
trusted_key_ids:
- "sha256:abc123..."
- "sha256:def456..."
# Detailed trusted keys with metadata
trusted_keys:
- key_id: "sha256:789xyz..."
name: "CI Signing Key"
public_key_path: "./keys/ci-public.pem"
7.2 Policy Evaluation¶
- If
require_signed: trueand tool is unsigned → reject - Extract
key_idfrom signature - Check if
key_idintrusted_key_ids→ accept - Check if
key_idmatches anytrusted_keys[].key_id→ accept - Otherwise → reject as untrusted
8. Security Considerations¶
8.1 Key Management¶
- Private keys MUST be stored with mode
0600 - Private keys SHOULD NOT be committed to version control
- Use CI secrets or key management systems for automated signing
8.2 Type Confusion Prevention¶
The payload_type field prevents attacks where a valid signature for one type of document is reused for another. Verification MUST fail if payload_type doesn't match the expected value.
8.3 Key ID vs Embedded Public Key¶
key_idis the authoritative identifier for trust decisions- Verifiers MUST NOT base trust decisions on embedded
public_keyalone - The
public_keyfield is for development/testing convenience only - Production verifiers MUST use trust policy (
key_idmatching) or explicit--allow-embedded-keyflag - Trust policies SHOULD enumerate trusted
key_idvalues, not embedded keys --allow-embedded-keySHOULD only be used in development/testing environments
8.4 Replay Protection¶
This specification does not include replay protection. The signed_at timestamp is metadata only and not cryptographically bound. For replay-sensitive use cases, include a nonce or use transparency logs (future Sigstore integration).
9. Examples¶
9.1 Unsigned Tool¶
{
"name": "read_file",
"description": "Read contents of a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}
}
9.2 Signed Tool¶
{
"name": "read_file",
"description": "Read contents of a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
},
"x-assay-sig": {
"version": 1,
"algorithm": "ed25519",
"payload_type": "application/vnd.assay.tool+json;v=1",
"payload_digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"key_id": "sha256:a1b2c3d4e5f6...",
"signature": "MEUCIQDx...",
"signed_at": "2026-01-28T12:00:00Z"
}
}
9.3 Canonical Form (Signing Input)¶
For the tool above, the JCS canonical form (signing input) is:
{"description":"Read contents of a file","inputSchema":{"properties":{"path":{"type":"string"}},"required":["path"],"type":"object"},"name":"read_file"}
10. Future Extensions¶
10.1 Sigstore Integration (Enterprise)¶
v2 will add: - algorithm: "ecdsa-p256" for Sigstore - certificate field for Fulcio short-lived certs - rekor_entry field for transparency log proof - identity object with OIDC issuer/subject
Field mapping to DSSE/in-toto:
| v1 Field | DSSE/in-toto Equivalent |
|---|---|
payload_type | DSSE payloadType |
signature | DSSE envelope signature |
public_key | Fulcio certificate |
key_id | Certificate fingerprint |
Note: v1 uses snake_case for field names. A future version may offer camelCase aliases (payloadType) for closer DSSE alignment, but v1 implementations MUST use snake_case.
10.2 Tool Bundles¶
Future versions may support signing multiple tools in a bundle with a single signature.