huskarl-resource-server 0.9.1

OAuth2 resource server (JWT validation) support for the huskarl ecosystem.
Documentation
//! Server-side `DPoP` nonce enforcement (RFC 9449 §8).
//!
//! A `DPoP` nonce is a server-chosen value the client must echo in its next
//! proof, letting the resource server control proof freshness and limit replay.
//! Implement [`DPoPNonceChecker`] to issue and validate nonces, or use the
//! batteries-included [`SealedTimestampNonce`], which encodes the issue time in
//! an AEAD-sealed token and so needs no server-side state.

use std::sync::Arc;

use bon::Builder;

use crate::core::{
    Error,
    crypto::cipher::{AeadSealer, AeadSealerSelector, AeadUnsealer},
    platform::{Duration, MaybeSendBoxFuture, MaybeSendSync, SystemTime},
};

/// The outcome of a `DPoP` nonce check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NonceCheck {
    /// The nonce is valid and fresh — no action needed.
    Valid,
    /// The nonce is valid but approaching expiry — include the new nonce in the response
    /// so the client can use it on the next request.
    ValidWithNewNonce(String),
    /// The nonce is missing, invalid, or expired. Reject the request with
    /// `use_dpop_nonce` and include the provided nonce in the response.
    Invalid(String),
}

/// Validates `DPoP` nonces presented by clients.
///
/// Implement this trait to enforce nonce-based replay protection on `DPoP` proofs,
/// as described in RFC 9449 §8.
///
/// This trait is dyn-capable: validators store it as `Arc<dyn DPoPNonceChecker>`.
/// Write the method body as `Box::pin(async move { ... })`.
pub trait DPoPNonceChecker: MaybeSendSync {
    /// Checks whether the presented nonce is valid.
    ///
    /// Returns a [`NonceCheck`] indicating whether the nonce is valid, approaching
    /// expiry, or invalid, along with any new nonce to include in the response.
    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)
    }
}

/// A [`DPoPNonceChecker`] that issues and validates AEAD-encrypted timestamp nonces.
///
/// Nonces are generated by encrypting the current Unix timestamp with an AEAD cipher,
/// then base64url-encoding the result (without padding). This allows stateless verification of nonce age
/// without a database, as long as the encryption key is stable across requests.
#[derive(Debug, Builder)]
pub struct SealedTimestampNonce<S: AeadSealerSelector + AeadUnsealer> {
    /// The AEAD sealer/unsealer for nonce timestamps — one object that seals on
    /// the way out and unseals on the way in.
    ///
    /// Wrap a fixed [`AeadCipher`](crate::core::crypto::cipher::AeadCipher) (a
    /// symmetric or KMS-backed key) in
    /// [`StaticAeadCipher`](crate::core::crypto::cipher::StaticAeadCipher), or a
    /// rotating key in
    /// [`ScheduledRefreshCipher`](crate::core::crypto::cipher::ScheduledRefreshCipher);
    /// either erases to `Arc<dyn SealedAeadCipherSelector>`.
    sealer: S,
    /// The maximum age of a valid nonce. Defaults to 1 hour.
    #[builder(into, default = Duration::from_hours(1))]
    nonce_lifetime: Duration,
    /// How far before expiry a still-valid nonce triggers proactive rotation.
    /// Defaults to 15 minutes.
    #[builder(into, default = Duration::from_mins(15))]
    renewal_window: Duration,
    /// AEAD associated data bound into every nonce. The default is fine; change
    /// it only for domain separation, and keep it stable across requests (seal
    /// and unseal must use the same value).
    #[builder(into, default = b"dpop-nonce")]
    aad: Vec<u8>,
}

impl<S: AeadSealerSelector + AeadUnsealer> SealedTimestampNonce<S> {
    async fn generate_nonce(&self) -> Result<String, Error> {
        use base64::prelude::*;
        let current_time = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .map_err(|e| Error::new(crate::core::ErrorKind::DPoP, e))?
            .as_secs()
            .to_be_bytes();
        // Select a frozen sealer for this nonce: whatever key seals it is the key
        // named by any metadata read off the same snapshot, even under rotation.
        let sealer = self.sealer.select_sealer().await;
        let sealed_bytes = sealer.seal(&current_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)
            .ok()?
            .as_secs();
        Some(current_secs.saturating_sub(nonce_issued_at))
    }
}

impl<S: AeadSealerSelector + 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, CipherMatch, DecryptError, SealedAeadCipherSelector, StaticAeadCipher,
        },
    };

    /// XOR "cipher" with both capabilities on one object — the shape a
    /// symmetric key or KMS-backed cipher provides.
    #[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<StaticAeadCipher<MockCipher>> {
        SealedTimestampNonce::builder()
            .sealer(StaticAeadCipher::new(MockCipher))
            .build()
    }

    /// Forge a nonce whose sealed timestamp lies `age_secs` in the past.
    async fn nonce_with_age(
        checker: &SealedTimestampNonce<StaticAeadCipher<MockCipher>>,
        age_secs: u64,
    ) -> String {
        let issued_at = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs()
            - age_secs;
        let sealed = checker
            .sealer
            .select_sealer()
            .await
            .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; // past the 2700s renewal threshold
        assert!(matches!(
            checker.check_nonce(Some(&aging)).await.unwrap(),
            NonceCheck::ValidWithNewNonce(_)
        ));
    }

    /// The erased one-object form also satisfies the bound.
    #[tokio::test]
    async fn erased_sealed_cipher_constructs() {
        let cipher: Arc<dyn SealedAeadCipherSelector> = Arc::new(StaticAeadCipher::new(MockCipher));
        let checker = SealedTimestampNonce::builder().sealer(cipher).build();
        assert!(matches!(
            checker.check_nonce(None).await.unwrap(),
            NonceCheck::Invalid(_)
        ));
    }
}