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:
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user