use std::sync::Arc;
use polyc_ceremony::{
CeremonyAuthority, CeremonyClassification, CeremonyError, CeremonyKind, CeremonyPayload,
CeremonyToken, MAX_TTL_MS, MintCeremony,
};
#[cfg(any(test, feature = "test-util"))]
use polyc_crypto::session::RevokedTokens;
const LOGIN_CHALLENGE_SCHEMA: &[u8] = b"\x01";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebAuthnLoginConfig {
pub rp_id: String,
pub origin: String,
}
impl WebAuthnLoginConfig {
#[must_use]
pub fn from_base_url(base_url: &str, rp_id_override: Option<&str>) -> Option<Self> {
let url = url::Url::parse(base_url).ok()?;
let host = url.host_str()?;
if host.is_empty() {
return None;
}
let origin = url.origin();
if !origin.is_tuple() {
return None;
}
let rp_id = match rp_id_override {
Some(rp_id) if host == rp_id || host.ends_with(&format!(".{rp_id}")) => {
rp_id.to_owned()
}
Some(_) => return None,
None => host.to_owned(),
};
Some(Self {
rp_id,
origin: origin.ascii_serialization(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginChallenge {
pub token: String,
pub challenge_b64: String,
}
pub struct LoginChallengeRegistry {
ceremonies: Arc<dyn CeremonyAuthority>,
config: WebAuthnLoginConfig,
#[cfg(any(test, feature = "test-util"))]
revoked: Arc<RevokedTokens>,
}
impl LoginChallengeRegistry {
#[must_use]
pub fn from_base_url(
base_url: &str,
rp_id_override: Option<&str>,
ceremonies: Arc<dyn CeremonyAuthority>,
) -> Option<Self> {
let config = WebAuthnLoginConfig::from_base_url(base_url, rp_id_override)?;
Some(Self {
ceremonies,
config,
#[cfg(any(test, feature = "test-util"))]
revoked: Arc::new(RevokedTokens::new()),
})
}
#[must_use]
pub const fn config(&self) -> &WebAuthnLoginConfig {
&self.config
}
pub async fn challenge(&self, now_ms: u64) -> Result<LoginChallenge, CeremonyError> {
let token = self
.ceremonies
.mint(MintCeremony {
kind: CeremonyKind::PasskeyLogin,
schema_version: 1,
classification: CeremonyClassification::Internal,
payload: CeremonyPayload::new(LOGIN_CHALLENGE_SCHEMA.to_vec()),
now_ms,
ttl_ms: MAX_TTL_MS,
})
.await?;
let challenge_b64 = polyc_passkey::challenge_b64(token.expose().as_bytes());
Ok(LoginChallenge {
token: token.expose().to_owned(),
challenge_b64,
})
}
pub async fn redeem(&self, challenge_token: &str, now_ms: u64) -> Result<(), CeremonyError> {
let payload = self
.ceremonies
.redeem(
CeremonyKind::PasskeyLogin,
CeremonyToken::new(challenge_token),
now_ms,
)
.await?;
if payload.as_bytes() != LOGIN_CHALLENGE_SCHEMA {
return Err(CeremonyError::Invalid);
}
Ok(())
}
#[cfg(any(test, feature = "test-util"))]
pub fn logout(&self, session_token: &str) {
self.revoked.revoke(session_token);
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub const fn revoked(&self) -> &Arc<RevokedTokens> {
&self.revoked
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use polyc_ceremony::test_util::MemoryCeremonies;
const NOW: u64 = 1_700_000_000_000;
const BASE_URL: &str = "https://explore.polychrome.test";
fn registry() -> LoginChallengeRegistry {
LoginChallengeRegistry::from_base_url(BASE_URL, None, Arc::new(MemoryCeremonies::new()))
.expect("valid config")
}
#[test]
fn config_from_base_url_derives_rp_id_and_origin() {
let config = WebAuthnLoginConfig::from_base_url(BASE_URL, None)
.expect("a well-formed https URL configures");
assert_eq!(config.rp_id, "explore.polychrome.test");
assert_eq!(config.origin, "https://explore.polychrome.test");
}
#[test]
fn config_from_base_url_fails_closed_on_an_opaque_or_unparseable_url() {
assert!(WebAuthnLoginConfig::from_base_url("not a url", None).is_none());
assert!(WebAuthnLoginConfig::from_base_url("data:text/plain,hi", None).is_none());
assert!(WebAuthnLoginConfig::from_base_url("", None).is_none());
}
#[test]
fn rp_id_override_unset_keeps_the_host_as_rp_id() {
let config = WebAuthnLoginConfig::from_base_url(BASE_URL, None)
.expect("a well-formed https URL with no override configures");
assert_eq!(config.rp_id, "explore.polychrome.test");
assert_eq!(config.origin, "https://explore.polychrome.test");
}
#[test]
fn rp_id_override_set_to_a_registrable_parent_widens_rp_id_origin_unchanged() {
let config = WebAuthnLoginConfig::from_base_url(BASE_URL, Some("polychrome.test"))
.expect("a registrable-parent override configures");
assert_eq!(config.rp_id, "polychrome.test");
assert_eq!(
config.origin, "https://explore.polychrome.test",
"origin must stay the login page's own origin even when rp_id widens"
);
}
#[test]
fn rp_id_override_equal_to_the_host_is_accepted() {
let config = WebAuthnLoginConfig::from_base_url(BASE_URL, Some("explore.polychrome.test"))
.expect("an override equal to the host configures");
assert_eq!(config.rp_id, "explore.polychrome.test");
}
#[test]
fn rp_id_override_non_suffix_fails_closed() {
assert!(WebAuthnLoginConfig::from_base_url(BASE_URL, Some("evil.example")).is_none());
assert!(
WebAuthnLoginConfig::from_base_url(
BASE_URL,
Some("explore.polychrome.test.evil.example")
)
.is_none()
);
assert!(WebAuthnLoginConfig::from_base_url(BASE_URL, Some("olychrome.test")).is_none());
}
#[tokio::test]
async fn registry_mints_and_redeems_one_durable_challenge() {
let registry = registry();
let challenge = registry.challenge(NOW).await.expect("mint challenge");
registry
.redeem(&challenge.token, NOW + 1)
.await
.expect("redeem once");
assert_eq!(
registry.redeem(&challenge.token, NOW + 2).await,
Err(CeremonyError::Spent)
);
}
#[test]
fn registry_logout_revokes_a_previously_minted_session_token() {
use polyc_crypto::session::{SessionScope, SessionSubject, mint_session, verify_session};
use polyc_crypto::signing_role::SessionSigner;
let registry = registry();
let signer =
SessionSigner::from_key_bytes(&[7u8; 32]).expect("valid 32-byte ed25519 key material");
let token = mint_session(
&signer,
&SessionSubject::Wallet {
wallet_address: "0xabc".to_owned(),
persona_id: None,
},
&[SessionScope::WalletManage],
NOW,
3_600_000,
);
assert!(
verify_session(&signer.public_key_bytes(), &token, NOW, registry.revoked()).is_some(),
"a freshly minted session verifies before logout"
);
registry.logout(&token);
assert!(
verify_session(&signer.public_key_bytes(), &token, NOW, registry.revoked()).is_none(),
"the same session must be rejected once revoked"
);
}
}