Skip to main content

arcana/encoding/
pem.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! PEM armor: base64 encoding with `-----BEGIN/END ...-----` headers.
5
6// ====================================================================
7// Base64 (RFC 4648)
8// ====================================================================
9
10const B64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
11
12/// Encode `data` as base64.
13pub fn base64_encode(data: &[u8]) -> String {
14    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
15    for chunk in data.chunks(3) {
16        let b0 = chunk[0] as u32;
17        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
18        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
19        let triple = (b0 << 16) | (b1 << 8) | b2;
20
21        out.push(B64_CHARS[((triple >> 18) & 0x3F) as usize] as char);
22        out.push(B64_CHARS[((triple >> 12) & 0x3F) as usize] as char);
23        if chunk.len() > 1 {
24            out.push(B64_CHARS[((triple >> 6) & 0x3F) as usize] as char);
25        } else {
26            out.push('=');
27        }
28        if chunk.len() > 2 {
29            out.push(B64_CHARS[(triple & 0x3F) as usize] as char);
30        } else {
31            out.push('=');
32        }
33    }
34    out
35}
36
37/// Decode a base64 string. Returns `None` on invalid input.
38pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
39    let mut out = Vec::with_capacity(s.len() * 3 / 4);
40    let mut buf = 0u32;
41    let mut count = 0u32;
42
43    for c in s.chars() {
44        if c.is_whitespace() {
45            continue;
46        }
47        if c == '=' {
48            break;
49        }
50        let val = b64_char_value(c)? as u32;
51        buf = (buf << 6) | val;
52        count += 1;
53        if count == 4 {
54            out.push((buf >> 16) as u8);
55            out.push((buf >> 8) as u8);
56            out.push(buf as u8);
57            buf = 0;
58            count = 0;
59        }
60    }
61    match count {
62        2 => {
63            buf <<= 12;
64            out.push((buf >> 16) as u8);
65        }
66        3 => {
67            buf <<= 6;
68            out.push((buf >> 16) as u8);
69            out.push((buf >> 8) as u8);
70        }
71        0 => {}
72        _ => return None, // 1 leftover char is invalid
73    }
74    Some(out)
75}
76
77fn b64_char_value(c: char) -> Option<u8> {
78    match c {
79        'A'..='Z' => Some(c as u8 - b'A'),
80        'a'..='z' => Some(c as u8 - b'a' + 26),
81        '0'..='9' => Some(c as u8 - b'0' + 52),
82        '+' => Some(62),
83        '/' => Some(63),
84        _ => None,
85    }
86}
87
88// ====================================================================
89// PEM
90// ====================================================================
91
92/// Encode DER bytes as PEM with the given label (e.g. "RSA PUBLIC KEY").
93pub fn pem_encode(label: &str, der: &[u8]) -> String {
94    let b64 = base64_encode(der);
95    let mut out = String::new();
96    out.push_str("-----BEGIN ");
97    out.push_str(label);
98    out.push_str("-----\n");
99    // Wrap at 64 characters per line (PEM standard).
100    for line in b64.as_bytes().chunks(64) {
101        out.push_str(std::str::from_utf8(line).unwrap());
102        out.push('\n');
103    }
104    out.push_str("-----END ");
105    out.push_str(label);
106    out.push_str("-----\n");
107    out
108}
109
110/// Decode a PEM block with the expected label. Returns the DER bytes.
111/// Returns `None` if the label doesn't match or base64 is invalid.
112pub fn pem_decode(label: &str, pem: &str) -> Option<Vec<u8>> {
113    let begin = format!("-----BEGIN {}-----", label);
114    let end = format!("-----END {}-----", label);
115
116    let start = pem.find(&begin)? + begin.len();
117    let stop = pem.find(&end)?;
118    let b64_block = &pem[start..stop];
119    base64_decode(b64_block)
120}
121
122// ====================================================================
123// Tests
124// ====================================================================
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn base64_roundtrip() {
132        let cases: &[(&[u8], &str)] = &[
133            (b"", ""),
134            (b"f", "Zg=="),
135            (b"fo", "Zm8="),
136            (b"foo", "Zm9v"),
137            (b"foob", "Zm9vYg=="),
138            (b"fooba", "Zm9vYmE="),
139            (b"foobar", "Zm9vYmFy"),
140        ];
141        for (data, expected) in cases {
142            let encoded = base64_encode(data);
143            assert_eq!(encoded, *expected, "encode {:?}", data);
144            let decoded = base64_decode(&encoded).unwrap();
145            assert_eq!(decoded, *data, "decode {:?}", expected);
146        }
147    }
148
149    #[test]
150    fn base64_binary() {
151        let data: Vec<u8> = (0..256).map(|i| i as u8).collect();
152        let enc = base64_encode(&data);
153        let dec = base64_decode(&enc).unwrap();
154        assert_eq!(dec, data);
155    }
156
157    #[test]
158    fn pem_roundtrip() {
159        let der = vec![0x30, 0x03, 0x02, 0x01, 0x42]; // tiny DER
160        let pem = pem_encode("TEST DATA", &der);
161        assert!(pem.contains("-----BEGIN TEST DATA-----"));
162        assert!(pem.contains("-----END TEST DATA-----"));
163        let decoded = pem_decode("TEST DATA", &pem).unwrap();
164        assert_eq!(decoded, der);
165    }
166
167    #[test]
168    fn pem_wrong_label_returns_none() {
169        let pem = pem_encode("FOO", &[0x01]);
170        assert!(pem_decode("BAR", &pem).is_none());
171    }
172}