use async_trait::async_trait;
use serde::Deserialize;
use crate::core::{Secret, Timestamp};
use super::{DataKey, KeyError, KeyRing, WrappedKey};
#[derive(Debug, Clone)]
pub struct VaultTransit {
http: reqwest::Client,
address: String,
mount: String,
token: Secret,
}
#[derive(Deserialize)]
struct DataKeyReply {
data: DataKeyData,
}
#[derive(Deserialize)]
struct DataKeyData {
plaintext: String,
ciphertext: String,
}
#[derive(Deserialize)]
struct PlaintextReply {
data: PlaintextData,
}
#[derive(Deserialize)]
struct PlaintextData {
plaintext: String,
}
impl VaultTransit {
pub fn new(
address: impl Into<String>,
mount: impl Into<String>,
token: impl Into<String>,
) -> Result<Self, KeyError> {
let http = reqwest::Client::builder()
.timeout(Self::TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| KeyError::Unavailable(format!("could not build an HTTP client: {e}")))?;
Ok(Self {
http,
address: address.into().trim_end_matches('/').to_owned(),
mount: mount.into().trim_matches('/').to_owned(),
token: Secret::new(token),
})
}
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn url(&self, tail: &str) -> String {
format!("{}/v1/{}/{tail}", self.address, self.mount)
}
async fn call(
&self,
method: reqwest::Method,
url: &str,
body: Option<serde_json::Value>,
scope: &str,
) -> Result<String, KeyError> {
let mut req = self
.http
.request(method, url)
.header("X-Vault-Token", self.token.expose());
if let Some(b) = body {
req = req.json(&b);
}
let response = req
.send()
.await
.map_err(|e| KeyError::Unavailable(format!("{url}: {e}")))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|e| KeyError::Unavailable(format!("{url}: reading the reply: {e}")))?;
let reason = || first_error(&text).unwrap_or_else(|| text.trim().to_owned());
match status {
200 | 204 => Ok(text),
400 | 404 if is_missing_key(&reason()) => Err(KeyError::Destroyed {
scope: scope.to_owned(),
at: Timestamp::UNIX_EPOCH,
reason: "the wrapping key no longer exists in Vault".to_owned(),
}),
400 | 403 | 404 => Err(KeyError::Refused(format!("{url}: {}", reason()))),
412 | 429 | 500..=599 => Err(KeyError::Unavailable(format!(
"{url}: status {status}: {}",
text.trim()
))),
other => Err(KeyError::Unavailable(format!(
"{url}: unexpected status {other}: {}",
text.trim()
))),
}
}
}
fn is_missing_key(reason: &str) -> bool {
let r = reason.to_ascii_lowercase();
r.contains("encryption key not found") || r.contains("could not delete key; not found")
}
fn as_retired(e: KeyError, scope: &str, key_id: &str) -> KeyError {
let retired = match &e {
KeyError::Refused(reason) => {
let r = reason.to_ascii_lowercase();
r.contains("ciphertext or signature version is disallowed by policy")
|| r.contains("ciphertext version is disallowed by policy")
}
_ => false,
};
if retired {
KeyError::Retired {
scope: scope.to_owned(),
key_id: key_id.to_owned(),
}
} else {
e
}
}
fn first_error(body: &str) -> Option<String> {
#[derive(Deserialize)]
struct Errors {
errors: Vec<String>,
}
serde_json::from_str::<Errors>(body)
.ok()?
.errors
.into_iter()
.next()
}
fn unb64(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
})
};
let raw: Vec<u8> = s.bytes().filter(|b| *b != b'=').collect();
let mut out = Vec::with_capacity(raw.len() * 3 / 4);
for chunk in raw.chunks(4) {
let mut n = 0u32;
for (i, c) in chunk.iter().enumerate() {
n |= val(*c)? << (18 - 6 * i);
}
for i in 0..chunk.len() * 6 / 8 {
out.push(((n >> (16 - 8 * i)) & 0xff) as u8);
}
}
Some(out)
}
fn to_key(b64: &str, what: &str) -> Result<DataKey, KeyError> {
let raw = unb64(b64).ok_or_else(|| KeyError::Refused(format!("{what} is not valid base64")))?;
let bytes: [u8; 32] = raw.try_into().map_err(|_| {
KeyError::Refused(format!(
"{what} is not 32 bytes — ask transit for a 256-bit key"
))
})?;
Ok(DataKey::new(bytes))
}
#[async_trait]
impl KeyRing for VaultTransit {
async fn data_key(&self, scope: &str) -> Result<(DataKey, WrappedKey), KeyError> {
let url = self.url(&format!("datakey/plaintext/{scope}"));
let body = self
.call(
reqwest::Method::POST,
&url,
Some(serde_json::json!({})),
scope,
)
.await?;
let reply: DataKeyReply = serde_json::from_str(&body)
.map_err(|e| KeyError::Refused(format!("{url}: unreadable reply: {e}")))?;
Ok((
to_key(&reply.data.plaintext, "the data key transit returned")?,
WrappedKey {
scope: scope.to_owned(),
wrapped_by: reply
.data
.ciphertext
.split(':')
.take(2)
.collect::<Vec<_>>()
.join(":"),
sealed: reply.data.ciphertext.into_bytes(),
},
))
}
async fn open(&self, wrapped: &WrappedKey) -> Result<DataKey, KeyError> {
let ciphertext = String::from_utf8(wrapped.sealed.clone())
.map_err(|_| KeyError::Refused("a transit ciphertext must be text".to_owned()))?;
let url = self.url(&format!("decrypt/{}", wrapped.scope));
let body = self
.call(
reqwest::Method::POST,
&url,
Some(serde_json::json!({ "ciphertext": ciphertext })),
&wrapped.scope,
)
.await
.map_err(|e| as_retired(e, &wrapped.scope, &wrapped.wrapped_by))?;
let reply: PlaintextReply = serde_json::from_str(&body)
.map_err(|e| KeyError::Refused(format!("{url}: unreadable reply: {e}")))?;
to_key(&reply.data.plaintext, "the data key transit returned")
}
async fn destroy(&self, scope: &str, _at: Timestamp, _reason: &str) -> Result<(), KeyError> {
let url = self.url(&format!("keys/{scope}"));
match self.call(reqwest::Method::DELETE, &url, None, scope).await {
Ok(_) | Err(KeyError::Destroyed { .. }) => Ok(()),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_retired_ciphertext_version_is_told_apart_from_a_refusal() {
let refused = |m: &str| KeyError::Refused(m.to_owned());
match as_retired(
refused("ciphertext or signature version is disallowed by policy (too old)"),
"acme/case-1",
"vault:v1",
) {
KeyError::Retired { scope, key_id } => {
assert_eq!(scope, "acme/case-1");
assert_eq!(
key_id, "vault:v1",
"the error must name the version the floor has to readmit"
);
}
other => panic!(
"a retired version stayed a bare refusal ({other:?}), so a one-setting \
configuration change reaches an operator as unrecoverable data loss"
),
}
assert!(
matches!(
as_retired(refused("permission denied"), "acme/case-1", "vault:v1"),
KeyError::Refused(_)
),
"a permission failure was read as a retired key version, which sends an \
operator to lower a floor that was never the problem and leaves the real \
refusal uninvestigated"
);
assert!(
matches!(
as_retired(
KeyError::Unavailable("vault is sealed".to_owned()),
"acme/case-1",
"vault:v1"
),
KeyError::Unavailable(_)
),
"an outage was reclassified as a retired version, turning something that \
comes back on its own into a configuration investigation"
);
}
#[test]
fn the_base64_decoder_reaches_the_bytes_that_differ() {
assert_eq!(unb64("+/8=").expect("decodes"), vec![0xFB, 0xFF]);
assert!(
unb64("-_8=").is_none(),
"the URL-safe alphabet was accepted, so a key would decode to the \
wrong bytes without anything reporting it"
);
}
#[test]
fn a_key_that_is_not_256_bits_is_refused() {
let err = to_key("AAAA", "a test key").expect_err("must refuse");
assert!(
matches!(err, KeyError::Refused(ref m) if m.contains("32 bytes")),
"wrong refusal: {err}"
);
let full = "A".repeat(43) + "=";
assert!(
to_key(&full, "a test key").is_ok(),
"a 32-byte key was refused"
);
}
#[test]
fn a_vault_error_body_is_read_rather_than_dumped() {
let body =
r#"{"errors":["1 error occurred:\n\t* deletion is not allowed for this key\n\n"]}"#;
let first = first_error(body).expect("an error");
assert!(
first.contains("deletion is not allowed"),
"the operator-facing reason was lost: {first}"
);
assert!(
first_error("not json at all").is_none(),
"a non-JSON body must fall through to the raw text rather than \
producing a confident empty reason"
);
}
#[test]
fn the_wrapping_key_id_is_the_transit_key_version() {
let ct = "vault:v3:abcdefGHIJ==";
let id: String = ct.split(':').take(2).collect::<Vec<_>>().join(":");
assert_eq!(id, "vault:v3");
}
}