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.
306 lines
11 KiB
306 lines
11 KiB
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
|
use derive_more::{AsRef, Deref, Display, From};
|
|
use diesel::prelude::*;
|
|
use macros::UuidFromParam;
|
|
|
|
use crate::api::EmptyResult;
|
|
use crate::db::schema::{web_authn_credentials, web_authn_login_challenges};
|
|
use crate::db::{DbConn, DbPool};
|
|
use crate::error::MapResult;
|
|
use crate::util::get_uuid;
|
|
|
|
use super::UserId;
|
|
|
|
/// How long a pending passkey-login challenge stays valid before it is rejected.
|
|
const WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS: i64 = 300;
|
|
|
|
#[derive(Debug, Identifiable, Queryable, Insertable)]
|
|
#[diesel(table_name = web_authn_credentials)]
|
|
#[diesel(primary_key(uuid))]
|
|
pub struct WebAuthnCredential {
|
|
pub uuid: WebAuthnCredentialId,
|
|
pub user_uuid: UserId,
|
|
pub name: String,
|
|
pub credential: String,
|
|
pub supports_prf: bool,
|
|
pub encrypted_user_key: Option<String>,
|
|
pub encrypted_public_key: Option<String>,
|
|
pub encrypted_private_key: Option<String>,
|
|
}
|
|
|
|
impl WebAuthnCredential {
|
|
pub fn new(
|
|
user_uuid: UserId,
|
|
name: String,
|
|
credential: String,
|
|
supports_prf: bool,
|
|
encrypted_user_key: Option<String>,
|
|
encrypted_public_key: Option<String>,
|
|
encrypted_private_key: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
uuid: WebAuthnCredentialId(get_uuid()),
|
|
user_uuid,
|
|
name,
|
|
credential,
|
|
supports_prf,
|
|
encrypted_user_key,
|
|
encrypted_public_key,
|
|
encrypted_private_key,
|
|
}
|
|
}
|
|
|
|
/// Whether this credential carries a complete PRF "rotateable key set",
|
|
/// i.e. passwordless vault decryption is fully enabled for it.
|
|
pub fn has_prf_keyset(&self) -> bool {
|
|
self.supports_prf
|
|
&& self.encrypted_user_key.is_some()
|
|
&& self.encrypted_public_key.is_some()
|
|
&& self.encrypted_private_key.is_some()
|
|
}
|
|
|
|
/// Bitwarden `WebAuthnPrfStatus`: 0 = Enabled, 1 = Supported, 2 = Unsupported.
|
|
/// Mirrors `WebAuthnCredential.GetPrfStatus()` in the upstream Bitwarden server.
|
|
pub fn prf_status(&self) -> i32 {
|
|
match (self.supports_prf, self.has_prf_keyset()) {
|
|
(false, _) => 2, // Unsupported
|
|
(true, true) => 0, // Enabled
|
|
(true, false) => 1, // Supported
|
|
}
|
|
}
|
|
|
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn: {
|
|
diesel::insert_into(web_authn_credentials::table)
|
|
.values(self)
|
|
.execute(conn)
|
|
.map_res("Error saving web_authn_credential")
|
|
}}
|
|
}
|
|
|
|
/// Persist the serialized passkey blob after a successful assertion advances
|
|
/// its signature counter. Touches only the `credential` column so a concurrent
|
|
/// key rotation cannot clobber it (see [`Self::update_keys`]).
|
|
pub async fn update_credential(&self, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn: {
|
|
diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid)))
|
|
.set(web_authn_credentials::credential.eq(&self.credential))
|
|
.execute(conn)
|
|
.map_res("Error updating web_authn_credential signature counter")
|
|
}}
|
|
}
|
|
|
|
/// Persist the PRF unlock blobs that the rotation flow re-encrypts under the
|
|
/// new account key. Touches only the two columns that key rotation actually
|
|
/// changes, so it cannot clobber a concurrent signature-counter advance (see
|
|
/// [`Self::update_credential`]) nor the enrollment-time `encrypted_private_key`.
|
|
pub async fn update_keys(&self, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn: {
|
|
diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid)))
|
|
.set((
|
|
web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key),
|
|
web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key),
|
|
))
|
|
.execute(conn)
|
|
.map_res("Error updating web_authn_credential keys")
|
|
}}
|
|
}
|
|
|
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
|
db_run! { conn: {
|
|
web_authn_credentials::table
|
|
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
|
|
.load::<Self>(conn)
|
|
.expect("Error loading web_authn_credentials")
|
|
}}
|
|
}
|
|
|
|
pub async fn find_by_uuid_and_user(uuid: &WebAuthnCredentialId, user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
|
db_run! { conn: {
|
|
web_authn_credentials::table
|
|
.filter(web_authn_credentials::uuid.eq(uuid))
|
|
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
|
|
.first::<Self>(conn)
|
|
.ok()
|
|
}}
|
|
}
|
|
|
|
pub async fn delete_by_uuid_and_user(
|
|
uuid: &WebAuthnCredentialId,
|
|
user_uuid: &UserId,
|
|
conn: &DbConn,
|
|
) -> EmptyResult {
|
|
db_run! { conn: {
|
|
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_res("Error removing web_authn_credential")
|
|
}}
|
|
}
|
|
|
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn: {
|
|
diesel::delete(web_authn_credentials::table.filter(web_authn_credentials::user_uuid.eq(user_uuid)))
|
|
.execute(conn)
|
|
.map_res("Error deleting all web_authn_credentials for user")
|
|
}}
|
|
}
|
|
}
|
|
|
|
/// A pending passkey-login (discoverable credential) authentication challenge.
|
|
///
|
|
/// The login ceremony begins before the user is known, so the challenge state
|
|
/// cannot be tied to a `twofactor` row. It is persisted here keyed by a random
|
|
/// single-use token and consumed exactly once by [`WebAuthnLoginChallenge::take`].
|
|
#[derive(Debug, Queryable, Insertable)]
|
|
#[diesel(table_name = web_authn_login_challenges)]
|
|
pub struct WebAuthnLoginChallenge {
|
|
pub id: WebAuthnLoginChallengeId,
|
|
pub challenge: String,
|
|
pub created_at: NaiveDateTime,
|
|
}
|
|
|
|
impl WebAuthnLoginChallenge {
|
|
pub fn new(challenge: String) -> Self {
|
|
Self {
|
|
id: WebAuthnLoginChallengeId(get_uuid()),
|
|
challenge,
|
|
created_at: Utc::now().naive_utc(),
|
|
}
|
|
}
|
|
|
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
|
db_run! { conn: {
|
|
diesel::insert_into(web_authn_login_challenges::table)
|
|
.values(self)
|
|
.execute(conn)
|
|
.map_res("Error saving web_authn_login_challenge")
|
|
}}
|
|
}
|
|
|
|
/// Fetch and delete a pending challenge (single-use). Returns `None` when the
|
|
/// token is unknown, has already been consumed, or the challenge has expired.
|
|
pub async fn take(id: &WebAuthnLoginChallengeId, conn: &DbConn) -> Option<Self> {
|
|
db_run! { conn: {
|
|
// Single-use: the SELECT and DELETE run in one transaction so the row
|
|
// is read and removed atomically. Only the request whose DELETE
|
|
// removes the row (deleted == 1) may use the challenge; a concurrent
|
|
// request deletes 0 rows and gets `None`. A DB error aborts the
|
|
// transaction, leaving the challenge intact rather than silently
|
|
// treating it as consumed.
|
|
let taken = conn
|
|
.transaction::<Option<WebAuthnLoginChallenge>, diesel::result::Error, _>(|conn| {
|
|
let challenge = web_authn_login_challenges::table
|
|
.filter(web_authn_login_challenges::id.eq(id))
|
|
.first::<WebAuthnLoginChallenge>(conn)
|
|
.optional()?;
|
|
let deleted = diesel::delete(
|
|
web_authn_login_challenges::table.filter(web_authn_login_challenges::id.eq(id)),
|
|
)
|
|
.execute(conn)?;
|
|
Ok(challenge.filter(|_| deleted == 1))
|
|
})
|
|
.unwrap_or(None);
|
|
|
|
let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS);
|
|
taken.filter(|c| c.created_at >= cutoff)
|
|
}}
|
|
}
|
|
|
|
/// Scheduled cleanup of challenges that were started but never consumed.
|
|
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
|
|
debug!("Purging expired web_authn_login_challenges");
|
|
if let Ok(conn) = pool.get().await {
|
|
let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS);
|
|
db_run! { conn: {
|
|
diesel::delete(web_authn_login_challenges::table.filter(web_authn_login_challenges::created_at.lt(cutoff)))
|
|
.execute(conn)
|
|
.map_res("Error deleting expired web_authn_login_challenges")
|
|
}}
|
|
} else {
|
|
err!("Failed to get DB connection while purging expired web_authn_login_challenges")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Clone,
|
|
Debug,
|
|
AsRef,
|
|
Deref,
|
|
DieselNewType,
|
|
Display,
|
|
From,
|
|
FromForm,
|
|
Hash,
|
|
PartialEq,
|
|
Eq,
|
|
Serialize,
|
|
Deserialize,
|
|
UuidFromParam,
|
|
)]
|
|
pub struct WebAuthnCredentialId(String);
|
|
|
|
#[derive(
|
|
Clone,
|
|
Debug,
|
|
AsRef,
|
|
Deref,
|
|
DieselNewType,
|
|
Display,
|
|
From,
|
|
FromForm,
|
|
Hash,
|
|
PartialEq,
|
|
Eq,
|
|
Serialize,
|
|
Deserialize,
|
|
UuidFromParam,
|
|
)]
|
|
pub struct WebAuthnLoginChallengeId(String);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn cred(
|
|
supports_prf: bool,
|
|
user_key: Option<&str>,
|
|
pub_key: Option<&str>,
|
|
priv_key: Option<&str>,
|
|
) -> WebAuthnCredential {
|
|
WebAuthnCredential::new(
|
|
UserId::from(String::from("00000000-0000-0000-0000-000000000000")),
|
|
String::from("test"),
|
|
String::from("{}"),
|
|
supports_prf,
|
|
user_key.map(String::from),
|
|
pub_key.map(String::from),
|
|
priv_key.map(String::from),
|
|
)
|
|
}
|
|
|
|
// Bitwarden WebAuthnPrfStatus: Enabled = 0, Supported = 1, Unsupported = 2.
|
|
#[test]
|
|
fn prf_status_unsupported_when_authenticator_has_no_prf() {
|
|
assert_eq!(cred(false, None, None, None).prf_status(), 2);
|
|
// No PRF support means Unsupported even if blobs are somehow present.
|
|
assert_eq!(cred(false, Some("u"), Some("p"), Some("k")).prf_status(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn prf_status_supported_when_prf_capable_but_keyset_incomplete() {
|
|
assert_eq!(cred(true, None, None, None).prf_status(), 1);
|
|
assert_eq!(cred(true, Some("u"), Some("p"), None).prf_status(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn prf_status_enabled_only_with_a_complete_keyset() {
|
|
let c = cred(true, Some("u"), Some("p"), Some("k"));
|
|
assert!(c.has_prf_keyset());
|
|
assert_eq!(c.prf_status(), 0);
|
|
}
|
|
}
|
|
|