use std::time::Duration;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use crate::sealed::{Expiring, SealedToken};
pub const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60);
const CLOCK_SKEW_SECS: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CeremonyPurpose {
Registration,
Authentication,
}
impl CeremonyPurpose {
fn seal_tag(self) -> &'static [u8] {
match self {
Self::Registration => b"rustango-passkey-reg-v2",
Self::Authentication => b"rustango-passkey-auth-v2",
}
}
}
#[must_use]
pub fn seal_challenge(
challenge: &[u8],
purpose: CeremonyPurpose,
context: &[u8],
secret: &[u8],
) -> String {
seal_at(
challenge,
purpose,
context,
secret,
chrono::Utc::now().timestamp(),
)
}
fn seal_at(
challenge: &[u8],
purpose: CeremonyPurpose,
context: &[u8],
secret: &[u8],
issued_at: i64,
) -> String {
let ceremony = Ceremony {
iat: issued_at,
challenge: URL_SAFE_NO_PAD.encode(challenge),
context: URL_SAFE_NO_PAD.encode(context),
};
SealedToken::seal(secret, purpose.seal_tag(), &ceremony)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Ceremony {
iat: i64,
challenge: String,
context: String,
}
impl Expiring for Ceremony {
fn is_expired_at(&self, now: i64) -> bool {
let ttl = i64::try_from(CHALLENGE_TTL.as_secs()).unwrap_or(i64::MAX);
self.iat > now + CLOCK_SKEW_SECS || now - self.iat > ttl
}
}
fn verify_at(
sealed: &str,
purpose: CeremonyPurpose,
secret: &[u8],
now: i64,
) -> Option<(Vec<u8>, Vec<u8>)> {
let c: Ceremony = SealedToken::open_at(secret, purpose.seal_tag(), sealed, now).ok()?;
let challenge = URL_SAFE_NO_PAD.decode(c.challenge).ok()?;
let context = URL_SAFE_NO_PAD.decode(c.context).ok()?;
Some((challenge, context))
}
pub async fn open_challenge(
sealed: &str,
purpose: CeremonyPurpose,
secret: &[u8],
cache: &dyn crate::cache::Cache,
) -> Option<(Vec<u8>, Vec<u8>)> {
let opened = verify_at(sealed, purpose, secret, chrono::Utc::now().timestamp())?;
if cache.stores_nothing() {
tracing::error!(
target: "rustango::passkey",
"passkey challenge refused: the cache keeps nothing (`NullCache`); use a shared cache"
);
return None;
}
if cache.is_process_local() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
target: "rustango::passkey",
"passkey challenges use a process-local cache; another replica can open one again"
);
});
}
let sig = &sealed[sealed.rfind('.')? + 1..];
let ttl = CHALLENGE_TTL + Duration::from_secs(CLOCK_SKEW_SECS.unsigned_abs());
match cache
.add(&format!("passkey_challenge_used:{sig}"), "1", Some(ttl))
.await
{
Ok(true) => Some(opened),
Ok(false) => None,
Err(e) => {
tracing::error!(target: "rustango::passkey", error = %e, "passkey challenge refused: cache failed");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{InMemoryCache, NullCache};
use CeremonyPurpose::{Authentication, Registration};
const NOW: i64 = 1_800_000_000;
#[test]
fn seal_open_round_trip_with_context() {
let secret = b"signing-secret";
let challenge = b"\x00\x01\x02 random-32-byte-ish challenge";
let token = seal_at(challenge, Registration, b"user-42", secret, NOW);
let (got_challenge, got_ctx) = verify_at(&token, Registration, secret, NOW).expect("opens");
assert_eq!(got_challenge, challenge);
assert_eq!(got_ctx, b"user-42");
}
#[test]
fn empty_context_round_trips() {
let token = seal_at(b"chal", Authentication, b"", b"k", NOW);
let (c, ctx) = verify_at(&token, Authentication, b"k", NOW).unwrap();
assert_eq!(c, b"chal");
assert!(ctx.is_empty());
}
#[test]
fn wrong_secret_is_rejected() {
let token = seal_at(b"chal", Registration, b"ctx", b"secret-A", NOW);
assert!(verify_at(&token, Registration, b"secret-B", NOW).is_none());
}
#[test]
fn tampered_token_is_rejected() {
let token = seal_at(b"chal", Registration, b"ctx", b"secret", NOW);
let mut bytes = token.into_bytes();
bytes[0] ^= 0x01;
let tampered = String::from_utf8(bytes).unwrap();
assert!(verify_at(&tampered, Registration, b"secret", NOW).is_none());
}
#[test]
fn malformed_token_is_none() {
assert!(verify_at("nodothere", Registration, b"s", NOW).is_none());
assert!(verify_at("only.onedot", Registration, b"s", NOW).is_none());
}
#[test]
fn expired_challenge_is_rejected() {
let token = seal_at(b"chal", Authentication, b"", b"s", NOW);
let ttl = CHALLENGE_TTL.as_secs() as i64;
assert!(verify_at(&token, Authentication, b"s", NOW + ttl).is_some());
assert!(verify_at(&token, Authentication, b"s", NOW + ttl + 1).is_none());
assert!(verify_at(&token, Authentication, b"s", NOW - 3600).is_none());
}
#[test]
fn purpose_is_bound() {
let token = seal_at(b"chal", Registration, b"", b"s", NOW);
assert!(verify_at(&token, Authentication, b"s", NOW).is_none());
}
#[tokio::test]
async fn open_is_one_time() {
let cache = InMemoryCache::new();
let token = seal_challenge(b"chal", Authentication, b"", b"s");
assert!(open_challenge(&token, Authentication, b"s", &cache)
.await
.is_some());
assert!(
open_challenge(&token, Authentication, b"s", &cache)
.await
.is_none(),
"a replayed challenge must not open twice"
);
}
#[tokio::test]
async fn null_cache_fails_closed() {
let token = seal_challenge(b"chal", Authentication, b"", b"s");
assert!(open_challenge(&token, Authentication, b"s", &NullCache)
.await
.is_none());
}
}