Skip to main content

gpg_inspector_lib/
armor.rs

1//! ASCII armor encoding and decoding for PGP data.
2//!
3//! This module handles the ASCII-armored format used for PGP data,
4//! which wraps binary data in Base64 encoding with headers, footers,
5//! and a CRC24 checksum.
6//!
7//! # Format
8//!
9//! Armored data looks like:
10//! ```text
11//! -----BEGIN PGP PUBLIC KEY BLOCK-----
12//!
13//! mDMEZ... (Base64 data)
14//! =XXXX (CRC24 checksum)
15//! -----END PGP PUBLIC KEY BLOCK-----
16//! ```
17//!
18//! The armor type (e.g., "PGP PUBLIC KEY BLOCK") identifies the content type.
19
20use std::sync::Arc;
21
22use crate::error::{Error, Result};
23
24const BASE64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
25
26fn base64_decode_char(c: u8) -> Option<u8> {
27    BASE64_CHARS.iter().position(|&x| x == c).map(|p| p as u8)
28}
29
30fn base64_decode(input: &str) -> Result<Vec<u8>> {
31    let input: Vec<u8> = input
32        .bytes()
33        .filter(|&b| !b.is_ascii_whitespace())
34        .collect();
35
36    let mut output = Vec::with_capacity(input.len() * 3 / 4);
37    let mut buffer = 0u32;
38    let mut bits = 0;
39
40    for &byte in &input {
41        if byte == b'=' {
42            break;
43        }
44        let value = base64_decode_char(byte)
45            .ok_or_else(|| Error::Base64Error(format!("Invalid character: {}", byte as char)))?;
46
47        buffer = (buffer << 6) | (value as u32);
48        bits += 6;
49
50        if bits >= 8 {
51            bits -= 8;
52            output.push((buffer >> bits) as u8);
53            buffer &= (1 << bits) - 1;
54        }
55    }
56
57    Ok(output)
58}
59
60fn crc24(data: &[u8]) -> u32 {
61    const CRC24_INIT: u32 = 0xB704CE;
62    const CRC24_POLY: u32 = 0x1864CFB;
63
64    let mut crc = CRC24_INIT;
65    for &byte in data {
66        crc ^= (byte as u32) << 16;
67        for _ in 0..8 {
68            crc <<= 1;
69            if crc & 0x1000000 != 0 {
70                crc ^= CRC24_POLY;
71            }
72        }
73    }
74    crc & 0xFFFFFF
75}
76
77/// The result of decoding ASCII-armored PGP data.
78///
79/// Contains the decoded binary data and the armor type identifier.
80pub struct ArmorResult {
81    /// The decoded binary PGP packet data.
82    pub bytes: Arc<[u8]>,
83    /// The armor type (e.g., "PGP PUBLIC KEY BLOCK", "PGP MESSAGE").
84    pub armor_type: Arc<str>,
85}
86
87/// Decodes ASCII-armored PGP data into binary.
88///
89/// This function parses the armor format, extracts the Base64-encoded body,
90/// decodes it, and validates the CRC24 checksum if present.
91///
92/// # Arguments
93///
94/// * `input` - ASCII-armored PGP data
95///
96/// # Errors
97///
98/// Returns `Error::InvalidArmor` if:
99/// - Missing `-----BEGIN ... -----` header
100/// - Missing `-----END ... -----` footer
101/// - Empty body
102///
103/// Returns `Error::Base64Error` if the Base64 data contains invalid characters.
104///
105/// Returns `Error::ChecksumMismatch` if the CRC24 checksum doesn't match.
106///
107/// # Example
108///
109/// ```ignore
110/// use gpg_inspector_lib::armor::decode_armor;
111///
112/// let armored = r#"-----BEGIN PGP PUBLIC KEY BLOCK-----
113///
114/// mDMEZ...
115/// =XXXX
116/// -----END PGP PUBLIC KEY BLOCK-----"#;
117///
118/// let result = decode_armor(armored)?;
119/// println!("Type: {}", result.armor_type);
120/// println!("Size: {} bytes", result.bytes.len());
121/// ```
122pub fn decode_armor(input: &str) -> Result<ArmorResult> {
123    let lines: Vec<&str> = input.lines().collect();
124    let (bytes, armor_type, _next) = decode_block(&lines, 0)?;
125
126    Ok(ArmorResult {
127        bytes: bytes.into(),
128        armor_type: armor_type.into(),
129    })
130}
131
132/// Decodes the first armor block found at or after line index `from`.
133///
134/// Returns the decoded bytes, the armor type, and the line index just
135/// past the block's END footer.
136fn decode_block(lines: &[&str], from: usize) -> Result<(Vec<u8>, String, usize)> {
137    let header_idx = lines[from..]
138        .iter()
139        .position(|line| line.starts_with("-----BEGIN ") && line.ends_with("-----"))
140        .map(|p| p + from)
141        .ok_or_else(|| Error::InvalidArmor("Missing BEGIN header".into()))?;
142
143    let header_line = lines[header_idx];
144    let armor_type = header_line
145        .strip_prefix("-----BEGIN ")
146        .and_then(|s| s.strip_suffix("-----"))
147        .ok_or_else(|| Error::InvalidArmor("Invalid BEGIN header format".into()))?
148        .to_string();
149
150    let end_marker = format!("-----END {}-----", armor_type);
151    let footer_idx = lines[header_idx..]
152        .iter()
153        .position(|line| *line == end_marker)
154        .map(|p| p + header_idx)
155        .ok_or_else(|| Error::InvalidArmor("Missing END header".into()))?;
156
157    let mut body_start = header_idx + 1;
158    while body_start < footer_idx {
159        let line = lines[body_start];
160        if line.is_empty() || line.contains(':') {
161            body_start += 1;
162        } else {
163            break;
164        }
165    }
166
167    let body_lines: Vec<&str> = lines[body_start..footer_idx]
168        .iter()
169        .copied()
170        .filter(|line| !line.is_empty())
171        .collect();
172
173    if body_lines.is_empty() {
174        return Err(Error::InvalidArmor("Empty body".into()));
175    }
176
177    let checksum_line = body_lines
178        .last()
179        .filter(|line| line.starts_with('='))
180        .copied();
181
182    let data_lines = if checksum_line.is_some() {
183        &body_lines[..body_lines.len() - 1]
184    } else {
185        &body_lines[..]
186    };
187
188    let base64_data: String = data_lines.join("");
189    let bytes = base64_decode(&base64_data)?;
190
191    if let Some(checksum_str) = checksum_line {
192        let checksum_b64 = &checksum_str[1..];
193        let checksum_bytes = base64_decode(checksum_b64)?;
194        if checksum_bytes.len() >= 3 {
195            let expected = ((checksum_bytes[0] as u32) << 16)
196                | ((checksum_bytes[1] as u32) << 8)
197                | (checksum_bytes[2] as u32);
198            let actual = crc24(&bytes);
199            if expected != actual {
200                return Err(Error::ChecksumMismatch { expected, actual });
201            }
202        }
203    }
204
205    Ok((bytes, armor_type, footer_idx + 1))
206}
207
208/// One decoded armor block within a [`MultiArmorResult`].
209pub struct ArmorBlock {
210    /// The armor type (e.g., "PGP PUBLIC KEY BLOCK", "PGP SIGNATURE").
211    pub armor_type: Arc<str>,
212    /// Byte range of this block's decoded data within
213    /// [`MultiArmorResult::bytes`].
214    pub range: (usize, usize),
215}
216
217/// The result of decoding input that may contain several armor blocks
218/// and/or a cleartext signed message.
219pub struct MultiArmorResult {
220    /// All blocks' decoded binary data, concatenated in input order, so
221    /// that packet byte spans share a single address space.
222    pub bytes: Arc<[u8]>,
223    /// The decoded blocks, in input order, with their byte ranges.
224    pub blocks: Vec<ArmorBlock>,
225    /// The dash-unescaped cleartext, if the input contained a
226    /// `-----BEGIN PGP SIGNED MESSAGE-----` section.
227    pub cleartext: Option<Arc<str>>,
228}
229
230const CLEARTEXT_HEADER: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
231const SIGNATURE_HEADER: &str = "-----BEGIN PGP SIGNATURE-----";
232
233/// Decodes ASCII-armored PGP data that may contain multiple armor blocks.
234///
235/// A strict superset of [`decode_armor`]: every `-----BEGIN ... -----`
236/// block in the input is decoded and the binary data concatenated (with
237/// per-block ranges in [`MultiArmorResult::blocks`]). Cleartext signed
238/// messages (`-----BEGIN PGP SIGNED MESSAGE-----`) are also handled: the
239/// dash-escaped cleartext is captured and the trailing signature block is
240/// decoded normally.
241///
242/// # Errors
243///
244/// Returns `Error::InvalidArmor` if no armor block is found, a block is
245/// malformed, or a cleartext section has no trailing signature block.
246/// Base64 and CRC24 checksum errors propagate per block.
247pub fn decode_armor_multi(input: &str) -> Result<MultiArmorResult> {
248    let lines: Vec<&str> = input.lines().collect();
249
250    let mut bytes: Vec<u8> = Vec::new();
251    let mut blocks: Vec<ArmorBlock> = Vec::new();
252    let mut cleartext: Option<Arc<str>> = None;
253    let mut idx = 0;
254
255    while let Some(begin_rel) = lines[idx..]
256        .iter()
257        .position(|line| line.starts_with("-----BEGIN ") && line.ends_with("-----"))
258    {
259        let begin_idx = idx + begin_rel;
260
261        if lines[begin_idx] == CLEARTEXT_HEADER {
262            let (text, sig_idx) = read_cleartext(&lines, begin_idx + 1)?;
263            if cleartext.is_none() {
264                cleartext = Some(text.into());
265            }
266            // Continue at the signature header; it decodes as a normal block
267            idx = sig_idx;
268            continue;
269        }
270
271        let (block_bytes, armor_type, next) = decode_block(&lines, begin_idx)?;
272        let start = bytes.len();
273        bytes.extend_from_slice(&block_bytes);
274        blocks.push(ArmorBlock {
275            armor_type: armor_type.into(),
276            range: (start, bytes.len()),
277        });
278        idx = next;
279    }
280
281    if blocks.is_empty() {
282        return Err(Error::InvalidArmor("Missing BEGIN header".into()));
283    }
284
285    Ok(MultiArmorResult {
286        bytes: bytes.into(),
287        blocks,
288        cleartext,
289    })
290}
291
292/// Reads a cleartext signed message body starting just after its BEGIN
293/// line. Skips `Hash:` armor headers, collects dash-unescaped text until
294/// the signature block, and returns the text plus the line index of the
295/// `-----BEGIN PGP SIGNATURE-----` header.
296fn read_cleartext(lines: &[&str], mut idx: usize) -> Result<(String, usize)> {
297    // Armor headers (e.g. "Hash: SHA256") end at the first empty line
298    while idx < lines.len() && !lines[idx].is_empty() {
299        idx += 1;
300    }
301    if idx < lines.len() {
302        idx += 1; // skip the blank separator line
303    }
304
305    let mut text_lines: Vec<&str> = Vec::new();
306    while idx < lines.len() {
307        let line = lines[idx];
308        if line == SIGNATURE_HEADER {
309            return Ok((text_lines.join("\n"), idx));
310        }
311        text_lines.push(line.strip_prefix("- ").unwrap_or(line));
312        idx += 1;
313    }
314
315    Err(Error::InvalidArmor(
316        "Cleartext signed message missing signature block".into(),
317    ))
318}
319
320/// Returns `true` if `bytes` looks like raw (unarmored) binary PGP data.
321///
322/// Every OpenPGP packet stream begins with a tag octet whose high bit is
323/// set (RFC 4880 ยง4.2), while ASCII-armored input always begins with
324/// printable ASCII, so testing the first byte's high bit is a reliable
325/// discriminator.
326pub fn looks_binary(bytes: &[u8]) -> bool {
327    bytes.first().is_some_and(|&b| b & 0x80 != 0)
328}