27 changed files with 2853 additions and 122 deletions
@ -0,0 +1,2 @@ |
|||||
|
DROP TABLE web_authn_login_challenges; |
||||
|
DROP TABLE web_authn_credentials; |
||||
@ -0,0 +1,19 @@ |
|||||
|
CREATE TABLE web_authn_credentials ( |
||||
|
uuid CHAR(36) PRIMARY KEY NOT NULL, |
||||
|
user_uuid CHAR(36) NOT NULL, |
||||
|
name TEXT NOT NULL, |
||||
|
credential TEXT NOT NULL, |
||||
|
credential_id_hash VARCHAR(64) NOT NULL, |
||||
|
supports_prf BOOLEAN NOT NULL, |
||||
|
encrypted_user_key TEXT, |
||||
|
encrypted_public_key TEXT, |
||||
|
encrypted_private_key TEXT, |
||||
|
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, |
||||
|
UNIQUE (credential_id_hash) |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE web_authn_login_challenges ( |
||||
|
token_hash VARCHAR(64) PRIMARY KEY NOT NULL, |
||||
|
state TEXT NOT NULL, |
||||
|
created_at BIGINT NOT NULL |
||||
|
); |
||||
@ -0,0 +1,2 @@ |
|||||
|
DROP TABLE web_authn_login_challenges; |
||||
|
DROP TABLE web_authn_credentials; |
||||
@ -0,0 +1,19 @@ |
|||||
|
CREATE TABLE web_authn_credentials ( |
||||
|
uuid TEXT PRIMARY KEY NOT NULL, |
||||
|
user_uuid TEXT NOT NULL, |
||||
|
name TEXT NOT NULL, |
||||
|
credential TEXT NOT NULL, |
||||
|
credential_id_hash TEXT NOT NULL, |
||||
|
supports_prf BOOLEAN NOT NULL, |
||||
|
encrypted_user_key TEXT, |
||||
|
encrypted_public_key TEXT, |
||||
|
encrypted_private_key TEXT, |
||||
|
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, |
||||
|
UNIQUE (credential_id_hash) |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE web_authn_login_challenges ( |
||||
|
token_hash TEXT PRIMARY KEY NOT NULL, |
||||
|
state TEXT NOT NULL, |
||||
|
created_at BIGINT NOT NULL |
||||
|
); |
||||
@ -0,0 +1,2 @@ |
|||||
|
DROP TABLE web_authn_login_challenges; |
||||
|
DROP TABLE web_authn_credentials; |
||||
@ -0,0 +1,19 @@ |
|||||
|
CREATE TABLE web_authn_credentials ( |
||||
|
uuid TEXT PRIMARY KEY NOT NULL, |
||||
|
user_uuid TEXT NOT NULL, |
||||
|
name TEXT NOT NULL, |
||||
|
credential TEXT NOT NULL, |
||||
|
credential_id_hash TEXT NOT NULL, |
||||
|
supports_prf BOOLEAN NOT NULL, |
||||
|
encrypted_user_key TEXT, |
||||
|
encrypted_public_key TEXT, |
||||
|
encrypted_private_key TEXT, |
||||
|
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, |
||||
|
UNIQUE (credential_id_hash) |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE web_authn_login_challenges ( |
||||
|
token_hash TEXT PRIMARY KEY NOT NULL, |
||||
|
state TEXT NOT NULL, |
||||
|
created_at BIGINT NOT NULL |
||||
|
); |
||||
@ -0,0 +1,766 @@ |
|||||
|
// Authenticated management adapted from Vaultwarden PR 7297 (AGPL-3.0),
|
||||
|
// branch snapshot at 727fead3a06eabf477ae9abed2ff0328acba7a6f.
|
||||
|
use chrono::Utc; |
||||
|
use rocket::{Route, http::Status, serde::json::Json, serde::json::Value}; |
||||
|
use webauthn_rs::prelude::{ |
||||
|
Base64UrlSafeData, Credential as WebauthnCredentialData, Passkey, PasskeyAuthentication, PasskeyRegistration, |
||||
|
}; |
||||
|
use webauthn_rs_proto::{ |
||||
|
AuthenticatorAttestationResponseRaw, AuthenticatorTransport, RegisterPublicKeyCredential, |
||||
|
RegistrationExtensionsClientOutputs, UserVerificationPolicy, |
||||
|
}; |
||||
|
|
||||
|
use crate::{ |
||||
|
CONFIG, |
||||
|
api::{ |
||||
|
ApiResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, |
||||
|
core::two_factor::webauthn::{PublicKeyCredentialCopy, WEBAUTHN}, |
||||
|
}, |
||||
|
auth::Headers, |
||||
|
crypto, |
||||
|
db::{ |
||||
|
DbConn, |
||||
|
models::{PasskeyAccount, TwoFactor, TwoFactorType, User, WebAuthnCredential}, |
||||
|
}, |
||||
|
error::Error, |
||||
|
util::get_uuid, |
||||
|
}; |
||||
|
|
||||
|
const WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS: i64 = 300; |
||||
|
const WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS: i64 = 30; |
||||
|
const MAX_WEBAUTHN_CREDENTIALS: usize = 5; |
||||
|
|
||||
|
pub fn routes() -> Vec<Route> { |
||||
|
routes![ |
||||
|
get_api_webauthn, |
||||
|
post_api_webauthn, |
||||
|
put_api_webauthn, |
||||
|
post_api_webauthn_assertion_options, |
||||
|
post_api_webauthn_attestation_options, |
||||
|
post_api_webauthn_delete, |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
#[get("/webauthn")] |
||||
|
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
let user = headers.user; |
||||
|
|
||||
|
let data: Vec<Value> = WebAuthnCredential::find_by_user(&user.uuid, &conn) |
||||
|
.await? |
||||
|
.into_iter() |
||||
|
.map(|credential| credential_response(&credential)) |
||||
|
.collect(); |
||||
|
|
||||
|
Ok(Json(json!({ |
||||
|
"object": "list", |
||||
|
"data": data, |
||||
|
"continuationToken": null |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
fn credential_response(credential: &WebAuthnCredential) -> Value { |
||||
|
json!({"id":credential.uuid,"name":credential.name,"prfStatus":credential.prf_status(), |
||||
|
"encryptedUserKey":credential.encrypted_user_key,"encryptedPublicKey":credential.encrypted_public_key, |
||||
|
"object":"webauthnCredential"}) |
||||
|
} |
||||
|
|
||||
|
// EncString contract: bitwarden/server v2026.7.0 and v2026.9.1, AGPL source at
|
||||
|
// src/Core/Utilities/EncryptedStringAttribute.cs and the WebAuthn request models.
|
||||
|
// Retain the supported releases' legacy forms; validate IV/MAC dimensions too.
|
||||
|
// Ciphertext stays opaque:
|
||||
|
// structural validity cannot prove integrity or possession of the decryption key.
|
||||
|
fn valid_wrapped_key(value: &str) -> bool { |
||||
|
if value.is_empty() || value.len() > 2000 { |
||||
|
return false; |
||||
|
} |
||||
|
let (scheme, body) = if let Some((header, body)) = value.split_once('.') { |
||||
|
let scheme = header.trim().parse::<u8>().ok().or_else(|| match header.trim() { |
||||
|
"AesCbc256_B64" => Some(0), |
||||
|
"AesCbc128_HmacSha256_B64" => Some(1), |
||||
|
"AesCbc256_HmacSha256_B64" => Some(2), |
||||
|
"Rsa2048_OaepSha256_B64" => Some(3), |
||||
|
"Rsa2048_OaepSha1_B64" => Some(4), |
||||
|
"Rsa2048_OaepSha256_HmacSha256_B64" => Some(5), |
||||
|
"Rsa2048_OaepSha1_HmacSha256_B64" => Some(6), |
||||
|
"XChaCha20Poly1305_B64" | "CoseEncrypt0B64" => Some(7), |
||||
|
_ => None, |
||||
|
}); |
||||
|
let Some(scheme) = scheme else { |
||||
|
return false; |
||||
|
}; |
||||
|
(scheme, body) |
||||
|
} else { |
||||
|
(u8::from(value.matches('|').count() == 2), value) |
||||
|
}; |
||||
|
// Zero means variable-length ciphertext, not an empty allowed component.
|
||||
|
let lengths: &[usize] = match scheme { |
||||
|
0 => &[16, 0], |
||||
|
1 | 2 => &[16, 0, 32], |
||||
|
3 | 4 | 7 => &[0], |
||||
|
5 | 6 => &[0, 0], |
||||
|
_ => return false, |
||||
|
}; |
||||
|
let mut specification = data_encoding::BASE64.specification(); |
||||
|
specification.check_trailing_bits = false; |
||||
|
" \t\r\n".clone_into(&mut specification.ignore); |
||||
|
let encoding = specification.encoding().expect("fixed base64 specification"); |
||||
|
let mut pieces = body.split('|'); |
||||
|
for length in lengths { |
||||
|
let Some(piece) = pieces.next() else { |
||||
|
return false; |
||||
|
}; |
||||
|
let Ok(bytes) = encoding.decode(piece.as_bytes()) else { |
||||
|
return false; |
||||
|
}; |
||||
|
if bytes.is_empty() || (*length != 0 && bytes.len() != *length) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
pieces.next().is_none() |
||||
|
} |
||||
|
|
||||
|
#[derive(Serialize, Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct WebAuthnPasskeyRegistrationChallenge { |
||||
|
token: String, |
||||
|
created_at: i64, |
||||
|
user_security_stamp: String, |
||||
|
device_uuid: String, |
||||
|
account_key_binding: String, |
||||
|
state: PasskeyRegistration, |
||||
|
} |
||||
|
|
||||
|
#[derive(Serialize, Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct WebAuthnPasskeyAssertionChallenge { |
||||
|
token: String, |
||||
|
created_at: i64, |
||||
|
user_security_stamp: String, |
||||
|
device_uuid: String, |
||||
|
account_key_binding: String, |
||||
|
state: PasskeyAuthentication, |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct PasskeyRegisterPublicKeyCredentialCopy { |
||||
|
id: String, |
||||
|
raw_id: Base64UrlSafeData, |
||||
|
response: PasskeyAuthenticatorAttestationResponseRawCopy, |
||||
|
#[serde(default, alias = "clientExtensionResults")] |
||||
|
extensions: RegistrationExtensionsClientOutputs, |
||||
|
r#type: String, |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct PasskeyAuthenticatorAttestationResponseRawCopy { |
||||
|
#[serde(rename = "AttestationObject", alias = "attestationObject")] |
||||
|
attestation_object: Base64UrlSafeData, |
||||
|
#[serde(rename = "clientDataJson", alias = "clientDataJSON")] |
||||
|
client_data_json: Base64UrlSafeData, |
||||
|
transports: Option<Vec<AuthenticatorTransport>>, |
||||
|
} |
||||
|
|
||||
|
impl From<PasskeyRegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential { |
||||
|
fn from(r: PasskeyRegisterPublicKeyCredentialCopy) -> Self { |
||||
|
Self { |
||||
|
id: r.id, |
||||
|
raw_id: r.raw_id, |
||||
|
response: AuthenticatorAttestationResponseRaw { |
||||
|
attestation_object: r.response.attestation_object, |
||||
|
client_data_json: r.response.client_data_json, |
||||
|
transports: r.response.transports, |
||||
|
}, |
||||
|
type_: r.r#type, |
||||
|
extensions: r.extensions, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fn passkey_management_challenge_is_fresh(created_at: i64) -> bool { |
||||
|
passkey_management_challenge_is_fresh_at(created_at, Utc::now().timestamp()) |
||||
|
} |
||||
|
|
||||
|
fn passkey_management_challenge_is_fresh_at(created_at: i64, now: i64) -> bool { |
||||
|
created_at >= now.saturating_sub(WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS) |
||||
|
&& created_at <= now.saturating_add(WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS) |
||||
|
} |
||||
|
|
||||
|
fn passkey_registration_challenge_state( |
||||
|
data: &str, |
||||
|
token: Option<&str>, |
||||
|
user_security_stamp: &str, |
||||
|
device_uuid: &str, |
||||
|
account_key_binding: &str, |
||||
|
) -> ApiResult<PasskeyRegistration> { |
||||
|
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyRegistrationChallenge>(data) else { |
||||
|
err!("Invalid registration challenge. Please try again.") |
||||
|
}; |
||||
|
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) { |
||||
|
err!("Invalid registration challenge. Please try again.") |
||||
|
} |
||||
|
if !passkey_management_challenge_is_fresh(saved.created_at) { |
||||
|
err!("Invalid registration challenge. Please try again.") |
||||
|
} |
||||
|
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) { |
||||
|
err!("Invalid registration challenge. Please try again.") |
||||
|
} |
||||
|
if !crypto::ct_eq(device_uuid, &saved.device_uuid) |
||||
|
|| !crypto::ct_eq(account_key_binding, &saved.account_key_binding) |
||||
|
{ |
||||
|
err!("Account or session changed. Please try again.") |
||||
|
} |
||||
|
Ok(saved.state) |
||||
|
} |
||||
|
|
||||
|
fn passkey_assertion_challenge_state( |
||||
|
data: &str, |
||||
|
token: &str, |
||||
|
user_security_stamp: &str, |
||||
|
device_uuid: &str, |
||||
|
account_key_binding: &str, |
||||
|
) -> ApiResult<PasskeyAuthentication> { |
||||
|
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyAssertionChallenge>(data) else { |
||||
|
err!("Invalid assertion challenge. Please try again.") |
||||
|
}; |
||||
|
if !crypto::ct_eq(token, &saved.token) { |
||||
|
err!("Invalid assertion challenge. Please try again.") |
||||
|
} |
||||
|
if !passkey_management_challenge_is_fresh(saved.created_at) { |
||||
|
err!("Invalid assertion challenge. Please try again.") |
||||
|
} |
||||
|
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) { |
||||
|
err!("Invalid assertion challenge. Please try again.") |
||||
|
} |
||||
|
if !crypto::ct_eq(device_uuid, &saved.device_uuid) |
||||
|
|| !crypto::ct_eq(account_key_binding, &saved.account_key_binding) |
||||
|
{ |
||||
|
err!("Account or session changed. Please try again.") |
||||
|
} |
||||
|
Ok(saved.state) |
||||
|
} |
||||
|
|
||||
|
pub(crate) fn passkey_credential_id_hash(credential_id: &[u8]) -> String { |
||||
|
crypto::sha256_hex(credential_id) |
||||
|
} |
||||
|
|
||||
|
fn passkey_count_limit_reached(count: usize) -> bool { |
||||
|
count >= MAX_WEBAUTHN_CREDENTIALS |
||||
|
} |
||||
|
|
||||
|
pub(crate) fn account_passkeys_allowed() -> bool { |
||||
|
CONFIG.passkeys_enabled() && CONFIG.is_webauthn_2fa_supported() |
||||
|
} |
||||
|
|
||||
|
pub(crate) fn passkey_counter(passkey: &Passkey) -> u32 { |
||||
|
let credential: WebauthnCredentialData = passkey.clone().into(); |
||||
|
credential.counter |
||||
|
} |
||||
|
|
||||
|
#[derive(PartialEq)] |
||||
|
struct PasskeyRegistrationPrfData { |
||||
|
supports_prf: bool, |
||||
|
encrypted_user_key: Option<String>, |
||||
|
encrypted_public_key: Option<String>, |
||||
|
encrypted_private_key: Option<String>, |
||||
|
} |
||||
|
|
||||
|
fn passkey_registration_prf_data( |
||||
|
client_supports_prf: bool, |
||||
|
encrypted_user_key: Option<String>, |
||||
|
encrypted_public_key: Option<String>, |
||||
|
encrypted_private_key: Option<String>, |
||||
|
) -> ApiResult<PasskeyRegistrationPrfData> { |
||||
|
for key in [&encrypted_user_key, &encrypted_public_key, &encrypted_private_key].into_iter().flatten() { |
||||
|
if !valid_wrapped_key(key) { |
||||
|
err!("Invalid wrapped passkey key") |
||||
|
} |
||||
|
} |
||||
|
let supports_prf = client_supports_prf; |
||||
|
let has_key_material = |
||||
|
encrypted_user_key.is_some() || encrypted_public_key.is_some() || encrypted_private_key.is_some(); |
||||
|
|
||||
|
if !supports_prf { |
||||
|
if has_key_material { |
||||
|
err!("Passkey does not support PRF") |
||||
|
} |
||||
|
return Ok(PasskeyRegistrationPrfData { |
||||
|
supports_prf: false, |
||||
|
encrypted_user_key: None, |
||||
|
encrypted_public_key: None, |
||||
|
encrypted_private_key: None, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
if !has_key_material { |
||||
|
return Ok(PasskeyRegistrationPrfData { |
||||
|
supports_prf: true, |
||||
|
encrypted_user_key: None, |
||||
|
encrypted_public_key: None, |
||||
|
encrypted_private_key: None, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
let Some(encrypted_user_key) = encrypted_user_key else { |
||||
|
err!("Encrypted user key is required") |
||||
|
}; |
||||
|
let Some(encrypted_public_key) = encrypted_public_key else { |
||||
|
err!("Encrypted public key is required") |
||||
|
}; |
||||
|
let Some(encrypted_private_key) = encrypted_private_key else { |
||||
|
err!("Encrypted private key is required") |
||||
|
}; |
||||
|
|
||||
|
Ok(PasskeyRegistrationPrfData { |
||||
|
supports_prf: true, |
||||
|
encrypted_user_key: Some(encrypted_user_key), |
||||
|
encrypted_public_key: Some(encrypted_public_key), |
||||
|
encrypted_private_key: Some(encrypted_private_key), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fn check_passkey_endpoint_preconditions(ip: &std::net::IpAddr, _action_verb: &str) -> ApiResult<()> { |
||||
|
crate::ratelimit::check_limit_login(ip)?; |
||||
|
if !account_passkeys_allowed() { |
||||
|
err!("Passkeys are not enabled") |
||||
|
} |
||||
|
Ok(()) |
||||
|
} |
||||
|
|
||||
|
#[post("/webauthn/attestation-options", data = "<data>")] |
||||
|
async fn post_api_webauthn_attestation_options( |
||||
|
data: Json<PasswordOrOtpData>, |
||||
|
headers: Headers, |
||||
|
conn: DbConn, |
||||
|
) -> JsonResult { |
||||
|
check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?; |
||||
|
|
||||
|
let data: PasswordOrOtpData = data.into_inner(); |
||||
|
let user = headers.user; |
||||
|
|
||||
|
data.validate(&user, true, &conn).await?; |
||||
|
|
||||
|
let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await?; |
||||
|
if passkey_count_limit_reached(all_creds.len()) { |
||||
|
err!("Maximum number of passkeys reached") |
||||
|
} |
||||
|
|
||||
|
let existing_cred_ids: Vec<_> = all_creds |
||||
|
.into_iter() |
||||
|
.filter_map(|wac| { |
||||
|
if let Ok(passkey) = serde_json::from_str::<Passkey>(&wac.credential) { |
||||
|
Some(passkey.cred_id().to_owned()) |
||||
|
} else { |
||||
|
warn!("Skipping an invalid stored passkey"); |
||||
|
None |
||||
|
} |
||||
|
}) |
||||
|
.collect(); |
||||
|
|
||||
|
let user_uuid = uuid::Uuid::parse_str(&user.uuid) |
||||
|
.map_err(|_| Error::new("Invalid user", "Could not parse user UUID for passkey registration"))?; |
||||
|
|
||||
|
let (mut challenge, state) = |
||||
|
WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; |
||||
|
|
||||
|
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { |
||||
|
asc.user_verification = UserVerificationPolicy::Required; |
||||
|
asc.require_resident_key = true; |
||||
|
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); |
||||
|
} |
||||
|
|
||||
|
let token = get_uuid(); |
||||
|
let saved_challenge = WebAuthnPasskeyRegistrationChallenge { |
||||
|
token: token.clone(), |
||||
|
created_at: Utc::now().timestamp(), |
||||
|
user_security_stamp: user.security_stamp.clone(), |
||||
|
device_uuid: headers.device.uuid.to_string(), |
||||
|
account_key_binding: PasskeyAccount::from_user(&user)?.binding(), |
||||
|
state, |
||||
|
}; |
||||
|
|
||||
|
PasskeyAccount::from_user(&user)? |
||||
|
.replace_challenge( |
||||
|
TwoFactor::new( |
||||
|
user.uuid.clone(), |
||||
|
TwoFactorType::WebauthnPasskeyRegisterChallenge, |
||||
|
serde_json::to_string(&saved_challenge)?, |
||||
|
), |
||||
|
&conn, |
||||
|
) |
||||
|
.await?; |
||||
|
|
||||
|
let mut options = serde_json::to_value(challenge.public_key)?; |
||||
|
options["status"] = "ok".into(); |
||||
|
options["errorMessage"] = "".into(); |
||||
|
|
||||
|
Ok(Json(json!({ |
||||
|
"options": options, |
||||
|
"token": token, |
||||
|
"object": "webauthnCredentialCreateOptions" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
#[post("/webauthn/assertion-options", data = "<data>")] |
||||
|
async fn post_api_webauthn_assertion_options( |
||||
|
data: Json<PasswordOrOtpData>, |
||||
|
headers: Headers, |
||||
|
conn: DbConn, |
||||
|
) -> JsonResult { |
||||
|
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?; |
||||
|
|
||||
|
let data: PasswordOrOtpData = data.into_inner(); |
||||
|
let user = headers.user; |
||||
|
|
||||
|
data.validate(&user, true, &conn).await?; |
||||
|
|
||||
|
let credentials: Vec<Passkey> = WebAuthnCredential::find_by_user(&user.uuid, &conn) |
||||
|
.await? |
||||
|
.into_iter() |
||||
|
.filter(|wac| wac.supports_prf) |
||||
|
.filter_map(|wac| { |
||||
|
if let Ok(passkey) = serde_json::from_str::<Passkey>(&wac.credential) { |
||||
|
Some(passkey) |
||||
|
} else { |
||||
|
warn!("Skipping an invalid stored passkey"); |
||||
|
None |
||||
|
} |
||||
|
}) |
||||
|
.collect(); |
||||
|
|
||||
|
if credentials.is_empty() { |
||||
|
err!("No PRF-capable passkeys registered") |
||||
|
} |
||||
|
|
||||
|
let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?; |
||||
|
|
||||
|
let token = get_uuid(); |
||||
|
let saved_challenge = WebAuthnPasskeyAssertionChallenge { |
||||
|
token: token.clone(), |
||||
|
created_at: Utc::now().timestamp(), |
||||
|
user_security_stamp: user.security_stamp.clone(), |
||||
|
device_uuid: headers.device.uuid.to_string(), |
||||
|
account_key_binding: PasskeyAccount::from_user(&user)?.binding(), |
||||
|
state, |
||||
|
}; |
||||
|
PasskeyAccount::from_user(&user)? |
||||
|
.replace_challenge( |
||||
|
TwoFactor::new( |
||||
|
user.uuid.clone(), |
||||
|
TwoFactorType::WebauthnPasskeyAssertionChallenge, |
||||
|
serde_json::to_string(&saved_challenge)?, |
||||
|
), |
||||
|
&conn, |
||||
|
) |
||||
|
.await?; |
||||
|
|
||||
|
Ok(Json(json!({ |
||||
|
"options": response.public_key, |
||||
|
"token": token, |
||||
|
"object": "webAuthnLoginAssertionOptions" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct WebAuthnLoginCredentialCreateRequest { |
||||
|
device_response: PasskeyRegisterPublicKeyCredentialCopy, |
||||
|
name: String, |
||||
|
token: Option<String>, |
||||
|
supports_prf: bool, |
||||
|
encrypted_user_key: Option<String>, |
||||
|
encrypted_public_key: Option<String>, |
||||
|
encrypted_private_key: Option<String>, |
||||
|
} |
||||
|
|
||||
|
#[post("/webauthn", data = "<data>")] |
||||
|
async fn post_api_webauthn( |
||||
|
data: Json<WebAuthnLoginCredentialCreateRequest>, |
||||
|
headers: Headers, |
||||
|
conn: DbConn, |
||||
|
nt: Notify<'_>, |
||||
|
) -> JsonResult { |
||||
|
check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?; |
||||
|
|
||||
|
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); |
||||
|
let user = headers.user; |
||||
|
|
||||
|
let Some(mut current_user) = User::find_by_uuid(&user.uuid, &conn).await else { |
||||
|
err!("User not found") |
||||
|
}; |
||||
|
|
||||
|
if passkey_count_limit_reached(WebAuthnCredential::find_by_user(¤t_user.uuid, &conn).await?.len()) { |
||||
|
err!("Maximum number of passkeys reached") |
||||
|
} |
||||
|
|
||||
|
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; |
||||
|
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else { |
||||
|
err!("No registration challenge found. Please try again.") |
||||
|
}; |
||||
|
let state = passkey_registration_challenge_state( |
||||
|
&tf.data, |
||||
|
data.token.as_deref(), |
||||
|
¤t_user.security_stamp, |
||||
|
&headers.device.uuid.to_string(), |
||||
|
&PasskeyAccount::from_user(¤t_user)?.binding(), |
||||
|
)?; |
||||
|
if !WebAuthnCredential::consume_challenge(tf, &conn).await? { |
||||
|
err!("No registration challenge found. Please try again.") |
||||
|
} |
||||
|
|
||||
|
let credential = WEBAUTHN |
||||
|
.finish_passkey_registration(&data.device_response.into(), &state) |
||||
|
.map_err(|_| Error::new_msg("Invalid passkey registration"))?; |
||||
|
let credential_id_hash = passkey_credential_id_hash(credential.cred_id().as_slice()); |
||||
|
let PasskeyRegistrationPrfData { |
||||
|
supports_prf, |
||||
|
encrypted_user_key, |
||||
|
encrypted_public_key, |
||||
|
encrypted_private_key, |
||||
|
} = passkey_registration_prf_data( |
||||
|
data.supports_prf, |
||||
|
data.encrypted_user_key, |
||||
|
data.encrypted_public_key, |
||||
|
data.encrypted_private_key, |
||||
|
)?; |
||||
|
|
||||
|
let account = PasskeyAccount::from_user(¤t_user)?; |
||||
|
if data.name.is_empty() || data.name.len() > 256 { |
||||
|
err!("Invalid passkey name") |
||||
|
} |
||||
|
let credential = WebAuthnCredential { |
||||
|
uuid: WebAuthnCredential::id(), |
||||
|
user_uuid: current_user.uuid.clone(), |
||||
|
name: data.name, |
||||
|
credential: serde_json::to_string(&credential)?, |
||||
|
credential_id_hash, |
||||
|
supports_prf, |
||||
|
encrypted_user_key, |
||||
|
encrypted_public_key, |
||||
|
encrypted_private_key, |
||||
|
}; |
||||
|
credential.insert(&account, &conn).await?; |
||||
|
|
||||
|
current_user.update_revision(&conn).await?; |
||||
|
nt.send_user_update(UpdateType::SyncVault, ¤t_user, headers.device.push_uuid.as_ref(), &conn).await; |
||||
|
|
||||
|
Ok(Json(credential_response(&credential))) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct WebAuthnLoginCredentialUpdateRequest { |
||||
|
device_response: PublicKeyCredentialCopy, |
||||
|
token: String, |
||||
|
encrypted_user_key: Option<String>, |
||||
|
encrypted_public_key: Option<String>, |
||||
|
encrypted_private_key: Option<String>, |
||||
|
} |
||||
|
|
||||
|
#[put("/webauthn", data = "<data>")] |
||||
|
async fn put_api_webauthn( |
||||
|
data: Json<WebAuthnLoginCredentialUpdateRequest>, |
||||
|
headers: Headers, |
||||
|
conn: DbConn, |
||||
|
nt: Notify<'_>, |
||||
|
) -> ApiResult<Status> { |
||||
|
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?; |
||||
|
|
||||
|
let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner(); |
||||
|
let user = headers.user; |
||||
|
|
||||
|
let Some(encrypted_user_key) = data.encrypted_user_key else { |
||||
|
err!("Encrypted user key is required") |
||||
|
}; |
||||
|
let Some(encrypted_public_key) = data.encrypted_public_key else { |
||||
|
err!("Encrypted public key is required") |
||||
|
}; |
||||
|
let Some(encrypted_private_key) = data.encrypted_private_key else { |
||||
|
err!("Encrypted private key is required") |
||||
|
}; |
||||
|
|
||||
|
let Some(mut current_user) = User::find_by_uuid(&user.uuid, &conn).await else { |
||||
|
err!("User not found") |
||||
|
}; |
||||
|
|
||||
|
let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32; |
||||
|
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else { |
||||
|
err!("No assertion challenge found. Please try again.") |
||||
|
}; |
||||
|
let state = passkey_assertion_challenge_state( |
||||
|
&tf.data, |
||||
|
&data.token, |
||||
|
¤t_user.security_stamp, |
||||
|
&headers.device.uuid.to_string(), |
||||
|
&PasskeyAccount::from_user(¤t_user)?.binding(), |
||||
|
)?; |
||||
|
if !WebAuthnCredential::consume_challenge(tf, &conn).await? { |
||||
|
err!("No assertion challenge found. Please try again.") |
||||
|
} |
||||
|
|
||||
|
for key in [&encrypted_user_key, &encrypted_public_key, &encrypted_private_key] { |
||||
|
if !valid_wrapped_key(key) { |
||||
|
err!("Invalid wrapped passkey key") |
||||
|
} |
||||
|
} |
||||
|
let credential_response = data.device_response.into(); |
||||
|
|
||||
|
let authentication_result = WEBAUTHN |
||||
|
.finish_passkey_authentication(&credential_response, &state) |
||||
|
.map_err(|_| Error::new_msg("Invalid passkey assertion"))?; |
||||
|
let credential_id_hash = passkey_credential_id_hash(authentication_result.cred_id().as_slice()); |
||||
|
let Some(mut matched_wac) = |
||||
|
WebAuthnCredential::find_by_user_and_credential_id_hash(¤t_user.uuid, &credential_id_hash, &conn).await? |
||||
|
else { |
||||
|
err!("Verified credential is not registered") |
||||
|
}; |
||||
|
|
||||
|
if !matched_wac.supports_prf { |
||||
|
err!("Passkey does not support PRF") |
||||
|
} |
||||
|
|
||||
|
let previous_credential = matched_wac.credential.clone(); |
||||
|
let mut passkey: Passkey = |
||||
|
serde_json::from_str(&previous_credential).map_err(|_| Error::new_msg("Invalid stored passkey"))?; |
||||
|
|
||||
|
let previous_counter = passkey_counter(&passkey); |
||||
|
let updated_credential = passkey.update_credential(&authentication_result) == Some(true); |
||||
|
let advanced_counter = updated_credential && authentication_result.counter() > previous_counter; |
||||
|
if advanced_counter { |
||||
|
matched_wac.credential = serde_json::to_string(&passkey)?; |
||||
|
} |
||||
|
matched_wac.encrypted_user_key = Some(encrypted_user_key); |
||||
|
matched_wac.encrypted_public_key = Some(encrypted_public_key); |
||||
|
matched_wac.encrypted_private_key = Some(encrypted_private_key); |
||||
|
matched_wac.update_prf(&PasskeyAccount::from_user(¤t_user)?, &previous_credential, &conn).await?; |
||||
|
|
||||
|
current_user.update_revision(&conn).await?; |
||||
|
nt.send_user_update(UpdateType::SyncVault, ¤t_user, headers.device.push_uuid.as_ref(), &conn).await; |
||||
|
|
||||
|
Ok(Status::Ok) |
||||
|
} |
||||
|
|
||||
|
#[post("/webauthn/<uuid>/delete", data = "<data>")] |
||||
|
async fn post_api_webauthn_delete( |
||||
|
data: Json<PasswordOrOtpData>, |
||||
|
uuid: String, |
||||
|
headers: Headers, |
||||
|
conn: DbConn, |
||||
|
nt: Notify<'_>, |
||||
|
) -> ApiResult<Status> { |
||||
|
crate::ratelimit::check_limit_login(&headers.ip.ip)?; |
||||
|
|
||||
|
let data: PasswordOrOtpData = data.into_inner(); |
||||
|
let mut user = headers.user; |
||||
|
|
||||
|
data.validate(&user, true, &conn).await?; |
||||
|
|
||||
|
let deleted = WebAuthnCredential::delete_by_uuid_and_user(&uuid, &user.uuid, &conn).await?; |
||||
|
if deleted { |
||||
|
user.update_revision(&conn).await?; |
||||
|
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; |
||||
|
} else { |
||||
|
debug!("post_api_webauthn_delete: credential {uuid} was not registered for user {}", user.uuid); |
||||
|
} |
||||
|
|
||||
|
Ok(Status::Ok) |
||||
|
} |
||||
|
|
||||
|
pub async fn sync_options(user: &User, conn: &DbConn) -> ApiResult<Vec<Value>> { |
||||
|
let mut options = Vec::new(); |
||||
|
if !account_passkeys_allowed() { |
||||
|
return Ok(options); |
||||
|
} |
||||
|
for row in WebAuthnCredential::find_by_user(&user.uuid, conn).await? { |
||||
|
if !row.has_prf_keyset() { |
||||
|
continue; |
||||
|
} |
||||
|
let Ok(passkey) = serde_json::from_str::<Passkey>(&row.credential) else { |
||||
|
warn!("Skipping an invalid stored passkey"); |
||||
|
continue; |
||||
|
}; |
||||
|
let credential: WebauthnCredentialData = passkey.into(); |
||||
|
options.push(json!({"credentialId": credential.cred_id, |
||||
|
"transports": credential.transports.unwrap_or_default(), "encryptedUserKey": row.encrypted_user_key, |
||||
|
"encryptedPrivateKey": row.encrypted_private_key})); |
||||
|
} |
||||
|
Ok(options) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
pub struct PasskeyUnlockData { |
||||
|
id: String, |
||||
|
encrypted_public_key: String, |
||||
|
encrypted_user_key: String, |
||||
|
} |
||||
|
|
||||
|
pub async fn prepare_rotation( |
||||
|
user: &User, |
||||
|
data: Vec<PasskeyUnlockData>, |
||||
|
conn: &DbConn, |
||||
|
) -> ApiResult<Vec<(WebAuthnCredential, String, String)>> { |
||||
|
let mut existing = WebAuthnCredential::find_by_user(&user.uuid, conn) |
||||
|
.await? |
||||
|
.into_iter() |
||||
|
.filter(WebAuthnCredential::has_prf_keyset) |
||||
|
.map(|row| (row.uuid.clone(), row)) |
||||
|
.collect::<std::collections::HashMap<_, _>>(); |
||||
|
let mut prepared = Vec::new(); |
||||
|
for item in data { |
||||
|
let Some(row) = existing.remove(&item.id) else { |
||||
|
err!("Unknown or duplicate passkey in key rotation") |
||||
|
}; |
||||
|
for key in [&item.encrypted_public_key, &item.encrypted_user_key] { |
||||
|
if !valid_wrapped_key(key) { |
||||
|
err!("Invalid wrapped passkey key") |
||||
|
} |
||||
|
} |
||||
|
prepared.push((row, item.encrypted_user_key, item.encrypted_public_key)); |
||||
|
} |
||||
|
if !existing.is_empty() { |
||||
|
err!("All enabled passkeys must be included in key rotation") |
||||
|
} |
||||
|
Ok(prepared) |
||||
|
} |
||||
|
|
||||
|
#[cfg(test)] |
||||
|
mod tests { |
||||
|
use super::valid_wrapped_key; |
||||
|
|
||||
|
#[test] |
||||
|
fn wrapped_key_wire_contract_preserves_supported_encstring_forms() { |
||||
|
let iv = data_encoding::BASE64.encode(&[0; 16]); |
||||
|
let ct = data_encoding::BASE64.encode(&[1; 32]); |
||||
|
let mac = data_encoding::BASE64.encode(&[2; 32]); |
||||
|
for value in [ |
||||
|
format!("0.{iv}|{ct}"), |
||||
|
format!("1.{iv}|{ct}|{mac}"), |
||||
|
format!("2.{iv}|{ct}|{mac}"), |
||||
|
format!("3.{ct}"), |
||||
|
format!("4.{ct}"), |
||||
|
format!("5.{ct}|{mac}"), |
||||
|
format!("6.{ct}|{mac}"), |
||||
|
format!("7.{ct}"), |
||||
|
format!("{iv}|{ct}"), |
||||
|
format!("{iv}|{ct}|{mac}"), |
||||
|
format!("AesCbc256_HmacSha256_B64.{iv}|{ct}|{mac}"), |
||||
|
// Native clients accept non-zero unused base64 padding bits.
|
||||
|
"0.AAECAwQFBgcICQoLDA0OD/==|lGD=".to_owned(), |
||||
|
] { |
||||
|
assert!(valid_wrapped_key(&value)); |
||||
|
} |
||||
|
for value in [ |
||||
|
String::new(), |
||||
|
"plaintext".to_owned(), |
||||
|
"9.AAAA".to_owned(), |
||||
|
"2.AA==|AA==|AA==".to_owned(), |
||||
|
format!("2.{iv}|{ct}|{mac}|{mac}"), |
||||
|
"4.!not-base64!".to_owned(), |
||||
|
format!("4.{}", "A".repeat(2000)), |
||||
|
] { |
||||
|
assert!(!valid_wrapped_key(&value)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,448 @@ |
|||||
|
// Native credential/keyset contract informed by Vaultwarden PR 7297 (AGPL-3.0),
|
||||
|
// branch snapshot at 727fead3a06eabf477ae9abed2ff0328acba7a6f.
|
||||
|
// Transactional lifecycle guards keep vault key rotation and passkey keys consistent.
|
||||
|
use super::{TwoFactor, User, UserId}; |
||||
|
use crate::{ |
||||
|
api::{ApiResult, EmptyResult}, |
||||
|
crypto, |
||||
|
db::{ |
||||
|
DbConn, DbConnInner, |
||||
|
schema::{twofactor, users, web_authn_credentials, web_authn_login_challenges}, |
||||
|
}, |
||||
|
error::Error, |
||||
|
util::get_uuid, |
||||
|
}; |
||||
|
use diesel::prelude::*; |
||||
|
|
||||
|
// Opaque encrypted fields and challenge data intentionally have no Debug implementation.
|
||||
|
#[derive(Clone, Queryable, Insertable)] |
||||
|
#[diesel(table_name = web_authn_credentials)] |
||||
|
pub struct WebAuthnCredential { |
||||
|
pub uuid: String, |
||||
|
pub user_uuid: UserId, |
||||
|
pub name: String, |
||||
|
pub credential: String, |
||||
|
pub credential_id_hash: String, |
||||
|
pub supports_prf: bool, |
||||
|
pub encrypted_user_key: Option<String>, |
||||
|
pub encrypted_public_key: Option<String>, |
||||
|
pub encrypted_private_key: Option<String>, |
||||
|
} |
||||
|
|
||||
|
#[derive(Clone)] |
||||
|
pub struct PasskeyAccount { |
||||
|
pub uuid: UserId, |
||||
|
stamp: String, |
||||
|
private_key: String, |
||||
|
} |
||||
|
impl PasskeyAccount { |
||||
|
pub fn from_user(user: &User) -> ApiResult<Self> { |
||||
|
let private_key = user |
||||
|
.private_key |
||||
|
.as_ref() |
||||
|
.filter(|v| !v.is_empty()) |
||||
|
.ok_or_else(|| Error::new_msg("Account keys are not initialized"))?; |
||||
|
Ok(Self { |
||||
|
uuid: user.uuid.clone(), |
||||
|
stamp: user.security_stamp.clone(), |
||||
|
private_key: private_key.clone(), |
||||
|
}) |
||||
|
} |
||||
|
pub fn binding(&self) -> String { |
||||
|
crypto::sha256_hex(self.private_key.as_bytes()) |
||||
|
} |
||||
|
// No-op write serializes per-account credential changes on every backend.
|
||||
|
// Read the condition back instead of relying on MySQL's changed-row count.
|
||||
|
fn lock(&self, conn: &mut DbConnInner) -> EmptyResult { |
||||
|
diesel::update(users::table.filter(users::uuid.eq(&self.uuid))) |
||||
|
.set(users::uuid.eq(&self.uuid)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
let count = users::table |
||||
|
.filter(users::uuid.eq(&self.uuid)) |
||||
|
.filter(users::enabled.eq(true)) |
||||
|
.filter(users::security_stamp.eq(&self.stamp)) |
||||
|
.filter(users::private_key.eq(&self.private_key)) |
||||
|
.count() |
||||
|
.get_result::<i64>(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
if count != 1 { |
||||
|
return Err(Error::new_msg("Account changed. Sync and try again.")); |
||||
|
} |
||||
|
Ok(()) |
||||
|
} |
||||
|
pub async fn lock_for_rotation(&self, conn: &DbConn) -> EmptyResult { |
||||
|
let account = self.clone(); |
||||
|
conn.run(move |conn| account.lock(conn)).await |
||||
|
} |
||||
|
pub async fn replace_challenge(&self, row: TwoFactor, conn: &DbConn) -> EmptyResult { |
||||
|
let account = self.clone(); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
account.lock(conn)?; |
||||
|
diesel::delete( |
||||
|
twofactor::table |
||||
|
.filter(twofactor::user_uuid.eq(&account.uuid)) |
||||
|
.filter(twofactor::atype.eq(row.atype)), |
||||
|
) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
diesel::insert_into(twofactor::table).values(row).execute(conn).map_err(|_| unavailable())?; |
||||
|
Ok(()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
} |
||||
|
fn unavailable() -> Error { |
||||
|
Error::new_msg("Passkey storage unavailable") |
||||
|
} |
||||
|
fn concurrent() -> Error { |
||||
|
Error::new_msg("Passkey changed. Sync and try again.") |
||||
|
} |
||||
|
|
||||
|
impl WebAuthnCredential { |
||||
|
pub fn same_login_epoch(expected_credential: &Self, expected_user: &User, credential: &Self, user: &User) -> bool { |
||||
|
expected_credential.uuid == credential.uuid |
||||
|
&& expected_credential.user_uuid == credential.user_uuid |
||||
|
&& expected_credential.credential == credential.credential |
||||
|
&& expected_credential.supports_prf == credential.supports_prf |
||||
|
&& expected_credential.encrypted_user_key == credential.encrypted_user_key |
||||
|
&& expected_credential.encrypted_public_key == credential.encrypted_public_key |
||||
|
&& expected_credential.encrypted_private_key == credential.encrypted_private_key |
||||
|
&& expected_user.uuid == user.uuid |
||||
|
&& expected_user.enabled == user.enabled |
||||
|
&& expected_user.security_stamp == user.security_stamp |
||||
|
&& expected_user.email == user.email |
||||
|
&& expected_user.akey == user.akey |
||||
|
&& expected_user.private_key == user.private_key |
||||
|
&& expected_user.public_key == user.public_key |
||||
|
&& expected_user.password_hash == user.password_hash |
||||
|
&& expected_user.client_kdf_type == user.client_kdf_type |
||||
|
&& expected_user.client_kdf_iter == user.client_kdf_iter |
||||
|
&& expected_user.client_kdf_memory == user.client_kdf_memory |
||||
|
&& expected_user.client_kdf_parallelism == user.client_kdf_parallelism |
||||
|
} |
||||
|
|
||||
|
pub fn has_prf_keyset(&self) -> bool { |
||||
|
self.supports_prf |
||||
|
&& [&self.encrypted_user_key, &self.encrypted_public_key, &self.encrypted_private_key] |
||||
|
.iter() |
||||
|
.all(|v| v.as_ref().is_some_and(|v| !v.is_empty())) |
||||
|
} |
||||
|
pub fn prf_status(&self) -> i32 { |
||||
|
if self.supports_prf { |
||||
|
i32::from(!self.has_prf_keyset()) |
||||
|
} else { |
||||
|
2 |
||||
|
} |
||||
|
} |
||||
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> ApiResult<Vec<Self>> { |
||||
|
conn.run(move |conn| { |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::user_uuid.eq(user_uuid)) |
||||
|
.order(web_authn_credentials::uuid.asc()) |
||||
|
.load(conn) |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
// A single statement gives the credential wrappers and account keys one
|
||||
|
// database snapshot, including while key rotation is committing.
|
||||
|
fn load_login_account(hash: &str, conn: &mut DbConnInner) -> ApiResult<Option<(Self, User)>> { |
||||
|
web_authn_credentials::table |
||||
|
.inner_join(users::table.on(users::uuid.eq(web_authn_credentials::user_uuid))) |
||||
|
.filter(web_authn_credentials::credential_id_hash.eq(hash)) |
||||
|
.select((web_authn_credentials::all_columns, users::all_columns)) |
||||
|
.first(conn) |
||||
|
.optional() |
||||
|
.map_err(|_| unavailable()) |
||||
|
} |
||||
|
pub async fn find_login_account(hash: &str, conn: &DbConn) -> ApiResult<Option<(Self, User)>> { |
||||
|
conn.run(move |conn| Self::load_login_account(hash, conn)).await |
||||
|
} |
||||
|
pub async fn update_authentication(&self, previous_credential: &str, conn: &DbConn) -> EmptyResult { |
||||
|
let row = self.clone(); |
||||
|
let previous = previous_credential.to_owned(); |
||||
|
conn.run(move |conn| { |
||||
|
let changed = diesel::update( |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::uuid.eq(&row.uuid)) |
||||
|
.filter(web_authn_credentials::user_uuid.eq(&row.user_uuid)) |
||||
|
.filter(web_authn_credentials::credential.eq(previous)), |
||||
|
) |
||||
|
.set(web_authn_credentials::credential.eq(row.credential)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
if changed != 1 { |
||||
|
return Err(concurrent()); |
||||
|
} |
||||
|
Ok(()) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn find_by_user_and_credential_id_hash( |
||||
|
user_uuid: &UserId, |
||||
|
hash: &str, |
||||
|
conn: &DbConn, |
||||
|
) -> ApiResult<Option<Self>> { |
||||
|
conn.run(move |conn| { |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::user_uuid.eq(user_uuid)) |
||||
|
.filter(web_authn_credentials::credential_id_hash.eq(hash)) |
||||
|
.first(conn) |
||||
|
.optional() |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn insert(&self, account: &PasskeyAccount, conn: &DbConn) -> EmptyResult { |
||||
|
let row = self.clone(); |
||||
|
let account = account.clone(); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
account.lock(conn)?; |
||||
|
let count = web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::user_uuid.eq(&account.uuid)) |
||||
|
.count() |
||||
|
.get_result::<i64>(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
if count >= 5 { |
||||
|
return Err(Error::new_msg("Maximum number of passkeys reached")); |
||||
|
} |
||||
|
diesel::insert_into(web_authn_credentials::table).values(row).execute(conn).map_err( |
||||
|
|error| match error { |
||||
|
diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => { |
||||
|
Error::new_msg("Passkey is already registered") |
||||
|
} |
||||
|
_ => unavailable(), |
||||
|
}, |
||||
|
)?; |
||||
|
Ok(()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn update_prf(&self, account: &PasskeyAccount, previous_credential: &str, conn: &DbConn) -> EmptyResult { |
||||
|
let row = self.clone(); |
||||
|
let account = account.clone(); |
||||
|
let previous = previous_credential.to_owned(); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
account.lock(conn)?; |
||||
|
let target = || { |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::uuid.eq(&row.uuid)) |
||||
|
.filter(web_authn_credentials::user_uuid.eq(&account.uuid)) |
||||
|
.filter(web_authn_credentials::credential.eq(&previous)) |
||||
|
}; |
||||
|
if target().count().get_result::<i64>(conn).map_err(|_| unavailable())? != 1 { |
||||
|
return Err(concurrent()); |
||||
|
} |
||||
|
diesel::update(target()) |
||||
|
.set(( |
||||
|
web_authn_credentials::credential.eq(row.credential), |
||||
|
web_authn_credentials::encrypted_user_key.eq(row.encrypted_user_key), |
||||
|
web_authn_credentials::encrypted_public_key.eq(row.encrypted_public_key), |
||||
|
web_authn_credentials::encrypted_private_key.eq(row.encrypted_private_key), |
||||
|
)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
Ok(()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn rewrap_all(account: &PasskeyAccount, rows: Vec<(Self, String, String)>, conn: &DbConn) -> EmptyResult { |
||||
|
if rows.is_empty() { |
||||
|
return Ok(()); |
||||
|
} |
||||
|
let account = account.clone(); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
account.lock(conn)?; |
||||
|
for (old, encrypted_user_key, encrypted_public_key) in rows { |
||||
|
let target = || { |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::uuid.eq(&old.uuid)) |
||||
|
.filter(web_authn_credentials::user_uuid.eq(&account.uuid)) |
||||
|
.filter(web_authn_credentials::credential.eq(&old.credential)) |
||||
|
.filter(web_authn_credentials::encrypted_private_key.eq(&old.encrypted_private_key)) |
||||
|
}; |
||||
|
if target().count().get_result::<i64>(conn).map_err(|_| unavailable())? != 1 { |
||||
|
return Err(concurrent()); |
||||
|
} |
||||
|
diesel::update(target()) |
||||
|
.set(( |
||||
|
web_authn_credentials::encrypted_user_key.eq(encrypted_user_key), |
||||
|
web_authn_credentials::encrypted_public_key.eq(encrypted_public_key), |
||||
|
)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
} |
||||
|
Ok(()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
|
||||
|
pub async fn delete_by_uuid_and_user(uuid: &str, user_uuid: &UserId, conn: &DbConn) -> ApiResult<bool> { |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
diesel::update(users::table.filter(users::uuid.eq(user_uuid))) |
||||
|
.set(users::uuid.eq(user_uuid)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
diesel::delete( |
||||
|
web_authn_credentials::table |
||||
|
.filter(web_authn_credentials::uuid.eq(uuid)) |
||||
|
.filter(web_authn_credentials::user_uuid.eq(user_uuid)), |
||||
|
) |
||||
|
.execute(conn) |
||||
|
.map(|n| n == 1) |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
diesel::update(users::table.filter(users::uuid.eq(user_uuid))) |
||||
|
.set(users::uuid.eq(user_uuid)) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
diesel::delete(web_authn_credentials::table.filter(web_authn_credentials::user_uuid.eq(user_uuid))) |
||||
|
.execute(conn) |
||||
|
.map(|_| ()) |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn consume_challenge(row: TwoFactor, conn: &DbConn) -> ApiResult<bool> { |
||||
|
conn.run(move |conn| { |
||||
|
diesel::delete( |
||||
|
twofactor::table |
||||
|
.filter(twofactor::uuid.eq(row.uuid)) |
||||
|
.filter(twofactor::user_uuid.eq(row.user_uuid)) |
||||
|
.filter(twofactor::data.eq(row.data)), |
||||
|
) |
||||
|
.execute(conn) |
||||
|
.map(|n| n == 1) |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub fn id() -> String { |
||||
|
get_uuid() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Anonymous WebAuthn state is stored server-side so every assertion consumes one challenge.
|
||||
|
// This is required for synced authenticators whose signature counter stays at zero.
|
||||
|
pub struct WebAuthnLoginChallenge; |
||||
|
impl WebAuthnLoginChallenge { |
||||
|
pub async fn create(token: &str, state: String, created_at: i64, conn: &DbConn) -> EmptyResult { |
||||
|
let token_hash = crypto::sha256_hex(token.as_bytes()); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
diesel::delete( |
||||
|
web_authn_login_challenges::table |
||||
|
.filter(web_authn_login_challenges::created_at.lt(created_at.saturating_sub(300))), |
||||
|
) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
diesel::insert_into(web_authn_login_challenges::table) |
||||
|
.values(( |
||||
|
web_authn_login_challenges::token_hash.eq(token_hash), |
||||
|
web_authn_login_challenges::state.eq(state), |
||||
|
web_authn_login_challenges::created_at.eq(created_at), |
||||
|
)) |
||||
|
.execute(conn) |
||||
|
.map(|_| ()) |
||||
|
.map_err(|_| unavailable()) |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
pub async fn consume(token: &str, now: i64, conn: &DbConn) -> ApiResult<Option<String>> { |
||||
|
let token_hash = crypto::sha256_hex(token.as_bytes()); |
||||
|
conn.run(move |conn| { |
||||
|
conn.transaction::<_, Error, _>(|conn| { |
||||
|
let row: Option<(String, i64)> = web_authn_login_challenges::table |
||||
|
.filter(web_authn_login_challenges::token_hash.eq(&token_hash)) |
||||
|
.select((web_authn_login_challenges::state, web_authn_login_challenges::created_at)) |
||||
|
.first(conn) |
||||
|
.optional() |
||||
|
.map_err(|_| unavailable())?; |
||||
|
let deleted = diesel::delete( |
||||
|
web_authn_login_challenges::table.filter(web_authn_login_challenges::token_hash.eq(token_hash)), |
||||
|
) |
||||
|
.execute(conn) |
||||
|
.map_err(|_| unavailable())?; |
||||
|
match row { |
||||
|
Some((state, created)) if deleted == 1 && created <= now + 30 && created >= now - 300 => { |
||||
|
Ok(Some(state)) |
||||
|
} |
||||
|
_ => Ok(None), |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
|
.await |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[cfg(all(test, sqlite))] |
||||
|
mod login_snapshot_tests { |
||||
|
use super::*; |
||||
|
use diesel::{Connection, connection::SimpleConnection, sqlite::SqliteConnection}; |
||||
|
use diesel_migrations::MigrationHarness; |
||||
|
|
||||
|
#[test] |
||||
|
fn login_snapshot_never_mixes_key_rotation_epochs_and_observes_deletion() { |
||||
|
let path = std::env::temp_dir().join(format!("vw-passkey-login-snapshot-{}.sqlite3", get_uuid())); |
||||
|
let db_url = path.to_str().unwrap(); |
||||
|
let mut migration_conn = SqliteConnection::establish(db_url).unwrap(); |
||||
|
migration_conn.run_pending_migrations(crate::db::sqlite_migrations::MIGRATIONS).unwrap(); |
||||
|
migration_conn.batch_execute("PRAGMA journal_mode=WAL;").unwrap(); |
||||
|
drop(migration_conn); |
||||
|
let mut reader = DbConnInner::Sqlite(SqliteConnection::establish(db_url).unwrap()); |
||||
|
let mut writer = DbConnInner::Sqlite(SqliteConnection::establish(db_url).unwrap()); |
||||
|
let user = get_uuid(); |
||||
|
reader.batch_execute(&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 ('{user}',1,datetime('now'),datetime('now'),0,'user@example.test','Synthetic user',X'00',zeroblob(64),1000,'old-account-wrapper','old-private-wrapper','old-stamp','[]','[]',0,600000); INSERT INTO web_authn_credentials (uuid,user_uuid,name,credential,credential_id_hash,supports_prf,encrypted_user_key,encrypted_public_key,encrypted_private_key) VALUES ('{}','{user}','Synthetic passkey','old-credential','fingerprint',1,'old-user-wrapper','old-public-wrapper','old-passkey-private-wrapper');", |
||||
|
get_uuid() |
||||
|
)).unwrap(); |
||||
|
let (before_credential, before_user) = |
||||
|
WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap(); |
||||
|
assert_eq!(before_credential.encrypted_user_key.as_deref(), Some("old-user-wrapper")); |
||||
|
assert_eq!(before_user.akey, "old-account-wrapper"); |
||||
|
|
||||
|
writer.transaction::<_, diesel::result::Error, _>(|db| { |
||||
|
db.batch_execute("UPDATE users SET akey='new-account-wrapper', private_key='new-private-wrapper', security_stamp='new-stamp' WHERE email='user@example.test'; UPDATE web_authn_credentials SET encrypted_user_key='new-user-wrapper', encrypted_public_key='new-public-wrapper' WHERE credential_id_hash='fingerprint';")?; |
||||
|
// The writer has changed both rows but has not committed. A login
|
||||
|
// statement sees one committed epoch, never one row from each.
|
||||
|
let (credential, user) = WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap(); |
||||
|
assert_eq!(credential.encrypted_user_key.as_deref(), Some("old-user-wrapper")); |
||||
|
assert_eq!(user.akey, "old-account-wrapper"); |
||||
|
Ok(()) |
||||
|
}).unwrap(); |
||||
|
|
||||
|
let (after_credential, after_user) = |
||||
|
WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap(); |
||||
|
assert_eq!(after_credential.encrypted_user_key.as_deref(), Some("new-user-wrapper")); |
||||
|
assert_eq!(after_user.akey, "new-account-wrapper"); |
||||
|
assert!(!WebAuthnCredential::same_login_epoch( |
||||
|
&before_credential, |
||||
|
&before_user, |
||||
|
&after_credential, |
||||
|
&after_user |
||||
|
)); |
||||
|
writer.batch_execute("DELETE FROM web_authn_credentials WHERE credential_id_hash='fingerprint';").unwrap(); |
||||
|
assert!(WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().is_none()); |
||||
|
drop(reader); |
||||
|
drop(writer); |
||||
|
std::fs::remove_file(path).unwrap(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
# Passkey regression tests |
||||
|
|
||||
|
`passkey_unlock.rs` launches the real Vaultwarden binary on loopback with a |
||||
|
temporary database, synthetic accounts, signed WebAuthn assertions and encrypted |
||||
|
key fixtures. No identity provider or authenticator hardware is contacted. The |
||||
|
tests do not establish hardware PRF support or stock-client decryption. |
||||
|
|
||||
|
Run the portable HTTP cases with SQLite: |
||||
|
|
||||
|
```sh |
||||
|
cargo test --locked --features sqlite --test passkey_unlock |
||||
|
``` |
||||
|
|
||||
|
The PostgreSQL tests require a disposable local PostgreSQL instance. They create |
||||
|
and drop their own `vw_passkey_*` databases. The fixture deliberately accepts |
||||
|
only loopback, the `postgres` maintenance database, and the synthetic credentials |
||||
|
shown below. The test role needs permission to create databases; never use a |
||||
|
production instance or credentials. |
||||
|
|
||||
|
```sh |
||||
|
VW_PASSKEY_TEST_DATABASE_URL='postgresql://passkey_test:synthetic-passkey-test@127.0.0.1:15432/postgres' \ |
||||
|
cargo test --locked --features sqlite,postgresql --test passkey_unlock \ |
||||
|
-- --include-ignored --test-threads=1 |
||||
|
``` |
||||
|
|
||||
|
The PostgreSQL-only concurrency and interruption cases are marked `ignore` |
||||
|
because they need that instance. A default test run does not cover them. |
||||
|
|
||||
|
Before enabling passkeys for real vaults, separately test compatible clients and |
||||
|
authenticators: enrollment, fresh sign-in, PRF decryption of existing personal |
||||
|
and shared items, master-password fallback, multiple passkeys, removal, and |
||||
|
password/KDF/account-key changes. For SSO-only installations, verify SSO is still |
||||
|
required. Keep backups and a working fallback throughout these checks. |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue