use std::fmt;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
use ring::rand::{SecureRandom, SystemRandom};
pub const INSTANCE_KEY_TAG: &str = "zfk1";
pub const WRAPPED_KEY_TAG: &str = "zfw1";
pub const SECRET_TAG: &str = "zfc1";
pub const KEY_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretCryptoError {
Malformed {
envelope: &'static str,
reason: String,
},
WrongKey {
envelope: &'static str,
},
NoRandomness,
}
impl fmt::Display for SecretCryptoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed { envelope, reason } => {
write!(f, "not a usable '{envelope}' value ({reason})")
}
Self::WrongKey { envelope } => write!(
f,
"a '{envelope}' value did not authenticate under this key — the key is the wrong \
one, or the stored bytes were altered"
),
Self::NoRandomness => write!(f, "the system random source is unavailable"),
}
}
}
impl std::error::Error for SecretCryptoError {}
#[derive(Clone, PartialEq, Eq)]
pub struct SecretKey([u8; KEY_LEN]);
impl fmt::Debug for SecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretKey(…)")
}
}
impl Drop for SecretKey {
fn drop(&mut self) {
for byte in &mut self.0 {
unsafe { std::ptr::write_volatile(byte, 0) };
}
}
}
impl SecretKey {
pub fn generate() -> Result<Self, SecretCryptoError> {
let mut bytes = [0u8; KEY_LEN];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| SecretCryptoError::NoRandomness)?;
Ok(Self(bytes))
}
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self(bytes)
}
fn aead(&self) -> LessSafeKey {
LessSafeKey::new(
UnboundKey::new(&CHACHA20_POLY1305, &self.0).expect("32-byte chacha20-poly1305 key"),
)
}
fn seal(&self, aad: &str, plaintext: &[u8]) -> Result<String, SecretCryptoError> {
let mut nonce_bytes = [0u8; NONCE_LEN];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| SecretCryptoError::NoRandomness)?;
let mut buffer = plaintext.to_vec();
self.aead()
.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce_bytes),
Aad::from(aad.as_bytes()),
&mut buffer,
)
.map_err(|_| SecretCryptoError::NoRandomness)?;
let mut out = Vec::with_capacity(NONCE_LEN + buffer.len());
out.extend_from_slice(&nonce_bytes);
out.append(&mut buffer);
Ok(URL_SAFE_NO_PAD.encode(out))
}
fn open(
&self,
envelope: &'static str,
aad: &str,
body: &str,
) -> Result<Vec<u8>, SecretCryptoError> {
let raw =
URL_SAFE_NO_PAD
.decode(body.trim())
.map_err(|err| SecretCryptoError::Malformed {
envelope,
reason: format!("body is not base64url: {err}"),
})?;
if raw.len() <= NONCE_LEN {
return Err(SecretCryptoError::Malformed {
envelope,
reason: format!(
"body is {} bytes, shorter than a nonce and a tag",
raw.len()
),
});
}
let (nonce_bytes, rest) = raw.split_at(NONCE_LEN);
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(nonce_bytes);
let mut buffer = rest.to_vec();
let opened = self
.aead()
.open_in_place(
Nonce::assume_unique_for_key(nonce),
Aad::from(aad.as_bytes()),
&mut buffer,
)
.map_err(|_| SecretCryptoError::WrongKey { envelope })?;
Ok(opened.to_vec())
}
pub fn render(&self) -> String {
format!("{INSTANCE_KEY_TAG}:{}", URL_SAFE_NO_PAD.encode(self.0))
}
pub fn parse(text: &str) -> Result<Self, SecretCryptoError> {
let text = text.trim();
let Some(body) = text.strip_prefix(&format!("{INSTANCE_KEY_TAG}:")) else {
let word = text.split(':').next().unwrap_or("");
return Err(SecretCryptoError::Malformed {
envelope: INSTANCE_KEY_TAG,
reason: if word.is_empty() {
"the value is empty".to_string()
} else {
format!("format word is '{word}', not '{INSTANCE_KEY_TAG}'")
},
});
};
let raw =
URL_SAFE_NO_PAD
.decode(body.trim())
.map_err(|err| SecretCryptoError::Malformed {
envelope: INSTANCE_KEY_TAG,
reason: format!("key is not base64url: {err}"),
})?;
if raw.len() != KEY_LEN {
return Err(SecretCryptoError::Malformed {
envelope: INSTANCE_KEY_TAG,
reason: format!("key is {} bytes, not {KEY_LEN}", raw.len()),
});
}
let mut bytes = [0u8; KEY_LEN];
bytes.copy_from_slice(&raw);
Ok(Self(bytes))
}
}
fn wrap_aad(key_id: u32) -> String {
format!("zebflow/credential-keyring/{WRAPPED_KEY_TAG}/{key_id}")
}
fn secret_aad(key_id: u32) -> String {
format!("zebflow/credential/{SECRET_TAG}/{key_id}")
}
pub fn wrap_data_key(
instance: &SecretKey,
key_id: u32,
data: &SecretKey,
) -> Result<String, SecretCryptoError> {
let body = instance.seal(&wrap_aad(key_id), &data.0)?;
Ok(format!("{WRAPPED_KEY_TAG}:{body}"))
}
pub fn unwrap_data_key(
instance: &SecretKey,
key_id: u32,
wrapped: &str,
) -> Result<SecretKey, SecretCryptoError> {
let Some(body) = wrapped.trim().strip_prefix(&format!("{WRAPPED_KEY_TAG}:")) else {
let word = wrapped.trim().split(':').next().unwrap_or("");
return Err(SecretCryptoError::Malformed {
envelope: WRAPPED_KEY_TAG,
reason: format!("format word is '{word}', not '{WRAPPED_KEY_TAG}'"),
});
};
let raw = instance.open(WRAPPED_KEY_TAG, &wrap_aad(key_id), body)?;
if raw.len() != KEY_LEN {
return Err(SecretCryptoError::Malformed {
envelope: WRAPPED_KEY_TAG,
reason: format!("wrapped key is {} bytes, not {KEY_LEN}", raw.len()),
});
}
let mut bytes = [0u8; KEY_LEN];
bytes.copy_from_slice(&raw);
Ok(SecretKey::from_bytes(bytes))
}
pub fn seal_secret(
data_key: &SecretKey,
key_id: u32,
plaintext: &str,
) -> Result<String, SecretCryptoError> {
let body = data_key.seal(&secret_aad(key_id), plaintext.as_bytes())?;
Ok(format!("{SECRET_TAG}:{key_id}:{body}"))
}
pub fn open_secret(
data_key: &SecretKey,
key_id: u32,
body: &str,
) -> Result<String, SecretCryptoError> {
let raw = data_key.open(SECRET_TAG, &secret_aad(key_id), body)?;
String::from_utf8(raw).map_err(|err| SecretCryptoError::Malformed {
envelope: SECRET_TAG,
reason: format!("plaintext is not utf-8: {err}"),
})
}
pub fn parse_secret_envelope(text: &str) -> Option<(u32, &str)> {
let rest = text.strip_prefix(&format!("{SECRET_TAG}:"))?;
let (id, body) = rest.split_once(':')?;
let key_id = id.parse::<u32>().ok()?;
if body.is_empty() {
return None;
}
Some((key_id, body))
}
pub fn is_secret_envelope(text: &str) -> bool {
parse_secret_envelope(text).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_secret_round_trips_and_a_wrong_key_fails_loudly() {
let key = SecretKey::generate().expect("generate");
let sealed = seal_secret(&key, 1, r#"{"password":"hunter2"}"#).expect("seal");
assert!(sealed.starts_with("zfc1:1:"));
assert!(
!sealed.contains("hunter2"),
"the plaintext must not survive in the envelope: {sealed}"
);
let (key_id, body) = parse_secret_envelope(&sealed).expect("parse");
assert_eq!(key_id, 1);
assert_eq!(
open_secret(&key, key_id, body).expect("open"),
r#"{"password":"hunter2"}"#
);
let other = SecretKey::generate().expect("generate");
assert_eq!(
open_secret(&other, key_id, body),
Err(SecretCryptoError::WrongKey { envelope: "zfc1" })
);
assert_eq!(
open_secret(&key, 2, body),
Err(SecretCryptoError::WrongKey { envelope: "zfc1" })
);
}
#[test]
fn one_plaintext_never_seals_to_the_same_bytes_twice() {
let key = SecretKey::generate().expect("generate");
let first = seal_secret(&key, 1, "same").expect("seal");
let second = seal_secret(&key, 1, "same").expect("seal");
assert_ne!(first, second);
}
#[test]
fn an_altered_envelope_is_refused_rather_than_decrypted() {
let key = SecretKey::generate().expect("generate");
let sealed = seal_secret(&key, 1, "value").expect("seal");
let (key_id, body) = parse_secret_envelope(&sealed).expect("parse");
let mut altered: Vec<char> = body.chars().collect();
let last = altered.len() - 1;
altered[last] = if altered[last] == 'A' { 'B' } else { 'A' };
let altered: String = altered.into_iter().collect();
assert!(open_secret(&key, key_id, &altered).is_err());
}
#[test]
fn a_data_key_survives_wrapping_and_is_bound_to_its_slot() {
let instance = SecretKey::generate().expect("generate");
let data = SecretKey::generate().expect("generate");
let wrapped = wrap_data_key(&instance, 3, &data).expect("wrap");
assert!(wrapped.starts_with("zfw1:"));
assert_eq!(
unwrap_data_key(&instance, 3, &wrapped).expect("unwrap"),
data
);
assert_eq!(
unwrap_data_key(&instance, 4, &wrapped),
Err(SecretCryptoError::WrongKey { envelope: "zfw1" })
);
let other = SecretKey::generate().expect("generate");
assert_eq!(
unwrap_data_key(&other, 3, &wrapped),
Err(SecretCryptoError::WrongKey { envelope: "zfw1" })
);
}
#[test]
fn an_instance_key_round_trips_and_a_damaged_one_is_never_an_absent_one() {
let key = SecretKey::generate().expect("generate");
let rendered = key.render();
assert!(rendered.starts_with("zfk1:"));
assert_eq!(SecretKey::parse(&rendered).expect("parse"), key);
assert_eq!(
SecretKey::parse(&format!(" {rendered}\n")).expect("ws"),
key
);
for damaged in ["", "hello", "zfk2:abc", "zfk1:!!!", "zfk1:AAAA"] {
let err = SecretKey::parse(damaged).expect_err(damaged);
assert!(
matches!(err, SecretCryptoError::Malformed { .. }),
"{damaged}: {err}"
);
}
}
#[test]
fn a_legacy_plaintext_value_is_recognised_as_not_an_envelope() {
assert!(!is_secret_envelope(r#"{"password":"hunter2"}"#));
assert!(!is_secret_envelope("zfc1:"));
assert!(!is_secret_envelope("zfc1:x:body"));
assert!(!is_secret_envelope("zfc1:1:"));
assert!(is_secret_envelope("zfc1:1:body"));
}
}