use std::collections::BTreeMap;
use base64::Engine as _;
use async_trait::async_trait;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use crate::{Dek, Kms, KmsError};
const WRAP_AAD: &[u8] = b"FZ-KMS-WORKER-SECRET-WRAP-v1";
const NONCE_LEN: usize = 24;
const VERSION_LEN: usize = 4;
const CURRENT_SECRET: &str = "HARNESS_KEK_CURRENT";
const MAX_VERSION: u32 = 1024;
pub struct WorkerSecretKms {
current: u32,
keys: BTreeMap<u32, Dek>,
key_ref: String,
}
impl std::fmt::Debug for WorkerSecretKms {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkerSecretKms")
.field("provider", &"worker-secret")
.field("key_ref", &self.key_ref)
.finish_non_exhaustive()
}
}
impl WorkerSecretKms {
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, KmsError> {
let named = lookup(CURRENT_SECRET).ok_or_else(|| {
KmsError::Invalid(format!(
"{CURRENT_SECRET} is not set: it holds the decimal number of the KEK version \
new wraps use, e.g. 1, set with `wrangler secret put {CURRENT_SECRET}`"
))
})?;
let current = parse_version(&named)?;
let mut keys = BTreeMap::new();
for version in 1..=current {
let name = secret_name(version);
let Some(text) = lookup(&name) else {
if version == current {
return Err(KmsError::Invalid(format!(
"{CURRENT_SECRET} says {current}, but {name} is not set; put the key \
with `wrangler secret put {name}`"
)));
}
continue;
};
keys.insert(version, decode_key(&name, &text)?);
}
let key_ref = format!("worker-secret:{}", secret_name(current));
Ok(Self {
current,
keys,
key_ref,
})
}
fn cipher(key: &Dek) -> Result<chacha20poly1305::XChaCha20Poly1305, KmsError> {
chacha20poly1305::XChaCha20Poly1305::new_from_slice(key.expose())
.map_err(|_| KmsError::Invalid("a KEK is not 32 bytes".to_owned()))
}
}
fn secret_name(version: u32) -> String {
format!("HARNESS_KEK_V{version}")
}
fn parse_version(text: &str) -> Result<u32, KmsError> {
let text = text.trim();
let invalid = || {
KmsError::Invalid(format!(
"{CURRENT_SECRET} is not a version number: it must be a decimal count like 1, \
set with `wrangler secret put {CURRENT_SECRET}`"
))
};
if !text.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid());
}
let version: u32 = text.parse().map_err(|_| invalid())?;
if version == 0 {
return Err(KmsError::Invalid(format!(
"{CURRENT_SECRET} is 0: versions start at 1, so put 1 with \
`wrangler secret put {CURRENT_SECRET}`"
)));
}
if version > MAX_VERSION {
return Err(KmsError::Invalid(format!(
"{CURRENT_SECRET} is {version}, over the bound of {MAX_VERSION}: check for a \
typo and put the right number with `wrangler secret put {CURRENT_SECRET}`; \
if the key ring is genuinely that deep, the bound in \
crates/kms/src/worker_secret.rs moves deliberately"
)));
}
Ok(version)
}
fn decode_key(name: &str, text: &str) -> Result<Dek, KmsError> {
let invalid = |reason: &str| {
KmsError::Invalid(format!(
"{name} {reason}: the value is 32 key bytes, standard base64, set with \
`wrangler secret put {name}`"
))
};
let bytes = base64::engine::general_purpose::STANDARD
.decode(text.trim().as_bytes())
.map_err(|_| invalid("is not standard base64"))?;
Dek::from_bytes(bytes).map_err(|_| invalid("does not decode to exactly 32 bytes"))
}
fn aad(version: u32) -> Vec<u8> {
let mut aad = Vec::with_capacity(WRAP_AAD.len() + VERSION_LEN);
aad.extend_from_slice(WRAP_AAD);
aad.extend_from_slice(&version.to_be_bytes());
aad
}
#[async_trait]
impl Kms for WorkerSecretKms {
fn provider(&self) -> &'static str {
"worker-secret"
}
fn key_ref(&self) -> &str {
&self.key_ref
}
async fn wrap(&self, dek: &Dek) -> Result<Vec<u8>, KmsError> {
let key = self.keys.get(&self.current).ok_or_else(|| {
KmsError::Unavailable(format!(
"{} is {}, which this deployment no longer holds; restore the secret",
CURRENT_SECRET, self.current
))
})?;
let cipher = Self::cipher(key)?;
let mut nonce = [0_u8; NONCE_LEN];
getrandom::fill(&mut nonce)
.map_err(|err| KmsError::Unavailable(format!("the OS random source failed: {err}")))?;
let sealed = cipher
.encrypt(
(&nonce).into(),
Payload {
msg: dek.expose(),
aad: &aad(self.current),
},
)
.map_err(|_| KmsError::Invalid("the data key could not be wrapped".to_owned()))?;
let mut out = Vec::with_capacity(VERSION_LEN + NONCE_LEN + sealed.len());
out.extend_from_slice(&self.current.to_be_bytes());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
async fn unwrap(&self, wrapped: &[u8]) -> Result<Dek, KmsError> {
if wrapped.len() <= VERSION_LEN + NONCE_LEN {
return Err(KmsError::Tampered(
"the wrapped key is too short to contain a version, a nonce and a tag".to_owned(),
));
}
let (header, rest) = wrapped.split_at(VERSION_LEN);
let version = u32::from_be_bytes(header.try_into().expect("split at VERSION_LEN"));
let (nonce, sealed) = rest.split_at(NONCE_LEN);
let nonce: &[u8; NONCE_LEN] = nonce.try_into().expect("split at NONCE_LEN");
let Some(key) = self.keys.get(&version) else {
return Err(KmsError::Tampered(format!(
"wrapped under {}, which this deployment does not hold ({} is {}); restore \
the secret or re-wrap",
secret_name(version),
CURRENT_SECRET,
self.current
)));
};
let opened = Self::cipher(key)?
.decrypt(
nonce.into(),
Payload {
msg: sealed,
aad: &aad(version),
},
)
.map_err(|_| {
KmsError::Tampered(format!(
"the wrapped key did not authenticate under {}",
secret_name(version)
))
})?;
Dek::from_bytes(opened)
}
}