steam-friend-code 0.1.2

Encode and decode Steam CS:GO/CS2 friend codes and short Steam quick-invite codes.
Documentation
//! CSGO in-game friend code (Base32/MD5 algorithm).
//!
//! These friend codes are used in CS:GO/CS2 to add friends via the in-game UI.
//! They use a Base32 encoding with MD5 hash verification.

use std::{fmt, ops::Deref, str::FromStr};

use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CsgoFriendCode(String);

impl CsgoFriendCode {
    pub fn new(code: String) -> Self {
        Self(code)
    }

    pub fn from_steam_id(steam_id: u64) -> Self {
        let mut steam_id = steam_id;
        let h = hash_steam_id(steam_id);

        let mut r = 0u64;
        for i in 0..8 {
            let id_nibble = steam_id & 0xF;
            steam_id >>= 4;

            let hash_nibble = (h >> i) & 1;

            let a = (r << 4) | id_nibble;

            r = make_u64(r >> 28, a);
            r = make_u64(r >> 31, (a << 1) | hash_nibble);
        }

        let mut res = b32(r);

        if res.starts_with("AAAA-") {
            res = res[5..].to_string();
        }

        Self(res)
    }

    pub fn to_account_id(&self) -> Option<u32> {
        let code_clean = self.0.replace("-", "");

        // Handle "AAAA-" strip case.
        // Full code is 13 chars. Stripped is 9 chars.
        let full_code = match code_clean.len() {
            13 => code_clean,
            9 => format!("AAAA{}", code_clean),
            _ => return None,
        };

        let mut val = b32_decode(&full_code)?;

        // Reverse swap_bytes
        val = val.swap_bytes();

        // The encoding loop packs 8 blocks of 5 bits (4 bits ID + 1 bit hash).
        // It shifts left: r = (r << 5) | block
        // So the LAST block added (i=7, highest nibble of ID) is in the LSB of `val`.
        // So `val` LSB is block7. MSB is block0.

        let mut id: u32 = 0;
        let mut hash_bits: u8 = 0;

        for i in 0..8 {
            // Processing from LSB (block 7) to MSB (block 0).
            // So we are processing i_rev = 7 - i
            let i_rev = 7 - i;

            let block = val & 0x1F;
            val >>= 5;

            let id_nibble = (block >> 1) as u32;
            let hash_bit = (block & 1) as u8;

            id |= id_nibble << (i_rev * 4);
            hash_bits |= hash_bit << i_rev;
        }

        // Verify hash
        let expected_hash = hash_steam_id(id as u64);
        if (expected_hash & 0xFF) as u8 == hash_bits {
            Some(id)
        } else {
            None
        }
    }

    pub fn is_valid(&self) -> bool {
        self.to_account_id().is_some()
    }
}

impl Deref for CsgoFriendCode {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl FromStr for CsgoFriendCode {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let s = s.trim().to_uppercase();
        let code = CsgoFriendCode(s);
        if code.is_valid() {
            Ok(code)
        } else {
            Err(())
        }
    }
}

impl From<u64> for CsgoFriendCode {
    fn from(steam_id: u64) -> Self {
        Self::from_steam_id(steam_id)
    }
}

impl From<String> for CsgoFriendCode {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl fmt::Display for CsgoFriendCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

fn make_u64(hi: u64, lo: u64) -> u64 {
    (hi << 32) | lo
}

fn hash_steam_id(id: u64) -> u64 {
    let account_id = id & 0xFFFFFFFF;
    let strange_steam_id = account_id | 0x4353474F00000000; // "CSGO" as high bits

    let bytes = strange_steam_id.to_le_bytes();
    let digest = md5::compute(bytes);

    // md5::Digest implements Deref<Target=[u8; 16]>
    let slice: [u8; 4] = digest[0..4].try_into().expect("slice with incorrect length");
    u32::from_le_bytes(slice) as u64
}

fn b32(input: u64) -> String {
    let mut input = input;
    let alnum = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    let mut res = String::new();

    // The Typescript does:
    // input = ByteSwap.from_big_endian(ByteSwap.to_little_endian(input))
    // Which effectively reverses the bytes of the u64.
    input = input.swap_bytes();

    for i in 0..13 {
        if i == 4 || i == 9 {
            res.push('-');
        }
        let index = (input & 0x1F) as usize;
        res.push(alnum[index] as char);
        input >>= 5;
    }

    res
}

fn b32_decode(input: &str) -> Option<u64> {
    let alnum = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    let mut res = 0u64;
    let mut shift = 0;

    for c in input.bytes() {
        let pos = alnum.iter().position(|&x| x == c)?;
        res |= (pos as u64) << shift;
        shift += 5;
    }

    Some(res)
}

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

    #[test]
    fn test_steam_friend_code_serialization() {
        let code = CsgoFriendCode::new("ABCDE-12345".to_string());
        let json = serde_json::to_string(&code).unwrap();
        assert_eq!(json, "\"ABCDE-12345\"");

        let decoded: CsgoFriendCode = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded, code);
    }

    #[test]
    fn test_friend_code_conversion() {
        let steam_id = 76561197960287930u64;
        let code = CsgoFriendCode::from_steam_id(steam_id);
        println!("Code: {}", code);
        let decoded = code.to_account_id();
        assert_eq!(decoded, Some(22202));
    }

    #[test]
    fn test_roundtrip_random() {
        let ids = vec![12345, 999999, 1, 0, 2147483647];
        for id in ids {
            let full_id = id as u64 | 0x0110000100000000;
            let code = CsgoFriendCode::from_steam_id(full_id);
            assert_eq!(code.to_account_id(), Some(id), "Failed for id {} code {}", id, code);
        }
    }

    #[test]
    fn test_invalid_code() {
        let code = CsgoFriendCode("INVALID-CODE".to_string());
        assert_eq!(code.to_account_id(), None);
    }

    #[test]
    fn test_from_str() {
        let id = 12345;
        let full_id = id as u64 | 0x0110000100000000;
        let code = CsgoFriendCode::from_steam_id(full_id);
        let s = code.to_string();

        let parsed: Result<CsgoFriendCode, _> = s.parse();
        assert!(parsed.is_ok());
        assert_eq!(parsed.unwrap().to_account_id(), Some(id));

        assert!(CsgoFriendCode::from_str("INVALID").is_err());
    }
}