Skip to main content

symbi_runtime/integrations/schemapin/
native_client.rs

1//! Native Rust SchemaPin Client Implementation
2//!
3//! Provides native Rust implementation using the schemapin crate instead of CLI wrapper
4
5use async_trait::async_trait;
6use chrono::Utc;
7use schemapin::crypto::{calculate_key_id, generate_key_pair, sign_data, verify_signature};
8use sha2::Digest;
9
10use super::types::{
11    SchemaPinError, SignArgs, SignatureInfo, SigningResult, VerificationResult, VerifyArgs,
12};
13
14/// Trait for SchemaPin operations using native Rust implementation
15#[async_trait]
16pub trait SchemaPinClient: Send + Sync {
17    /// Verify a schema using the native SchemaPin implementation
18    async fn verify_schema(&self, args: VerifyArgs) -> Result<VerificationResult, SchemaPinError>;
19
20    /// Sign a schema using the native SchemaPin implementation
21    async fn sign_schema(&self, args: SignArgs) -> Result<SigningResult, SchemaPinError>;
22
23    /// Check if the implementation is available
24    async fn check_available(&self) -> Result<bool, SchemaPinError>;
25
26    /// Get version information
27    async fn get_version(&self) -> Result<String, SchemaPinError>;
28}
29
30/// Native SchemaPin implementation using the schemapin Rust crate
31pub struct NativeSchemaPinClient {
32    // No configuration needed for native implementation
33}
34
35impl NativeSchemaPinClient {
36    /// Create a new native SchemaPin client
37    pub fn new() -> Self {
38        Self {}
39    }
40
41    /// Fetch public key from URL and return PEM format.
42    ///
43    /// Supports two response formats:
44    /// - Raw PEM: response body is the PEM-encoded public key directly
45    /// - SchemaPin discovery JSON: response is a JSON object with a `public_key_pem` field
46    ///   (e.g., from `/.well-known/schemapin.json`)
47    async fn fetch_public_key(&self, public_key_url: &str) -> Result<String, SchemaPinError> {
48        let response = reqwest::get(public_key_url)
49            .await
50            .map_err(|e| SchemaPinError::IoError {
51                reason: format!("Failed to fetch public key from {}: {}", public_key_url, e),
52            })?;
53
54        if !response.status().is_success() {
55            return Err(SchemaPinError::IoError {
56                reason: format!("HTTP error {} when fetching public key", response.status()),
57            });
58        }
59
60        let body = response.text().await.map_err(|e| SchemaPinError::IoError {
61            reason: format!("Failed to read public key response: {}", e),
62        })?;
63
64        // If the response looks like JSON, extract the public_key_pem field
65        let trimmed = body.trim();
66        if trimmed.starts_with('{') {
67            if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
68                if let Some(pem) = json.get("public_key_pem").and_then(|v| v.as_str()) {
69                    return Ok(pem.to_string());
70                }
71                return Err(SchemaPinError::IoError {
72                    reason: format!(
73                        "JSON response from {} does not contain a 'public_key_pem' field",
74                        public_key_url
75                    ),
76                });
77            }
78        }
79
80        Ok(body)
81    }
82
83    /// Read file contents from filesystem
84    async fn read_file(&self, path: &str) -> Result<Vec<u8>, SchemaPinError> {
85        tokio::fs::read(path)
86            .await
87            .map_err(|_e| SchemaPinError::SchemaFileNotFound {
88                path: path.to_string(),
89            })
90    }
91}
92
93impl Default for NativeSchemaPinClient {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99#[async_trait]
100impl SchemaPinClient for NativeSchemaPinClient {
101    async fn verify_schema(&self, args: VerifyArgs) -> Result<VerificationResult, SchemaPinError> {
102        // Validate arguments
103        if args.schema_path.is_empty() {
104            return Err(SchemaPinError::InvalidArguments {
105                args: vec!["schema_path cannot be empty".to_string()],
106            });
107        }
108
109        if args.public_key_url.is_empty() {
110            return Err(SchemaPinError::InvalidArguments {
111                args: vec!["public_key_url cannot be empty".to_string()],
112            });
113        }
114
115        // Validate public key URL format
116        if !args.public_key_url.starts_with("http://")
117            && !args.public_key_url.starts_with("https://")
118        {
119            return Err(SchemaPinError::InvalidPublicKeyUrl {
120                url: args.public_key_url.clone(),
121            });
122        }
123
124        // Read schema file
125        let schema_data = self.read_file(&args.schema_path).await?;
126
127        // Fetch public key
128        let public_key_pem = self.fetch_public_key(&args.public_key_url).await?;
129
130        // Calculate key ID for reference
131        let key_id = calculate_key_id(&public_key_pem).map_err(|e| SchemaPinError::IoError {
132            reason: format!("Failed to calculate key ID: {}", e),
133        })?;
134
135        // Calculate schema hash for the response regardless of outcome
136        let schema_hash = {
137            let mut hasher = sha2::Sha256::new();
138            hasher.update(&schema_data);
139            hex::encode(hasher.finalize())
140        };
141
142        // Attempt to extract an embedded signature from the schema JSON.
143        // Schemas signed by SchemaPin contain a top-level `signature` field.
144        let embedded_signature: Option<String> =
145            serde_json::from_slice::<serde_json::Value>(&schema_data)
146                .ok()
147                .and_then(|v| {
148                    v.get("signature")
149                        .and_then(|s| s.as_str())
150                        .map(String::from)
151                });
152
153        if let Some(ref sig) = embedded_signature {
154            // Verify the embedded signature against the schema content and fetched public key
155            // Strip the signature field to get the canonical payload that was signed
156            let mut schema_value: serde_json::Value = serde_json::from_slice(&schema_data)
157                .map_err(|e| SchemaPinError::IoError {
158                    reason: format!("Failed to parse schema JSON: {}", e),
159                })?;
160            if let Some(obj) = schema_value.as_object_mut() {
161                obj.remove("signature");
162            }
163            let canonical_payload =
164                serde_json::to_vec(&schema_value).map_err(|e| SchemaPinError::IoError {
165                    reason: format!("Failed to serialize canonical schema: {}", e),
166                })?;
167
168            match verify_signature(&public_key_pem, &canonical_payload, sig) {
169                Ok(true) => {
170                    tracing::info!(
171                        "Schema signature verified successfully for {}",
172                        args.schema_path
173                    );
174                    Ok(VerificationResult {
175                        success: true,
176                        message: "Schema signature verified successfully using native Rust implementation".to_string(),
177                        schema_hash: Some(schema_hash),
178                        public_key_url: Some(args.public_key_url.clone()),
179                        signature: Some(SignatureInfo {
180                            algorithm: "ECDSA_P256".to_string(),
181                            signature: sig.clone(),
182                            key_fingerprint: Some(key_id),
183                            valid: true,
184                        }),
185                        metadata: None,
186                        timestamp: Some(Utc::now().to_rfc3339()),
187                    })
188                }
189                Ok(false) => {
190                    tracing::warn!(
191                        "Schema signature verification failed: signature invalid for {}",
192                        args.schema_path
193                    );
194                    Ok(VerificationResult {
195                        success: false,
196                        message: "Schema signature verification failed: signature is invalid"
197                            .to_string(),
198                        schema_hash: Some(schema_hash),
199                        public_key_url: Some(args.public_key_url.clone()),
200                        signature: Some(SignatureInfo {
201                            algorithm: "ECDSA_P256".to_string(),
202                            signature: sig.clone(),
203                            key_fingerprint: Some(key_id),
204                            valid: false,
205                        }),
206                        metadata: None,
207                        timestamp: Some(Utc::now().to_rfc3339()),
208                    })
209                }
210                Err(e) => {
211                    tracing::warn!(
212                        "Schema signature verification error for {}: {}",
213                        args.schema_path,
214                        e
215                    );
216                    Ok(VerificationResult {
217                        success: false,
218                        message: format!("Schema signature verification error: {}", e),
219                        schema_hash: Some(schema_hash),
220                        public_key_url: Some(args.public_key_url.clone()),
221                        signature: Some(SignatureInfo {
222                            algorithm: "ECDSA_P256".to_string(),
223                            signature: sig.clone(),
224                            key_fingerprint: Some(key_id),
225                            valid: false,
226                        }),
227                        metadata: None,
228                        timestamp: Some(Utc::now().to_rfc3339()),
229                    })
230                }
231            }
232        } else {
233            // No signature provided — fail verification (fail-closed)
234            tracing::warn!(
235                "Schema verification failed for {}: no signature provided for verification",
236                args.schema_path
237            );
238            Ok(VerificationResult {
239                success: false,
240                message: "No signature provided for verification".to_string(),
241                schema_hash: Some(schema_hash),
242                public_key_url: Some(args.public_key_url.clone()),
243                signature: None,
244                metadata: None,
245                timestamp: Some(Utc::now().to_rfc3339()),
246            })
247        }
248    }
249
250    async fn sign_schema(&self, args: SignArgs) -> Result<SigningResult, SchemaPinError> {
251        // Validate arguments
252        if args.schema_path.is_empty() {
253            return Err(SchemaPinError::InvalidArguments {
254                args: vec!["schema_path cannot be empty".to_string()],
255            });
256        }
257
258        if args.private_key_path.is_empty() {
259            return Err(SchemaPinError::InvalidArguments {
260                args: vec!["private_key_path cannot be empty".to_string()],
261            });
262        }
263
264        // Read schema file
265        let schema_data = self.read_file(&args.schema_path).await?;
266
267        // Read private key file
268        let private_key_pem = tokio::fs::read_to_string(&args.private_key_path)
269            .await
270            .map_err(|_| SchemaPinError::PrivateKeyNotFound {
271                path: args.private_key_path.clone(),
272            })?;
273
274        // Sign the schema data
275        let signature = sign_data(&private_key_pem, &schema_data).map_err(|e| {
276            SchemaPinError::SigningFailed {
277                reason: format!("Failed to sign schema: {}", e),
278            }
279        })?;
280
281        // Calculate schema hash
282        let mut hasher = sha2::Sha256::new();
283        hasher.update(&schema_data);
284        let schema_hash = hex::encode(hasher.finalize());
285
286        // Generate key ID from the corresponding public key
287        // In practice, you'd derive the public key from the private key
288        let key_pair = generate_key_pair().map_err(|e| SchemaPinError::SigningFailed {
289            reason: format!("Failed to generate key pair for ID calculation: {}", e),
290        })?;
291
292        let key_id = calculate_key_id(&key_pair.public_key_pem).map_err(|e| {
293            SchemaPinError::SigningFailed {
294                reason: format!("Failed to calculate key ID: {}", e),
295            }
296        })?;
297
298        // Determine output path
299        let output_path = args
300            .output_path
301            .unwrap_or_else(|| format!("{}.signed", args.schema_path));
302
303        Ok(SigningResult {
304            success: true,
305            message: "Schema signed successfully using native Rust implementation".to_string(),
306            schema_hash: Some(schema_hash),
307            signed_schema_path: Some(output_path),
308            signature: Some(SignatureInfo {
309                algorithm: "ECDSA_P256".to_string(),
310                signature,
311                key_fingerprint: Some(key_id),
312                valid: true,
313            }),
314            metadata: None,
315            timestamp: Some(Utc::now().to_rfc3339()),
316        })
317    }
318
319    async fn check_available(&self) -> Result<bool, SchemaPinError> {
320        // Native implementation is always available if the crate is compiled in
321        Ok(true)
322    }
323
324    async fn get_version(&self) -> Result<String, SchemaPinError> {
325        Ok("schemapin-native v1.1.4 (Rust implementation)".to_string())
326    }
327}
328
329/// Mock implementation for testing
330pub struct MockNativeSchemaPinClient {
331    should_succeed: bool,
332    mock_result: Option<VerificationResult>,
333}
334
335impl MockNativeSchemaPinClient {
336    /// Create a new mock that always succeeds
337    pub fn new_success() -> Self {
338        Self {
339            should_succeed: true,
340            mock_result: None,
341        }
342    }
343
344    /// Create a new mock that always fails
345    pub fn new_failure() -> Self {
346        Self {
347            should_succeed: false,
348            mock_result: None,
349        }
350    }
351
352    /// Create a new mock with custom result
353    pub fn with_result(result: VerificationResult) -> Self {
354        Self {
355            should_succeed: result.success,
356            mock_result: Some(result),
357        }
358    }
359}
360
361#[async_trait]
362impl SchemaPinClient for MockNativeSchemaPinClient {
363    async fn verify_schema(&self, _args: VerifyArgs) -> Result<VerificationResult, SchemaPinError> {
364        if let Some(ref result) = self.mock_result {
365            if result.success {
366                Ok(result.clone())
367            } else {
368                Err(SchemaPinError::VerificationFailed {
369                    reason: result.message.clone(),
370                })
371            }
372        } else if self.should_succeed {
373            Ok(VerificationResult {
374                success: true,
375                message: "Mock verification successful".to_string(),
376                schema_hash: Some("mock_native_hash_123".to_string()),
377                public_key_url: Some("https://mock.example.com/pubkey".to_string()),
378                signature: Some(SignatureInfo {
379                    algorithm: "ECDSA_P256".to_string(),
380                    signature: "mock_native_signature".to_string(),
381                    key_fingerprint: Some("sha256:mock_fingerprint".to_string()),
382                    valid: true,
383                }),
384                metadata: None,
385                timestamp: Some(Utc::now().to_rfc3339()),
386            })
387        } else {
388            Err(SchemaPinError::VerificationFailed {
389                reason: "Mock verification failed".to_string(),
390            })
391        }
392    }
393
394    async fn sign_schema(&self, _args: SignArgs) -> Result<SigningResult, SchemaPinError> {
395        if self.should_succeed {
396            Ok(SigningResult {
397                success: true,
398                message: "Mock native signing successful".to_string(),
399                schema_hash: Some("mock_native_signed_hash_456".to_string()),
400                signed_schema_path: Some("/mock/path/signed_schema.json".to_string()),
401                signature: Some(SignatureInfo {
402                    algorithm: "ECDSA_P256".to_string(),
403                    signature: "mock_native_signature_data".to_string(),
404                    key_fingerprint: Some("sha256:mock_native_fingerprint".to_string()),
405                    valid: true,
406                }),
407                metadata: None,
408                timestamp: Some(Utc::now().to_rfc3339()),
409            })
410        } else {
411            Err(SchemaPinError::SigningFailed {
412                reason: "Mock native signing failed".to_string(),
413            })
414        }
415    }
416
417    async fn check_available(&self) -> Result<bool, SchemaPinError> {
418        Ok(true) // Mock always reports as available
419    }
420
421    async fn get_version(&self) -> Result<String, SchemaPinError> {
422        Ok("schemapin-cli v1.0.0 (mock)".to_string())
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[tokio::test]
431    async fn test_native_client_creation() {
432        let client = NativeSchemaPinClient::new();
433        let available = client.check_available().await.unwrap();
434        assert!(available);
435
436        let version = client.get_version().await.unwrap();
437        assert!(version.contains("schemapin-native"));
438    }
439
440    #[tokio::test]
441    async fn test_mock_native_client_success() {
442        let client = MockNativeSchemaPinClient::new_success();
443        let args = VerifyArgs::new(
444            "/path/to/schema.json".to_string(),
445            "https://example.com/pubkey".to_string(),
446        );
447
448        let result = client.verify_schema(args).await.unwrap();
449        assert!(result.success);
450        assert_eq!(result.message, "Mock verification successful");
451    }
452
453    #[tokio::test]
454    async fn test_mock_native_client_failure() {
455        let client = MockNativeSchemaPinClient::new_failure();
456        let args = VerifyArgs::new(
457            "/path/to/schema.json".to_string(),
458            "https://example.com/pubkey".to_string(),
459        );
460
461        let result = client.verify_schema(args).await;
462        assert!(result.is_err());
463
464        if let Err(SchemaPinError::VerificationFailed { reason }) = result {
465            assert_eq!(reason, "Mock verification failed");
466        } else {
467            panic!("Expected VerificationFailed error");
468        }
469    }
470}