use ring::rand::{SecureRandom, SystemRandom};
use secure_device_trust::{DeviceTrustDecision, DeviceTrustOutcome, TrustTier};
use secure_network::{MtlsClientIdentity, MtlsClientIdentityStatus, NoMtlsRevocations};
use security_core::{
identity::AuthenticatedIdentity,
types::{ActorId, TenantId},
};
use time::{Duration, OffsetDateTime};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PasswordlessMethod {
Passkey,
DeepLink,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PasskeySupport {
Supported,
Unsupported,
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordlessChallengeRequest {
pub preferred_method: PasswordlessMethod,
pub passkey_support: PasskeySupport,
pub user_hint: Option<String>,
}
impl std::fmt::Debug for PasswordlessChallengeRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordlessChallengeRequest")
.field("preferred_method", &self.preferred_method)
.field("passkey_support", &self.passkey_support)
.field("user_hint", &self.user_hint.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl PasswordlessChallengeRequest {
#[must_use]
pub fn passkey_preferred(passkey_support: PasskeySupport) -> Self {
Self {
preferred_method: PasswordlessMethod::Passkey,
passkey_support,
user_hint: None,
}
}
#[must_use]
pub fn with_user_hint(mut self, user_hint: impl Into<String>) -> Self {
self.user_hint = Some(user_hint.into());
self
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct DeviceSessionBinding {
certificate_serial: String,
certificate_fingerprint: String,
trust_tier: TrustTier,
}
impl std::fmt::Debug for DeviceSessionBinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeviceSessionBinding")
.field("certificate_serial", &"<redacted>")
.field("certificate_fingerprint", &"<redacted>")
.field("trust_tier", &self.trust_tier)
.finish()
}
}
impl DeviceSessionBinding {
#[must_use]
pub fn new(
certificate_serial: impl Into<String>,
certificate_fingerprint: impl Into<String>,
trust_tier: TrustTier,
) -> Self {
Self {
certificate_serial: certificate_serial.into(),
certificate_fingerprint: certificate_fingerprint.into(),
trust_tier,
}
}
#[must_use]
pub fn certificate_serial(&self) -> &str {
&self.certificate_serial
}
#[must_use]
pub fn certificate_fingerprint(&self) -> &str {
&self.certificate_fingerprint
}
#[must_use]
pub fn trust_tier(&self) -> TrustTier {
self.trust_tier
}
#[must_use]
pub fn matches_mtls(&self, mtls: &MtlsClientIdentity) -> bool {
self.certificate_serial == mtls.serial && self.certificate_fingerprint == mtls.fingerprint
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PasswordlessChallenge {
challenge_id: String,
method: PasswordlessMethod,
binding: DeviceSessionBinding,
issued_at: OffsetDateTime,
expires_at: OffsetDateTime,
}
impl std::fmt::Debug for PasswordlessChallenge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordlessChallenge")
.field("challenge_id", &"<redacted>")
.field("method", &self.method)
.field("binding", &self.binding)
.field("issued_at", &self.issued_at)
.field("expires_at", &self.expires_at)
.finish()
}
}
impl PasswordlessChallenge {
#[must_use]
pub fn new(
challenge_id: impl Into<String>,
method: PasswordlessMethod,
binding: DeviceSessionBinding,
issued_at: OffsetDateTime,
expires_at: OffsetDateTime,
) -> Self {
Self {
challenge_id: challenge_id.into(),
method,
binding,
issued_at,
expires_at,
}
}
#[must_use]
pub fn challenge_id(&self) -> &str {
&self.challenge_id
}
#[must_use]
pub fn method(&self) -> PasswordlessMethod {
self.method
}
#[must_use]
pub fn device_binding(&self) -> &DeviceSessionBinding {
&self.binding
}
#[must_use]
pub fn issued_at(&self) -> OffsetDateTime {
self.issued_at
}
#[must_use]
pub fn expires_at(&self) -> OffsetDateTime {
self.expires_at
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum PasswordlessProof {
Passkey {
challenge_id: String,
credential_id: String,
client_data_hash: String,
},
DeepLink {
challenge_id: String,
nonce: String,
signature: String,
},
}
impl std::fmt::Debug for PasswordlessProof {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Passkey { .. } => f
.debug_struct("Passkey")
.field("challenge_id", &"<redacted>")
.field("credential_id", &"<redacted>")
.field("client_data_hash", &"<redacted>")
.finish(),
Self::DeepLink { .. } => f
.debug_struct("DeepLink")
.field("challenge_id", &"<redacted>")
.field("nonce", &"<redacted>")
.field("signature", &"<redacted>")
.finish(),
}
}
}
impl PasswordlessProof {
#[must_use]
pub fn passkey(
challenge_id: impl Into<String>,
credential_id: impl Into<String>,
client_data_hash: impl Into<String>,
) -> Self {
Self::Passkey {
challenge_id: challenge_id.into(),
credential_id: credential_id.into(),
client_data_hash: client_data_hash.into(),
}
}
#[must_use]
pub fn deep_link(
challenge_id: impl Into<String>,
nonce: impl Into<String>,
signature: impl Into<String>,
) -> Self {
Self::DeepLink {
challenge_id: challenge_id.into(),
nonce: nonce.into(),
signature: signature.into(),
}
}
#[must_use]
pub fn challenge_id(&self) -> &str {
match self {
Self::Passkey { challenge_id, .. } | Self::DeepLink { challenge_id, .. } => {
challenge_id
}
}
}
#[must_use]
pub fn method(&self) -> PasswordlessMethod {
match self {
Self::Passkey { .. } => PasswordlessMethod::Passkey,
Self::DeepLink { .. } => PasswordlessMethod::DeepLink,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct BoundUserSession {
session_token: String,
actor_id: ActorId,
tenant_id: Option<TenantId>,
roles: Vec<String>,
binding: DeviceSessionBinding,
created_at: OffsetDateTime,
expires_at: OffsetDateTime,
}
impl std::fmt::Debug for BoundUserSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoundUserSession")
.field("session_token", &"<redacted>")
.field("actor_id", &"<redacted>")
.field("tenant_id", &self.tenant_id.as_ref().map(|_| "<redacted>"))
.field("roles", &self.roles)
.field("binding", &self.binding)
.field("created_at", &self.created_at)
.field("expires_at", &self.expires_at)
.finish()
}
}
impl BoundUserSession {
#[must_use]
pub fn new(
session_token: impl Into<String>,
identity: &AuthenticatedIdentity,
binding: DeviceSessionBinding,
created_at: OffsetDateTime,
expires_at: OffsetDateTime,
) -> Self {
Self {
session_token: session_token.into(),
actor_id: identity.actor_id.clone(),
tenant_id: identity.tenant_id.clone(),
roles: identity.roles.clone(),
binding,
created_at,
expires_at,
}
}
#[must_use]
pub fn session_token(&self) -> &str {
&self.session_token
}
#[must_use]
pub fn actor_id(&self) -> &ActorId {
&self.actor_id
}
#[must_use]
pub fn tenant_id(&self) -> Option<&TenantId> {
self.tenant_id.as_ref()
}
#[must_use]
pub fn roles(&self) -> &[String] {
&self.roles
}
#[must_use]
pub fn device_binding(&self) -> &DeviceSessionBinding {
&self.binding
}
#[must_use]
pub fn created_at(&self) -> OffsetDateTime {
self.created_at
}
#[must_use]
pub fn expires_at(&self) -> OffsetDateTime {
self.expires_at
}
#[must_use]
pub fn is_bound_to(&self, mtls: &MtlsClientIdentity) -> bool {
self.binding.matches_mtls(mtls)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum PasswordlessError {
MissingClientCertificate,
DeniedDeviceTrust,
ChallengeExpired,
CertificateBindingMismatch,
ChallengeMismatch,
ChallengeMethodMismatch,
InvalidProof,
InvalidSessionLifetime,
ProviderUnavailable,
}
impl std::fmt::Display for PasswordlessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingClientCertificate => write!(f, "missing client certificate"),
Self::DeniedDeviceTrust => write!(f, "device trust denied passwordless challenge"),
Self::ChallengeExpired => write!(f, "passwordless challenge expired"),
Self::CertificateBindingMismatch => {
write!(f, "passwordless challenge certificate binding mismatch")
}
Self::ChallengeMismatch => write!(f, "passwordless proof answered another challenge"),
Self::ChallengeMethodMismatch => write!(f, "passwordless proof method mismatch"),
Self::InvalidProof => write!(f, "passwordless proof rejected"),
Self::InvalidSessionLifetime => write!(f, "passwordless session lifetime invalid"),
Self::ProviderUnavailable => write!(f, "passwordless provider unavailable"),
}
}
}
impl std::error::Error for PasswordlessError {}
impl From<PasswordlessError> for crate::IdentityError {
fn from(error: PasswordlessError) -> Self {
match error {
PasswordlessError::ChallengeExpired => Self::TokenExpired,
PasswordlessError::ProviderUnavailable => Self::ProviderUnavailable,
_ => Self::InvalidCredentials,
}
}
}
pub trait PasswordlessProofVerifier {
fn verify(
&self,
challenge: &PasswordlessChallenge,
proof: &PasswordlessProof,
) -> Result<AuthenticatedIdentity, PasswordlessError>;
}
#[derive(Clone)]
pub struct PasswordlessChallengeService<V> {
verifier: V,
challenge_ttl: Duration,
}
impl<V> PasswordlessChallengeService<V>
where
V: PasswordlessProofVerifier,
{
#[must_use]
pub fn new(verifier: V) -> Self {
Self {
verifier,
challenge_ttl: Duration::minutes(5),
}
}
#[must_use]
pub fn with_challenge_ttl(mut self, challenge_ttl: Duration) -> Self {
self.challenge_ttl = challenge_ttl;
self
}
pub fn request_challenge(
&self,
mtls: Option<&MtlsClientIdentity>,
decision: &DeviceTrustDecision,
request: &PasswordlessChallengeRequest,
now: OffsetDateTime,
) -> Result<PasswordlessChallenge, PasswordlessError> {
let mtls = mtls.ok_or(PasswordlessError::MissingClientCertificate)?;
if mtls.serial.trim().is_empty()
|| mtls.fingerprint.trim().is_empty()
|| mtls.validate_at(now, &NoMtlsRevocations) != MtlsClientIdentityStatus::Valid
{
return Err(PasswordlessError::MissingClientCertificate);
}
if decision.outcome() == DeviceTrustOutcome::Denied || decision.tier() <= TrustTier::None {
return Err(PasswordlessError::DeniedDeviceTrust);
}
let method = match (request.preferred_method, request.passkey_support) {
(PasswordlessMethod::Passkey, PasskeySupport::Unsupported) => {
PasswordlessMethod::DeepLink
}
(method, _) => method,
};
let binding = DeviceSessionBinding::new(
mtls.serial.clone(),
mtls.fingerprint.clone(),
decision.tier(),
);
Ok(PasswordlessChallenge::new(
generate_opaque_token("plc")?,
method,
binding,
now,
now + self.challenge_ttl,
))
}
pub fn complete_challenge(
&self,
mtls: &MtlsClientIdentity,
challenge: &PasswordlessChallenge,
proof: &PasswordlessProof,
session_lifetime_secs: u64,
now: OffsetDateTime,
) -> Result<BoundUserSession, PasswordlessError> {
if !challenge.device_binding().matches_mtls(mtls) {
return Err(PasswordlessError::CertificateBindingMismatch);
}
if now >= challenge.expires_at() {
return Err(PasswordlessError::ChallengeExpired);
}
if proof.challenge_id() != challenge.challenge_id() {
return Err(PasswordlessError::ChallengeMismatch);
}
if proof.method() != challenge.method() {
return Err(PasswordlessError::ChallengeMethodMismatch);
}
let lifetime_secs = i64::try_from(session_lifetime_secs)
.map_err(|_| PasswordlessError::InvalidSessionLifetime)?;
if lifetime_secs <= 0 {
return Err(PasswordlessError::InvalidSessionLifetime);
}
let identity = self.verifier.verify(challenge, proof)?;
let expires_at = now + Duration::seconds(lifetime_secs);
Ok(BoundUserSession::new(
generate_opaque_token("bus")?,
&identity,
challenge.device_binding().clone(),
now,
expires_at,
))
}
}
fn generate_opaque_token(prefix: &str) -> Result<String, PasswordlessError> {
let rng = SystemRandom::new();
let mut bytes = [0_u8; 16];
rng.fill(&mut bytes)
.map_err(|_| PasswordlessError::ProviderUnavailable)?;
let suffix = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
Ok(format!("{prefix}_{suffix}"))
}