From 34f050a1f0dce0167ae71e705fd0a8a7cf878428 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sun, 12 Apr 2026 10:38:31 +0300 Subject: [PATCH] feat(oauth): discoverable passkey authentication Lewis: May this revision serve well! --- ...b3e21db2383442c3e6f09de4eb49ea437e7c.json} | 4 +- ...86e7b5083fafedc582b8eac9916983e8fc2d.json} | 4 +- ...6fd51b7f6a0381c81c4907dad61d2f37992bd.json | 14 + ...0e28c94d6b8d6b16035474dd8f484e6171d60.json | 22 + ...98bf34ef8b66758ca9ebab4ea706ffb62914.json} | 4 +- ...3194c72322d2d1ec54402c262194791a2b06a.json | 18 + Cargo.lock | 45 +- Cargo.toml | 4 +- crates/tranquil-db-traits/src/user.rs | 17 +- crates/tranquil-db/src/postgres/user.rs | 63 ++- crates/tranquil-lexicon/src/validate.rs | 4 +- .../src/endpoints/authorize/login.rs | 8 +- .../src/endpoints/authorize/passkey.rs | 397 +++++++++++++----- crates/tranquil-pds/Cargo.toml | 1 + crates/tranquil-pds/src/auth/webauthn.rs | 57 +++ crates/tranquil-store/src/metastore/client.rs | 49 ++- .../tranquil-store/src/metastore/handler.rs | 56 ++- .../tranquil-store/src/metastore/user_ops.rs | 67 ++- frontend/src/components/RandomHandle.svelte | 2 +- .../dashboard/PasskeySection.svelte | 13 +- .../dashboard/SecurityContent.svelte | 1 + frontend/src/locales/en.json | 1 + frontend/src/locales/fi.json | 1 + frontend/src/locales/ja.json | 1 + frontend/src/locales/ko.json | 1 + frontend/src/locales/sv.json | 1 + frontend/src/locales/zh.json | 1 + frontend/src/routes/OAuthLogin.svelte | 164 ++++---- 28 files changed, 751 insertions(+), 269 deletions(-) rename .sqlx/{query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json => query-053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c.json} (82%) rename .sqlx/{query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json => query-060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d.json} (95%) create mode 100644 .sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json create mode 100644 .sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json rename .sqlx/{query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json => query-aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914.json} (95%) create mode 100644 .sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json diff --git a/.sqlx/query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json b/.sqlx/query-053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c.json similarity index 82% rename from .sqlx/query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json rename to .sqlx/query-053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c.json index 6a04e0c..1ea148a 100644 --- a/.sqlx/query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json +++ b/.sqlx/query-053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", + "query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1", "describe": { "columns": [ { @@ -24,5 +24,5 @@ true ] }, - "hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038" + "hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c" } diff --git a/.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json b/.sqlx/query-060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d.json similarity index 95% rename from .sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json rename to .sqlx/query-060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d.json index 793d325..dd77231 100644 --- a/.sqlx/query-7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa.json +++ b/.sqlx/query-060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ", "describe": { "columns": [ { @@ -118,5 +118,5 @@ false ] }, - "hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa" + "hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d" } diff --git a/.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json b/.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json new file mode 100644 index 0000000..e68d3cb --- /dev/null +++ b/.sqlx/query-3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd" +} diff --git a/.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json b/.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json new file mode 100644 index 0000000..be6a5d7 --- /dev/null +++ b/.sqlx/query-6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state_json", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60" +} diff --git a/.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json b/.sqlx/query-aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914.json similarity index 95% rename from .sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json rename to .sqlx/query-aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914.json index 61db6fb..ce51f49 100644 --- a/.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json +++ b/.sqlx/query-aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1", "describe": { "columns": [ { @@ -132,5 +132,5 @@ null ] }, - "hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249" + "hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914" } diff --git a/.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json b/.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json new file mode 100644 index 0000000..b7f7fa2 --- /dev/null +++ b/.sqlx/query-c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a" +} diff --git a/Cargo.lock b/Cargo.lock index 3f13904..48fb813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7405,7 +7405,7 @@ dependencies = [ [[package]] name = "tranquil-api" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", @@ -7456,7 +7456,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "base32", @@ -7479,7 +7479,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -7493,7 +7493,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -7511,7 +7511,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.5.0" +version = "0.5.1" dependencies = [ "confique", "serde", @@ -7519,7 +7519,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.5.0" +version = "0.5.1" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -7535,7 +7535,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "chrono", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "bytes", @@ -7579,7 +7579,7 @@ dependencies = [ [[package]] name = "tranquil-lexicon" -version = "0.5.0" +version = "0.5.1" dependencies = [ "chrono", "hickory-resolver", @@ -7597,7 +7597,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", @@ -7620,7 +7620,7 @@ dependencies = [ [[package]] name = "tranquil-oauth-server" -version = "0.5.0" +version = "0.5.1" dependencies = [ "axum", "base64 0.22.1", @@ -7653,7 +7653,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.5.0" +version = "0.5.1" dependencies = [ "aes-gcm", "anyhow", @@ -7738,13 +7738,14 @@ dependencies = [ "urlencoding", "uuid", "webauthn-rs", + "webauthn-rs-proto", "wiremock", "zip", ] [[package]] name = "tranquil-repo" -version = "0.5.0" +version = "0.5.1" dependencies = [ "bytes", "cid", @@ -7756,7 +7757,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "backon", @@ -7781,7 +7782,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.5.0" +version = "0.5.1" dependencies = [ "axum", "futures", @@ -7797,7 +7798,7 @@ dependencies = [ [[package]] name = "tranquil-server" -version = "0.5.0" +version = "0.5.1" dependencies = [ "axum", "clap", @@ -7818,7 +7819,7 @@ dependencies = [ [[package]] name = "tranquil-signal" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "chrono", @@ -7841,7 +7842,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "aws-config", @@ -7858,7 +7859,7 @@ dependencies = [ [[package]] name = "tranquil-store" -version = "0.5.0" +version = "0.5.1" dependencies = [ "async-trait", "bytes", @@ -7904,7 +7905,7 @@ dependencies = [ [[package]] name = "tranquil-sync" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", @@ -7926,7 +7927,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.5.0" +version = "0.5.1" dependencies = [ "chrono", "cid", diff --git a/Cargo.toml b/Cargo.toml index b7390ff..0839019 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ ] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2024" license = "AGPL-3.0-or-later" @@ -126,7 +126,7 @@ tracing = "0.1" tracing-subscriber = "0.3" urlencoding = "2.1" uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] } -webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] } webauthn-rs-proto = "0.5" zip = { version = "7.0", default-features = false, features = ["deflate"] } diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 544be79..6e8ccd4 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -144,12 +144,12 @@ pub trait UserRepository: Send + Sync { async fn get_by_email(&self, email: &str) -> Result, DbError>; - async fn get_login_check_by_handle_or_email( + async fn get_login_check_by_identifier( &self, identifier: &str, ) -> Result, DbError>; - async fn get_login_info_by_handle_or_email( + async fn get_login_info_by_identifier( &self, identifier: &str, ) -> Result, DbError>; @@ -358,6 +358,19 @@ pub trait UserRepository: Send + Sync { challenge_type: WebauthnChallengeType, ) -> Result<(), DbError>; + async fn save_discoverable_challenge( + &self, + request_key: &str, + state_json: &str, + ) -> Result; + + async fn load_discoverable_challenge( + &self, + request_key: &str, + ) -> Result, DbError>; + + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>; + async fn get_totp_record(&self, did: &Did) -> Result, DbError>; async fn get_totp_record_state(&self, did: &Did) -> Result, DbError>; diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index db840f6..22d94b6 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -1102,6 +1102,59 @@ impl UserRepository for PostgresUserRepository { Ok(()) } + async fn save_discoverable_challenge( + &self, + request_key: &str, + state_json: &str, + ) -> Result { + let id = Uuid::new_v4(); + let challenge = id.as_bytes().to_vec(); + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); + sqlx::query!( + r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) + VALUES ($1, $2, $3, 'discoverable', $4, $5)"#, + id, + request_key, + challenge, + state_json, + expires_at, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn load_discoverable_challenge( + &self, + request_key: &str, + ) -> Result, DbError> { + let row = sqlx::query_scalar!( + r#"SELECT state_json FROM webauthn_challenges + WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1"#, + request_key, + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row) + } + + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'", + request_key, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + async fn get_totp_record(&self, did: &Did) -> Result, DbError> { let row = sqlx::query!( "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", @@ -1330,12 +1383,12 @@ impl UserRepository for PostgresUserRepository { Ok(()) } - async fn get_login_check_by_handle_or_email( + async fn get_login_check_by_identifier( &self, identifier: &str, ) -> Result, DbError> { sqlx::query!( - "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", + "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1", identifier ) .fetch_optional(&self.pool) @@ -1349,7 +1402,7 @@ impl UserRepository for PostgresUserRepository { }) } - async fn get_login_info_by_handle_or_email( + async fn get_login_info_by_identifier( &self, identifier: &str, ) -> Result, DbError> { @@ -1361,7 +1414,7 @@ impl UserRepository for PostgresUserRepository { email_verified, discord_verified, telegram_verified, signal_verified, account_type as "account_type!: AccountType" FROM users - WHERE handle = $1 OR email = $1 + WHERE handle = $1 OR did = $1 "#, identifier ) @@ -1524,7 +1577,7 @@ impl UserRepository for PostgresUserRepository { COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!" FROM users u JOIN user_keys k ON u.id = k.user_id - WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, + WHERE u.handle = $1 OR u.did = $1"#, identifier ) .fetch_optional(&self.pool) diff --git a/crates/tranquil-lexicon/src/validate.rs b/crates/tranquil-lexicon/src/validate.rs index 5e4e2bd..6876a5e 100644 --- a/crates/tranquil-lexicon/src/validate.rs +++ b/crates/tranquil-lexicon/src/validate.rs @@ -322,7 +322,9 @@ fn validate_blob_ref( if let Some(ref accept) = lex_blob.accept { let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or(""); - let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern)); + let matched = accept + .iter() + .any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern)); if !mime_type.is_empty() && !matched { return Err(LexValidationError::field( path, diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs index e07f74b..c6c5ae6 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs @@ -108,7 +108,7 @@ pub async fn authorize_get( match state .repos .user - .get_login_check_by_handle_or_email(normalized.as_str()) + .get_login_check_by_identifier(normalized.as_str()) .await { Ok(Some(user)) => { @@ -401,7 +401,7 @@ pub async fn authorize_post( let user = match state .repos .user - .get_login_info_by_handle_or_email(normalized_username.as_str()) + .get_login_info_by_identifier(normalized_username.as_str()) .await { Ok(Some(u)) => u, @@ -410,7 +410,7 @@ pub async fn authorize_post( &form.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", ); - return show_login_error("Invalid handle/email or password.", json_response); + return show_login_error("Invalid identifier or password.", json_response); } Err(_) => return show_login_error("An error occurred. Please try again.", json_response), }; @@ -486,7 +486,7 @@ pub async fn authorize_post( None => false, }; if !password_valid { - return show_login_error("Invalid handle/email or password.", json_response); + return show_login_error("Invalid identifier or password.", json_response); } let is_verified = user.channel_verification.has_any_verified(); if !is_verified { diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs index aa753d2..521a69e 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs @@ -22,7 +22,7 @@ pub async fn check_user_has_passkeys( let user = state .repos .user - .get_login_check_by_handle_or_email(bare_identifier.as_str()) + .get_login_check_by_identifier(bare_identifier.as_str()) .await; let has_passkeys = match user { @@ -55,7 +55,7 @@ pub async fn check_user_security_status( let user = state .repos .user - .get_login_check_by_handle_or_email(normalized_identifier.as_str()) + .get_login_check_by_identifier(normalized_identifier.as_str()) .await; let (has_passkeys, has_totp, has_password, is_delegated, did): ( @@ -99,7 +99,7 @@ pub async fn check_user_security_status( #[derive(Debug, Deserialize)] pub struct PasskeyStartInput { pub request_uri: String, - pub identifier: String, + pub identifier: Option, pub delegated_did: Option, } @@ -160,14 +160,91 @@ pub async fn passkey_start( .into_response(); } + match form.identifier.filter(|s| !s.trim().is_empty()) { + Some(identifier) => { + passkey_start_named( + state, + identifier, + form.delegated_did, + request_data, + passkey_start_request_id, + ) + .await + } + None => passkey_start_discoverable(state, passkey_start_request_id).await, + } +} + +async fn passkey_start_discoverable( + state: AppState, + request_id: RequestId, +) -> Response { + let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() { + Ok(result) => result, + Err(e) => { + tracing::error!(error = %e, "Failed to start discoverable passkey authentication"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to start authentication." + })), + ) + .into_response(); + } + }; + + let state_json = match serde_json::to_string(&auth_state) { + Ok(j) => j, + Err(e) => { + tracing::error!(error = %e, "Failed to serialize authentication state"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + if let Err(e) = state + .repos + .user + .save_discoverable_challenge(request_id.as_str(), &state_json) + .await + { + tracing::error!(error = %e, "Failed to save discoverable authentication state"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); + Json(PasskeyStartResponse { options }).into_response() +} + +async fn passkey_start_named( + state: AppState, + identifier: String, + delegated_did: Option, + request_data: tranquil_pds::oauth::RequestData, + passkey_start_request_id: RequestId, +) -> Response { let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let normalized_username = - NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles); + NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles); let user = match state .repos .user - .get_login_info_by_handle_or_email(normalized_username.as_str()) + .get_login_info_by_identifier(normalized_username.as_str()) .await { Ok(Some(u)) => u, @@ -325,7 +402,7 @@ pub async fn passkey_start( .into_response(); } - let delegation_from_param = match &form.delegated_did { + let delegation_from_param = match &delegated_did { Some(delegated_did_str) => match delegated_did_str.parse::() { Ok(delegated_did) if delegated_did != user.did => { match state @@ -471,85 +548,6 @@ pub async fn passkey_finish( .into_response(); } - let did_str = match request_data.did { - Some(d) => d, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "No passkey authentication in progress." - })), - ) - .into_response(); - } - }; - let did: tranquil_types::Did = match did_str.parse() { - Ok(d) => d, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "Invalid DID format." - })), - ) - .into_response(); - } - }; - - let controller_did: Option = request_data - .controller_did - .as_ref() - .and_then(|s| s.parse().ok()); - let passkey_owner_did = controller_did.as_ref().unwrap_or(&did); - - let auth_state_json = match state - .repos - .user - .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) - .await - { - Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "No passkey authentication in progress or challenge expired." - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to load authentication state"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": "An error occurred." - })), - ) - .into_response(); - } - }; - - let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication = - match serde_json::from_str(&auth_state_json) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "Failed to deserialize authentication state"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": "An error occurred." - })), - ) - .into_response(); - } - }; - let credential: webauthn_rs::prelude::PublicKeyCredential = match serde_json::from_value(form.credential) { Ok(c) => c, @@ -566,33 +564,35 @@ pub async fn passkey_finish( } }; - let auth_result = match state - .webauthn_config - .finish_authentication(&credential, &auth_state) - { - Ok(r) => r, - Err(e) => { - tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "access_denied", - "error_description": "Passkey verification failed." - })), + let (did, auth_result) = match request_data.did.clone() { + Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await { + Ok(result) => result, + Err(response) => return response, + }, + None => { + let result = match passkey_finish_discoverable( + &state, + &credential, + &passkey_finish_request_id, ) - .into_response(); + .await + { + Ok(result) => result, + Err(response) => return response, + }; + if state + .repos + .oauth + .set_authorization_did(&passkey_finish_request_id, &result.0, None) + .await + .is_err() + { + return OAuthError::ServerError("An error occurred.".into()).into_response(); + } + result } }; - if let Err(e) = state - .repos - .user - .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) - .await - { - tracing::warn!(error = %e, "Failed to delete authentication state"); - } - if auth_result.needs_update() { let cred_id_bytes = auth_result.cred_id().as_slice(); match state @@ -691,6 +691,187 @@ pub async fn passkey_finish( .into_response() } +async fn passkey_finish_named( + state: &AppState, + did: tranquil_types::Did, + request_data: &tranquil_pds::oauth::RequestData, + credential: &webauthn_rs::prelude::PublicKeyCredential, +) -> Result< + ( + tranquil_types::Did, + webauthn_rs::prelude::AuthenticationResult, + ), + Response, +> { + let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did); + + let auth_state_json = state + .repos + .user + .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to load authentication state"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })? + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "No passkey authentication in progress or challenge expired." + })), + ).into_response() + })?; + + let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication = + serde_json::from_str(&auth_state_json).map_err(|e| { + tracing::error!(error = %e, "Failed to deserialize authentication state"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })?; + + let auth_result = state + .webauthn_config + .finish_authentication(credential, &auth_state) + .map_err(|e| { + tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey verification failed." + })), + ) + .into_response() + })?; + + let _ = state + .repos + .user + .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) + .await; + + Ok((did, auth_result)) +} + +async fn passkey_finish_discoverable( + state: &AppState, + credential: &webauthn_rs::prelude::PublicKeyCredential, + request_id: &RequestId, +) -> Result< + ( + tranquil_types::Did, + webauthn_rs::prelude::AuthenticationResult, + ), + Response, +> { + let auth_state_json = state + .repos + .user + .load_discoverable_challenge(request_id.as_str()) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to load discoverable authentication state"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })? + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "No passkey authentication in progress or challenge expired." + })), + ).into_response() + })?; + + let auth_state: webauthn_rs::prelude::DiscoverableAuthentication = + serde_json::from_str(&auth_state_json).map_err(|e| { + tracing::error!(error = %e, "Failed to deserialize discoverable authentication state"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })?; + + let (_user_uuid, cred_id) = state + .webauthn_config + .identify_discoverable_authentication(credential) + .map_err(|e| { + tracing::warn!(error = %e, "Failed to identify discoverable credential"); + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey verification failed." + })), + ) + .into_response() + })?; + + let stored_passkey = state + .repos + .user + .get_passkey_by_credential_id(cred_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to look up passkey by credential ID"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })? + .ok_or_else(|| { + tracing::warn!("Discoverable credential not found in database"); + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey not recognized." + })), + ).into_response() + })?; + + let discoverable_key: webauthn_rs::prelude::DiscoverableKey = + serde_json::from_slice(&stored_passkey.public_key).map_err(|e| { + tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ).into_response() + })?; + + let auth_result = state + .webauthn_config + .finish_discoverable_authentication(credential, auth_state, &[discoverable_key]) + .map_err(|e| { + tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication"); + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey verification failed." + })), + ).into_response() + })?; + + let _ = state + .repos + .user + .delete_discoverable_challenge(request_id.as_str()) + .await; + + Ok((stored_passkey.did, auth_result)) +} + #[derive(Debug, Deserialize)] pub struct AuthorizePasskeyQuery { pub request_uri: String, diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 1bd6c92..d564537 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -78,6 +78,7 @@ tracing = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } webauthn-rs = { workspace = true } +webauthn-rs-proto = { workspace = true } zip = { workspace = true } aws-config = { workspace = true, optional = true } aws-sdk-s3 = { workspace = true, optional = true } diff --git a/crates/tranquil-pds/src/auth/webauthn.rs b/crates/tranquil-pds/src/auth/webauthn.rs index 12615fa..e255f25 100644 --- a/crates/tranquil-pds/src/auth/webauthn.rs +++ b/crates/tranquil-pds/src/auth/webauthn.rs @@ -1,5 +1,8 @@ use uuid::Uuid; use webauthn_rs::prelude::*; +use webauthn_rs_proto::{ + AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy, +}; #[derive(Debug, thiserror::Error)] pub enum WebauthnError { @@ -57,6 +60,15 @@ impl WebAuthnConfig { None, None, ) + .map(|(mut ccr, state)| { + let sel = ccr + .public_key + .authenticator_selection + .get_or_insert_with(AuthenticatorSelectionCriteria::default); + sel.resident_key = Some(ResidentKeyRequirement::Required); + sel.require_resident_key = true; + (ccr, state) + }) .map_err(|e| WebauthnError::RegistrationFailed(e.to_string())) } @@ -88,4 +100,49 @@ impl WebAuthnConfig { .finish_securitykey_authentication(auth, state) .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) } + + pub fn start_discoverable_authentication( + &self, + ) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> { + let (mut rcr, state) = self + .webauthn + .start_discoverable_authentication() + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; + + rcr.mediation = None; + rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + + let mut state_json = serde_json::to_value(&state) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; + let ast = state_json + .get_mut("ast") + .ok_or_else(|| WebauthnError::AuthenticationFailed( + "webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(), + ))?; + ast["policy"] = serde_json::json!("discouraged"); + let patched: DiscoverableAuthentication = serde_json::from_value(state_json) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?; + + Ok((rcr, patched)) + } + + pub fn identify_discoverable_authentication<'a>( + &self, + credential: &'a PublicKeyCredential, + ) -> Result<(Uuid, &'a [u8]), WebauthnError> { + self.webauthn + .identify_discoverable_authentication(credential) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) + } + + pub fn finish_discoverable_authentication( + &self, + credential: &PublicKeyCredential, + state: DiscoverableAuthentication, + creds: &[DiscoverableKey], + ) -> Result { + self.webauthn + .finish_discoverable_authentication(credential, state, creds) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) + } } diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index bc5fccd..72fc61f 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -3487,13 +3487,13 @@ impl tranquil_db_traits::UserRepository for MetastoreCli recv(rx).await } - async fn get_login_check_by_handle_or_email( + async fn get_login_check_by_identifier( &self, identifier: &str, ) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool.send(MetastoreRequest::User( - UserRequest::GetLoginCheckByHandleOrEmail { + UserRequest::GetLoginCheckByIdentifier { identifier: identifier.to_owned(), tx, }, @@ -3501,13 +3501,13 @@ impl tranquil_db_traits::UserRepository for MetastoreCli recv(rx).await } - async fn get_login_info_by_handle_or_email( + async fn get_login_info_by_identifier( &self, identifier: &str, ) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool.send(MetastoreRequest::User( - UserRequest::GetLoginInfoByHandleOrEmail { + UserRequest::GetLoginInfoByIdentifier { identifier: identifier.to_owned(), tx, }, @@ -4233,6 +4233,47 @@ impl tranquil_db_traits::UserRepository for MetastoreCli recv(rx).await } + async fn save_discoverable_challenge( + &self, + request_key: &str, + state_json: &str, + ) -> Result { + let (tx, rx) = oneshot::channel(); + self.pool.send(MetastoreRequest::User( + UserRequest::SaveDiscoverableChallenge { + request_key: request_key.to_owned(), + state_json: state_json.to_owned(), + tx, + }, + ))?; + recv(rx).await + } + + async fn load_discoverable_challenge( + &self, + request_key: &str, + ) -> Result, DbError> { + let (tx, rx) = oneshot::channel(); + self.pool.send(MetastoreRequest::User( + UserRequest::LoadDiscoverableChallenge { + request_key: request_key.to_owned(), + tx, + }, + ))?; + recv(rx).await + } + + async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> { + let (tx, rx) = oneshot::channel(); + self.pool.send(MetastoreRequest::User( + UserRequest::DeleteDiscoverableChallenge { + request_key: request_key.to_owned(), + tx, + }, + ))?; + recv(rx).await + } + async fn get_totp_record(&self, did: &Did) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index a4a5004..d651158 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -996,11 +996,11 @@ pub enum UserRequest { email: String, tx: Tx>, }, - GetLoginCheckByHandleOrEmail { + GetLoginCheckByIdentifier { identifier: String, tx: Tx>, }, - GetLoginInfoByHandleOrEmail { + GetLoginInfoByIdentifier { identifier: String, tx: Tx>, }, @@ -1273,6 +1273,19 @@ pub enum UserRequest { challenge_type: WebauthnChallengeType, tx: Tx<()>, }, + SaveDiscoverableChallenge { + request_key: String, + state_json: String, + tx: Tx, + }, + LoadDiscoverableChallenge { + request_key: String, + tx: Tx>, + }, + DeleteDiscoverableChallenge { + request_key: String, + tx: Tx<()>, + }, GetTotpRecord { did: Did, tx: Tx>, @@ -1726,8 +1739,8 @@ impl UserRequest { | Self::GetAnyAdminUserId { .. } | Self::SearchAccounts { .. } | Self::GetByEmail { .. } - | Self::GetLoginCheckByHandleOrEmail { .. } - | Self::GetLoginInfoByHandleOrEmail { .. } + | Self::GetLoginCheckByIdentifier { .. } + | Self::GetLoginInfoByIdentifier { .. } | Self::CheckEmailVerifiedByIdentifier { .. } | Self::StoreTelegramChatId { .. } | Self::StoreDiscordUserId { .. } @@ -1743,7 +1756,10 @@ impl UserRequest { | Self::CleanupExpiredHandleReservations { .. } | Self::CheckAndConsumeInviteCode { .. } | Self::GetPasswordResetInfo { .. } - | Self::ExpirePasswordResetCode { .. } => Routing::Global, + | Self::ExpirePasswordResetCode { .. } + | Self::SaveDiscoverableChallenge { .. } + | Self::LoadDiscoverableChallenge { .. } + | Self::DeleteDiscoverableChallenge { .. } => Routing::Global, } } } @@ -5066,15 +5082,15 @@ fn dispatch_user(state: &HandlerState, req: UserReque UserRequest::GetByEmail { email, tx } => { let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db)); } - UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => { + UserRequest::GetLoginCheckByIdentifier { identifier, tx } => { let _ = tx.send( - user.get_login_check_by_handle_or_email(&identifier) + user.get_login_check_by_identifier(&identifier) .map_err(metastore_to_db), ); } - UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => { + UserRequest::GetLoginInfoByIdentifier { identifier, tx } => { let _ = tx.send( - user.get_login_info_by_handle_or_email(&identifier) + user.get_login_info_by_identifier(&identifier) .map_err(metastore_to_db), ); } @@ -5434,6 +5450,28 @@ fn dispatch_user(state: &HandlerState, req: UserReque .map_err(metastore_to_db), ); } + UserRequest::SaveDiscoverableChallenge { + request_key, + state_json, + tx, + } => { + let _ = tx.send( + user.save_discoverable_challenge(&request_key, &state_json) + .map_err(metastore_to_db), + ); + } + UserRequest::LoadDiscoverableChallenge { request_key, tx } => { + let _ = tx.send( + user.load_discoverable_challenge(&request_key) + .map_err(metastore_to_db), + ); + } + UserRequest::DeleteDiscoverableChallenge { request_key, tx } => { + let _ = tx.send( + user.delete_discoverable_challenge(&request_key) + .map_err(metastore_to_db), + ); + } UserRequest::GetTotpRecord { did, tx } => { let _ = tx.send(user.get_totp_record(&did).map_err(metastore_to_db)); } diff --git a/crates/tranquil-store/src/metastore/user_ops.rs b/crates/tranquil-store/src/metastore/user_ops.rs index 4b62387..73d62b6 100644 --- a/crates/tranquil-store/src/metastore/user_ops.rs +++ b/crates/tranquil-store/src/metastore/user_ops.rs @@ -125,15 +125,9 @@ impl UserOps { } fn load_by_identifier(&self, identifier: &str) -> Result, MetastoreError> { - match identifier.contains('@') { - true => self.load_by_email(identifier).and_then(|opt| match opt { - Some(v) => Ok(Some(v)), - None => self.load_by_handle(identifier), - }), - false => self.load_by_handle(identifier).and_then(|opt| match opt { - Some(v) => Ok(Some(v)), - None => self.load_by_email(identifier), - }), + match identifier.starts_with("did:") { + true => self.load_user_by_did(identifier), + false => self.load_by_handle(identifier), } } @@ -472,7 +466,7 @@ impl UserOps { .transpose() } - pub fn get_login_check_by_handle_or_email( + pub fn get_login_check_by_identifier( &self, identifier: &str, ) -> Result, MetastoreError> { @@ -487,7 +481,7 @@ impl UserOps { .transpose() } - pub fn get_login_info_by_handle_or_email( + pub fn get_login_info_by_identifier( &self, identifier: &str, ) -> Result, MetastoreError> { @@ -1511,6 +1505,57 @@ impl UserOps { .map_err(MetastoreError::Fjall) } + const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2; + + pub fn save_discoverable_challenge( + &self, + request_key: &str, + state_json: &str, + ) -> Result { + let key_hash = UserHash::from_did(request_key); + let id = Uuid::new_v4(); + let now_ms = Utc::now().timestamp_millis(); + + let value = WebauthnChallengeValue { + id, + challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE, + state_json: state_json.to_owned(), + created_at_ms: now_ms, + }; + + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); + self.auth + .insert(key.as_slice(), value.serialize_with_ttl()) + .map_err(MetastoreError::Fjall)?; + + Ok(id) + } + + pub fn load_discoverable_challenge( + &self, + request_key: &str, + ) -> Result, MetastoreError> { + let key_hash = UserHash::from_did(request_key); + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); + + let val: Option = point_lookup( + &self.auth, + key.as_slice(), + WebauthnChallengeValue::deserialize, + "corrupt webauthn challenge", + )?; + + Ok(val.map(|v| v.state_json)) + } + + pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> { + let key_hash = UserHash::from_did(request_key); + let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE); + self.auth + .remove(key.as_slice()) + .map_err(MetastoreError::Fjall) + } + pub fn get_totp_record(&self, did: &Did) -> Result, MetastoreError> { let user_hash = self.resolve_hash(did.as_str()); let key = totp_key(user_hash); diff --git a/frontend/src/components/RandomHandle.svelte b/frontend/src/components/RandomHandle.svelte index 27239a0..0487140 100644 --- a/frontend/src/components/RandomHandle.svelte +++ b/frontend/src/components/RandomHandle.svelte @@ -1,7 +1,7 @@