extrapaytr-crypto 0.1.0

Digest, HMAC, constant-time comparison and at-rest sealing helpers for ExtraPayTR
Documentation
//! Authenticated encryption for values an integrating application has to
//! persist — principally the card-on-file tokens returned by
//! [`CardStorage`](../../extrapaytr_core/gateway/trait.CardStorage.html).
//!
//! # Why this lives in a payments SDK
//!
//! This crate otherwise contains only what provider adapters need to sign
//! requests. Sealing is here for a different reason: a stored-card charge
//! on at least one provider (iyzico) requires no CVV and no second
//! factor, so the `(cardUserKey, cardToken)` pair alone can move money.
//! Every application that stores one faces the identical problem, and
//! "roll your own AES" is the wrong answer to hand a payments integrator.
//!
//! Scope is deliberately narrow: one algorithm (AES-256-GCM), one key
//! size, a versioned output format, and no key management. Key storage,
//! rotation policy, and access control remain the application's job —
//! this only ensures the bytes are encrypted correctly.
//!
//! # Format
//!
//! ```text
//! epv1:<base64(nonce ‖ ciphertext ‖ tag)>
//! ```
//!
//! The `epv1:` prefix is what makes rotation to a future algorithm
//! possible without having to guess what a stored value contains, and
//! lets [`SealingKey::is_sealed`] distinguish an already-encrypted value
//! from a legacy plaintext one during a migration.
//!
//! ```
//! use extrapaytr_crypto::seal::SealingKey;
//!
//! let key_hex = SealingKey::generate_key_hex();
//! let key = SealingKey::from_hex(&key_hex)?;
//!
//! let sealed = key.seal(b"cardToken-73a05d99")?;
//! assert!(SealingKey::is_sealed(&sealed));
//! assert_eq!(key.open(&sealed)?, b"cardToken-73a05d99");
//! # Ok::<(), extrapaytr_crypto::seal::SealError>(())
//! ```

use aes_gcm::aead::{Aead, Generate, KeyInit, Nonce};
use aes_gcm::{Aes256Gcm, Key};

use crate::encoding::{base64_decode, base64_encode};

const PREFIX: &str = "epv1:";
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;

#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SealError {
    #[error("sealing key must be exactly {KEY_LEN} bytes ({} hex characters)", KEY_LEN * 2)]
    InvalidKeyLength,
    #[error("sealing key is not valid hex")]
    InvalidKeyEncoding,
    #[error("value is not in the `epv1:` sealed format")]
    NotSealed,
    /// Covers a corrupted, truncated, or tampered-with value as well as
    /// one sealed under a different key — deliberately indistinguishable,
    /// since telling them apart would leak whether a guessed key was
    /// close.
    #[error("sealed value could not be opened")]
    Undecryptable,
}

/// An AES-256-GCM key for sealing stored values.
///
/// Deliberately has no `Debug`/`Display`/`Clone` that could surface key
/// material, and no accessor to read it back — a `SealingKey` is
/// something you use, not something you inspect.
pub struct SealingKey {
    cipher: Aes256Gcm,
}

impl SealingKey {
    pub fn from_bytes(key: &[u8]) -> Result<Self, SealError> {
        let key = Key::<Aes256Gcm>::try_from(key).map_err(|_| SealError::InvalidKeyLength)?;
        Ok(Self {
            cipher: Aes256Gcm::new(&key),
        })
    }

    pub fn from_hex(key_hex: &str) -> Result<Self, SealError> {
        let bytes = decode_hex(key_hex.trim())?;
        Self::from_bytes(&bytes)
    }

    /// Generates a fresh key as hex, for an operator to place in a secret
    /// store. Returned as a `String` rather than a `SealingKey` because
    /// the one thing you must do with a new key is persist it somewhere
    /// this crate knows nothing about.
    pub fn generate_key_hex() -> String {
        crate::encoding::hex_lower(<[u8; KEY_LEN]>::generate())
    }

    /// Encrypts `plaintext`, returning the `epv1:`-prefixed form.
    ///
    /// A fresh random nonce is drawn per call, so sealing the same value
    /// twice yields different output — without that, anyone holding the
    /// database could tell which rows share a token.
    pub fn seal(&self, plaintext: &[u8]) -> Result<String, SealError> {
        let nonce_bytes = <[u8; NONCE_LEN]>::generate();
        let nonce =
            Nonce::<Aes256Gcm>::try_from(&nonce_bytes[..]).map_err(|_| SealError::Undecryptable)?;

        let ciphertext = self
            .cipher
            .encrypt(&nonce, plaintext)
            .map_err(|_| SealError::Undecryptable)?;

        let mut envelope = Vec::with_capacity(NONCE_LEN + ciphertext.len());
        envelope.extend_from_slice(&nonce_bytes);
        envelope.extend_from_slice(&ciphertext);
        Ok(format!("{PREFIX}{}", base64_encode(envelope)))
    }

    /// Decrypts a value produced by [`seal`](Self::seal).
    ///
    /// Returns [`SealError::NotSealed`] for input without the prefix
    /// rather than passing it through: silently returning unencrypted
    /// input would turn a migration bug into a security hole. Use
    /// [`is_sealed`](Self::is_sealed) to branch explicitly while
    /// migrating existing plaintext rows.
    pub fn open(&self, sealed: &str) -> Result<Vec<u8>, SealError> {
        let encoded = sealed.strip_prefix(PREFIX).ok_or(SealError::NotSealed)?;
        let envelope = base64_decode(encoded).map_err(|_| SealError::Undecryptable)?;
        if envelope.len() <= NONCE_LEN {
            return Err(SealError::Undecryptable);
        }
        let (nonce_bytes, ciphertext) = envelope.split_at(NONCE_LEN);
        let nonce =
            Nonce::<Aes256Gcm>::try_from(nonce_bytes).map_err(|_| SealError::Undecryptable)?;
        self.cipher
            .decrypt(&nonce, ciphertext)
            .map_err(|_| SealError::Undecryptable)
    }

    /// Whether a stored value is already in sealed form. Cheap enough to
    /// call per row when migrating, and the reason `epv1:` is a prefix
    /// rather than a suffix.
    pub fn is_sealed(value: &str) -> bool {
        value.starts_with(PREFIX)
    }
}

impl std::fmt::Debug for SealingKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SealingKey(<redacted>)")
    }
}

fn decode_hex(input: &str) -> Result<Vec<u8>, SealError> {
    // `usize::is_multiple_of` would read better but is stable only since
    // 1.87; this workspace's MSRV is 1.85.
    if input.len() % 2 != 0 {
        return Err(SealError::InvalidKeyEncoding);
    }
    (0..input.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&input[i..i + 2], 16).map_err(|_| SealError::InvalidKeyEncoding)
        })
        .collect()
}

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

    fn key() -> SealingKey {
        SealingKey::from_hex(&SealingKey::generate_key_hex()).unwrap()
    }

    #[test]
    fn round_trips() {
        let k = key();
        let sealed = k.seal(b"cardToken-73a05d99").unwrap();
        assert!(SealingKey::is_sealed(&sealed));
        assert_eq!(k.open(&sealed).unwrap(), b"cardToken-73a05d99");
    }

    #[test]
    fn sealed_output_does_not_contain_the_plaintext() {
        let sealed = key().seal(b"73a05d99-01fa-c218").unwrap();
        assert!(!sealed.contains("73a05d99"));
    }

    #[test]
    fn same_plaintext_seals_differently_each_time() {
        // Otherwise the database reveals which rows hold the same token.
        let k = key();
        assert_ne!(k.seal(b"same").unwrap(), k.seal(b"same").unwrap());
    }

    #[test]
    fn a_different_key_cannot_open_it() {
        let sealed = key().seal(b"secret").unwrap();
        assert_eq!(key().open(&sealed), Err(SealError::Undecryptable));
    }

    #[test]
    fn tampering_is_detected() {
        // The whole reason for GCM rather than raw AES: a flipped bit in
        // the ciphertext must fail, not decrypt to something else.
        let k = key();
        let sealed = k.seal(b"secret").unwrap();
        let mut envelope = base64_decode(sealed.strip_prefix(PREFIX).unwrap()).unwrap();
        let last = envelope.len() - 1;
        envelope[last] ^= 0xff;
        let tampered = format!("{PREFIX}{}", base64_encode(envelope));
        assert_eq!(k.open(&tampered), Err(SealError::Undecryptable));
    }

    #[test]
    fn a_truncated_envelope_is_rejected() {
        let tampered = format!("{PREFIX}{}", base64_encode([0u8; NONCE_LEN]));
        assert_eq!(key().open(&tampered), Err(SealError::Undecryptable));
    }

    #[test]
    fn unsealed_input_is_an_error_not_a_passthrough() {
        // Returning the input unchanged would let a migration bug quietly
        // ship plaintext tokens.
        assert_eq!(key().open("73a05d99-plaintext"), Err(SealError::NotSealed));
        assert!(!SealingKey::is_sealed("73a05d99-plaintext"));
    }

    #[test]
    fn rejects_malformed_keys() {
        // `SealingKey` deliberately has no `PartialEq` (nothing should be
        // comparing key material), so these match on the error instead.
        assert!(matches!(
            SealingKey::from_hex("zz"),
            Err(SealError::InvalidKeyEncoding)
        ));
        assert!(matches!(
            SealingKey::from_hex("abcd"),
            Err(SealError::InvalidKeyLength)
        ));
        assert!(matches!(
            SealingKey::from_bytes(&[0u8; 16]),
            Err(SealError::InvalidKeyLength)
        ));
    }

    #[test]
    fn generated_keys_are_the_right_size_and_not_constant() {
        let a = SealingKey::generate_key_hex();
        let b = SealingKey::generate_key_hex();
        assert_eq!(a.len(), KEY_LEN * 2);
        assert_ne!(a, b);
    }

    #[test]
    fn seals_empty_input() {
        let k = key();
        let sealed = k.seal(b"").unwrap();
        assert_eq!(k.open(&sealed).unwrap(), b"");
    }
}