1use 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
44pub 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#[derive(Debug, Clone)]
65pub struct SecurityKeyInfo {
66 pub label: String,
68 pub credential_id: Vec<u8>,
71 pub rp_id: String,
73 pub public_key: Vec<u8>,
75 pub comment: Option<String>,
77}
78
79#[derive(Debug, Clone)]
90pub struct SecurityKeySignature {
91 pub signature_der: Vec<u8>,
93 pub flags: u8,
96 pub counter: u32,
99}
100
101#[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
114impl 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 #[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 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 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 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 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 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 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 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 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 pub fn delete_credential(&self, label: &str) -> Result<()> {
244 let info = self.get_credential(label)?;
245 drop(self.do_delete_credential(&info.credential_id));
247 metadata::delete_key_files(&self.keys_dir, label)?;
249 Ok(())
250 }
251
252 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 #[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 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
471pub(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
494pub(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#[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#[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 let rp_id = rp_id_for("sshenc", "test-key");
575 assert!(rp_id.starts_with("sshenc-"));
577 assert!(rp_id.ends_with(".local"));
578 let hex_part = &rp_id[7..rp_id.len() - 6]; 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}