1use std::fs;
28use std::path::Path;
29
30use crate::crypto::base64::base64_decode;
31use crate::entropy::EntropyError;
32
33pub const ENTROPY_KEY_LEN: usize = 16;
36
37pub const ENTROPY_IV_LEN: usize = 16;
40
41#[derive(Clone, Copy, Eq, PartialEq)]
51pub struct EntropyKey([u8; ENTROPY_KEY_LEN]);
52
53impl EntropyKey {
54 #[must_use]
56 pub fn from_bytes(bytes: [u8; ENTROPY_KEY_LEN]) -> Self {
57 Self(bytes)
58 }
59
60 #[must_use]
62 pub fn as_bytes(&self) -> &[u8; ENTROPY_KEY_LEN] {
63 &self.0
64 }
65}
66
67impl std::fmt::Debug for EntropyKey {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("EntropyKey")
70 .field("len", &ENTROPY_KEY_LEN)
71 .finish()
72 }
73}
74
75#[derive(Clone, Copy, Eq, PartialEq)]
85pub struct EntropyIv([u8; ENTROPY_IV_LEN]);
86
87impl EntropyIv {
88 #[must_use]
90 pub fn from_bytes(bytes: [u8; ENTROPY_IV_LEN]) -> Self {
91 Self(bytes)
92 }
93
94 #[must_use]
96 pub fn as_bytes(&self) -> &[u8; ENTROPY_IV_LEN] {
97 &self.0
98 }
99}
100
101impl std::fmt::Debug for EntropyIv {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.debug_struct("EntropyIv")
104 .field("len", &ENTROPY_IV_LEN)
105 .finish()
106 }
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct EntropyMaterial {
124 key: EntropyKey,
125 iv: EntropyIv,
126}
127
128impl EntropyMaterial {
129 #[must_use]
131 pub fn new(key: EntropyKey, iv: EntropyIv) -> Self {
132 Self { key, iv }
133 }
134
135 #[must_use]
137 pub fn key(&self) -> &EntropyKey {
138 &self.key
139 }
140
141 #[must_use]
143 pub fn iv(&self) -> &EntropyIv {
144 &self.iv
145 }
146}
147
148fn parse_secret_bytes(text: &str) -> Result<Vec<u8>, EntropyError> {
152 if text.contains("-----BEGIN") {
153 return decode_pem_block(text);
154 }
155 let trimmed = text.trim_end_matches(['\r', '\n', ' ', '\t']);
156 Ok(trimmed.as_bytes().to_vec())
157}
158
159fn decode_pem_block(text: &str) -> Result<Vec<u8>, EntropyError> {
165 let mut lines = text.lines();
166 while let Some(line) = lines.next() {
167 if line.trim_start().starts_with("-----BEGIN") {
168 let mut body = String::new();
169 let mut saw_end = false;
170 for inner in lines.by_ref() {
171 let trimmed = inner.trim();
172 if trimmed.starts_with("-----END") {
173 saw_end = true;
174 break;
175 }
176 body.push_str(trimmed);
177 }
178 if !saw_end {
179 return Err(EntropyError::KeyMaterial(
180 "PEM block missing END marker".to_string(),
181 ));
182 }
183 return base64_decode(&body)
184 .map_err(|e| EntropyError::KeyMaterial(format!("PEM base64 decode: {e}")));
185 }
186 }
187 Err(EntropyError::KeyMaterial(
188 "PEM block missing BEGIN marker".to_string(),
189 ))
190}
191
192pub fn load_key_file(path: &Path) -> Result<EntropyKey, EntropyError> {
209 let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read key file", &e))?;
210 let bytes = parse_secret_bytes(&raw)?;
211 if bytes.len() < ENTROPY_KEY_LEN {
212 return Err(EntropyError::KeyMaterial(format!(
213 "expected at least {ENTROPY_KEY_LEN} bytes of key material in {}, got {}",
214 path.display(),
215 bytes.len()
216 )));
217 }
218 let mut out = [0u8; ENTROPY_KEY_LEN];
219 out.copy_from_slice(&bytes[..ENTROPY_KEY_LEN]);
220 Ok(EntropyKey(out))
221}
222
223pub fn load_iv_file(path: &Path) -> Result<EntropyIv, EntropyError> {
240 let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read iv file", &e))?;
241 let bytes = parse_secret_bytes(&raw)?;
242 if bytes.len() < ENTROPY_IV_LEN {
243 return Err(EntropyError::KeyMaterial(format!(
244 "expected at least {ENTROPY_IV_LEN} bytes of IV material in {}, got {}",
245 path.display(),
246 bytes.len()
247 )));
248 }
249 let mut out = [0u8; ENTROPY_IV_LEN];
250 out.copy_from_slice(&bytes[..ENTROPY_IV_LEN]);
251 Ok(EntropyIv(out))
252}
253
254pub fn load_material(key_file: &Path, iv_file: &Path) -> Result<EntropyMaterial, EntropyError> {
272 let key = load_key_file(key_file)?;
273 let iv = load_iv_file(iv_file)?;
274 Ok(EntropyMaterial::new(key, iv))
275}
276
277fn io_err(path: &Path, what: &str, e: &std::io::Error) -> EntropyError {
278 EntropyError::Io(std::io::Error::new(
279 e.kind(),
280 format!("{what} {}: {e}", path.display()),
281 ))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::io::Write;
288 use tempfile::NamedTempFile;
289
290 fn write_temp(contents: &[u8]) -> NamedTempFile {
291 let mut f = NamedTempFile::new().unwrap();
292 f.write_all(contents).unwrap();
293 f.flush().unwrap();
294 f
295 }
296
297 #[test]
298 fn loads_raw_16_byte_key() {
299 let f = write_temp(b"0123456789012345\n");
300 let key = load_key_file(f.path()).unwrap();
301 assert_eq!(key.as_bytes(), b"0123456789012345");
302 }
303
304 #[test]
305 fn loads_raw_16_byte_iv() {
306 let f = write_temp(b"0123456789012345\n");
307 let iv = load_iv_file(f.path()).unwrap();
308 assert_eq!(iv.as_bytes(), b"0123456789012345");
309 }
310
311 #[test]
312 fn rejects_short_key() {
313 let f = write_temp(b"short\n");
314 let err = load_key_file(f.path()).unwrap_err();
315 assert!(matches!(err, EntropyError::KeyMaterial(_)));
316 }
317
318 #[test]
319 fn rejects_short_iv() {
320 let f = write_temp(b"short\n");
321 let err = load_iv_file(f.path()).unwrap_err();
322 assert!(matches!(err, EntropyError::KeyMaterial(_)));
323 }
324
325 #[test]
326 fn truncates_oversized_key_to_16_bytes() {
327 let f = write_temp(b"01234567890123456\n");
328 let key = load_key_file(f.path()).unwrap();
329 assert_eq!(key.as_bytes(), b"0123456789012345");
330 }
331
332 #[test]
333 fn truncates_oversized_iv_to_16_bytes() {
334 let f = write_temp(b"01234567890123456\n");
335 let iv = load_iv_file(f.path()).unwrap();
336 assert_eq!(iv.as_bytes(), b"0123456789012345");
337 }
338
339 #[test]
340 fn loads_pem_armored_16_bytes() {
341 let body: [u8; 16] = [0x42; 16];
343 let armored = format!(
344 "-----BEGIN ENTROPY KEY-----\n{}\n-----END ENTROPY KEY-----\n",
345 crate::crypto::base64::base64_encode(&body)
346 );
347 let f = write_temp(armored.as_bytes());
348 let key = load_key_file(f.path()).unwrap();
349 assert_eq!(key.as_bytes(), &body);
350 }
351
352 #[test]
353 fn missing_file_is_io_error() {
354 let path = Path::new("/nonexistent/dynomite/no-such-key");
355 let err = load_key_file(path).unwrap_err();
356 assert!(matches!(err, EntropyError::Io(_)));
357 }
358
359 #[test]
360 fn loads_bundled_recon_fixtures() {
361 let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
363 let key_path = crate_root.join("tests/fixtures/recon/recon_key.pem");
364 let iv_path = crate_root.join("tests/fixtures/recon/recon_iv.pem");
365 let key = load_key_file(&key_path).unwrap();
366 let iv = load_iv_file(&iv_path).unwrap();
367 assert_eq!(key.as_bytes(), b"0123456789012345");
371 assert_eq!(iv.as_bytes(), b"0123456789012345");
372 }
373}