#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod common;
use common::{App, Browser, ORIGIN, OWNER, PASSWORD};
use latchkey::flow::LinkKind;
use latchkey::flow::sign_in::{SignIn, SignInEvent};
use latchkey::flow::step_up::{StepUp, StepUpEvent};
use latchkey::passkey::webauthn_rs::prelude::{
Passkey, PublicKeyCredential, RequestChallengeResponse,
};
use latchkey::passkey::{CEREMONY_TTL, RelyingParty, record_use};
use latchkey::session::SignInMethod;
use latchkey::{Error, PasskeyId, UserId};
use time::Duration;
use url::Url;
use webauthn_authenticator_rs::WebauthnAuthenticator;
use webauthn_authenticator_rs::softpasskey::SoftPasskey;
struct Key {
inner: WebauthnAuthenticator<SoftPasskey>,
credential_id: Option<latchkey::passkey::webauthn_rs::prelude::Base64UrlSafeData>,
}
impl Key {
fn new() -> Self {
Self {
inner: WebauthnAuthenticator::new(SoftPasskey::new(true)),
credential_id: None,
}
}
fn origin() -> Url {
Url::parse(ORIGIN).unwrap()
}
fn register(
&mut self,
app: &mut App,
rp: &RelyingParty,
user: UserId,
existing: &[Passkey],
) -> Result<Passkey, Error> {
let session = app
.sessions
.values()
.find(|s| s.user_id == user)
.unwrap()
.id;
let (challenge, ceremony) = rp
.start_registration(
user,
OWNER,
"Owner",
existing,
session,
app.now,
&mut app.rng,
)
.unwrap();
let credential = self
.inner
.do_registration(Self::origin(), challenge)
.map_err(|_| Error::InvalidRequest("authenticator_refused"))?;
self.credential_id = Some(credential.raw_id.clone());
let state = ceremony.into_state(Some(session), Some(user), app.now)?;
rp.finish_registration(&credential, &state, existing)
}
fn assert(&mut self, challenge: RequestChallengeResponse) -> PublicKeyCredential {
self.inner
.do_authentication(Self::origin(), challenge)
.unwrap()
}
fn assert_discoverable(
&mut self,
mut challenge: RequestChallengeResponse,
user: UserId,
) -> PublicKeyCredential {
challenge.public_key.allow_credentials = vec![webauthn_rs_proto::AllowCredentials {
type_: "public-key".into(),
id: self.credential_id.clone().unwrap(),
transports: None,
}];
let mut credential = self.assert(challenge);
credential.response.user_handle = Some(user.as_uuid().as_bytes().to_vec().into());
credential
}
}
fn owner_with_passkey(
app: &mut App,
browser: &mut Browser,
rp: &RelyingParty,
key: &mut Key,
) -> Vec<Passkey> {
app.request_setup(browser);
let token = app.mail.last().unwrap().link_token().unwrap();
let _ = app.redeem_setup(browser, LinkKind::Setup, &token).unwrap();
app.setup_password(browser, PASSWORD).unwrap();
let owner = app.owner_id();
let passkey = key.register(app, rp, owner, &[]).unwrap();
let id = PasskeyId::random(&mut app.rng);
let sealed = app
.data_key
.seal_passkey(&mut app.rng, owner, id, &passkey)
.unwrap();
app.rows
.get_mut(&owner)
.unwrap()
.passkeys
.push((id, sealed));
app.finish_setup(browser).unwrap();
vec![passkey]
}
#[test]
fn setup_with_a_passkey_only_and_sign_in_with_it() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let mut passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let owner = app.owner_id();
let (challenge, ceremony) = rp.start_discoverable(app.now, &mut app.rng).unwrap();
let credential = key.assert_discoverable(challenge, owner);
let claimed = rp.identify_discoverable(&credential).unwrap();
assert_eq!(claimed, owner);
let state = ceremony.into_state(None, None, app.now).unwrap();
let result = rp
.finish_discoverable(&credential, state, claimed, &passkeys)
.unwrap();
assert_eq!(record_use(&mut passkeys, &result), Some(0));
let mut browser = Browser::default();
let client = app.client(&browser, Some(owner));
let step = SignIn::Start
.advance(
&app.config,
SignInEvent::DiscoverablePasskey {
account: app.account(claimed),
verified: true,
client,
},
app.now,
&mut app.rng,
)
.unwrap();
app.apply(step.effects, &mut browser).unwrap();
let SignIn::SignedIn { session, .. } = step.state else {
panic!("not signed in");
};
assert_eq!(session.method, Some(SignInMethod::Passkey));
}
#[test]
fn a_ceremony_is_consumed_by_use() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let (_, ceremony) = rp.start_discoverable(app.now, &mut app.rng).unwrap();
let stored = serde_json::to_string(&ceremony).unwrap();
let state = ceremony.into_state(None, None, app.now);
assert!(state.is_ok());
let restored: latchkey::passkey::Ceremony<
latchkey::passkey::webauthn_rs::prelude::DiscoverableAuthentication,
> = serde_json::from_str(&stored).unwrap();
assert!(restored.into_state(None, None, app.now).is_ok());
}
#[test]
fn ceremonies_expire_and_are_bound_to_their_session_and_user() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let owner = app.owner_id();
let session = latchkey::SessionId::random(&mut app.rng);
let other = latchkey::SessionId::random(&mut app.rng);
let start = |app: &mut App| {
rp.start_authentication(owner, &passkeys, session, app.now, &mut app.rng)
.unwrap()
.1
};
let unknown = Some(Error::InvalidRequest("unknown_ceremony"));
assert_eq!(
start(&mut app)
.into_state(Some(other), Some(owner), app.now)
.err(),
unknown
);
let stranger = UserId::random(&mut app.rng);
assert_eq!(
start(&mut app)
.into_state(Some(session), Some(stranger), app.now)
.err(),
unknown
);
let ceremony = start(&mut app);
assert_eq!(
ceremony
.into_state(Some(session), Some(owner), app.now + CEREMONY_TTL)
.err(),
unknown
);
assert!(
start(&mut app)
.into_state(Some(session), Some(owner), app.now)
.is_ok()
);
}
#[test]
fn passkey_for_another_user_handle_is_rejected() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let (challenge, ceremony) = rp.start_discoverable(app.now, &mut app.rng).unwrap();
let stranger = UserId::random(&mut app.rng);
let credential = key.assert_discoverable(challenge, stranger);
let claimed = rp.identify_discoverable(&credential).unwrap();
assert_eq!(claimed, stranger);
assert!(app.account(claimed).is_none(), "the app finds no account");
let state = ceremony.into_state(None, None, app.now).unwrap();
assert_eq!(
rp.finish_discoverable(&credential, state, app.owner_id(), &passkeys)
.err(),
Some(Error::SignInFailed),
"a handle that is not the account whose passkeys were loaded"
);
let step = SignIn::Start.advance(
&app.config,
SignInEvent::DiscoverablePasskey {
account: None,
verified: false,
client: latchkey::flow::Client::default(),
},
app.now,
&mut app.rng,
);
assert_eq!(step.err().map(|r| r.error), Some(Error::SignInFailed));
}
#[test]
fn passkey_as_second_factor_after_password() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let mut passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let mut laptop = Browser::default();
let SignIn::SecondFactor { account, session } =
app.password_step(&mut laptop, OWNER, PASSWORD).unwrap()
else {
panic!("no second factor");
};
let (challenge, ceremony) = rp
.start_authentication(account.id, &passkeys, session.id, app.now, &mut app.rng)
.unwrap();
let credential = key.assert(challenge);
let state = ceremony
.into_state(Some(session.id), Some(account.id), app.now)
.unwrap();
let result = rp.finish_authentication(&credential, &state).unwrap();
let verified = record_use(&mut passkeys, &result).is_some();
let client = app.client(&laptop, Some(account.id));
let step = SignIn::second_factor(session, account)
.advance(
&app.config,
SignInEvent::Passkey { verified, client },
app.now,
&mut app.rng,
)
.unwrap();
app.apply(step.effects, &mut laptop).unwrap();
assert_eq!(
app.session_of(&laptop).unwrap().method,
Some(SignInMethod::PasswordPasskey)
);
}
#[test]
fn step_up_with_a_passkey() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let mut browser = Browser::default();
let passkeys = owner_with_passkey(&mut app, &mut browser, &rp, &mut key);
app.advance(Duration::minutes(6));
let session = app.session_of(&browser).unwrap();
let account = app.account(session.user_id).unwrap();
let (challenge, ceremony) = rp
.start_authentication(account.id, &passkeys, session.id, app.now, &mut app.rng)
.unwrap();
let credential = key.assert(challenge);
let state = ceremony
.into_state(Some(session.id), Some(account.id), app.now)
.unwrap();
let verified = rp.finish_authentication(&credential, &state).is_ok();
let client = app.client(&browser, Some(account.id));
let step = StepUp::new(account, session)
.advance(
&app.config,
StepUpEvent::Passkey { verified, client },
app.now,
)
.unwrap();
app.apply(step.effects, &mut browser).unwrap();
let session = app.session_of(&browser).unwrap();
assert_eq!(session.authenticated_at, Some(app.now));
}
#[test]
fn the_same_passkey_cannot_be_registered_twice() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let owner = app.owner_id();
let session = latchkey::SessionId::random(&mut app.rng);
let (challenge, ceremony) = rp
.start_registration(
owner,
OWNER,
"Owner",
&passkeys,
session,
app.now,
&mut app.rng,
)
.unwrap();
let excluded = &challenge.public_key.exclude_credentials;
assert_eq!(excluded.as_ref().map(Vec::len), Some(1));
let credential = key.inner.do_registration(Key::origin(), challenge).unwrap();
let state = ceremony
.into_state(Some(session), Some(owner), app.now)
.unwrap();
let added = rp
.finish_registration(&credential, &state, &passkeys)
.unwrap();
assert_eq!(
rp.finish_registration(&credential, &state, &[added]).err(),
Some(Error::Conflict("passkey_exists"))
);
}
#[test]
fn sealed_passkeys_open_only_for_their_row() {
let mut app = App::new();
let rp = RelyingParty::new(&app.config).unwrap();
let mut key = Key::new();
let passkeys = owner_with_passkey(&mut app, &mut Browser::default(), &rp, &mut key);
let owner = app.owner_id();
let id = PasskeyId::random(&mut app.rng);
let sealed = app
.data_key
.seal_passkey(&mut app.rng, owner, id, &passkeys[0])
.unwrap();
assert!(!sealed.contains("cred"));
let opened = app.data_key.open_passkey(owner, id, &sealed).unwrap();
assert_eq!(opened.cred_id(), passkeys[0].cred_id());
let other = PasskeyId::random(&mut app.rng);
assert!(app.data_key.open_passkey(owner, other, &sealed).is_err());
}
#[test]
fn a_relying_party_must_match_the_origin() {
let origin = Url::parse(ORIGIN).unwrap();
assert!(RelyingParty::from_parts("other.example", "x", &origin).is_err());
}