From 1c13c04241ce7a722366d772376f27c44d1874e4 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 7 Jul 2025 08:14:20 -0600 Subject: [PATCH] mcf: Base64 decoding support Adds a `Base64` enum with variants for `Bcrypt`, `Crypt`, and `ShaCrypt`, i.e. the non-standard Base64 variants used in MCF hashes. This is extracted from `password_hash::Encoding` which was straddling the line between the PHC format and MCF with such a feature. That type can now be removed in the next breaking release. --- Cargo.lock | 4 ++ mcf/Cargo.toml | 6 +++ mcf/src/base64.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++ mcf/src/error.rs | 25 +++++++++++++ mcf/src/fields.rs | 20 ++++++++-- mcf/src/lib.rs | 27 ++++---------- mcf/tests/mcf.rs | 23 ++++++++++-- 7 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 mcf/src/base64.rs create mode 100644 mcf/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index b804dfdd7..48a3daf91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -861,6 +861,10 @@ checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "mcf" version = "0.0.0" +dependencies = [ + "base64ct", + "hex-literal", +] [[package]] name = "memchr" diff --git a/mcf/Cargo.toml b/mcf/Cargo.toml index bd583013f..902f1c206 100644 --- a/mcf/Cargo.toml +++ b/mcf/Cargo.toml @@ -14,6 +14,12 @@ Pure Rust implementation of the Modular Crypt Format (MCF) which is used to stor in the form `${id}$...` """ +[dependencies] +base64ct = { version = "1.7", features = ["alloc"] } + +[dev-dependencies] +hex-literal = "1" + [features] default = ["alloc"] alloc = [] diff --git a/mcf/src/base64.rs b/mcf/src/base64.rs new file mode 100644 index 000000000..6159374f1 --- /dev/null +++ b/mcf/src/base64.rs @@ -0,0 +1,95 @@ +//! Base64 encoding variants. + +use base64ct::{Base64Bcrypt, Base64Crypt, Base64ShaCrypt, Encoding as _, Error as B64Error}; + +#[cfg(feature = "alloc")] +use alloc::{string::String, vec::Vec}; + +/// Base64 encoding variants used in various MCF encodings. +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum Base64 { + /// bcrypt encoding. + /// + /// ```text + /// ./ [A-Z] [a-z] [0-9] + /// 0x2e-0x2f, 0x41-0x5a, 0x61-0x7a, 0x30-0x39 + /// ``` + Bcrypt, + + /// `crypt(3)` encoding. + /// + /// ```text + /// [.-9] [A-Z] [a-z] + /// 0x2e-0x39, 0x41-0x5a, 0x61-0x7a + /// ``` + Crypt, + + /// `crypt(3)` Base64 encoding for the following schemes: + /// - sha1_crypt, + /// - sha256_crypt, + /// - sha512_crypt, + /// - md5_crypt + /// + /// ```text + /// [.-9] [A-Z] [a-z] + /// 0x2e-0x39, 0x41-0x5a, 0x61-0x7a + /// ``` + ShaCrypt, +} + +impl Base64 { + /// Decode a Base64 string into the provided destination buffer. + pub fn decode(self, src: impl AsRef<[u8]>, dst: &mut [u8]) -> Result<&[u8], B64Error> { + match self { + Self::Bcrypt => Base64Bcrypt::decode(src, dst), + Self::Crypt => Base64Crypt::decode(src, dst), + Self::ShaCrypt => Base64ShaCrypt::decode(src, dst), + } + } + + /// Decode a Base64 string into a byte vector. + #[cfg(feature = "alloc")] + pub fn decode_vec(self, input: &str) -> Result, B64Error> { + match self { + Self::Bcrypt => Base64Bcrypt::decode_vec(input), + Self::Crypt => Base64Crypt::decode_vec(input), + Self::ShaCrypt => Base64ShaCrypt::decode_vec(input), + } + } + + /// Encode the input byte slice as Base64. + /// + /// Writes the result into the provided destination slice, returning an + /// ASCII-encoded Base64 string value. + pub fn encode<'a>(self, src: &[u8], dst: &'a mut [u8]) -> Result<&'a str, B64Error> { + match self { + Self::Bcrypt => Base64Bcrypt::encode(src, dst), + Self::Crypt => Base64Crypt::encode(src, dst), + Self::ShaCrypt => Base64ShaCrypt::encode(src, dst), + } + .map_err(Into::into) + } + + /// Encode input byte slice into a [`String`] containing Base64. + /// + /// # Panics + /// If `input` length is greater than `usize::MAX/4`. + #[cfg(feature = "alloc")] + pub fn encode_string(self, input: &[u8]) -> String { + match self { + Self::Bcrypt => Base64Bcrypt::encode_string(input), + Self::Crypt => Base64Crypt::encode_string(input), + Self::ShaCrypt => Base64ShaCrypt::encode_string(input), + } + } + + /// Get the length of Base64 produced by encoding the given bytes. + pub fn encoded_len(self, bytes: &[u8]) -> usize { + match self { + Self::Bcrypt => Base64Bcrypt::encoded_len(bytes), + Self::Crypt => Base64Crypt::encoded_len(bytes), + Self::ShaCrypt => Base64ShaCrypt::encoded_len(bytes), + } + } +} diff --git a/mcf/src/error.rs b/mcf/src/error.rs new file mode 100644 index 000000000..37d9890e1 --- /dev/null +++ b/mcf/src/error.rs @@ -0,0 +1,25 @@ +//! Error types. + +use core::fmt; + +/// Result type for `mcf`. +pub type Result = core::result::Result; + +/// Error type. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct Error {} + +impl From for Error { + fn from(_: base64ct::Error) -> Error { + Error {} + } +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("modular crypt format error") + } +} diff --git a/mcf/src/fields.rs b/mcf/src/fields.rs index 452610765..74d6c1237 100644 --- a/mcf/src/fields.rs +++ b/mcf/src/fields.rs @@ -1,8 +1,11 @@ //! Fields of an MCF password hash, delimited by `$` -use crate::{Error, Result}; +use crate::{Base64, Error, Result}; use core::fmt; +#[cfg(feature = "alloc")] +use alloc::vec::Vec; + /// MCF field delimiter: `$`. pub const DELIMITER: char = '$'; @@ -60,12 +63,23 @@ impl<'a> Field<'a> { } /// Borrow the field's contents as a `str`. - pub fn as_str(&self) -> &'a str { + pub fn as_str(self) -> &'a str { self.0 } + /// Decode Base64 into the provided output buffer. + pub fn decode_base64_into(self, base64_variant: Base64, out: &mut [u8]) -> Result<&[u8]> { + Ok(base64_variant.decode(self.0, out)?) + } + + /// Decode this field as the provided Base64 variant. + #[cfg(feature = "alloc")] + pub fn decode_base64(self, base64_variant: Base64) -> Result> { + Ok(base64_variant.decode_vec(self.0)?) + } + /// Validate a field in the password hash is well-formed. - pub(crate) fn validate(&self) -> Result<()> { + pub(crate) fn validate(self) -> Result<()> { if self.0.is_empty() { return Err(Error {}); } diff --git a/mcf/src/lib.rs b/mcf/src/lib.rs index 9ad4c4110..88c3c5b2d 100644 --- a/mcf/src/lib.rs +++ b/mcf/src/lib.rs @@ -16,14 +16,19 @@ #[cfg(feature = "alloc")] extern crate alloc; +mod base64; +mod error; mod fields; +pub use base64::Base64; +pub use error::{Error, Result}; pub use fields::{Field, Fields}; -use core::fmt; - #[cfg(feature = "alloc")] -use {alloc::string::String, core::str}; +use { + alloc::string::String, + core::{fmt, str}, +}; /// Debug message used in panics when invariants aren't properly held. #[cfg(feature = "alloc")] @@ -155,19 +160,3 @@ fn validate_id(id: &str) -> Result<()> { Ok(()) } - -/// Result type for `mcf`. -pub type Result = core::result::Result; - -/// Error type. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub struct Error {} - -impl core::error::Error for Error {} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("modular crypt format error") - } -} diff --git a/mcf/tests/mcf.rs b/mcf/tests/mcf.rs index 1dcb60188..7b64a02fe 100644 --- a/mcf/tests/mcf.rs +++ b/mcf/tests/mcf.rs @@ -2,7 +2,8 @@ #![cfg(feature = "alloc")] -use mcf::McfHash; +use hex_literal::hex; +use mcf::{Base64, McfHash}; #[test] fn parse_malformed() { @@ -25,10 +26,26 @@ fn parse_sha512_hash() { let mut fields = hash.fields(); assert_eq!("rounds=100000", fields.next().unwrap().as_str()); - assert_eq!("exn6tVc2j/MZD8uG", fields.next().unwrap().as_str()); + + let salt = fields.next().unwrap(); + assert_eq!("exn6tVc2j/MZD8uG", salt.as_str()); + + let salt_bytes = salt.decode_base64(Base64::ShaCrypt).unwrap(); + assert_eq!(&hex!("6a3f237988126f80958fa24b"), salt_bytes.as_slice()); + + let hash = fields.next().unwrap(); assert_eq!( "BI1Xh8qQSK9J4m14uwy7abn.ctj/TIAzlaVCto0MQrOFIeTXsc1iwzH16XEWo/a7c7Y9eVJvufVzYAs4EsPOy0", - fields.next().unwrap().as_str() + hash.as_str() ); + + let hash_bytes = hash.decode_base64(Base64::ShaCrypt).unwrap(); + assert_eq!( + &hex!( + "0d358cad62739eb554863c183aef27e6390368fe061fc5fcb1193a392d60dcad4594fa8d383ab8fc3f0dc8088974602668422e6a58edfa1afe24831b10be69be" + ), + hash_bytes.as_slice() + ); + assert_eq!(None, fields.next()); }