Skip to main content

steam_friend_code/
csgo_friend_code.rs

1//! CSGO in-game friend code (Base32/MD5 algorithm).
2//!
3//! These friend codes are used in CS:GO/CS2 to add friends via the in-game UI.
4//! They use a Base32 encoding with MD5 hash verification.
5
6use std::{fmt, ops::Deref, str::FromStr};
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[serde(transparent)]
12pub struct CsgoFriendCode(String);
13
14impl CsgoFriendCode {
15    pub fn new(code: String) -> Self {
16        Self(code)
17    }
18
19    pub fn from_steam_id(steam_id: u64) -> Self {
20        let mut steam_id = steam_id;
21        let h = hash_steam_id(steam_id);
22
23        let mut r = 0u64;
24        for i in 0..8 {
25            let id_nibble = steam_id & 0xF;
26            steam_id >>= 4;
27
28            let hash_nibble = (h >> i) & 1;
29
30            let a = (r << 4) | id_nibble;
31
32            r = make_u64(r >> 28, a);
33            r = make_u64(r >> 31, (a << 1) | hash_nibble);
34        }
35
36        let mut res = b32(r);
37
38        if res.starts_with("AAAA-") {
39            res = res[5..].to_string();
40        }
41
42        Self(res)
43    }
44
45    pub fn to_account_id(&self) -> Option<u32> {
46        let code_clean = self.0.replace("-", "");
47
48        // Handle "AAAA-" strip case.
49        // Full code is 13 chars. Stripped is 9 chars.
50        let full_code = match code_clean.len() {
51            13 => code_clean,
52            9 => format!("AAAA{}", code_clean),
53            _ => return None,
54        };
55
56        let mut val = b32_decode(&full_code)?;
57
58        // Reverse swap_bytes
59        val = val.swap_bytes();
60
61        // The encoding loop packs 8 blocks of 5 bits (4 bits ID + 1 bit hash).
62        // It shifts left: r = (r << 5) | block
63        // So the LAST block added (i=7, highest nibble of ID) is in the LSB of `val`.
64        // So `val` LSB is block7. MSB is block0.
65
66        let mut id: u32 = 0;
67        let mut hash_bits: u8 = 0;
68
69        for i in 0..8 {
70            // Processing from LSB (block 7) to MSB (block 0).
71            // So we are processing i_rev = 7 - i
72            let i_rev = 7 - i;
73
74            let block = val & 0x1F;
75            val >>= 5;
76
77            let id_nibble = (block >> 1) as u32;
78            let hash_bit = (block & 1) as u8;
79
80            id |= id_nibble << (i_rev * 4);
81            hash_bits |= hash_bit << i_rev;
82        }
83
84        // Verify hash
85        let expected_hash = hash_steam_id(id as u64);
86        if (expected_hash & 0xFF) as u8 == hash_bits {
87            Some(id)
88        } else {
89            None
90        }
91    }
92
93    pub fn is_valid(&self) -> bool {
94        self.to_account_id().is_some()
95    }
96}
97
98impl Deref for CsgoFriendCode {
99    type Target = str;
100
101    fn deref(&self) -> &Self::Target {
102        &self.0
103    }
104}
105
106impl FromStr for CsgoFriendCode {
107    type Err = ();
108
109    fn from_str(s: &str) -> Result<Self, Self::Err> {
110        let s = s.trim().to_uppercase();
111        let code = CsgoFriendCode(s);
112        if code.is_valid() {
113            Ok(code)
114        } else {
115            Err(())
116        }
117    }
118}
119
120impl From<u64> for CsgoFriendCode {
121    fn from(steam_id: u64) -> Self {
122        Self::from_steam_id(steam_id)
123    }
124}
125
126impl From<String> for CsgoFriendCode {
127    fn from(s: String) -> Self {
128        Self(s)
129    }
130}
131
132impl fmt::Display for CsgoFriendCode {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138fn make_u64(hi: u64, lo: u64) -> u64 {
139    (hi << 32) | lo
140}
141
142fn hash_steam_id(id: u64) -> u64 {
143    let account_id = id & 0xFFFFFFFF;
144    let strange_steam_id = account_id | 0x4353474F00000000; // "CSGO" as high bits
145
146    let bytes = strange_steam_id.to_le_bytes();
147    let digest = md5::compute(bytes);
148
149    // md5::Digest implements Deref<Target=[u8; 16]>
150    let slice: [u8; 4] = digest[0..4].try_into().expect("slice with incorrect length");
151    u32::from_le_bytes(slice) as u64
152}
153
154fn b32(input: u64) -> String {
155    let mut input = input;
156    let alnum = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
157    let mut res = String::new();
158
159    // The Typescript does:
160    // input = ByteSwap.from_big_endian(ByteSwap.to_little_endian(input))
161    // Which effectively reverses the bytes of the u64.
162    input = input.swap_bytes();
163
164    for i in 0..13 {
165        if i == 4 || i == 9 {
166            res.push('-');
167        }
168        let index = (input & 0x1F) as usize;
169        res.push(alnum[index] as char);
170        input >>= 5;
171    }
172
173    res
174}
175
176fn b32_decode(input: &str) -> Option<u64> {
177    let alnum = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
178    let mut res = 0u64;
179    let mut shift = 0;
180
181    for c in input.bytes() {
182        let pos = alnum.iter().position(|&x| x == c)?;
183        res |= (pos as u64) << shift;
184        shift += 5;
185    }
186
187    Some(res)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_steam_friend_code_serialization() {
196        let code = CsgoFriendCode::new("ABCDE-12345".to_string());
197        let json = serde_json::to_string(&code).unwrap();
198        assert_eq!(json, "\"ABCDE-12345\"");
199
200        let decoded: CsgoFriendCode = serde_json::from_str(&json).unwrap();
201        assert_eq!(decoded, code);
202    }
203
204    #[test]
205    fn test_friend_code_conversion() {
206        let steam_id = 76561197960287930u64;
207        let code = CsgoFriendCode::from_steam_id(steam_id);
208        println!("Code: {}", code);
209        let decoded = code.to_account_id();
210        assert_eq!(decoded, Some(22202));
211    }
212
213    #[test]
214    fn test_roundtrip_random() {
215        let ids = vec![12345, 999999, 1, 0, 2147483647];
216        for id in ids {
217            let full_id = id as u64 | 0x0110000100000000;
218            let code = CsgoFriendCode::from_steam_id(full_id);
219            assert_eq!(code.to_account_id(), Some(id), "Failed for id {} code {}", id, code);
220        }
221    }
222
223    #[test]
224    fn test_invalid_code() {
225        let code = CsgoFriendCode("INVALID-CODE".to_string());
226        assert_eq!(code.to_account_id(), None);
227    }
228
229    #[test]
230    fn test_from_str() {
231        let id = 12345;
232        let full_id = id as u64 | 0x0110000100000000;
233        let code = CsgoFriendCode::from_steam_id(full_id);
234        let s = code.to_string();
235
236        let parsed: Result<CsgoFriendCode, _> = s.parse();
237        assert!(parsed.is_ok());
238        assert_eq!(parsed.unwrap().to_account_id(), Some(id));
239
240        assert!(CsgoFriendCode::from_str("INVALID").is_err());
241    }
242}