#![cfg(all(unix, any(feature = "sqlite", feature = "sqlite_system")))] // Synthetic HTTP/storage contracts. These do not prove PRF hardware or client decryption. use diesel::{Connection, RunQueryDsl, connection::SimpleConnection}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use openssl::{hash::MessageDigest, rsa::Rsa}; use reqwest::{Client, Response, StatusCode}; use serde_json::{Value, json}; use std::{ fs, net::SocketAddr, os::unix::fs::{DirBuilderExt, PermissionsExt}, path::PathBuf, process::{Child, Command, Stdio}, time::{Duration, Instant}, }; const ORIGIN: &str = "http://localhost"; const PASSWORD: &str = "synthetic-master-password-hash"; // Real native-format AES/HMAC/RSA envelopes over disposable synthetic key material. // These exercise wire validation; hardware PRF and stock-client decryption remain separate. struct WrappedKeys { user_key: String, public_key: String, private_key: String, } struct KeyVectors { initial: WrappedKeys, updated: WrappedKeys, rotated: WrappedKeys, original_account_private_key: String, repaired_public_key: String, repaired_private_key: String, } fn keys() -> &'static KeyVectors { static KEYS: std::sync::LazyLock = std::sync::LazyLock::new(|| { use openssl::{ encrypt::Encrypter, pkey::{PKey, Private}, rsa::Padding, symm::{Cipher, encrypt}, }; fn aes(plain: &[u8], key: &[u8; 64]) -> String { let mut iv = [0; 16]; openssl::rand::rand_bytes(&mut iv).unwrap(); let ciphertext = encrypt(Cipher::aes_256_cbc(), &key[..32], Some(&iv), plain).unwrap(); let mac_key = PKey::hmac(&key[32..]).unwrap(); let mut mac = openssl::sign::Signer::new(MessageDigest::sha256(), &mac_key).unwrap(); mac.update(&iv).unwrap(); mac.update(&ciphertext).unwrap(); format!( "2.{}|{}|{}", data_encoding::BASE64.encode(&iv), data_encoding::BASE64.encode(&ciphertext), data_encoding::BASE64.encode(&mac.sign_to_vec().unwrap()) ) } fn wrap(pair: &PKey, user_key: &[u8; 64]) -> WrappedKeys { let mut rsa = Encrypter::new(pair).unwrap(); rsa.set_rsa_padding(Padding::PKCS1_OAEP).unwrap(); rsa.set_rsa_oaep_md(MessageDigest::sha1()).unwrap(); rsa.set_rsa_mgf1_md(MessageDigest::sha1()).unwrap(); let mut ciphertext = vec![0; rsa.encrypt_len(user_key).unwrap()]; let len = rsa.encrypt(user_key, &mut ciphertext).unwrap(); ciphertext.truncate(len); WrappedKeys { user_key: format!("4.{}", data_encoding::BASE64.encode(&ciphertext)), public_key: aes(&pair.public_key_to_der().unwrap(), user_key), private_key: aes(&pair.private_key_to_pkcs8().unwrap(), &[0x33; 64]), } } let original = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); let replacement = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); let account_pair = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); let repaired_public_key = data_encoding::BASE64.encode(&account_pair.public_key_to_der().unwrap()); let original_account_private_key = aes(&account_pair.private_key_to_pkcs8().unwrap(), &[0x11; 64]); let repaired_private_key = aes(&account_pair.private_key_to_pkcs8().unwrap(), &[0x11; 64]); let initial = wrap(&original, &[0x11; 64]); let updated = wrap(&replacement, &[0x11; 64]); let mut rotated = wrap(&original, &[0x22; 64]); rotated.private_key.clone_from(&initial.private_key); KeyVectors { initial, updated, rotated, original_account_private_key, repaired_public_key, repaired_private_key, } }); &KEYS } struct Fixture { path: PathBuf, process: Child, url: String, client: Client, user: String, device: String, token: String, postgres: Option<(String, String)>, } impl Fixture { async fn new() -> Self { Self::with_policy(true).await } async fn with_policy(sso_only: bool) -> Self { Self::with_verification_policy(sso_only, false).await } async fn with_verification_policy(sso_only: bool, verify_email: bool) -> Self { static CRYPTO: std::sync::Once = std::sync::Once::new(); CRYPTO.call_once(|| rustls::crypto::ring::default_provider().install_default().unwrap()); let id = || uuid::Uuid::new_v4().to_string(); let path = std::env::temp_dir().join(format!("vaultwarden-passkey-test-{}", id())); fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); let key = Rsa::generate(2048).unwrap().private_key_to_pem().unwrap(); fs::write(path.join("rsa.pem"), &key).unwrap(); fs::set_permissions(path.join("rsa.pem"), fs::Permissions::from_mode(0o600)).unwrap(); fs::write(path.join("config.json"), "{}").unwrap(); fs::write(path.join("empty.env"), "").unwrap(); fs::write(path.join("db.sqlite3"), "").unwrap(); let log = fs::File::create(path.join("server.log")).unwrap(); let postgres = local_postgres_database(); let database_url = postgres .as_ref() .map_or_else(|| path.join("db.sqlite3").to_string_lossy().into_owned(), |(_, url)| url.clone()); let mut command = Command::new(env!("CARGO_BIN_EXE_vaultwarden")); command .env_clear() .current_dir(&path) .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("DATA_FOLDER", &path) .env("DATABASE_URL", database_url) .env("CONFIG_FILE", path.join("config.json")) .env("ENV_FILE", path.join("empty.env")) .env("RSA_KEY_FILENAME", path.join("rsa")) .env("DOMAIN", ORIGIN) .env("ROCKET_ADDRESS", "127.0.0.1") .env("ROCKET_PORT", "0") .env("WEB_VAULT_ENABLED", "false") .env("ENABLE_WEBSOCKET", "false") .env("PUSH_ENABLED", "false") .env("DISABLE_ICON_DOWNLOAD", "true") .env("JOB_POLL_INTERVAL_MS", "0") .env("LOGIN_RATELIMIT_MAX_BURST", "100") .env("DATABASE_MAX_CONNS", "4") .env("DATABASE_MIN_CONNS", "1") .env("SSO_ENABLED", sso_only.to_string()) .env("SSO_ONLY", sso_only.to_string()) .env("SSO_CLIENT_ID", "synthetic-client") .env("SSO_CLIENT_SECRET", "synthetic-secret") .env("SSO_AUTHORITY", "http://127.0.0.1:9") .stdout(Stdio::from(log.try_clone().unwrap())) .stderr(Stdio::from(log)); if verify_email { command .env("SIGNUPS_VERIFY", "true") .env("SMTP_HOST", "127.0.0.1") .env("SMTP_PORT", "9") .env("SMTP_SECURITY", "off") .env("SMTP_FROM", "notifications@example.test"); } for name in ["DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"] { if let Some(value) = std::env::var_os(name) { command.env(name, value); } } let user = id(); let device = id(); let now = chrono::Utc::now().timestamp(); let token = jsonwebtoken::encode( &Header::new(Algorithm::RS256), &json!({ "nbf":now-30,"exp":now+3600,"iss":format!("{ORIGIN}|login"),"sub":user, "premium":true,"name":"Synthetic user","email":"user@example.test","email_verified":true, "sstamp":"synthetic-stamp","device":device,"devicetype":"14","client_id":"web", "scope":["api","offline_access"],"amr":["Application"] }), &EncodingKey::from_rsa_pem(&key).unwrap(), ) .unwrap(); let mut f = Self { path, process: command.spawn().unwrap(), url: String::new(), client: Client::builder().no_proxy().timeout(Duration::from_secs(15)).build().unwrap(), user, device, token, postgres, }; let until = Instant::now() + Duration::from_secs(20); loop { assert!( f.process.try_wait().unwrap().is_none() && Instant::now() < until, "fixture failed: {}\n{}", f.path.display(), fs::read_to_string(f.path.join("server.log")).unwrap_or_default() ); if f.url.is_empty() { let log = fs::read_to_string(f.path.join("server.log")).unwrap(); if let Some(address) = log.split_inclusive('\n').find_map(|line| { let (_, a) = line.strip_suffix('\n')?.split_once("Rocket has launched from http://")?; let a = a.trim().parse::().ok()?; (a.ip().is_loopback() && a.port() != 0).then_some(a) }) { f.url = format!("http://{address}"); } } if !f.url.is_empty() && let Ok(r) = f.client.get(format!("{}/alive", f.url)).send().await && r.status() == StatusCode::OK { break; } tokio::time::sleep(Duration::from_millis(40)).await; } let salt = [0u8; 64]; let mut hash = [0u8; 32]; openssl::pkcs5::pbkdf2_hmac(PASSWORD.as_bytes(), &salt, 1000, MessageDigest::sha256(), &mut hash).unwrap(); let hash = data_encoding::HEXLOWER.encode(&hash); // All interpolated values are generated UUIDs or fixed synthetic fixture values. f.sql(&format!("INSERT INTO users (uuid,enabled,created_at,updated_at,login_verify_count,email,name,password_hash,salt,password_iterations,akey,private_key,security_stamp,equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter) VALUES ('{}',1,datetime('now'),datetime('now'),0,'user@example.test','Synthetic user',X'{hash}',zeroblob(64),1000,'synthetic-key','synthetic-private-key','synthetic-stamp','[]','[]',0,600000); INSERT INTO devices (uuid,user_uuid,created_at,updated_at,name,atype,refresh_token) VALUES ('{}','{}',datetime('now'),datetime('now'),'Synthetic browser',14,'synthetic-refresh');", f.user,f.device,f.user)); f } fn sql(&self, sql: &str) { #[cfg(feature = "postgresql")] if let Some((_, url)) = &self.postgres { let mut c = diesel::pg::PgConnection::establish(url).unwrap(); let sql = sql .replace("datetime('now')", "CURRENT_TIMESTAMP") .replace(",1,CURRENT_TIMESTAMP", ",true,CURRENT_TIMESTAMP") .replace("zeroblob(64)", "decode(repeat('00',64),'hex')"); let sql = regex::Regex::new("X'([0-9a-f]+)'").unwrap().replace_all(&sql, "decode('$1','hex')"); let sql = sql .replace("json_set(data,'$.createdAt',0)", "jsonb_set(data::jsonb,'{createdAt}','0')::text") .replace( "json_set(data,'$.deviceUuid','another-device')", "jsonb_set(data::jsonb,'{deviceUuid}','\"another-device\"')::text", ) .replace( "json_set(data,'$.accountKeyBinding','another-key')", "jsonb_set(data::jsonb,'{accountKeyBinding}','\"another-key\"')::text", ); c.batch_execute(&sql).unwrap(); return; } let mut c = diesel::sqlite::SqliteConnection::establish(self.path.join("db.sqlite3").to_str().unwrap()).unwrap(); c.batch_execute("PRAGMA busy_timeout=5000;").unwrap(); c.batch_execute(sql).unwrap(); } fn count(&self, table: &str) -> i64 { #[derive(diesel::QueryableByName)] struct Count { #[diesel(sql_type=diesel::sql_types::BigInt)] count: i64, } #[cfg(feature = "postgresql")] if let Some((_, url)) = &self.postgres { let mut c = diesel::pg::PgConnection::establish(url).unwrap(); return diesel::sql_query(format!("SELECT count(*) AS count FROM {table}")) .get_result::(&mut c) .unwrap() .count; } let mut c = diesel::sqlite::SqliteConnection::establish(self.path.join("db.sqlite3").to_str().unwrap()).unwrap(); diesel::sql_query(format!("SELECT count(*) AS count FROM {table}")).get_result::(&mut c).unwrap().count } async fn post(&self, path: &str, data: Value) -> Response { self.client.post(format!("{}{path}", self.url)).bearer_auth(&self.token).json(&data).send().await.unwrap() } async fn get(&self, path: &str) -> Value { let r = self.client.get(format!("{}{path}", self.url)).bearer_auth(&self.token).send().await.unwrap(); assert_eq!(r.status(), StatusCode::OK); r.json().await.unwrap() } async fn options(&self) -> Value { let r = self.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await; assert_eq!(r.status(), StatusCode::OK); r.json().await.unwrap() } } impl Drop for Fixture { fn drop(&mut self) { drop(self.process.kill()); drop(self.process.wait()); #[cfg(feature = "postgresql")] if let Some((admin, url)) = &self.postgres { let parsed = url::Url::parse(url).unwrap(); let database = parsed.path().trim_start_matches('/'); assert!( database.starts_with("vw_passkey_") && database.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') ); if let Ok(mut conn) = diesel::pg::PgConnection::establish(admin) { drop(conn.batch_execute(&format!("DROP DATABASE {database} WITH (FORCE)"))); } } drop(fs::remove_dir_all(&self.path)); } } #[tokio::test] async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() { let f = Fixture::new().await; for proof in [json!({}), json!({"masterPasswordHash":"wrong"})] { assert_eq!(f.post("/api/webauthn/attestation-options", proof).await.status(), StatusCode::BAD_REQUEST); } let options = f.options().await; assert_eq!(options["options"]["rp"]["id"], "localhost"); assert_eq!(options["options"]["authenticatorSelection"]["userVerification"], "required"); assert!(options["token"].as_str().is_some_and(|s| !s.is_empty())); assert_eq!(f.get("/api/webauthn").await["data"], json!([])); let public_options = f.client.get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)).send().await.unwrap(); assert!(!public_options.status().is_success()); let r = f .client .post(format!("{}/identity/connect/token", f.url)) .form(&[("grant_type", "webauthn"), ("client_id", "web"), ("token", "synthetic"), ("deviceResponse", "{}")]) .send() .await .unwrap(); assert!(!r.status().is_success()); let sync = f.get("/api/sync").await; assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); assert!(sync["userDecryption"]["webAuthnPrfOptions"].is_null()); assert_eq!(f.count("web_authn_credentials"), 0); } #[tokio::test] async fn user_key_id_initialization_preserves_existing_keys_and_rejects_replacement() { let f = Fixture::with_policy(false).await; let key_id = uuid::Uuid::new_v4().to_string(); let data = json!({"userKeyId":key_id}); assert_eq!(f.post("/api/accounts/key-management/user-key-id", data.clone()).await.status(), StatusCode::OK); assert_eq!(f.count(&format!("users WHERE key_id='{key_id}' AND akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'")),1); assert_eq!( f.post("/api/accounts/key-management/user-key-id", data).await.status(), StatusCode::UNPROCESSABLE_ENTITY ); let sync = f.get("/api/sync").await; assert_eq!(sync["userDecryption"]["userKeyId"], key_id); assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); } #[tokio::test] async fn passkeys_are_available_without_opt_in_and_keep_master_password_unlock() { let f = Fixture::with_policy(false).await; f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); let keypair = json!({"publicKey":keys().repaired_public_key, "encryptedPrivateKey":keys().original_account_private_key}); assert_eq!(f.post("/api/accounts/keys", keypair.clone()).await.status(), StatusCode::OK); assert_eq!(f.post("/api/accounts/keys", keypair).await.status(), StatusCode::BAD_REQUEST); assert_eq!( f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await.status(), StatusCode::OK ); assert_eq!(f.get("/api/webauthn").await["data"], json!([])); let sync = f.get("/api/sync").await; assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); assert!(sync["userDecryption"]["webAuthnPrfOptions"].is_null()); } fn registration_with_key( options: &Value, origin: &str, uv: bool, ) -> (Value, openssl::ec::EcKey) { use openssl::{ bn::{BigNum, BigNumContext}, ec::{EcGroup, EcKey}, nid::Nid, }; let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); let key = EcKey::generate(&group).unwrap(); let (mut x, mut y, mut ctx) = (BigNum::new().unwrap(), BigNum::new().unwrap(), BigNumContext::new().unwrap()); key.public_key().affine_coordinates_gfp(&group, &mut x, &mut y, &mut ctx).unwrap(); let mut cose = vec![0xa5, 1, 2, 3, 0x26, 0x20, 1, 0x21, 0x58, 32]; cose.extend(x.to_vec_padded(32).unwrap()); cose.extend([0x22, 0x58, 32]); cose.extend(y.to_vec_padded(32).unwrap()); let credential = uuid::Uuid::new_v4(); let mut auth = openssl::sha::sha256(b"localhost").to_vec(); auth.push(if uv { 0x45 } else { 0x41 }); auth.extend([0; 4]); auth.extend([0; 16]); auth.extend([0, 16]); auth.extend(credential.as_bytes()); auth.extend(cose); // Minimal standards-format 'none' attestation from a synthetic EC credential. // This exercises webauthn-rs verification; it does not simulate PRF hardware. let mut attestation = b"\xa3\x63fmt\x64none\x67attStmt\xa0\x68authData\x58".to_vec(); attestation.push(u8::try_from(auth.len()).unwrap()); attestation.extend(auth); let client = json!({"type":"webauthn.create","challenge":options["options"]["challenge"],"origin":origin,"crossOrigin":false}); let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); let response = json!({"deviceResponse":{"id":encode(credential.as_bytes()),"rawId":encode(credential.as_bytes()), "response":{"attestationObject":encode(&attestation),"clientDataJson":encode(&serde_json::to_vec(&client).unwrap()),"transports":["usb"]}, "type":"public-key","clientExtensionResults":{"prf":{"enabled":true}}}, "name":"Synthetic passkey","token":options["token"],"supportsPrf":true, "encryptedUserKey":keys().initial.user_key.as_str(),"encryptedPublicKey":keys().initial.public_key.as_str(),"encryptedPrivateKey":keys().initial.private_key.as_str()}); (response, key) } fn registration(options: &Value, origin: &str, uv: bool) -> Value { registration_with_key(options, origin, uv).0 } #[tokio::test] async fn passkey_registration_verifies_origin_uv_replay_and_complete_wrapped_keys() { let f = Fixture::new().await; for (origin, uv) in [("http://unrelated.example", true), (ORIGIN, false)] { let o = f.options().await; let r = f.post("/api/webauthn", registration(&o, origin, uv)).await; assert!(!r.status().is_success()); assert_eq!(f.count("web_authn_credentials"), 0); } let o = f.options().await; let mut incomplete = registration(&o, ORIGIN, true); incomplete.as_object_mut().unwrap().remove("encryptedPrivateKey"); assert!(!f.post("/api/webauthn", incomplete).await.status().is_success()); assert_eq!(f.count("web_authn_credentials"), 0); let o = f.options().await; let data = registration(&o, ORIGIN, true); assert_eq!(f.post("/api/webauthn", data.clone()).await.status(), StatusCode::OK); assert!(!f.post("/api/webauthn", data).await.status().is_success()); assert_eq!(f.count("web_authn_credentials"), 1); let list = f.get("/api/webauthn").await; assert_eq!(list["data"][0]["prfStatus"], 0); let sync = f.get("/api/sync").await; assert_eq!( sync["userDecryption"]["webAuthnPrfOptions"][0]["encryptedPrivateKey"], keys().initial.private_key.as_str() ); assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); } #[tokio::test] async fn account_deletion_removes_enrolled_passkeys_and_pending_challenges() { let f = Fixture::with_policy(false).await; let options = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&options, ORIGIN, true)).await.status(), StatusCode::OK); let credentials = format!("web_authn_credentials WHERE user_uuid='{}'", f.user); assert_eq!(f.count(&credentials), 1); // Leave both user-bound challenge types pending when the account is deleted. f.options().await; assert_eq!( f.post("/api/webauthn/assertion-options", json!({"masterPasswordHash":PASSWORD})).await.status(), StatusCode::OK ); for atype in [1005, 1006] { assert_eq!(f.count(&format!("twofactor WHERE user_uuid='{}' AND atype={atype}", f.user)), 1); } let devices = format!("devices WHERE user_uuid='{}' AND uuid='{}'", f.user, f.device); assert_eq!(f.count(&devices), 1); f.get("/api/sync").await; assert_eq!(f.post("/api/accounts/delete", json!({"masterPasswordHash":PASSWORD})).await.status(), StatusCode::OK); assert_eq!(f.count(&format!("users WHERE uuid='{}'", f.user)), 0); assert_eq!(f.count(&devices), 0); assert_eq!(f.count(&credentials), 0); assert_eq!(f.count(&format!("twofactor WHERE user_uuid='{}'", f.user)), 0); let response = f.client.get(format!("{}/api/sync", f.url)).bearer_auth(&f.token).send().await.unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn passkey_multiple_options_removal_and_password_wrapping_are_consistent() { let f = Fixture::new().await; for _ in 0..2 { let o = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); } assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 2); let list = f.get("/api/webauthn").await; let id = list["data"][0]["id"].as_str().unwrap(); assert_eq!( f.post(&format!("/api/webauthn/{id}/delete"), json!({"masterPasswordHash":PASSWORD})).await.status(), StatusCode::OK ); assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); // A master-password wrapper/KDF change preserves the actual user key. f.sql(&format!("UPDATE users SET akey='new-master-wrapper',client_kdf_iter=650000 WHERE uuid='{}'", f.user)); assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); } fn rotate_request(passkeys: &Value) -> Value { json!({"oldMasterKeyAuthenticationHash":PASSWORD, "accountUnlockData":{"emergencyAccessUnlockData":[],"organizationAccountRecoveryUnlockData":[], "passkeyUnlockData":passkeys,"masterPasswordUnlockData":{"kdfType":0,"kdfIterations":600_000, "kdfMemory":null,"kdfParallelism":null,"email":"user@example.test","masterKeyAuthenticationHash":PASSWORD, "masterKeyEncryptedUserKey":"new-master-wrapper"}}, "accountKeys":{"userKeyEncryptedAccountPrivateKey":"new-account-private-wrapper","accountPublicKey":"synthetic-public-key"}, "accountData":{"ciphers":[],"folders":[],"sends":[]}}) } #[tokio::test] async fn passkey_rotation_validates_before_mutation_and_rewraps_all_credentials() { let f = Fixture::new().await; f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); let o = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); let list = f.get("/api/webauthn").await; let id = list["data"][0]["id"].as_str().unwrap(); assert_eq!( f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&json!([]))).await.status(), StatusCode::BAD_REQUEST ); assert_eq!( f.get("/api/sync").await["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key" ); for field in ["encryptedUserKey", "encryptedPublicKey"] { for invalid in [Value::Null, json!("not-an-encrypted-string")] { let mut changes = json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]); let expected = if invalid.is_null() { StatusCode::UNPROCESSABLE_ENTITY } else { StatusCode::BAD_REQUEST }; changes[0][field] = invalid; assert_eq!( f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&changes)) .await .status(), expected ); assert_eq!( f.get("/api/sync").await["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key" ); } } let changes = json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]); assert_eq!( f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&changes)).await.status(), StatusCode::OK ); // Read only synthetic persistence. Token invalidation is native rotation behavior. assert_eq!( f.count(&format!( "web_authn_credentials WHERE encrypted_user_key='{}' AND encrypted_private_key='{}'", keys().rotated.user_key, keys().initial.private_key )), 1 ); assert_eq!(f.count("users WHERE akey='new-master-wrapper' AND private_key='new-account-private-wrapper'"), 1); } #[tokio::test] async fn passkey_challenges_are_fresh_session_bound_and_consumed_exactly_once() { let f = Fixture::new().await; for change in [ "json_set(data,'$.createdAt',0)", "json_set(data,'$.deviceUuid','another-device')", "json_set(data,'$.accountKeyBinding','another-key')", ] { let o = f.options().await; f.sql(&format!("UPDATE twofactor SET data={change} WHERE atype=1005")); assert!(!f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status().is_success()); assert_eq!(f.count("web_authn_credentials"), 0); } let stale = f.options().await; let current = f.options().await; assert!(!f.post("/api/webauthn", registration(&stale, ORIGIN, true)).await.status().is_success()); let data = registration(¤t, ORIGIN, true); let (first, second) = tokio::join!(f.post("/api/webauthn", data.clone()), f.post("/api/webauthn", data)); assert_eq!(usize::from(first.status().is_success()) + usize::from(second.status().is_success()), 1); assert_eq!(f.count("web_authn_credentials"), 1); assert_eq!(f.count("twofactor WHERE atype=1005"), 0); } #[tokio::test] async fn passkey_prf_update_verifies_assertion_and_rejects_replay() { let f = Fixture::new().await; let o = f.options().await; let (registration, key) = registration_with_key(&o, ORIGIN, true); let credential = registration["deviceResponse"]["rawId"].clone(); assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); let response = f.post("/api/webauthn/assertion-options", json!({"masterPasswordHash":PASSWORD})).await; assert_eq!(response.status(), StatusCode::OK); let o: Value = response.json().await.unwrap(); let client = json!({"type":"webauthn.get","challenge":o["options"]["challenge"],"origin":ORIGIN,"crossOrigin":false}); let client = serde_json::to_vec(&client).unwrap(); let mut auth = openssl::sha::sha256(b"localhost").to_vec(); auth.push(5); auth.extend(1u32.to_be_bytes()); let key = openssl::pkey::PKey::from_ec_key(key).unwrap(); let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap(); signer.update(&auth).unwrap(); signer.update(&openssl::sha::sha256(&client)).unwrap(); let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); let data = json!({"token":o["token"],"deviceResponse":{"id":credential,"rawId":credential,"type":"public-key", "extensions":{},"response":{"authenticatorData":encode(&auth),"clientDataJson":encode(&client),"signature":encode(&signer.sign_to_vec().unwrap()),"userHandle":null}}, "encryptedUserKey":keys().updated.user_key.as_str(),"encryptedPublicKey":keys().updated.public_key.as_str(),"encryptedPrivateKey":keys().updated.private_key.as_str()}); let send = |data| f.client.put(format!("{}/api/webauthn", f.url)).bearer_auth(&f.token).json(&data).send(); assert_eq!(send(data.clone()).await.unwrap().status(), StatusCode::OK); assert!(!send(data).await.unwrap().status().is_success()); assert_eq!( f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"][0]["encryptedPrivateKey"], keys().updated.private_key.as_str() ); } #[tokio::test] async fn passkey_credentials_cannot_be_read_or_removed_by_another_account() { let f = Fixture::new().await; let o = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); let list = f.get("/api/webauthn").await; let id = list["data"][0]["id"].as_str().unwrap(); let other = uuid::Uuid::new_v4().to_string(); let device = uuid::Uuid::new_v4().to_string(); f.sql(&format!("INSERT INTO users (uuid,enabled,created_at,updated_at,login_verify_count,email,name,password_hash,salt,password_iterations,akey,private_key,security_stamp,equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter) SELECT '{other}',enabled,created_at,updated_at,login_verify_count,'other@example.test','Other synthetic user',password_hash,salt,password_iterations,akey,private_key,'other-stamp',equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter FROM users WHERE uuid='{}'; INSERT INTO devices (uuid,user_uuid,created_at,updated_at,name,atype,refresh_token) VALUES ('{device}','{other}',datetime('now'),datetime('now'),'Other browser',14,'other-refresh');",f.user)); let now = chrono::Utc::now().timestamp(); let token=jsonwebtoken::encode(&Header::new(Algorithm::RS256),&json!({"nbf":now-30,"exp":now+3600,"iss":format!("{ORIGIN}|login"),"sub":other,"premium":true,"name":"Other user","email":"other@example.test","email_verified":true,"sstamp":"other-stamp","device":device,"devicetype":"14","client_id":"web","scope":["api","offline_access"],"amr":["Application"]}),&EncodingKey::from_rsa_pem(&fs::read(f.path.join("rsa.pem")).unwrap()).unwrap()).unwrap(); let r = f.client.get(format!("{}/api/webauthn", f.url)).bearer_auth(&token).send().await.unwrap(); assert_eq!(r.json::().await.unwrap()["data"], json!([])); assert_eq!( f.client .post(format!("{}/api/webauthn/{id}/delete", f.url)) .bearer_auth(&token) .json(&json!({"masterPasswordHash":PASSWORD})) .send() .await .unwrap() .status(), StatusCode::OK ); assert_eq!(f.get("/api/webauthn").await["data"].as_array().unwrap().len(), 1); let r = f.client.get(format!("{}/api/webauthn", f.url)).send().await.unwrap(); assert_eq!(r.status(), StatusCode::UNAUTHORIZED); } fn local_postgres_database() -> Option<(String, String)> { let Ok(value) = std::env::var("VW_PASSKEY_TEST_DATABASE_URL") else { return None; }; let url = url::Url::parse(&value).expect("local test URL"); assert_eq!(url.scheme(), "postgresql"); assert_eq!(url.host_str(), Some("127.0.0.1")); assert!(url.username() == "passkey_test", "Expected the disposable test account"); assert!(url.password() == Some("synthetic-passkey-test"), "Expected synthetic test credentials"); assert_eq!(url.path(), "/postgres"); assert!(url.fragment().is_none()); let parameters = url.query_pairs().collect::>(); if !parameters.is_empty() { assert_eq!(parameters.len(), 1); assert_eq!(parameters[0].0, "host"); let directory = PathBuf::from(parameters[0].1.as_ref()); let canonical = directory.canonicalize().expect("private fixture socket directory"); assert_eq!(canonical, directory); assert_eq!(directory.parent(), Some(std::env::temp_dir().canonicalize().unwrap().as_path())); assert!(directory.file_name().unwrap().to_str().unwrap().starts_with("vw-passkey-pg-")); assert_eq!(fs::metadata(&directory).unwrap().permissions().mode() & 0o777, 0o700); } #[cfg(feature = "postgresql")] { let database = format!("vw_passkey_{}", uuid::Uuid::new_v4().simple()); let mut c = diesel::pg::PgConnection::establish(&value).unwrap(); c.batch_execute(&format!("CREATE DATABASE {database}")).unwrap(); let mut database_url = url.clone(); database_url.set_path(&database); Some((value, database_url.to_string())) } #[cfg(not(feature = "postgresql"))] panic!("PostgreSQL fixture requires the postgresql feature"); } #[tokio::test] async fn passkey_rewrap_failure_rolls_back_account_keys_and_can_retry() { let f = Fixture::new().await; f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); let o = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); let list = f.get("/api/webauthn").await; let data = rotate_request( &json!([{"id":list["data"][0]["id"],"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]), ); if f.postgres.is_some() { f.sql("CREATE FUNCTION reject_rewrap() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION 'synthetic failure'; END;$$; CREATE TRIGGER reject_rewrap BEFORE UPDATE OF encrypted_user_key ON web_authn_credentials FOR EACH ROW EXECUTE FUNCTION reject_rewrap();"); } else { f.sql("CREATE TRIGGER reject_rewrap BEFORE UPDATE OF encrypted_user_key ON web_authn_credentials BEGIN SELECT RAISE(ABORT,'synthetic failure'); END;"); } assert!(!f.post("/api/accounts/key-management/rotate-user-account-keys", data.clone()).await.status().is_success()); assert_eq!(f.count("users WHERE akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'"),1); assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); if f.postgres.is_some() { f.sql("DROP TRIGGER reject_rewrap ON web_authn_credentials; DROP FUNCTION reject_rewrap();"); } else { f.sql("DROP TRIGGER reject_rewrap;"); } assert_eq!(f.post("/api/accounts/key-management/rotate-user-account-keys", data).await.status(), StatusCode::OK); } #[cfg(feature = "postgresql")] struct PausedRotation { control: diesel::pg::PgConnection, } #[cfg(feature = "postgresql")] impl PausedRotation { fn new(f: &Fixture) -> Self { let (_, url) = f.postgres.as_ref().expect("run through the disposable PostgreSQL gate"); let mut control = diesel::pg::PgConnection::establish(url).unwrap(); control.batch_execute("SELECT pg_advisory_lock(424242);").unwrap(); f.sql("CREATE FUNCTION pause_rotation() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN PERFORM pg_advisory_xact_lock(424242); RETURN NEW; END;$$; CREATE TRIGGER pause_rotation AFTER UPDATE OF akey ON users FOR EACH ROW WHEN (NEW.akey='new-master-wrapper') EXECUTE FUNCTION pause_rotation();"); Self { control, } } fn release(&mut self) { self.control.batch_execute("SELECT pg_advisory_unlock(424242);").unwrap(); } } #[cfg(feature = "postgresql")] impl Drop for PausedRotation { fn drop(&mut self) { drop(self.control.batch_execute("SELECT pg_advisory_unlock_all();")); } } #[cfg(feature = "postgresql")] impl Fixture { async fn paused_rotation_data(&self) -> (Value, String) { assert!(self.postgres.is_some(), "PostgreSQL gate must supply a disposable database"); self.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", self.user)); let o = self.options().await; assert_eq!(self.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); let list = self.get("/api/webauthn").await; let id = list["data"][0]["id"].as_str().unwrap().to_owned(); let folder = uuid::Uuid::new_v4(); self.sql(&format!("INSERT INTO folders (uuid,user_uuid,created_at,updated_at,name) VALUES ('{folder}','{}',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'original-folder-wrapper')",self.user)); let mut data = rotate_request( &json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]), ); data["accountData"]["folders"] = json!([{"id":folder,"name":"rotated-folder-wrapper"}]); (data, id) } fn start_rotation(&self, data: &Value) -> tokio::task::JoinHandle> { let request = self .client .post(format!("{}/api/accounts/key-management/rotate-user-account-keys", self.url)) .bearer_auth(&self.token) .json(data); tokio::spawn(async move { request.send().await }) } async fn wait_for_database_wait(&self, condition: &str) { let until = Instant::now() + Duration::from_secs(3); while self.count(condition) == 0 { assert!(Instant::now() < until, "expected database wait did not occur"); tokio::time::sleep(Duration::from_millis(10)).await; } } async fn wait_for_paused_rotation(&self) { self.wait_for_database_wait("pg_locks WHERE database=(SELECT oid FROM pg_database WHERE datname=current_database()) AND locktype='advisory' AND objid=424242 AND NOT granted").await; } fn assert_original_key_state(&self) { assert_eq!(self.count("users WHERE akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'"),1); assert_eq!(self.count("folders WHERE name='original-folder-wrapper'"), 1); assert_eq!(self.count("devices WHERE refresh_token='synthetic-refresh'"), 1); assert_eq!( self.count(&format!("web_authn_credentials WHERE encrypted_user_key='{}'", keys().initial.user_key)), 1 ); } } #[cfg(feature = "postgresql")] #[tokio::test] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { for competing_action in ["delete", "rotate", "user-key-id"] { let f = Fixture::new().await; let (data, id) = f.paused_rotation_data().await; let mut pause = PausedRotation::new(&f); let first = f.start_rotation(&data); f.wait_for_paused_rotation().await; let competing = if competing_action == "delete" { let request = f .client .post(format!("{}/api/webauthn/{id}/delete", f.url)) .bearer_auth(&f.token) .json(&json!({"masterPasswordHash":PASSWORD})); tokio::spawn(async move { request.send().await }) } else if competing_action == "user-key-id" { let request = f .client .post(format!("{}/api/accounts/key-management/user-key-id", f.url)) .bearer_auth(&f.token) .json(&json!({"userKeyId": uuid::Uuid::new_v4().to_string()})); tokio::spawn(async move { request.send().await }) } else { f.start_rotation(&data) }; // A real PG wait proves the competing request reached the shared account lock. f.wait_for_database_wait("pg_stat_activity WHERE datname=current_database() AND wait_event='transactionid'") .await; assert!(!competing.is_finished()); pause.release(); assert_eq!(first.await.unwrap().unwrap().status(), StatusCode::OK); let result = competing.await.unwrap().unwrap(); if competing_action == "delete" { assert_eq!(result.status(), StatusCode::OK); } else { assert!(!result.status().is_success()); } assert_eq!(f.count("users WHERE akey='new-master-wrapper' AND private_key='new-account-private-wrapper'"), 1); assert_eq!(f.count("folders WHERE name='rotated-folder-wrapper'"), 1); assert_eq!(f.count("web_authn_credentials"), i64::from(competing_action != "delete")); if competing_action == "user-key-id" { assert_eq!(f.count("users WHERE key_id IS NOT NULL"), 0); } } } #[cfg(feature = "postgresql")] #[tokio::test] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] async fn passkey_postgres_cancelled_statement_rolls_back_and_retries() { let f = Fixture::new().await; let (data, _) = f.paused_rotation_data().await; let mut pause = PausedRotation::new(&f); let request = f.start_rotation(&data); f.wait_for_paused_rotation().await; // Target only the waiting statement in this test's generated database. f.sql("SELECT pg_cancel_backend(pid) FROM pg_locks WHERE database=(SELECT oid FROM pg_database WHERE datname=current_database()) AND locktype='advisory' AND objid=424242 AND NOT granted;"); assert!(!request.await.unwrap().unwrap().status().is_success()); f.assert_original_key_state(); assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); pause.release(); assert_eq!(f.post("/api/accounts/key-management/rotate-user-account-keys", data).await.status(), StatusCode::OK); } #[cfg(feature = "postgresql")] #[tokio::test] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] async fn passkey_postgres_server_interruption_rolls_back_vault_and_keys() { let mut f = Fixture::new().await; let (data, _) = f.paused_rotation_data().await; let mut pause = PausedRotation::new(&f); let request = f.start_rotation(&data); f.wait_for_paused_rotation().await; f.process.kill().unwrap(); f.process.wait().unwrap(); assert!(request.await.unwrap().is_err()); // PostgreSQL can finish a statement before noticing the client's closed socket. // Remove our artificial pause, then wait for all service sessions to exit; // there can be no client COMMIT after the server process has been killed. pause.release(); drop(pause); let until = Instant::now() + Duration::from_secs(3); while f.count("pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()") != 0 { assert!(Instant::now() < until); tokio::time::sleep(Duration::from_millis(10)).await; } f.assert_original_key_state(); } #[tokio::test] async fn passkey_native_registration_response_and_options_match_bitwarden() { let f = Fixture::new().await; let options = f.options().await; assert!(options["options"]["extensions"]["hmacCreateSecret"].is_null()); let mut request = registration(&options, ORIGIN, true); // Match the pinned stock web DTO: standard-base64 rawId, empty client // extensions, and no transport hint (id itself stays base64url). let raw_id = data_encoding::BASE64URL_NOPAD.decode(request["deviceResponse"]["rawId"].as_str().unwrap().as_bytes()).unwrap(); request["deviceResponse"]["rawId"] = json!(data_encoding::BASE64.encode(&raw_id)); request["deviceResponse"]["extensions"] = json!({}); request["deviceResponse"].as_object_mut().unwrap().remove("clientExtensionResults"); request["deviceResponse"]["response"].as_object_mut().unwrap().remove("transports"); let response = f.post("/api/webauthn", request).await; assert_eq!(response.status(), StatusCode::OK); let credential: Value = response.json().await.expect("native credential response JSON"); assert_eq!(credential["object"], "webauthnCredential"); assert_eq!(credential["name"], "Synthetic passkey"); assert_eq!(credential["prfStatus"], 0); assert_eq!(credential["encryptedUserKey"], keys().initial.user_key); assert_eq!(credential["encryptedPublicKey"], keys().initial.public_key); assert!(credential.get("encryptedPrivateKey").is_none()); assert!( f.get("/api/webauthn").await["data"][0] == credential, "keypair repair must preserve enabled passkey metadata" ); assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"][0]["transports"], json!([])); } #[tokio::test] async fn passkey_rejects_malformed_wrapped_keys_before_advertising_them() { let f = Fixture::new().await; for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] { for invalid in [ "plain text".to_owned(), "9.AAAA".to_owned(), "2.AA==|AA==|AA==".to_owned(), format!("4.{}", "A".repeat(2000)), ] { let options = f.options().await; let mut request = registration(&options, ORIGIN, true); request[field] = json!(invalid); assert_eq!(f.post("/api/webauthn", request).await.status(), StatusCode::BAD_REQUEST); assert_eq!(f.count("web_authn_credentials"), 0); } } assert!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].is_null()); } #[tokio::test] async fn passkey_excludes_registered_credentials_and_limits_enrollment_to_five() { let f = Fixture::new().await; let mut ids = Vec::new(); for count in 0..5 { let options = f.options().await; let excluded = options["options"]["excludeCredentials"].as_array().unwrap(); assert_eq!(excluded.len(), count); for id in &ids { assert!(excluded.iter().any(|item| &item["id"] == id)); } let request = registration(&options, ORIGIN, true); ids.push(request["deviceResponse"]["rawId"].clone()); assert_eq!(f.post("/api/webauthn", request).await.status(), StatusCode::OK); } assert_eq!( f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await.status(), StatusCode::BAD_REQUEST ); assert_eq!(f.count("web_authn_credentials"), 5); } #[tokio::test] async fn passkey_rejects_keypair_replacement_and_remains_in_rotation() { let f = Fixture::new().await; f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); assert_eq!( f.post( "/api/accounts/keys", json!({"publicKey":keys().repaired_public_key, "encryptedPrivateKey":keys().original_account_private_key}) ) .await .status(), StatusCode::OK ); let options = f.options().await; assert_eq!(f.post("/api/webauthn", registration(&options, ORIGIN, true)).await.status(), StatusCode::OK); let before = f.get("/api/sync").await["userDecryption"].clone(); let credential = f.get("/api/webauthn").await["data"][0].clone(); // Upstream rejects replacing an existing account keypair. The rejection // must preserve its existing master-password and passkey unlock options. let response = f .post( "/api/accounts/keys", json!({"publicKey":keys().repaired_public_key, "encryptedPrivateKey":keys().repaired_private_key}), ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert!( f.get("/api/sync").await["userDecryption"] == before, "keypair repair must preserve both native unlock options" ); assert!( f.get("/api/webauthn").await["data"][0] == credential, "keypair repair must preserve enabled passkey metadata" ); let mut missing = rotate_request(&json!([])); missing["accountKeys"]["accountPublicKey"] = json!(keys().repaired_public_key); assert_eq!( f.post("/api/accounts/key-management/rotate-user-account-keys", missing).await.status(), StatusCode::BAD_REQUEST ); let mut rotation = rotate_request(&json!([{"id":credential["id"],"encryptedUserKey":keys().rotated.user_key, "encryptedPublicKey":keys().rotated.public_key}])); rotation["accountKeys"]["accountPublicKey"] = json!(keys().repaired_public_key); assert_eq!( f.post("/api/accounts/key-management/rotate-user-account-keys", rotation).await.status(), StatusCode::OK ); assert_eq!( f.count(&format!( "web_authn_credentials WHERE encrypted_user_key='{}' AND encrypted_private_key='{}'", keys().rotated.user_key, keys().initial.private_key )), 1 ); } fn native_assertion( options: &Value, credential: &str, key: &openssl::ec::EcKey, user: &str, origin: &str, uv: bool, ) -> Value { let client = json!({"type":"webauthn.get","challenge":options["options"]["challenge"],"origin":origin,"crossOrigin":false}); let client = serde_json::to_vec(&client).unwrap(); let mut auth = openssl::sha::sha256(b"localhost").to_vec(); auth.push(if uv { 5 } else { 1 }); // Synced passkeys may always report zero; challenge consumption must still block replay. auth.extend(0u32.to_be_bytes()); let key = openssl::pkey::PKey::from_ec_key(key.clone()).unwrap(); let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap(); signer.update(&auth).unwrap(); signer.update(&openssl::sha::sha256(&client)).unwrap(); let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); let handle = uuid::Uuid::parse_str(user).unwrap(); json!({"id":credential,"rawId":credential,"type":"public-key","extensions":{}, "response":{"authenticatorData":encode(&auth),"clientDataJSON":encode(&client), "signature":encode(&signer.sign_to_vec().unwrap()),"userHandle":encode(handle.as_bytes())}}) } #[tokio::test] async fn native_passkey_login_uses_one_challenge_and_returns_prf_unlock() { let f = Fixture::with_policy(false).await; let registration_options = f.options().await; let (registration, key) = registration_with_key(®istration_options, ORIGIN, true); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); let factor = uuid::Uuid::new_v4(); f.sql(&format!( "INSERT INTO twofactor (uuid,user_uuid,atype,enabled,data,last_used) VALUES ('{factor}','{}',0,TRUE,'JBSWY3DPEHPK3PXP',0)", f.user )); let options: Value = f .client .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(options["options"]["userVerification"], "required"); let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true); let response = response.to_string(); let token = options["token"].as_str().unwrap(); let form = [ ("grant_type", "webauthn"), ("scope", "api offline_access"), ("client_id", "web"), ("token", token), ("deviceResponse", response.as_str()), ("deviceIdentifier", f.device.as_str()), ("deviceName", "Synthetic browser"), ("deviceType", "14"), ]; let first = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); assert_eq!(first.status(), StatusCode::OK); let result: Value = first.json().await.unwrap(); assert!(result["access_token"].as_str().is_some_and(|v| !v.is_empty())); assert!(result["refresh_token"].as_str().is_some_and(|v| !v.is_empty())); assert_eq!(result["UserDecryptionOptions"]["WebAuthnPrfOption"]["EncryptedUserKey"], keys().initial.user_key); assert_eq!(f.count("twofactor WHERE atype=0"), 1); let second = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); assert!(!second.status().is_success()); } #[tokio::test] async fn native_passkey_login_rejects_wrong_handle_origin_and_uv() { let f = Fixture::with_policy(false).await; let registration_options = f.options().await; let (registration, key) = registration_with_key(®istration_options, ORIGIN, true); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); for case in 0..3 { let options: Value = f .client .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) .send() .await .unwrap() .json() .await .unwrap(); let origin = if case == 1 { "https://attacker.example" } else { ORIGIN }; let mut response = native_assertion(&options, &credential, &key, &f.user, origin, case != 2); if case == 0 { response["response"]["userHandle"] = json!(data_encoding::BASE64URL_NOPAD.encode(uuid::Uuid::new_v4().as_bytes())); } let response = response.to_string(); let form = [ ("grant_type", "webauthn"), ("scope", "api offline_access"), ("client_id", "web"), ("token", options["token"].as_str().unwrap()), ("deviceResponse", response.as_str()), ("deviceIdentifier", f.device.as_str()), ("deviceName", "Synthetic browser"), ("deviceType", "14"), ]; let result = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); assert!(!result.status().is_success(), "case {case} unexpectedly authenticated"); } } #[tokio::test] async fn native_passkey_without_prf_keeps_master_password_unlock_and_refresh() { let f = Fixture::with_policy(false).await; let registration_options = f.options().await; let (mut registration, key) = registration_with_key(®istration_options, ORIGIN, true); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); registration["supportsPrf"] = json!(false); for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] { registration.as_object_mut().unwrap().remove(field); } assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); let options: Value = f .client .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) .send() .await .unwrap() .json() .await .unwrap(); let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true).to_string(); let form = [ ("grant_type", "webauthn"), ("scope", "api offline_access"), ("client_id", "web"), ("token", options["token"].as_str().unwrap()), ("deviceResponse", response.as_str()), ("deviceIdentifier", f.device.as_str()), ("deviceName", "Synthetic browser"), ("deviceType", "14"), ]; let result: Value = f .client .post(format!("{}/identity/connect/token", f.url)) .form(&form) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(result["UserDecryptionOptions"]["MasterPasswordUnlock"]["MasterKeyWrappedUserKey"], "synthetic-key"); assert!(result["UserDecryptionOptions"].get("WebAuthnPrfOption").is_none()); let refresh_token = result["refresh_token"].as_str().unwrap(); let refresh: Value = f .client .post(format!("{}/identity/connect/token", f.url)) .form(&[("grant_type", "refresh_token"), ("client_id", "web"), ("refresh_token", refresh_token)]) .send() .await .unwrap() .json() .await .unwrap(); assert!(refresh["access_token"].as_str().is_some_and(|v| !v.is_empty())); } #[tokio::test] async fn native_passkey_login_enforces_configured_email_verification() { let f = Fixture::with_verification_policy(false, true).await; let registration_options = f.options().await; let (registration, key) = registration_with_key(®istration_options, ORIGIN, true); let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); for verified in [false, true] { if verified { f.sql("UPDATE users SET verified_at=CURRENT_TIMESTAMP"); } let options: Value = f .client .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) .send() .await .unwrap() .json() .await .unwrap(); let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true).to_string(); let result = f .client .post(format!("{}/identity/connect/token", f.url)) .form(&[ ("grant_type", "webauthn"), ("scope", "api offline_access"), ("client_id", "web"), ("token", options["token"].as_str().unwrap()), ("deviceResponse", response.as_str()), ("deviceIdentifier", f.device.as_str()), ("deviceName", "Synthetic browser"), ("deviceType", "14"), ]) .send() .await .unwrap(); assert_eq!( result.status(), if verified { StatusCode::OK } else { StatusCode::BAD_REQUEST } ); let body: Value = result.json().await.unwrap(); assert_eq!(body.get("access_token").is_some(), verified); assert_eq!(body.get("refresh_token").is_some(), verified); } }