From 9c12fb43720acc15c23d99339c2ad90c2ad6252d Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 19:01:43 +0900 Subject: [PATCH] feat: verify discoverable passkeys against their saved owners --- Cargo.lock | 2 + crates/server-api/src/lib.rs | 9 +- crates/workspace-server/Cargo.toml | 3 +- crates/workspace-server/src/server.rs | 500 +++++++++++------- .../src/server_activation_tests.rs | 2 +- .../src/server_discoverable_tests.rs | 335 ++++++++++++ openapi/server-api.json | 4 +- tests/e2e/Cargo.toml | 1 + tests/e2e/src/resident_authenticator.rs | 99 ++++ tests/e2e/tests/auth_passkeys.rs | 93 ++-- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 15 files changed, 819 insertions(+), 239 deletions(-) create mode 100644 crates/workspace-server/src/server_discoverable_tests.rs create mode 100644 tests/e2e/src/resident_authenticator.rs diff --git a/Cargo.lock b/Cargo.lock index 863d98af5..122e578dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6713,6 +6713,7 @@ dependencies = [ "sha2 0.11.0", "tempfile", "webauthn-authenticator-rs", + "webauthn-rs-proto", ] [[package]] @@ -6773,6 +6774,7 @@ dependencies = [ "uuid", "webauthn-authenticator-rs", "webauthn-rs", + "webauthn-rs-proto", "wip-protocol", "workdir", "worker", diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 5ba53348e..1e30192d7 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4088,10 +4088,11 @@ pub struct PasskeyRegistrationCompleteRequest { pub credential: RegisterPublicKeyCredential, } -/// Identified-account login. Missing handles are rejected, not resolved to an implicit user. -/// -/// The nullable wire field permits an explicit authentication error for old callers; it does not -/// advertise a discoverable-login ceremony. +/// Normal discoverable login omits the handle: the authenticator selects a credential. +/// An explicit handle selects legacy identified-account login, for non-resident keys or +/// keys registered with an old random WebAuthn user handle. This never adds a key or +/// falls back to another identity. Authenticate first, then enroll an RK from Account. +/// Existing challenge states are short-lived and are not migrated to the new ceremony. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 4750c1c07..b561ca4eb 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -53,7 +53,8 @@ ts-rs = "12.0.1" url.workspace = true uuid = { workspace = true, features = ["v7"] } zeroize.workspace = true -webauthn-rs = { workspace = true } +webauthn-rs = { workspace = true, features = ["conditional-ui"] } +webauthn-rs-proto = "0.5.5" [dev-dependencies] wip-protocol.workspace = true diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 4e002ce71..fa96254bb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -29686,11 +29686,13 @@ struct PasskeyRegistrationEnvelope { } /// An account gets one stable WebAuthn user handle, without storing a new schema -/// field. Migration leaves all existing credentials, their random registration -/// handles and key material unchanged. Credential-ID-scoped authentication must -/// accept signed assertions both without userHandle and with the original random -/// or newly registered stable userHandle; it is not discoverable-handle lookup. -/// The real-verifier compatibility tests exercise each representation explicitly. +/// field. Legacy identified-account login accepts original random handles or no +/// returned handle, always requiring the saved credential's signature. Normal +/// discoverable login additionally requires the saved owner's stable handle. +/// Random registration handles were never stored, so they cannot be recovered +/// or safely mapped by trusting the unsigned assertion. Preserve these keys and +/// use explicit legacy login to authenticate before adding a stable-handle RK. +/// Merely having a stable handle does not prove the old key is discoverable. fn passkey_user_handle(account_id: &str) -> Uuid { let digest = Sha256::digest(account_id.as_bytes()); Uuid::from_bytes( @@ -29794,7 +29796,7 @@ fn start_passkey_registration( let header_origin = request_origin(headers); let auth = auth_config_for_origin(&api.config, browser_origin.or(header_origin.as_deref()))?; let webauthn = webauthn_for_auth(&auth)?; - let (public_key, verifier) = webauthn + let (mut public_key, verifier) = webauthn .start_passkey_registration( passkey_user_handle(&user.account_id), &user.handle, @@ -29802,6 +29804,21 @@ fn start_passkey_registration( Some(exclude_credentials), ) .map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?; + // webauthn-rs passkeys require UV but do not request residency by default. + // These public protocol fields request an RK on conforming clients; residency + // itself is not remotely attestable. Keep the library's UV/verifier state. + let selection = public_key + .public_key + .authenticator_selection + .as_mut() + .ok_or_else(|| { + auth_error( + "webauthn_registration_options_failed", + "authenticator selection is missing", + ) + })?; + selection.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); + selection.require_resident_key = true; let challenge_id = new_id("webauthn-registration"); // New users do not exist yet, so the nullable FK remains empty. The complete // target (including its allocated user ID) is inside the purpose-tagged state. @@ -29966,6 +29983,18 @@ fn complete_passkey_enrollment( issue_browser_session(api, user) } +/// Normal login has no preselected identity. Explicit handle requests are the +/// legacy migration path, not an automatic fallback. Non-resident keys and keys +/// with old random user handles can authenticate there, then add an RK from the +/// live account. No saved key or owner is rewritten. Existing stable-handle RKs +/// can already use normal login; discoverability cannot be inferred from storage. +#[derive(Serialize, Deserialize)] +#[serde(tag = "mode", content = "verifier", rename_all = "snake_case")] +enum PasskeyLoginState { + Discoverable(webauthn_rs::prelude::DiscoverableAuthentication), + Legacy(PasskeyAuthentication), +} + async fn post_passkey_login_options( State(api): State, headers: HeaderMap, @@ -29974,48 +30003,66 @@ async fn post_passkey_login_options( api.store .db_executor() .run(move || { - let user = match request.handle.as_deref() { - Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, - None => { - return Err(auth_error( - "passkey_handle_required", - "login requires an explicit handle", - ) - .into()); - } - } - .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; - if api.store.is_user_pending_activation(&user.user_id)? { - return Err(auth_error( - "passkey_activation_required", - "initial passkey activation is required", - ) - .into()); - } - let passkeys = passkeys_for_user(&api, &user.user_id)?; - if passkeys.is_empty() { - return Err(auth_error( - "passkey_not_registered", - "user has no registered passkey credentials", - ) - .into()); - } let header_origin = request_origin(&headers); - let requested_origin = request - .browser_origin - .as_deref() - .or(header_origin.as_deref()); - let auth = auth_config_for_origin(&api.config, requested_origin)?; + let auth = auth_config_for_origin( + &api.config, + request + .browser_origin + .as_deref() + .or(header_origin.as_deref()), + )?; let webauthn = webauthn_for_auth(&auth)?; - let (public_key, state) = webauthn - .start_passkey_authentication(&passkeys) - .map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?; + let (public_key, state, user_id) = + if let Some(handle) = request.handle.as_deref() { + let user = api + .store + .get_user_by_handle(&normalize_handle(handle)?)? + .ok_or_else(|| { + auth_error("unknown_auth_user", "no matching user account exists") + })?; + if api.store.is_user_pending_activation(&user.user_id)? { + return Err(auth_error( + "passkey_activation_required", + "initial passkey activation is required", + ) + .into()); + } + let passkeys = passkeys_for_user(&api, &user.user_id)?; + if passkeys.is_empty() { + return Err(auth_error( + "passkey_not_registered", + "user has no registered passkey credentials", + ) + .into()); + } + let (options, state) = webauthn + .start_passkey_authentication(&passkeys) + .map_err(|error| { + auth_error("webauthn_login_options_failed", &error.to_string()) + })?; + ( + options, + PasskeyLoginState::Legacy(state), + Some(user.user_id), + ) + } else { + let (mut options, state) = webauthn + .start_discoverable_authentication() + .map_err(|error| { + auth_error("webauthn_login_options_failed", &error.to_string()) + })?; + // The feature exposes a conditional-UI builder. Use its empty allow + // list and required UV verifier with an explicit user gesture, not + // conditional/autofill mediation. No account inventory is returned. + options.mediation = None; + (options, PasskeyLoginState::Discoverable(state), None) + }; let challenge_id = new_id("webauthn-login"); api.store.put_auth_challenge(&AuthChallengeRecord { challenge_id: challenge_id.clone(), - ceremony: "passkey_login".to_string(), + ceremony: "passkey_login".into(), challenge: challenge_id.clone(), - user_id: Some(user.user_id), + user_id, rp_id: auth.rp_id, origin: auth.origin, state_json: Some(serde_json::to_string(&state).map_err(|error| { @@ -30057,64 +30104,114 @@ fn complete_passkey_login( ) .into()); } - let user_id = challenge.user_id.clone().ok_or_else(|| { - auth_error( - "invalid_passkey_challenge", - "passkey login challenge is not bound to a user", - ) - })?; - let user = api - .store - .get_user(&user_id)? - .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; - let state_json = challenge.state_json.clone().ok_or_else(|| { - auth_error( - "missing_webauthn_state", - "passkey login state was not persisted", - ) - })?; - let state: PasskeyAuthentication = serde_json::from_str(&state_json) - .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; - let webauthn = webauthn_for_challenge(&api.config, &challenge)?; - let auth_result = webauthn - .finish_passkey_authentication(&request.credential, &state) - .map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?; - let credential_id = serde_json::to_value(auth_result.cred_id()) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .ok_or_else(|| { + let state: PasskeyLoginState = + serde_json::from_str(challenge.state_json.as_deref().ok_or_else(|| { auth_error( - "invalid_webauthn_credential_id", - "verified credential id was not serializable", + "missing_webauthn_state", + "passkey login state was not persisted", ) - })?; + })?) + .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; + let credential_id = serde_json::to_value(webauthn_rs::prelude::Base64UrlSafeData::from( + request.credential.get_credential_id().to_vec(), + )) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .ok_or_else(|| auth_error("invalid_webauthn_credential_id", "credential id is invalid"))?; let stored = api .store .get_passkey_credential(&credential_id)? .ok_or_else(|| { auth_error( "unknown_passkey_credential", - "verified passkey credential is not registered", + "passkey credential is not registered", ) })?; - if stored.user_id != user.user_id { + let user = api + .store + .get_user(&stored.user_id)? + .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; + if api.store.is_user_pending_activation(&user.user_id)? { return Err(auth_error( - "passkey_user_mismatch", - "verified passkey credential does not belong to the challenged user", + "passkey_activation_required", + "initial passkey activation is required", ) .into()); } + let mut passkey: Passkey = serde_json::from_str(&stored.public_key_cose) + .map_err(|error| auth_error("webauthn_passkey_deserialize_failed", &error.to_string()))?; + if passkey_credential_id(&passkey)? != credential_id { + return Err(auth_error( + "passkey_user_mismatch", + "stored credential id does not match key material", + ) + .into()); + } + let webauthn = webauthn_for_challenge(&api.config, &challenge)?; + let auth_result = match state { + PasskeyLoginState::Discoverable(state) => { + // userHandle is unsigned routing data, never identity authority. The + // candidate owner comes only from the exact saved credential ID; + // its stable handle must agree, and the saved key must verify below. + if challenge.user_id.is_some() + || request.credential.get_user_unique_id() + != Some(passkey_user_handle(&user.account_id).as_bytes().as_slice()) + { + return Err(auth_error( + "passkey_user_mismatch", + "credential owner/user handle mismatch; use explicit legacy login for old keys", + ) + .into()); + } + webauthn.finish_discoverable_authentication( + &request.credential, + state, + &[passkey.clone().into()], + ) + } + PasskeyLoginState::Legacy(state) => { + if challenge.user_id.as_deref() != Some(user.user_id.as_str()) { + return Err(auth_error( + "passkey_user_mismatch", + "credential does not belong to the challenged user", + ) + .into()); + } + webauthn.finish_passkey_authentication(&request.credential, &state) + } + } + .map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?; + if auth_result.cred_id().as_slice() != request.credential.get_credential_id() { + return Err(auth_error( + "passkey_user_mismatch", + "verified credential differs from saved credential", + ) + .into()); + } + if stored.sign_count > 0 + && auth_result.counter() > 0 + && u64::from(auth_result.counter()) <= stored.sign_count + { + return Err(auth_error( + "webauthn_login_verification_failed", + "credential counter did not advance", + ) + .into()); + } + passkey.update_credential(&auth_result); + let public_key_cose = serde_json::to_string(&passkey) + .map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?; api.store .upsert_passkey_credential(&PasskeyCredentialRecord { credential_id, user_id: user.user_id.clone(), - public_key_cose: stored.public_key_cose, + public_key_cose, transports_json: stored.transports_json, sign_count: u64::from(auth_result.counter()), created_at: stored.created_at, last_used_at: Some(crate::auth::now_rfc3339()), })?; - issue_browser_session(&api, user) + issue_browser_session(api, user) } fn issue_browser_session( @@ -51692,16 +51789,20 @@ mod tests { } /// Generated HTTP admission, real SQLite and WebAuthn verification are connected - /// here. Only the physical authenticator is replaced with SoftPasskey; all + /// here. The authenticator is a resident software model with a SoftPasskey signing engine; all /// identities used for addition come from cookies/tokens issued by real login. mod passkey_security { use super::*; - use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + mod resident_model { + include!("../../../tests/e2e/src/resident_authenticator.rs"); + } + use resident_model::ResidentAuthenticator; include!("server_activation_tests.rs"); + include!("server_discoverable_tests.rs"); const ORIGIN_URL: &str = "https://workspace.test"; - type Authenticator = WebauthnAuthenticator; + type Authenticator = ResidentAuthenticator; struct Fixture { _root: tempfile::TempDir, @@ -52029,7 +52130,7 @@ mod tests { } fn authenticator() -> Authenticator { - WebauthnAuthenticator::new(SoftPasskey::new(true)) + ResidentAuthenticator::new(true) } fn rejected(result: (StatusCode, HeaderMap, Value), status: StatusCode, code: &str) { @@ -52220,17 +52321,17 @@ mod tests { .unwrap() .is_none() ); - rejected( - f.post( + let (status, _, options) = f + .post( "/api/auth/passkeys/login/options", None, Some(ORIGIN_URL), serde_json::json!({}), ) - .await, - StatusCode::BAD_REQUEST, - "passkey_handle_required", - ); + .await; + assert_eq!(status, StatusCode::OK, "{options}"); + let options: PasskeyLoginOptionsResponse = serde_json::from_value(options).unwrap(); + assert!(options.public_key.public_key.allow_credentials.is_empty()); assert_eq!(f.credentials(&user), originals); } @@ -53075,113 +53176,152 @@ mod tests { #[tokio::test] async fn legacy_random_user_handle_credential_authenticates_before_and_after_stable_handle_addition() { - let f = Fixture::new().await; - let api = ServerAuthApi::from(&f.api); - let user = allocate_new_user(&api, "legacy", None).unwrap(); - let random_handle = Uuid::now_v7(); - assert_ne!(random_handle, passkey_user_handle(&user.account_id)); - let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap(); - let (options, state) = webauthn - .start_passkey_registration(random_handle, &user.handle, &user.display_name, None) - .unwrap(); - let legacy_user_handle = options.public_key.user.id.clone(); - assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes()); - let mut legacy_authenticator = authenticator(); - let credential = legacy_authenticator - .do_registration(Url::parse(ORIGIN_URL).unwrap(), options) - .unwrap(); - let passkey = webauthn - .finish_passkey_registration(&credential, &state) - .unwrap(); - let record = PasskeyCredentialRecord { - credential_id: passkey_credential_id(&passkey).unwrap(), - user_id: user.user_id.clone(), - public_key_cose: serde_json::to_string(&passkey).unwrap(), - transports_json: None, - sign_count: 0, - created_at: crate::auth::now_rfc3339(), - last_used_at: None, - }; - f.api - .store - .register_verified_passkey( - &PasskeyRegistrationTarget::NewAccount { user: user.clone() }, - &record, - ) - .unwrap(); - let expected = user_response(user.clone()); - for returned_handle in [None, Some(legacy_user_handle.as_slice())] { - let (_, logged_in) = f - .login_session_with_user_handle( - "legacy", - &mut legacy_authenticator, - returned_handle, + for resident in [false, true] { + let f = Fixture::new().await; + let api = ServerAuthApi::from(&f.api); + let user = allocate_new_user(&api, "legacy", None).unwrap(); + let random_handle = Uuid::now_v7(); + assert_ne!(random_handle, passkey_user_handle(&user.account_id)); + let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap(); + let (mut options, state) = webauthn + .start_passkey_registration( + random_handle, + &user.handle, + &user.display_name, + None, ) - .await; - assert_eq!(logged_in, expected); - } - let (cookie, _) = f - .login_session_with_user_handle( - "legacy", - &mut legacy_authenticator, - Some(legacy_user_handle.as_slice()), - ) - .await; - let before_add = f.credentials(&expected); - let mut added = authenticator(); - let options = f.options("addition", Some(&cookie), "").await; - let stable_user_handle = options.public_key.public_key.user.id.clone(); - assert_eq!( - stable_user_handle.as_slice(), - passkey_user_handle(&user.account_id).as_bytes() - ); - assert_ne!(stable_user_handle, legacy_user_handle); - let request = Fixture::credential(options, &mut added); - let (status, _, body) = f - .post( - "/api/auth/passkeys/addition/complete", - Some(&cookie), - Some(ORIGIN_URL), - request, - ) - .await; - assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!( - serde_json::from_value::(body) - .unwrap() - .user, - expected - ); - assert_eq!( + .unwrap(); + if resident { + let selection = options.public_key.authenticator_selection.as_mut().unwrap(); + selection.require_resident_key = true; + selection.resident_key = + Some(webauthn_rs_proto::ResidentKeyRequirement::Required); + } + let legacy_user_handle = options.public_key.user.id.clone(); + assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes()); + let mut legacy_authenticator = authenticator(); + let credential = legacy_authenticator + .do_registration(Url::parse(ORIGIN_URL).unwrap(), options) + .unwrap(); + let passkey = webauthn + .finish_passkey_registration(&credential, &state) + .unwrap(); + let record = PasskeyCredentialRecord { + credential_id: passkey_credential_id(&passkey).unwrap(), + user_id: user.user_id.clone(), + public_key_cose: serde_json::to_string(&passkey).unwrap(), + transports_json: None, + sign_count: 0, + created_at: crate::auth::now_rfc3339(), + last_used_at: None, + }; f.api .store - .get_passkey_credential(&record.credential_id) - .unwrap() - .as_ref(), - Some(&before_add[0]) - ); - for returned_handle in [None, Some(legacy_user_handle.as_slice())] { - let (_, logged_in) = f + .register_verified_passkey( + &PasskeyRegistrationTarget::NewAccount { user: user.clone() }, + &record, + ) + .unwrap(); + let expected = user_response(user.clone()); + if resident { + let normal = f.discoverable_request(&mut legacy_authenticator).await; + rejected( + f.post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(normal).unwrap(), + ) + .await, + StatusCode::BAD_REQUEST, + "passkey_user_mismatch", + ); + } else { + let normal = f.discoverable_options().await; + assert!( + legacy_authenticator + .do_authentication(Url::parse(ORIGIN_URL).unwrap(), normal.public_key) + .is_err(), + "a non-resident key cannot be discovered without an allow list" + ); + } + for returned_handle in [None, Some(legacy_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle( + "legacy", + &mut legacy_authenticator, + returned_handle, + ) + .await; + assert_eq!(logged_in, expected); + } + let (cookie, _) = f .login_session_with_user_handle( "legacy", &mut legacy_authenticator, - returned_handle, + Some(legacy_user_handle.as_slice()), ) .await; - assert_eq!(logged_in, expected); - } - for returned_handle in [None, Some(stable_user_handle.as_slice())] { - let (_, logged_in) = f - .login_session_with_user_handle("legacy", &mut added, returned_handle) + let before_add = f.credentials(&expected); + let mut added = authenticator(); + let options = f.options("addition", Some(&cookie), "").await; + let stable_user_handle = options.public_key.public_key.user.id.clone(); + assert_eq!( + stable_user_handle.as_slice(), + passkey_user_handle(&user.account_id).as_bytes() + ); + assert_ne!(stable_user_handle, legacy_user_handle); + let request = Fixture::credential(options, &mut added); + let (status, _, body) = f + .post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + request, + ) .await; - assert_eq!(logged_in, expected); + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + serde_json::from_value::(body) + .unwrap() + .user, + expected + ); + assert_eq!( + f.api + .store + .get_passkey_credential(&record.credential_id) + .unwrap() + .as_ref(), + Some(&before_add[0]) + ); + for returned_handle in [None, Some(legacy_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle( + "legacy", + &mut legacy_authenticator, + returned_handle, + ) + .await; + assert_eq!(logged_in, expected); + } + for returned_handle in [None, Some(stable_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle("legacy", &mut added, returned_handle) + .await; + assert_eq!(logged_in, expected); + } + assert_eq!(f.discoverable_login(&mut added).await, expected); } } } #[tokio::test] async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() { - use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + mod resident_model { + include!("../../../tests/e2e/src/resident_authenticator.rs"); + } + use resident_model::ResidentAuthenticator; let workspace = tempfile::tempdir().unwrap(); let mut api = test_api(workspace.path()).await; @@ -53198,7 +53338,7 @@ mod tests { let origin = origin.clone(); let cookie_name = cookie_name.clone(); let app = generated_auth_contract_router(ServerApiContractService::Workspace(api)); - let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut authenticator = ResidentAuthenticator::new(true); let authenticator_origin = url::Url::parse(&origin).unwrap(); let registration_options_response = app diff --git a/crates/workspace-server/src/server_activation_tests.rs b/crates/workspace-server/src/server_activation_tests.rs index 5a50da553..52b7f3519 100644 --- a/crates/workspace-server/src/server_activation_tests.rs +++ b/crates/workspace-server/src/server_activation_tests.rs @@ -1,5 +1,5 @@ // Production generated routes, SQLite and WebAuthn verifier share the passkey -// security fixture. Only the authenticator is SoftPasskey, not a browser/OS key. +// security fixture. The authenticator is a resident model, not a browser/OS key. // The ordinary Server test target selects these isolated integration cases. impl Fixture { diff --git a/crates/workspace-server/src/server_discoverable_tests.rs b/crates/workspace-server/src/server_discoverable_tests.rs new file mode 100644 index 000000000..6fa9d0166 --- /dev/null +++ b/crates/workspace-server/src/server_discoverable_tests.rs @@ -0,0 +1,335 @@ +// The generated HTTP router, real SQLite and production verifier/session issuer +// are connected. Only the authenticator is the shared resident software model; +// SoftPasskey alone is not resident-capable and browser-native UX is not claimed. + +impl Fixture { + async fn discoverable_options(&self) -> PasskeyLoginOptionsResponse { + let (status, _, body) = self + .post( + "/api/auth/passkeys/login/options", + None, + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let options: PasskeyLoginOptionsResponse = serde_json::from_value(body).unwrap(); + assert!(options.public_key.public_key.allow_credentials.is_empty()); + assert!(options.public_key.mediation.is_none()); + assert_eq!( + serde_json::to_value(&options.public_key).unwrap()["publicKey"]["userVerification"], + "required" + ); + let owner: Option = self + .api + .config_store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT user_id FROM auth_challenges WHERE challenge_id=?1", + [&options.challenge_id], + |row| row.get(0), + )?) + }) + .unwrap(); + assert!( + owner.is_none(), + "normal challenge must not preselect any account" + ); + options + } + + async fn discoverable_request(&self, key: &mut Authenticator) -> PasskeyLoginCompleteRequest { + let options = self.discoverable_options().await; + let credential = key + .do_authentication(Url::parse(ORIGIN_URL).unwrap(), options.public_key) + .unwrap(); + PasskeyLoginCompleteRequest { + challenge_id: options.challenge_id, + credential, + } + } + + async fn discoverable_login(&self, key: &mut Authenticator) -> AuthenticatedUser { + let request = self.discoverable_request(key).await; + let (status, headers, body) = self + .post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(request).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let user: AuthUserResponse = serde_json::from_value(body).unwrap(); + let cookie = headers[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap(); + let token = cookie.split_once('=').unwrap().1; + let live = crate::auth::resolve_request_actor_sync( + self.api.store.as_ref(), + None, + Some(token.to_string()), + ) + .unwrap() + .unwrap(); + assert_eq!(live.user_id, user.user.user_id); + assert_eq!(live.account_id, user.user.account_id); + user.user + } +} + +#[tokio::test] +async fn resident_selection_with_multiple_accounts_and_all_enrollment_purposes_issues_only_owner_session() + { + let f = Fixture::new().await; + let mut device = authenticator(); + let (alice_cookie, alice) = f.enroll("registration", None, "alice", &mut device).await; + let alice_id = f.credentials(&alice)[0].credential_id.clone(); + let (_, bob) = f.enroll("registration", None, "bob", &mut device).await; + let bob_id = f.credentials(&bob)[0].credential_id.clone(); + for (id, expected) in [(alice_id, alice.clone()), (bob_id, bob)] { + let id: webauthn_rs::prelude::Base64UrlSafeData = + serde_json::from_value(Value::String(id)).unwrap(); + device.select(&id); + assert_eq!(f.discoverable_login(&mut device).await, expected); + } + let mut added = authenticator(); + f.enroll("addition", Some(&alice_cookie), "", &mut added) + .await; + assert_eq!(f.discoverable_login(&mut added).await, alice); + // Registration policy controls account creation, not login or own addition. + let admin = f.activation_admin().await; + assert_eq!(f.discoverable_login(&mut added).await, alice); + let issued = f.issue_activation(&admin, "pending-discoverable").await; + let mut activated = authenticator(); + let options = f.activation_options(&issued).await; + let body = Fixture::credential(options, &mut activated); + let (status, _, body) = f + .post( + "/api/auth/activation/complete", + None, + Some(ORIGIN_URL), + body, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(f.discoverable_login(&mut activated).await, issued.user); +} + +#[tokio::test] +async fn discoverable_verifier_rejects_unknown_owner_signature_origin_rp_uv_expiry_reuse_and_revocation() + { + let f = Fixture::new().await; + let mut key = authenticator(); + let (_, alice) = f.enroll("registration", None, "alice", &mut key).await; + for defect in [ + "unknown", + "handle", + "signature", + "origin", + "rp", + "uv", + "expired", + "consumed", + "stored_id", + "stored_owner", + ] { + let mut request = f.discoverable_request(&mut key).await; + let original = f.credentials(&alice)[0].clone(); + let change_byte = + |value: &mut webauthn_rs::prelude::Base64UrlSafeData, index: usize, bit: u8| { + let mut bytes = value.to_vec(); + bytes[index] ^= bit; + *value = bytes.into(); + }; + match defect { + "unknown" => change_byte(&mut request.credential.raw_id, 0, 1), + "handle" => request.credential.response.user_handle = Some(vec![0; 16].into()), + "signature" => change_byte(&mut request.credential.response.signature, 0, 1), + "origin" => { + let mut data: Value = + serde_json::from_slice(&request.credential.response.client_data_json).unwrap(); + data["origin"] = Value::String("https://other.test".into()); + request.credential.response.client_data_json = + serde_json::to_vec(&data).unwrap().into(); + } + "rp" => change_byte(&mut request.credential.response.authenticator_data, 0, 1), + "uv" => change_byte(&mut request.credential.response.authenticator_data, 32, 4), + "expired" => f.expire_challenge(&request.challenge_id), + "consumed" => { + f.api + .store + .consume_auth_challenge_by_id( + &request.challenge_id, + "passkey_login", + &crate::auth::now_rfc3339(), + ) + .unwrap(); + } + "stored_id" => { + let mut corrupt = original.clone(); + let mut value: Value = serde_json::from_str(&corrupt.public_key_cose).unwrap(); + // Keep the indexed ID, replace the embedded verified credential ID. + value["cred"]["cred_id"] = Value::String("YW5vdGhlci1jcmVkZW50aWFs".into()); + corrupt.public_key_cose = serde_json::to_string(&value).unwrap(); + f.api.store.upsert_passkey_credential(&corrupt).unwrap(); + } + "stored_owner" => { + let (_, bob) = f + .enroll("registration", None, "bob", &mut authenticator()) + .await; + f.api + .config_store + .with_conn(|conn| { + conn.execute( + "UPDATE passkey_credentials SET user_id=?1 WHERE credential_id=?2", + [&bob.user_id, &original.credential_id], + )?; + Ok(()) + }) + .unwrap(); + } + _ => unreachable!(), + } + let (status, headers, body) = f + .post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(&request).unwrap(), + ) + .await; + assert!(status.is_client_error(), "{defect}: {body}"); + assert!( + !headers.contains_key(axum::http::header::SET_COOKIE), + "{defect}" + ); + let (status, headers, _) = f + .post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(request).unwrap(), + ) + .await; + assert!( + status.is_client_error(), + "failed attempts must consume their ceremony: {defect}" + ); + assert!(!headers.contains_key(axum::http::header::SET_COOKIE)); + f.api + .config_store + .with_conn(|conn| { + conn.execute( + "UPDATE passkey_credentials SET user_id=?1 WHERE credential_id=?2", + [&original.user_id, &original.credential_id], + )?; + Ok(()) + }) + .unwrap(); + f.api.store.upsert_passkey_credential(&original).unwrap(); + } + assert_eq!(f.discoverable_login(&mut key).await, alice); +} + +#[tokio::test] +async fn signed_assertions_with_foreign_origin_rp_or_without_uv_are_rejected_by_required_verifier() +{ + let f = Fixture::new().await; + let mut key = authenticator(); + let (_, alice) = f.enroll("registration", None, "alice", &mut key).await; + let id: webauthn_rs::prelude::Base64UrlSafeData = serde_json::from_value(Value::String( + f.credentials(&alice)[0].credential_id.clone(), + )) + .unwrap(); + for defect in ["origin", "rp", "uv"] { + let mut options = f.discoverable_options().await; + let mut origin = ORIGIN_URL; + match defect { + "origin" => origin = "https://sub.workspace.test", + "rp" => { + origin = "https://other.test"; + options.public_key.public_key.rp_id = "other.test".into(); + // Explicit signer selection produces a genuinely signed assertion + // for the wrong RP; normal Server options remain empty/unchanged. + options.public_key.public_key.allow_credentials = + vec![webauthn_rs_proto::AllowCredentials { + type_: "public-key".into(), + id: id.clone(), + transports: None, + }]; + } + "uv" => { + options.public_key.public_key.user_verification = + webauthn_rs_proto::UserVerificationPolicy::Preferred + } + _ => unreachable!(), + } + let mut credential = key + .do_authentication(Url::parse(origin).unwrap(), options.public_key) + .unwrap(); + if defect == "uv" { + assert_eq!(credential.response.authenticator_data[32] & 4, 0); + } + credential.response.user_handle = Some( + passkey_user_handle(&alice.account_id) + .as_bytes() + .to_vec() + .into(), + ); + let request = PasskeyLoginCompleteRequest { + challenge_id: options.challenge_id, + credential, + }; + rejected( + f.post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(request).unwrap(), + ) + .await, + StatusCode::BAD_REQUEST, + "webauthn_login_verification_failed", + ); + } + assert_eq!(f.discoverable_login(&mut key).await, alice); +} + +#[tokio::test] +async fn non_resident_library_authenticator_refuses_registration_without_creating_identity() { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + let f = Fixture::new().await; + let options = f.options("registration", None, "unsupported-key").await; + let selection = options + .public_key + .public_key + .authenticator_selection + .as_ref() + .unwrap(); + assert!(selection.require_resident_key); + assert_eq!( + selection.resident_key, + Some(webauthn_rs_proto::ResidentKeyRequirement::Required) + ); + assert_eq!( + selection.user_verification, + webauthn_rs_proto::UserVerificationPolicy::Required + ); + let result = WebauthnAuthenticator::new(SoftPasskey::new(true)) + .do_registration(Url::parse(ORIGIN_URL).unwrap(), options.public_key); + assert!( + result.is_err(), + "SoftPasskey itself must not be reported as resident-capable" + ); + assert!( + f.api + .store + .get_user_by_handle("unsupported-key") + .unwrap() + .is_none() + ); +} diff --git a/openapi/server-api.json b/openapi/server-api.json index 5ed145eaa..e601f843e 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -6661,7 +6661,7 @@ }, "PasskeyLoginOptionsRequest": { "additionalProperties": false, - "description": "Identified-account login. Missing handles are rejected, not resolved to an implicit user.\n\nThe nullable wire field permits an explicit authentication error for old callers; it does not\nadvertise a discoverable-login ceremony.", + "description": "Normal discoverable login omits the handle: the authenticator selects a credential.\nAn explicit handle selects legacy identified-account login, for non-resident keys or\nkeys registered with an old random WebAuthn user handle. This never adds a key or\nfalls back to another identity. Authenticate first, then enroll an RK from Account.\nExisting challenge states are short-lived and are not migrated to the new ceremony.", "properties": { "browser_origin": { "type": [ @@ -17831,7 +17831,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8" + "x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" }, "openapi": "3.1.0", "paths": { diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index beadf526b..9638ae45a 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -20,6 +20,7 @@ tempfile.workspace = true [dev-dependencies] server-api.workspace = true +webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } [[test]] diff --git a/tests/e2e/src/resident_authenticator.rs b/tests/e2e/src/resident_authenticator.rs new file mode 100644 index 000000000..5e20c9072 --- /dev/null +++ b/tests/e2e/src/resident_authenticator.rs @@ -0,0 +1,99 @@ +// 共有テスト用のresident認証器モデル。ブラウザ/OSの実装ではない。 +// SoftPasskey 0.5.5自体はRK要求を拒否しdiscoverできないため、このモデルが +// RP・credential ID・user handleのresident storeと明示選択を所有し、 +// SoftPasskeyはattestation/署名エンジンとしてだけ使う。本番verifier、 +// challenge、session発行を固定成功fakeへ置換しない。UVはテスト用bitの +// 模擬であり、物理的PIN/生体確認やbrowser-nativeの互換性は未検証。 +use std::collections::BTreeMap; +use webauthn_authenticator_rs::prelude::{Url, WebauthnCError}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs_proto::{ + AllowCredentials, CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, + RequestChallengeResponse, ResidentKeyRequirement, +}; + +pub struct ResidentAuthenticator { + signer: WebauthnAuthenticator, + resident: BTreeMap, (String, Vec)>, + selected: Option>, +} + +impl ResidentAuthenticator { + pub fn new(simulate_uv: bool) -> Self { + Self { + signer: WebauthnAuthenticator::new(SoftPasskey::new(simulate_uv)), + resident: BTreeMap::new(), + selected: None, + } + } + + pub fn select(&mut self, credential_id: &[u8]) { + assert!( + self.resident.contains_key(credential_id), + "selection must be resident on this authenticator" + ); + self.selected = Some(credential_id.to_vec()); + } + + pub fn do_registration( + &mut self, + origin: Url, + mut options: CreationChallengeResponse, + ) -> Result { + let rp = options.public_key.rp.id.clone(); + let user = options.public_key.user.id.to_vec(); + let resident = options + .public_key + .authenticator_selection + .as_ref() + .is_some_and(|selection| { + selection.require_resident_key + || selection.resident_key == Some(ResidentKeyRequirement::Required) + }); + if resident { + // 要求されたresident保存はこのモデルが担当し、署名だけを + // non-residentエンジンへ渡す。Serverの要求を黙って無視しない。 + let selection = options.public_key.authenticator_selection.as_mut().unwrap(); + selection.require_resident_key = false; + selection.resident_key = Some(ResidentKeyRequirement::Discouraged); + } + let credential = self.signer.do_registration(origin, options)?; + if resident { + self.resident.insert(credential.raw_id.to_vec(), (rp, user)); + self.selected = None; + } + Ok(credential) + } + + pub fn do_authentication( + &mut self, + origin: Url, + mut options: RequestChallengeResponse, + ) -> Result { + let discovered = options.public_key.allow_credentials.is_empty(); + let user_handle = if discovered { + let candidates: Vec<_> = self + .resident + .iter() + .filter(|(_, (rp, _))| rp == &options.public_key.rp_id) + .collect(); + let (id, (_, handle)) = match self.selected.as_ref() { + Some(selected) => candidates.into_iter().find(|(id, _)| *id == selected), + None if candidates.len() == 1 => candidates.into_iter().next(), + _ => None, + } + .ok_or(WebauthnCError::NotSupported)?; + options.public_key.allow_credentials = vec![AllowCredentials { + type_: "public-key".into(), + id: id.clone().into(), + transports: None, + }]; + Some(handle.clone()) + } else { + None + }; + let mut credential = self.signer.do_authentication(origin, options)?; + credential.response.user_handle = user_handle.map(Into::into); + Ok(credential) + } +} diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 39144fa41..95dce869b 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,6 +1,6 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 -//! 明示handle login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 -//! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。 +//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 +//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 @@ -24,7 +24,9 @@ use server_api::{ PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse, WhoamiResponse, }; -use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +#[path = "../src/resident_authenticator.rs"] +mod resident_model; +use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; type Result = std::result::Result>; @@ -99,7 +101,7 @@ fn register( base: &str, artifacts: &Path, handle: &str, - authenticator: &mut WebauthnAuthenticator, + authenticator: &mut ResidentAuthenticator, ) -> Result<(AuthUserResponse, String, Value)> { let options: PasskeyRegistrationOptionsResponse = decode(send( client.post(format!("{base}/api/auth/passkeys/registration/options")) @@ -130,19 +132,19 @@ fn login( base: &str, artifacts: &Path, handle: &str, - authenticator: &mut WebauthnAuthenticator, + authenticator: &mut ResidentAuthenticator, ) -> Result<(AuthUserResponse, String)> { let options: PasskeyLoginOptionsResponse = decode(send( client .post(format!("{base}/api/auth/passkeys/login/options")) .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"handle": handle, "browser_origin": BROWSER_ORIGIN})), - "explicit handle login options", + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "discoverable login options", artifacts, )?)?; let credential = authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?; - session(send( + let result = session(send( client .post(format!("{base}/api/auth/passkeys/login/complete")) .header(ORIGIN, BROWSER_ORIGIN) @@ -152,7 +154,9 @@ fn login( }), "verified login complete", artifacts, - )?) + )?)?; + assert_eq!(result.0.user.handle, handle); + Ok(result) } fn http_client() -> Result { @@ -265,7 +269,7 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result fn host_admin_login( environment: &WorkerE2eEnvironment, client: &Client, - key: &mut WebauthnAuthenticator, + key: &mut ResidentAuthenticator, ) -> Result<(AuthUserResponse, String)> { let base = environment .server_url @@ -338,7 +342,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow let artifacts = &environment.artifacts_dir; let client = http_client()?; policy(&client, base, artifacts, false)?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; policy(&client, base, artifacts, false)?; set_policy(&client, base, artifacts, &admin_cookie, true)?; @@ -379,7 +383,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow "auth_required", )?; - let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut original_key = ResidentAuthenticator::new(true); let (registered, registration_cookie, user_handle) = register( &client, base, @@ -418,7 +422,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow )?, StatusCode::BAD_REQUEST, "auth_handle_exists")?; // 2 usersを実登録し、handle省略が最古userへfallbackしないことを検証する。 - let mut other_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut other_key = ResidentAuthenticator::new(true); let other_handle = format!("{}-other", environment.account_handle); let (other, _, other_user_handle) = register(&client, base, artifacts, &other_handle, &mut other_key)?; @@ -427,18 +431,15 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow access(&client, base, artifacts, &cookie, false)?; set_policy(&client, base, artifacts, &admin_cookie, false)?; // 以下の本人鍵追加と両方の鍵によるloginは、自己登録停止中にも必要。 - rejected( - send( - client - .post(format!("{base}/api/auth/passkeys/login/options")) - .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), - "omitted handle cannot select first account", - artifacts, - )?, - StatusCode::BAD_REQUEST, - "passkey_handle_required", - )?; + let options: PasskeyLoginOptionsResponse = decode(send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "normal login has no account inventory", + artifacts, + )?)?; + assert!(options.public_key.public_key.allow_credentials.is_empty()); for (field, value) in [ ("handle", &other.user.handle), @@ -494,7 +495,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow user_handle, "same account must retain its WebAuthn user handle" ); - let mut added_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut added_key = ResidentAuthenticator::new(true); let credential = added_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?; let complete = PasskeyRegistrationCompleteRequest { challenge_id: options.challenge_id, @@ -725,10 +726,10 @@ fn workspace_catalog_zero_one_multiple_preserves_explicit_selection_and_actor_ow StatusCode::FORBIDDEN, "authenticated owner", )?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; set_policy(&client, base, artifacts, &admin_cookie, true)?; - let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut key = ResidentAuthenticator::new(true); let (registered, registration_cookie, _) = register( &client, base, @@ -881,7 +882,7 @@ fn ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_a base: &str, artifacts: &Path, user: &AuthUserResponse, - key: &mut WebauthnAuthenticator, + key: &mut ResidentAuthenticator, ) -> Result { let flow: Value = decode(send( client @@ -1007,7 +1008,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() let artifacts = environment.artifacts_dir.clone(); policy(&client, &base, &artifacts, false)?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; policy(&client, &base, &artifacts, false)?; for path in ["/api/hosting/access", "/api/hosting/settings"] { @@ -1102,7 +1103,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() access(&client, &base, &artifacts, &admin_cookie, true)?; // 拒否されたhandleも予約されておらず、許可後には通常の新規登録ができる。 - let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut ordinary_key = ResidentAuthenticator::new(true); let (ordinary, ordinary_cookie, _) = register( &client, &base, @@ -1172,7 +1173,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() "registration starts while enabled", &artifacts, )?)?; - let mut pending_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut pending_key = ResidentAuthenticator::new(true); let credential = pending_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?; let complete = PasskeyRegistrationCompleteRequest { challenge_id: options.challenge_id, @@ -1396,7 +1397,7 @@ fn activation_options( fn activation_credential( options: PasskeyRegistrationOptionsResponse, - key: &mut WebauthnAuthenticator, + key: &mut ResidentAuthenticator, ) -> Result { Ok(PasskeyRegistrationCompleteRequest { challenge_id: options.challenge_id, @@ -1461,7 +1462,7 @@ fn activation_with_disabled_self_registration_survives_reopen_and_enables_only_p let client = http_client()?; let artifacts = environment.artifacts_dir.clone(); let mut base = environment.server_url.clone().ok_or("missing server URL")?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; policy(&client, &base, &artifacts, false)?; let issue = create_pending( @@ -1496,7 +1497,7 @@ fn activation_with_disabled_self_registration_survives_reopen_and_enables_only_p .ok_or("missing restarted server URL")?; policy(&client, &base, &artifacts, false)?; let options = activation_options(&client, &base, &artifacts, &issue)?; - let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut key = ResidentAuthenticator::new(true); let complete = activation_credential(options, &mut key)?; let (activated, cookie) = session(activation_success(send( activation_complete_request(&client, &base, &complete), @@ -1558,7 +1559,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims .server_url .as_deref() .ok_or("missing server URL")?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; let ordinary = create_pending( &client, @@ -1567,7 +1568,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims &admin_cookie, &environment.account_handle, )?; - let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut ordinary_key = ResidentAuthenticator::new(true); let complete = activation_credential( activation_options(&client, base, artifacts, &ordinary)?, &mut ordinary_key, @@ -1721,7 +1722,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp .as_deref() .ok_or("missing server URL")?; let artifacts = &environment.artifacts_dir; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; let issue = create_pending( &client, @@ -1759,7 +1760,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp )?)?; let options = activation_options(&client, base, artifacts, &issue)?; let challenge_id = options.challenge_id.clone(); - let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut key = ResidentAuthenticator::new(true); let complete = activation_credential(options, &mut key)?; // code/限定challenge IDのどちらも通常tokenではない。通常API、Workspace、device approvalに流用しない。 @@ -1860,7 +1861,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp )?)?; // 別IDに対する誤ったcode入力が元のaccountを消費していない。 let _ = activation_options(&client, base, artifacts, &other)?; - let mut replacement_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut replacement_key = ResidentAuthenticator::new(true); let replacement_complete = activation_credential( activation_options(&client, base, artifacts, &replacement)?, &mut replacement_key, @@ -1884,7 +1885,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp "start distinct public registration ceremony", artifacts, )?)?; - let mut public_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut public_key = ResidentAuthenticator::new(true); let public_complete = activation_credential(public_options, &mut public_key)?; activation_denied(send( activation_complete_request(&client, base, &public_complete), @@ -1900,7 +1901,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp "start distinct authenticated self-addition ceremony", artifacts, )?)?; - let mut addition_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut addition_key = ResidentAuthenticator::new(true); let addition_complete = activation_credential(addition_options, &mut addition_key)?; activation_denied(send( activation_complete_request(&client, base, &addition_complete), @@ -1932,7 +1933,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only .server_url .as_deref() .ok_or("missing server URL")?; - let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut admin_key = ResidentAuthenticator::new(true); let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; let issue = create_pending( &client, @@ -1941,7 +1942,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only &admin_cookie, &environment.account_handle, )?; - let mut bad_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut bad_key = ResidentAuthenticator::new(true); let bad_complete = activation_credential( activation_options(&client, base, artifacts, &issue)?, &mut bad_key, @@ -1965,12 +1966,12 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only "failed verifier did not activate or save a credential", artifacts, )?)?; - let mut first_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut first_key = ResidentAuthenticator::new(true); let first = activation_credential( activation_options(&client, base, artifacts, &issue)?, &mut first_key, )?; - let mut second_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let mut second_key = ResidentAuthenticator::new(true); let second = activation_credential( activation_options(&client, base, artifacts, &issue)?, &mut second_key, diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 39564d222..146d0ce28 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) +// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 5e965ec0b..74a8c9547 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) +// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 1613cbbab..138ed0fd1 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) +// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 988151e8f..2dbcf6109 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) +// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ccb0791a6..8fff5d2ea 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) +// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check