cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/release-manifest-language
goq / src-tauri / src / commands / auth.rs
auth.rs 12.4K
1use anyhow::Context as _;2use ctap_hid_fido2::fidokey::{3 GetAssertionArgsBuilder, MakeCredentialArgsBuilder, get_assertion::Extension as Gext,4 get_assertion::get_assertion_params::Assertion, make_credential::Extension as Mext,5};6use ctap_hid_fido2::public_key_credential_user_entity::PublicKeyCredentialUserEntity;7use ctap_hid_fido2::{Cfg, FidoKeyHidFactory, verifier};8use iroh::SecretKey;9use serde::Serialize;10use sha2::{Digest, Sha256};11use std::time::Duration;1213// These values are enrollment compatibility identifiers, not display14// branding. Changing any of them in place would derive a different Portal15// identity or create a second resident credential for existing users. See16// docs/compatibility-identifiers.md before introducing a versioned successor.17const FIDO_RP_ID: &str = "sigil";18const FIDO_HMAC_SALT_MESSAGE: &str = "sigil-iroh-identity-v1";19const FIDO_RESIDENT_USER_ID: &[u8] = b"sigil-user";20const FIDO_RESIDENT_USER_NAME: &str = "sigil";21const FIDO_RESIDENT_USER_DISPLAY_NAME: &str = "Sigil";22const PORTAL_CLIENT_IDENTITY_DOMAIN: &[u8] = b"goq/portal-client-identity/v1\0";2324// ─── FIDO2 HMAC-Secret Derivation ────────────────────────────────────────────2526fn fido_hmac_salt() -> [u8; 32] {27 Sha256::digest(FIDO_HMAC_SALT_MESSAGE.as_bytes()).into()28}2930pub fn derive_secret_from_key(pin: &str) -> anyhow::Result<[u8; 32]> {31 let cfg = Cfg::init();32 let device = FidoKeyHidFactory::create(&cfg)33 .context("Security key not found. Make sure it is plugged in.")?;3435 let salt = fido_hmac_salt();3637 // Try resident key first38 let challenge = verifier::create_challenge();39 let get_args = GetAssertionArgsBuilder::new(FIDO_RP_ID, &challenge)40 .pin(pin)41 .extensions(&[Gext::HmacSecret(Some(salt))])42 .build();4344 if let Ok(assertions) = device.get_assertion_with_args(&get_args) {45 return extract_hmac_secret(&assertions);46 }4748 // No resident key — create one49 let user_entity = PublicKeyCredentialUserEntity::new(50 Some(FIDO_RESIDENT_USER_ID),51 Some(FIDO_RESIDENT_USER_NAME),52 Some(FIDO_RESIDENT_USER_DISPLAY_NAME),53 );5455 let challenge = verifier::create_challenge();56 let make_args = MakeCredentialArgsBuilder::new(FIDO_RP_ID, &challenge)57 .pin(pin)58 .user_entity(&user_entity)59 .resident_key()60 .extensions(&[Mext::HmacSecret(Some(true))])61 .build();6263 let attestation = device64 .make_credential_with_args(&make_args)65 .context("make_credential failed")?;6667 let verify_result = verifier::verify_attestation(FIDO_RP_ID, &challenge, &attestation);68 if !verify_result.is_success {69 anyhow::bail!("Attestation verification failed");70 }71 let credential_id = verify_result.credential_id;7273 let challenge2 = verifier::create_challenge();74 let get_args = GetAssertionArgsBuilder::new(FIDO_RP_ID, &challenge2)75 .pin(pin)76 .credential_id(&credential_id)77 .extensions(&[Gext::HmacSecret(Some(salt))])78 .build();7980 let assertions = device81 .get_assertion_with_args(&get_args)82 .context("get_assertion failed")?;8384 extract_hmac_secret(&assertions)85}8687pub fn extract_hmac_secret(assertions: &[Assertion]) -> anyhow::Result<[u8; 32]> {88 let assertion = assertions.first().context(89 "Security key returned no assertions. Reconnect the key and try the PIN and tap again.",90 )?;9192 for ext in &assertion.extensions {93 match ext {94 Gext::HmacSecret(Some(output)) => return Ok(*output),95 Gext::HmacSecret2(Some(_)) => {96 anyhow::bail!(97 "Security key returned a two-output hmac-secret response (64 bytes); Portal requires exactly one 32-byte output. Reconnect the key or use a compatible FIDO2 key."98 )99 }100 _ => {}101 }102 }103 anyhow::bail!(104 "Security key assertion did not contain hmac-secret output. Use a compatible FIDO2 key and try again."105 )106}107108pub fn derive_iroh_secret_from_key(pin: &str) -> anyhow::Result<SecretKey> {109 let hmac_secret = derive_secret_from_key(pin)?;110 Ok(portal_client_secret_from_hmac(hmac_secret))111}112113/// Derive Portal's stable Iroh peer identity from the FIDO hmac-secret without114/// reusing the passkey output as another product identity directly. The same115/// tap still supplies both onboarding and ordinary connection attempts.116fn portal_client_secret_from_hmac(hmac_secret: [u8; 32]) -> SecretKey {117 let mut hasher = Sha256::new();118 hasher.update(PORTAL_CLIENT_IDENTITY_DOMAIN);119 hasher.update(hmac_secret);120 let digest: [u8; 32] = hasher.finalize().into();121 SecretKey::from_bytes(&digest)122}123124// ─── FIDO2 Tauri Commands ─────────────────────────────────────────────────────125126#[derive(Default, Serialize)]127pub struct FidoDeviceInfo {128 pub found: bool,129 pub vid: u16,130 pub pid: u16,131 pub product: String,132 pub versions: Vec<String>,133 pub extensions: Vec<String>,134 pub options: Vec<(String, bool)>,135 pub max_msg_size: u32,136 pub pin_retries: u32,137 pub error: Option<String>,138}139140#[tauri::command]141pub fn fido_device_info() -> FidoDeviceInfo {142 let devices = ctap_hid_fido2::get_fidokey_devices();143 if devices.is_empty() {144 return FidoDeviceInfo {145 found: false,146 ..Default::default()147 };148 }149150 let dev = &devices[0];151 let vid = dev.vid;152 let pid = dev.pid;153 // product_string is the human-readable name from the HID descriptor154 let product = if dev.product_string.is_empty() {155 dev.info.clone()156 } else {157 dev.product_string.clone()158 };159160 // Try to open the device and query CTAP info; degrade gracefully if it fails161 // (device may be busy or need user presence for some operations)162 let cfg = Cfg::init();163 match FidoKeyHidFactory::create(&cfg) {164 Ok(device) => {165 let (versions, extensions, options, max_msg_size) = match device.get_info() {166 Ok(i) => (167 i.versions.clone(),168 i.extensions.clone(),169 i.options.clone(),170 i.max_msg_size as u32,171 ),172 Err(_) => (vec![], vec![], vec![], 0),173 };174 let pin_retries = device.get_pin_retries().unwrap_or(0);175 FidoDeviceInfo {176 found: true,177 vid,178 pid,179 product,180 versions,181 extensions,182 options,183 max_msg_size,184 pin_retries: pin_retries as u32,185 error: None,186 }187 }188 Err(e) => {189 // Device was enumerated but couldn't be opened — still report it as found190 FidoDeviceInfo {191 found: true,192 vid,193 pid,194 product,195 error: Some(format!("{:?}", e)),196 ..Default::default()197 }198 }199 }200}201202#[derive(Serialize)]203pub struct PinRetries {204 pub retries: u32,205 pub error: Option<String>,206}207208#[tauri::command]209pub fn fido_pin_retries() -> PinRetries {210 let cfg = Cfg::init();211 match FidoKeyHidFactory::create(&cfg) {212 Ok(device) => match device.get_pin_retries() {213 Ok(n) => PinRetries {214 retries: n as u32,215 error: None,216 },217 Err(e) => PinRetries {218 retries: 0,219 error: Some(format!("{:?}", e)),220 },221 },222 Err(e) => PinRetries {223 retries: 0,224 error: Some(format!("Device not found: {:?}", e)),225 },226 }227}228229#[derive(Serialize)]230pub struct KeyIdentity {231 pub node_id: String,232 pub error: Option<String>,233}234235#[tauri::command]236pub async fn key_derive_identity(pin: String) -> KeyIdentity {237 let result = tokio::time::timeout(238 Duration::from_secs(30),239 tokio::task::spawn_blocking(move || derive_iroh_secret_from_key(&pin)),240 )241 .await;242 match result {243 Err(_) => KeyIdentity {244 node_id: String::new(),245 error: Some("Security key timed out (30s). Check that your key is connected.".into()),246 },247 Ok(Err(e)) => KeyIdentity {248 node_id: String::new(),249 error: Some(format!("Task error: {}", e)),250 },251 Ok(Ok(Err(e))) => KeyIdentity {252 node_id: String::new(),253 error: Some(format!("{:?}", e)),254 },255 Ok(Ok(Ok(secret))) => KeyIdentity {256 node_id: secret.public().to_string(),257 error: None,258 },259 }260}261262#[cfg(test)]263mod tests {264 use super::*;265266 #[test]267 fn extract_hmac_secret_rejects_empty_assertion_list() {268 let error = extract_hmac_secret(&[]).expect_err("empty assertions must fail closed");269 assert_eq!(270 error.to_string(),271 "Security key returned no assertions. Reconnect the key and try the PIN and tap again."272 );273 }274275 #[test]276 fn extract_hmac_secret_rejects_two_output_response() {277 let assertion = Assertion {278 extensions: vec![Gext::HmacSecret2(Some(([0x2a; 32], [0x2b; 32])))],279 ..Default::default()280 };281282 let error =283 extract_hmac_secret(&[assertion]).expect_err("two-output response must fail closed");284 assert_eq!(285 error.to_string(),286 "Security key returned a two-output hmac-secret response (64 bytes); Portal requires exactly one 32-byte output. Reconnect the key or use a compatible FIDO2 key."287 );288 }289290 #[test]291 fn extract_hmac_secret_rejects_extension_without_output() {292 let assertion = Assertion {293 extensions: vec![Gext::HmacSecret(None)],294 ..Default::default()295 };296297 let error = extract_hmac_secret(&[assertion]).expect_err("absent output must fail closed");298 assert_eq!(299 error.to_string(),300 "Security key assertion did not contain hmac-secret output. Use a compatible FIDO2 key and try again."301 );302 }303304 #[test]305 fn extract_hmac_secret_rejects_assertion_without_extension() {306 let error = extract_hmac_secret(&[Assertion::default()])307 .expect_err("missing extension must fail closed");308 assert_eq!(309 error.to_string(),310 "Security key assertion did not contain hmac-secret output. Use a compatible FIDO2 key and try again."311 );312 }313314 #[test]315 fn extract_hmac_secret_returns_exact_output() {316 let expected = [0x2a; 32];317 let assertion = Assertion {318 extensions: vec![Gext::HmacSecret(Some(expected))],319 ..Default::default()320 };321322 assert_eq!(extract_hmac_secret(&[assertion]).unwrap(), expected);323 }324325 #[test]326 fn portal_identity_compatibility_identifiers_are_golden() {327 assert_eq!(FIDO_RP_ID, "sigil");328 assert_eq!(FIDO_HMAC_SALT_MESSAGE, "sigil-iroh-identity-v1");329 assert_eq!(FIDO_RESIDENT_USER_ID, b"sigil-user");330 assert_eq!(FIDO_RESIDENT_USER_NAME, "sigil");331 assert_eq!(FIDO_RESIDENT_USER_DISPLAY_NAME, "Sigil");332 assert_eq!(333 fido_hmac_salt(),334 [335 0x14, 0x7e, 0xc5, 0x1f, 0xc9, 0x5a, 0x4a, 0xdd, 0x3b, 0x73, 0xf9, 0xf3, 0x66, 0xb1,336 0x57, 0xfb, 0x5d, 0xec, 0x70, 0x6a, 0x12, 0x17, 0x31, 0x51, 0x06, 0x27, 0x28, 0xdb,337 0xad, 0x15, 0x84, 0xaa,338 ]339 );340 assert_eq!(341 PORTAL_CLIENT_IDENTITY_DOMAIN,342 b"goq/portal-client-identity/v1\0"343 );344345 let root = [0x2a; 32];346 let first = portal_client_secret_from_hmac(root);347 let second = portal_client_secret_from_hmac(root);348 assert_eq!(first.to_bytes(), second.to_bytes());349 assert_eq!(350 first.to_bytes(),351 [352 0x78, 0xcc, 0xce, 0x6e, 0x04, 0x07, 0x0f, 0xff, 0x0a, 0xc5, 0xf7, 0x4c, 0xde, 0x6d,353 0x94, 0x8e, 0xa4, 0x80, 0x42, 0xa6, 0x6f, 0x45, 0xa8, 0xc5, 0x8a, 0x96, 0xeb, 0x45,354 0x9c, 0xa1, 0x0d, 0xd9,355 ]356 );357 assert_eq!(358 first.public().to_string(),359 "0383aa3774fe624d7a3bc9189c64770e077c43db7315dde1df19085538adc136"360 );361 assert_ne!(first.to_bytes(), root);362 assert_ne!(363 first.to_bytes(),364 portal_client_secret_from_hmac([0x2b; 32]).to_bytes()365 );366 }367}