sealwd 0.5.0

Secure password and token management library for Rust, featuring hashing, encryption, and random generation.
Documentation
// Copyright 2026 Thomas Zuyev

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

//     http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::{
    encoding::base64,
    random,
    token::{Error, GenericToken, Result, TokenMaterial},
};

/// Cryptographically-random, fixed-size token material.
///
/// # Security notes
/// - Intended for high-entropy secrets such as session IDs, API tokens,
///   CSRF tokens, and refresh tokens.
/// - NOT suitable for passwords or other low-entropy user-provided secrets.
/// - Should never be stored directly; store only a cryptographic hash
///   (e.g. BLAKE3 with a server-side pepper).
///
/// Equality comparison should be performed on hashes, not tokens.
/// Original token material cannot be recovered.
#[derive(zeroize::ZeroizeOnDrop, Clone)]
pub struct Token<const LEN: usize>([u8; LEN]);

impl<const LEN: usize> Token<LEN> {
    /// Generates a new `Token` populated with cryptographically secure random bytes.
    ///
    /// # Errors
    /// Returns [`Error::Random`] if system random generation sources fail.
    pub fn random() -> Result<Self> {
        let mut s = [0u8; LEN];
        random::fill_bytes(&mut s)?;
        Ok(Self(s))
    }

    /// Directly maps a known, constant array slice sequence into a type-safe fixed `Token`.
    #[must_use]
    pub fn from_bytes(data: [u8; LEN]) -> Self {
        Self(data)
    }

    /// Consumes the fixed-length token context, turning it into a dynamically-sized heap allocated variant.
    #[must_use]
    pub fn to_generic_token(self) -> GenericToken {
        GenericToken::from_bytes(self.as_bytes())
    }
}

impl<const LEN: usize> TokenMaterial for Token<LEN> {
    fn as_bytes(&self) -> &[u8] {
        &self.0
    }

    fn from_vec(bytes: Vec<u8>) -> Result<Self> {
        let provided = bytes.len();
        let arr = bytes.try_into().map_err(|_| Error::LengthMismatch {
            expected: LEN,
            provided,
        })?;
        Ok(Self::from_bytes(arr))
    }

    /// Direct slice-based decoding implementation optimized to bypass intermediate heap generation.
    fn from_base64(data: &str) -> Result<Self> {
        let mut s = [0u8; LEN];
        let written = base64::decode_slice(data, &mut s).map_err(|e| match e {
            base64::DecodeSliceError::DecodeError(e) => e.into(),
            base64::DecodeSliceError::OutputSliceTooSmall => Error::LengthMismatch {
                expected: LEN,
                provided: (LEN * 4).div_ceil(3),
            },
        })?;

        if written == LEN {
            Ok(Self(s))
        } else {
            Err(Error::LengthMismatch {
                expected: LEN,
                provided: written,
            })
        }
    }
}

impl<const LEN: usize> std::fmt::Debug for Token<LEN> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "<redacted>")
    }
}

impl<const LEN: usize> std::fmt::Display for Token<LEN> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "<redacted>")
    }
}

#[cfg(feature = "serde")]
impl<const LEN: usize> serde::Serialize for Token<LEN> {
    fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        if serializer.is_human_readable() {
            serializer.serialize_str(&self.to_base64())
        } else {
            serializer.serialize_bytes(self.as_bytes())
        }
    }
}

#[cfg(feature = "serde")]
impl<'de, const LEN: usize> serde::Deserialize<'de> for Token<LEN> {
    fn deserialize<D>(deserializer: D) -> std::prelude::v1::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        if deserializer.is_human_readable() {
            let s = String::deserialize(deserializer)?;
            Self::from_base64(&s).map_err(serde::de::Error::custom)
        } else {
            let bytes = <serde_big_array::Array<u8, LEN>>::deserialize(deserializer)?;
            Ok(Self::from_bytes(*bytes))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn redacted_debug() {
        let token = Token::<64>::random().unwrap();

        assert_eq!(format!("{:?}", token), "<redacted>");
    }

    #[test]
    fn redacted_display() {
        let token = Token::<64>::random().unwrap();

        assert_eq!(format!("{token}"), "<redacted>");
    }

    #[test]
    #[cfg(feature = "serde")]
    fn serde_human_readable_roundtrip() {
        let token = Token::<10>::from_bytes(*b"abcdefghij");
        let json = serde_json::to_string(&token).unwrap();
        let deserialized = serde_json::from_str::<Token<10>>(&json).unwrap();

        assert_eq!(token.0, deserialized.0);
        assert_eq!(json, "\"YWJjZGVmZ2hpag\"");
    }
}