pas-external 0.7.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! AES-256-GCM wrapper for protecting PAS refresh_tokens at rest.
//!
//! # Why encryption and not hashing
//!
//! PAS stores refresh_tokens as one-way hashes because it only needs to
//! *verify* incoming tokens. Consumers of this SDK must present the
//! *original* refresh_token back to PAS on every liveness check, so
//! hashing is not an option — a reversible transform is required.
//!
//! # Format
//!
//! Ciphertext is `base64_standard(nonce[12] || ciphertext+tag)`. The 96-bit
//! nonce is fresh on every encrypt call (CSPRNG), so repeated encryption of
//! the same plaintext produces distinct ciphertexts.
//!
//! # Key source & rotation
//!
//! The key is supplied at startup via [`TokenCipher::from_base64_key`];
//! consumers typically read it from an env var and validate length +
//! encoding there so failures surface before the first request. Because
//! every ciphertext is bound to the key that produced it, a key rotation
//! strategy has two shapes:
//!
//! - "Revoke everyone, re-auth on next visit" — zero code, lose all
//!   active sessions. This is the consumer default.
//! - Dual-key window — decrypt-with-either / encrypt-with-new for a
//!   transitional period. Consumer-owned; not shipped here because the
//!   right cutoff is policy, not cryptography.

use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::{Engine, engine::general_purpose::STANDARD};

/// A PAS `refresh_token` that has been encrypted with [`TokenCipher::encrypt`].
///
/// This newtype is the *only* shape in which the middleware hands a refresh
/// token to consumer code (`SessionStore::create`). Plaintext refresh tokens
/// never cross the SDK→consumer boundary, so consumers cannot accidentally
/// persist them in clear.
///
/// Construct via [`TokenCipher::encrypt_to_token`] when you need to wrap a
/// known plaintext yourself (e.g., test fixtures).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedRefreshToken(String);

impl EncryptedRefreshToken {
    /// Get the base64 ciphertext as a string slice.
    ///
    /// Use this when persisting the value to a database column. Do not log.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Consume and return the inner ciphertext.
    #[must_use]
    pub fn into_inner(self) -> String {
        self.0
    }

    /// Build from a previously-stored ciphertext (e.g., loaded from the
    /// database when running a liveness check).
    #[must_use]
    pub fn from_stored(ciphertext: String) -> Self {
        Self(ciphertext)
    }
}

impl std::fmt::Display for EncryptedRefreshToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// 96-bit nonce size per RFC 5116 / AES-GCM spec.
const NONCE_SIZE: usize = 12;

/// Errors produced by [`TokenCipher`].
///
/// Every variant is "unrecoverable for this session" from the consumer's
/// point of view — the correct response is to drop the cached auth
/// context and force the user to re-auth. There is no retry that will
/// turn a cipher failure into success.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CipherError {
    #[error("key decode failed: {0}")]
    KeyDecode(String),
    #[error("key must be 32 bytes after base64 decode, got {0}")]
    KeyWrongLength(usize),
    #[error("encrypt failed: {0}")]
    Encrypt(String),
    #[error("ciphertext decode failed: {0}")]
    CiphertextDecode(String),
    #[error("ciphertext shorter than nonce")]
    CiphertextTruncated,
    #[error("nonce size mismatch")]
    NonceSize,
    #[error("decrypt failed: {0}")]
    Decrypt(String),
    #[error("plaintext is not valid utf-8: {0}")]
    PlaintextUtf8(String),
}

/// AES-256-GCM cipher for PAS refresh_token at-rest encryption.
///
/// Cheap to [`Clone`] — the inner key schedule is wrapped in a shared
/// `Aes256Gcm` instance. Build one at startup from the base64-encoded
/// key env var and hand clones to every adapter that needs it.
#[derive(Clone)]
pub struct TokenCipher {
    cipher: Aes256Gcm,
}

impl TokenCipher {
    /// Build a cipher from a base64-encoded 32-byte key.
    ///
    /// Whitespace is trimmed so env-var values with trailing newlines
    /// still parse cleanly.
    pub fn from_base64_key(key_b64: &str) -> Result<Self, CipherError> {
        let bytes = STANDARD
            .decode(key_b64.trim())
            .map_err(|e| CipherError::KeyDecode(e.to_string()))?;
        let cipher = Aes256Gcm::new_from_slice(&bytes)
            .map_err(|_| CipherError::KeyWrongLength(bytes.len()))?;
        Ok(Self { cipher })
    }

    /// Encrypt a plaintext token. Returns `base64(nonce || ciphertext+tag)`.
    pub fn encrypt(&self, plaintext: &str) -> Result<String, CipherError> {
        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
        let ciphertext = self
            .cipher
            .encrypt(&nonce, plaintext.as_bytes())
            .map_err(|e| CipherError::Encrypt(e.to_string()))?;

        let nonce_bytes: &[u8] = nonce.as_ref();
        let mut combined = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
        combined.extend_from_slice(nonce_bytes);
        combined.extend_from_slice(&ciphertext);
        Ok(STANDARD.encode(&combined))
    }

    /// Encrypt a plaintext token directly into the [`EncryptedRefreshToken`]
    /// newtype. Prefer this over [`Self::encrypt`] when interfacing with
    /// SDK middleware types.
    ///
    /// # Errors
    ///
    /// Returns the underlying [`CipherError`] from [`Self::encrypt`].
    pub fn encrypt_to_token(&self, plaintext: &str) -> Result<EncryptedRefreshToken, CipherError> {
        Ok(EncryptedRefreshToken(self.encrypt(plaintext)?))
    }

    /// Decrypt a ciphertext produced by [`Self::encrypt`].
    ///
    /// Any tampering (wrong key, truncated ciphertext, altered nonce,
    /// altered tag) fails AEAD authentication and returns
    /// [`CipherError::Decrypt`]. Callers should treat any error here as
    /// "session unrecoverable, force re-auth" rather than a transient
    /// condition.
    pub fn decrypt(&self, ciphertext_b64: &str) -> Result<String, CipherError> {
        let combined = STANDARD
            .decode(ciphertext_b64.trim())
            .map_err(|e| CipherError::CiphertextDecode(e.to_string()))?;

        if combined.len() <= NONCE_SIZE {
            return Err(CipherError::CiphertextTruncated);
        }

        let (nonce_bytes, ct) = combined.split_at(NONCE_SIZE);
        let nonce_array: [u8; NONCE_SIZE] =
            nonce_bytes.try_into().map_err(|_| CipherError::NonceSize)?;
        let nonce = Nonce::from(nonce_array);

        let plaintext = self
            .cipher
            .decrypt(&nonce, ct)
            .map_err(|e| CipherError::Decrypt(e.to_string()))?;
        String::from_utf8(plaintext).map_err(|e| CipherError::PlaintextUtf8(e.to_string()))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    fn zero_key() -> String {
        STANDARD.encode([0u8; 32])
    }

    fn other_key() -> String {
        let mut key = [0u8; 32];
        key[0] = 1;
        STANDARD.encode(key)
    }

    #[test]
    fn roundtrip_preserves_plaintext() {
        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let plain = "rt_live_abc123def456";
        let encrypted = cipher.encrypt(plain).unwrap();
        assert_eq!(cipher.decrypt(&encrypted).unwrap(), plain);
    }

    #[test]
    fn encryption_is_randomized() {
        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let plain = "same input";
        let a = cipher.encrypt(plain).unwrap();
        let b = cipher.encrypt(plain).unwrap();
        assert_ne!(a, b);
        assert_eq!(cipher.decrypt(&a).unwrap(), plain);
        assert_eq!(cipher.decrypt(&b).unwrap(), plain);
    }

    #[test]
    fn wrong_key_fails_authentication() {
        let c1 = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let c2 = TokenCipher::from_base64_key(&other_key()).unwrap();
        let ct = c1.encrypt("secret").unwrap();
        assert!(matches!(c2.decrypt(&ct), Err(CipherError::Decrypt(_))));
    }

    #[test]
    fn tampered_ciphertext_fails_authentication() {
        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let ct = cipher.encrypt("secret").unwrap();
        let mut bytes = STANDARD.decode(&ct).unwrap();
        let last = bytes.len() - 1;
        bytes[last] ^= 0x01;
        let tampered = STANDARD.encode(&bytes);
        assert!(matches!(
            cipher.decrypt(&tampered),
            Err(CipherError::Decrypt(_))
        ));
    }

    #[test]
    fn short_input_is_rejected() {
        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let too_short = STANDARD.encode([0u8; 8]);
        assert!(matches!(
            cipher.decrypt(&too_short),
            Err(CipherError::CiphertextTruncated)
        ));
    }

    #[test]
    fn invalid_base64_key_is_rejected() {
        assert!(matches!(
            TokenCipher::from_base64_key("not base64!!!"),
            Err(CipherError::KeyDecode(_))
        ));
    }

    #[test]
    fn wrong_length_key_is_rejected() {
        let too_short = STANDARD.encode([0u8; 16]);
        assert!(matches!(
            TokenCipher::from_base64_key(&too_short),
            Err(CipherError::KeyWrongLength(16))
        ));
    }

    #[test]
    fn key_with_trailing_whitespace_still_parses() {
        let mut key = zero_key();
        key.push('\n');
        key.push(' ');
        assert!(TokenCipher::from_base64_key(&key).is_ok());
    }

    #[test]
    fn encrypt_to_token_roundtrips_via_from_stored() {
        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
        let plaintext = "rt_live_xyz789";
        let token = cipher.encrypt_to_token(plaintext).unwrap();

        // Persisted as string, loaded back later via from_stored
        let persisted: String = token.as_str().to_string();
        let restored = EncryptedRefreshToken::from_stored(persisted);

        assert_eq!(cipher.decrypt(restored.as_str()).unwrap(), plaintext);
    }

    #[test]
    fn encrypted_refresh_token_display_matches_inner() {
        let token = EncryptedRefreshToken::from_stored("base64-cipher".to_string());
        assert_eq!(token.to_string(), "base64-cipher");
        assert_eq!(token.as_str(), "base64-cipher");
    }
}