use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use zeroize::Zeroizing;
const PEM_HEADER: &[u8] = b"-----BEGIN ";
const PEM_FOOTER: &[u8] = b"-----END ";
const PEM_ENDING: &[u8] = b"-----";
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("missing PEM header")]
MissingHeader,
#[error("malformed PEM header: {0}")]
MalformedHeader(String),
#[error("empty PEM block type")]
EmptyBlockType,
#[error("malformed PEM block type")]
MalformedBlockType,
#[error("missing PEM footer")]
MissingFooter,
#[error("trailing data after PEM block")]
TrailingData,
#[error("malformed PEM body: {0}")]
MalformedBody(String),
#[error("malformed base64 payload: {0}")]
MalformedPayload(String),
}
pub fn decode(data: &[u8]) -> Result<(String, Zeroizing<Vec<u8>>), Error> {
if !data.starts_with(PEM_HEADER) {
return Err(Error::MissingHeader);
}
let header_end = data
.iter()
.position(|&b| b == b'\n')
.ok_or_else(|| Error::MalformedHeader("incomplete header line".into()))?;
let line_ending: &[u8] = if header_end > 0 && data[header_end - 1] == b'\r' {
b"\r\n"
} else {
b"\n"
};
let header = if line_ending.len() == 2 {
&data[..header_end - 1]
} else {
&data[..header_end]
};
if !header.starts_with(PEM_HEADER) || !header.ends_with(PEM_ENDING) {
return Err(Error::MalformedHeader("unterminated block type".into()));
}
let block_type = &header[PEM_HEADER.len()..header.len() - PEM_ENDING.len()];
if block_type.is_empty() {
return Err(Error::EmptyBlockType);
}
let kind = String::from_utf8(block_type.to_vec()).map_err(|_| Error::MalformedBlockType)?;
let mut footer = Vec::with_capacity(PEM_FOOTER.len() + block_type.len() + PEM_ENDING.len());
footer.extend_from_slice(PEM_FOOTER);
footer.extend_from_slice(block_type);
footer.extend_from_slice(PEM_ENDING);
let search_area = &data[header_end + 1..];
let footer_idx = search_area
.windows(footer.len())
.position(|w| w == footer.as_slice())
.ok_or(Error::MissingFooter)?;
let footer_start = header_end + 1 + footer_idx;
let footer_end = footer_start + footer.len();
let rest = &data[footer_end..];
if !rest.is_empty() && rest != line_ending {
return Err(Error::TrailingData);
}
let body = &data[header_end + 1..footer_start];
if body.is_empty() {
return Err(Error::MalformedBody("empty body".into()));
}
if !body.ends_with(line_ending) {
return Err(Error::MalformedBody("missing newline before footer".into()));
}
let body = &body[..body.len() - line_ending.len()];
let mut b64 = Zeroizing::new(Vec::with_capacity(body.len()));
let mut lines = body.split(|&b| b == b'\n').peekable();
while let Some(mut line) = lines.next() {
if line_ending.len() == 2 && lines.peek().is_some() {
line = line
.strip_suffix(b"\r")
.ok_or_else(|| Error::MalformedPayload("stray line endings".into()))?;
}
if line.contains(&b'\r') {
return Err(Error::MalformedPayload("stray line endings".into()));
}
b64.extend_from_slice(line);
}
if b64.is_empty() {
return Err(Error::MalformedBody("empty body".into()));
}
let mut decoded = Zeroizing::new(Vec::new());
STANDARD
.decode_vec(&b64, &mut decoded)
.map_err(|err| Error::MalformedPayload(err.to_string()))?;
Ok((kind, decoded))
}
pub fn encode(kind: &str, data: &[u8]) -> String {
assert!(!data.is_empty(), "empty PEM payload");
let b64 = Zeroizing::new(STANDARD.encode(data));
let lines = b64.len().div_ceil(64);
let mut buf = String::with_capacity(
PEM_HEADER.len()
+ PEM_FOOTER.len()
+ 2 * (kind.len() + PEM_ENDING.len() + 1)
+ b64.len()
+ lines,
);
buf.push_str("-----BEGIN ");
buf.push_str(kind);
buf.push_str("-----\n");
for chunk in b64.as_bytes().chunks(64) {
buf.push_str(std::str::from_utf8(chunk).unwrap());
buf.push('\n');
}
buf.push_str("-----END ");
buf.push_str(kind);
buf.push_str("-----\n");
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_empty_payload() {
let tests = [
(
"no body",
"-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n",
),
(
"blank line",
"-----BEGIN PUBLIC KEY-----\n\n-----END PUBLIC KEY-----\n",
),
(
"blank crlf line",
"-----BEGIN PUBLIC KEY-----\r\n\r\n-----END PUBLIC KEY-----\r\n",
),
(
"two blank lines",
"-----BEGIN PUBLIC KEY-----\n\n\n-----END PUBLIC KEY-----\n",
),
];
for (name, input) in tests {
assert!(
matches!(decode(input.as_bytes()), Err(Error::MalformedBody(_))),
"{name}"
);
}
}
#[test]
#[should_panic]
fn test_encode_empty_payload() {
encode("PUBLIC KEY", &[]);
}
#[test]
fn test_round_trip() {
for size in [1, 47, 48, 49, 96, 97] {
let payload = vec![0xa5; size];
let (kind, decoded) = decode(encode("PUBLIC KEY", &payload).as_bytes()).unwrap();
assert_eq!(kind, "PUBLIC KEY", "{size}");
assert_eq!(decoded.as_slice(), payload.as_slice(), "{size}");
}
}
}