darkbio_crypto/pem/
mod.rs

1// crypto-rs: cryptography primitives and wrappers
2// Copyright 2025 Dark Bio AG. All rights reserved.
3//
4// Use of this source code is governed by a BSD-style
5// license that can be found in the LICENSE file.
6
7//! Strict PEM encoding and decoding.
8
9use base64::Engine;
10use base64::engine::general_purpose::STANDARD;
11use std::error::Error;
12
13const PEM_HEADER: &[u8] = b"-----BEGIN ";
14const PEM_FOOTER: &[u8] = b"-----END ";
15const PEM_ENDING: &[u8] = b"-----";
16
17/// Decodes a single PEM block with strict validation.
18///
19/// Rules:
20///   - Header must start at byte 0 (no leading whitespace)
21///   - Footer must end the data (only optional line ending after)
22///   - Line endings must be consistent (\n or \r\n throughout)
23///   - Base64 lines contain only base64 characters
24///   - Strict base64 decoding (no padding errors, etc.)
25///   - No trailing data after the PEM block
26///
27/// Returns (kind, data) tuple on success.
28pub fn decode(data: &[u8]) -> Result<(String, Vec<u8>), Box<dyn Error>> {
29    // Must start with header immediately (no leading whitespace)
30    if !data.starts_with(PEM_HEADER) {
31        return Err("pem: missing PEM header".into());
32    }
33    // Find the end of header line (first \n)
34    let header_end = data
35        .iter()
36        .position(|&b| b == b'\n')
37        .ok_or("pem: incomplete PEM header")?;
38
39    // Detect line ending style from first line
40    let line_ending: &[u8] = if header_end > 0 && data[header_end - 1] == b'\r' {
41        b"\r\n"
42    } else {
43        b"\n"
44    };
45
46    // Extract header (without line ending)
47    let header = if line_ending.len() == 2 {
48        &data[..header_end - 1]
49    } else {
50        &data[..header_end]
51    };
52
53    // Parse the block type from the header
54    if !header.starts_with(PEM_HEADER) || !header.ends_with(PEM_ENDING) {
55        return Err("pem: malformed PEM header".into());
56    }
57    let block_type = &header[PEM_HEADER.len()..header.len() - PEM_ENDING.len()];
58    if block_type.is_empty() {
59        return Err("pem: empty PEM block type".into());
60    }
61    let kind = String::from_utf8(block_type.to_vec())?;
62
63    // Build expected footer
64    let mut footer = Vec::with_capacity(PEM_FOOTER.len() + block_type.len() + PEM_ENDING.len());
65    footer.extend_from_slice(PEM_FOOTER);
66    footer.extend_from_slice(block_type);
67    footer.extend_from_slice(PEM_ENDING);
68
69    // Find the footer
70    let search_area = &data[header_end + 1..];
71    let footer_idx = search_area
72        .windows(footer.len())
73        .position(|w| w == footer.as_slice())
74        .ok_or("pem: missing PEM footer")?;
75    let footer_start = header_end + 1 + footer_idx;
76    let footer_end = footer_start + footer.len();
77
78    // Validate what comes after footer: nothing or same line ending
79    let rest = &data[footer_end..];
80    if !rest.is_empty() && rest != line_ending {
81        return Err("pem: trailing data after PEM block".into());
82    }
83
84    // Extract body (between header and footer)
85    let body = &data[header_end + 1..footer_start];
86
87    // Body must end with the line ending (the line before footer)
88    if body.is_empty() {
89        return Err("pem: empty PEM body".into());
90    }
91    if !body.ends_with(line_ending) {
92        return Err("pem: body must end with newline before footer".into());
93    }
94    let body = &body[..body.len() - line_ending.len()];
95
96    // Strip line endings and decode
97    let b64: Vec<u8> = body
98        .split(|&b| b == b'\n')
99        .flat_map(|line| {
100            if line.ends_with(b"\r") {
101                &line[..line.len() - 1]
102            } else {
103                line
104            }
105        })
106        .copied()
107        .collect();
108
109    let decoded = STANDARD.decode(&b64)?;
110
111    Ok((kind, decoded))
112}
113
114/// Encodes data as a PEM block with the given type.
115/// Lines are 64 characters, using \n line endings.
116pub fn encode(kind: &str, data: &[u8]) -> String {
117    let b64 = STANDARD.encode(data);
118
119    let mut buf = String::new();
120    buf.push_str("-----BEGIN ");
121    buf.push_str(kind);
122    buf.push_str("-----\n");
123
124    for chunk in b64.as_bytes().chunks(64) {
125        buf.push_str(std::str::from_utf8(chunk).unwrap());
126        buf.push('\n');
127    }
128
129    buf.push_str("-----END ");
130    buf.push_str(kind);
131    buf.push_str("-----\n");
132
133    buf
134}