use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use secrecy::ExposeSecret;
use super::base64url;
use super::key::{AppKey, ENCRYPTER_LABEL};
const VERSION: &str = "v1";
const NONCE_BYTES: usize = 24;
const TAG_BYTES: usize = 16;
const ASSOCIATED_DATA: &[u8] = b"arcature/crypt/v1";
#[non_exhaustive]
pub struct Encrypter {
cipher: XChaCha20Poly1305,
}
impl Encrypter {
#[must_use]
pub fn new(key: &AppKey) -> Self {
let subkey = key.subkey(ENCRYPTER_LABEL);
let material = Key::try_from(subkey.expose_secret())
.expect("a derived subkey is exactly the 32 bytes XChaCha20 takes");
Self {
cipher: XChaCha20Poly1305::new(&material),
}
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<String, EncryptError> {
let mut nonce = [0u8; NONCE_BYTES];
getrandom::fill(&mut nonce).map_err(|_| EncryptError::Rng)?;
let xnonce =
XNonce::try_from(&nonce[..]).expect("NONCE_BYTES is XChaCha20-Poly1305's nonce length");
let payload = Payload {
msg: plaintext,
aad: ASSOCIATED_DATA,
};
let sealed = self
.cipher
.encrypt(&xnonce, payload)
.map_err(|_| EncryptError::Oversized)?;
let mut raw = Vec::with_capacity(NONCE_BYTES + sealed.len());
raw.extend_from_slice(&nonce);
raw.extend_from_slice(&sealed);
Ok(format!("{VERSION}.{}", base64url::encode(&raw)))
}
pub fn encrypt_string(&self, plaintext: &str) -> Result<String, EncryptError> {
self.encrypt(plaintext.as_bytes())
}
pub fn decrypt(&self, token: &str) -> Result<Vec<u8>, DecryptError> {
let body = token
.strip_prefix(VERSION)
.and_then(|rest| rest.strip_prefix('.'))
.ok_or(DecryptError::UnknownVersion)?;
let raw = base64url::decode(body).ok_or(DecryptError::Malformed)?;
if raw.len() < NONCE_BYTES + TAG_BYTES {
return Err(DecryptError::Malformed);
}
let (nonce, sealed) = raw.split_at(NONCE_BYTES);
let xnonce = XNonce::try_from(nonce).map_err(|_| DecryptError::Malformed)?;
let payload = Payload {
msg: sealed,
aad: ASSOCIATED_DATA,
};
self.cipher
.decrypt(&xnonce, payload)
.map_err(|_| DecryptError::Authentication)
}
pub fn decrypt_string(&self, token: &str) -> Result<String, DecryptError> {
String::from_utf8(self.decrypt(token)?).map_err(|_| DecryptError::NotUtf8)
}
}
impl std::fmt::Debug for Encrypter {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("Encrypter(XChaCha20-Poly1305, <redacted key>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EncryptError {
Rng,
Oversized,
}
impl std::fmt::Display for EncryptError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Rng => {
"the operating system's random number generator failed; nothing was encrypted"
}
Self::Oversized => "the plaintext is too large for the cipher; nothing was encrypted",
})
}
}
impl std::error::Error for EncryptError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DecryptError {
UnknownVersion,
Malformed,
Authentication,
NotUtf8,
}
impl std::fmt::Display for DecryptError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::UnknownVersion => "the token does not carry a known version tag",
Self::Malformed => "the token is not well-formed",
Self::Authentication => {
"the token failed authentication; it was altered or was \
encrypted under a different key"
}
Self::NotUtf8 => "the token decrypted, but the plaintext is not valid UTF-8",
})
}
}
impl std::error::Error for DecryptError {}
#[cfg(test)]
mod tests {
use super::{DecryptError, Encrypter, NONCE_BYTES, TAG_BYTES, VERSION};
use crate::crypt::AppKey;
use crate::crypt::base64url;
fn encrypter(fill: u8) -> Encrypter {
Encrypter::new(&AppKey::from_bytes(&[fill; 64]).expect("64 bytes"))
}
#[test]
fn a_token_round_trips() {
let encrypter = encrypter(0x4a);
let token = encrypter.encrypt(b"the quick brown fox").expect("encrypt");
assert_eq!(
encrypter.decrypt(&token).expect("decrypt"),
b"the quick brown fox"
);
}
#[test]
fn an_empty_plaintext_round_trips() {
let encrypter = encrypter(0x4a);
let token = encrypter.encrypt(b"").expect("encrypt");
assert_eq!(
encrypter.decrypt(&token).expect("decrypt"),
Vec::<u8>::new()
);
}
#[test]
fn arbitrary_bytes_round_trip() {
let encrypter = encrypter(0x4a);
let plaintext: Vec<u8> = (0..=255).collect();
let token = encrypter.encrypt(&plaintext).expect("encrypt");
assert_eq!(encrypter.decrypt(&token).expect("decrypt"), plaintext);
}
#[test]
fn strings_round_trip_including_non_ascii() {
let encrypter = encrypter(0x4a);
let token = encrypter
.encrypt_string("caractère — 文字")
.expect("encrypt");
assert_eq!(
encrypter.decrypt_string(&token).expect("decrypt"),
"caractère — 文字"
);
}
#[test]
fn a_token_is_url_safe_and_carries_its_version() {
let token = encrypter(0x4a).encrypt(b"payload").expect("encrypt");
let body = token
.strip_prefix(VERSION)
.and_then(|rest| rest.strip_prefix('.'))
.expect("version tag");
assert!(
body.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"{token}"
);
}
#[test]
fn a_token_carries_a_nonce_and_a_tag_and_the_ciphertext() {
let token = encrypter(0x4a).encrypt(b"1234").expect("encrypt");
let body = token.strip_prefix("v1.").expect("version tag");
let raw = base64url::decode(body).expect("base64url");
assert_eq!(raw.len(), NONCE_BYTES + TAG_BYTES + 4);
}
#[test]
fn a_token_from_another_key_does_not_decrypt() {
let token = encrypter(0x01).encrypt(b"payload").expect("encrypt");
assert_eq!(
encrypter(0x02).decrypt(&token),
Err(DecryptError::Authentication)
);
}
#[test]
fn a_token_with_no_version_tag_is_refused() {
let encrypter = encrypter(0x4a);
let token = encrypter.encrypt(b"payload").expect("encrypt");
let body = token.strip_prefix("v1.").expect("version tag");
assert_eq!(encrypter.decrypt(body), Err(DecryptError::UnknownVersion));
}
#[test]
fn a_truncated_token_is_malformed_rather_than_authenticated() {
let encrypter = encrypter(0x4a);
let short = format!("v1.{}", base64url::encode(&[0u8; NONCE_BYTES]));
assert_eq!(encrypter.decrypt(&short), Err(DecryptError::Malformed));
}
#[test]
fn debug_never_shows_the_key() {
assert_eq!(
format!("{:?}", encrypter(0x4a)),
"Encrypter(XChaCha20-Poly1305, <redacted key>)"
);
}
}