#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![doc = include_str!("../README.md")]
use blake2::Blake2s256;
use digest::Digest;
use std::fmt;
use std::str::FromStr;
#[cfg(feature = "hmac")]
use hmac::{Mac, SimpleHmac};
#[cfg(feature = "salt-iter")]
use rand::{rngs::OsRng, CryptoRng, RngCore};
#[cfg(feature = "zeroize")]
use zeroize::Zeroize;
const CRC32_HEX_LEN: usize = 8;
const BASELINE_BLAKE_HEX_LEN: usize = 8;
const SALT_ITER_TAG_HEX_LEN: usize = 8;
const MAX_BLAKE_HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scheme {
Baseline,
#[cfg(feature = "hmac")]
Hmac {
key: Vec<u8>,
tag_len: usize,
},
#[cfg(feature = "salt-iter")]
SaltIter {
iters: usize,
salt_len: usize,
},
}
#[cfg(feature = "zeroize")]
impl Zeroize for Scheme {
fn zeroize(&mut self) {
match self {
Scheme::Baseline => {}
#[cfg(feature = "hmac")]
Scheme::Hmac { key, .. } => key.zeroize(),
#[cfg(feature = "salt-iter")]
Scheme::SaltIter { .. } => {}
}
}
}
#[cfg(feature = "zeroize")]
impl Drop for Scheme {
fn drop(&mut self) {
self.zeroize();
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Token(pub String);
impl Token {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl AsRef<str> for Token {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<Token> for String {
fn from(value: Token) -> Self {
value.0
}
}
impl FromStr for Token {
type Err = RRIDError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
Err(RRIDError::InvalidFormat)
} else {
Ok(Token(s.to_owned()))
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RRIDError {
#[error("failed to parse token")]
ParseError(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("invalid token format")]
InvalidFormat,
#[error("invalid reversed decimal component")]
InvalidRev,
#[error("invalid tag")]
InvalidTag,
#[error("cryptographic failure: {0}")]
CryptoError(&'static str),
#[error("invalid user id")]
InvalidUserId,
}
impl RRIDError {
fn parse_err<E>(err: E) -> Self
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
RRIDError::ParseError(err.into())
}
}
impl From<std::num::ParseIntError> for RRIDError {
fn from(err: std::num::ParseIntError) -> Self {
RRIDError::parse_err(err)
}
}
impl From<hex::FromHexError> for RRIDError {
fn from(err: hex::FromHexError) -> Self {
RRIDError::parse_err(err)
}
}
#[cfg(feature = "hmac")]
impl From<hmac::digest::InvalidLength> for RRIDError {
fn from(err: hmac::digest::InvalidLength) -> Self {
RRIDError::parse_err(err)
}
}
pub fn rev_decimal_of_u128(n2: &str) -> String {
n2.chars().rev().collect()
}
pub fn payload_bytes_from_n2_and_rev(n2: &str, rev: &str) -> Vec<u8> {
format!("{n2}|{rev}").into_bytes()
}
fn ensure_user_id(user_id: u64) -> Result<u64, RRIDError> {
if user_id == 0 {
Err(RRIDError::InvalidUserId)
} else {
Ok(user_id)
}
}
fn compute_n2_rev(user_id: u64) -> Result<(String, String), RRIDError> {
let uid = ensure_user_id(user_id)? as u128;
let n2 = (uid * uid).to_string();
let rev = rev_decimal_of_u128(&n2);
Ok((n2, rev))
}
fn ensure_rev_consistency(n2: &str, rev: &str) -> Result<(), RRIDError> {
if !n2.chars().all(|c| c.is_ascii_digit()) || n2.is_empty() {
return Err(RRIDError::InvalidFormat);
}
if rev_decimal_of_u128(n2) != rev {
return Err(RRIDError::InvalidRev);
}
Ok(())
}
fn compute_crc32(payload: &[u8]) -> String {
let mut hasher = crc32fast::Hasher::new();
hasher.update(payload);
format!("{:0width$x}", hasher.finalize(), width = CRC32_HEX_LEN)
}
fn truncate_hex(mut hex: String, len: usize) -> Result<String, RRIDError> {
if len == 0 || len > hex.len() {
return Err(RRIDError::InvalidTag);
}
hex.truncate(len);
Ok(hex)
}
fn validate_tag_len(tag_len: usize, min: usize) -> Result<(), RRIDError> {
if tag_len < min || tag_len > MAX_BLAKE_HEX_LEN || tag_len % 2 != 0 {
return Err(RRIDError::InvalidTag);
}
Ok(())
}
pub fn make_token_baseline(user_id: u64) -> Result<Token, RRIDError> {
let (n2, rev) = compute_n2_rev(user_id)?;
let payload = payload_bytes_from_n2_and_rev(&n2, &rev);
let crc = compute_crc32(&payload);
let blake_hex = hex::encode(Blake2s256::digest(&payload));
let tag = truncate_hex(blake_hex, BASELINE_BLAKE_HEX_LEN)?;
Ok(Token(format!("{n2}-{rev}-{crc}-{tag}")))
}
pub fn validate_token_baseline(token: &str) -> bool {
parse_baseline(token).is_ok()
}
fn parse_baseline(token: &str) -> Result<(), RRIDError> {
let segments: Vec<&str> = token.split('-').collect();
if segments.len() != 4 {
return Err(RRIDError::InvalidFormat);
}
let (n2, rev, crc_hex, blake_hex) = (segments[0], segments[1], segments[2], segments[3]);
ensure_rev_consistency(n2, rev)?;
if crc_hex.len() != CRC32_HEX_LEN || !crc_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(RRIDError::InvalidTag);
}
if blake_hex.len() != BASELINE_BLAKE_HEX_LEN
|| !blake_hex.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(RRIDError::InvalidTag);
}
let payload = payload_bytes_from_n2_and_rev(n2, rev);
let expected_crc = compute_crc32(&payload);
if expected_crc != crc_hex {
return Err(RRIDError::InvalidTag);
}
let blake_full = hex::encode(Blake2s256::digest(&payload));
let expected_tag = truncate_hex(blake_full, BASELINE_BLAKE_HEX_LEN)?;
if expected_tag != blake_hex {
return Err(RRIDError::InvalidTag);
}
Ok(())
}
#[cfg(feature = "hmac")]
pub fn make_token_hmac(user_id: u64, key: &[u8], tag_len: usize) -> Result<Token, RRIDError> {
if key.is_empty() {
return Err(RRIDError::InvalidFormat);
}
validate_tag_len(tag_len, BASELINE_BLAKE_HEX_LEN)?;
let (n2, rev) = compute_n2_rev(user_id)?;
let payload = payload_bytes_from_n2_and_rev(&n2, &rev);
let tag = compute_hmac_tag(key, &payload, tag_len)?;
Ok(Token(format!("{n2}-{rev}-{tag}")))
}
#[cfg(feature = "hmac")]
pub fn validate_token_hmac(token: &str, key: &[u8], tag_len: usize) -> bool {
match parse_hmac(token, key, tag_len) {
Ok(()) => true,
Err(_) => false,
}
}
#[cfg(feature = "hmac")]
fn parse_hmac(token: &str, key: &[u8], tag_len: usize) -> Result<(), RRIDError> {
if key.is_empty() {
return Err(RRIDError::InvalidFormat);
}
validate_tag_len(tag_len, BASELINE_BLAKE_HEX_LEN)?;
let segments: Vec<&str> = token.split('-').collect();
if segments.len() != 3 {
return Err(RRIDError::InvalidFormat);
}
let (n2, rev, tag_hex) = (segments[0], segments[1], segments[2]);
if tag_hex.len() != tag_len || !tag_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(RRIDError::InvalidTag);
}
ensure_rev_consistency(n2, rev)?;
let payload = payload_bytes_from_n2_and_rev(n2, rev);
let expected = compute_hmac_tag(key, &payload, tag_len)?;
if expected != tag_hex {
return Err(RRIDError::InvalidTag);
}
Ok(())
}
#[cfg(feature = "hmac")]
fn compute_hmac_tag(key: &[u8], payload: &[u8], tag_len: usize) -> Result<String, RRIDError> {
#[cfg(feature = "zeroize")]
{
use zeroize::Zeroizing;
let key_material = Zeroizing::new(key.to_vec());
let mut mac = SimpleHmac::<Blake2s256>::new_from_slice(&key_material)?;
mac.update(payload);
truncate_hex(hex::encode(mac.finalize().into_bytes()), tag_len)
}
#[cfg(not(feature = "zeroize"))]
{
let mut mac = SimpleHmac::<Blake2s256>::new_from_slice(key)?;
mac.update(payload);
truncate_hex(hex::encode(mac.finalize().into_bytes()), tag_len)
}
}
#[cfg(feature = "salt-iter")]
pub fn make_token_salt_iter(
user_id: u64,
salt_len: usize,
iters: usize,
) -> Result<Token, RRIDError> {
let mut rng = OsRng;
make_token_salt_iter_with_rng(user_id, salt_len, iters, &mut rng)
}
#[cfg(feature = "salt-iter")]
fn make_token_salt_iter_with_rng<R: RngCore + CryptoRng>(
user_id: u64,
salt_len: usize,
iters: usize,
rng: &mut R,
) -> Result<Token, RRIDError> {
if salt_len == 0 || iters == 0 {
return Err(RRIDError::InvalidFormat);
}
let (n2, rev) = compute_n2_rev(user_id)?;
let mut salt = vec![0u8; salt_len];
rng.fill_bytes(&mut salt);
let salt_hex = hex::encode(&salt);
let payload = payload_bytes_from_n2_and_rev(&n2, &rev);
let digest = salt_iter_digest(&payload, &salt, iters)?;
let tag = truncate_hex(hex::encode(digest), SALT_ITER_TAG_HEX_LEN)?;
Ok(Token(format!("{n2}-{rev}-{salt_hex}-{iters}-{tag}")))
}
#[cfg(feature = "salt-iter")]
fn salt_iter_digest(payload: &[u8], salt: &[u8], iters: usize) -> Result<Vec<u8>, RRIDError> {
if iters == 0 {
return Err(RRIDError::InvalidFormat);
}
let mut digest = {
let mut hasher = Blake2s256::new();
hasher.update(payload);
hasher.update(salt);
hasher.finalize().to_vec()
};
for _ in 1..iters {
digest = Blake2s256::digest(&digest).to_vec();
}
Ok(digest)
}
#[cfg(feature = "salt-iter")]
pub fn validate_token_salt_iter(token: &str) -> bool {
match parse_salt_iter(token) {
Ok(()) => true,
Err(_) => false,
}
}
#[cfg(feature = "salt-iter")]
fn parse_salt_iter(token: &str) -> Result<(), RRIDError> {
let segments: Vec<&str> = token.split('-').collect();
if segments.len() != 5 {
return Err(RRIDError::InvalidFormat);
}
let (n2, rev, salt_hex, iters_str, tag_hex) = (
segments[0],
segments[1],
segments[2],
segments[3],
segments[4],
);
ensure_rev_consistency(n2, rev)?;
if tag_hex.len() != SALT_ITER_TAG_HEX_LEN || !tag_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(RRIDError::InvalidTag);
}
if salt_hex.len() % 2 != 0 || salt_hex.is_empty() {
return Err(RRIDError::InvalidFormat);
}
let salt = hex::decode(salt_hex)?;
let iters: usize = iters_str.parse()?;
if iters == 0 {
return Err(RRIDError::InvalidFormat);
}
let payload = payload_bytes_from_n2_and_rev(n2, rev);
let digest = salt_iter_digest(&payload, &salt, iters)?;
let expected = truncate_hex(hex::encode(digest), SALT_ITER_TAG_HEX_LEN)?;
if expected != tag_hex {
return Err(RRIDError::InvalidTag);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::HashSet;
#[test]
fn baseline_round_trip() {
let token = make_token_baseline(42).expect("baseline token");
assert!(validate_token_baseline(token.as_str()));
}
#[test]
fn baseline_invalid_user_id() {
let err = make_token_baseline(0).unwrap_err();
assert!(matches!(err, RRIDError::InvalidUserId));
}
#[test]
fn baseline_tamper_detection() {
let token = make_token_baseline(99).unwrap();
let mut parts: Vec<_> = token.as_str().split('-').map(String::from).collect();
parts[0].push('1');
let tampered = parts.join("-");
assert!(!validate_token_baseline(&tampered));
}
#[test]
fn baseline_parse_error() {
assert!(!validate_token_baseline("broken-token"));
}
#[test]
fn baseline_max_user_id() {
let token = make_token_baseline(u64::MAX).unwrap();
assert!(validate_token_baseline(token.as_str()));
}
#[test]
fn baseline_uniqueness() {
let mut seen = HashSet::new();
for uid in 1..=500u64 {
let token = make_token_baseline(uid).unwrap();
assert!(seen.insert(token.as_str().to_string()));
}
}
proptest! {
#[test]
fn baseline_round_trip_prop(uid in 1u64..1_000_000) {
let token = make_token_baseline(uid).unwrap();
prop_assert!(validate_token_baseline(token.as_str()));
}
}
#[cfg(feature = "hmac")]
#[test]
fn hmac_round_trip() {
let key = hex::decode("00112233445566778899aabbccddeeff").unwrap();
let token = make_token_hmac(123, &key, 16).unwrap();
assert!(validate_token_hmac(token.as_str(), &key, 16));
}
#[cfg(feature = "hmac")]
#[test]
fn hmac_tamper_detection() {
let key = b"supersecretkey";
let token = make_token_hmac(321, key, 16).unwrap();
let tampered = format!("{token}x");
assert!(!validate_token_hmac(&tampered, key, 16));
}
#[cfg(feature = "hmac")]
#[test]
fn hmac_invalid_tag_len() {
let key = b"k";
let err = make_token_hmac(5, key, 7).unwrap_err();
assert!(matches!(err, RRIDError::InvalidTag));
}
#[cfg(feature = "hmac")]
#[test]
fn hmac_wrong_key_rejected() {
let key = b"correct horse battery staple";
let token = make_token_hmac(99, key, 16).unwrap();
assert!(!validate_token_hmac(token.as_str(), b"wrong-key", 16));
}
#[cfg(feature = "hmac")]
proptest! {
#[test]
fn hmac_round_trip_prop(uid in 1u64..100_000, tag_len in prop::sample::select(vec![8usize, 16, 32, 64])) {
const KEY: &[u8] = b"0123456789abcdef0123456789abcdef";
let token = make_token_hmac(uid, KEY, tag_len).unwrap();
prop_assert!(validate_token_hmac(token.as_str(), KEY, tag_len));
}
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_round_trip() {
let token = make_token_salt_iter(55, 4, 16).unwrap();
assert!(validate_token_salt_iter(token.as_str()));
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_tamper_detection() {
let token = make_token_salt_iter(66, 4, 8).unwrap();
let mut parts: Vec<_> = token.as_str().split('-').collect();
parts[2] = "00000000";
let tampered = parts.join("-");
assert!(!validate_token_salt_iter(&tampered));
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_deterministic_with_seed() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let token_a = super::make_token_salt_iter_with_rng(77, 4, 4, &mut rng).unwrap();
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let token_b = super::make_token_salt_iter_with_rng(77, 4, 4, &mut rng).unwrap();
assert_eq!(token_a, token_b);
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_invalid_iters() {
let err = make_token_salt_iter(10, 4, 0).unwrap_err();
assert!(matches!(err, RRIDError::InvalidFormat));
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_invalid_validation_iters() {
let token = make_token_salt_iter(10, 4, 2).unwrap().to_string();
let mut parts: Vec<_> = token.split('-').collect();
parts[3] = "0";
let tampered = parts.join("-");
assert!(!validate_token_salt_iter(&tampered));
}
#[cfg(feature = "salt-iter")]
#[test]
fn salt_iter_parse_error() {
assert!(!validate_token_salt_iter("not-a-token"));
}
#[cfg(feature = "salt-iter")]
proptest! {
#[test]
fn salt_iter_round_trip_prop(uid in 1u64..10_000, salt_len in 2usize..8, iters in 1usize..64) {
use rand::{rngs::StdRng, SeedableRng};
let mut rng = StdRng::seed_from_u64(uid);
let token = super::make_token_salt_iter_with_rng(uid, salt_len, iters, &mut rng).unwrap();
prop_assert!(validate_token_salt_iter(token.as_str()));
}
}
}