fix(auth): confirm passkey operations and preserve safe legacy migration

Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
This commit is contained in:
2026-10-26 07:18:23 +09:00
parent b284934e34
commit f17b5212c7
66 changed files with 4716 additions and 532 deletions
@@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into())
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
+17 -1
View File
@@ -228,13 +228,21 @@ fn resolve_whoami_sync(
use server_api::{BrowserSessionState, WhoamiResponse};
let anonymous = || WhoamiResponse {
actor: None,
authentication_reference: None,
session: BrowserSessionState::Anonymous {},
};
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
if let Some((actor, expires_at)) =
resolve_credentials_sync(store, bearer, session_token.clone())?
resolve_credentials_sync(store, bearer.clone(), session_token.clone())?
{
let credential = match actor.auth_method {
ActorAuthMethod::ApiToken => bearer.as_deref(),
ActorAuthMethod::BrowserSession => session_token.as_deref(),
}
.ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?;
let authentication_reference = Some(authentication_reference(&token_hash(credential)));
return Ok(WhoamiResponse {
authentication_reference,
actor: Some(actor),
session: BrowserSessionState::Authenticated { expires_at },
});
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
return Ok(WhoamiResponse {
actor: None,
authentication_reference: None,
session: BrowserSessionState::Expired {},
});
}
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
Ok(anonymous())
}
/// Domain-separated public comparison value; never accepted as a credential.
pub(crate) fn authentication_reference(credential_hash: &str) -> String {
token_hash(&format!(
"passkey-authentication-reference:{credential_hash}"
))
}
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
@@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
// Reconstruct the prior composition before its new business capture existed.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
@@ -679,7 +687,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TABLE subject_create_recoveries;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
@@ -0,0 +1,18 @@
CREATE TABLE passkey_metadata (
credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
key_reference TEXT NOT NULL UNIQUE,
normal_login_verified_at TEXT
);
INSERT INTO passkey_metadata(credential_id, key_reference)
SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials;
CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials
BEGIN
INSERT INTO passkey_metadata(credential_id, key_reference)
VALUES (NEW.credential_id, lower(hex(randomblob(16))));
END;
CREATE TABLE passkey_addition_receipts (
challenge_id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
authentication_reference TEXT NOT NULL
);
+194 -5
View File
@@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
.merge(server_api::server_api_axum::hosting_settings_update(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_list(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_result(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_options(
service.clone(),
))
@@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService {
)
}
async fn auth_passkey_list(
&self,
context: server_api::ServerRequestContext,
) -> std::result::Result<server_api::PasskeyListResponse, server_api::RepositoryApiError> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_owner_authority(&api, &headers, false)?;
Ok::<_, ApiError>(server_api::PasskeyListResponse {
keys: api.store.list_passkey_summaries(&user.user_id)?,
user: user_response(user),
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_result(
&self,
context: server_api::ServerRequestContext,
request: server_api::PasskeyAdditionResultRequest,
) -> std::result::Result<
server_api::PasskeyAdditionResultResponse,
server_api::RepositoryApiError,
> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_addition_authority(&api, &headers)?;
if user.user_id != request.expected_user_id
|| user.account_id != request.expected_account_id
{
return Err(auth_error(
"passkey_authentication_changed",
"current authenticated owner does not match the operation owner",
)
.into());
}
let key = api.store.read_passkey_addition_result(
&user.user_id,
&request.expected_authentication_reference,
&request.challenge_id,
)?;
Ok::<_, ApiError>(match key {
Some(key) => server_api::PasskeyAdditionResultResponse::Saved {
user: user_response(user),
key,
},
None => server_api::PasskeyAdditionResultResponse::Unknown {},
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_options(
&self,
context: server_api::ServerRequestContext,
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
.db_executor()
.run(move || {
let (user, authentication) = passkey_addition_authority(&api, &headers)?;
require_expected_passkey_owner(
&user,
&authentication,
&request.expected_user_id,
&request.expected_account_id,
&request.expected_authentication_reference,
)?;
start_passkey_registration(
&api,
&headers,
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
fn passkey_addition_authority(
api: &ServerAuthApi,
headers: &HeaderMap,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
passkey_owner_authority(api, headers, true)
}
fn passkey_owner_authority(
api: &ServerAuthApi,
headers: &HeaderMap,
require_origin: bool,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
let auth = auth_public_config(&api.config);
let actor = crate::auth::resolve_request_actor_sync(
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
})?;
let binding = match actor.auth_method {
ActorAuthMethod::BrowserSession => {
if request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
return Err(Error::WorkspacePermissionDenied(
"cookie-authenticated passkey addition requires the configured Browser origin"
.into(),
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
Ok((user, binding))
}
fn require_expected_passkey_owner(
user: &UserRecord,
binding: &PasskeyAuthenticationBinding,
expected_user_id: &str,
expected_account_id: &str,
expected_authentication_reference: &str,
) -> ApiResult<()> {
if user.user_id != expected_user_id
|| user.account_id != expected_account_id
|| binding.reference() != expected_authentication_reference
{
return Err(auth_error(
"passkey_authentication_changed",
"authentication changed; refresh account before starting another operation",
)
.into());
}
Ok(())
}
fn start_passkey_registration(
api: &ServerAuthApi,
headers: &HeaderMap,
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
/// Credential commit precedes session issuance. If issuing the session or
/// delivering the response fails, the credential can already exist: log in with
/// that key rather than overwriting it or replaying the consumed completion.
/// After an unknown commit outcome, try login first. Restart new-account
/// Own additions save a receipt atomically with the credential: a currently
/// authenticated same owner can inspect it using the original operation binding
/// reference even if the response body was lost after Set-Cookie took effect.
/// This read does not authorize a completion or prove current session health.
/// For unknown new-account outcomes, try login first. Restart new-account
/// enrollment with fresh options only for an unused handle with no persisted
/// identity; legacy pre-created identities follow the migration policy on
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
request: PasskeyRegistrationCompleteRequest,
completion: EnrollmentCompletion<'_>,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
let authority = match completion {
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
_ => None,
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
&error.to_string(),
)
})?;
api.store.register_verified_passkey(
api.store.register_verified_passkey_with_result(
&envelope.target,
&PasskeyCredentialRecord {
credential_id: passkey_credential_id(&passkey)?,
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
created_at: crate::auth::now_rfc3339(),
last_used_at: None,
},
is_addition.then_some(request.challenge_id.as_str()),
)?;
issue_browser_session(api, user)
}
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
.into());
}
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
let auth_result = match state {
PasskeyLoginState::Discoverable(state) => {
// userHandle is unsigned routing data, never identity authority. The
@@ -30635,7 +30742,7 @@ fn complete_passkey_login(
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
api.store
.upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id,
credential_id: credential_id.clone(),
user_id: user.user_id.clone(),
public_key_cose,
transports_json: stored.transports_json,
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()),
})?;
if normal_login {
api.store
.mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?;
}
issue_browser_session(api, user)
}
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
Ok((
AuthUserResponse {
user: user_response(user),
authentication_reference: crate::auth::authentication_reference(&token_hash(
&session_token,
)),
},
server_api::SetCookieHeader::new(cookie),
))
@@ -52401,6 +52515,7 @@ mod tests {
include!("server_activation_tests.rs");
include!("server_discoverable_tests.rs");
include!("server_passkey_results_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = ResidentAuthenticator;
@@ -52454,9 +52569,61 @@ mod tests {
origin: Option<&str>,
body: Value,
) -> (StatusCode, HeaderMap, Value) {
let mut body = body;
if path == "/api/auth/passkeys/addition/options"
&& body.get("expected_user_id").is_none()
{
let expected = self.expected_owner(cookie, bearer).await;
for (field, value) in expected.as_object().unwrap() {
body[field] = value.clone();
}
}
Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await
}
async fn get_authenticated(
&self,
path: &str,
cookie: Option<&str>,
bearer: Option<&str>,
) -> (StatusCode, Value) {
let mut request = Request::builder().uri(path);
if let Some(cookie) = cookie {
request = request.header(axum::http::header::COOKIE, cookie);
}
if let Some(bearer) = bearer {
request = request.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {bearer}"),
);
}
let response = self
.app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
let status = response.status();
let body = serde_json::from_slice(
&to_bytes(response.into_body(), usize::MAX).await.unwrap(),
)
.unwrap();
(status, body)
}
async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value {
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", cookie, bearer)
.await;
assert_eq!(status, StatusCode::OK);
serde_json::json!({
"expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"),
"expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"),
"expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"),
})
}
async fn post_to(
app: Router,
path: &str,
@@ -52612,6 +52779,14 @@ mod tests {
.next()
.unwrap()
.to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"completion must identify its exact issued session"
);
(
cookie,
serde_json::from_value::<AuthUserResponse>(body)
@@ -52699,6 +52874,14 @@ mod tests {
.next()
.unwrap()
.to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"login completion must identify its exact issued session"
);
(
cookie,
serde_json::from_value::<AuthUserResponse>(body)
@@ -53222,6 +53405,9 @@ mod tests {
.auth_passkey_addition_options(
context,
server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()),
},
)
@@ -53236,6 +53422,9 @@ mod tests {
.auth_passkey_addition_options(
context,
server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()),
},
)
@@ -54241,7 +54430,7 @@ mod tests {
.header(axum::http::header::COOKIE, &marker)
.header(ORIGIN, &auth.origin)
.header(CONTENT_TYPE, "application/json")
.body(Body::from("{}"))
.body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#))
.unwrap(),
)
.await
@@ -0,0 +1,399 @@
// Integration boundary: generated HTTP admission -> real WebAuthn verifier ->
// SQLite credential/receipt transaction -> real session issuance. Only the
// authenticator is synthetic; failures are SQLite triggers, not success fakes.
impl Fixture {
async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) {
let mut request = self.expected_owner(Some(cookie), None).await;
request["challenge_id"] = challenge_id.into();
let (status, _, body) = self
.post(
"/api/auth/passkeys/addition/result",
Some(cookie),
Some(ORIGIN_URL),
request,
)
.await;
(status, body)
}
}
#[tokio::test]
async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() {
for failure in [
"before_credential",
"receipt_insert",
"session_insert",
"lost_response",
"lost_body_accepted_cookie",
] {
let f = Fixture::new().await;
let mut original = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await;
let original_expectation = f.expected_owner(Some(&cookie), None).await;
let options = f.options("addition", Some(&cookie), "").await;
let challenge_id = options.challenge_id.clone();
let mut added = authenticator();
let request = Fixture::credential(options, &mut added);
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "unknown");
let table = match failure {
"before_credential" => Some("passkey_credentials"),
"receipt_insert" => Some("passkey_addition_receipts"),
"session_insert" => Some("browser_sessions"),
_ => None,
};
if let Some(table) = table {
f.api.config_store.with_conn(|conn| {
conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?;
Ok(())
}).unwrap();
}
// Discard the completion body; one variant still accepts Set-Cookie.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request.clone(),
)
.await;
assert_eq!(
status,
if matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
StatusCode::OK
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
);
if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
}
let saved = matches!(
failure,
"session_insert" | "lost_response" | "lost_body_accepted_cookie"
);
if failure == "lost_body_accepted_cookie" {
let accepted_cookie = headers[axum::http::header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap();
let current = f.expected_owner(Some(accepted_cookie), None).await;
assert!(
current["expected_authentication_reference"]
!= original_expectation["expected_authentication_reference"],
"addition issued a distinct session"
);
let mut recovery = original_expectation.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(accepted_cookie),
Some(ORIGIN_URL),
recovery,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(result["user"]["user_id"], owner.user_id);
}
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], if saved { "saved" } else { "unknown" });
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
let receipt_count = f
.api
.config_store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT COUNT(*) FROM passkey_addition_receipts",
[],
|row| row.get::<_, u64>(0),
)?)
})
.unwrap();
assert_eq!(receipt_count, u64::from(saved));
if saved {
assert_eq!(result["user"]["user_id"], owner.user_id);
let (_, listed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
let reference = result["key"]["key_reference"].as_str().unwrap();
assert!(
listed["keys"]
.as_array()
.unwrap()
.iter()
.any(|key| key["key_reference"] == reference)
);
assert!(result["key"]["normal_login_verified_at"].is_null());
}
if table.is_some() {
f.api
.config_store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER inject_passkey_failure")?;
Ok(())
})
.unwrap();
}
// Consumed completion is not retry authority, even after a transaction failure.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
if saved {
assert_eq!(f.discoverable_login(&mut added).await, owner);
}
}
}
#[tokio::test]
async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() {
let f = Fixture::new().await;
let mut alice_key = authenticator();
let (alice_cookie, alice) = f
.enroll("registration", None, "alice", &mut alice_key)
.await;
let (bob_cookie, _) = f
.enroll("registration", None, "bob", &mut authenticator())
.await;
let expected = f.expected_owner(Some(&alice_cookie), None).await;
let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await;
let count_challenges = || {
f.api
.config_store
.with_conn(|conn| {
Ok(
conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| {
row.get::<_, u64>(0)
})?,
)
})
.unwrap()
};
let before = count_challenges();
for cookie in [&bob_cookie, &replacement_cookie] {
let (status, _, _) = f
.post(
"/api/auth/passkeys/addition/options",
Some(cookie),
Some(ORIGIN_URL),
expected.clone(),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
count_challenges(),
before,
"stale options must not persist a ceremony"
);
}
let options = f.options("addition", Some(&alice_cookie), "").await;
let challenge_id = options.challenge_id.clone();
let request = Fixture::credential(options, &mut authenticator());
assert_eq!(
f.post(
"/api/auth/passkeys/addition/complete",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::OK
);
assert_eq!(
f.addition_result(&alice_cookie, &challenge_id).await.1["status"],
"saved"
);
// Current owner authorization is separate from original operation binding.
let mut recovery = expected.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery.clone(),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&bob_cookie),
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
None,
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::FORBIDDEN
);
recovery["expected_authentication_reference"] = "changed-original-reference".into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery
)
.await
.0,
StatusCode::BAD_REQUEST
);
// Bob's own expected fields cannot discover Alice's operation either.
let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result, serde_json::json!({"status":"unknown"}));
assert_eq!(f.credentials(&alice).len(), 2);
assert_eq!(
f.post(
"/api/auth/logout",
Some(&alice_cookie),
Some(ORIGIN_URL),
serde_json::json!({})
)
.await
.0,
StatusCode::OK
);
let mut request = expected;
request["challenge_id"] = challenge_id.into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() {
let f = Fixture::new().await;
let mut key = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await;
let (other_cookie, _) = f
.enroll("registration", None, "other", &mut authenticator())
.await;
assert_eq!(
f.get_authenticated("/api/auth/passkeys", None, None)
.await
.0,
StatusCode::FORBIDDEN
);
let (status, list) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["user"]["user_id"], owner.user_id);
assert_eq!(list["keys"].as_array().unwrap().len(), 1);
let summary = &list["keys"][0];
assert!(summary["normal_login_verified_at"].is_null());
assert!(summary["last_used_at"].is_null());
let reference = summary["key_reference"].as_str().unwrap();
assert_eq!(reference.len(), 32);
assert_ne!(reference, f.credentials(&owner)[0].credential_id);
assert_eq!(
summary
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>(),
[
"key_reference",
"created_at",
"last_used_at",
"normal_login_verified_at"
]
.into_iter()
.collect()
);
let (_, other) = f
.get_authenticated("/api/auth/passkeys", Some(&other_cookie), None)
.await;
assert_ne!(other["keys"][0]["key_reference"], reference);
assert_eq!(f.login("owner", &mut key).await, owner);
let (_, legacy) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert!(legacy["keys"][0]["last_used_at"].is_string());
assert!(legacy["keys"][0]["normal_login_verified_at"].is_null());
// Rejected normal assertion does not produce confirmation metadata.
let mut invalid = f.discoverable_request(&mut key).await;
invalid.credential.response.signature = vec![0u8; 64].into();
assert_eq!(
f.post(
"/api/auth/passkeys/login/complete",
None,
Some(ORIGIN_URL),
serde_json::to_value(invalid).unwrap()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert!(
f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0]
.normal_login_verified_at
.is_none()
);
assert_eq!(f.discoverable_login(&mut key).await, owner);
let (_, confirmed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(confirmed["keys"][0]["key_reference"], reference);
assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string());
}
#[tokio::test]
async fn unknown_transport_metadata_does_not_discard_verified_registration() {
let f = Fixture::new().await;
let options = f.options("registration", None, "future-transport").await;
let mut key = authenticator();
let mut request = Fixture::credential(options, &mut key);
request["credential"]["response"]["transports"] =
serde_json::json!(["internal", "future-transport", "test"]);
let (status, _, body) = f
.post(
"/api/auth/passkeys/registration/complete",
None,
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::OK);
let user = serde_json::from_value::<AuthUserResponse>(body)
.unwrap()
.user;
assert_eq!(f.discoverable_login(&mut key).await, user);
}
+206 -4
View File
@@ -119,7 +119,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 102;
const LATEST_SCHEMA_VERSION: i64 = 103;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable failed Subject creation recovery",
apply: migrate_subject_create_recovery_v101_to_v102,
},
Migration {
version: 103,
name: "Passkey metadata and atomic addition results",
apply: migrate_passkey_results_v102_to_v103,
},
];
#[derive(Clone, Copy)]
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
ApiToken(String),
}
impl PasskeyAuthenticationBinding {
pub(crate) fn reference(&self) -> String {
let hash = match self {
Self::BrowserSession(hash) | Self::ApiToken(hash) => hash,
};
crate::auth::authentication_reference(hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PasskeyCredentialRecord {
pub credential_id: String,
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
) -> Result<()> {
self.register_verified_passkey_with_result(target, credential, None)
}
/// Receipt and credential commit together; no receipt claims session issuance.
fn register_verified_passkey_with_result(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()>;
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>>;
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>>;
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()>;
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
fn get_passkey_credential(
@@ -6972,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn register_verified_passkey(
fn register_verified_passkey_with_result(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()> {
self.with_immediate_transaction(|tx| {
let user = match target {
@@ -7042,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
)?;
if let Some(challenge_id) = addition_challenge_id {
let PasskeyRegistrationTarget::Addition { user, authentication } = target else {
return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition"));
};
tx.execute(
"INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)",
params![challenge_id, credential.credential_id, user.user_id, authentication.reference()],
)?;
}
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
@@ -7051,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at
FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id)
WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference",
)?;
let rows = stmt.query_map([user_id], read_passkey_summary)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>> {
self.with_conn(|conn| {
// Current authenticated ownership authorizes reading. The original
// binding reference identifies the saved operation, not the current
// session: Set-Cookie may have arrived even if its body was lost.
let result = conn.query_row(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference
FROM passkey_addition_receipts r
JOIN passkey_credentials c USING(credential_id)
JOIN passkey_metadata m USING(credential_id)
WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2",
params![challenge_id, user_id],
|row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)),
).optional()?;
match result {
Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)),
Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")),
None => Ok(None),
}
})
}
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()> {
self.with_conn(|conn| {
conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?;
Ok(())
})
}
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
@@ -11911,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String {
)
}
fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::PasskeySummary> {
Ok(server_api::PasskeySummary {
key_reference: row.get(0)?,
created_at: row.get(1)?,
last_used_at: row.get(2)?,
normal_login_verified_at: row.get(3)?,
})
}
fn read_passkey_credential_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<PasskeyCredentialRecord> {
@@ -15164,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 102 {
return Err(Error::Store(
"Passkey results migration requires schema 102".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(include_str!("passkey_results_schema.sql"))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)",
["Passkey metadata and atomic addition results"],
)?;
tx.commit()?;
Ok(())
}
fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 101 {
return Err(Error::Store(
@@ -15255,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
Ok(())
}
@@ -18948,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()},
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
]
);
@@ -19060,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(100, "Provisional users and scoped passkey activation".to_string()),
(101, "Durable explicit Subject body release".to_string()),
(102, "Durable failed Subject creation recovery".to_string()),
(103, "Passkey metadata and atomic addition results".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -21288,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
}
#[test]
fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() {
for baseline in [50, 102] {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("server.db");
if baseline == 50 {
prepare_schema_v50(&database, None);
} else {
prepare_retained_schema(&database, baseline);
}
let conn = Connection::open(&database).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated');
INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at)
VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated');
INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#)
.unwrap();
if baseline == 102 {
conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations
WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap();
assert!(migrate_passkey_results_v102_to_v103(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 102);
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap());
conn.execute_batch("DROP TRIGGER interrupt_passkey_migration")
.unwrap();
}
drop(conn);
let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap();
assert!(preflight.migration_required());
// Preflight must not install metadata in the source database.
let conn = Connection::open(&database).unwrap();
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
drop(conn);
let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap();
let record = migrated.get_passkey_credential("old-key").unwrap().unwrap();
assert_eq!(record.public_key_cose, "unchanged-key-material");
assert_eq!(record.sign_count, 7);
assert_eq!(record.last_used_at.as_deref(), Some("used-key"));
let summary = migrated
.list_passkey_summaries("user-old")
.unwrap()
.remove(0);
assert_eq!(summary.created_at, "created-key");
assert_eq!(summary.last_used_at.as_deref(), Some("used-key"));
assert!(summary.normal_login_verified_at.is_none());
assert_eq!(summary.key_reference.len(), 32);
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let schema = |conn: &Connection| -> Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN
('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?;
Ok(stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?)
};
assert_eq!(
migrated.with_conn(schema).unwrap(),
fresh.with_conn(schema).unwrap()
);
migrated.with_conn(|conn| {
assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err());
assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference)
VALUES('operation','missing-key','user-old','{}')", []).is_err());
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0);
Ok(())
}).unwrap();
drop(migrated);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
assert_eq!(
reopened.list_passkey_summaries("user-old").unwrap(),
vec![summary]
);
}
}
#[test]
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
let dir = tempfile::tempdir().unwrap();
@@ -21327,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
drop(conn);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
reopened
.with_conn(|c| verify_schema_history(c, 102))
.with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
.unwrap();
}
@@ -22266,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into())]);
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}