use crate::error::AgeError; use crate::identities::{Header, Stanza}; use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; const VERSION_LINE: &[u8] = b"age-encryption.org/v1\n"; const FOOTER_PREFIX: &[u8] = b"--- "; /// Parse an age binary file's header. /// /// Returns `(Header, payload_offset)` where `payload_offset` is the byte /// index into `input` where the payload (nonce + ciphertext) begins. pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> { if !input.starts_with(VERSION_LINE) { return Err(AgeError::ParseError( "missing age version line".to_string(), )); } let mut pos = VERSION_LINE.len(); let mut recipients: Vec = Vec::new(); loop { if pos >= input.len() { return Err(AgeError::ParseError("unexpected end of header".to_string())); } // Check for footer line: "--- \n" if input[pos..].starts_with(FOOTER_PREFIX) { let line_end = input[pos..] .iter() .position(|&b| b == b'\n') .ok_or_else(|| AgeError::ParseError("footer line not terminated".to_string()))?; let mac_b64 = &input[pos + FOOTER_PREFIX.len()..pos + line_end]; let mac = STANDARD_NO_PAD .decode(mac_b64) .map_err(|e| AgeError::ParseError(format!("footer MAC base64: {e}")))?; let payload_offset = pos + line_end + 1; return Ok((Header { recipients, mac }, payload_offset)); } // Must be a stanza line: "-> [args...]\n" if !input[pos..].starts_with(b"-> ") { return Err(AgeError::ParseError(format!( "expected stanza or footer at byte {pos}" ))); } let line_end = input[pos..] .iter() .position(|&b| b == b'\n') .ok_or_else(|| AgeError::ParseError("stanza header line not terminated".to_string()))?; let header_line = std::str::from_utf8(&input[pos + 3..pos + line_end]) .map_err(|e| AgeError::ParseError(format!("stanza line UTF-8: {e}")))?; let mut parts = header_line.split(' '); let tag = parts .next() .ok_or_else(|| AgeError::ParseError("stanza missing tag".to_string()))? .to_string(); let args: Vec = parts.map(str::to_string).collect(); pos += line_end + 1; // Read stanza body: base64 lines until an empty line let mut body_b64 = String::new(); loop { if pos >= input.len() { return Err(AgeError::ParseError( "unexpected end during stanza body".to_string(), )); } let line_end = input[pos..] .iter() .position(|&b| b == b'\n') .ok_or_else(|| { AgeError::ParseError("stanza body line not terminated".to_string()) })?; let line = &input[pos..pos + line_end]; pos += line_end + 1; if line.is_empty() { break; } body_b64.push_str( std::str::from_utf8(line) .map_err(|e| AgeError::ParseError(format!("body UTF-8: {e}")))?, ); } let body = if body_b64.is_empty() { Vec::new() } else { STANDARD_NO_PAD .decode(&body_b64) .map_err(|e| AgeError::ParseError(format!("stanza body base64: {e}")))? }; recipients.push(Stanza { tag, args, body }); } } /// Serialize a `Header` to bytes. /// /// Writes version line, all stanzas (with body split into 64-char base64 lines), /// and the `--- ` footer. Does NOT write the payload. pub fn serialize_header(header: &Header) -> Vec { let mut out = Vec::new(); out.extend_from_slice(VERSION_LINE); for stanza in &header.recipients { out.extend_from_slice(b"-> "); out.extend_from_slice(stanza.tag.as_bytes()); for arg in &stanza.args { out.push(b' '); out.extend_from_slice(arg.as_bytes()); } out.push(b'\n'); if stanza.body.is_empty() { out.push(b'\n'); } else { let encoded = STANDARD_NO_PAD.encode(&stanza.body); for chunk in encoded.as_bytes().chunks(64) { out.extend_from_slice(chunk); out.push(b'\n'); } // Terminating empty line after body out.push(b'\n'); } } // Footer out.extend_from_slice(FOOTER_PREFIX); out.extend_from_slice(STANDARD_NO_PAD.encode(&header.mac).as_bytes()); out.push(b'\n'); out } /// Return the header bytes up to and including `"--- "` (not including the MAC value). /// /// Used for MAC computation. pub fn header_bytes_up_to_footer(input: &[u8]) -> Result, AgeError> { let footer_pos = input .windows(FOOTER_PREFIX.len()) .position(|w| w == FOOTER_PREFIX) .ok_or_else(|| AgeError::ParseError("footer not found".to_string()))?; Ok(input[..footer_pos + FOOTER_PREFIX.len()].to_vec()) } #[cfg(test)] mod tests { use super::*; use crate::identities::{Header, Stanza}; fn minimal_age_file() -> Vec { let mut out = Vec::new(); out.extend_from_slice(b"age-encryption.org/v1\n"); out.extend_from_slice(b"-> X25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"); // empty stanza body out.extend_from_slice(b"\n"); // MAC line: "--- " + base64(32 zero bytes) -- note: STANDARD_NO_PAD encodes 32 zeros as 43 chars out.extend_from_slice(b"--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"); // payload: 16-byte nonce + 16-byte ciphertext placeholder out.extend_from_slice(&[0x01u8; 16]); out.extend_from_slice(&[0x02u8; 16]); out } #[test] fn parse_minimal_header_roundtrip() { let input = minimal_age_file(); let (header, payload_offset) = parse_header(&input).expect("parse should succeed"); assert_eq!(header.recipients.len(), 1); assert_eq!(header.recipients[0].tag, "X25519"); assert_eq!(header.recipients[0].args.len(), 1); assert!(header.recipients[0].body.is_empty()); // payload offset should point to the 32 bytes at the end assert_eq!(&input[payload_offset..], { let mut v = vec![0x01u8; 16]; v.extend_from_slice(&[0x02u8; 16]); v }.as_slice()); } #[test] fn serialize_header_roundtrip() { let stanza = Stanza { tag: "X25519".to_string(), args: vec!["dGVzdA".to_string()], body: vec![0xde, 0xad, 0xbe, 0xef], }; let header = Header { recipients: vec![stanza], mac: vec![0u8; 32], }; let serialized = serialize_header(&header); // Append dummy payload so parse_header can find the end let mut with_payload = serialized.clone(); with_payload.extend_from_slice(&[0u8; 32]); let (parsed, _) = parse_header(&with_payload).expect("round-trip parse should succeed"); assert_eq!(parsed.recipients.len(), 1); assert_eq!(parsed.recipients[0].tag, "X25519"); assert_eq!(parsed.recipients[0].body, vec![0xde, 0xad, 0xbe, 0xef]); } #[test] fn parse_missing_version_line_returns_error() { let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n"; let result = parse_header(input); assert!(result.is_err(), "expected ParseError for missing version line"); } #[test] fn parse_truncated_before_footer_returns_error() { let input = b"age-encryption.org/v1\n-> X25519 arg\n"; let result = parse_header(input); assert!(result.is_err(), "expected ParseError for truncated header"); } #[test] fn parse_empty_input_returns_error() { let result = parse_header(b""); assert!(result.is_err()); } #[test] fn stanza_body_split_across_multiple_64char_lines() { // 49 bytes -> base64 no-pad = 66 chars -> must split: 64 + 2 let body = vec![0xabu8; 49]; let stanza = Stanza { tag: "scrypt".to_string(), args: vec!["salt".to_string(), "18".to_string()], body: body.clone(), }; let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] }; let serialized = serialize_header(&header); let mut with_payload = serialized; with_payload.extend_from_slice(&[0u8; 32]); let (parsed, _) = parse_header(&with_payload).unwrap(); assert_eq!(parsed.recipients[0].body, body); } #[test] fn header_bytes_up_to_footer_excludes_mac() { let input = minimal_age_file(); let bytes = header_bytes_up_to_footer(&input).expect("should find footer"); // Must end with "--- " (not include the MAC value) assert!(bytes.ends_with(b"--- "), "header_bytes must end at '--- '"); } }