Skip to main content

codec_pem/
encode.rs

1// SPDX-FileCopyrightText: Copyright © 2026 ReallyMe LLC. All rights reserved
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use base64::{encoded_len, engine::general_purpose::STANDARD, Engine as _};
6use zeroize::Zeroizing;
7
8use crate::{PemEncodeOptions, PemError, PemLabel};
9
10/// Encode DER bytes as PEM text armor.
11pub fn encode_pem(
12    label: PemLabel,
13    der: &[u8],
14    options: PemEncodeOptions,
15) -> Result<Zeroizing<String>, PemError> {
16    if der.is_empty() || der.len() > options.max_der_len {
17        return Err(PemError::DerTooLarge);
18    }
19    if options.line_width == 0 || options.line_width > 76 {
20        return Err(PemError::InvalidOptions);
21    }
22
23    let encoded_length = encoded_len(der.len(), true).ok_or(PemError::InvalidOptions)?;
24    let mut encoded = Zeroizing::new(vec![0_u8; encoded_length]);
25    let written = STANDARD
26        .encode_slice(der, encoded.as_mut_slice())
27        .map_err(|_| PemError::InvalidOptions)?;
28    if written != encoded_length {
29        return Err(PemError::InvalidOptions);
30    }
31
32    let newline = options.line_ending.as_str();
33    let output_length = encoded_pem_length(
34        label.as_str().len(),
35        encoded_length,
36        options.line_width,
37        newline.len(),
38    )?;
39    // Allocate the final secret-bearing buffer once. Growing a String after
40    // private-key armor has been written would free prior allocations without
41    // wiping them; an exact capacity prevents that remanence path.
42    let mut output = Zeroizing::new(String::with_capacity(output_length));
43    output.push_str("-----BEGIN ");
44    output.push_str(label.as_str());
45    output.push_str("-----");
46    output.push_str(newline);
47
48    for chunk in encoded.as_slice().chunks(options.line_width) {
49        let line = core::str::from_utf8(chunk).map_err(|_| PemError::InvalidBase64)?;
50        output.push_str(line);
51        output.push_str(newline);
52    }
53
54    output.push_str("-----END ");
55    output.push_str(label.as_str());
56    output.push_str("-----");
57    output.push_str(newline);
58
59    if output.len() != output_length {
60        return Err(PemError::InvalidOptions);
61    }
62
63    Ok(output)
64}
65
66fn encoded_pem_length(
67    label_length: usize,
68    encoded_length: usize,
69    line_width: usize,
70    newline_length: usize,
71) -> Result<usize, PemError> {
72    let boundary_length = "-----BEGIN "
73        .len()
74        .checked_add(label_length)
75        .and_then(|length| length.checked_add("-----".len()))
76        .and_then(|length| length.checked_add(newline_length))
77        .ok_or(PemError::InvalidOptions)?;
78    let footer_length = "-----END "
79        .len()
80        .checked_add(label_length)
81        .and_then(|length| length.checked_add("-----".len()))
82        .and_then(|length| length.checked_add(newline_length))
83        .ok_or(PemError::InvalidOptions)?;
84    let line_count = encoded_length
85        .checked_add(line_width.checked_sub(1).ok_or(PemError::InvalidOptions)?)
86        .ok_or(PemError::InvalidOptions)?
87        / line_width;
88    let body_newlines = line_count
89        .checked_mul(newline_length)
90        .ok_or(PemError::InvalidOptions)?;
91
92    boundary_length
93        .checked_add(encoded_length)
94        .and_then(|length| length.checked_add(body_newlines))
95        .and_then(|length| length.checked_add(footer_length))
96        .ok_or(PemError::InvalidOptions)
97}