maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use std::fmt;

use sha2::{Digest as _, Sha256};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use zeroize::Zeroize as _;

use super::identity::{decode_lower_hex, encode_lower_hex};

const TOKEN_BYTES: usize = 32;
#[cfg(test)]
const TOKEN_HEX_BYTES: usize = TOKEN_BYTES * 2;
const SESSION_TOKEN_PREFIX: &str = "mcs1_";
const CSRF_TOKEN_PREFIX: &str = "mcc1_";
const LOGIN_CHALLENGE_PREFIX: &str = "mcl1_";
const SESSION_DIGEST_CONTEXT: &[u8] = b"maincopy session token digest v1\0";
const CSRF_DIGEST_CONTEXT: &[u8] = b"maincopy csrf token digest v1\0";
const LOGIN_CHALLENGE_DIGEST_CONTEXT: &[u8] = b"maincopy login challenge digest v1\0";

macro_rules! opaque_token {
    ($name:ident, $digest:ident, $prefix:ident, $context:ident, $debug:literal) => {
        pub struct $name {
            bytes: [u8; TOKEN_BYTES],
            encoded: String,
        }

        impl $name {
            pub fn generate() -> Result<Self, TokenGenerationError> {
                let mut bytes = [0_u8; TOKEN_BYTES];
                getrandom::fill(&mut bytes).map_err(|_| TokenGenerationError)?;
                Ok(Self::from_bytes(bytes))
            }

            pub fn parse(value: &str) -> Result<Self, TokenParseError> {
                let hex = value.strip_prefix($prefix).ok_or(TokenParseError)?;
                let bytes = decode_lower_hex::<TOKEN_BYTES>(hex).ok_or(TokenParseError)?;
                Ok(Self {
                    bytes,
                    encoded: value.into(),
                })
            }

            pub fn expose_secret(&self) -> &str {
                &self.encoded
            }

            pub fn digest(&self) -> $digest {
                let mut hasher = Sha256::new();
                hasher.update($context);
                hasher.update(self.bytes);
                $digest(hasher.finalize().into())
            }

            fn from_bytes(bytes: [u8; TOKEN_BYTES]) -> Self {
                Self {
                    encoded: format!("{}{}", $prefix, encode_lower_hex(&bytes)),
                    bytes,
                }
            }
        }

        impl fmt::Debug for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str($debug)
            }
        }

        impl Drop for $name {
            fn drop(&mut self) {
                self.bytes.zeroize();
                self.encoded.zeroize();
            }
        }

        #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $digest([u8; TOKEN_BYTES]);

        impl $digest {
            #[cfg(test)]
            pub const fn from_bytes(bytes: [u8; TOKEN_BYTES]) -> Self {
                Self(bytes)
            }

            pub const fn as_bytes(&self) -> &[u8; TOKEN_BYTES] {
                &self.0
            }
        }

        impl fmt::Debug for $digest {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(concat!(stringify!($digest), "(<redacted>)"))
            }
        }
    };
}

opaque_token!(
    SessionToken,
    SessionTokenDigest,
    SESSION_TOKEN_PREFIX,
    SESSION_DIGEST_CONTEXT,
    "SessionToken(<redacted>)"
);
opaque_token!(
    CsrfToken,
    CsrfTokenDigest,
    CSRF_TOKEN_PREFIX,
    CSRF_DIGEST_CONTEXT,
    "CsrfToken(<redacted>)"
);
opaque_token!(
    LoginChallenge,
    LoginChallengeDigest,
    LOGIN_CHALLENGE_PREFIX,
    LOGIN_CHALLENGE_DIGEST_CONTEXT,
    "LoginChallenge(<redacted>)"
);

macro_rules! stored_digest {
    ($digest:ident) => {
        impl $digest {
            pub fn parse_bytes(bytes: &[u8]) -> Result<Self, TokenDigestParseError> {
                let bytes: [u8; TOKEN_BYTES] =
                    bytes.try_into().map_err(|_| TokenDigestParseError)?;
                Ok(Self(bytes))
            }

            pub fn ct_eq(&self, other: &Self) -> bool {
                bool::from(self.0.ct_eq(&other.0))
            }
        }
    };
}

stored_digest!(CsrfTokenDigest);
stored_digest!(LoginChallengeDigest);

#[cfg(test)]
impl SessionTokenDigest {
    pub fn parse_bytes(bytes: &[u8]) -> Result<Self, TokenDigestParseError> {
        let bytes: [u8; TOKEN_BYTES] = bytes.try_into().map_err(|_| TokenDigestParseError)?;
        Ok(Self(bytes))
    }

    pub fn ct_eq(&self, other: &Self) -> bool {
        bool::from(self.0.ct_eq(&other.0))
    }
}

#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
#[error("the operating system could not generate an authentication token")]
pub struct TokenGenerationError;

#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
#[error("the authentication token is not a canonical opaque token")]
pub struct TokenParseError;

#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
#[error("an authentication token digest must be exactly 32 bytes")]
pub struct TokenDigestParseError;

#[cfg(test)]
mod tests {
    use std::{borrow::Borrow, ops::Deref};

    use serde::{Serialize, de::DeserializeOwned};

    use super::*;

    macro_rules! assert_not_impl {
        ($value:ty: $bound:path) => {
            const _: fn() = || {
                trait AmbiguousIfImpl<Marker> {
                    fn marker() {}
                }

                impl<Value: ?Sized> AmbiguousIfImpl<()> for Value {}
                impl<Value: ?Sized + $bound> AmbiguousIfImpl<u8> for Value {}

                let _ = <$value as AmbiguousIfImpl<_>>::marker;
            };
        };
    }

    assert_not_impl!(SessionToken: Copy);
    assert_not_impl!(SessionToken: Clone);
    assert_not_impl!(SessionToken: Serialize);
    assert_not_impl!(SessionToken: DeserializeOwned);
    assert_not_impl!(SessionToken: Deref);
    assert_not_impl!(SessionToken: AsRef<str>);
    assert_not_impl!(SessionToken: Borrow<str>);
    assert_not_impl!(CsrfToken: Copy);
    assert_not_impl!(CsrfToken: Clone);
    assert_not_impl!(CsrfToken: Serialize);
    assert_not_impl!(CsrfToken: DeserializeOwned);
    assert_not_impl!(LoginChallenge: Copy);
    assert_not_impl!(LoginChallenge: Clone);
    assert_not_impl!(LoginChallenge: Serialize);
    assert_not_impl!(LoginChallenge: DeserializeOwned);

    #[test]
    fn generated_tokens_have_independent_256_bit_values() {
        let session = SessionToken::generate().unwrap();
        let csrf = CsrfToken::generate().unwrap();
        let second_session = SessionToken::generate().unwrap();
        let challenge = LoginChallenge::generate().unwrap();

        assert_eq!(
            session.expose_secret().len(),
            SESSION_TOKEN_PREFIX.len() + TOKEN_HEX_BYTES
        );
        assert_eq!(
            csrf.expose_secret().len(),
            CSRF_TOKEN_PREFIX.len() + TOKEN_HEX_BYTES
        );
        assert_ne!(session.digest().as_bytes(), csrf.digest().as_bytes());
        assert_ne!(session.digest(), second_session.digest());
        assert_eq!(
            challenge.expose_secret().len(),
            LOGIN_CHALLENGE_PREFIX.len() + TOKEN_HEX_BYTES
        );
    }

    #[test]
    fn token_roles_have_distinct_encodings_and_digest_domains() {
        let bytes = [0xa5; TOKEN_BYTES];
        let session = SessionToken::from_bytes(bytes);
        let csrf = CsrfToken::from_bytes(bytes);
        let challenge = LoginChallenge::from_bytes(bytes);

        assert!(CsrfToken::parse(session.expose_secret()).is_err());
        assert!(SessionToken::parse(csrf.expose_secret()).is_err());
        assert!(LoginChallenge::parse(session.expose_secret()).is_err());
        assert!(SessionToken::parse(challenge.expose_secret()).is_err());
        assert_ne!(session.digest().as_bytes(), csrf.digest().as_bytes());
        assert_ne!(session.digest().as_bytes(), challenge.digest().as_bytes());
        assert_ne!(csrf.digest().as_bytes(), challenge.digest().as_bytes());
    }

    #[test]
    fn token_parsing_is_strict_and_round_trips() {
        let token = SessionToken::from_bytes([0x12; TOKEN_BYTES]);
        let encoded = token.expose_secret().to_owned();
        let parsed = SessionToken::parse(&encoded).unwrap();
        assert_eq!(parsed.digest(), token.digest());

        assert!(SessionToken::parse("").is_err());
        assert!(SessionToken::parse(&encoded.to_uppercase()).is_err());
        assert!(SessionToken::parse(&encoded[..encoded.len() - 1]).is_err());
        assert!(SessionToken::parse(&format!("{encoded}0")).is_err());
    }

    #[test]
    fn raw_tokens_are_redacted_and_digests_require_exact_width() {
        let token = SessionToken::from_bytes([0x77; TOKEN_BYTES]);
        assert_eq!(format!("{token:?}"), "SessionToken(<redacted>)");
        assert_eq!(
            format!("{:?}", token.digest()),
            "SessionTokenDigest(<redacted>)"
        );
        assert_eq!(
            SessionTokenDigest::parse_bytes(token.digest().as_bytes()).unwrap(),
            token.digest()
        );
        assert!(token.digest().ct_eq(&token.digest()));
        assert!(
            !token
                .digest()
                .ct_eq(&SessionTokenDigest::from_bytes([0; TOKEN_BYTES]))
        );
        assert!(SessionTokenDigest::parse_bytes(&[0_u8; TOKEN_BYTES - 1]).is_err());
        assert!(SessionTokenDigest::parse_bytes(&[0_u8; TOKEN_BYTES + 1]).is_err());

        let challenge = LoginChallenge::from_bytes([0x33; TOKEN_BYTES]);
        assert_eq!(format!("{challenge:?}"), "LoginChallenge(<redacted>)");
        assert_eq!(
            format!("{:?}", challenge.digest()),
            "LoginChallengeDigest(<redacted>)"
        );
    }
}