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, TokenError};
6use hmac::{Hmac, KeyInit, Mac};
7use secrecy::{ExposeSecret, SecretBox};
8use serde::{Deserialize, Serialize};
9use sha2::Sha256;
10use uuid::Uuid;
11
12type HmacSha256 = Hmac<Sha256>;
13
14/// Zeroizing wrapper for HMAC token secrets.
15///
16/// ## Security Properties
17/// - Memory is zeroed on drop via `zeroize` (through `secrecy::SecretBox`)
18/// - **Explicitly does NOT implement:** `Debug`, `Display`, `Clone`, `PartialEq`,
19///   `Serialize`, `Deserialize` -- preventing accidental logging, serialization, or copying
20/// - Secret bytes accessed only via `expose_secret()` which returns `&[u8]`
21///
22/// ## Threat Model
23/// Protects against: accidental logging, memory dumps after drop,
24/// clone-and-forget patterns, comparison side channels, JSON serialization leakage.
25/// Does NOT protect against: active memory forensics while the secret
26/// is in use, side-channel attacks on the HMAC computation itself.
27///
28/// ## Usage in Structs
29/// When embedding `TokenSecret` in a struct that derives `Serialize`:
30/// ```rust,ignore
31/// #[derive(serde::Serialize)]
32/// struct MyServer {
33///     #[serde(skip)]  // REQUIRED -- TokenSecret does not implement Serialize
34///     token_secret: TokenSecret,
35///     // ... other fields
36/// }
37/// ```
38pub struct TokenSecret(SecretBox<[u8]>);
39
40// SAFETY NOTE: TokenSecret intentionally does NOT derive or implement:
41// - Debug (prevents logging secret bytes)
42// - Display (prevents printing secret bytes)
43// - Clone (prevents accidental copies that bypass zeroize)
44// - Serialize / Deserialize (prevents JSON/wire leakage)
45// - PartialEq / Eq (prevents timing side-channel comparisons)
46// These denials are verified by negative trait tests in Plan 05.
47
48impl TokenSecret {
49    /// Create from raw bytes. The input Vec is consumed and its contents
50    /// copied into a SecretBox. The original Vec is NOT zeroed -- callers
51    /// should use `from_env()` for maximum security.
52    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
53        let bytes: Vec<u8> = secret.into();
54        Self(SecretBox::new(Box::from(bytes.as_slice())))
55    }
56
57    /// Read from an environment variable. The string value is converted
58    /// to bytes and wrapped immediately.
59    pub fn from_env(var: &str) -> Result<Self, std::env::VarError> {
60        let val = std::env::var(var)?;
61        Ok(Self::new(val.into_bytes()))
62    }
63
64    /// Expose the secret bytes for cryptographic operations.
65    /// Callers MUST NOT log or persist the returned slice.
66    pub fn expose_secret(&self) -> &[u8] {
67        self.0.expose_secret()
68    }
69}
70
71/// Approval token that authorizes code execution.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ApprovalToken {
74    /// Unique request ID (prevents replay attacks)
75    pub request_id: String,
76
77    /// SHA-256 hash of the canonicalized code
78    pub code_hash: String,
79
80    /// User ID from the access token
81    pub user_id: String,
82
83    /// MCP session ID (prevents cross-session usage)
84    pub session_id: String,
85
86    /// Server that validated the code
87    pub server_id: String,
88
89    /// Hash of schema + permissions (detects context changes)
90    pub context_hash: String,
91
92    /// Assessed risk level
93    pub risk_level: RiskLevel,
94
95    /// Unix timestamp when token was created
96    pub created_at: i64,
97
98    /// Unix timestamp when token expires
99    pub expires_at: i64,
100
101    /// HMAC signature over all fields above
102    pub signature: String,
103}
104
105impl ApprovalToken {
106    /// Encode the token to a string for transport.
107    pub fn encode(&self) -> Result<String, serde_json::Error> {
108        let json = serde_json::to_string(self)?;
109        Ok(base64::Engine::encode(
110            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
111            json.as_bytes(),
112        ))
113    }
114
115    /// Decode a token from a string.
116    pub fn decode(encoded: &str) -> Result<Self, TokenDecodeError> {
117        let bytes =
118            base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, encoded)
119                .map_err(|_| TokenDecodeError::InvalidBase64)?;
120
121        let json = String::from_utf8(bytes).map_err(|_| TokenDecodeError::InvalidUtf8)?;
122
123        serde_json::from_str(&json).map_err(|_| TokenDecodeError::InvalidJson)
124    }
125
126    /// Get the payload bytes for signing/verification.
127    /// Build the canonical payload bytes for HMAC signing/verification.
128    ///
129    /// BREAKING CHANGE (v0.1.0 pre-release): This now uses `Display` formatting
130    /// for `risk_level` (stable "LOW"/"MEDIUM"/"HIGH"/"CRITICAL") instead of
131    /// `Debug` formatting. Tokens signed with the prior `Debug` format ("Low",
132    /// "Medium", etc.) will fail verification after this change.
133    fn payload_bytes(&self) -> Vec<u8> {
134        format!(
135            "{}|{}|{}|{}|{}|{}|{}|{}|{}",
136            self.request_id,
137            self.code_hash,
138            self.user_id,
139            self.session_id,
140            self.server_id,
141            self.context_hash,
142            self.risk_level,
143            self.created_at,
144            self.expires_at,
145        )
146        .into_bytes()
147    }
148}
149
150/// Errors that can occur when decoding a token.
151#[derive(Debug, thiserror::Error)]
152pub enum TokenDecodeError {
153    #[error(
154        "Token is not valid base64 — it may have been truncated or corrupted during transport"
155    )]
156    InvalidBase64,
157    #[error("Token contains invalid UTF-8 bytes after base64 decoding")]
158    InvalidUtf8,
159    #[error("Token decoded to invalid JSON — the token string may have been truncated, double-encoded, or is not an approval token")]
160    InvalidJson,
161}
162
163/// Trait for token generators.
164pub trait TokenGenerator: Send + Sync {
165    /// Generate a signed approval token.
166    fn generate(
167        &self,
168        code: &str,
169        user_id: &str,
170        session_id: &str,
171        server_id: &str,
172        context_hash: &str,
173        risk_level: RiskLevel,
174        ttl_seconds: i64,
175    ) -> ApprovalToken;
176
177    /// Verify a token and return Ok if valid.
178    fn verify(&self, token: &ApprovalToken) -> Result<(), ExecutionError>;
179
180    /// Verify that submitted code matches the token's code hash.
181    fn verify_code(&self, code: &str, token: &ApprovalToken) -> Result<(), ExecutionError>;
182}
183
184/// HMAC-based token generator for MVP.
185pub struct HmacTokenGenerator {
186    secret: TokenSecret,
187}
188
189impl HmacTokenGenerator {
190    /// Minimum secret length in bytes for HMAC token generation.
191    ///
192    /// Secrets shorter than this are rejected to prevent trivially forgeable tokens.
193    /// 16 bytes (128 bits) is the minimum recommended for HMAC-SHA256.
194    pub const MIN_SECRET_LEN: usize = 16;
195
196    /// Create a new HMAC token generator with a `TokenSecret`.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`TokenError::SecretTooShort`] if the secret is shorter than
201    /// [`Self::MIN_SECRET_LEN`] (16 bytes).
202    pub fn new(secret: TokenSecret) -> Result<Self, TokenError> {
203        if secret.expose_secret().len() < Self::MIN_SECRET_LEN {
204            return Err(TokenError::SecretTooShort {
205                minimum: Self::MIN_SECRET_LEN,
206                actual: secret.expose_secret().len(),
207            });
208        }
209        Ok(Self { secret })
210    }
211
212    /// Create from raw bytes (backward-compatible migration helper).
213    ///
214    /// Wraps the bytes in a `TokenSecret` internally. Prefer constructing
215    /// a `TokenSecret` directly for new code.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`TokenError::SecretTooShort`] if the secret is shorter than
220    /// [`Self::MIN_SECRET_LEN`] (16 bytes).
221    pub fn new_from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, TokenError> {
222        Self::new(TokenSecret::new(bytes))
223    }
224
225    /// Create from an environment variable.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if the environment variable is not set or if the
230    /// secret is shorter than [`Self::MIN_SECRET_LEN`] (16 bytes).
231    pub fn from_env(env_var: &str) -> Result<Self, Box<dyn std::error::Error>> {
232        let secret = TokenSecret::from_env(env_var)?;
233        Ok(Self::new(secret)?)
234    }
235
236    /// Sign the token payload.
237    fn sign(&self, payload: &[u8]) -> String {
238        let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret())
239            .expect("HMAC can take key of any size");
240        mac.update(payload);
241        hex::encode(mac.finalize().into_bytes())
242    }
243
244    /// Verify the signature.
245    fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
246        let mut mac = HmacSha256::new_from_slice(self.secret.expose_secret())
247            .expect("HMAC can take key of any size");
248        mac.update(payload);
249
250        let expected = hex::decode(signature).unwrap_or_default();
251        mac.verify_slice(&expected).is_ok()
252    }
253}
254
255impl TokenGenerator for HmacTokenGenerator {
256    fn generate(
257        &self,
258        code: &str,
259        user_id: &str,
260        session_id: &str,
261        server_id: &str,
262        context_hash: &str,
263        risk_level: RiskLevel,
264        ttl_seconds: i64,
265    ) -> ApprovalToken {
266        let now = chrono::Utc::now().timestamp();
267
268        let mut token = ApprovalToken {
269            request_id: Uuid::new_v4().to_string(),
270            code_hash: hash_code(code),
271            user_id: user_id.to_string(),
272            session_id: session_id.to_string(),
273            server_id: server_id.to_string(),
274            context_hash: context_hash.to_string(),
275            risk_level,
276            created_at: now,
277            expires_at: now + ttl_seconds,
278            signature: String::new(),
279        };
280
281        token.signature = self.sign(&token.payload_bytes());
282        token
283    }
284
285    fn verify(&self, token: &ApprovalToken) -> Result<(), ExecutionError> {
286        let now = chrono::Utc::now().timestamp();
287        if now > token.expires_at {
288            return Err(ExecutionError::TokenExpired);
289        }
290
291        if !self.verify_signature(&token.payload_bytes(), &token.signature) {
292            return Err(ExecutionError::TokenInvalid(
293                "signature verification failed".into(),
294            ));
295        }
296
297        Ok(())
298    }
299
300    fn verify_code(&self, code: &str, token: &ApprovalToken) -> Result<(), ExecutionError> {
301        let current_hash = hash_code(code);
302        if current_hash != token.code_hash {
303            let expected_prefix = if token.code_hash.len() >= 12 {
304                &token.code_hash[..12]
305            } else {
306                &token.code_hash
307            };
308            let actual_prefix = if current_hash.len() >= 12 {
309                &current_hash[..12]
310            } else {
311                &current_hash
312            };
313            return Err(ExecutionError::CodeMismatch {
314                expected_hash: expected_prefix.to_string(),
315                actual_hash: actual_prefix.to_string(),
316            });
317        }
318        Ok(())
319    }
320}
321
322/// Compute the SHA-256 hash of canonicalized code.
323///
324/// This is the same hash used in approval tokens. Clients can call this
325/// to verify their code will match the token before executing.
326pub fn hash_code(code: &str) -> String {
327    use sha2::Digest;
328    let mut hasher = Sha256::new();
329    hasher.update(canonicalize_code(code).as_bytes());
330    hex::encode(hasher.finalize())
331}
332
333/// Canonicalize code for consistent hashing.
334///
335/// This normalizes whitespace to ensure semantically identical code
336/// produces the same hash, regardless of:
337/// - Leading/trailing whitespace or newlines on the whole string
338/// - Trailing whitespace on individual lines
339/// - Windows vs Unix line endings (\r\n vs \n)
340/// - Blank lines between statements
341pub fn canonicalize_code(code: &str) -> String {
342    let mut result = String::new();
343    for line in code.trim().lines() {
344        let trimmed = line.trim();
345        if !trimmed.is_empty() {
346            if !result.is_empty() {
347                result.push('\n');
348            }
349            result.push_str(trimmed);
350        }
351    }
352    result
353}
354
355/// Compute a context hash from schema and permissions.
356pub fn compute_context_hash(schema_hash: &str, permissions_hash: &str) -> String {
357    use sha2::Digest;
358    let mut hasher = Sha256::new();
359    hasher.update(schema_hash.as_bytes());
360    hasher.update(b"|");
361    hasher.update(permissions_hash.as_bytes());
362    hex::encode(hasher.finalize())
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_token_generation_and_verification() {
371        let generator =
372            HmacTokenGenerator::new(TokenSecret::new(b"test-secret-key!".to_vec())).unwrap();
373
374        let token = generator.generate(
375            "query { users { id } }",
376            "user-123",
377            "session-456",
378            "server-789",
379            "context-hash",
380            RiskLevel::Low,
381            300,
382        );
383
384        // Token should verify successfully
385        assert!(generator.verify(&token).is_ok());
386
387        // Code should match
388        assert!(generator
389            .verify_code("query { users { id } }", &token)
390            .is_ok());
391    }
392
393    #[test]
394    fn test_code_mismatch() {
395        let generator =
396            HmacTokenGenerator::new(TokenSecret::new(b"test-secret-key!".to_vec())).unwrap();
397
398        let token = generator.generate(
399            "query { users { id } }",
400            "user-123",
401            "session-456",
402            "server-789",
403            "context-hash",
404            RiskLevel::Low,
405            300,
406        );
407
408        // Different code should fail
409        let result = generator.verify_code("query { orders { id } }", &token);
410        assert!(matches!(result, Err(ExecutionError::CodeMismatch { .. })));
411    }
412
413    #[test]
414    fn test_token_encode_decode() {
415        let generator =
416            HmacTokenGenerator::new(TokenSecret::new(b"test-secret-key!".to_vec())).unwrap();
417
418        let token = generator.generate(
419            "query { users { id } }",
420            "user-123",
421            "session-456",
422            "server-789",
423            "context-hash",
424            RiskLevel::Low,
425            300,
426        );
427
428        let encoded = token.encode().unwrap();
429        let decoded = ApprovalToken::decode(&encoded).unwrap();
430
431        assert_eq!(token.request_id, decoded.request_id);
432        assert_eq!(token.code_hash, decoded.code_hash);
433        assert_eq!(token.signature, decoded.signature);
434    }
435
436    #[test]
437    fn test_canonicalize_code() {
438        let code1 = "query { users { id } }";
439        let code2 = "  query { users { id } }  ";
440        let code3 = "query {\n  users {\n    id\n  }\n}";
441
442        // Trimmed versions should be equivalent
443        assert_eq!(canonicalize_code(code1), canonicalize_code(code2));
444
445        // Multi-line should normalize differently
446        let canonical = canonicalize_code(code3);
447        assert!(canonical.contains("query {"));
448        assert!(canonical.contains("users {"));
449    }
450
451    #[test]
452    fn test_empty_secret_rejected() {
453        let result = HmacTokenGenerator::new(TokenSecret::new(b"".to_vec()));
454        assert!(matches!(
455            result,
456            Err(TokenError::SecretTooShort {
457                minimum: 16,
458                actual: 0
459            })
460        ));
461    }
462
463    #[test]
464    fn test_short_secret_rejected() {
465        let result = HmacTokenGenerator::new(TokenSecret::new(b"short".to_vec()));
466        assert!(matches!(
467            result,
468            Err(TokenError::SecretTooShort {
469                minimum: 16,
470                actual: 5
471            })
472        ));
473    }
474}