From 8ba164782189685f5103b7408d4e50188380aee9 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Wed, 4 Mar 2026 23:52:41 +0000 Subject: [PATCH 1/5] fix: allow discoverable credentials without UV requirement - Set uv: None to not claim built-in UV support (desktop notifications are used instead) - Set client_pin: Some(false) to indicate PIN is supported but not set - Set always_uv: None to allow operations without mandatory UV This fixes passkey login without username by allowing the authentication flow to proceed via user presence callback instead of requiring biometric UV which passless doesn't have. Resolves: #157 --- cmd/passless/src/authenticator.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/passless/src/authenticator.rs b/cmd/passless/src/authenticator.rs index 43b25c2..adea30b 100644 --- a/cmd/passless/src/authenticator.rs +++ b/cmd/passless/src/authenticator.rs @@ -301,17 +301,17 @@ impl AuthenticatorService { /// Create a new authenticator service pub fn new(storage: S, security_config: SecurityConfig) -> Result { let options = AuthenticatorOptions { - rk: true, // Resident keys (passkeys) - up: true, // User presence - uv: Some(true), // User verification - plat: true, // Platform authenticator - client_pin: None, // Client PIN support - pin_uv_auth_token: Some(true), // PIN UV auth token - cred_mgmt: Some(true), // Credential management enabled + rk: true, + up: true, + uv: None, + plat: true, + client_pin: Some(false), + pin_uv_auth_token: Some(true), + cred_mgmt: Some(true), bio_enroll: None, large_blobs: None, ep: None, - always_uv: Some(true), + always_uv: None, make_cred_uv_not_required: Some(true), }; From 23f8166fe1aa27f325b531d5d572b56f175800ac Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 5 Mar 2026 00:09:25 +0000 Subject: [PATCH 2/5] fix: rename list_credentials to read_credentials to match soft-fido2 API --- cmd/passless/src/authenticator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/passless/src/authenticator.rs b/cmd/passless/src/authenticator.rs index adea30b..ccc227e 100644 --- a/cmd/passless/src/authenticator.rs +++ b/cmd/passless/src/authenticator.rs @@ -173,7 +173,7 @@ impl AuthenticatorCallbacks for PasslessCallbacks { Ok(()) } - fn list_credentials(&self, rp_id: &str, _user_id: Option<&[u8]>) -> Result> { + fn read_credentials(&self, rp_id: &str, _user_id: Option<&[u8]>) -> Result> { info!("Listing credentials for RP: {}", rp_id); let mut storage = match self.storage.lock() { From 6e3b6659bb405e3d00adf5159c6c6816f84d4513 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 5 Mar 2026 00:12:08 +0000 Subject: [PATCH 3/5] fix: align with soft-fido2 API --- cmd/passless/src/commands/custom.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/passless/src/commands/custom.rs b/cmd/passless/src/commands/custom.rs index 941acc1..a08de7c 100644 --- a/cmd/passless/src/commands/custom.rs +++ b/cmd/passless/src/commands/custom.rs @@ -2,7 +2,7 @@ /// /// This module provides compatibility with the Yubikey credential management variant (0x41). /// The standard credential management (0x0a) is handled by soft-fido2's built-in implementation, -/// which properly calls the AuthenticatorCallbacks methods (enumerate_rps, list_credentials, etc.) +/// which properly calls the AuthenticatorCallbacks methods (enumerate_rps, read_credentials, etc.) /// that read from the actual storage backend. use crate::authenticator::AuthenticatorService; use crate::storage::CredentialStorage; From aff28f4284b6658ec8a91272c17d1598c2d7a7ce Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Thu, 5 Mar 2026 00:43:35 +0000 Subject: [PATCH 4/5] fix: rename read_credentials back to list_credentials The AuthenticatorCallbacks trait from soft-fido2 expects list_credentials, not read_credentials. This fixes the compilation error. --- cmd/passless/src/authenticator.rs | 2 +- cmd/passless/src/commands/custom.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/passless/src/authenticator.rs b/cmd/passless/src/authenticator.rs index ccc227e..adea30b 100644 --- a/cmd/passless/src/authenticator.rs +++ b/cmd/passless/src/authenticator.rs @@ -173,7 +173,7 @@ impl AuthenticatorCallbacks for PasslessCallbacks { Ok(()) } - fn read_credentials(&self, rp_id: &str, _user_id: Option<&[u8]>) -> Result> { + fn list_credentials(&self, rp_id: &str, _user_id: Option<&[u8]>) -> Result> { info!("Listing credentials for RP: {}", rp_id); let mut storage = match self.storage.lock() { diff --git a/cmd/passless/src/commands/custom.rs b/cmd/passless/src/commands/custom.rs index a08de7c..941acc1 100644 --- a/cmd/passless/src/commands/custom.rs +++ b/cmd/passless/src/commands/custom.rs @@ -2,7 +2,7 @@ /// /// This module provides compatibility with the Yubikey credential management variant (0x41). /// The standard credential management (0x0a) is handled by soft-fido2's built-in implementation, -/// which properly calls the AuthenticatorCallbacks methods (enumerate_rps, read_credentials, etc.) +/// which properly calls the AuthenticatorCallbacks methods (enumerate_rps, list_credentials, etc.) /// that read from the actual storage backend. use crate::authenticator::AuthenticatorService; use crate::storage::CredentialStorage; From 77c878519ddd7c764a3ca6f04f9755e1933975f0 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Thu, 5 Mar 2026 18:54:52 +0100 Subject: [PATCH 5/5] feat: add passwordless login e2e tests and fix uv support - Add e2e tests for passwordless login with single and multiple users - Fix authenticator config to advertise uv support (uv: Some(true)) - Auto-create storage directories in e2e test mode - Increase test harness wait time for reliability - Fix user info cbor parsing to use string keys --- cmd/passless/src/authenticator.rs | 4 +- cmd/passless/src/storage/local/init.rs | 15 +- .../src/storage/pass/init/uninitialized.rs | 18 + cmd/passless/src/storage/tpm/init.rs | 13 + cmd/passless/tests/e2e_webauthn.rs | 330 ++++++++++++++++++ cmd/passless/tests/harness.rs | 6 +- 6 files changed, 382 insertions(+), 4 deletions(-) diff --git a/cmd/passless/src/authenticator.rs b/cmd/passless/src/authenticator.rs index adea30b..75fc5bf 100644 --- a/cmd/passless/src/authenticator.rs +++ b/cmd/passless/src/authenticator.rs @@ -303,7 +303,7 @@ impl AuthenticatorService { let options = AuthenticatorOptions { rk: true, up: true, - uv: None, + uv: Some(true), // Support user verification via request_up callback plat: true, client_pin: Some(false), pin_uv_auth_token: Some(true), @@ -311,7 +311,7 @@ impl AuthenticatorService { bio_enroll: None, large_blobs: None, ep: None, - always_uv: None, + always_uv: Some(false), // Don't require UV for all operations make_cred_uv_not_required: Some(true), }; diff --git a/cmd/passless/src/storage/local/init.rs b/cmd/passless/src/storage/local/init.rs index 371b6ba..08af533 100644 --- a/cmd/passless/src/storage/local/init.rs +++ b/cmd/passless/src/storage/local/init.rs @@ -14,7 +14,7 @@ use log::{info, warn}; /// Ensure local storage directory is initialized /// -/// If the directory doesn't exist, prompts the user via desktop notification +/// If the directory doesn't exist, prompts user via desktop notification /// to confirm creation. pub fn ensure_initialized(storage_path: &Path) -> Result<()> { if storage_path.exists() { @@ -26,6 +26,19 @@ pub fn ensure_initialized(storage_path: &Path) -> Result<()> { storage_path ); + // Check for E2E test mode (only available in debug builds) + #[cfg(debug_assertions)] + { + if std::env::var("PASSLESS_E2E_AUTO_ACCEPT_UV").is_ok() { + info!("E2E test mode: Auto-creating local storage directory"); + fs::create_dir_all(storage_path).map_err(|e| { + Error::Storage(format!("Failed to create storage directory: {}", e)) + })?; + info!("Created local storage directory at {:?}", storage_path); + return Ok(()); + } + } + match show_yes_no_notification( "Local Storage Not Initialized", &format!( diff --git a/cmd/passless/src/storage/pass/init/uninitialized.rs b/cmd/passless/src/storage/pass/init/uninitialized.rs index 2db40cb..3a3e1a6 100644 --- a/cmd/passless/src/storage/pass/init/uninitialized.rs +++ b/cmd/passless/src/storage/pass/init/uninitialized.rs @@ -42,6 +42,24 @@ impl Uninitialized { } pub fn prompt_user(self) -> Result { + // Check for E2E test mode (only available in debug builds) + #[cfg(debug_assertions)] + { + if std::env::var("PASSLESS_E2E_AUTO_ACCEPT_UV").is_ok() { + info!("E2E test mode: Auto-creating password store directory"); + if !self.store_path.exists() { + fs::create_dir_all(&self.store_path).map_err(|e| { + Error::Storage(format!("Failed to create store directory: {}", e)) + })?; + info!("Created store directory at {:?}", self.store_path); + } + return Ok(DirectoryCreated { + store_path: self.store_path, + gpg_backend: self.gpg_backend, + }); + } + } + match show_yes_no_notification( "Password Store Not Initialized", &format!( diff --git a/cmd/passless/src/storage/tpm/init.rs b/cmd/passless/src/storage/tpm/init.rs index 098be2e..932a265 100644 --- a/cmd/passless/src/storage/tpm/init.rs +++ b/cmd/passless/src/storage/tpm/init.rs @@ -23,6 +23,19 @@ pub fn ensure_initialized(storage_path: &Path) -> Result<()> { info!("TPM storage directory does not exist at {:?}", storage_path); + // Check for E2E test mode (only available in debug builds) + #[cfg(debug_assertions)] + { + if std::env::var("PASSLESS_E2E_AUTO_ACCEPT_UV").is_ok() { + info!("E2E test mode: Auto-creating TPM storage directory"); + fs::create_dir_all(storage_path).map_err(|e| { + Error::Storage(format!("Failed to create storage directory: {}", e)) + })?; + info!("Created TPM storage directory at {:?}", storage_path); + return Ok(()); + } + } + match show_yes_no_notification( "TPM Storage Not Initialized", &format!( diff --git a/cmd/passless/tests/e2e_webauthn.rs b/cmd/passless/tests/e2e_webauthn.rs index 9ed9ebb..67ede4b 100644 --- a/cmd/passless/tests/e2e_webauthn.rs +++ b/cmd/passless/tests/e2e_webauthn.rs @@ -825,3 +825,333 @@ fn test_tpm_registration_with_different_rps() -> Result<()> { run_registration_with_different_rps_test() }) } + +/// Core test logic for passwordless login (single credential) +/// This tests GetAssertion WITHOUT allowList, using resident key discovery +fn run_passwordless_login_test() -> Result<()> { + println!("\n╔════════════════════════════════════════════════╗"); + println!("║ E2E Test: Passwordless Login (Single User) ║"); + println!("╚════════════════════════════════════════════════╝\n"); + + let mut transport = connect_to_authenticator()?; + + println!("📝 [1/2] Registering passkey (resident key)..."); + let challenge = generate_challenge(); + let client_data_hash = generate_client_data_hash_for_registration(&challenge); + + let rp = RelyingParty { + id: RP_ID.to_string(), + name: Some("Example Corp".to_string()), + }; + + let user = User { + id: vec![1, 2, 3, 4], + name: Some("alice@example.com".to_string()), + display_name: Some("Alice".to_string()), + }; + + println!(" RP: {}", rp.id); + println!(" User: {}", user.name.as_ref().unwrap()); + + let request = MakeCredentialRequest::new(client_data_hash, rp, user.clone()) + .with_resident_key(true) + .with_user_verification(true) + .with_timeout(30000); + + print_operation("Register passkey for passwordless login"); + let attestation = Client::make_credential(&mut transport, request)?; + println!(" ✓ Passkey registered ({} bytes)\n", attestation.len()); + + println!("🔐 [2/2] Passwordless authentication (no username/allowList)...\n"); + println!(" Note: Authenticator discovers credential via resident key lookup"); + + let challenge = generate_challenge(); + let client_data_hash = generate_client_data_hash_for_authentication(&challenge); + + let request = GetAssertionRequest::new(client_data_hash, RP_ID) + .with_user_verification(true) + .with_timeout(30000); + + print_operation("Passwordless login - just tap to authenticate"); + + let assertion = Client::get_assertion(&mut transport, request)?; + println!( + " ✓ Passwordless authentication succeeded ({} bytes)\n", + assertion.len() + ); + + println!("📋 Validating assertion response..."); + match ciborium::from_reader::(&assertion[..]) { + Ok(ciborium::value::Value::Map(map)) => { + println!(" ✓ Valid CBOR response with {} fields", map.len()); + + let mut has_credential = false; + let mut has_auth_data = false; + let mut has_signature = false; + let mut has_user = false; + let mut user_info: Option<(Vec, Option, Option)> = None; + + for (k, v) in &map { + if let ciborium::value::Value::Integer(i) = k { + let key: i128 = (*i).into(); + match key { + 1 => { + has_credential = true; + println!(" ✓ Response contains credential (key 0x01)"); + } + 2 => { + has_auth_data = true; + println!(" ✓ Response contains authData (key 0x02)"); + } + 3 => { + has_signature = true; + println!(" ✓ Response contains signature (key 0x03)"); + } + 4 => { + has_user = true; + println!(" ✓ Response contains user info (key 0x04)"); + + if let ciborium::value::Value::Map(user_map) = v { + let mut user_id: Option> = None; + let mut user_name: Option = None; + let mut user_display_name: Option = None; + + for (uk, uv) in user_map { + if let ciborium::value::Value::Text(key_name) = uk { + match key_name.as_str() { + "id" => { + if let ciborium::value::Value::Bytes(id) = uv { + user_id = Some(id.clone()); + } + } + "name" => { + if let ciborium::value::Value::Text(name) = uv { + user_name = Some(name.clone()); + } + } + "displayName" => { + if let ciborium::value::Value::Text(dn) = uv { + user_display_name = Some(dn.clone()); + } + } + _ => {} + } + } + } + + if let Some(id) = user_id { + user_info = Some((id, user_name, user_display_name)); + } + } + } + _ => {} + } + } + } + + assert!(has_credential, "Response should contain credential"); + assert!(has_auth_data, "Response should contain authData"); + assert!(has_signature, "Response should contain signature"); + assert!( + has_user, + "Response should contain user info for passwordless login" + ); + + if let Some((id, name, display_name)) = user_info { + println!("\n ✓ User info returned:"); + println!(" ID: {} bytes", id.len()); + if let Some(n) = name { + println!(" Name: {}", n); + assert_eq!(&n, user.name.as_ref().unwrap(), "User name should match"); + } + if let Some(dn) = display_name { + println!(" Display Name: {}", dn); + assert_eq!( + &dn, + user.display_name.as_ref().unwrap(), + "Display name should match" + ); + } + assert_eq!(id, user.id, "User ID should match"); + } else { + panic!("User info should be present and complete for passwordless login"); + } + } + Ok(_) => panic!("Response should be a CBOR map"), + Err(e) => panic!("Failed to parse CBOR response: {}", e), + } + + println!("\n╔════════════════════════════════════════════════╗"); + println!("║ ✓ Passwordless Login Works! ║"); + println!("╚════════════════════════════════════════════════╝\n"); + + Ok(()) +} + +#[test] +#[ignore] +fn test_local_passwordless_login() -> Result<()> { + with_backend("local", AuthenticatorHarness::with_local, || { + run_passwordless_login_test() + }) +} + +#[test] +#[ignore] +fn test_pass_passwordless_login() -> Result<()> { + with_backend("password-store", AuthenticatorHarness::with_pass, || { + run_passwordless_login_test() + }) +} + +#[test] +#[ignore] +fn test_tpm_passwordless_login() -> Result<()> { + with_backend("TPM (swtpm)", AuthenticatorHarness::with_tpm, || { + run_passwordless_login_test() + }) +} + +/// Core test logic for passwordless login with multiple users +/// Tests that the authenticator can handle multiple passkeys for the same RP +fn run_passwordless_login_multiple_users_test() -> Result<()> { + println!("\n╔════════════════════════════════════════════════╗"); + println!("║ E2E Test: Passwordless Login (Multiple Users) ║"); + println!("╚════════════════════════════════════════════════╝\n"); + + let mut transport = connect_to_authenticator()?; + + let users = [ + ("alice@example.com", "Alice", vec![1, 2, 3, 4]), + ("bob@example.com", "Bob", vec![5, 6, 7, 8]), + ]; + + println!("📝 Registering {} passkeys...\n", users.len()); + + let rp = RelyingParty { + id: RP_ID.to_string(), + name: Some("Example Corp".to_string()), + }; + + for (i, (email, display_name, user_id)) in users.iter().enumerate() { + println!("[{}/{}] Registering {}...", i + 1, users.len(), email); + + let challenge = generate_challenge(); + let client_data_hash = generate_client_data_hash_for_registration(&challenge); + + let user = User { + id: user_id.clone(), + name: Some(email.to_string()), + display_name: Some(display_name.to_string()), + }; + + let request = MakeCredentialRequest::new(client_data_hash, rp.clone(), user) + .with_resident_key(true) + .with_user_verification(true) + .with_timeout(30000); + + print_operation(&format!("Register passkey for {}", email)); + let attestation = Client::make_credential(&mut transport, request)?; + println!(" ✓ Registered {} ({} bytes)\n", email, attestation.len()); + } + + println!("🔐 Passwordless authentication (no username specified)...\n"); + println!( + " Note: Authenticator will select from {} available passkeys", + users.len() + ); + + let challenge = generate_challenge(); + let client_data_hash = generate_client_data_hash_for_authentication(&challenge); + + let request = GetAssertionRequest::new(client_data_hash, RP_ID) + .with_user_verification(true) + .with_timeout(30000); + + print_operation("Passwordless login - select from multiple accounts"); + + let assertion = Client::get_assertion(&mut transport, request)?; + println!( + " ✓ Authentication succeeded ({} bytes)\n", + assertion.len() + ); + + println!("📋 Validating assertion response..."); + match ciborium::from_reader::(&assertion[..]) { + Ok(ciborium::value::Value::Map(map)) => { + println!(" ✓ Valid CBOR response with {} fields", map.len()); + + let mut has_user = false; + let mut has_number_of_credentials = false; + let mut number_of_credentials: Option = None; + + for (k, v) in &map { + if let ciborium::value::Value::Integer(i) = k { + let key: i128 = (*i).into(); + match key { + 4 => { + has_user = true; + println!(" ✓ Response contains user info (key 0x04)"); + } + 5 => { + has_number_of_credentials = true; + if let ciborium::value::Value::Integer(n) = v { + let n_val: i128 = (*n).into(); + number_of_credentials = Some(n_val as u64); + println!( + " ✓ numberOfCredentials = {} (key 0x05)", + number_of_credentials.unwrap() + ); + } + } + _ => {} + } + } + } + + assert!( + has_user, + "Response should contain user info for passwordless login" + ); + + if has_number_of_credentials { + assert!( + number_of_credentials.unwrap_or(0) > 1, + "numberOfCredentials should indicate multiple credentials" + ); + } + } + Ok(_) => panic!("Response should be a CBOR map"), + Err(e) => panic!("Failed to parse CBOR response: {}", e), + } + + println!("\n╔════════════════════════════════════════════════╗"); + println!("║ ✓ Multiple User Passwordless Login Works! ║"); + println!("╚════════════════════════════════════════════════╝\n"); + + Ok(()) +} + +#[test] +#[ignore] +fn test_local_passwordless_login_multiple_users() -> Result<()> { + with_backend("local", AuthenticatorHarness::with_local, || { + run_passwordless_login_multiple_users_test() + }) +} + +#[test] +#[ignore] +fn test_pass_passwordless_login_multiple_users() -> Result<()> { + with_backend("password-store", AuthenticatorHarness::with_pass, || { + run_passwordless_login_multiple_users_test() + }) +} + +#[test] +#[ignore] +fn test_tpm_passwordless_login_multiple_users() -> Result<()> { + with_backend("TPM (swtpm)", AuthenticatorHarness::with_tpm, || { + run_passwordless_login_multiple_users_test() + }) +} diff --git a/cmd/passless/tests/harness.rs b/cmd/passless/tests/harness.rs index 36b170a..30da25a 100644 --- a/cmd/passless/tests/harness.rs +++ b/cmd/passless/tests/harness.rs @@ -402,6 +402,8 @@ impl AuthenticatorHarness { if let Ok(list) = TransportList::enumerate() && list.len() >= expected_count { + // Wait extra time for the authenticator to fully initialize + std::thread::sleep(Duration::from_millis(1000)); println!(" ✓ Authenticator is ready"); return Ok(()); } @@ -449,10 +451,12 @@ impl AuthenticatorHarness { // Spawn threads to capture stdout/stderr using a small helper fn spawn_log_thread(r: R) { + use std::io::Write; thread::spawn(move || { let reader = BufReader::new(r); for line in reader.lines().map_while(Result::ok) { - println!("{}", line); + eprintln!("{}", line); + let _ = std::io::stderr().flush(); } }); }