You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1170 lines
55 KiB
1170 lines
55 KiB
#![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<KeyVectors> = 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<Private>, 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::<SocketAddr>().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::<Count>(&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::<Count>(&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 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<openssl::pkey::Private>) {
|
|
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 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::<Value>().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::<Vec<_>>();
|
|
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<Result<Response, reqwest::Error>> {
|
|
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"] {
|
|
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 {
|
|
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"));
|
|
}
|
|
}
|
|
|
|
#[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<openssl::pkey::Private>,
|
|
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);
|
|
}
|
|
}
|
|
|