use polyc_passkey::{Assertion, verify_assertion};
use std::sync::Arc;
use crate::{
LoginChallenge, LoginChallengeRegistry, MintedSession, ScopeRoot, WebAuthnLoginConfig,
};
use polyc_ceremony::{CeremonyAuthority, CeremonyError};
use polyc_session_family::authority::BrowserSessionAuthority;
#[derive(Debug, Clone)]
pub struct PostedLoginAssertion {
pub credential_id: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub client_data_json: Vec<u8>,
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("login challenge was missing, already used, or expired")]
ChallengeExpired,
#[error("no enrolled passkey credential matches this login assertion")]
UnknownCredential,
#[error("this passkey credential has been revoked")]
CredentialRevoked,
#[error("the passkey assertion did not verify")]
AssertionRejected,
#[error("this persona is not a linked, registered user")]
NotLinked,
#[error("a persona-store read failed during login")]
Storage,
#[error("a session-store write failed during login")]
FamilyStore(#[from] polyc_session_family::authority::SessionAuthorityError),
#[error("the one-time ceremony authority is unavailable")]
CeremonyStore,
}
pub struct PasskeyLoginSessions {
registry: LoginChallengeRegistry,
}
impl PasskeyLoginSessions {
#[must_use]
pub fn from_base_url(
base_url: &str,
rp_id_override: Option<&str>,
ceremonies: Arc<dyn CeremonyAuthority>,
) -> Option<Self> {
Some(Self {
registry: LoginChallengeRegistry::from_base_url(base_url, rp_id_override, ceremonies)?,
})
}
#[must_use]
pub const fn config(&self) -> &WebAuthnLoginConfig {
self.registry.config()
}
pub async fn challenge(&self, now_ms: u64) -> Result<LoginChallenge, CeremonyError> {
self.registry.challenge(now_ms).await
}
pub async fn login(
&self,
challenge_token: &str,
proof: &PostedLoginAssertion,
persona: &(impl crate::PersonaSessionReader + ?Sized),
sessions: &dyn BrowserSessionAuthority,
now_ms: u64,
) -> Result<MintedSession, LoginError> {
match self.registry.redeem(challenge_token, now_ms).await {
Ok(()) => {}
Err(
CeremonyError::NotRecognized
| CeremonyError::Expired
| CeremonyError::Spent
| CeremonyError::Conflict,
) => return Err(LoginError::ChallengeExpired),
Err(CeremonyError::Invalid | CeremonyError::Unavailable) => {
return Err(LoginError::CeremonyStore);
}
}
let expected_challenge = polyc_passkey::challenge_b64(challenge_token.as_bytes());
let persona_id = persona
.session_credential_persona(&proof.credential_id)
.await
.map_err(|()| LoginError::Storage)?
.ok_or(LoginError::UnknownCredential)?;
let credential = persona
.session_credential(&persona_id)
.await
.map_err(|()| LoginError::Storage)?
.ok_or(LoginError::UnknownCredential)?;
if credential.revoked {
return Err(LoginError::CredentialRevoked);
}
let config = self.registry.config();
let assertion = Assertion {
authenticator_data: &proof.authenticator_data,
client_data_json: &proof.client_data_json,
signature: &proof.signature,
};
verify_assertion(
&credential.p256_public_key_sec1,
&assertion,
&expected_challenge,
&config.rp_id,
&config.origin,
)
.map_err(|_| LoginError::AssertionRejected)?;
let profile = persona
.session_profile(persona_id.clone())
.await
.map_err(|()| LoginError::Storage)?
.ok_or(LoginError::UnknownCredential)?;
if profile.status != polyc_persona::STATUS_LINKED {
return Err(LoginError::NotLinked);
}
let (subject, scopes) = crate::resolve_subject_and_scopes(
persona,
ScopeRoot::PersonaCredential {
persona_id: persona_id.clone(),
},
)
.await
.map_err(|_| LoginError::Storage)?;
let (family, grant) = sessions
.create_family(ScopeRoot::PersonaCredential { persona_id }, now_ms)
.await
.map_err(LoginError::FamilyStore)?;
let session_token = sessions
.mint_bearer(&subject, &scopes, now_ms, crate::SESSION_TTL_MS)
.await
.map_err(LoginError::FamilyStore)?;
Ok(MintedSession {
session_token,
grant,
family_expires_ms: family.absolute_expires_ms,
})
}
#[cfg(any(test, feature = "test-util"))]
pub fn logout(&self, session_token: &str) {
self.registry.logout(session_token);
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub const fn revoked(&self) -> &std::sync::Arc<polyc_crypto::session::RevokedTokens> {
self.registry.revoked()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use polyc_ceremony::test_util::MemoryCeremonies;
use polyc_crypto::session::{
SessionScope, SessionSubject, mint_session, verify_authorized_session, verify_session,
};
use polyc_crypto::signing_role::WebSessionGrantSigner;
use polyc_passkey::softkey::SoftPasskey;
use polyc_persona::PersonaHost;
use super::*;
use polyc_session_family::family_host::test_util::spawn_families;
const BASE_URL: &str = "https://explore.polychrome.test";
const NOW: u64 = 1_700_000_000_000;
fn test_signer() -> WebSessionGrantSigner {
WebSessionGrantSigner::from_key_bytes(&[7u8; 32])
.expect("valid 32-byte ed25519 key material")
}
fn test_sessions() -> PasskeyLoginSessions {
PasskeyLoginSessions::from_base_url(BASE_URL, None, Arc::new(MemoryCeremonies::new()))
.expect("a well-formed https URL configures")
}
struct TestPersona {
host: PersonaHost,
shutdown: tokio_util::sync::CancellationToken,
dir: std::path::PathBuf,
}
impl std::ops::Deref for TestPersona {
type Target = PersonaHost;
fn deref(&self) -> &Self::Target {
&self.host
}
}
impl Drop for TestPersona {
fn drop(&mut self) {
self.shutdown.cancel();
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn spawn_persona() -> TestPersona {
let dir = std::env::temp_dir().join(format!(
"polychrome-passkey-login-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let shutdown = tokio_util::sync::CancellationToken::new();
let host = PersonaHost::spawn(dir.clone(), shutdown.clone()).expect("spawn persona host");
TestPersona {
host,
shutdown,
dir,
}
}
fn credential_id_of(soft: &SoftPasskey) -> Vec<u8> {
soft.sign_assertion("unused", "unused.example", "https://unused.example")
.credential_id
}
async fn enroll_and_link(persona: &PersonaHost, soft: &SoftPasskey) -> String {
let actor = polyc_proto::proto::polychrome::persona::v1::ExternalIdentity {
provider: "test".to_owned(),
external_id: "actor".to_owned(),
..Default::default()
};
persona
.attribute(
actor.clone(),
"conv-bootstrap".to_owned(),
polyc_persona::ROLE_INITIATOR.to_owned(),
NOW,
)
.await
.expect("bootstrap actor persona");
let target = polyc_proto::proto::polychrome::persona::v1::ExternalIdentity {
provider: "test".to_owned(),
external_id: uuid::Uuid::new_v4().to_string(),
..Default::default()
};
let code = uuid::Uuid::new_v4().to_string();
persona
.start_link(actor, code.clone(), 3_600_000, NOW)
.await
.expect("start_link");
let persona_id = match persona
.complete_link(code, target, NOW)
.await
.expect("complete_link")
{
polyc_persona::CompleteLinkOutcome::Linked { persona_id } => persona_id,
other => panic!("a fresh target must link cleanly: {other:?}"),
};
persona
.seed_persona_credential_for_tests(
&persona_id,
credential_id_of(soft),
soft.public_key_sec1(),
vec![9u8; 32],
"unused.enrollment.rp".to_owned(),
"https://unused.enrollment.origin".to_owned(),
NOW,
)
.await
.expect("seed_persona_credential_for_tests");
persona_id
}
fn build_proof(
soft: &SoftPasskey,
challenge_b64: &str,
rp_id: &str,
origin: &str,
) -> PostedLoginAssertion {
let signed = soft.sign_assertion(challenge_b64, rp_id, origin);
PostedLoginAssertion {
credential_id: signed.credential_id,
authenticator_data: signed.authenticator_data,
client_data_json: signed.client_data_json,
signature: signed.signature,
}
}
#[tokio::test]
async fn valid_login_mints_a_session_verify_session_accepts() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let signer = test_signer();
let soft = SoftPasskey::from_seed(1);
let persona_id = enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
let minted = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("a valid assertion for a linked, enrolled persona logs in");
let resolved =
verify_authorized_session(&signer.public_key_bytes(), &minted.session_token, NOW)
.expect("a freshly minted session verifies");
assert_eq!(resolved.subject.persona_id(), Some(persona_id.as_str()));
assert!(resolved.has_scope(polyc_crypto::session::SessionScope::ExplorerRead));
assert!(
resolved.has_scope(polyc_crypto::session::SessionScope::AgentTurn),
"a verified assertion for a STATUS_LINKED persona is the same standard of proof a turn \
needs, so this ceremony mints AgentTurn alongside ExplorerRead (ADR 0007)"
);
assert!(
!resolved.has_scope(polyc_crypto::session::SessionScope::WalletManage),
"minting two scopes together is a policy choice, not a slide into minting every scope \
— wallet-manage still belongs to the wallet-rooted ceremony alone"
);
let claims =
polyc_crypto::session_grant::verify_grant(&signer.public_key_bytes(), &minted.grant)
.expect("a freshly minted grant verifies");
assert_eq!(claims.generation, 1);
let family = families
.get(claims.family_id.clone())
.await
.expect("get")
.expect("the family login created is present");
assert_eq!(family.persona_id, persona_id);
assert_eq!(minted.family_expires_ms, family.absolute_expires_ms);
}
#[tokio::test]
async fn wrong_challenge_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(2);
enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
"some-other-challenge",
&sessions.config().rp_id,
&sessions.config().origin,
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a mismatched challenge must be rejected");
assert_eq!(err, LoginError::AssertionRejected);
}
#[tokio::test]
async fn wrong_origin_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(3);
enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
"https://evil.example",
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a mismatched origin must be rejected");
assert_eq!(err, LoginError::AssertionRejected);
}
#[tokio::test]
async fn wrong_rp_id_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(4);
enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
"other.rp.id",
&sessions.config().origin,
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a mismatched rp id must be rejected");
assert_eq!(err, LoginError::AssertionRejected);
}
#[tokio::test]
async fn expired_challenge_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(5);
enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
let err = sessions
.login(
&challenge.token,
&proof,
&*persona,
&*families,
NOW + polyc_ceremony::MAX_TTL_MS + 1,
)
.await
.expect_err("a challenge older than the shared TTL must be rejected");
assert_eq!(err, LoginError::ChallengeExpired);
}
#[tokio::test]
async fn replayed_challenge_token_is_rejected_proving_single_use() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(6);
enroll_and_link(&persona, &soft).await;
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect("first redemption succeeds");
let replay = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await;
assert_eq!(
replay.unwrap_err(),
LoginError::ChallengeExpired,
"a spent challenge token must never redeem twice"
);
}
#[tokio::test]
async fn unlinked_provisional_persona_with_a_valid_assertion_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(7);
let outcome = persona
.attribute(
polyc_proto::proto::polychrome::persona::v1::ExternalIdentity {
provider: "test".to_owned(),
external_id: "unlinked-persona".to_owned(),
..Default::default()
},
"conv-1".to_owned(),
polyc_persona::ROLE_INITIATOR.to_owned(),
NOW,
)
.await
.expect("attribute provisions a persona");
persona
.seed_persona_credential_for_tests(
&outcome.persona_id,
credential_id_of(&soft),
soft.public_key_sec1(),
vec![9u8; 32],
"unused.enrollment.rp".to_owned(),
"https://unused.enrollment.origin".to_owned(),
NOW,
)
.await
.expect("seed_persona_credential_for_tests");
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a provisional persona must not log in");
assert_eq!(err, LoginError::NotLinked);
}
#[tokio::test]
async fn unknown_credential_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(8);
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("an unenrolled credential id must not resolve");
assert_eq!(err, LoginError::UnknownCredential);
}
#[tokio::test]
async fn revoked_credential_is_rejected() {
let sessions = test_sessions();
let persona = spawn_persona();
let families = spawn_families(test_signer());
let _signer = test_signer();
let soft = SoftPasskey::from_seed(9);
let persona_id = enroll_and_link(&persona, &soft).await;
persona
.revoke_persona_credential(&persona_id, NOW)
.await
.expect("revoke")
.expect("a credential was enrolled to revoke");
let challenge = sessions.challenge(NOW).await.expect("mint challenge");
let proof = build_proof(
&soft,
&challenge.challenge_b64,
&sessions.config().rp_id,
&sessions.config().origin,
);
let err = sessions
.login(&challenge.token, &proof, &*persona, &*families, NOW)
.await
.expect_err("a revoked credential must not log in even with a valid assertion");
assert_eq!(err, LoginError::CredentialRevoked);
}
#[test]
fn logout_revokes_a_previously_accepted_session() {
let sessions = test_sessions();
let signer = test_signer();
let session_signer = signer.relabel_for_test();
let token = mint_session(
&session_signer,
&SessionSubject::Persona {
persona_id: "persona-1".to_owned(),
},
&[SessionScope::ExplorerRead],
NOW,
crate::SESSION_TTL_MS,
);
assert!(
verify_session(
&session_signer.public_key_bytes(),
&token,
NOW,
sessions.revoked()
)
.is_some(),
"a freshly minted session verifies before logout"
);
sessions.logout(&token);
assert!(
verify_session(
&session_signer.public_key_bytes(),
&token,
NOW,
sessions.revoked()
)
.is_none(),
"the same session must be rejected once revoked"
);
}
#[test]
fn config_from_base_url_derives_rp_id_and_origin() {
let sessions = test_sessions();
assert_eq!(sessions.config().rp_id, "explore.polychrome.test");
assert_eq!(sessions.config().origin, "https://explore.polychrome.test");
}
#[test]
fn from_base_url_fails_closed_on_an_opaque_or_unparseable_url() {
let invalid = |url| {
PasskeyLoginSessions::from_base_url(url, None, Arc::new(MemoryCeremonies::new()))
.is_none()
};
assert!(invalid("not a url"));
assert!(invalid("data:text/plain,hi"));
assert!(invalid(""));
}
}