use std::fmt;
use std::path::Path;
use std::sync::Arc;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result, SessionContext};
pub trait SpoolEncryptionKeyProvider: Send + Sync + fmt::Debug {
fn resolve_key(&self, session: &SessionContext) -> Result<Arc<[u8; 32]>>;
fn label(&self) -> &'static str {
"custom"
}
}
pub struct StaticSpoolKey {
key: Arc<[u8; 32]>,
label: &'static str,
}
impl fmt::Debug for StaticSpoolKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StaticSpoolKey")
.field("label", &self.label)
.finish_non_exhaustive()
}
}
impl StaticSpoolKey {
pub fn from_bytes(key: [u8; 32]) -> Result<Self> {
reject_unusable_key(&key)?;
Ok(Self {
key: Arc::new(key),
label: "static",
})
}
pub fn from_hex(hex: &str) -> Result<Self> {
Self::from_bytes(*parse_spool_key_hex(hex)?)
}
pub fn from_env(var: &str) -> Result<Self> {
let raw = std::env::var(var).map_err(|_| {
AsxError::new(
ErrorCode::PolicyViolation,
format!("spool encryption key environment variable {var} is not set"),
ErrorContext::new("as2_spool_key_from_env"),
)
})?;
Self::from_hex(&raw)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|err| {
AsxError::new(
ErrorCode::PolicyViolation,
format!(
"failed to read spool encryption key from {}: {err}",
path.display()
),
ErrorContext::new("as2_spool_key_from_file"),
)
})?;
Self::from_hex(&raw)
}
#[must_use]
pub fn with_label(mut self, label: &'static str) -> Self {
self.label = label;
self
}
}
impl SpoolEncryptionKeyProvider for StaticSpoolKey {
fn resolve_key(&self, _session: &SessionContext) -> Result<Arc<[u8; 32]>> {
Ok(Arc::clone(&self.key))
}
fn label(&self) -> &'static str {
self.label
}
}
pub(crate) fn reject_unusable_key(key: &[u8; 32]) -> Result<()> {
if key.iter().all(|b| *b == 0) {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"spool encryption key is all zeros; this is an unset or truncated \
secret, not a key",
ErrorContext::new("as2_spool_key_validate"),
));
}
Ok(())
}
pub(crate) fn parse_spool_key_hex(hex_key: &str) -> Result<Arc<[u8; 32]>> {
let hex_key = hex_key.trim();
if hex_key.len() != 64 {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!(
"spool encryption key must contain exactly 64 hex characters \
(got {})",
hex_key.len()
),
ErrorContext::new("as2_spool_encryption_key_parse"),
));
}
let mut out = [0u8; 32];
for (idx, chunk) in hex_key.as_bytes().as_chunks::<2>().0.iter().enumerate() {
let hi = (chunk[0] as char).to_digit(16);
let lo = (chunk[1] as char).to_digit(16);
let (Some(hi), Some(lo)) = (hi, lo) else {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"spool encryption key contains non-hex characters",
ErrorContext::new("as2_spool_encryption_key_parse"),
));
};
out[idx] = ((hi << 4) | lo) as u8;
}
Ok(Arc::new(out))
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn session() -> SessionContext {
SessionContext::new("s-spool", "p1", "strict").expect("session")
}
#[test]
fn hex_key_round_trips() {
let provider = StaticSpoolKey::from_hex(VALID_HEX).expect("parse");
let key = provider.resolve_key(&session()).expect("resolve");
assert_eq!(key[0], 0x01);
assert_eq!(key[31], 0xef);
assert_eq!(provider.label(), "static");
}
#[test]
fn hex_key_rejects_wrong_length_and_non_hex() {
assert_eq!(
StaticSpoolKey::from_hex("abcd").expect_err("short").code,
ErrorCode::InvalidInput
);
let non_hex = "z".repeat(64);
assert_eq!(
StaticSpoolKey::from_hex(&non_hex)
.expect_err("non-hex")
.code,
ErrorCode::InvalidInput
);
}
#[test]
fn all_zero_key_is_rejected() {
let err = StaticSpoolKey::from_bytes([0u8; 32]).expect_err("all-zero key");
assert_eq!(err.code, ErrorCode::PolicyViolation);
let zero_hex = "0".repeat(64);
assert_eq!(
StaticSpoolKey::from_hex(&zero_hex)
.expect_err("all-zero hex")
.code,
ErrorCode::PolicyViolation
);
}
#[test]
fn missing_env_var_names_itself() {
let err = StaticSpoolKey::from_env("ASX_TEST_DEFINITELY_UNSET_KEY").expect_err("unset");
assert_eq!(err.code, ErrorCode::PolicyViolation);
assert!(err.message.contains("ASX_TEST_DEFINITELY_UNSET_KEY"));
}
#[test]
fn file_key_tolerates_trailing_newline() {
let dir = std::env::temp_dir().join("asx-spool-key-test");
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("key.hex");
std::fs::write(&path, format!("{VALID_HEX}\n")).expect("write");
let provider = StaticSpoolKey::from_file(&path).expect("read");
assert_eq!(provider.resolve_key(&session()).expect("resolve")[0], 0x01);
std::fs::remove_file(&path).ok();
}
#[test]
fn label_is_overridable_and_debug_hides_the_key() {
let provider = StaticSpoolKey::from_hex(VALID_HEX)
.expect("parse")
.with_label("aws-kms");
assert_eq!(provider.label(), "aws-kms");
let rendered = format!("{provider:?}");
assert!(rendered.contains("aws-kms"));
assert!(
!rendered.contains("0123456789abcdef"),
"Debug must never print key material: {rendered}"
);
}
}