1use std::fs;
33use std::path::Path;
34
35use crate::crypto::base64::base64_decode;
36use crate::entropy::EntropyError;
37
38pub const ENTROPY_KEY_LEN: usize = 16;
41
42pub const ENTROPY_IV_LEN: usize = 16;
45
46#[derive(Clone, Copy, Eq, PartialEq)]
56pub struct EntropyKey([u8; ENTROPY_KEY_LEN]);
57
58impl EntropyKey {
59 #[must_use]
61 pub fn from_bytes(bytes: [u8; ENTROPY_KEY_LEN]) -> Self {
62 Self(bytes)
63 }
64
65 #[must_use]
67 pub fn as_bytes(&self) -> &[u8; ENTROPY_KEY_LEN] {
68 &self.0
69 }
70}
71
72impl std::fmt::Debug for EntropyKey {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("EntropyKey")
75 .field("len", &ENTROPY_KEY_LEN)
76 .finish()
77 }
78}
79
80#[derive(Clone, Copy, Eq, PartialEq)]
90pub struct EntropyIv([u8; ENTROPY_IV_LEN]);
91
92impl EntropyIv {
93 #[must_use]
95 pub fn from_bytes(bytes: [u8; ENTROPY_IV_LEN]) -> Self {
96 Self(bytes)
97 }
98
99 #[must_use]
101 pub fn as_bytes(&self) -> &[u8; ENTROPY_IV_LEN] {
102 &self.0
103 }
104}
105
106impl std::fmt::Debug for EntropyIv {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("EntropyIv")
109 .field("len", &ENTROPY_IV_LEN)
110 .finish()
111 }
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct EntropyMaterial {
129 key: EntropyKey,
130 iv: EntropyIv,
131}
132
133impl EntropyMaterial {
134 #[must_use]
136 pub fn new(key: EntropyKey, iv: EntropyIv) -> Self {
137 Self { key, iv }
138 }
139
140 #[must_use]
142 pub fn key(&self) -> &EntropyKey {
143 &self.key
144 }
145
146 #[must_use]
148 pub fn iv(&self) -> &EntropyIv {
149 &self.iv
150 }
151}
152
153fn parse_secret_bytes(text: &str) -> Result<Vec<u8>, EntropyError> {
157 if text.contains("-----BEGIN") {
158 return decode_pem_block(text);
159 }
160 let trimmed = text.trim_end_matches(['\r', '\n', ' ', '\t']);
161 Ok(trimmed.as_bytes().to_vec())
162}
163
164fn decode_pem_block(text: &str) -> Result<Vec<u8>, EntropyError> {
170 let mut lines = text.lines();
171 while let Some(line) = lines.next() {
172 if line.trim_start().starts_with("-----BEGIN") {
173 let mut body = String::new();
174 let mut saw_end = false;
175 for inner in lines.by_ref() {
176 let trimmed = inner.trim();
177 if trimmed.starts_with("-----END") {
178 saw_end = true;
179 break;
180 }
181 body.push_str(trimmed);
182 }
183 if !saw_end {
184 return Err(EntropyError::KeyMaterial(
185 "PEM block missing END marker".to_string(),
186 ));
187 }
188 return base64_decode(&body)
189 .map_err(|e| EntropyError::KeyMaterial(format!("PEM base64 decode: {e}")));
190 }
191 }
192 Err(EntropyError::KeyMaterial(
193 "PEM block missing BEGIN marker".to_string(),
194 ))
195}
196
197pub fn load_key_file(path: &Path) -> Result<EntropyKey, EntropyError> {
214 let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read key file", &e))?;
215 let bytes = parse_secret_bytes(&raw)?;
216 if bytes.len() < ENTROPY_KEY_LEN {
217 return Err(EntropyError::KeyMaterial(format!(
218 "expected at least {ENTROPY_KEY_LEN} bytes of key material in {}, got {}",
219 path.display(),
220 bytes.len()
221 )));
222 }
223 let mut out = [0u8; ENTROPY_KEY_LEN];
224 out.copy_from_slice(&bytes[..ENTROPY_KEY_LEN]);
225 Ok(EntropyKey(out))
226}
227
228pub fn load_iv_file(path: &Path) -> Result<EntropyIv, EntropyError> {
245 let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read iv file", &e))?;
246 let bytes = parse_secret_bytes(&raw)?;
247 if bytes.len() < ENTROPY_IV_LEN {
248 return Err(EntropyError::KeyMaterial(format!(
249 "expected at least {ENTROPY_IV_LEN} bytes of IV material in {}, got {}",
250 path.display(),
251 bytes.len()
252 )));
253 }
254 let mut out = [0u8; ENTROPY_IV_LEN];
255 out.copy_from_slice(&bytes[..ENTROPY_IV_LEN]);
256 Ok(EntropyIv(out))
257}
258
259pub fn load_material(key_file: &Path, iv_file: &Path) -> Result<EntropyMaterial, EntropyError> {
277 let key = load_key_file(key_file)?;
278 let iv = load_iv_file(iv_file)?;
279 Ok(EntropyMaterial::new(key, iv))
280}
281
282fn io_err(path: &Path, what: &str, e: &std::io::Error) -> EntropyError {
283 EntropyError::Io(std::io::Error::new(
284 e.kind(),
285 format!("{what} {}: {e}", path.display()),
286 ))
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::io::Write;
293 use tempfile::NamedTempFile;
294
295 fn write_temp(contents: &[u8]) -> NamedTempFile {
296 let mut f = NamedTempFile::new().unwrap();
297 f.write_all(contents).unwrap();
298 f.flush().unwrap();
299 f
300 }
301
302 #[test]
303 fn loads_raw_16_byte_key() {
304 let f = write_temp(b"0123456789012345\n");
305 let key = load_key_file(f.path()).unwrap();
306 assert_eq!(key.as_bytes(), b"0123456789012345");
307 }
308
309 #[test]
310 fn loads_raw_16_byte_iv() {
311 let f = write_temp(b"0123456789012345\n");
312 let iv = load_iv_file(f.path()).unwrap();
313 assert_eq!(iv.as_bytes(), b"0123456789012345");
314 }
315
316 #[test]
317 fn rejects_short_key() {
318 let f = write_temp(b"short\n");
319 let err = load_key_file(f.path()).unwrap_err();
320 assert!(matches!(err, EntropyError::KeyMaterial(_)));
321 }
322
323 #[test]
324 fn rejects_short_iv() {
325 let f = write_temp(b"short\n");
326 let err = load_iv_file(f.path()).unwrap_err();
327 assert!(matches!(err, EntropyError::KeyMaterial(_)));
328 }
329
330 #[test]
331 fn truncates_oversized_key_to_16_bytes() {
332 let f = write_temp(b"01234567890123456\n");
333 let key = load_key_file(f.path()).unwrap();
334 assert_eq!(key.as_bytes(), b"0123456789012345");
335 }
336
337 #[test]
338 fn truncates_oversized_iv_to_16_bytes() {
339 let f = write_temp(b"01234567890123456\n");
340 let iv = load_iv_file(f.path()).unwrap();
341 assert_eq!(iv.as_bytes(), b"0123456789012345");
342 }
343
344 #[test]
345 fn loads_pem_armored_16_bytes() {
346 let body: [u8; 16] = [0x42; 16];
348 let armored = format!(
349 "-----BEGIN ENTROPY KEY-----\n{}\n-----END ENTROPY KEY-----\n",
350 crate::crypto::base64::base64_encode(&body)
351 );
352 let f = write_temp(armored.as_bytes());
353 let key = load_key_file(f.path()).unwrap();
354 assert_eq!(key.as_bytes(), &body);
355 }
356
357 #[test]
358 fn missing_file_is_io_error() {
359 let path = Path::new("/nonexistent/dynomite/no-such-key");
360 let err = load_key_file(path).unwrap_err();
361 assert!(matches!(err, EntropyError::Io(_)));
362 }
363
364 #[test]
365 fn loads_bundled_recon_fixtures() {
366 let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
368 let key_path = crate_root.join("tests/fixtures/recon/recon_key.pem");
369 let iv_path = crate_root.join("tests/fixtures/recon/recon_iv.pem");
370 let key = load_key_file(&key_path).unwrap();
371 let iv = load_iv_file(&iv_path).unwrap();
372 assert_eq!(key.as_bytes(), b"0123456789012345");
377 assert_eq!(iv.as_bytes(), b"0123456789012345");
378 }
379}