Drop non-discoverable passkeys and the decoy padding
Passkey sign-in now always issues a discoverable challenge with no credential list, so it cannot reveal which accounts exist. The decoy credentials that hid this for named challenges are gone. Registration requires a resident key (residentKey and requireResidentKey), so U2F-only security keys can no longer register. A migration drops passkeys.discoverable and the decoy_secret meta row. The web client no longer sends a name or shows the "needs your user name" badge. Pending::Authenticate is renamed to SecondFactor: finishing it creates a session, so it must only follow a correct password. Note: a passwordless account whose only passkeys are non-discoverable can no longer sign in. An admin password reset recovers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MREADME.md
@@ -159,8 +159,8 @@ none; the session is the gate, and every change signs the account out
everywhere else.
The login button asks the browser for any passkey it holds for this site, so
no user name is needed. Older security keys cannot do that and need the name
typed in first; the settings list marks those with "needs your user name".
no user name is needed. A passkey must live on the authenticator itself.
Older U2F-only security keys cannot store one and cannot be registered.
Passkeys need a domain name. Set `--public-url` behind a proxy that rewrites
`Host`, and note that a bare IP address will not work at all. Password sign-in
Mapi-types/src/lib.rs
@@ -1189,11 +1189,6 @@ pub struct PasskeyInfo {
/// RFC 3339 UTC.
pub created_at: String,
pub last_used_at: Option<String>,
/// Whether the browser reported this credential as discoverable, so it
/// can sign in without the account name. `None` when the browser did not
/// say — the `credProps` extension is optional and unsigned, so absence
/// means "unknown", never "no".
pub discoverable: Option<bool>,
}
/// One app password, as shown in profile settings.
@@ -1271,11 +1266,6 @@ pub struct PasskeyRegisterFinish {
/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
#[derive(Serialize, Deserialize)]
pub struct PasskeyLoginBegin {
/// Account name, when the user typed one. Without it the server issues a
/// discoverable challenge, which only finds passkeys the authenticator
/// stores itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Ask for a conditional-mediation (autofill) challenge instead of a
/// modal one.
#[serde(default)]
Mserver/Cargo.toml
@@ -79,7 +79,7 @@ ignore = "0.4"
grep = "0.4"
webauthn-rs = { version = "0.5.5", features = ["conditional-ui"], default-features = false }
getrandom = "0.4"
# For `ResidentKeyRequirement` and `AllowCredentials`, which `webauthn-rs` uses
# For `ResidentKeyRequirement`, which `webauthn-rs` uses
# internally but does not re-export. Keep this version equal to webauthn-rs'
# own: it already pulls this crate in, and two different versions would compile
# while being different types.
Mserver/src/api/auth.rs
@@ -271,10 +271,9 @@ async fn second_factor(
)
})?;
let challenge = crate::api::passkeys::challenge(
crate::webauthn::Pending::Authenticate {
crate::webauthn::Pending::SecondFactor {
user_id: user.id,
state: Box::new(auth),
second_factor: true,
},
&options,
)?;
Mserver/src/api/passkeys.rs
@@ -16,7 +16,7 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use webauthn_rs::prelude::*;
use webauthn_rs_proto::{AllowCredentials, ResidentKeyRequirement};
use webauthn_rs_proto::ResidentKeyRequirement;
use crate::api::common::{SessionUser, credential_label, hash_password, validate_password};
use crate::auth::{self, parse_session_cookie, session_cookie};
@@ -108,7 +108,6 @@ fn info(row: &PasskeyRow) -> PasskeyInfo {
name: row.name.clone(),
created_at: row.created_at.clone(),
last_used_at: row.last_used_at.clone(),
discoverable: row.discoverable,
}
}
@@ -302,7 +301,7 @@ pub async fn register_begin(
let (mut options, reg) = rp
.start_passkey_registration(wid, &user.name, &user.name, Some(existing))
.map_err(webauthn_failed)?;
ask_for_discoverable(&mut options);
require_discoverable(&mut options);
Ok(Json(challenge(
Pending::Register {
user_id: user.id,
@@ -335,9 +334,6 @@ pub async fn register_finish(
}
let cred: RegisterPublicKeyCredential =
serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
// Whether the browser thinks it stored a discoverable credential. Unsigned
// and optional, so it is a UI hint only — never a security decision.
let discoverable = cred.extensions.cred_props.as_ref().and_then(|c| c.rk);
let passkey = rp
.finish_passkey_registration(&cred, ®)
.map_err(webauthn_failed)?;
@@ -354,7 +350,6 @@ pub async fn register_finish(
passkey.cred_id().as_ref(),
&encoded,
&credential_label(&body.name, "Passkey"),
discoverable,
)
.await
.map_err(|e| match e {
@@ -382,167 +377,27 @@ pub async fn register_finish(
// Signing in with a passkey
// ---------------------------------------------------------------------------
/// Key material for one decoy, in counter mode so any id length is reachable.
///
/// `tag` separates the two things derived per decoy, its length and its bytes,
/// so neither can be read off the other.
fn decoy_bytes(secret: &str, name: &str, index: u32, tag: u8, len: usize) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut out = Vec::with_capacity(len + 32);
let mut block = 0u32;
while out.len() < len {
let mut h = Sha256::new();
h.update(secret.as_bytes());
h.update([tag]);
// Length-prefixed, so two names cannot run together into one input.
h.update((name.len() as u64).to_le_bytes());
h.update(name.as_bytes());
h.update(index.to_le_bytes());
h.update(block.to_le_bytes());
out.extend_from_slice(&h.finalize());
block += 1;
}
out.truncate(len);
out
}
/// One fake `allowCredentials` entry, stable across requests.
///
/// A real account lists the same credential ids every time. A decoy derived
/// from a per-install secret does too, so probing one name twice gives an
/// attacker nothing to compare.
///
/// The name is ASCII-folded first, because `users.name` is `COLLATE NOCASE`.
/// Without that, "admin" and "ADMIN" would return the same real credential
/// with different decoys around it, and comparing the two spellings would say
/// which entries were real.
fn decoy(secret: &str, name: &str, index: u32, lengths: &[usize]) -> AllowCredentials {
let name = &name.to_ascii_lowercase();
let pick = decoy_bytes(secret, name, index, 1, 1);
let len = lengths[usize::from(pick[0]) % lengths.len()];
AllowCredentials {
type_: "public-key".to_string(),
id: decoy_bytes(secret, name, index, 0, len).into(),
transports: None,
}
}
/// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in.
///
/// An empty name gets a discoverable challenge, which any passkey the browser
/// holds for this site can answer. A name gets a challenge listing credentials,
/// which is the only form a non-discoverable credential can answer.
///
/// This route needs no session, so a named challenge must not say whether the
/// name exists. It does not: an unknown name gets a list of decoys, and the
/// two starters' other differences are flattened below. The account behind a
/// real name still decides nothing here, because the assertion has to verify
/// before anyone is signed in.
/// The challenge lists no credentials, so any passkey the browser holds for
/// this site can answer it, and it says nothing about which accounts exist.
pub async fn login_begin(
State(state): State<Arc<AppState>>,
Rp(rp): Rp,
Json(body): Json<PasskeyLoginBegin>,
) -> Result<Json<PasskeyChallenge>, ApiError> {
// Autofill carries no name and its challenge is the same for everyone, so
// it needs none of the padding below.
let name = match body
.name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
{
Some(name) if !body.conditional => name,
_ => {
let (mut options, disc) = rp
.start_discoverable_authentication()
.map_err(webauthn_failed)?;
// `start_discoverable_authentication` always asks for conditional
// mediation, which parks the request in the autofill dropdown. The
// button wants the modal picker instead.
if !body.conditional {
options.mediation = None;
}
return Ok(Json(challenge(
Pending::Discoverable {
state: Box::new(disc),
decoy: false,
},
&options,
)?));
}
};
let found = match state.db.find_user_by_name(name).await?.filter(|u| u.active) {
Some(u) => {
let keys: Vec<Passkey> = load_passkeys(&state, u.id)
.await?
.into_iter()
.map(|(_, k)| k)
.collect();
(!keys.is_empty()).then_some((u.id, keys))
}
None => None,
};
// An unknown name still gets a working ceremony, not a fake one: a passkey
// the browser holds for this site can answer it. Only the credential list
// is invented.
let (mut options, pending) = match found {
Some((user_id, keys)) => {
let (options, auth) = rp
.start_passkey_authentication(&keys)
.map_err(webauthn_failed)?;
(
options,
Pending::Authenticate {
user_id,
state: Box::new(auth),
second_factor: false,
},
)
}
None => {
let (options, disc) = rp
.start_discoverable_authentication()
.map_err(webauthn_failed)?;
(
options,
Pending::Discoverable {
state: Box::new(disc),
decoy: true,
},
)
}
};
// The two starters disagree on more than the credential list.
// `start_discoverable_authentication` asks for the `uvm` extension and
// conditional mediation; `start_passkey_authentication` asks for neither.
// Left alone those two fields would answer the question the decoys are
// here to hide. Neither is checked when the assertion comes back, so
// clearing them costs nothing.
options.public_key.extensions = None;
options.mediation = None;
// Transports vary per authenticator and a decoy has none to copy, so they
// come off the real entries too. They are a hint to the browser about
// where to look, never a requirement.
for cred in &mut options.public_key.allow_credentials {
cred.transports = None;
}
let secret = state.db.decoy_secret().await?;
let mut lengths = state.db.cred_id_lengths().await?;
if lengths.is_empty() {
lengths.push(32);
}
// Always exactly `PASSKEY_LIMIT` entries. No account may hold more, so the
// list never has to grow past the padding and its length says nothing.
for index in options.public_key.allow_credentials.len()..PASSKEY_LIMIT {
options
.public_key
.allow_credentials
.push(decoy(&secret, name, index as u32, &lengths));
let (mut options, disc) = rp
.start_discoverable_authentication()
.map_err(webauthn_failed)?;
// `start_discoverable_authentication` always asks for conditional
// mediation, which parks the request in the autofill dropdown. The button
// wants the modal picker instead.
if !body.conditional {
options.mediation = None;
}
Ok(Json(challenge(pending, &options)?))
Ok(Json(challenge(
Pending::Discoverable(Box::new(disc)),
&options,
)?))
}
/// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
@@ -561,17 +416,16 @@ pub async fn login_finish(
serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
let (user_id, second_factor, result) = match pending {
Pending::Authenticate {
Pending::SecondFactor {
user_id,
state: auth_state,
second_factor,
} => {
let res = rp
.finish_passkey_authentication(&cred, &auth_state)
.map_err(webauthn_failed)?;
(user_id, second_factor, res)
(user_id, true, res)
}
Pending::Discoverable { state: disc, decoy } => {
Pending::Discoverable(disc) => {
// The user handle comes from the credential, so it is only a
// claim until `finish_discoverable_authentication` checks the
// signature against that account's own keys below.
@@ -592,14 +446,6 @@ pub async fn login_finish(
let res = rp
.finish_discoverable_authentication(&cred, *disc, &keys)
.map_err(webauthn_failed)?;
// The name this challenge was issued for does not exist. The
// ceremony was real so that it could not be told apart from a
// real one, and it is verified before being refused for the same
// reason. Signing this passkey's owner in instead would answer
// the question the whole padding is there to swallow.
if decoy {
return Err(webauthn_failed(WebauthnError::CredentialNotFound));
}
(user.id, false, res)
}
// Any other handle names a different ceremony. Refusing keeps a
@@ -666,28 +512,22 @@ pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result<Response,
.into_response())
}
/// Ask the authenticator to store the credential itself.
///
/// `start_passkey_registration` sends `residentKey: "discouraged"`, which
/// tells a password manager *not* to make a discoverable passkey — and they
/// obey it, so every credential would then need the account name typed in to
/// be found again. There is no builder switch for this on the passkey API,
/// hence the patch.
/// Require the authenticator to store the credential itself.
///
/// Only the request changes, not what is accepted: an authenticator with no
/// room for a resident key still registers, and the `credProps` extension
/// reports what actually happened. Enforcing it would lock out the older
/// security keys this server deliberately still supports.
fn ask_for_discoverable(options: &mut CreationChallengeResponse) {
/// Sign-in only issues discoverable challenges, so a credential the
/// authenticator does not store could never sign in. `start_passkey_registration`
/// sends `residentKey: "discouraged"`, and the passkey API has no builder
/// switch for it, hence the patch. With `required` the browser refuses an
/// authenticator that cannot store the credential, such as a U2F-only key.
fn require_discoverable(options: &mut CreationChallengeResponse) {
match options.public_key.authenticator_selection.as_mut() {
Some(sel) => {
sel.resident_key = Some(ResidentKeyRequirement::Required);
// `require_resident_key` is the CTAP1-era boolean, consulted only
// when `residentKey` is absent. Some older keys fail outright on
// it, so it stays false.
// Browsers that predate `residentKey` read only this flag.
sel.require_resident_key = true;
}
// `webauthn-rs` always sends this block today. If a future version
// stops, every new passkey silently goes back to needing a typed name.
// stops, new passkeys may not be discoverable and could not sign in.
None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"),
}
}
Mserver/src/db.rs
@@ -56,11 +56,6 @@ pub struct User {
pub const NO_PASSWORD: &str = "";
/// How many passkeys one account may hold.
///
/// Also the exact number of credentials a named sign-in challenge lists. The
/// two are one number on purpose: the challenge pads a short list with decoys
/// so its length says nothing about the account, and that only works while no
/// account can push past the padding.
pub const PASSKEY_LIMIT: usize = 8;
/// How many app passwords one account may hold. One per client is the point.
@@ -84,7 +79,6 @@ pub struct PasskeyRow {
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub discoverable: Option<bool>,
/// `webauthn_rs::prelude::Passkey` as JSON.
pub passkey: String,
}
@@ -788,62 +782,19 @@ impl Db {
.unwrap_or(0))
}
/// The per-install secret behind the decoy credentials a named passkey
/// challenge is padded with. Created on first use, so no migration.
pub async fn decoy_secret(&self) -> DbResult<String> {
let c = self.conn.lock().await;
let existing: Option<String> = c
.query_row(
"SELECT value FROM meta WHERE key = 'decoy_secret'",
[],
|r| r.get(0),
)
.optional()?;
if let Some(secret) = existing {
return Ok(secret);
}
let fresh = crate::auth::random_token();
c.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('decoy_secret', ?1)",
[&fresh],
)?;
Ok(fresh)
}
/// The length in bytes of every credential id registered on this server,
/// one entry per credential.
///
/// Decoys draw their length from this list. Authenticators disagree on it —
/// a security key emits 64 bytes, a platform key often 16 or 32 — so a
/// decoy of one fixed length would stand out next to the real entries.
///
/// Duplicates are kept deliberately. Drawing from the distinct lengths
/// would make a rare length as likely as a common one, and decoys that do
/// not match how the install actually looks are the thing worth avoiding.
pub async fn cred_id_lengths(&self) -> DbResult<Vec<usize>> {
self.rows(
"SELECT length(cred_id) FROM passkeys ORDER BY id",
[],
|r| Ok(r.get::<_, i64>(0)?.max(1) as usize),
)
.await
}
/// Store a freshly registered passkey. A duplicate `cred_id` is a unique
/// violation, which is the intended answer: the same credential must not
/// be registered twice, not even to a second account.
///
/// `None` means the account is already at [`PASSKEY_LIMIT`]. The count and
/// the insert share one transaction, so two registrations landing together
/// cannot put the account one over and give its sign-in challenge a
/// telltale length.
/// cannot put the account one over.
pub async fn add_passkey(
&self,
user_id: i64,
cred_id: &[u8],
passkey: &str,
name: &str,
discoverable: Option<bool>,
) -> DbResult<Option<PasskeyRow>> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
@@ -857,10 +808,10 @@ impl Db {
}
let row = tx.query_row(
&format!(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, discoverable, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING {PASSKEY_COLS}"
"INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at)
VALUES (?1, ?2, ?3, ?4, ?5) RETURNING {PASSKEY_COLS}"
),
params![user_id, cred_id, passkey, name, discoverable, now()],
params![user_id, cred_id, passkey, name, now()],
map_passkey,
)?;
tx.commit()?;
@@ -2152,7 +2103,7 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
})
}
const PASSKEY_COLS: &str = "id, name, created_at, last_used_at, discoverable, passkey";
const PASSKEY_COLS: &str = "id, name, created_at, last_used_at, passkey";
fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
Ok(PasskeyRow {
@@ -2160,8 +2111,7 @@ fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
name: r.get(1)?,
created_at: r.get(2)?,
last_used_at: r.get(3)?,
discoverable: r.get(4)?,
passkey: r.get(5)?,
passkey: r.get(4)?,
})
}
@@ -2706,6 +2656,9 @@ const MIGRATIONS: &[&str] = &[
// The cascades from a deleted principal or collection.
"CREATE INDEX IF NOT EXISTS idx_pim_props_principal ON pim_props(principal_id);
CREATE INDEX IF NOT EXISTS idx_pim_props_collection ON pim_props(collection_id);",
// Leftovers of the named passkey sign-in.
"ALTER TABLE passkeys DROP COLUMN discoverable;
DELETE FROM meta WHERE key = 'decoy_secret';",
];
const SCHEMA_V1: &str = r#"
@@ -2973,6 +2926,50 @@ mod tests {
);
}
#[tokio::test]
async fn named_sign_in_leftovers_are_dropped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pre.sqlite");
let at = MIGRATIONS
.iter()
.position(|m| m.contains("DROP COLUMN discoverable"))
.unwrap();
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
.unwrap();
for sql in &MIGRATIONS[..at] {
conn.execute_batch(sql).unwrap();
}
conn.execute(
"INSERT INTO meta (key, value) VALUES ('schema_version', ?1),
('decoy_secret', 'old')",
[at.to_string()],
)
.unwrap();
conn.execute_batch(
"INSERT INTO users (name, pass_hash, is_admin, created_at)
VALUES ('legacy', 'hash', 1, '2024-01-01T00:00:00Z');
INSERT INTO passkeys (user_id, cred_id, passkey, name, discoverable, created_at)
VALUES (1, x'01', '{}', 'Key', 0, '2024-01-01T00:00:00Z');",
)
.unwrap();
}
let db = Db::open(&path).await.unwrap();
assert_eq!(db.user_passkeys(1).await.unwrap().len(), 1);
let c = db.conn.lock().await;
let leftovers: i64 = c
.query_row(
"SELECT (SELECT COUNT(*) FROM pragma_table_info('passkeys')
WHERE name = 'discoverable')
+ (SELECT COUNT(*) FROM meta WHERE key = 'decoy_secret')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(leftovers, 0);
}
#[tokio::test]
async fn deleted_defaults_come_back() {
let (db, admin) = db_with_admin().await;
Mserver/src/webauthn.rs
@@ -129,27 +129,17 @@ pub enum Pending {
user_id: i64,
state: Box<PasskeyRegistration>,
},
/// Signing in with a known account: the challenge names that account's
/// credentials, so the answer can only come from one of them.
Authenticate {
/// The passkey after a correct password: the challenge names that
/// account's credentials, so the answer can only come from one of them.
/// Finishing it creates a session, so only create it once the password
/// has passed.
SecondFactor {
user_id: i64,
state: Box<PasskeyAuthentication>,
/// True when the password already passed and this is the second
/// factor. Only then does finishing create a session directly.
second_factor: bool,
},
/// Signing in without a name. The account is only known once the browser
/// answers, because the answer carries the user handle.
Discoverable {
state: Box<DiscoverableAuthentication>,
/// True when this stands in for a name the server does not know.
///
/// The ceremony is real so that it cannot be told apart from one for
/// an account that exists. Finishing it must still fail, or answering
/// with any passkey would sign that passkey's owner in and turn the
/// answer into the name oracle the padding exists to prevent.
decoy: bool,
},
/// Signing in with a passkey first. The account is only known once the
/// browser answers, because the answer carries the user handle.
Discoverable(Box<DiscoverableAuthentication>),
/// A passkey passed, but the account also requires its password. Holds
/// the identified user until `POST /api/auth/login` supplies it.
NeedsPassword { user_id: i64 },
@@ -163,14 +153,7 @@ impl Pending {
/// limits them better than a number here could. It also keeps a flood of
/// the cheap kind from evicting a sign-in that is halfway done.
fn anonymous(&self) -> bool {
matches!(
self,
Pending::Discoverable { .. }
| Pending::Authenticate {
second_factor: false,
..
}
)
matches!(self, Pending::Discoverable(_))
}
}
@@ -267,10 +250,7 @@ mod tests {
.build()
.unwrap();
let (_, disc) = rp.start_discoverable_authentication().unwrap();
Pending::Discoverable {
state: Box::new(disc),
decoy: false,
}
Pending::Discoverable(Box::new(disc))
}
#[test]
Mserver/tests/api/app_passwords.rs
@@ -47,7 +47,7 @@ async fn an_app_password_mounts_an_account_that_requires_a_passkey() {
// `both` needs an existing passkey. Its contents never matter here.
env.state
.db
.add_passkey(id, b"cred-app-pw", "{}", "Test key", Some(true))
.add_passkey(id, b"cred-app-pw", "{}", "Test key")
.await
.unwrap()
.unwrap();
Mserver/tests/api/passkeys.rs
@@ -20,9 +20,6 @@ const STORED_PASSKEY: &str = r#"{"cred":{"cred_id":"AQIDBA","cred":{"type_":"ES2
/// Put a passkey on an account without a browser.
///
/// `label` only has to be unique; the stored id is four bytes derived from it.
/// A real registration stores exactly the credential id that is inside the
/// passkey JSON, and `STORED_PASSKEY` carries a four-byte one. The challenge
/// padding reads its decoy lengths from the stored ids, so the two must agree.
async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut h = DefaultHasher::new();
@@ -30,7 +27,7 @@ async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) {
let cred_id = (h.finish() as u32).to_le_bytes();
env.state
.db
.add_passkey(user_id, &cred_id, STORED_PASSKEY, "Test key", Some(true))
.add_passkey(user_id, &cred_id, STORED_PASSKEY, "Test key")
.await
.unwrap()
.expect("the account was already at the passkey limit");
@@ -147,7 +144,6 @@ async fn the_passkey_list_is_per_account_and_carries_no_key_material() {
let list = j.as_array().unwrap();
assert_eq!(list.len(), 1, "admin must not see bob's passkey");
assert_eq!(list[0]["name"], "Test key");
assert_eq!(list[0]["discoverable"], true);
assert!(list[0]["last_used_at"].is_null());
assert!(
!j.to_string().contains("cred_id"),
@@ -387,130 +383,40 @@ async fn webdav_refuses_an_account_that_requires_a_passkey() {
// ---------------------------------------------------------------------------
#[tokio::test]
async fn beginning_a_passkey_sign_in_never_says_whether_a_name_exists() {
async fn a_passkey_challenge_never_names_an_account() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-probe").await;
let c = Client::new(env.app.clone());
// An unknown name, a real name without passkeys, and no name at all must
// be indistinguishable: all three get a usable challenge.
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
for body in [
json!({ "name": "nobody-here" }),
json!({ "name": "bob" }),
json!({}),
] {
// This route needs no session. A credential list would say which names
// exist, so a name in the body must change nothing.
for body in [json!({ "name": "admin" }), json!({})] {
let r = c.post_json("/api/auth/passkey/login", &body).await;
assert_eq!(r.status, StatusCode::OK, "{body}: {}", r.text());
let j = r.json();
assert!(j["state_id"].is_string(), "{body}: {j}");
assert!(j["options"].as_str().unwrap().contains("challenge"));
let opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
let allow = &opts["publicKey"]["allowCredentials"];
assert!(
allow.is_null() || allow.as_array().is_some_and(Vec::is_empty),
"{body}: {opts}"
);
}
// A name with passkeys gets exactly the same shape. Everything the client
// can see must match, or the difference is the oracle.
let real = begin_named(&c, "admin").await;
let fake = begin_named(&c, "nobody-here").await;
assert_eq!(real, fake, "a named challenge must not depend on the name");
}
#[tokio::test]
async fn a_second_spelling_of_a_name_gives_nothing_away() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-case").await;
let c = Client::new(env.app.clone());
// Account names are case-insensitive, so both spellings reach the same
// account and repeat the same real credential. If the decoys around it
// moved, comparing the two answers would show which entries were real.
let lower = begin_named_raw(&c, "admin").await;
let upper = begin_named_raw(&c, "ADMIN").await;
assert_eq!(lower, upper, "a name's case changed its credential list");
// And an unknown name must not share entries with either spelling of it.
let miss = begin_named_raw(&c, "nobody").await;
let miss_upper = begin_named_raw(&c, "NOBODY").await;
assert_eq!(miss, miss_upper);
assert_ne!(miss, lower);
}
/// The credential ids of a named challenge, in order.
async fn begin_named_raw(c: &Client, name: &str) -> Vec<String> {
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": name }))
.await;
assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text());
let opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
opts["publicKey"]["allowCredentials"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_str().unwrap().to_string())
.collect()
}
/// The visible shape of a named challenge, with the parts that are random by
/// design blanked out. What is left must not depend on whether the name exists.
async fn begin_named(c: &Client, name: &str) -> serde_json::Value {
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": name }))
.await;
assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text());
let mut opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
let key = &mut opts["publicKey"];
key["challenge"] = json!("<challenge>");
// The ids differ between the two by construction. Their count and their
// lengths are what a probe could read, so keep those.
for cred in key["allowCredentials"].as_array_mut().unwrap() {
let len = cred["id"].as_str().unwrap().len();
cred["id"] = json!(len);
}
opts
}
#[tokio::test]
async fn a_challenge_for_an_unknown_name_can_never_sign_anyone_in() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let state_id = r.json()["state_id"].as_str().unwrap().to_string();
// The ceremony looks real on purpose. Finishing it must not: a visitor
// holding any passkey for this site could otherwise answer it, get signed
// in as themselves, and read the name's absence off the 200.
let pending = server::webauthn::take(&state_id).expect("the handle was stored");
assert!(
matches!(
pending,
server::webauthn::Pending::Discoverable { decoy: true, .. }
),
"a challenge for an unknown name must be marked as a decoy"
);
}
#[tokio::test]
async fn an_account_cannot_hold_more_passkeys_than_a_challenge_lists() {
async fn an_account_cannot_hold_more_passkeys_than_the_limit() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
for n in 0..server::db::PASSKEY_LIMIT {
give_passkey(&env, id, format!("cred-{n}").as_bytes()).await;
}
// One past the limit must not land. Otherwise this account's sign-in
// challenge would be longer than everyone else's and say who it is.
let over = env
.state
.db
.add_passkey(id, b"over", STORED_PASSKEY, "One too many", Some(true))
.add_passkey(id, b"over", STORED_PASSKEY, "One too many")
.await
.unwrap();
assert!(over.is_none(), "the limit let an extra passkey through");
@@ -519,37 +425,6 @@ async fn an_account_cannot_hold_more_passkeys_than_a_challenge_lists() {
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_passkey_limit");
let listed = begin_named_raw(&Client::new(env.app.clone()), "admin").await;
assert_eq!(listed.len(), server::db::PASSKEY_LIMIT);
}
#[tokio::test]
async fn decoy_credentials_stay_the_same_between_requests() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
// A real account lists stable credential ids. A decoy must too, or two
// probes of one name would tell an attacker it was never real.
let first = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let second = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let ids = |r: &Resp| -> Vec<String> {
let opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
opts["publicKey"]["allowCredentials"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_str().unwrap().to_string())
.collect()
};
let ids = (ids(&first), ids(&second));
assert_eq!(ids.0.len(), server::db::PASSKEY_LIMIT);
assert_eq!(ids.0, ids.1, "decoys must not change between requests");
}
#[tokio::test]
@@ -637,13 +512,17 @@ async fn registration_asks_the_authenticator_to_store_the_credential() {
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let options = r.json()["options"].as_str().unwrap().to_string();
// `webauthn-rs` asks for "discouraged" by default, and password managers
// obey it: every credential would then need the account name typed in
// before it could be found again.
// `webauthn-rs` asks for "discouraged" by default. Sign-in only finds
// credentials the authenticator stores itself.
assert!(
options.contains(r#""residentKey":"required""#),
"registration must ask for a discoverable credential: {options}"
);
// Browsers that predate `residentKey` read only this flag.
assert!(
options.contains(r#""requireResidentKey":true"#),
"{options}"
);
// User verification too, so a passkey on its own is still two factors.
assert!(
options.contains(r#""userVerification":"required""#),
Mweb/app.css
@@ -2913,17 +2913,6 @@ body:has(.page-busy) {
white-space: nowrap;
}
/* "Needs your user name": a note, not an error. This passkey works, it just
cannot be found without the name typed in first. */
.passkey-warning {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
}
/* The sign-in requirement's labels are whole phrases, so the shared 190px
cap for setting selects would clip them. */
.security-tab .setting-row-select select {
Mweb/src/api.rs
@@ -234,14 +234,11 @@ pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
///
/// `Ok(None)` means the browser request was cancelled to make room for
/// another one — nothing happened, and nothing should be shown.
pub async fn passkey_login(
name: Option<String>,
conditional: bool,
) -> Result<Option<LoginResp>, ApiError> {
pub async fn passkey_login(conditional: bool) -> Result<Option<LoginResp>, ApiError> {
let challenge: PasskeyChallenge = request(
"POST",
AUTH_PASSKEY_LOGIN,
Some(PasskeyLoginBegin { name, conditional }),
Some(PasskeyLoginBegin { conditional }),
)
.await?;
passkey_finish(challenge, conditional).await
Mweb/src/i18n.rs
@@ -432,8 +432,6 @@ i18n_keys! {
PASSKEY_ADDED_ON = "passkey_added_on" => "Added {}",
PASSKEY_LABEL = "passkey_label" => "Name for this passkey",
PASSKEY_LAST_USED = "passkey_last_used" => "Last used {}",
PASSKEY_NEEDS_NAME = "passkey_needs_name" => "Needs your user name",
PASSKEY_NEEDS_NAME_HINT = "passkey_needs_name_hint" => "This passkey is not stored on the authenticator itself, so the server has to look it up. Type your user name on the sign-in page before using it.",
PASSKEY_NEVER_USED = "passkey_never_used" => "Never used",
PASSKEY_NOT_USED = "passkey_not_used" => "No passkey was used.",
PASSKEY_REMOVED = "passkey_removed" => "Passkey removed",
@@ -764,7 +762,6 @@ i18n_keys! {
UPLOADS = "uploads" => "Uploads",
UPLOADS_EMPTY = "uploads_empty" => "No uploads yet",
USE_PASSKEY = "use_passkey" => "Use a passkey",
USE_PASSKEY_HINT = "use_passkey_hint" => "Leave the name empty unless your passkey needs it.",
USER_CREATED = "user_created" => "User created",
USER_DELETE_ERR = "user_delete_err" => "Could not delete user",
USER_DISABLED = "user_disabled" => "disabled",
@@ -1221,11 +1218,6 @@ const DE: &[(&str, &str)] = &[
("passkey_added_on", "Hinzugefügt {}"),
("passkey_label", "Name für diesen Passkey"),
("passkey_last_used", "Zuletzt benutzt {}"),
("passkey_needs_name", "Braucht Ihren Benutzernamen"),
(
"passkey_needs_name_hint",
"Dieser Passkey liegt nicht im Authenticator selbst, der Server muss ihn nachschlagen. Geben Sie Ihren Benutzernamen auf der Anmeldeseite ein, bevor Sie ihn verwenden.",
),
("passkey_never_used", "Nie benutzt"),
("passkey_not_used", "Es wurde kein Passkey verwendet."),
("passkey_removed", "Passkey entfernt"),
@@ -1742,10 +1734,6 @@ const DE: &[(&str, &str)] = &[
("uploads", "Uploads"),
("uploads_empty", "Noch keine Uploads"),
("use_passkey", "Passkey verwenden"),
(
"use_passkey_hint",
"Lassen Sie den Namen leer, außer Ihr Passkey braucht ihn.",
),
("user_created", "Benutzer erstellt"),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
("user_disabled", "deaktiviert"),
@@ -2205,11 +2193,6 @@ const FR: &[(&str, &str)] = &[
("passkey_added_on", "Ajoutée {}"),
("passkey_label", "Nom de cette clé d'accès"),
("passkey_last_used", "Dernière utilisation {}"),
("passkey_needs_name", "Exige votre nom d'utilisateur"),
(
"passkey_needs_name_hint",
"Cette clé d'accès n'est pas stockée dans l'authentificateur, le serveur doit la retrouver. Saisissez votre nom d'utilisateur sur la page de connexion avant de l'utiliser.",
),
("passkey_never_used", "Jamais utilisée"),
("passkey_not_used", "Aucune clé d'accès n'a été utilisée."),
("passkey_removed", "Clé d'accès retirée"),
@@ -2732,10 +2715,6 @@ const FR: &[(&str, &str)] = &[
("uploads", "Téléversements"),
("uploads_empty", "Aucun téléversement pour l'instant"),
("use_passkey", "Utiliser une clé d'accès"),
(
"use_passkey_hint",
"Laissez le nom vide, sauf si votre clé d'accès en a besoin.",
),
("user_created", "Utilisateur créé"),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
("user_disabled", "désactivé"),
Mweb/src/passkey.rs
@@ -66,7 +66,7 @@ export async function passkeyGet(optionsJson, conditional) {
if (!cred) throw new Error("no credential was returned");
const json = cred.toJSON();
// The server's parser wants the key present even when it is null, and
// not every browser includes it for a non-discoverable credential.
// an answer to a challenge that lists credentials may omit it.
if (json.response && !("userHandle" in json.response)) {
json.response.userHandle = null;
}
Mweb/src/views/login.rs
@@ -89,18 +89,14 @@ pub fn LoginView(
});
};
// The name goes along only if one was typed: without it the server issues
// a discoverable challenge, which is what lets a passkey sign in with an
// empty form.
let on_passkey = move |_| {
if busy.get() {
return;
}
let name = input_value("login-name").trim().to_string();
set_error.set(String::new());
set_busy.set(true);
spawn_local(async move {
match api::passkey_login((!name.is_empty()).then_some(name), false).await {
match api::passkey_login(false).await {
Ok(None) => set_busy.set(false),
Ok(Some(resp)) => apply(resp),
Err(e) => {
@@ -121,7 +117,7 @@ pub fn LoginView(
if !crate::passkey::conditional_supported().await {
return;
}
match api::passkey_login(None, true).await {
match api::passkey_login(true).await {
Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error),
Ok(Some(resp)) => {
set_busy.set(true);
@@ -188,7 +184,6 @@ pub fn LoginView(
<button class="btn" disabled=move || busy.get() on:click=on_passkey>
{i18n::tr(i18n::k::USE_PASSKEY)}
</button>
<p class="setting-desc">{i18n::tr(i18n::k::USE_PASSKEY_HINT)}</p>
</Show>
</div>
</div>
Mweb/src/views/security.rs
@@ -303,21 +303,15 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
}
Some(list) => list
.into_iter()
.map(|p| {
// Only `Some(false)` earns a warning: `None` means
// the browser did not say.
let warn = p.discoverable == Some(false);
view! {
<CredentialRow
icon=IconName::User
id=p.id
name=p.name
created_at=p.created_at
last_used_at=p.last_used_at
warn=warn
on_remove=remove_passkey
/>
}
.map(|p| view! {
<CredentialRow
icon=IconName::User
id=p.id
name=p.name
created_at=p.created_at
last_used_at=p.last_used_at
on_remove=remove_passkey
/>
})
.collect::<Vec<_>>()
.into_any(),
@@ -356,7 +350,6 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
name=p.name
created_at=p.created_at
last_used_at=p.last_used_at
warn=false
on_remove=remove_app_password
/>
})
@@ -395,8 +388,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
}
}
/// One passkey or app password in the list. `warn` marks a passkey that
/// the browser reported as not discoverable.
/// One passkey or app password in the list.
#[component]
fn CredentialRow(
icon: IconName,
@@ -404,7 +396,6 @@ fn CredentialRow(
name: String,
created_at: String,
last_used_at: Option<String>,
warn: bool,
on_remove: Callback<i64>,
) -> impl IntoView {
let used = match &last_used_at {
@@ -420,11 +411,6 @@ fn CredentialRow(
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &format_date(&created_at))}
" · " {used}
</span>
{warn.then(|| view! {
<span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
{i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
</span>
})}
</span>
<button
class="icon-btn"