Skip to main content

hardware_enclave/internal/bridge/
protocol.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! JSON-RPC protocol types shared between server and client.
5#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
6
7use crate::internal::core::AccessPolicy;
8use base64::Engine;
9use serde::{Deserialize, Serialize};
10
11/// Bridge request sent from WSL client to Windows server.
12#[derive(Debug, Serialize, Deserialize)]
13pub struct BridgeRequest {
14    /// Method: "init", "encrypt", "decrypt", "destroy"
15    pub method: String,
16    /// Parameters.
17    pub params: BridgeParams,
18}
19
20/// Bridge request parameters.
21///
22/// The legacy `biometric: bool` field from earlier releases has been
23/// removed. `access_policy` is now the only accepted encoding of the
24/// key's access policy on the wire. See THREAT_MODEL.md T5 — the
25/// legacy field opened a silent-downgrade path for a malicious bridge
26/// peer that honored `biometric` and ignored `access_policy`.
27///
28/// The `webauthn_*` fields are populated only by the
29/// `webauthn_make_credential` / `webauthn_get_assertion` /
30/// `webauthn_delete_platform_credential` methods (used to give WSL
31/// callers parity with native-Windows SK keygen / sign by routing
32/// through `webauthn.dll` on the Windows host). Other methods leave
33/// them empty and the server ignores them.
34#[derive(Debug, Default, Serialize, Deserialize)]
35pub struct BridgeParams {
36    /// Base64-encoded data (plaintext for encrypt, ciphertext for decrypt).
37    #[serde(default)]
38    pub data: String,
39    /// Access policy to enforce on key use.
40    #[serde(default)]
41    pub access_policy: AccessPolicy,
42    /// Application name (determines TPM key name).
43    #[serde(default)]
44    pub app_name: String,
45    /// Key label within the application namespace.
46    #[serde(default)]
47    pub key_label: String,
48
49    // --- WebAuthn / SK fields ---
50    //
51    // All optional; populated only by the `webauthn_*` methods.
52    // Skipped on serialize when `None` so non-SK requests stay on
53    // the existing wire shape.
54    /// FIDO2 Relying-Party identifier (e.g. `sshenc-<keyhash>.local`).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub rp_id: Option<String>,
57    /// Human-readable RP name surfaced in the Hello prompt.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub rp_name: Option<String>,
60    /// Per-user opaque ID (base64). Used at make-credential time.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub user_id_b64: Option<String>,
63    /// Username surfaced in the Hello prompt.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub user_name: Option<String>,
66    /// Display name surfaced in the Hello prompt.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub user_display_name: Option<String>,
69    /// Credential identifier (base64). Required for
70    /// `webauthn_get_assertion` and `webauthn_delete_platform_credential`.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub credential_id_b64: Option<String>,
73    /// Bytes that webauthn.dll hashes with SHA-256 and signs as the
74    /// `clientDataHash`. For SSH-SK signing this is the raw SSH
75    /// session-binding payload (we exploit the bytes-of-JSON
76    /// contract -- see `enclaveapp-windows-webauthn` for the
77    /// brittleness note). Base64.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub client_data_b64: Option<String>,
80    /// Hello prompt timeout in milliseconds (default 60_000).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub timeout_ms: Option<u32>,
83}
84
85impl BridgeParams {
86    /// Access policy requested by this message. Kept as a method for
87    /// source-compatibility with the legacy `effective_access_policy()`
88    /// call sites that used to reconcile `access_policy` vs a legacy
89    /// `biometric: bool` flag.
90    #[must_use]
91    pub fn effective_access_policy(&self) -> AccessPolicy {
92        self.access_policy
93    }
94
95    /// Build a new `BridgeParams` for the legacy (TPM/CNG) methods.
96    /// The WebAuthn-side fields are left `None`.
97    #[must_use]
98    pub fn new(
99        data: String,
100        access_policy: AccessPolicy,
101        app_name: String,
102        key_label: String,
103    ) -> Self {
104        Self {
105            data,
106            access_policy,
107            app_name,
108            key_label,
109            rp_id: None,
110            rp_name: None,
111            user_id_b64: None,
112            user_name: None,
113            user_display_name: None,
114            credential_id_b64: None,
115            client_data_b64: None,
116            timeout_ms: None,
117        }
118    }
119}
120
121/// JSON payload returned in `BridgeResponse::result` for a successful
122/// `webauthn_make_credential` call. Caller `serde_json::from_str`s
123/// the result string to recover the structure.
124#[derive(Debug, Serialize, Deserialize)]
125pub struct WebauthnMakeCredentialResult {
126    /// Base64 of the platform-authenticator credential identifier.
127    pub credential_id_b64: String,
128    /// Hex-encoded ECDSA P-256 public-key X coordinate (32 bytes).
129    pub public_key_x_hex: String,
130    /// Hex-encoded ECDSA P-256 public-key Y coordinate (32 bytes).
131    pub public_key_y_hex: String,
132    /// Base64 of the raw `authenticator_data` from the make-credential
133    /// response. Caller can use it for audit / debugging; not
134    /// required for downstream signing.
135    pub authenticator_data_b64: String,
136    /// Whether the platform authenticator made the credential
137    /// resident (Win11 26200+ forces resident regardless of the
138    /// `bRequireResidentKey` hint).
139    pub resident: bool,
140}
141
142/// JSON payload returned in `BridgeResponse::result` for a successful
143/// `webauthn_get_assertion` call.
144#[derive(Debug, Serialize, Deserialize)]
145pub struct WebauthnAssertionResult {
146    /// Base64 of the DER-encoded ECDSA signature (`SEQUENCE { INTEGER r, INTEGER s }`).
147    pub signature_der_b64: String,
148    /// Base64 of the `authenticator_data` blob (32-byte rpIdHash + flags + counter, possibly + extensions).
149    pub authenticator_data_b64: String,
150    /// `authenticator_data[32]`. Bit 0 = User Present, bit 2 = User Verified.
151    pub flags: u8,
152    /// Big-endian u32 from `authenticator_data[33..37]`. The TPM
153    /// increments this on every assertion.
154    pub counter: u32,
155}
156
157/// Bridge response from Windows server to WSL client.
158#[derive(Debug, Serialize, Deserialize)]
159pub struct BridgeResponse {
160    /// Base64-encoded result data (on success).
161    pub result: Option<String>,
162    /// Error message (on failure).
163    pub error: Option<String>,
164}
165
166impl BridgeResponse {
167    /// Create a successful response with data.
168    pub fn success(data: &str) -> Self {
169        BridgeResponse {
170            result: Some(data.to_string()),
171            error: None,
172        }
173    }
174
175    /// Create an error response.
176    pub fn error(msg: &str) -> Self {
177        BridgeResponse {
178            result: None,
179            error: Some(msg.to_string()),
180        }
181    }
182
183    /// Create a successful response with no data.
184    pub fn ok() -> Self {
185        BridgeResponse {
186            result: Some(String::new()),
187            error: None,
188        }
189    }
190
191    /// Require that the response contains a success result payload.
192    pub fn require_result(&self, operation: &str) -> crate::internal::core::Result<&str> {
193        if let Some(error) = &self.error {
194            return Err(crate::internal::core::Error::KeyOperation {
195                operation: operation.into(),
196                detail: error.clone(),
197            });
198        }
199        self.result
200            .as_deref()
201            .ok_or_else(|| crate::internal::core::Error::KeyOperation {
202                operation: operation.into(),
203                detail: "bridge response missing result payload".into(),
204            })
205    }
206
207    /// Require an acknowledged success response.
208    pub fn require_ok(&self, operation: &str) -> crate::internal::core::Result<()> {
209        let _unused = self.require_result(operation)?;
210        Ok(())
211    }
212
213    /// Decode a base64-encoded success payload.
214    pub fn decode_result(&self, operation: &str) -> crate::internal::core::Result<Vec<u8>> {
215        decode_data(self.require_result(operation)?)
216    }
217}
218
219/// Encode binary data as base64 for the bridge protocol.
220pub fn encode_data(data: &[u8]) -> String {
221    base64::engine::general_purpose::STANDARD.encode(data)
222}
223
224/// Decode base64 data from the bridge protocol.
225pub fn decode_data(encoded: &str) -> crate::internal::core::Result<Vec<u8>> {
226    base64::engine::general_purpose::STANDARD
227        .decode(encoded)
228        .map_err(|e| crate::internal::core::Error::Serialization(format!("base64 decode: {e}")))
229}
230
231#[cfg(test)]
232#[allow(clippy::unwrap_used, clippy::panic)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn bridge_request_serde_roundtrip() {
238        let request = BridgeRequest {
239            method: "encrypt".to_string(),
240            params: BridgeParams::new(
241                "aGVsbG8=".to_string(),
242                AccessPolicy::BiometricOnly,
243                "test-app".to_string(),
244                "cache-key".to_string(),
245            ),
246        };
247        let json = serde_json::to_string(&request).unwrap();
248        let parsed: BridgeRequest = serde_json::from_str(&json).unwrap();
249        assert_eq!(parsed.method, "encrypt");
250        assert_eq!(parsed.params.data, "aGVsbG8=");
251        assert_eq!(parsed.params.access_policy, AccessPolicy::BiometricOnly);
252        assert_eq!(parsed.params.app_name, "test-app");
253        assert_eq!(parsed.params.key_label, "cache-key");
254    }
255
256    #[test]
257    fn bridge_request_defaults_for_missing_fields() {
258        let json = r#"{"method":"init","params":{}}"#;
259        let parsed: BridgeRequest = serde_json::from_str(json).unwrap();
260        assert_eq!(parsed.method, "init");
261        assert_eq!(parsed.params.data, "");
262        assert_eq!(parsed.params.access_policy, AccessPolicy::None);
263        assert_eq!(parsed.params.app_name, "");
264        assert_eq!(parsed.params.key_label, "");
265    }
266
267    #[test]
268    fn bridge_request_ignores_legacy_biometric_field() {
269        // Older callers may still include `biometric: true` on the wire.
270        // We must not silently honor it — access_policy is authoritative.
271        // Unknown fields are ignored by serde's default, so the legacy
272        // flag simply has no effect.
273        let json = r#"{"method":"encrypt","params":{"biometric":true,"access_policy":"none","app_name":"a","key_label":"k"}}"#;
274        let parsed: BridgeRequest = serde_json::from_str(json).unwrap();
275        assert_eq!(parsed.params.access_policy, AccessPolicy::None);
276        assert_eq!(parsed.params.effective_access_policy(), AccessPolicy::None);
277    }
278
279    #[test]
280    fn bridge_response_success_construction() {
281        let resp = BridgeResponse::success("c29tZSBkYXRh");
282        assert_eq!(resp.result, Some("c29tZSBkYXRh".to_string()));
283        assert!(resp.error.is_none());
284    }
285
286    #[test]
287    fn bridge_response_error_construction() {
288        let resp = BridgeResponse::error("something went wrong");
289        assert!(resp.result.is_none());
290        assert_eq!(resp.error, Some("something went wrong".to_string()));
291    }
292
293    #[test]
294    fn bridge_response_ok_construction() {
295        let resp = BridgeResponse::ok();
296        assert_eq!(resp.result, Some(String::new()));
297        assert!(resp.error.is_none());
298    }
299
300    #[test]
301    fn bridge_response_serde_roundtrip() {
302        let resp = BridgeResponse::success("dGVzdA==");
303        let json = serde_json::to_string(&resp).unwrap();
304        let parsed: BridgeResponse = serde_json::from_str(&json).unwrap();
305        assert_eq!(parsed.result, Some("dGVzdA==".to_string()));
306        assert!(parsed.error.is_none());
307    }
308
309    #[test]
310    fn encode_decode_roundtrip_empty() {
311        let data = b"";
312        let encoded = encode_data(data);
313        let decoded = decode_data(&encoded).unwrap();
314        assert_eq!(decoded, data);
315    }
316
317    #[test]
318    fn encode_decode_roundtrip_small() {
319        let data = b"hello, world!";
320        let encoded = encode_data(data);
321        let decoded = decode_data(&encoded).unwrap();
322        assert_eq!(decoded, data);
323    }
324
325    #[test]
326    fn encode_decode_roundtrip_large() {
327        let data: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
328        let encoded = encode_data(&data);
329        let decoded = decode_data(&encoded).unwrap();
330        assert_eq!(decoded, data);
331    }
332
333    #[test]
334    fn decode_data_rejects_invalid_base64() {
335        let result = decode_data("not valid base64!!!");
336        assert!(result.is_err());
337    }
338
339    #[test]
340    fn bridge_request_all_methods() {
341        for method in &["init", "encrypt", "decrypt", "destroy", "delete"] {
342            let request = BridgeRequest {
343                method: (*method).to_string(),
344                params: BridgeParams::new(
345                    String::new(),
346                    AccessPolicy::None,
347                    "test".to_string(),
348                    "default".to_string(),
349                ),
350            };
351            let json = serde_json::to_string(&request).unwrap();
352            let parsed: BridgeRequest = serde_json::from_str(&json).unwrap();
353            assert_eq!(parsed.method, *method);
354        }
355    }
356
357    #[test]
358    fn bridge_response_success_with_empty_result() {
359        let resp = BridgeResponse::ok();
360        assert_eq!(resp.result, Some(String::new()));
361        assert!(resp.error.is_none());
362
363        // Verify it roundtrips through JSON
364        let json = serde_json::to_string(&resp).unwrap();
365        let parsed: BridgeResponse = serde_json::from_str(&json).unwrap();
366        assert_eq!(parsed.result, Some(String::new()));
367    }
368
369    #[test]
370    fn bridge_response_error_preserves_message() {
371        let msg = "TPM device not found: error code 0x8028000F";
372        let resp = BridgeResponse::error(msg);
373        assert_eq!(resp.error.as_deref(), Some(msg));
374        assert!(resp.result.is_none());
375
376        let json = serde_json::to_string(&resp).unwrap();
377        let parsed: BridgeResponse = serde_json::from_str(&json).unwrap();
378        assert_eq!(parsed.error.as_deref(), Some(msg));
379    }
380
381    #[test]
382    fn encode_decode_binary_data_with_null_bytes() {
383        let data: Vec<u8> = vec![0x00, 0x01, 0x00, 0xFF, 0x00, 0xFE];
384        let encoded = encode_data(&data);
385        let decoded = decode_data(&encoded).unwrap();
386        assert_eq!(decoded, data);
387    }
388
389    #[test]
390    fn encode_decode_large_data_1mb() {
391        let data: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
392        let encoded = encode_data(&data);
393        let decoded = decode_data(&encoded).unwrap();
394        assert_eq!(decoded, data);
395    }
396
397    #[test]
398    fn decode_data_invalid_base64_returns_error() {
399        let result = decode_data("!!!not-base64!!!");
400        assert!(result.is_err());
401        let err_msg = format!("{}", result.unwrap_err());
402        assert!(err_msg.contains("base64"));
403    }
404
405    #[test]
406    fn decode_data_empty_string_returns_empty_vec() {
407        let result = decode_data("").unwrap();
408        assert!(result.is_empty());
409    }
410
411    #[test]
412    fn bridge_params_default_values() {
413        let json = r#"{}"#;
414        let params: BridgeParams = serde_json::from_str(json).unwrap();
415        assert_eq!(params.data, "");
416        assert_eq!(params.access_policy, AccessPolicy::None);
417        assert_eq!(params.app_name, "");
418        assert_eq!(params.key_label, "");
419    }
420
421    #[cfg(target_os = "macos")]
422    #[test]
423    fn find_bridge_returns_none_on_macos() {
424        // On macOS there's no /mnt/c/ and no bridge binary
425        let result = crate::internal::bridge::find_bridge("sshenc");
426        assert!(result.is_none());
427    }
428
429    #[test]
430    fn bridge_params_biometric_only_access_policy() {
431        let json = r#"{"access_policy":"biometric_only","app_name":"t","key_label":"k"}"#;
432        let params: BridgeParams = serde_json::from_str(json).unwrap();
433        assert_eq!(
434            params.effective_access_policy(),
435            AccessPolicy::BiometricOnly
436        );
437    }
438
439    #[test]
440    fn bridge_params_any_access_policy() {
441        let json = r#"{"access_policy":"any","app_name":"t","key_label":"k"}"#;
442        let params: BridgeParams = serde_json::from_str(json).unwrap();
443        assert_eq!(params.effective_access_policy(), AccessPolicy::Any);
444    }
445
446    #[test]
447    fn bridge_params_wire_format_omits_biometric_field() {
448        // We must not serialize a `biometric` field on the wire — old
449        // servers that preferred it over `access_policy` would observe
450        // a false value and silently downgrade. Note: the
451        // `biometric_only` access-policy enum variant string contains
452        // the substring `biometric`, so the assertion must check for
453        // the quoted JSON key specifically.
454        let params = BridgeParams::new(
455            String::new(),
456            AccessPolicy::BiometricOnly,
457            "app".into(),
458            "key".into(),
459        );
460        let json = serde_json::to_string(&params).unwrap();
461        assert!(!json.contains("\"biometric\""));
462        assert!(json.contains("\"access_policy\":\"biometric_only\""));
463    }
464
465    #[test]
466    fn bridge_response_require_result_rejects_null() {
467        let resp = BridgeResponse {
468            result: None,
469            error: None,
470        };
471        let err = resp.require_result("test_op").unwrap_err();
472        assert!(err.to_string().contains("missing result payload"));
473    }
474
475    #[test]
476    fn bridge_response_require_result_rejects_error() {
477        let resp = BridgeResponse::error("boom");
478        let err = resp.require_result("test_op").unwrap_err();
479        assert!(err.to_string().contains("boom"));
480    }
481
482    #[test]
483    fn bridge_response_decode_result_works() {
484        let resp = BridgeResponse::success("aGVsbG8=");
485        let data = resp.decode_result("test_op").unwrap();
486        assert_eq!(data, b"hello");
487    }
488
489    #[test]
490    fn bridge_response_require_ok_succeeds_on_ok() {
491        let resp = BridgeResponse::ok();
492        assert!(resp.require_ok("test").is_ok());
493    }
494
495    #[test]
496    fn bridge_response_require_ok_rejects_null() {
497        let resp = BridgeResponse {
498            result: None,
499            error: None,
500        };
501        let err = resp.require_ok("test").unwrap_err();
502        assert!(err.to_string().contains("missing result payload"));
503    }
504
505    #[test]
506    fn bridge_response_require_ok_rejects_error() {
507        let resp = BridgeResponse::error("fail");
508        let err = resp.require_ok("test").unwrap_err();
509        assert!(err.to_string().contains("fail"));
510    }
511
512    #[test]
513    fn bridge_response_decode_result_empty_string() {
514        let resp = BridgeResponse::ok();
515        let data = resp.decode_result("test").unwrap();
516        assert!(data.is_empty());
517    }
518
519    #[test]
520    fn bridge_response_decode_result_rejects_invalid_base64() {
521        let resp = BridgeResponse::success("not-valid-base64!!!");
522        let err = resp.decode_result("test").unwrap_err();
523        assert!(err.to_string().contains("base64"));
524    }
525
526    #[test]
527    fn effective_access_policy_with_all_variants() {
528        let params = BridgeParams::new(String::new(), AccessPolicy::Any, "a".into(), "k".into());
529        assert_eq!(params.effective_access_policy(), AccessPolicy::Any);
530
531        let params = BridgeParams::new(
532            String::new(),
533            AccessPolicy::PasswordOnly,
534            "a".into(),
535            "k".into(),
536        );
537        assert_eq!(params.effective_access_policy(), AccessPolicy::PasswordOnly);
538
539        let params = BridgeParams::new(String::new(), AccessPolicy::None, "a".into(), "k".into());
540        assert_eq!(params.effective_access_policy(), AccessPolicy::None);
541    }
542
543    #[test]
544    fn bridge_params_roundtrip_preserves_all_fields() {
545        let original = BridgeParams::new(
546            "dGVzdA==".into(),
547            AccessPolicy::BiometricOnly,
548            "my-app".into(),
549            "my-key".into(),
550        );
551        let json = serde_json::to_string(&original).unwrap();
552        let parsed: BridgeParams = serde_json::from_str(&json).unwrap();
553        assert_eq!(parsed.data, "dGVzdA==");
554        assert_eq!(parsed.access_policy, AccessPolicy::BiometricOnly);
555        assert_eq!(
556            parsed.effective_access_policy(),
557            AccessPolicy::BiometricOnly
558        );
559        assert_eq!(parsed.app_name, "my-app");
560        assert_eq!(parsed.key_label, "my-key");
561    }
562}