use std::path::PathBuf;
use crate::internal::core::metadata::{self, KeyMeta};
use crate::internal::core::types::{validate_label, KeyType};
use base64::prelude::*;
use sha2::{Digest, Sha256};
use crate::config::EnclaveConfig;
use crate::error::{Error, Result};
use crate::types::{AccessPolicy, BackendKind};
pub struct SecurityKeyHandle {
app_name: String,
keys_dir: PathBuf,
backend: SkBackend,
}
impl std::fmt::Debug for SecurityKeyHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecurityKeyHandle")
.field("app_name", &self.app_name)
.field("backend", &self.backend_kind())
.finish()
}
}
#[derive(Debug, Clone)]
pub struct SecurityKeyInfo {
pub label: String,
pub credential_id: Vec<u8>,
pub rp_id: String,
pub public_key: Vec<u8>,
pub comment: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SecurityKeySignature {
pub signature_der: Vec<u8>,
pub flags: u8,
pub counter: u32,
}
#[derive(Debug)]
enum SkBackend {
#[cfg(target_os = "windows")]
Native,
#[cfg(target_os = "linux")]
Bridge {
bridge_path: PathBuf,
},
Unavailable,
}
impl SecurityKeyHandle {
fn new(app_name: String, keys_dir: PathBuf, backend: SkBackend) -> Self {
Self {
app_name,
keys_dir,
backend,
}
}
#[allow(clippy::needless_return, unreachable_code)]
pub fn is_available(&self) -> bool {
match &self.backend {
#[cfg(target_os = "windows")]
SkBackend::Native => {
crate::internal::windows_webauthn::is_platform_authenticator_available()
}
#[cfg(target_os = "linux")]
SkBackend::Bridge { bridge_path } => {
crate::internal::bridge::bridge_webauthn_is_available(bridge_path).unwrap_or(false)
}
SkBackend::Unavailable => false,
}
}
pub fn generate(&self, label: &str, comment: Option<&str>) -> Result<SecurityKeyInfo> {
validate_label(label).map_err(Error::from)?;
let rp_id = rp_id_for(&self.app_name, label);
let user_id = user_id_for(&self.app_name, label);
let (credential_id, pk_x, pk_y) = self.do_make_credential(&rp_id, label, &user_id)?;
let mut public_key = Vec::with_capacity(65);
public_key.push(0x04);
public_key.extend_from_slice(&pk_x);
public_key.extend_from_slice(&pk_y);
metadata::ensure_dir(&self.keys_dir)?;
#[allow(let_underscore_drop)]
let _lock = metadata::DirLock::acquire(&self.keys_dir)?;
let mut meta = KeyMeta::new(label, KeyType::Signing, AccessPolicy::Any);
let cred_b64 = BASE64_STANDARD.encode(&credential_id);
meta.set_app_field("algorithm", "sk-ecdsa-sha2-nistp256");
meta.set_app_field("credential_id_b64", cred_b64.as_str());
meta.set_app_field("rp_id", rp_id.as_str());
if let Some(c) = comment {
meta.set_app_field("comment", c);
}
metadata::save_meta(&self.keys_dir, label, &meta)?;
let pub_path = self.keys_dir.join(format!("{label}.pub"));
metadata::atomic_write(&pub_path, &public_key)?;
Ok(SecurityKeyInfo {
label: label.to_string(),
credential_id,
rp_id,
public_key,
comment: comment.map(str::to_string),
})
}
pub fn sign(&self, label: &str, data: &[u8]) -> Result<SecurityKeySignature> {
let info = self.get_credential(label)?;
let (signature_der, flags, counter) =
self.do_get_assertion(&info.rp_id, &info.credential_id, data)?;
Ok(SecurityKeySignature {
signature_der,
flags,
counter,
})
}
pub fn list_credentials(&self) -> Result<Vec<SecurityKeyInfo>> {
let labels = metadata::list_labels(&self.keys_dir)?;
let mut out = Vec::new();
for label in labels {
if let Ok(meta) = metadata::load_meta(&self.keys_dir, &label) {
if meta.get_app_field("algorithm") == Some("sk-ecdsa-sha2-nistp256") {
if let Ok(info) = self.info_from_meta(&label, &meta) {
out.push(info);
}
}
}
}
Ok(out)
}
pub fn get_credential(&self, label: &str) -> Result<SecurityKeyInfo> {
let meta = metadata::load_meta(&self.keys_dir, label).map_err(|_| Error::KeyNotFound {
label: label.to_string(),
})?;
if meta.get_app_field("algorithm") != Some("sk-ecdsa-sha2-nistp256") {
return Err(Error::KeyNotFound {
label: label.to_string(),
});
}
self.info_from_meta(label, &meta)
}
pub fn credential_exists(&self, label: &str) -> Result<bool> {
match self.get_credential(label) {
Ok(_) => Ok(true),
Err(Error::KeyNotFound { .. }) => Ok(false),
Err(e) => Err(e),
}
}
pub fn delete_credential(&self, label: &str) -> Result<()> {
let info = self.get_credential(label)?;
drop(self.do_delete_credential(&info.credential_id));
metadata::delete_key_files(&self.keys_dir, label)?;
Ok(())
}
pub fn backend_kind(&self) -> Option<BackendKind> {
match &self.backend {
#[cfg(target_os = "windows")]
SkBackend::Native => Some(BackendKind::Tpm),
#[cfg(target_os = "linux")]
SkBackend::Bridge { .. } => Some(BackendKind::TpmBridge),
SkBackend::Unavailable => None,
}
}
#[allow(clippy::needless_return, unreachable_code)]
#[cfg_attr(
not(any(target_os = "windows", target_os = "linux")),
allow(unused_variables)
)]
fn do_make_credential(
&self,
rp_id: &str,
label: &str,
user_id: &[u8],
) -> Result<(Vec<u8>, [u8; 32], [u8; 32])> {
match &self.backend {
#[cfg(target_os = "windows")]
SkBackend::Native => {
let params = crate::internal::windows_webauthn::MakeCredentialParams {
rp_id,
rp_name: &self.app_name,
user_id,
user_name: label,
user_display_name: label,
timeout_ms: 60_000,
hwnd: None,
};
let cred =
crate::internal::windows_webauthn::make_credential(params).map_err(|e| {
Error::KeyOperation {
operation: "sk_make_credential".into(),
detail: e.to_string(),
}
})?;
return Ok((cred.credential_id, cred.public_key_x, cred.public_key_y));
}
#[cfg(target_os = "linux")]
SkBackend::Bridge { bridge_path } => {
let result = crate::internal::bridge::bridge_webauthn_make_credential(
bridge_path,
rp_id,
&self.app_name,
user_id,
label,
label,
60_000,
)
.map_err(|e| Error::KeyOperation {
operation: "sk_make_credential_bridge".into(),
detail: e.to_string(),
})?;
let credential_id =
BASE64_STANDARD
.decode(&result.credential_id_b64)
.map_err(|e| Error::KeyOperation {
operation: "sk_decode_credential_id".into(),
detail: e.to_string(),
})?;
let pk_x =
hex_to_32(&result.public_key_x_hex).map_err(|e| Error::KeyOperation {
operation: "sk_decode_pubkey_x".into(),
detail: e,
})?;
let pk_y =
hex_to_32(&result.public_key_y_hex).map_err(|e| Error::KeyOperation {
operation: "sk_decode_pubkey_y".into(),
detail: e,
})?;
return Ok((credential_id, pk_x, pk_y));
}
SkBackend::Unavailable => {
return Err(Error::NotAvailable);
}
}
}
#[allow(clippy::needless_return, unreachable_code)]
#[cfg_attr(
not(any(target_os = "windows", target_os = "linux")),
allow(unused_variables)
)]
fn do_get_assertion(
&self,
rp_id: &str,
credential_id: &[u8],
client_data: &[u8],
) -> Result<(Vec<u8>, u8, u32)> {
match &self.backend {
#[cfg(target_os = "windows")]
SkBackend::Native => {
let params = crate::internal::windows_webauthn::GetAssertionParams {
rp_id,
credential_id,
client_data,
timeout_ms: 60_000,
hwnd: None,
};
let assertion =
crate::internal::windows_webauthn::get_assertion(params).map_err(|e| {
Error::SignFailed {
detail: e.to_string(),
}
})?;
return Ok((assertion.signature_der, assertion.flags, assertion.counter));
}
#[cfg(target_os = "linux")]
SkBackend::Bridge { bridge_path } => {
let result = crate::internal::bridge::bridge_webauthn_get_assertion(
bridge_path,
rp_id,
credential_id,
client_data,
60_000,
)
.map_err(|e| Error::SignFailed {
detail: e.to_string(),
})?;
let signature_der =
BASE64_STANDARD
.decode(&result.signature_der_b64)
.map_err(|e| Error::SignFailed {
detail: format!("decode signature: {e}"),
})?;
return Ok((signature_der, result.flags, result.counter));
}
SkBackend::Unavailable => {
return Err(Error::NotAvailable);
}
}
}
#[allow(clippy::needless_return, unreachable_code)]
#[cfg_attr(
not(any(target_os = "windows", target_os = "linux")),
allow(unused_variables)
)]
fn do_delete_credential(&self, credential_id: &[u8]) -> Result<()> {
match &self.backend {
#[cfg(target_os = "windows")]
SkBackend::Native => {
return crate::internal::windows_webauthn::delete_platform_credential(
credential_id,
)
.map_err(|e| Error::KeyOperation {
operation: "sk_delete".into(),
detail: e.to_string(),
});
}
#[cfg(target_os = "linux")]
SkBackend::Bridge { bridge_path } => {
return crate::internal::bridge::bridge_webauthn_delete_platform_credential(
bridge_path,
credential_id,
)
.map_err(|e| Error::KeyOperation {
operation: "sk_delete_bridge".into(),
detail: e.to_string(),
});
}
SkBackend::Unavailable => {
return Ok(());
}
}
}
fn info_from_meta(&self, label: &str, meta: &KeyMeta) -> Result<SecurityKeyInfo> {
let credential_id_b64 =
meta.get_app_field("credential_id_b64")
.ok_or_else(|| Error::KeyOperation {
operation: "sk_load".into(),
detail: format!("key '{label}' missing credential_id_b64 in metadata"),
})?;
let credential_id =
BASE64_STANDARD
.decode(credential_id_b64)
.map_err(|e| Error::KeyOperation {
operation: "sk_load".into(),
detail: format!("invalid credential_id_b64: {e}"),
})?;
let rp_id = match meta.get_app_field("rp_id") {
Some(r) => r.to_string(),
None => rp_id_for(&self.app_name, label),
};
let comment = meta.get_app_field("comment").map(str::to_string);
let pub_path = self.keys_dir.join(format!("{label}.pub"));
let public_key = if pub_path.exists() {
metadata::read_no_follow(&pub_path).map_err(Error::from)?
} else {
Vec::new()
};
Ok(SecurityKeyInfo {
label: label.to_string(),
credential_id,
rp_id,
public_key,
comment,
})
}
}
pub(crate) fn rp_id_for(app_name: &str, label: &str) -> String {
let mut h = Sha256::new();
h.update(app_name.as_bytes());
h.update(b"-rp-id-v1\x00");
h.update(label.as_bytes());
let digest = h.finalize();
format!(
"{app_name}-{:08x}.local",
u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
)
}
pub(crate) fn user_id_for(app_name: &str, label: &str) -> Vec<u8> {
let mut h = Sha256::new();
h.update(app_name.as_bytes());
h.update(b"-user-id-v1\x00");
h.update(label.as_bytes());
h.finalize().to_vec()
}
#[cfg(target_os = "linux")]
fn hex_to_32(hex: &str) -> std::result::Result<[u8; 32], String> {
if hex.len() != 64 {
return Err(format!("expected 64 hex chars, got {}", hex.len()));
}
let mut out = [0_u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let s = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
out[i] = u8::from_str_radix(s, 16).map_err(|e| e.to_string())?;
}
Ok(out)
}
#[allow(clippy::needless_return, unreachable_code)]
pub(crate) fn make_security_key_handle(config: &EnclaveConfig) -> SecurityKeyHandle {
let app_name = config.effective_app_name();
let keys_dir = config
.keys_dir
.clone()
.unwrap_or_else(|| metadata::keys_dir(&app_name));
#[cfg(target_os = "windows")]
return SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Native);
#[cfg(target_os = "linux")]
{
let extra_paths: Vec<String> = match &config.platform {
crate::config::PlatformConfig::Linux(l) => l.extra_bridge_paths.clone(),
_ => Vec::new(),
};
if let Some(bridge_path) =
crate::internal::app_storage::platform::find_bridge_executable(&app_name, &extra_paths)
{
return SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Bridge { bridge_path });
}
}
SecurityKeyHandle::new(app_name, keys_dir, SkBackend::Unavailable)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rp_id_is_stable_and_unique() {
let a = rp_id_for("sshenc", "github");
let b = rp_id_for("sshenc", "github");
assert_eq!(a, b, "rp_id must be deterministic");
assert!(a.starts_with("sshenc-"));
assert!(a.ends_with(".local"));
let other = rp_id_for("sshenc", "gitlab");
assert_ne!(a, other, "different labels must produce different rp_ids");
}
#[test]
fn rp_id_matches_sshenc_formula() {
let rp_id = rp_id_for("sshenc", "test-key");
assert!(rp_id.starts_with("sshenc-"));
assert!(rp_id.ends_with(".local"));
let hex_part = &rp_id[7..rp_id.len() - 6]; assert_eq!(hex_part.len(), 8, "must be 8 hex chars (4 bytes)");
assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn user_id_is_32_bytes() {
let uid = user_id_for("sshenc", "test-key");
assert_eq!(uid.len(), 32);
}
#[test]
fn user_id_is_stable() {
let a = user_id_for("myapp", "key1");
let b = user_id_for("myapp", "key1");
assert_eq!(a, b);
let other = user_id_for("myapp", "key2");
assert_ne!(a, other);
}
#[test]
fn is_available_does_not_panic() {
let config = EnclaveConfig::new("testapp", "default");
let handle = make_security_key_handle(&config);
let _ = handle.is_available();
}
}