use std::sync::Arc;
use bon::Builder;
use crate::core::{
Error,
crypto::cipher::{AeadSealer, AeadUnsealer},
platform::{Duration, MaybeSendBoxFuture, MaybeSendSync, SystemTime},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NonceCheck {
Valid,
ValidWithNewNonce(String),
Invalid(String),
}
pub trait DpopNonceChecker: MaybeSendSync {
fn check_nonce<'a>(
&'a self,
nonce: Option<&'a str>,
) -> MaybeSendBoxFuture<'a, Result<NonceCheck, Error>>;
}
impl<T: DpopNonceChecker + ?Sized> DpopNonceChecker for &T {
fn check_nonce<'a>(
&'a self,
nonce: Option<&'a str>,
) -> MaybeSendBoxFuture<'a, Result<NonceCheck, Error>> {
(**self).check_nonce(nonce)
}
}
impl<T: DpopNonceChecker + ?Sized> DpopNonceChecker for Box<T> {
fn check_nonce<'a>(
&'a self,
nonce: Option<&'a str>,
) -> MaybeSendBoxFuture<'a, Result<NonceCheck, Error>> {
(**self).check_nonce(nonce)
}
}
impl<T: DpopNonceChecker + ?Sized> DpopNonceChecker for Arc<T> {
fn check_nonce<'a>(
&'a self,
nonce: Option<&'a str>,
) -> MaybeSendBoxFuture<'a, Result<NonceCheck, Error>> {
(**self).check_nonce(nonce)
}
}
#[derive(Debug, Builder)]
pub struct SealedTimestampNonce<S: AeadSealer + AeadUnsealer> {
sealer: S,
#[builder(into, default = Duration::from_secs(3600))]
nonce_lifetime: Duration,
#[builder(into, default = Duration::from_secs(900))]
renewal_window: Duration,
#[builder(into, default = b"dpop-nonce")]
aad: Vec<u8>,
}
impl<S: AeadSealer + AeadUnsealer> SealedTimestampNonce<S> {
async fn generate_nonce(&self) -> Result<String, Error> {
use base64::prelude::*;
let current_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_be_bytes();
let sealed_bytes = self.sealer.seal(¤t_time, &self.aad).await?;
Ok(BASE64_URL_SAFE_NO_PAD.encode(sealed_bytes))
}
async fn nonce_age_secs(&self, nonce: Option<&str>) -> Option<u64> {
use base64::prelude::*;
let nonce_bytes = BASE64_URL_SAFE_NO_PAD.decode(nonce?).ok()?;
let unsealed_bytes = self
.sealer
.unseal(None, &nonce_bytes, &self.aad)
.await
.ok()?;
let timestamp_bytes = <[u8; 8]>::try_from(unsealed_bytes).ok()?;
let nonce_issued_at = u64::from_be_bytes(timestamp_bytes);
let current_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
Some(current_secs.saturating_sub(nonce_issued_at))
}
}
impl<S: AeadSealer + AeadUnsealer> DpopNonceChecker for SealedTimestampNonce<S> {
fn check_nonce<'a>(
&'a self,
nonce: Option<&'a str>,
) -> MaybeSendBoxFuture<'a, Result<NonceCheck, Error>> {
Box::pin(async move {
let lifetime = self.nonce_lifetime.as_secs();
let renewal_threshold = lifetime.saturating_sub(self.renewal_window.as_secs());
match self.nonce_age_secs(nonce).await {
Some(age) if age <= lifetime => {
if age >= renewal_threshold {
Ok(NonceCheck::ValidWithNewNonce(self.generate_nonce().await?))
} else {
Ok(NonceCheck::Valid)
}
}
_ => Ok(NonceCheck::Invalid(self.generate_nonce().await?)),
}
})
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use base64::prelude::*;
use super::*;
use crate::core::crypto::{
KeyMatchStrength,
cipher::{AeadOutput, AeadV1Cipher, CipherMatch, DecryptError, SealedAeadCipher},
};
#[derive(Debug)]
struct MockCipher;
impl crate::core::crypto::cipher::AeadEncryptor for MockCipher {
fn enc_algorithm(&self) -> Cow<'_, str> {
"mock".into()
}
fn key_id(&self) -> Option<Cow<'_, str>> {
None
}
fn encrypt<'a>(
&'a self,
plaintext: &'a [u8],
_aad: &'a [u8],
) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
Box::pin(async move {
Ok(AeadOutput {
nonce: vec![1, 2, 3],
ciphertext: plaintext.iter().map(|b| b ^ 0xFF).collect(),
tag: vec![4, 5, 6, 7],
})
})
}
}
impl crate::core::crypto::cipher::AeadDecryptor for MockCipher {
fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
Some(KeyMatchStrength::ByAlgorithm)
}
fn decrypt<'a>(
&'a self,
_cipher_match: Option<&'a CipherMatch<'a>>,
_nonce: &'a [u8],
ciphertext: &'a [u8],
_tag: &'a [u8],
_aad: &'a [u8],
) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
Box::pin(async move { Ok(ciphertext.iter().map(|b| b ^ 0xFF).collect()) })
}
}
fn checker() -> SealedTimestampNonce<AeadV1Cipher<MockCipher>> {
SealedTimestampNonce::builder()
.sealer(AeadV1Cipher::new(MockCipher))
.build()
}
async fn nonce_with_age(
checker: &SealedTimestampNonce<AeadV1Cipher<MockCipher>>,
age_secs: u64,
) -> String {
let issued_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
- age_secs;
let sealed = checker
.sealer
.seal(&issued_at.to_be_bytes(), &checker.aad)
.await
.unwrap();
BASE64_URL_SAFE_NO_PAD.encode(sealed)
}
#[tokio::test]
async fn missing_nonce_is_invalid_and_issues_one_that_validates() {
let checker = checker();
let NonceCheck::Invalid(new_nonce) = checker.check_nonce(None).await.unwrap() else {
panic!("missing nonce must be Invalid");
};
assert_eq!(
checker.check_nonce(Some(&new_nonce)).await.unwrap(),
NonceCheck::Valid
);
}
#[tokio::test]
async fn garbage_nonce_is_invalid() {
let checker = checker();
assert!(matches!(
checker.check_nonce(Some("not-a-nonce")).await.unwrap(),
NonceCheck::Invalid(_)
));
}
#[tokio::test]
async fn expired_nonce_is_invalid() {
let checker = checker();
let old = nonce_with_age(&checker, 3601).await;
assert!(matches!(
checker.check_nonce(Some(&old)).await.unwrap(),
NonceCheck::Invalid(_)
));
}
#[tokio::test]
async fn nonce_in_renewal_window_rotates() {
let checker = checker();
let aging = nonce_with_age(&checker, 3000).await; assert!(matches!(
checker.check_nonce(Some(&aging)).await.unwrap(),
NonceCheck::ValidWithNewNonce(_)
));
}
#[tokio::test]
async fn erased_sealed_cipher_constructs() {
let cipher: Arc<dyn SealedAeadCipher> = Arc::new(AeadV1Cipher::new(MockCipher));
let checker = SealedTimestampNonce::builder().sealer(cipher).build();
assert!(matches!(
checker.check_nonce(None).await.unwrap(),
NonceCheck::Invalid(_)
));
}
}