commit 0c59fcc3d702ee648381ad152cd1adbd223546ff parent 9bedf27ccf36f6b24da16745986faed546d95ab2 Author: Antoine A <> Date: Thu, 30 Apr 2026 18:54:28 +0200 common: fix and improve base32 and add base64 & hex Diffstat:
21 files changed, 606 insertions(+), 199 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml @@ -48,7 +48,6 @@ http-client = { path = "common/http-client" } hyper = { version = "1.8.1", features = ["client", "http1", "http2"] } anyhow = "1" http-body-util = "0.1.2" -base64 = "0.22" owo-colors = "4.2.3" aws-lc-rs = "1.15" compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } diff --git a/common/http-client/Cargo.toml b/common/http-client/Cargo.toml @@ -21,7 +21,6 @@ taler-common.workspace = true compact_str.workspace = true url.workspace = true anyhow.workspace = true -base64.workspace = true hyper.workspace = true tokio.workspace = true hyper-util.workspace = true diff --git a/common/http-client/src/builder.rs b/common/http-client/src/builder.rs @@ -14,9 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::{borrow::Cow, fmt}; +use std::{ + borrow::Cow, + fmt::{self}, +}; -use base64::Engine; use http::{ HeaderMap, HeaderName, HeaderValue, StatusCode, header::{self}, @@ -24,6 +26,7 @@ use http::{ use http_body_util::{BodyDataStream, BodyExt, Full}; use hyper::{Method, body::Bytes}; use serde::{Serialize, de::DeserializeOwned}; +use taler_common::encoding::base64; use tracing::{Level, trace}; use url::Url; @@ -144,9 +147,7 @@ impl Req { U: fmt::Display, P: fmt::Display, { - let token = format!("{username}:{password}"); - let mut header = "Basic ".to_string(); - base64::engine::general_purpose::STANDARD.encode_string(token, &mut header); + let header = format!("Basic {}", base64::fmt(format!("{username}:{password}")),); self.sensitive_header(header::AUTHORIZATION, header) } diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -13,7 +13,6 @@ doctest = false [dependencies] listenfd = "1.0.0" dashmap = "6.1" -base64.workspace = true http-body-util.workspace = true zlib-rs = "0.6.3" tokio = { workspace = true, features = ["signal"] } diff --git a/common/taler-api/src/config.rs b/common/taler-api/src/config.rs @@ -16,10 +16,10 @@ use std::net::{IpAddr, SocketAddr}; -use base64::{Engine, prelude::BASE64_STANDARD}; use sqlx::postgres::PgConnectOptions; use taler_common::{ config::{Section, ValueErr}, + encoding::base64, map_config, }; @@ -52,7 +52,7 @@ impl AuthCfg { AuthCfg::None => AuthMethod::None, AuthCfg::Bearer(token) => AuthMethod::Bearer(token.clone()), AuthCfg::Basic { username, password } => { - AuthMethod::Basic(BASE64_STANDARD.encode(format!("{username}:{password}"))) + AuthMethod::Basic(base64::encode(format!("{username}:{password}").as_bytes())) } } } diff --git a/common/taler-api/src/subject.rs b/common/taler-api/src/subject.rs @@ -24,10 +24,8 @@ use compact_str::CompactString; use taler_common::{ api_common::{EddsaPublicKey, ShortHashCode}, db::IncomingType, - types::{ - base32::{Base32Error, CROCKFORD_ALPHABET}, - url, - }, + encoding::base32::{Base32Error, CROCKFORD_ALPHABET}, + types::url, }; use url::Url; diff --git a/common/taler-common/benches/base32.rs b/common/taler-common/benches/base32.rs @@ -16,7 +16,10 @@ use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; use rand::RngExt as _; -use taler_common::types::base32::{Base32, decode_static, encode_static}; +use taler_common::{ + encoding::base32::{decode_static, encode_static}, + types::base32::Base32, +}; fn parser(c: &mut Criterion) { let mut buf = [0u8; 255]; diff --git a/common/taler-common/src/api_common.rs b/common/taler-common/src/api_common.rs @@ -23,7 +23,7 @@ use aws_lc_rs::{ use serde::{Deserialize, Deserializer, Serialize, de::Error}; use serde_json::value::RawValue; -use crate::types::base32::{Base32, Base32Error}; +use crate::{encoding::base32::Base32Error, types::base32::Base32}; /// <https://docs.taler.net/core/api-common.html#tsref-type-ErrorDetail> #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/common/taler-common/src/encoding.rs b/common/taler-common/src/encoding.rs @@ -0,0 +1,19 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +pub mod base32; +pub mod base64; +pub mod hex; diff --git a/common/taler-common/src/encoding/base32.rs b/common/taler-common/src/encoding/base32.rs @@ -0,0 +1,247 @@ +/* + This file is part of TALER + Copyright (C) 2024, 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Display; + +pub const CROCKFORD_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** Encoded bytes len of Crockford's base32 */ +#[inline] +const fn encoded_len(len: usize) -> usize { + (len * 8).div_ceil(5) +} + +/** Buffer bytes len of Crockford's base32 using a batch of 8 chars */ +#[inline] +pub(crate) const fn encoded_buf_len(len: usize) -> usize { + (len / 5 + 1) * 8 +} + +/** Encode bytes using Crockford's base32 */ +pub fn encode_static<'a, const N: usize>(bytes: &[u8; N], out: &'a mut [u8]) -> &'a str { + // Batch encoded + encode_batch(bytes, out); + + // Truncate incomplete ending chunk + let truncated = &out[..encoded_len(bytes.len())]; + + // SAFETY: only contains valid ASCII characters from CROCKFORD_ALPHABET + unsafe { std::str::from_utf8_unchecked(truncated) } +} + +/** Encode bytes using Crockford's base32 */ +pub fn encode(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut buf = vec![0u8; encoded_buf_len(bytes.len())]; + // Batch encoded + encode_batch(bytes, &mut buf); + + // Truncate incomplete ending chunk + buf.truncate(encoded_len(bytes.len())); + + // SAFETY: only contains valid ASCII characters from CROCKFORD_ALPHABET + unsafe { std::string::String::from_utf8_unchecked(buf) } +} + +/** Format bytes using Crockford's base32 */ +pub fn fmt(bytes: impl AsRef<[u8]>) -> impl Display { + std::fmt::from_fn(move |f| { + for chunk in bytes.as_ref().chunks(5) { + let mut out_buf = [0u8; 8]; + encode_chunk(chunk, &mut out_buf); + + let n = encoded_len(chunk.len()); + // SAFETY: encode_chunk populates out_buf using CROCKFORD_ALPHABET, + // which consists of valid ASCII characters. + let s = unsafe { std::str::from_utf8_unchecked(&out_buf[..n]) }; + f.write_str(s)?; + } + Ok(()) + }) +} + +/** Encode a chunk using Crockford's base32 */ +#[inline(always)] +pub(crate) fn encode_chunk(chunk: &[u8], encoded: &mut [u8]) { + let mut buf = [0u8; 5]; + for (i, &b) in chunk.iter().enumerate() { + buf[i] = b; + } + encoded[0] = CROCKFORD_ALPHABET[((buf[0] & 0xF8) >> 3) as usize]; + encoded[1] = CROCKFORD_ALPHABET[(((buf[0] & 0x07) << 2) | ((buf[1] & 0xC0) >> 6)) as usize]; + encoded[2] = CROCKFORD_ALPHABET[((buf[1] & 0x3E) >> 1) as usize]; + encoded[3] = CROCKFORD_ALPHABET[(((buf[1] & 0x01) << 4) | ((buf[2] & 0xF0) >> 4)) as usize]; + encoded[4] = CROCKFORD_ALPHABET[(((buf[2] & 0x0F) << 1) | (buf[3] >> 7)) as usize]; + encoded[5] = CROCKFORD_ALPHABET[((buf[3] & 0x7C) >> 2) as usize]; + encoded[6] = CROCKFORD_ALPHABET[(((buf[3] & 0x03) << 3) | ((buf[4] & 0xE0) >> 5)) as usize]; + encoded[7] = CROCKFORD_ALPHABET[(buf[4] & 0x1F) as usize]; +} + +/** Batch encode bytes using Crockford's base32 */ +#[inline] +fn encode_batch(bytes: &[u8], encoded: &mut [u8]) { + // Check buffer len + assert!(encoded.len() >= encoded_buf_len(bytes.len())); + + // Encode chunks of 5B for 8 chars + for (chunk, encoded) in bytes.chunks(5).zip(encoded.chunks_exact_mut(8)) { + encode_chunk(chunk, encoded); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Base32Error<const N: usize> { + #[error("invalid Crockford's base32 format")] + Format, + #[error("invalid length expected {N} bytes got {0}")] + Length(usize), +} + +/** Crockford's base32 inverse table, case insentitive and with substitution */ +const CROCKFORD_INV: [u8; 256] = { + let mut table = [255; 256]; + + // Fill the canonical alphabet + let mut i = 0; + while i < CROCKFORD_ALPHABET.len() { + let b = CROCKFORD_ALPHABET[i]; + table[b as usize] = i as u8; + i += 1; + } + + // Add substitution + table[b'O' as usize] = table[b'0' as usize]; + table[b'I' as usize] = table[b'1' as usize]; + table[b'L' as usize] = table[b'1' as usize]; + table[b'U' as usize] = table[b'V' as usize]; + + // Make the table case insensitive + let mut i = 0; + while i < CROCKFORD_ALPHABET.len() { + let b = CROCKFORD_ALPHABET[i]; + table[b.to_ascii_lowercase() as usize] = table[b as usize]; + i += 1; + } + + // Add substitution + table[b'o' as usize] = table[b'0' as usize]; + table[b'i' as usize] = table[b'1' as usize]; + table[b'l' as usize] = table[b'1' as usize]; + table[b'u' as usize] = table[b'v' as usize]; + + table +}; + +/** Decoded bytes len of Crockford's base32 */ +#[inline] +const fn decoded_len(len: usize) -> usize { + len * 5 / 8 +} + +/** Buffer bytes len of Crockford's base32 using a batch of 5 bytes */ +#[inline] +pub(crate) const fn decoded_buf_len(len: usize) -> usize { + (len / 8 + 1) * 5 +} + +/** Decode N bytes from a Crockford's base32 string */ +pub fn decode_static<const N: usize>(encoded: &[u8]) -> Result<[u8; N], Base32Error<N>> { + // Check decode length + let output_length = decoded_len(encoded.len()); + if output_length != N { + return Err(Base32Error::Length(output_length)); + } + + let mut decoded = vec![0u8; decoded_buf_len(encoded.len())]; // TODO use a stack allocated buffer when supported + + if !decode_batch(encoded, &mut decoded) { + return Err(Base32Error::Format); + } + Ok(decoded[..N].try_into().unwrap()) +} + +/** Decode bytes from a Crockford's base32 string */ +pub fn decode(encoded: impl AsRef<[u8]>) -> Result<Vec<u8>, Base32Error<0>> { + let encoded = encoded.as_ref(); + let mut decoded = vec![0u8; decoded_buf_len(encoded.len())]; + + if !decode_batch(encoded, &mut decoded) { + return Err(Base32Error::Format); + } + decoded.truncate(decoded_len(encoded.len())); + Ok(decoded) +} + +/** Batch decode bytes using Crockford's base32 */ +#[inline] +fn decode_batch(encoded: &[u8], decoded: &mut [u8]) -> bool { + let mut invalid = false; + + // Encode chunks of 8 chars for 5B + for (chunk, decoded) in encoded.chunks(8).zip(decoded.chunks_exact_mut(5)) { + let mut buf = [0; 8]; + + // Lookup chunk + for (i, &b) in chunk.iter().enumerate() { + buf[i] = CROCKFORD_INV[b as usize]; + } + + // Check chunk validity + invalid |= buf.contains(&255); + + // Decode chunk + decoded[0] = (buf[0] << 3) | (buf[1] >> 2); + decoded[1] = (buf[1] << 6) | (buf[2] << 1) | (buf[3] >> 4); + decoded[2] = (buf[3] << 4) | (buf[4] >> 1); + decoded[3] = (buf[4] << 7) | (buf[5] << 2) | (buf[6] >> 3); + decoded[4] = (buf[6] << 5) | buf[7]; + } + + !invalid +} + +#[cfg(test)] +mod test { + use crate::encoding::base32::{Base32Error, decode, encode, fmt}; + + #[test] + fn base32() { + // RFC test vectors + for (decoded, encoded) in [ + ("", ""), + ("f", "CR"), + ("fo", "CSQG"), + ("foo", "CSQPY"), + ("foob", "CSQPYRG"), + ("fooba", "CSQPYRK1"), + ("foobar", "CSQPYRK1E8"), + ] { + assert_eq!(encode(decoded.as_bytes()), encoded); + assert_eq!(fmt(decoded).to_string(), encoded); + assert_eq!(decode(encoded.as_bytes()).unwrap(), decoded.as_bytes()); + } + + // Crockford allows case-insensitive decoding + assert_eq!(decode(b"oilu").unwrap(), decode(b"OILU").unwrap()); + + // Crockford remaps ambiguous characters on decode + assert_eq!(decode(b"OILU").unwrap(), decode(b"011V").unwrap()); + + // Invalid characters + assert_eq!(decode(b"C!"), Err(Base32Error::Format)); + assert_eq!(decode(b"C\x00R"), Err(Base32Error::Format)); + } +} diff --git a/common/taler-common/src/encoding/base64.rs b/common/taler-common/src/encoding/base64.rs @@ -0,0 +1,176 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Display; + +pub const BASE64_ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Encoded bytes len of base64 with padding */ +#[inline] +const fn encoded_len(len: usize) -> usize { + (len * 4).div_ceil(3).next_multiple_of(4) +} + +/** Encode a chunk using base64 */ +#[inline(always)] +fn encode_chunk(chunk: &[u8], encoded: &mut [u8]) { + let mut buf = [0u8; 3]; + for (i, &b) in chunk.iter().enumerate() { + buf[i] = b; + } + encoded[0] = BASE64_ALPHABET[((buf[0] & 0xFC) >> 2) as usize]; + encoded[1] = BASE64_ALPHABET[(((buf[0] & 0x03) << 4) | ((buf[1] & 0xF0) >> 4)) as usize]; + if chunk.len() > 1 { + encoded[2] = BASE64_ALPHABET[(((buf[1] & 0x0F) << 2) | ((buf[2] & 0xC0) >> 6)) as usize]; + } + if chunk.len() > 2 { + encoded[3] = BASE64_ALPHABET[(buf[2] & 0x3F) as usize]; + } +} + +/** Encode bytes using standard base64 with `=` padding */ +pub fn encode(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut buf = vec![b'='; encoded_len(bytes.len())]; + + for (chunk, buf) in bytes.chunks(3).zip(buf.chunks_exact_mut(4)) { + encode_chunk(chunk, buf) + } + + // SAFETY: only contains ASCII characters from BASE64_ALPHABET or b'=' + unsafe { String::from_utf8_unchecked(buf) } +} + +/** Format bytes using standard base64 with `=` padding */ +pub fn fmt(bytes: impl AsRef<[u8]>) -> impl Display { + std::fmt::from_fn(move |f| { + for chunk in bytes.as_ref().chunks(3) { + let mut tmp = [0u8; 3]; + for (i, &b) in chunk.iter().enumerate() { + tmp[i] = b; + } + + let mut out = [b'='; 4]; + out[0] = BASE64_ALPHABET[((tmp[0] & 0xFC) >> 2) as usize]; + out[1] = BASE64_ALPHABET[(((tmp[0] & 0x03) << 4) | ((tmp[1] & 0xF0) >> 4)) as usize]; + if chunk.len() > 1 { + out[2] = + BASE64_ALPHABET[(((tmp[1] & 0x0F) << 2) | ((tmp[2] & 0xC0) >> 6)) as usize]; + } + if chunk.len() > 2 { + out[3] = BASE64_ALPHABET[(tmp[2] & 0x3F) as usize]; + } + + // SAFETY: out contains only ASCII characters from BASE64_ALPHABET or b'=' + f.write_str(unsafe { std::str::from_utf8_unchecked(&out) })?; + } + Ok(()) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Base64Error { + #[error("invalid base64 format")] + Format, + #[error("invalid length: base64 input must be a multiple of 4")] + Length, +} + +const BASE64_INV: [u8; 256] = { + let mut table = [255u8; 256]; + let mut i = 0; + while i < 64 { + table[BASE64_ALPHABET[i] as usize] = i as u8; + i += 1; + } + table +}; + +/** Unpadded decoded length from a padded base64 string */ +fn decoded_len(encoded: &[u8]) -> usize { + let padding = encoded.iter().rev().take_while(|&&b| b == b'=').count(); + encoded.len() * 3 / 4 - padding +} + +/** Decode a standard base64 string (with `=` padding) */ +pub fn decode(encoded: impl AsRef<[u8]>) -> Result<Vec<u8>, Base64Error> { + let encoded = encoded.as_ref(); + if encoded.len() % 4 != 0 { + return Err(Base64Error::Length); + } + + let out_len = decoded_len(encoded); + let mut decoded = Vec::with_capacity(out_len); + let mut invalid = false; + + for chunk in encoded.chunks(4) { + let mut buf = [0u8; 4]; + // Lookup chunk + for (i, &b) in chunk.iter().enumerate() { + buf[i] = if b == b'=' { 0 } else { BASE64_INV[b as usize] }; + } + + // Check chunk validity + invalid |= buf.contains(&255); + + // Decode chunk + decoded.push((buf[0] << 2) | (buf[1] >> 4)); + if chunk[2] != b'=' { + decoded.push((buf[1] << 4) | (buf[2] >> 2)); + } + if chunk[3] != b'=' { + decoded.push((buf[2] << 6) | buf[3]); + } + } + + if invalid { + return Err(Base64Error::Format); + } + + Ok(decoded) +} + +#[cfg(test)] +mod test { + use crate::encoding::base64::{Base64Error, decode, encode, fmt}; + + #[test] + fn base64() { + // RFC test vectors + for (decoded, encoded) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("fooba", "Zm9vYmE="), + ("foobar", "Zm9vYmFy"), + ] { + assert_eq!(encode(decoded.as_bytes()), encoded); + assert_eq!(fmt(decoded).to_string(), encoded); + assert_eq!(decode(encoded.as_bytes()).unwrap(), decoded.as_bytes()); + } + + // Invalid length + assert_eq!(decode(b"Zg="), Err(Base64Error::Length)); + assert_eq!(decode(b"Z"), Err(Base64Error::Length)); + + // Invalid characters + assert_eq!(decode(b"Zg=!"), Err(Base64Error::Format)); + assert_eq!(decode(b"Z\x00=="), Err(Base64Error::Format)); + } +} diff --git a/common/taler-common/src/encoding/hex.rs b/common/taler-common/src/encoding/hex.rs @@ -0,0 +1,133 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Display; + +pub const HEX_ALPHABET: &[u8] = b"0123456789abcdef"; + +/** Encode a single byte to two hex characters */ +#[inline(always)] +fn encode_byte(byte: u8, encoded: &mut [u8]) { + encoded[0] = HEX_ALPHABET[(byte >> 4) as usize]; + encoded[1] = HEX_ALPHABET[(byte & 0x0F) as usize]; +} + +/** Encode bytes using lowercase hexadecimal */ +pub fn encode(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut buf = vec![0u8; bytes.len() * 2]; + + for (chunk, buf) in bytes.iter().zip(buf.chunks_exact_mut(2)) { + encode_byte(*chunk, buf); + } + + // SAFETY: only contains ASCII characters from HEX_ALPHABET + unsafe { String::from_utf8_unchecked(buf) } +} + +/** Format bytes using lowercase hexadecimal */ +pub fn fmt(bytes: impl AsRef<[u8]>) -> impl Display { + std::fmt::from_fn(move |f| { + for &byte in bytes.as_ref() { + let mut out = [0u8; 2]; + encode_byte(byte, &mut out); + // SAFETY: out contains only ASCII characters from HEX_ALPHABET + f.write_str(unsafe { std::str::from_utf8_unchecked(&out) })?; + } + Ok(()) + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum HexError { + #[error("invalid hex format")] + Format, + #[error("invalid length: hex input must be a multiple of 2")] + Length, +} + +const HEX_INV: [u8; 256] = { + let mut table = [255u8; 256]; + let mut i = 0; + while i < 10 { + table[(b'0' + i) as usize] = i; + i += 1; + } + i = 0; + while i < 6 { + table[(b'a' + i) as usize] = 10 + i; + table[(b'A' + i) as usize] = 10 + i; + i += 1; + } + table +}; + +/** Decode a hexadecimal string */ +pub fn decode(encoded: impl AsRef<[u8]>) -> Result<Vec<u8>, HexError> { + let encoded = encoded.as_ref(); + if encoded.len() % 2 != 0 { + return Err(HexError::Length); + } + + let mut decoded = Vec::with_capacity(encoded.len() / 2); + let mut invalid = false; + + for chunk in encoded.chunks_exact(2) { + let hi = HEX_INV[chunk[0] as usize]; + let lo = HEX_INV[chunk[1] as usize]; + + invalid |= hi == 255 || lo == 255; + + decoded.push((hi << 4) | lo); + } + + if invalid { + return Err(HexError::Format); + } + + Ok(decoded) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn hex() { + for (decoded, encoded) in [ + ("", ""), + ("f", "66"), + ("fo", "666f"), + ("foobar", "666f6f626172"), + ("kiwi", "6b697769"), + ("Hello, world!", "48656c6c6f2c20776f726c6421"), + ] { + assert_eq!(encode(decoded.as_bytes()), encoded); + assert_eq!(fmt(decoded).to_string(), encoded); + assert_eq!(decode(encoded.as_bytes()).unwrap(), decoded.as_bytes()); + } + + // Case insensitivity + assert_eq!(decode("48656C6C6F").unwrap(), b"Hello"); + + // Invalid length + assert_eq!(decode("666"), Err(HexError::Length)); + + // Invalid characters + assert_eq!(decode("6g"), Err(HexError::Format)); + assert_eq!(decode("6\x00"), Err(HexError::Format)); + } +} diff --git a/common/taler-common/src/lib.rs b/common/taler-common/src/lib.rs @@ -31,6 +31,7 @@ pub mod bench; pub mod cli; pub mod config; pub mod db; +pub mod encoding; pub mod error; pub mod error_code; pub mod json_file; diff --git a/common/taler-common/src/types/base32.rs b/common/taler-common/src/types/base32.rs @@ -18,170 +18,7 @@ use std::{borrow::Cow, fmt::Display, ops::Deref, str::FromStr}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; -pub const CROCKFORD_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -/** Encoded bytes len of Crockford's base32 */ -#[inline] -const fn encoded_len(len: usize) -> usize { - (len * 8).div_ceil(5) -} - -/** Buffer bytes len of Crockford's base32 using a batch of 8 chars */ -#[inline] -const fn encoded_buf_len(len: usize) -> usize { - (len / 5 + 1) * 8 -} - -/** Encode bytes using Crockford's base32 */ -pub fn encode_static<'a, const N: usize>(bytes: &[u8; N], out: &'a mut [u8]) -> &'a str { - // Batch encoded - encode_batch(bytes, out); - - // Truncate incomplete ending chunk - let truncated = &out[..encoded_len(bytes.len())]; - - // SAFETY: only contains valid ASCII characters from CROCKFORD_ALPHABET - unsafe { std::str::from_utf8_unchecked(truncated) } -} - -/** Encode bytes using Crockford's base32 */ -pub fn encode(bytes: &[u8]) -> String { - let mut buf = vec![0u8; encoded_buf_len(bytes.len())]; - // Batch encoded - encode_batch(bytes, &mut buf); - - // Truncate incomplete ending chunk - buf.truncate(encoded_len(bytes.len())); - - // SAFETY: only contains valid ASCII characters from CROCKFORD_ALPHABET - unsafe { std::string::String::from_utf8_unchecked(buf) } -} - -/** Batch encode bytes using Crockford's base32 */ -#[inline] -fn encode_batch(bytes: &[u8], encoded: &mut [u8]) { - // Check buffer len - assert!(encoded.len() >= encoded_buf_len(bytes.len())); - - // Encode chunks of 5B for 8 chars - for (chunk, encoded) in bytes.chunks(5).zip(encoded.chunks_exact_mut(8)) { - let mut buf = [0u8; 5]; - for (i, &b) in chunk.iter().enumerate() { - buf[i] = b; - } - encoded[0] = CROCKFORD_ALPHABET[((buf[0] & 0xF8) >> 3) as usize]; - encoded[1] = CROCKFORD_ALPHABET[(((buf[0] & 0x07) << 2) | ((buf[1] & 0xC0) >> 6)) as usize]; - encoded[2] = CROCKFORD_ALPHABET[((buf[1] & 0x3E) >> 1) as usize]; - encoded[3] = CROCKFORD_ALPHABET[(((buf[1] & 0x01) << 4) | ((buf[2] & 0xF0) >> 4)) as usize]; - encoded[4] = CROCKFORD_ALPHABET[(((buf[2] & 0x0F) << 1) | (buf[3] >> 7)) as usize]; - encoded[5] = CROCKFORD_ALPHABET[((buf[3] & 0x7C) >> 2) as usize]; - encoded[6] = CROCKFORD_ALPHABET[(((buf[3] & 0x03) << 3) | ((buf[4] & 0xE0) >> 5)) as usize]; - encoded[7] = CROCKFORD_ALPHABET[(buf[4] & 0x1F) as usize]; - } -} - -#[derive(Debug, thiserror::Error)] -pub enum Base32Error<const N: usize> { - #[error("invalid Crockford's base32 format")] - Format, - #[error("invalid length expected {N} bytes got {0}")] - Length(usize), -} - -/** Crockford's base32 inverse table, case insentitive and with substitution */ -const CROCKFORD_INV: [u8; 256] = { - let mut table = [255; 256]; - - // Fill the canonical alphabet - let mut i = 0; - while i < CROCKFORD_ALPHABET.len() { - let b = CROCKFORD_ALPHABET[i]; - table[b as usize] = i as u8; - i += 1; - } - - // Add substitution - table[b'O' as usize] = table[b'0' as usize]; - table[b'I' as usize] = table[b'1' as usize]; - table[b'L' as usize] = table[b'1' as usize]; - table[b'U' as usize] = table[b'V' as usize]; - - // Make the table case insensitive - let mut i = 0; - while i < CROCKFORD_ALPHABET.len() { - let b = CROCKFORD_ALPHABET[i]; - table[b.to_ascii_lowercase() as usize] = table[b as usize]; - i += 1; - } - - table -}; - -/** Decoded bytes len of Crockford's base32 */ -#[inline] -const fn decoded_len(len: usize) -> usize { - len * 5 / 8 -} - -/** Buffer bytes len of Crockford's base32 using a batch of 5 bytes */ -#[inline] -const fn decoded_buf_len(len: usize) -> usize { - (len / 8 + 1) * 5 -} - -/** Decode N bytes from a Crockford's base32 string */ -pub fn decode_static<const N: usize>(encoded: &[u8]) -> Result<[u8; N], Base32Error<N>> { - // Check decode length - let output_length = decoded_len(encoded.len()); - if output_length != N { - return Err(Base32Error::Length(output_length)); - } - - let mut decoded = vec![0u8; decoded_buf_len(encoded.len())]; // TODO use a stack allocated buffer when supported - - if !decode_batch(encoded, &mut decoded) { - return Err(Base32Error::Format); - } - Ok(decoded[..N].try_into().unwrap()) -} - -/** Decode bytes from a Crockford's base32 string */ -pub fn decode(encoded: &[u8]) -> Result<Vec<u8>, Base32Error<0>> { - let mut decoded = vec![0u8; decoded_buf_len(encoded.len())]; - - if !decode_batch(encoded, &mut decoded) { - return Err(Base32Error::Format); - } - Ok(decoded) -} - -/** Batch decode bytes using Crockford's base32 */ -#[inline] -fn decode_batch(encoded: &[u8], decoded: &mut [u8]) -> bool { - let mut invalid = false; - - // Encode chunks of 8 chars for 5B - for (chunk, decoded) in encoded.chunks(8).zip(decoded.chunks_exact_mut(5)) { - let mut buf = [0; 8]; - - // Lookup chunk - for (i, &b) in chunk.iter().enumerate() { - buf[i] = CROCKFORD_INV[b as usize]; - } - - // Check chunk validity - invalid |= buf.contains(&255); - - // Decode chunk - decoded[0] = (buf[0] << 3) | (buf[1] >> 2); - decoded[1] = (buf[1] << 6) | (buf[2] << 1) | (buf[3] >> 4); - decoded[2] = (buf[3] << 4) | (buf[4] >> 1); - decoded[3] = (buf[4] << 7) | (buf[5] << 2) | (buf[6] >> 3); - decoded[4] = (buf[6] << 5) | buf[7]; - } - - !invalid -} +use crate::encoding::base32::{Base32Error, decode_static, encode_static, encoded_buf_len}; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Base32<const L: usize>([u8; L]); diff --git a/common/taler-test-utils/Cargo.toml b/common/taler-test-utils/Cargo.toml @@ -27,5 +27,4 @@ sqlx.workspace = true http-body-util.workspace = true url.workspace = true aws-lc-rs.workspace = true -jiff.workspace = true -base64.workspace = true -\ No newline at end of file +jiff.workspace = true +\ No newline at end of file diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -25,11 +25,10 @@ use axum::{ header::{self, AUTHORIZATION, AsHeaderName, IntoHeaderName}, }, }; -use base64::{Engine as _, prelude::BASE64_STANDARD}; use flate2::{Compression, write::ZlibEncoder}; use http_body_util::BodyExt as _; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use taler_common::{api_common::ErrorDetail, error_code::ErrorCode}; +use taler_common::{api_common::ErrorDetail, encoding::base64, error_code::ErrorCode}; use tower::ServiceExt as _; use tracing::warn; use url::Url; @@ -124,7 +123,7 @@ impl TestRequest { AUTHORIZATION, format!( "Basic {}", - BASE64_STANDARD.encode(format!("{username}:{password}")) + base64::fmt(format!("{username}:{password}").as_bytes()) ), ) } diff --git a/taler-apns-relay/Cargo.toml b/taler-apns-relay/Cargo.toml @@ -30,7 +30,6 @@ rustls.workspace = true jiff.workspace = true tokio.workspace = true aws-lc-rs.workspace = true -base64.workspace = true compact_str.workspace = true tracing.workspace = true thiserror.workspace = true diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -21,7 +21,6 @@ use aws_lc_rs::{ rand::SystemRandom, signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, }; -use base64::{Engine as _, prelude::BASE64_STANDARD}; use compact_str::CompactString; use http::{StatusCode, header::CONTENT_TYPE}; use http_body_util::{BodyExt, Full}; @@ -31,7 +30,7 @@ use hyper_util::rt::{TokioExecutor, TokioTimer}; use jiff::{SignedDuration, Timestamp}; use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; use serde::Deserialize; -use taler_common::error::FmtSource; +use taler_common::{encoding::base64, error::FmtSource}; use taler_macros::EnumMeta; use crate::config::ApnsConfig; @@ -345,11 +344,11 @@ impl Client { let payload = format!(r#"{{"iss":"{team_id}","iat":{}}}"#, issued_at.as_second()); let token = format!( "{}.{}", - BASE64_STANDARD.encode(headers), - BASE64_STANDARD.encode(payload) + base64::fmt(headers.as_bytes()), + base64::fmt(payload.as_bytes()) ); let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; - Ok(format!("Bearer {}.{}", token, BASE64_STANDARD.encode(signature)).into_boxed_str()) + Ok(format!("Bearer {}.{}", token, base64::fmt(signature.as_ref())).into_boxed_str()) } } diff --git a/taler-magnet-bank/Cargo.toml b/taler-magnet-bank/Cargo.toml @@ -32,7 +32,6 @@ thiserror.workspace = true tracing.workspace = true tokio.workspace = true anyhow.workspace = true -base64.workspace = true owo-colors.workspace = true failure-injection.workspace = true hyper.workspace = true diff --git a/taler-magnet-bank/src/magnet_api/client.rs b/taler-magnet-bank/src/magnet_api/client.rs @@ -21,10 +21,10 @@ use aws_lc_rs::{ rand::SystemRandom, signature::{EcdsaKeyPair, KeyPair as _}, }; -use base64::{Engine as _, prelude::BASE64_STANDARD}; use hyper::Method; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use taler_common::encoding::base64; use crate::magnet_api::{ api::{ApiResult, MagnetRequest}, @@ -162,7 +162,7 @@ impl ApiClient<'_> { let der = pub_key.as_der().unwrap(); // TODO error self.request(Method::POST, "/RESTApi/resources/v2/token/public-key") .json(&json!({ - "keyData": BASE64_STANDARD.encode(der.as_ref()) + "keyData": base64::encode(der.as_ref()) })) .parse_json() .await @@ -298,7 +298,7 @@ impl ApiClient<'_> { let signature = signing_key .sign(&SystemRandom::new(), content.as_bytes()) .unwrap(); - let encoded = BASE64_STANDARD.encode(signature.as_ref()); + let encoded = base64::encode(signature.as_ref()); Ok(self .request(Method::PUT, "/RESTApi/resources/v2/tranzakcio/alairas") .json(&Req { diff --git a/taler-magnet-bank/src/magnet_api/oauth.rs b/taler-magnet-bank/src/magnet_api/oauth.rs @@ -17,11 +17,11 @@ use std::{borrow::Cow, time::SystemTime}; use aws_lc_rs::hmac; -use base64::{Engine as _, prelude::BASE64_STANDARD}; use http_client::builder::Req; use hyper::header; use percent_encoding::NON_ALPHANUMERIC; use serde::{Deserialize, Serialize}; +use taler_common::encoding::base64; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Token { @@ -43,7 +43,7 @@ fn oauth_nonce() -> String { let mut buf = [0u8; 8]; getrandom::fill(&mut buf).expect("Failed to get random bytes from OS"); // Encode as base64 string - BASE64_STANDARD.encode(buf) + base64::encode(buf) } /** Generate an OAuth timestamp */ @@ -118,7 +118,7 @@ fn oauth_header( }; let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes()); let signature = hmac::sign(&key, base_string.as_bytes()); - let signature_encoded = BASE64_STANDARD.encode(signature.as_ref()); + let signature_encoded = base64::encode(signature.as_ref()); // Authorization header {