use std::fmt;
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const PAD: u8 = b'=';
const GROUP_SYMBOLS: usize = 4;
const GROUP_BYTES: usize = 3;
const SEPARATOR: char = ':';
#[derive(Debug, PartialEq, Eq)]
pub enum ArmorError {
WrongLabel {
expected: String,
found: String,
},
Malformed,
}
impl fmt::Display for ArmorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArmorError::WrongLabel { expected, found } if found.is_empty() => {
write!(f, "this is not a {expected} file")
}
ArmorError::WrongLabel { expected, found } => {
write!(f, "this is a {found} file, not a {expected} one")
}
ArmorError::Malformed => write!(f, "the key material is not valid base64"),
}
}
}
impl std::error::Error for ArmorError {}
pub(crate) fn encode_labelled(label: &str, bytes: &[u8]) -> String {
format!("{label}{SEPARATOR}{}\n", encode(bytes))
}
pub(crate) fn decode_labelled(label: &str, text: &str) -> Result<Vec<u8>, ArmorError> {
let line = text.trim();
let Some((found, body)) = line.split_once(SEPARATOR) else {
return Err(ArmorError::WrongLabel {
expected: label.to_owned(),
found: String::new(),
});
};
if found != label {
return Err(ArmorError::WrongLabel {
expected: label.to_owned(),
found: found.to_owned(),
});
}
decode(body)
}
fn encode(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len().div_ceil(GROUP_BYTES) * GROUP_SYMBOLS);
for group in bytes.chunks(GROUP_BYTES) {
let packed = group
.iter()
.enumerate()
.fold(0u32, |packed, (slot, &byte)| {
packed | (u32::from(byte) << (16 - 8 * slot))
});
for slot in 0..GROUP_SYMBOLS {
let symbol = if slot <= group.len() {
let index = ((packed >> (18 - 6 * slot)) & 0x3F) as usize;
ALPHABET.get(index).copied().unwrap_or(PAD)
} else {
PAD
};
encoded.push(char::from(symbol));
}
}
encoded
}
fn decode(text: &str) -> Result<Vec<u8>, ArmorError> {
let body = text.trim().as_bytes();
if body.is_empty() || body.len() % GROUP_SYMBOLS != 0 {
return Err(ArmorError::Malformed);
}
let padding = body.iter().rev().take_while(|&&byte| byte == PAD).count();
if padding > 2 || body.iter().filter(|&&byte| byte == PAD).count() != padding {
return Err(ArmorError::Malformed);
}
let mut decoded = Vec::with_capacity(body.len() / GROUP_SYMBOLS * GROUP_BYTES);
for group in body.chunks(GROUP_SYMBOLS) {
let mut packed = 0u32;
for (slot, &symbol) in group.iter().enumerate() {
let value = if symbol == PAD {
0
} else {
value_of(symbol).ok_or(ArmorError::Malformed)?
};
packed |= u32::from(value) << (18 - 6 * slot);
}
decoded.push(((packed >> 16) & 0xFF) as u8);
decoded.push(((packed >> 8) & 0xFF) as u8);
decoded.push((packed & 0xFF) as u8);
}
decoded.truncate(decoded.len() - padding);
Ok(decoded)
}
fn value_of(symbol: u8) -> Option<u8> {
match symbol {
b'A'..=b'Z' => Some(symbol - b'A'),
b'a'..=b'z' => Some(symbol - b'a' + 26),
b'0'..=b'9' => Some(symbol - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
const LABEL: &str = "stenoxide-test-v1";
#[test]
fn encoding_matches_the_published_vectors() {
let vectors = [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
];
for (plain, expected) in vectors {
assert_eq!(encode(plain.as_bytes()), expected, "encoding {plain:?}");
if !plain.is_empty() {
assert_eq!(
decode(expected).expect("a published vector must decode"),
plain.as_bytes(),
"decoding {expected:?}"
);
}
}
}
#[test]
fn every_byte_round_trips_at_every_alignment() {
let all: Vec<u8> = (0..=255u8).collect();
for length in 1..=all.len() {
let slice = &all[..length];
let decoded = decode(&encode(slice)).expect("our own encoding must decode");
assert_eq!(decoded, slice, "at length {length}");
}
}
#[test]
fn a_labelled_line_survives_being_pasted() {
let material = [0x9Au8; 64];
let line = encode_labelled(LABEL, &material);
assert!(line.ends_with('\n'));
assert!(line.starts_with(LABEL));
for pasted in [line.clone(), format!(" {} \r\n", line.trim())] {
assert_eq!(
decode_labelled(LABEL, &pasted).expect("a pasted key must decode"),
material
);
}
}
#[test]
fn the_wrong_kind_of_key_file_says_which_it_is() {
let line = encode_labelled("stenoxide-other-v1", &[1u8; 8]);
match decode_labelled(LABEL, &line) {
Err(ArmorError::WrongLabel { expected, found }) => {
assert_eq!(expected, LABEL);
assert_eq!(found, "stenoxide-other-v1");
}
other => panic!("a mislabelled file must be reported as one: {other:?}"),
}
match decode_labelled(LABEL, "not a key file") {
Err(ArmorError::WrongLabel { found, .. }) => assert!(found.is_empty()),
other => panic!("a line without a label must be refused: {other:?}"),
}
}
#[test]
fn malformed_bodies_are_refused() {
for body in ["", "Zg=", "Zg===", "Z g=", "Zm9v!!!!", "AB=C", "====", "Z m9v"] {
let line = format!("{LABEL}{SEPARATOR}{body}");
assert_eq!(
decode_labelled(LABEL, &line).map(|_| ()),
Err(ArmorError::Malformed),
"body {body:?} must be refused"
);
}
}
#[test]
fn every_failure_explains_itself() {
assert!(ArmorError::Malformed.to_string().contains("base64"));
let mislabelled = ArmorError::WrongLabel {
expected: "private key".to_owned(),
found: "public key".to_owned(),
};
assert!(mislabelled.to_string().contains("public key"));
assert!(mislabelled.to_string().contains("private key"));
let unlabelled = ArmorError::WrongLabel {
expected: "private key".to_owned(),
found: String::new(),
};
assert!(unlabelled.to_string().contains("not a private key"));
}
}