Skip to main content

hardware_enclave/
security_key.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Hardware security key (FIDO2/WebAuthn) credentials via the Windows Hello
5//! platform authenticator.
6//!
7//! SK keys produce [`SecurityKeySignature`]s that carry the full FIDO2 assertion
8//! output — DER signature, user-presence flags, and a monotonic counter — which
9//! is the format required by `sk-ecdsa-sha2-nistp256@openssh.com` and other
10//! FIDO2-SK verifiers. This is distinct from [`SignerHandle`][crate::SignerHandle]
11//! which returns a plain DER-encoded ECDSA signature.
12//!
13//! ## Platform support
14//!
15//! | Platform | Backend |
16//! |----------|---------|
17//! | Windows  | Native `WebAuthn.dll` via platform authenticator |
18//! | WSL2     | JSON-RPC bridge → Windows TPM |
19//! | macOS / Linux | `Err(NotAvailable)` |
20//!
21//! ## RP ID derivation
22//!
23//! The FIDO2 Relying Party ID is derived deterministically from the app name
24//! and key label so each credential gets a unique, stable identifier:
25//!
26//! ```text
27//! rp_id = "{app_name}-{hex8(SHA-256("{app_name}-rp-id-v1\x00" || label)[..4])}.local"
28//! ```
29//!
30//! This prevents the Windows passkey chooser from listing multiple credentials
31//! (it only ever sees one matching RP ID), and is stable across process restarts.
32
33use std::path::PathBuf;
34
35use crate::internal::core::metadata::{self, KeyMeta};
36use crate::internal::core::types::{validate_label, KeyType};
37use base64::prelude::*;
38use sha2::{Digest, Sha256};
39
40use crate::config::EnclaveConfig;
41use crate::error::{Error, Result};
42use crate::types::{AccessPolicy, BackendKind};
43
44// ── Public types ─────────────────────────────────────────────────────────────
45
46/// A hardware security key credential backed by the Windows Hello platform
47/// authenticator (TPM + biometric/PIN). Obtain via [`create_security_key()`].
48pub struct SecurityKeyHandle {
49    app_name: String,
50    keys_dir: PathBuf,
51    backend: SkBackend,
52}
53
54impl std::fmt::Debug for SecurityKeyHandle {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("SecurityKeyHandle")
57            .field("app_name", &self.app_name)
58            .field("backend", &self.backend_kind())
59            .finish()
60    }
61}
62
63/// Metadata for a stored hardware security key credential.
64#[derive(Debug, Clone)]
65pub struct SecurityKeyInfo {
66    /// Key label (e.g. `"github-personal"`).
67    pub label: String,
68    /// Opaque credential identifier returned by the TPM at make-credential time.
69    /// Required for get-assertion calls.
70    pub credential_id: Vec<u8>,
71    /// FIDO2 Relying Party ID used when the credential was created.
72    pub rp_id: String,
73    /// Uncompressed SEC1 P-256 public key: `0x04 || X (32 bytes) || Y (32 bytes)`.
74    pub public_key: Vec<u8>,
75    /// Human-readable comment (e.g. `"user@host"`), if set at generation time.
76    pub comment: Option<String>,
77}
78
79/// Result of an SK signing operation.
80///
81/// The signature blob for `sk-ecdsa-sha2-nistp256@openssh.com` is constructed
82/// from these three fields:
83/// ```text
84/// string  "sk-ecdsa-sha2-nistp256@openssh.com"
85/// string  mpint(r) || mpint(s)   // extracted from signature_der
86/// byte    flags
87/// uint32  counter
88/// ```
89#[derive(Debug, Clone)]
90pub struct SecurityKeySignature {
91    /// DER-encoded ECDSA P-256 signature (`SEQUENCE { INTEGER r, INTEGER s }`).
92    pub signature_der: Vec<u8>,
93    /// User-presence flags from the TPM authenticator data.
94    /// Bit 0 = User Present (UP), Bit 2 = User Verified (UV).
95    pub flags: u8,
96    /// Monotonic counter from the TPM, incremented on each assertion.
97    /// Verifiers can check for replay/cloning by confirming counter increases.
98    pub counter: u32,
99}
100
101// ── Backend enum ─────────────────────────────────────────────────────────────
102
103#[derive(Debug)]
104enum SkBackend {
105    #[cfg(target_os = "windows")]
106    Native,
107    #[cfg(target_os = "linux")]
108    Bridge {
109        bridge_path: PathBuf,
110    },
111    Unavailable,
112}
113
114// ── impl SecurityKeyHandle ───────────────────────────────────────────────────
115
116impl SecurityKeyHandle {
117    fn new(app_name: String, keys_dir: PathBuf, backend: SkBackend) -> Self {
118        Self {
119            app_name,
120            keys_dir,
121            backend,
122        }
123    }
124
125    /// Whether the platform authenticator is reachable (fast, no prompt).
126    #[allow(clippy::needless_return, unreachable_code)]
127    pub fn is_available(&self) -> bool {
128        match &self.backend {
129            #[cfg(target_os = "windows")]
130            SkBackend::Native => {
131                crate::internal::windows_webauthn::is_platform_authenticator_available()
132            }
133            #[cfg(target_os = "linux")]
134            SkBackend::Bridge { bridge_path } => {
135                crate::internal::bridge::bridge_webauthn_is_available(bridge_path).unwrap_or(false)
136            }
137            SkBackend::Unavailable => false,
138        }
139    }
140
141    /// Generate a new TPM-backed credential. Fires a Hello gesture on the
142    /// Windows desktop.
143    ///
144    /// The derived RP ID and credential ID are stored in the key metadata
145    /// directory alongside a fingerprint and public key.
146    pub fn generate(&self, label: &str, comment: Option<&str>) -> Result<SecurityKeyInfo> {
147        validate_label(label).map_err(Error::from)?;
148
149        let rp_id = rp_id_for(&self.app_name, label);
150        let user_id = user_id_for(&self.app_name, label);
151
152        let (credential_id, pk_x, pk_y) = self.do_make_credential(&rp_id, label, &user_id)?;
153
154        // Build uncompressed SEC1 public key.
155        let mut public_key = Vec::with_capacity(65);
156        public_key.push(0x04);
157        public_key.extend_from_slice(&pk_x);
158        public_key.extend_from_slice(&pk_y);
159
160        // Persist metadata.
161        metadata::ensure_dir(&self.keys_dir)?;
162        #[allow(let_underscore_drop)]
163        let _lock = metadata::DirLock::acquire(&self.keys_dir)?;
164
165        let mut meta = KeyMeta::new(label, KeyType::Signing, AccessPolicy::Any);
166        let cred_b64 = BASE64_STANDARD.encode(&credential_id);
167        meta.set_app_field("algorithm", "sk-ecdsa-sha2-nistp256");
168        meta.set_app_field("credential_id_b64", cred_b64.as_str());
169        meta.set_app_field("rp_id", rp_id.as_str());
170        if let Some(c) = comment {
171            meta.set_app_field("comment", c);
172        }
173        metadata::save_meta(&self.keys_dir, label, &meta)?;
174
175        // Cache public key.
176        let pub_path = self.keys_dir.join(format!("{label}.pub"));
177        metadata::atomic_write(&pub_path, &public_key)?;
178
179        Ok(SecurityKeyInfo {
180            label: label.to_string(),
181            credential_id,
182            rp_id,
183            public_key,
184            comment: comment.map(str::to_string),
185        })
186    }
187
188    /// Sign `data` with the named credential. Fires a Hello gesture.
189    ///
190    /// Returns the full FIDO2 assertion output needed to build an
191    /// `sk-ecdsa-sha2-nistp256@openssh.com` signature blob.
192    pub fn sign(&self, label: &str, data: &[u8]) -> Result<SecurityKeySignature> {
193        let info = self.get_credential(label)?;
194        let (signature_der, flags, counter) =
195            self.do_get_assertion(&info.rp_id, &info.credential_id, data)?;
196        Ok(SecurityKeySignature {
197            signature_der,
198            flags,
199            counter,
200        })
201    }
202
203    /// List all SK credentials in this app's key directory.
204    pub fn list_credentials(&self) -> Result<Vec<SecurityKeyInfo>> {
205        let labels = metadata::list_labels(&self.keys_dir)?;
206        let mut out = Vec::new();
207        for label in labels {
208            if let Ok(meta) = metadata::load_meta(&self.keys_dir, &label) {
209                if meta.get_app_field("algorithm") == Some("sk-ecdsa-sha2-nistp256") {
210                    if let Ok(info) = self.info_from_meta(&label, &meta) {
211                        out.push(info);
212                    }
213                }
214            }
215        }
216        Ok(out)
217    }
218
219    /// Get metadata for a specific SK credential.
220    pub fn get_credential(&self, label: &str) -> Result<SecurityKeyInfo> {
221        let meta = metadata::load_meta(&self.keys_dir, label).map_err(|_| Error::KeyNotFound {
222            label: label.to_string(),
223        })?;
224        if meta.get_app_field("algorithm") != Some("sk-ecdsa-sha2-nistp256") {
225            return Err(Error::KeyNotFound {
226                label: label.to_string(),
227            });
228        }
229        self.info_from_meta(label, &meta)
230    }
231
232    /// Check whether an SK credential with this label exists.
233    pub fn credential_exists(&self, label: &str) -> Result<bool> {
234        match self.get_credential(label) {
235            Ok(_) => Ok(true),
236            Err(Error::KeyNotFound { .. }) => Ok(false),
237            Err(e) => Err(e),
238        }
239    }
240
241    /// Delete the SK credential and its metadata. Best-effort removal of the
242    /// platform credential (ignored if already deleted from Windows passkeys).
243    pub fn delete_credential(&self, label: &str) -> Result<()> {
244        let info = self.get_credential(label)?;
245        // Best-effort removal from the platform credential store.
246        drop(self.do_delete_credential(&info.credential_id));
247        // Remove local metadata files.
248        metadata::delete_key_files(&self.keys_dir, label)?;
249        Ok(())
250    }
251
252    /// Which hardware security backend backs this handle.
253    ///
254    /// Returns `None` when the platform authenticator is not available on this
255    /// platform (macOS, unsupported Linux without a WSL bridge). A `None` result
256    /// indicates that [`generate`][SecurityKeyHandle::generate] and
257    /// [`sign`][SecurityKeyHandle::sign] will always return
258    /// [`Error::NotAvailable`][crate::Error::NotAvailable].
259    pub fn backend_kind(&self) -> Option<BackendKind> {
260        match &self.backend {
261            #[cfg(target_os = "windows")]
262            SkBackend::Native => Some(BackendKind::Tpm),
263            #[cfg(target_os = "linux")]
264            SkBackend::Bridge { .. } => Some(BackendKind::TpmBridge),
265            SkBackend::Unavailable => None,
266        }
267    }
268
269    // ── private helpers ──────────────────────────────────────────────────────
270
271    // Parameters used only in platform-specific cfg arms; suppress "unused" on other platforms.
272    #[allow(clippy::needless_return, unreachable_code)]
273    #[cfg_attr(
274        not(any(target_os = "windows", target_os = "linux")),
275        allow(unused_variables)
276    )]
277    fn do_make_credential(
278        &self,
279        rp_id: &str,
280        label: &str,
281        user_id: &[u8],
282    ) -> Result<(Vec<u8>, [u8; 32], [u8; 32])> {
283        match &self.backend {
284            #[cfg(target_os = "windows")]
285            SkBackend::Native => {
286                let params = crate::internal::windows_webauthn::MakeCredentialParams {
287                    rp_id,
288                    rp_name: &self.app_name,
289                    user_id,
290                    user_name: label,
291                    user_display_name: label,
292                    timeout_ms: 60_000,
293                    hwnd: None,
294                };
295                let cred =
296                    crate::internal::windows_webauthn::make_credential(params).map_err(|e| {
297                        Error::KeyOperation {
298                            operation: "sk_make_credential".into(),
299                            detail: e.to_string(),
300                        }
301                    })?;
302                return Ok((cred.credential_id, cred.public_key_x, cred.public_key_y));
303            }
304            #[cfg(target_os = "linux")]
305            SkBackend::Bridge { bridge_path } => {
306                let result = crate::internal::bridge::bridge_webauthn_make_credential(
307                    bridge_path,
308                    rp_id,
309                    &self.app_name,
310                    user_id,
311                    label,
312                    label,
313                    60_000,
314                )
315                .map_err(|e| Error::KeyOperation {
316                    operation: "sk_make_credential_bridge".into(),
317                    detail: e.to_string(),
318                })?;
319                let credential_id =
320                    BASE64_STANDARD
321                        .decode(&result.credential_id_b64)
322                        .map_err(|e| Error::KeyOperation {
323                            operation: "sk_decode_credential_id".into(),
324                            detail: e.to_string(),
325                        })?;
326                let pk_x =
327                    hex_to_32(&result.public_key_x_hex).map_err(|e| Error::KeyOperation {
328                        operation: "sk_decode_pubkey_x".into(),
329                        detail: e,
330                    })?;
331                let pk_y =
332                    hex_to_32(&result.public_key_y_hex).map_err(|e| Error::KeyOperation {
333                        operation: "sk_decode_pubkey_y".into(),
334                        detail: e,
335                    })?;
336                return Ok((credential_id, pk_x, pk_y));
337            }
338            SkBackend::Unavailable => {
339                return Err(Error::NotAvailable);
340            }
341        }
342    }
343
344    #[allow(clippy::needless_return, unreachable_code)]
345    #[cfg_attr(
346        not(any(target_os = "windows", target_os = "linux")),
347        allow(unused_variables)
348    )]
349    fn do_get_assertion(
350        &self,
351        rp_id: &str,
352        credential_id: &[u8],
353        client_data: &[u8],
354    ) -> Result<(Vec<u8>, u8, u32)> {
355        match &self.backend {
356            #[cfg(target_os = "windows")]
357            SkBackend::Native => {
358                let params = crate::internal::windows_webauthn::GetAssertionParams {
359                    rp_id,
360                    credential_id,
361                    client_data,
362                    timeout_ms: 60_000,
363                    hwnd: None,
364                };
365                let assertion =
366                    crate::internal::windows_webauthn::get_assertion(params).map_err(|e| {
367                        Error::SignFailed {
368                            detail: e.to_string(),
369                        }
370                    })?;
371                return Ok((assertion.signature_der, assertion.flags, assertion.counter));
372            }
373            #[cfg(target_os = "linux")]
374            SkBackend::Bridge { bridge_path } => {
375                let result = crate::internal::bridge::bridge_webauthn_get_assertion(
376                    bridge_path,
377                    rp_id,
378                    credential_id,
379                    client_data,
380                    60_000,
381                )
382                .map_err(|e| Error::SignFailed {
383                    detail: e.to_string(),
384                })?;
385                let signature_der =
386                    BASE64_STANDARD
387                        .decode(&result.signature_der_b64)
388                        .map_err(|e| Error::SignFailed {
389                            detail: format!("decode signature: {e}"),
390                        })?;
391                return Ok((signature_der, result.flags, result.counter));
392            }
393            SkBackend::Unavailable => {
394                return Err(Error::NotAvailable);
395            }
396        }
397    }
398
399    #[allow(clippy::needless_return, unreachable_code)]
400    #[cfg_attr(
401        not(any(target_os = "windows", target_os = "linux")),
402        allow(unused_variables)
403    )]
404    fn do_delete_credential(&self, credential_id: &[u8]) -> Result<()> {
405        match &self.backend {
406            #[cfg(target_os = "windows")]
407            SkBackend::Native => {
408                return crate::internal::windows_webauthn::delete_platform_credential(
409                    credential_id,
410                )
411                .map_err(|e| Error::KeyOperation {
412                    operation: "sk_delete".into(),
413                    detail: e.to_string(),
414                });
415            }
416            #[cfg(target_os = "linux")]
417            SkBackend::Bridge { bridge_path } => {
418                return crate::internal::bridge::bridge_webauthn_delete_platform_credential(
419                    bridge_path,
420                    credential_id,
421                )
422                .map_err(|e| Error::KeyOperation {
423                    operation: "sk_delete_bridge".into(),
424                    detail: e.to_string(),
425                });
426            }
427            SkBackend::Unavailable => {
428                return Ok(());
429            }
430        }
431    }
432
433    fn info_from_meta(&self, label: &str, meta: &KeyMeta) -> Result<SecurityKeyInfo> {
434        let credential_id_b64 =
435            meta.get_app_field("credential_id_b64")
436                .ok_or_else(|| Error::KeyOperation {
437                    operation: "sk_load".into(),
438                    detail: format!("key '{label}' missing credential_id_b64 in metadata"),
439                })?;
440        let credential_id =
441            BASE64_STANDARD
442                .decode(credential_id_b64)
443                .map_err(|e| Error::KeyOperation {
444                    operation: "sk_load".into(),
445                    detail: format!("invalid credential_id_b64: {e}"),
446                })?;
447        let rp_id = match meta.get_app_field("rp_id") {
448            Some(r) => r.to_string(),
449            None => rp_id_for(&self.app_name, label),
450        };
451        let comment = meta.get_app_field("comment").map(str::to_string);
452
453        // Read cached public key if available.
454        let pub_path = self.keys_dir.join(format!("{label}.pub"));
455        let public_key = if pub_path.exists() {
456            metadata::read_no_follow(&pub_path).map_err(Error::from)?
457        } else {
458            Vec::new()
459        };
460
461        Ok(SecurityKeyInfo {
462            label: label.to_string(),
463            credential_id,
464            rp_id,
465            public_key,
466            comment,
467        })
468    }
469}
470
471// ── RP-ID / user-ID derivation ───────────────────────────────────────────────
472
473/// Derive a deterministic, unique FIDO2 Relying Party ID for `(app_name, label)`.
474///
475/// Format: `"{app_name}-{8 hex chars}.local"` where the hex is SHA-256 of
476/// `"{app_name}-rp-id-v1\x00" || label`, truncated to 4 bytes.
477///
478/// This matches the formula used by sshenc when `app_name == "sshenc"`,
479/// ensuring backward compatibility with existing credentials.
480///
481/// This is an internal derivation helper and is not part of the stable public API.
482pub(crate) fn rp_id_for(app_name: &str, label: &str) -> String {
483    let mut h = Sha256::new();
484    h.update(app_name.as_bytes());
485    h.update(b"-rp-id-v1\x00");
486    h.update(label.as_bytes());
487    let digest = h.finalize();
488    format!(
489        "{app_name}-{:08x}.local",
490        u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
491    )
492}
493
494/// Derive a deterministic 32-byte user ID for FIDO2 make-credential.
495///
496/// This is an internal derivation helper and is not part of the stable public API.
497pub(crate) fn user_id_for(app_name: &str, label: &str) -> Vec<u8> {
498    let mut h = Sha256::new();
499    h.update(app_name.as_bytes());
500    h.update(b"-user-id-v1\x00");
501    h.update(label.as_bytes());
502    h.finalize().to_vec()
503}
504
505// ── helpers ──────────────────────────────────────────────────────────────────
506
507#[cfg(target_os = "linux")]
508fn hex_to_32(hex: &str) -> std::result::Result<[u8; 32], String> {
509    if hex.len() != 64 {
510        return Err(format!("expected 64 hex chars, got {}", hex.len()));
511    }
512    let mut out = [0_u8; 32];
513    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
514        let s = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
515        out[i] = u8::from_str_radix(s, 16).map_err(|e| e.to_string())?;
516    }
517    Ok(out)
518}
519
520// ── factory ──────────────────────────────────────────────────────────────────
521
522/// Create a `SecurityKeyHandle` for the current platform.
523///
524/// Returns `Ok(handle)` even on platforms where SK is unavailable —
525/// check [`SecurityKeyHandle::is_available()`] before calling
526/// [`generate`][SecurityKeyHandle::generate] or [`sign`][SecurityKeyHandle::sign].
527#[allow(clippy::needless_return, unreachable_code)]
528pub(crate) fn make_security_key_handle(config: &EnclaveConfig) -> SecurityKeyHandle {
529    let app_name = config.effective_app_name();
530    let keys_dir = config
531        .keys_dir
532        .clone()
533        .unwrap_or_else(|| metadata::keys_dir(&app_name));
534
535    #[cfg(target_os = "windows")]
536    return SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Native);
537
538    #[cfg(target_os = "linux")]
539    {
540        let extra_paths: Vec<String> = match &config.platform {
541            crate::config::PlatformConfig::Linux(l) => l.extra_bridge_paths.clone(),
542            _ => Vec::new(),
543        };
544        if let Some(bridge_path) =
545            crate::internal::app_storage::platform::find_bridge_executable(&app_name, &extra_paths)
546        {
547            return SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Bridge { bridge_path });
548        }
549    }
550
551    SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Unavailable)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn rp_id_is_stable_and_unique() {
560        let a = rp_id_for("sshenc", "github");
561        let b = rp_id_for("sshenc", "github");
562        assert_eq!(a, b, "rp_id must be deterministic");
563        assert!(a.starts_with("sshenc-"));
564        assert!(a.ends_with(".local"));
565
566        let other = rp_id_for("sshenc", "gitlab");
567        assert_ne!(a, other, "different labels must produce different rp_ids");
568    }
569
570    #[test]
571    fn rp_id_matches_sshenc_formula() {
572        // Verify our formula matches the "sshenc-rp-id-v1\x00" domain separator
573        // that sshenc uses for backward compatibility.
574        let rp_id = rp_id_for("sshenc", "test-key");
575        // Must start with "sshenc-" and end with ".local", 8 hex chars in between.
576        assert!(rp_id.starts_with("sshenc-"));
577        assert!(rp_id.ends_with(".local"));
578        let hex_part = &rp_id[7..rp_id.len() - 6]; // strip "sshenc-" and ".local"
579        assert_eq!(hex_part.len(), 8, "must be 8 hex chars (4 bytes)");
580        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
581    }
582
583    #[test]
584    fn user_id_is_32_bytes() {
585        let uid = user_id_for("sshenc", "test-key");
586        assert_eq!(uid.len(), 32);
587    }
588
589    #[test]
590    fn user_id_is_stable() {
591        let a = user_id_for("myapp", "key1");
592        let b = user_id_for("myapp", "key1");
593        assert_eq!(a, b);
594        let other = user_id_for("myapp", "key2");
595        assert_ne!(a, other);
596    }
597
598    #[test]
599    fn is_available_does_not_panic() {
600        let config = EnclaveConfig::new("testapp", "default");
601        let handle = make_security_key_handle(&config);
602        let _ = handle.is_available();
603    }
604}