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::{
    fmt::impl_redacted_fmt,
    token::{Error, Result, TokenMaterial},
};

#[must_use]
fn compute_blake3_v1_raw(bytes: &[u8], pepper: &[u8]) -> blake3::Hash {
    let mut hasher = blake3::Hasher::new();
    hasher.update(pepper);
    hasher.update(bytes);

    hasher.finalize()
}

/// Algorithm identifier for BLAKE3-based token hashes (version 1).
///
/// Stored as the first byte of the serialized representation.
/// Must never be changed or reused for a different algorithm.
const ALG_BLAKE3_V1: u8 = 0;

/// Stored verifier for high-entropy authentication tokens.
///
/// This type is intentionally:
/// - self-describing (algorithm ID is embedded in serialized form)
/// - forward-compatible (new algorithms can be added)
/// - non-reversible (no access to original token material)
///
/// # Storage format (BYTEA)
/// ```text
/// [1 byte algorithm id][algorithm-specific payload]
/// ```
///
/// This type is suitable for database storage and comparison,
/// but MUST NOT be used for password hashing.
#[derive(Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TokenHash {
    /// BLAKE3-based token hash, version 1.
    ///
    /// Output size is fixed to 32 bytes.
    Blake3V1(blake3::Hash),
}

impl TokenHash {
    /// Computes a token hash from the provided token material using the system default version.
    #[must_use]
    pub fn compute<T: TokenMaterial>(token: &T, pepper: &[u8]) -> Self {
        Self::compute_blake3_v1(token, pepper)
    }

    /// Computes a explicit version 1 BLAKE3 hash configuration for the target token material.
    #[must_use]
    pub fn compute_blake3_v1<T: TokenMaterial>(token: &T, pepper: &[u8]) -> Self {
        let hash = compute_blake3_v1_raw(token.as_bytes(), pepper);
        Self::Blake3V1(hash)
    }

    /// Verifies whether the provided token material and server-side pepper match this hash instance.
    ///
    /// Performs constant-time evaluations over internal algorithm assets to counter timing side-channels.
    #[must_use]
    pub fn verify<T: TokenMaterial>(&self, token: &T, pepper: &[u8]) -> bool {
        match self {
            Self::Blake3V1(a) => {
                let b = compute_blake3_v1_raw(token.as_bytes(), pepper);
                // blake3 already ensures this comparison is constant-time
                *a == b
            }
        }
    }

    /// Serializes the token hash into a self-describing binary format.
    ///
    /// The resulting bytes are suitable for long-term database or file storage.
    pub fn to_bytes(&self) -> Vec<u8> {
        match &self {
            Self::Blake3V1(h) => {
                let mut res = Vec::with_capacity(1 + blake3::OUT_LEN);
                res.push(ALG_BLAKE3_V1);
                res.extend_from_slice(h.as_bytes());
                res
            }
        }
    }

    /// Deserializes a token hash from its binary representation.
    ///
    /// # Errors
    /// Fails with:
    /// - [`Error::HashAlgorithmMissing`] if input slices are empty.
    /// - [`Error::HashAlgorithmInvalid`] if version header bytes are unknown.
    /// - [`Error::LengthMismatch`] if the inner contents fail validation criteria.
    pub fn from_bytes(data: &[u8]) -> Result<Self> {
        if data.is_empty() {
            return Err(Error::HashAlgorithmMissing);
        }

        match data[0] {
            ALG_BLAKE3_V1 => Ok(Self::blake3_from_bytes(&data[1..])?),
            algo => Err(Error::HashAlgorithmInvalid(algo)),
        }
    }

    /// Converts the serialized hash structure into an unpadded, URL-safe Base64 string representation.
    #[must_use]
    pub fn to_base64(&self) -> String {
        crate::encoding::base64::encode(self.to_bytes())
    }

    /// Restores a token hash structure from an unpadded, URL-safe Base64 string payload.
    ///
    /// # Errors
    /// Returns an error if Base64 decoding fails or inner header structural data is malformed.
    pub fn from_base64(data: &str) -> Result<Self> {
        let decoded = crate::encoding::base64::decode(data)?;
        Self::from_bytes(&decoded)
    }

    /// Internal tool parsing raw, stripped slices into a validated BLAKE3 token variant wrapper.
    fn blake3_from_bytes(data: &[u8]) -> Result<Self> {
        let hash = blake3::Hash::from_slice(data).map_err(|_| Error::LengthMismatch {
            expected: blake3::OUT_LEN,
            provided: data.len(),
        })?;

        Ok(Self::Blake3V1(hash))
    }
}

impl_redacted_fmt!(TokenHash);

#[cfg(feature = "serde")]
impl serde::Serialize for TokenHash {
    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.to_bytes())
        }
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for TokenHash {
    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 = <Vec<u8>>::deserialize(deserializer)?;
            Self::from_bytes(&bytes).map_err(serde::de::Error::custom)
        }
    }
}

#[cfg(feature = "sqlx")]
mod sqlx_impl {
    use super::*;
    use sqlx::{Database, Decode, Encode, Type, encode::IsNull, error::BoxDynError};

    #[cfg(feature = "sqlx_postgres")]
    impl sqlx::postgres::PgHasArrayType for TokenHash {
        fn array_type_info() -> sqlx::postgres::PgTypeInfo {
            // Under the hood, we encode this as a byte array,
            // so our Postgres array type is an array of BYTEA.
            <Vec<u8> as sqlx::postgres::PgHasArrayType>::array_type_info()
        }
    }

    impl<'a, DB: Database> Type<DB> for TokenHash
    where
        &'a [u8]: Type<DB>,
    {
        fn type_info() -> <DB as Database>::TypeInfo {
            <&[u8] as Type<DB>>::type_info()
        }

        fn compatible(ty: &<DB as Database>::TypeInfo) -> bool {
            <&[u8] as Type<DB>>::compatible(ty)
        }
    }

    impl<'a, DB: Database> Encode<'a, DB> for TokenHash
    where
        Vec<u8>: Encode<'a, DB>,
    {
        fn encode_by_ref(
            &self,
            buf: &mut <DB as Database>::ArgumentBuffer<'a>,
        ) -> std::result::Result<IsNull, BoxDynError> {
            <Vec<u8> as Encode<'a, DB>>::encode_by_ref(&self.to_bytes(), buf)
        }
        fn produces(&self) -> Option<<DB as Database>::TypeInfo> {
            <Vec<u8> as Encode<'a, DB>>::produces(&self.to_bytes())
        }
        fn size_hint(&self) -> usize {
            <Vec<u8> as Encode<'a, DB>>::size_hint(&self.to_bytes())
        }
    }

    impl<'a, DB: Database> Decode<'a, DB> for TokenHash
    where
        Vec<u8>: Decode<'a, DB>,
    {
        fn decode(value: <DB as Database>::ValueRef<'a>) -> std::result::Result<Self, BoxDynError> {
            Ok(Self::from_bytes(&<Vec<u8> as Decode<'a, DB>>::decode(
                value,
            )?)?)
        }
    }
}

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

    #[test]
    fn redacted_debug() {
        let token = GenericToken::from_bytes(b"abcdef");
        let hash = token.to_default_hash(b"12345");

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

    #[test]
    fn redacted_display() {
        let token = GenericToken::from_bytes(b"abcdef");
        let hash = token.to_default_hash(b"12345");

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

    #[test]
    #[cfg(feature = "serde")]
    fn serde_human_readable_roundtrip() {
        let token = GenericToken::from_bytes(b"abcdef");
        let hash = token.to_blake3(b"12345");
        let json = serde_json::to_string(&hash).unwrap();
        let deserialized = serde_json::from_str::<TokenHash>(&json).unwrap();

        assert_eq!(hash, deserialized);
    }

    #[test]
    fn verify_success() {
        let token = GenericToken::random(32).unwrap();
        let pepper = b"secret-pepper";
        let hash = token.to_default_hash(pepper);

        assert!(hash.verify(&token, pepper));
    }

    #[test]
    fn verify_wrong_pepper_fail() {
        let token = GenericToken::random(32).unwrap();
        let hash = token.to_default_hash(b"pepper1");

        assert!(!hash.verify(&token, b"pepper2"));
    }

    #[test]
    fn verify_wrong_token_fail() {
        let token1 = GenericToken::random(32).unwrap();
        let token2 = GenericToken::random(32).unwrap();
        let hash = token1.to_default_hash(b"pepper");

        assert!(!hash.verify(&token2, b"pepper"));
    }

    #[test]
    fn reject_too_short() {
        let data = vec![0, 1, 2]; // too short

        assert!(TokenHash::from_bytes(&data).is_err());
    }

    #[test]
    fn reject_unknown_algorithm() {
        let mut data = vec![255];
        data.extend([0u8; 32]);

        assert!(matches!(
            TokenHash::from_bytes(&data),
            Err(Error::HashAlgorithmInvalid(255))
        ));
    }
}