Skip to content

Commit 3f44304

Browse files
authored
feat: Address webauthn counter tracking (#519)
The webauthn standart requires tracking of the passkey counter to prevent replay attacks. Fixes: #341
1 parent c942e23 commit 3f44304

File tree

17 files changed

+492
-82
lines changed

17 files changed

+492
-82
lines changed

src/bin/keystone.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ async fn main() -> Result<(), Report> {
134134
let webauthn_openapi = webauthn::api::openapi_router();
135135
let (main_router, main_api) = api::openapi_router().split_for_parts();
136136
openapi.merge(main_api);
137-
openapi.merge(webauthn_openapi.into_openapi());
137+
openapi = openapi.nest("/v4", webauthn_openapi.into_openapi());
138138

139139
if let Some(dump_format) = &args.dump_openapi {
140140
println!(
@@ -218,7 +218,7 @@ async fn main() -> Result<(), Report> {
218218
let webauthn_extension = webauthn::api::init_extension(shared_state.clone())?;
219219
let app = Router::new()
220220
.merge(main_router.with_state(shared_state.clone()))
221-
.merge(webauthn_extension)
221+
.nest("/v4", webauthn_extension)
222222
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi))
223223
.layer(middleware);
224224

src/db/entity/webauthn_credential.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ pub struct Model {
1010
pub user_id: String,
1111
pub credential_id: String,
1212
pub description: Option<String>,
13+
#[sea_orm(column_type = "Text")]
1314
pub passkey: String,
15+
pub counter: i32,
1416
pub r#type: String,
1517
pub aaguid: Option<String>,
1618
pub created_at: DateTime,

src/db_migration/m20250301_000001_passkey.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ impl MigrationTrait for Migration {
3333
.col(string_len(WebauthnCredential::CredentialId, 1024))
3434
.col(string_len(WebauthnCredential::Description, 64))
3535
.col(text(WebauthnCredential::Passkey))
36+
.col(unsigned(WebauthnCredential::Counter))
3637
.col(string_len(WebauthnCredential::Type, 25))
3738
.col(string_len_null(WebauthnCredential::Aaguid, 36))
3839
.col(date_time(WebauthnCredential::CreatedAt))
@@ -93,6 +94,7 @@ enum WebauthnCredential {
9394
CredentialId,
9495
Description,
9596
Passkey,
97+
Counter,
9698
Type,
9799
Aaguid,
98100
CreatedAt,

src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub enum DatabaseError {
195195
},
196196

197197
/// Database error.
198-
#[error("Database error while {context}")]
198+
#[error("Database error {source} while {context}")]
199199
Database {
200200
/// The source of the error.
201201
source: sea_orm::DbErr,

src/webauthn/api/auth/finish.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
//! # Finish passkey authentication process
1616
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
17+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
18+
use chrono::Utc;
1719
use tracing::debug;
1820
use validator::Validate;
1921

@@ -25,7 +27,7 @@ use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo};
2527
use crate::identity::IdentityApi;
2628
use crate::token::TokenApi;
2729
use crate::webauthn::{
28-
WebauthnApi,
30+
WebauthnApi, WebauthnError,
2931
api::types::{CombinedExtensionState, auth::*},
3032
};
3133

@@ -72,8 +74,50 @@ pub async fn finish(
7274
.webauthn
7375
.finish_passkey_authentication(&req.try_into()?, &s)
7476
{
75-
Ok(_auth_result) => {
76-
// Here should the DB update happen (last_used, ...)
77+
Ok(auth_result) => {
78+
// As per https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion 21:
79+
//
80+
// If the Credential Counter is greater than 0 you MUST assert that the counter
81+
// is greater than the stored counter. If the counter is equal or less than this
82+
// MAY indicate a cloned credential and you SHOULD invalidate and reject that
83+
// credential as a result.
84+
//
85+
// From this AuthenticationResult you should update the Credential’s Counter
86+
// value if it is valid per the above check. If you wish you may use the content
87+
// of the AuthenticationResult for extended validations (such as the presence of
88+
// the user verification flag).
89+
let cred_id = URL_SAFE_NO_PAD.encode(auth_result.cred_id());
90+
let mut credential = state
91+
.extension
92+
.provider
93+
.get_user_webauthn_credential(&state.core, &user_id, &cred_id)
94+
.await?
95+
.ok_or(WebauthnError::CredentialNotFound(cred_id))?;
96+
97+
let now = Utc::now();
98+
if auth_result.counter() > 0 {
99+
if auth_result.counter() <= credential.counter {
100+
return Err(WebauthnError::CounterVerification)?;
101+
}
102+
credential.counter = auth_result.counter();
103+
}
104+
105+
credential.last_used_at = Some(now);
106+
credential.updated_at = Some(now);
107+
// Integrate auth_result into the saved passkey data. Ignore the result since we
108+
// want to update the last_used_at anyway.
109+
credential.data.update_credential(&auth_result);
110+
111+
// Persist updated data.
112+
state
113+
.extension
114+
.provider
115+
.update_user_webauthn_credential(
116+
&state.core,
117+
credential.internal_id,
118+
&credential,
119+
)
120+
.await?;
77121
}
78122
Err(e) => {
79123
debug!("challenge_register -> {:?}", e);

src/webauthn/api/auth/start.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ pub async fn start(
6161
.list_user_webauthn_credentials(&state.core, &req.passkey.user_id)
6262
.await?
6363
.into_iter()
64+
.map(|x| x.data)
6465
.collect();
6566
let res = match state
6667
.extension

src/webauthn/api/register/finish.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use axum::{
1818
http::StatusCode,
1919
response::IntoResponse,
2020
};
21+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22+
use chrono::Utc;
2123
use mockall_double::double;
2224
use tracing::debug;
2325
use validator::Validate;
@@ -30,6 +32,7 @@ use crate::policy::Policy;
3032
use crate::webauthn::{
3133
WebauthnApi,
3234
api::types::{CombinedExtensionState, register::*},
35+
types::{CredentialType, WebauthnCredential},
3336
};
3437

3538
/// Finish passkey registration for the user.
@@ -96,15 +99,22 @@ pub(super) async fn finish(
9699
.finish_passkey_registration(&req.try_into()?, &s)
97100
{
98101
Ok(sk) => {
102+
let cred = WebauthnCredential {
103+
counter: 0,
104+
created_at: Utc::now(),
105+
credential_id: URL_SAFE_NO_PAD.encode(sk.cred_id()),
106+
data: sk,
107+
description: credential_description,
108+
internal_id: 0,
109+
last_used_at: None,
110+
r#type: CredentialType::CrossPlatform,
111+
updated_at: None,
112+
user_id: user_id.to_string(),
113+
};
99114
state
100115
.extension
101116
.provider
102-
.create_user_webauthn_credential(
103-
&state.core,
104-
&user_id,
105-
&sk,
106-
credential_description.as_deref(),
107-
)
117+
.create_user_webauthn_credential(&state.core, cred)
108118
.await?
109119
}
110120
Err(e) => {

src/webauthn/api/types/auth.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ pub struct AuthenticationExtensionsClientOutputs {
284284
/// The response to a hmac get secret request.
285285
#[serde(skip_serializing_if = "Option::is_none")]
286286
#[schema(nullable = false)]
287-
#[validate(nested, required)]
287+
#[validate(nested)]
288288
pub hmac_get_secret: Option<HmacGetSecretOutput>,
289289
}
290290

src/webauthn/driver.rs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub mod credential;
1616
pub mod state;
1717

1818
use async_trait::async_trait;
19-
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration};
19+
use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration};
2020

2121
use crate::keystone::ServiceState;
2222
use crate::webauthn::{
@@ -32,14 +32,23 @@ pub struct SqlDriver {}
3232
impl WebauthnApi for SqlDriver {
3333
/// Create webauthn credential for the user.
3434
#[tracing::instrument(level = "debug", skip(self, state))]
35-
async fn create_user_webauthn_credential<'a>(
35+
async fn create_user_webauthn_credential(
3636
&self,
3737
state: &ServiceState,
38-
user_id: &'a str,
39-
credential: &Passkey,
40-
description: Option<&'a str>,
38+
credential: WebauthnCredential,
4139
) -> Result<WebauthnCredential, WebauthnError> {
42-
credential::create(&state.db, user_id, credential, description, None).await
40+
credential::create(&state.db, credential).await
41+
}
42+
43+
/// Get webauthn credential of the user by the credential_id.
44+
#[tracing::instrument(level = "debug", skip(self, state))]
45+
async fn get_user_webauthn_credential<'a>(
46+
&self,
47+
state: &ServiceState,
48+
user_id: &'a str,
49+
credential_id: &'a str,
50+
) -> Result<Option<WebauthnCredential>, WebauthnError> {
51+
credential::find(&state.db, user_id, credential_id).await
4352
}
4453

4554
/// Delete webauthn credential auth state for a user.
@@ -88,7 +97,7 @@ impl WebauthnApi for SqlDriver {
8897
&self,
8998
state: &ServiceState,
9099
user_id: &'a str,
91-
) -> Result<Vec<Passkey>, WebauthnError> {
100+
) -> Result<Vec<WebauthnCredential>, WebauthnError> {
92101
credential::list(&state.db, user_id).await
93102
}
94103

@@ -113,6 +122,17 @@ impl WebauthnApi for SqlDriver {
113122
) -> Result<(), WebauthnError> {
114123
state::create_register(&state.db, user_id, reg_state).await
115124
}
125+
126+
/// Update credential data.
127+
#[tracing::instrument(level = "debug", skip(self, state))]
128+
async fn update_user_webauthn_credential(
129+
&self,
130+
state: &ServiceState,
131+
internal_id: i32,
132+
credential: &WebauthnCredential,
133+
) -> Result<WebauthnCredential, WebauthnError> {
134+
credential::update(&state.db, internal_id, credential).await
135+
}
116136
}
117137

118138
#[cfg(test)]

src/webauthn/driver/credential.rs

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,66 @@
1212
//
1313
// SPDX-License-Identifier: Apache-2.0
1414

15+
use sea_orm::entity::*;
16+
1517
use super::super::types::WebauthnCredential;
16-
use crate::db::entity::webauthn_credential;
18+
use crate::{db::entity::webauthn_credential, webauthn::WebauthnError};
1719

1820
mod create;
21+
mod get;
1922
mod list;
23+
mod update;
2024

2125
pub use create::create;
26+
pub use get::find;
2227
pub use list::list;
28+
pub use update::update;
2329

24-
impl From<webauthn_credential::Model> for WebauthnCredential {
25-
fn from(value: webauthn_credential::Model) -> Self {
26-
Self {
30+
impl TryFrom<webauthn_credential::Model> for WebauthnCredential {
31+
type Error = WebauthnError;
32+
fn try_from(value: webauthn_credential::Model) -> Result<Self, Self::Error> {
33+
Ok(Self {
34+
created_at: value.created_at.and_utc(),
2735
credential_id: value.credential_id,
36+
data: serde_json::from_str(&value.passkey)?,
37+
counter: value.counter.try_into()?,
2838
description: value.description,
29-
}
39+
internal_id: value.id,
40+
last_used_at: value.last_used_at.map(|x| x.and_utc()),
41+
r#type: value.r#type.into(),
42+
updated_at: value.last_updated_at.map(|x| x.and_utc()),
43+
user_id: value.user_id,
44+
})
45+
}
46+
}
47+
48+
impl TryFrom<WebauthnCredential> for webauthn_credential::ActiveModel {
49+
type Error = WebauthnError;
50+
51+
fn try_from(value: WebauthnCredential) -> Result<Self, Self::Error> {
52+
Ok(Self {
53+
id: if value.internal_id == 0 {
54+
NotSet
55+
} else {
56+
Set(value.internal_id)
57+
},
58+
user_id: Set(value.user_id),
59+
credential_id: Set(value.credential_id),
60+
description: value.description.map(Set).unwrap_or(NotSet).into(),
61+
passkey: Set(serde_json::to_string(&value.data)?),
62+
counter: Set(value.counter.try_into()?),
63+
r#type: Set(value.r#type.to_string()),
64+
aaguid: NotSet,
65+
created_at: Set(value.created_at.naive_utc()),
66+
last_used_at: value
67+
.last_used_at
68+
.map(|x| Set(Some(x.naive_utc())))
69+
.unwrap_or(NotSet),
70+
last_updated_at: value
71+
.updated_at
72+
.map(|x| Set(Some(x.naive_utc())))
73+
.unwrap_or(NotSet),
74+
})
3075
}
3176
}
3277

@@ -78,13 +123,14 @@ mod tests {
78123
.into()
79124
}
80125

81-
pub(super) fn get_mock<S: AsRef<str>>(id: S) -> webauthn_credential::Model {
126+
pub(super) fn get_mock<S: Into<String>>(id: S) -> webauthn_credential::Model {
82127
webauthn_credential::Model {
83128
id: 1,
84-
user_id: id.as_ref().to_string(),
129+
user_id: id.into(),
85130
credential_id: "cred".into(),
86131
description: Some("fake".into()),
87132
passkey: serde_json::to_string(&get_fake_passkey()).unwrap(),
133+
counter: 0,
88134
r#type: "cross-platform".into(),
89135
aaguid: Some("aaguid".into()),
90136
created_at: NaiveDateTime::default(),

0 commit comments

Comments
 (0)