use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::aead::{Aead, Generate as _, KeyInit};
use super::{DataKey, KeyError, KeyRing, WrappedKey};
pub(super) const FORMAT_VERSION: u8 = 1;
const NONCE: usize = 24;
const HEADER: usize = 1 + 4;
fn cipher(key: &DataKey) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new(key.expose().into())
}
pub(super) async fn seal(
keys: &dyn KeyRing,
scope: &str,
aad: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, KeyError> {
let (key, wrapped) = keys.data_key(scope).await?;
let nonce = chacha20poly1305::XNonce::generate();
let sealed = cipher(&key)
.encrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: plaintext,
aad,
},
)
.map_err(|e| KeyError::Refused(format!("sealing a payload failed: {e}")))?;
let wrapped_bytes = crate::core::canon::to_bytes(&wrapped)
.map_err(|e| KeyError::Refused(format!("a wrapped key would not serialise: {e}")))?;
let len = u32::try_from(wrapped_bytes.len())
.map_err(|_| KeyError::Refused("the wrapped key is implausibly large".to_owned()))?;
let mut envelope = Vec::with_capacity(HEADER + wrapped_bytes.len() + NONCE + sealed.len());
envelope.push(FORMAT_VERSION);
envelope.extend_from_slice(&len.to_be_bytes());
envelope.extend_from_slice(&wrapped_bytes);
envelope.extend_from_slice(&nonce);
envelope.extend_from_slice(&sealed);
Ok(envelope)
}
struct Parsed<'a> {
wrapped: WrappedKey,
nonce: &'a chacha20poly1305::XNonce,
sealed: &'a [u8],
}
fn parse(envelope: &[u8]) -> Result<Parsed<'_>, KeyError> {
let Some((&version, rest)) = envelope.split_first() else {
return Err(KeyError::Refused("the envelope is empty".to_owned()));
};
if version != FORMAT_VERSION {
return Err(KeyError::UnknownFormat {
version,
supported: FORMAT_VERSION,
});
}
if rest.len() < 4 {
return Err(KeyError::Refused("the envelope has no header".to_owned()));
}
let (len_bytes, rest) = rest.split_at(4);
let len = u32::from_be_bytes(len_bytes.try_into().unwrap_or([0; 4])) as usize;
let fits = len
.checked_add(NONCE)
.is_some_and(|needed| rest.len() >= needed);
if !fits {
return Err(KeyError::Refused(
"the envelope is shorter than its own header claims".to_owned(),
));
}
let (wrapped_bytes, rest) = rest.split_at(len);
let wrapped: WrappedKey =
serde_json::from_slice(wrapped_bytes).map_err(|e| KeyError::UnreadableHeader {
detail: e.to_string(),
})?;
let (nonce, sealed) = rest.split_at(NONCE);
let nonce = super::xnonce(nonce)
.ok_or_else(|| KeyError::Refused("the envelope's nonce is the wrong width".to_owned()))?;
Ok(Parsed {
wrapped,
nonce,
sealed,
})
}
pub(super) fn wrapped_scope(envelope: &[u8]) -> Result<String, KeyError> {
parse(envelope).map(|parsed| parsed.wrapped.scope)
}
pub(super) async fn open(
keys: &dyn KeyRing,
aad: &[u8],
envelope: &[u8],
) -> Result<Vec<u8>, KeyError> {
let Parsed {
wrapped,
nonce,
sealed,
} = parse(envelope)?;
let key = keys.open(&wrapped).await?;
cipher(&key)
.decrypt(nonce, chacha20poly1305::aead::Payload { msg: sealed, aad })
.map_err(|_| KeyError::Refused("the sealed payload did not authenticate".to_owned()))
}
pub(super) async fn open_or_erased(
keys: &dyn KeyRing,
aad: &[u8],
envelope: &[u8],
) -> Result<Option<Vec<u8>>, KeyError> {
match open(keys, aad, envelope).await {
Ok(plain) => Ok(Some(plain)),
Err(KeyError::Destroyed { .. }) => Ok(None),
Err(other) => Err(other),
}
}
#[cfg(all(test, feature = "testkit"))]
mod format_tests {
use super::*;
use crate::testkit::MemoryKeyRing;
const AAD: &[u8] = b"case-state:acme:matter";
async fn envelope() -> (MemoryKeyRing, Vec<u8>) {
let ring = MemoryKeyRing::new();
let bytes = seal(&ring, "acme/matter", AAD, b"the plaintext")
.await
.expect("seal");
(ring, bytes)
}
#[tokio::test]
async fn an_envelope_leads_with_the_format_version_it_claims() {
let (_ring, bytes) = envelope().await;
assert_eq!(
bytes.first().copied(),
Some(FORMAT_VERSION),
"the first byte of an envelope is the construction it was written to"
);
assert_eq!(
FORMAT_VERSION,
crate::keyring::ENVELOPE_FORMAT_VERSION,
"the public constant and the byte on the wire are one number"
);
}
#[tokio::test]
async fn a_version_this_build_does_not_read_is_not_reported_as_tampering() {
let (ring, mut bytes) = envelope().await;
bytes[0] = FORMAT_VERSION.wrapping_add(1);
let error = open(&ring, AAD, &bytes).await.expect_err("must refuse");
assert_eq!(
error,
KeyError::UnknownFormat {
version: FORMAT_VERSION.wrapping_add(1),
supported: FORMAT_VERSION,
},
"a future envelope must name the version it needs"
);
assert!(
!error.to_string().contains("authenticate"),
"a version skew reported in the vocabulary of tampering: {error}"
);
assert_eq!(
wrapped_scope(&bytes),
Err(KeyError::UnknownFormat {
version: FORMAT_VERSION.wrapping_add(1),
supported: FORMAT_VERSION,
}),
"reading the scope must refuse the same envelope `open` refuses, or a \
probe reconstructs an AAD from a header it could not parse"
);
}
#[tokio::test]
async fn a_header_member_this_build_does_not_know_is_refused() {
let ring = MemoryKeyRing::new();
let key = ring.data_key("acme/matter").await.expect("a data key");
let mut header = serde_json::to_value(&key.1).expect("the wrapped key serialises");
header["kdf"] = serde_json::json!("argon2id");
let header = crate::core::canon::to_bytes(&header).expect("serialises");
let mut bytes = vec![FORMAT_VERSION];
bytes.extend_from_slice(&u32::try_from(header.len()).expect("fits").to_be_bytes());
bytes.extend_from_slice(&header);
bytes.extend_from_slice(&[0_u8; NONCE]);
bytes.extend_from_slice(b"ciphertext");
let error = open(&ring, AAD, &bytes).await.expect_err("must refuse");
assert!(
format!("{error}").contains("kdf"),
"the refusal has to name the member nobody knows: {error}"
);
assert!(
!format!("{error}").contains("authenticate"),
"an unknown member reported in the vocabulary of tampering: {error}"
);
}
#[tokio::test]
async fn a_truncated_envelope_is_refused_rather_than_read_short() {
let (ring, bytes) = envelope().await;
for cut in [0, 1, HEADER, HEADER + 4] {
let short = &bytes[..cut.min(bytes.len())];
let error = open(&ring, AAD, short).await.expect_err("must refuse");
assert!(
matches!(error, KeyError::Refused(_)),
"a truncated envelope of {cut} bytes answered {error:?}"
);
}
}
#[tokio::test]
async fn an_envelope_opens_under_the_identity_that_sealed_it() {
let (ring, bytes) = envelope().await;
assert_eq!(
open(&ring, AAD, &bytes).await.expect("opens"),
b"the plaintext",
);
assert_eq!(
wrapped_scope(&bytes).expect("scope"),
"acme/matter",
"the scope is readable without opening anything"
);
}
}