Skip to content

Commit b383db0

Browse files
authored
feat(pq-key-encoder): phase 4 - base64 + PEM encoding/decoding (ENG-1300, ENG-1326, ENG-1327, ENG-1328, ENG-1329) (#10)
1 parent 02c945d commit b383db0

File tree

4 files changed

+997
-0
lines changed

4 files changed

+997
-0
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
use alloc::string::String;
2+
use alloc::vec::Vec;
3+
4+
use crate::error::{Error, Result};
5+
6+
const ENCODE_STD: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
7+
8+
const ENCODE_URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
9+
10+
/// Decode table: maps ASCII byte to 6-bit value. 0xFF = invalid.
11+
/// Supports both standard (+/) and url-safe (-_) alphabets.
12+
const DECODE: [u8; 256] = {
13+
let mut table = [0xFFu8; 256];
14+
let mut i = 0u8;
15+
// A-Z = 0-25
16+
while i < 26 {
17+
table[(b'A' + i) as usize] = i;
18+
i += 1;
19+
}
20+
// a-z = 26-51
21+
i = 0;
22+
while i < 26 {
23+
table[(b'a' + i) as usize] = 26 + i;
24+
i += 1;
25+
}
26+
// 0-9 = 52-61
27+
i = 0;
28+
while i < 10 {
29+
table[(b'0' + i) as usize] = 52 + i;
30+
i += 1;
31+
}
32+
// Standard: + = 62, / = 63
33+
table[b'+' as usize] = 62;
34+
table[b'/' as usize] = 63;
35+
// URL-safe: - = 62, _ = 63
36+
table[b'-' as usize] = 62;
37+
table[b'_' as usize] = 63;
38+
table
39+
};
40+
41+
/// Encode bytes to standard base64 with `=` padding.
42+
/// Pre-sizes the output string — single allocation, no intermediates.
43+
#[allow(dead_code)]
44+
pub(crate) fn encode_base64(data: &[u8]) -> String {
45+
encode_with_table(data, ENCODE_STD, true)
46+
}
47+
48+
/// Encode bytes to standard base64 with `=` padding, appending to `out`.
49+
/// No intermediate allocation — writes directly into the provided String.
50+
pub(crate) fn encode_base64_to(data: &[u8], out: &mut String) {
51+
encode_with_table_to(data, ENCODE_STD, true, out);
52+
}
53+
54+
/// Encode bytes to base64url without padding.
55+
#[allow(dead_code)]
56+
pub(crate) fn encode_base64url(data: &[u8]) -> String {
57+
encode_with_table(data, ENCODE_URL, false)
58+
}
59+
60+
/// Returns the length of the base64-encoded output for the given input length.
61+
pub(crate) fn encoded_len(data_len: usize, pad: bool) -> usize {
62+
if pad {
63+
data_len.div_ceil(3) * 4
64+
} else {
65+
let full_chunks = data_len / 3;
66+
let remainder = data_len % 3;
67+
full_chunks * 4
68+
+ match remainder {
69+
1 => 2,
70+
2 => 3,
71+
_ => 0,
72+
}
73+
}
74+
}
75+
76+
fn encode_with_table(data: &[u8], table: &[u8; 64], pad: bool) -> String {
77+
let mut out = String::with_capacity(encoded_len(data.len(), pad));
78+
encode_with_table_to(data, table, pad, &mut out);
79+
out
80+
}
81+
82+
/// Core encoder — writes base64 directly into the provided String.
83+
fn encode_with_table_to(data: &[u8], table: &[u8; 64], pad: bool, out: &mut String) {
84+
// Process full 3-byte chunks
85+
let mut i = 0;
86+
while i + 2 < data.len() {
87+
let b0 = data[i] as u32;
88+
let b1 = data[i + 1] as u32;
89+
let b2 = data[i + 2] as u32;
90+
let triple = (b0 << 16) | (b1 << 8) | b2;
91+
92+
out.push(table[((triple >> 18) & 0x3F) as usize] as char);
93+
out.push(table[((triple >> 12) & 0x3F) as usize] as char);
94+
out.push(table[((triple >> 6) & 0x3F) as usize] as char);
95+
out.push(table[(triple & 0x3F) as usize] as char);
96+
i += 3;
97+
}
98+
99+
// Handle remainder
100+
match data.len() - i {
101+
1 => {
102+
let b0 = data[i] as u32;
103+
out.push(table[((b0 >> 2) & 0x3F) as usize] as char);
104+
out.push(table[((b0 << 4) & 0x3F) as usize] as char);
105+
if pad {
106+
out.push('=');
107+
out.push('=');
108+
}
109+
}
110+
2 => {
111+
let b0 = data[i] as u32;
112+
let b1 = data[i + 1] as u32;
113+
out.push(table[((b0 >> 2) & 0x3F) as usize] as char);
114+
out.push(table[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
115+
out.push(table[((b1 << 2) & 0x3F) as usize] as char);
116+
if pad {
117+
out.push('=');
118+
}
119+
}
120+
_ => {}
121+
}
122+
}
123+
124+
/// Decode standard base64 (strips whitespace, accepts padded/unpadded).
125+
pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
126+
decode_impl(input)
127+
}
128+
129+
/// Decode base64url (strips whitespace, accepts padded/unpadded).
130+
/// Uses the same decode table since it maps both +/ and -_ variants.
131+
#[allow(dead_code)]
132+
pub(crate) fn decode_base64url(input: &str) -> Result<Vec<u8>> {
133+
decode_impl(input)
134+
}
135+
136+
fn decode_impl(input: &str) -> Result<Vec<u8>> {
137+
// Strip whitespace and padding, collect valid chars into a scratch buffer.
138+
// We avoid allocating a separate cleaned string by iterating bytes directly.
139+
let bytes = input.as_bytes();
140+
141+
// Count non-whitespace, non-padding bytes to pre-size output
142+
let mut clean_len = 0usize;
143+
for &b in bytes {
144+
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'=' {
145+
continue;
146+
}
147+
clean_len += 1;
148+
}
149+
150+
if clean_len == 0 {
151+
return Ok(Vec::new());
152+
}
153+
154+
// len % 4 == 1 is impossible (would encode partial nibble)
155+
if clean_len % 4 == 1 {
156+
return Err(Error::InvalidBase64("invalid length"));
157+
}
158+
159+
// Pre-size output: full quads produce 3 bytes each, remainder handled below
160+
let full_quads = clean_len / 4;
161+
let remainder = clean_len % 4;
162+
let out_len = full_quads * 3
163+
+ match remainder {
164+
2 => 1,
165+
3 => 2,
166+
_ => 0,
167+
};
168+
169+
let mut out = Vec::with_capacity(out_len);
170+
171+
// Iterate through input, skipping whitespace and padding
172+
let mut buf = [0u8; 4];
173+
let mut buf_pos = 0;
174+
175+
for &b in bytes {
176+
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'=' {
177+
continue;
178+
}
179+
let val = DECODE[b as usize];
180+
if val == 0xFF {
181+
return Err(Error::InvalidBase64("invalid character"));
182+
}
183+
buf[buf_pos] = val;
184+
buf_pos += 1;
185+
186+
if buf_pos == 4 {
187+
out.push((buf[0] << 2) | (buf[1] >> 4));
188+
out.push((buf[1] << 4) | (buf[2] >> 2));
189+
out.push((buf[2] << 6) | buf[3]);
190+
buf_pos = 0;
191+
}
192+
}
193+
194+
// Handle remainder
195+
match buf_pos {
196+
2 => {
197+
out.push((buf[0] << 2) | (buf[1] >> 4));
198+
}
199+
3 => {
200+
out.push((buf[0] << 2) | (buf[1] >> 4));
201+
out.push((buf[1] << 4) | (buf[2] >> 2));
202+
}
203+
0 => {}
204+
_ => return Err(Error::InvalidBase64("invalid length")),
205+
}
206+
207+
Ok(out)
208+
}
209+
210+
#[cfg(test)]
211+
mod tests {
212+
use super::*;
213+
214+
#[test]
215+
fn test_encode_empty() {
216+
assert_eq!(encode_base64(&[]), "");
217+
}
218+
219+
#[test]
220+
fn test_encode_one_byte() {
221+
assert_eq!(encode_base64(&[0x00]), "AA==");
222+
assert_eq!(encode_base64(&[0xFF]), "/w==");
223+
}
224+
225+
#[test]
226+
fn test_encode_two_bytes() {
227+
assert_eq!(encode_base64(&[0x00, 0x00]), "AAA=");
228+
assert_eq!(encode_base64(&[0xFF, 0xFF]), "//8=");
229+
}
230+
231+
#[test]
232+
fn test_encode_three_bytes() {
233+
assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
234+
assert_eq!(encode_base64(&[0xFF, 0xFF, 0xFF]), "////");
235+
}
236+
237+
#[test]
238+
fn test_encode_known_vectors() {
239+
assert_eq!(encode_base64(b""), "");
240+
assert_eq!(encode_base64(b"f"), "Zg==");
241+
assert_eq!(encode_base64(b"fo"), "Zm8=");
242+
assert_eq!(encode_base64(b"foo"), "Zm9v");
243+
assert_eq!(encode_base64(b"foob"), "Zm9vYg==");
244+
assert_eq!(encode_base64(b"fooba"), "Zm9vYmE=");
245+
assert_eq!(encode_base64(b"foobar"), "Zm9vYmFy");
246+
}
247+
248+
#[test]
249+
fn test_decode_known_vectors() {
250+
assert_eq!(decode_base64("").unwrap(), b"");
251+
assert_eq!(decode_base64("Zg==").unwrap(), b"f");
252+
assert_eq!(decode_base64("Zm8=").unwrap(), b"fo");
253+
assert_eq!(decode_base64("Zm9v").unwrap(), b"foo");
254+
assert_eq!(decode_base64("Zm9vYg==").unwrap(), b"foob");
255+
assert_eq!(decode_base64("Zm9vYmE=").unwrap(), b"fooba");
256+
assert_eq!(decode_base64("Zm9vYmFy").unwrap(), b"foobar");
257+
}
258+
259+
#[test]
260+
fn test_decode_unpadded() {
261+
assert_eq!(decode_base64("Zg").unwrap(), b"f");
262+
assert_eq!(decode_base64("Zm8").unwrap(), b"fo");
263+
}
264+
265+
#[test]
266+
fn test_decode_whitespace() {
267+
assert_eq!(decode_base64("Zm9v\nYmFy").unwrap(), b"foobar");
268+
assert_eq!(decode_base64(" Zm9v YmFy ").unwrap(), b"foobar");
269+
assert_eq!(decode_base64("Zm9v\r\nYmFy").unwrap(), b"foobar");
270+
assert_eq!(decode_base64("\tZm9vYmFy\t").unwrap(), b"foobar");
271+
}
272+
273+
#[test]
274+
fn test_decode_invalid_length() {
275+
// len % 4 == 1 after stripping is impossible
276+
let err = decode_base64("A").unwrap_err();
277+
assert!(matches!(err, Error::InvalidBase64("invalid length")));
278+
}
279+
280+
#[test]
281+
fn test_decode_invalid_character() {
282+
let err = decode_base64("Zm9v!!!").unwrap_err();
283+
assert!(matches!(err, Error::InvalidBase64("invalid character")));
284+
}
285+
286+
#[test]
287+
fn test_roundtrip() {
288+
let data: Vec<u8> = (0..=255).collect();
289+
let encoded = encode_base64(&data);
290+
let decoded = decode_base64(&encoded).unwrap();
291+
assert_eq!(decoded, data);
292+
}
293+
294+
#[test]
295+
fn test_roundtrip_various_lengths() {
296+
for len in 0..=50 {
297+
let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
298+
let encoded = encode_base64(&data);
299+
let decoded = decode_base64(&encoded).unwrap();
300+
assert_eq!(decoded, data, "roundtrip failed for len={}", len);
301+
}
302+
}
303+
304+
#[test]
305+
fn test_base64url_encode() {
306+
// Standard uses +/, url uses -_
307+
// 0xFB,0xEF,0xBE → all four 6-bit indices are 62 → '-' in url alphabet
308+
assert_eq!(encode_base64url(&[0xFB, 0xEF, 0xBE]), "----");
309+
assert_eq!(encode_base64url(&[0xFF, 0xFF, 0xFF]), "____");
310+
assert_eq!(encode_base64url(&[0x3E, 0x3E, 0x3E]), "Pj4-");
311+
}
312+
313+
#[test]
314+
fn test_base64url_no_padding() {
315+
assert_eq!(encode_base64url(b"f"), "Zg");
316+
assert_eq!(encode_base64url(b"fo"), "Zm8");
317+
assert_eq!(encode_base64url(b"foo"), "Zm9v");
318+
}
319+
320+
#[test]
321+
fn test_base64url_roundtrip() {
322+
let data: Vec<u8> = (0..=255).collect();
323+
let encoded = encode_base64url(&data);
324+
let decoded = decode_base64url(&encoded).unwrap();
325+
assert_eq!(decoded, data);
326+
}
327+
328+
#[test]
329+
fn test_decode_base64url_with_std_chars() {
330+
// Our decode table accepts both +/ and -_ variants
331+
assert_eq!(decode_base64url("Zm9v").unwrap(), b"foo");
332+
}
333+
334+
#[test]
335+
fn test_large_data_roundtrip() {
336+
let data = vec![0x42u8; 4096];
337+
let encoded = encode_base64(&data);
338+
let decoded = decode_base64(&encoded).unwrap();
339+
assert_eq!(decoded, data);
340+
}
341+
}

packages/pq-key-encoder/rust/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
extern crate alloc;
33

44
mod asn1;
5+
#[cfg(any(feature = "pem", feature = "jwk"))]
6+
mod base64;
57
mod der;
68
mod error;
9+
#[cfg(feature = "pem")]
10+
mod pem;
711
mod pkcs8;
812
mod spki;
913
mod types;

0 commit comments

Comments
 (0)