Skip to main content

pmcp_code_mode/
token.rs

1//! Approval token generation and verification.
2//!
3//! MVP uses HMAC-SHA256 for token signing. Full implementation will use AWS KMS.
4
5use crate::types::{ExecutionError, RiskLevel};
6use hmac::{Hmac, Mac};
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use uuid::Uuid;
10
11type HmacSha256 = Hmac<Sha256>;
12
13/// Approval token that authorizes code execution.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ApprovalToken {
16    /// Unique request ID (prevents replay attacks)
17    pub request_id: String,
18
19    /// SHA-256 hash of the canonicalized code
20    pub code_hash: String,
21
22    /// User ID from the access token
23    pub user_id: String,
24
25    /// MCP session ID (prevents cross-session usage)
26    pub session_id: String,
27
28    /// Server that validated the code
29    pub server_id: String,
30
31    /// Hash of schema + permissions (detects context changes)
32    pub context_hash: String,
33
34    /// Assessed risk level
35    pub risk_level: RiskLevel,
36
37    /// Unix timestamp when token was created
38    pub created_at: i64,
39
40    /// Unix timestamp when token expires
41    pub expires_at: i64,
42
43    /// HMAC signature over all fields above
44    pub signature: String,
45}
46
47impl ApprovalToken {
48    /// Encode the token to a string for transport.
49    pub fn encode(&self) -> Result<String, serde_json::Error> {
50        let json = serde_json::to_string(self)?;
51        Ok(base64::Engine::encode(
52            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
53            json.as_bytes(),
54        ))
55    }
56
57    /// Decode a token from a string.
58    pub fn decode(encoded: &str) -> Result<Self, TokenDecodeError> {
59        let bytes =
60            base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, encoded)
61                .map_err(|_| TokenDecodeError::InvalidBase64)?;
62
63        let json = String::from_utf8(bytes).map_err(|_| TokenDecodeError::InvalidUtf8)?;
64
65        serde_json::from_str(&json).map_err(|_| TokenDecodeError::InvalidJson)
66    }
67
68    /// Get the payload bytes for signing/verification.
69    fn payload_bytes(&self) -> Vec<u8> {
70        format!(
71            "{}|{}|{}|{}|{}|{}|{:?}|{}|{}",
72            self.request_id,
73            self.code_hash,
74            self.user_id,
75            self.session_id,
76            self.server_id,
77            self.context_hash,
78            self.risk_level,
79            self.created_at,
80            self.expires_at,
81        )
82        .into_bytes()
83    }
84}
85
86/// Errors that can occur when decoding a token.
87#[derive(Debug, thiserror::Error)]
88pub enum TokenDecodeError {
89    #[error(
90        "Token is not valid base64 — it may have been truncated or corrupted during transport"
91    )]
92    InvalidBase64,
93    #[error("Token contains invalid UTF-8 bytes after base64 decoding")]
94    InvalidUtf8,
95    #[error("Token decoded to invalid JSON — the token string may have been truncated, double-encoded, or is not an approval token")]
96    InvalidJson,
97}
98
99/// Trait for token generators.
100pub trait TokenGenerator: Send + Sync {
101    /// Generate a signed approval token.
102    fn generate(
103        &self,
104        code: &str,
105        user_id: &str,
106        session_id: &str,
107        server_id: &str,
108        context_hash: &str,
109        risk_level: RiskLevel,
110        ttl_seconds: i64,
111    ) -> ApprovalToken;
112
113    /// Verify a token and return Ok if valid.
114    fn verify(&self, token: &ApprovalToken) -> Result<(), ExecutionError>;
115
116    /// Verify that submitted code matches the token's code hash.
117    fn verify_code(&self, code: &str, token: &ApprovalToken) -> Result<(), ExecutionError>;
118}
119
120/// HMAC-based token generator for MVP.
121pub struct HmacTokenGenerator {
122    secret: Vec<u8>,
123}
124
125impl HmacTokenGenerator {
126    /// Create a new HMAC token generator with the given secret.
127    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
128        Self {
129            secret: secret.into(),
130        }
131    }
132
133    /// Create from an environment variable.
134    pub fn from_env(env_var: &str) -> Result<Self, std::env::VarError> {
135        let secret = std::env::var(env_var)?;
136        Ok(Self::new(secret.into_bytes()))
137    }
138
139    /// Sign the token payload.
140    fn sign(&self, payload: &[u8]) -> String {
141        let mut mac =
142            HmacSha256::new_from_slice(&self.secret).expect("HMAC can take key of any size");
143        mac.update(payload);
144        hex::encode(mac.finalize().into_bytes())
145    }
146
147    /// Verify the signature.
148    fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
149        let mut mac =
150            HmacSha256::new_from_slice(&self.secret).expect("HMAC can take key of any size");
151        mac.update(payload);
152
153        let expected = hex::decode(signature).unwrap_or_default();
154        mac.verify_slice(&expected).is_ok()
155    }
156}
157
158impl TokenGenerator for HmacTokenGenerator {
159    fn generate(
160        &self,
161        code: &str,
162        user_id: &str,
163        session_id: &str,
164        server_id: &str,
165        context_hash: &str,
166        risk_level: RiskLevel,
167        ttl_seconds: i64,
168    ) -> ApprovalToken {
169        let now = chrono::Utc::now().timestamp();
170
171        let mut token = ApprovalToken {
172            request_id: Uuid::new_v4().to_string(),
173            code_hash: hash_code(code),
174            user_id: user_id.to_string(),
175            session_id: session_id.to_string(),
176            server_id: server_id.to_string(),
177            context_hash: context_hash.to_string(),
178            risk_level,
179            created_at: now,
180            expires_at: now + ttl_seconds,
181            signature: String::new(),
182        };
183
184        // Sign the payload
185        token.signature = self.sign(&token.payload_bytes());
186        token
187    }
188
189    fn verify(&self, token: &ApprovalToken) -> Result<(), ExecutionError> {
190        // Check expiry
191        let now = chrono::Utc::now().timestamp();
192        if now > token.expires_at {
193            return Err(ExecutionError::TokenExpired);
194        }
195
196        // Verify signature
197        if !self.verify_signature(&token.payload_bytes(), &token.signature) {
198            return Err(ExecutionError::TokenInvalid(
199                "signature verification failed".into(),
200            ));
201        }
202
203        Ok(())
204    }
205
206    fn verify_code(&self, code: &str, token: &ApprovalToken) -> Result<(), ExecutionError> {
207        let current_hash = hash_code(code);
208        if current_hash != token.code_hash {
209            return Err(ExecutionError::CodeMismatch {
210                expected_hash: token.code_hash[..12].to_string(),
211                actual_hash: current_hash[..12].to_string(),
212            });
213        }
214        Ok(())
215    }
216}
217
218/// Compute the SHA-256 hash of canonicalized code.
219///
220/// This is the same hash used in approval tokens. Clients can call this
221/// to verify their code will match the token before executing.
222pub fn hash_code(code: &str) -> String {
223    use sha2::Digest;
224    let mut hasher = Sha256::new();
225    hasher.update(canonicalize_code(code).as_bytes());
226    hex::encode(hasher.finalize())
227}
228
229/// Canonicalize code for consistent hashing.
230///
231/// This normalizes whitespace to ensure semantically identical code
232/// produces the same hash, regardless of:
233/// - Leading/trailing whitespace or newlines on the whole string
234/// - Trailing whitespace on individual lines
235/// - Windows vs Unix line endings (\r\n vs \n)
236/// - Blank lines between statements
237pub fn canonicalize_code(code: &str) -> String {
238    code.trim()
239        .lines()
240        .map(|line| line.trim())
241        .filter(|line| !line.is_empty())
242        .collect::<Vec<_>>()
243        .join("\n")
244}
245
246/// Compute a context hash from schema and permissions.
247pub fn compute_context_hash(schema_hash: &str, permissions_hash: &str) -> String {
248    use sha2::Digest;
249    let mut hasher = Sha256::new();
250    hasher.update(schema_hash.as_bytes());
251    hasher.update(b"|");
252    hasher.update(permissions_hash.as_bytes());
253    hex::encode(hasher.finalize())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_token_generation_and_verification() {
262        let generator = HmacTokenGenerator::new(b"test-secret-key".to_vec());
263
264        let token = generator.generate(
265            "query { users { id } }",
266            "user-123",
267            "session-456",
268            "server-789",
269            "context-hash",
270            RiskLevel::Low,
271            300,
272        );
273
274        // Token should verify successfully
275        assert!(generator.verify(&token).is_ok());
276
277        // Code should match
278        assert!(generator
279            .verify_code("query { users { id } }", &token)
280            .is_ok());
281    }
282
283    #[test]
284    fn test_code_mismatch() {
285        let generator = HmacTokenGenerator::new(b"test-secret-key".to_vec());
286
287        let token = generator.generate(
288            "query { users { id } }",
289            "user-123",
290            "session-456",
291            "server-789",
292            "context-hash",
293            RiskLevel::Low,
294            300,
295        );
296
297        // Different code should fail
298        let result = generator.verify_code("query { orders { id } }", &token);
299        assert!(matches!(result, Err(ExecutionError::CodeMismatch { .. })));
300    }
301
302    #[test]
303    fn test_token_encode_decode() {
304        let generator = HmacTokenGenerator::new(b"test-secret-key".to_vec());
305
306        let token = generator.generate(
307            "query { users { id } }",
308            "user-123",
309            "session-456",
310            "server-789",
311            "context-hash",
312            RiskLevel::Low,
313            300,
314        );
315
316        let encoded = token.encode().unwrap();
317        let decoded = ApprovalToken::decode(&encoded).unwrap();
318
319        assert_eq!(token.request_id, decoded.request_id);
320        assert_eq!(token.code_hash, decoded.code_hash);
321        assert_eq!(token.signature, decoded.signature);
322    }
323
324    #[test]
325    fn test_canonicalize_code() {
326        let code1 = "query { users { id } }";
327        let code2 = "  query { users { id } }  ";
328        let code3 = "query {\n  users {\n    id\n  }\n}";
329
330        // Trimmed versions should be equivalent
331        assert_eq!(canonicalize_code(code1), canonicalize_code(code2));
332
333        // Multi-line should normalize differently
334        let canonical = canonicalize_code(code3);
335        assert!(canonical.contains("query {"));
336        assert!(canonical.contains("users {"));
337    }
338}