Skip to main content

steel_core/player/chat/
profile_key.rs

1//! Player profile public key management for secure chat.
2//!
3//! Ported from net/minecraft/world/entity/player/ProfilePublicKey.java
4
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use rsa::RsaPublicKey;
8use steel_crypto::{
9    CryptError, SignatureValidator, public_key_from_bytes, public_key_to_bytes,
10    signature::RsaPublicKeyValidator,
11};
12use steel_protocol::packets::game::ProtocolRemoteChatSessionData;
13use thiserror::Error;
14use uuid::Uuid;
15
16/// Grace period for expired keys (8 hours, as in vanilla)
17pub const EXPIRY_GRACE_PERIOD: Duration = Duration::from_hours(8);
18
19/// Maximum size of key signature in bytes
20pub const MAX_KEY_SIGNATURE_SIZE: usize = 4096;
21
22/// Errors that can occur during profile key validation
23#[derive(Debug, Error)]
24pub enum ValidationError {
25    /// Invalid public key signature
26    #[error("Invalid public key signature")]
27    InvalidSignature,
28
29    /// Key has expired
30    #[error("Key has expired")]
31    KeyExpired,
32
33    /// Cryptographic error
34    #[error("Cryptographic error: {0}")]
35    CryptoError(#[from] CryptError),
36}
37
38/// Profile public key data containing key, expiry, and Mojang signature.
39///
40/// Equivalent to ProfilePublicKey.Data in Minecraft.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct ProfilePublicKeyData {
43    /// When this key expires
44    pub expires_at: SystemTime,
45
46    /// The RSA public key
47    pub key: RsaPublicKey,
48
49    /// Signature of this key signed by Mojang's services
50    pub key_signature: Vec<u8>,
51}
52
53impl ProfilePublicKeyData {
54    /// Creates new profile public key data
55    #[must_use]
56    pub const fn new(expires_at: SystemTime, key: RsaPublicKey, key_signature: Vec<u8>) -> Self {
57        Self {
58            expires_at,
59            key,
60            key_signature,
61        }
62    }
63
64    /// Checks if the key has expired (without grace period)
65    #[must_use]
66    pub fn has_expired(&self) -> bool {
67        // Time has passed since expiry
68        self.expires_at.elapsed().is_ok()
69    }
70
71    /// Checks if the key has expired, with a grace period
72    #[must_use]
73    pub fn has_expired_with_grace(&self, grace_period: Duration) -> bool {
74        let expiry_with_grace = self.expires_at + grace_period;
75        expiry_with_grace.elapsed().is_ok()
76    }
77
78    /// Validates the key signature using Mojang's signature validator.
79    ///
80    /// The signature covers: profileId + expiresAt + key bytes
81    ///
82    /// # Errors
83    /// Returns `ValidationError` if signature validation fails or cryptographic operations fail
84    pub fn validate_signature(
85        &self,
86        profile_id: Uuid,
87        validator: &dyn SignatureValidator,
88    ) -> Result<(), ValidationError> {
89        let payload = self.signed_payload(profile_id)?;
90        let updater = ByteSliceUpdater(&payload);
91
92        let is_valid = validator
93            .validate(&updater, &self.key_signature)
94            .map_err(ValidationError::from)?;
95
96        if is_valid {
97            Ok(())
98        } else {
99            Err(ValidationError::InvalidSignature)
100        }
101    }
102
103    /// Constructs the byte payload that was signed by Mojang.
104    ///
105    /// Format: profileId (16 bytes) + expiresAt (8 bytes) + key bytes
106    fn signed_payload(&self, profile_id: Uuid) -> Result<Vec<u8>, ValidationError> {
107        let key_bytes = public_key_to_bytes(&self.key)?;
108
109        let mut payload = Vec::with_capacity(24 + key_bytes.len());
110
111        // Profile UUID (most significant bits + least significant bits, big-endian)
112        payload.extend_from_slice(&profile_id.as_u128().to_be_bytes());
113
114        // Expiry timestamp (milliseconds since epoch, big-endian)
115        let expiry_millis = system_time_to_millis(self.expires_at);
116        payload.extend_from_slice(&expiry_millis.to_be_bytes());
117
118        // Public key bytes
119        payload.extend_from_slice(&key_bytes);
120
121        Ok(payload)
122    }
123
124    /// Serializes the key data for network transmission
125    ///
126    /// # Errors
127    /// Returns `ValidationError` if key encoding fails
128    pub fn to_bytes(&self) -> Result<Vec<u8>, ValidationError> {
129        let mut bytes = Vec::new();
130
131        // Expiry timestamp (i64 milliseconds)
132        let expiry_millis = system_time_to_millis(self.expires_at);
133        bytes.extend_from_slice(&expiry_millis.to_be_bytes());
134
135        // Public key
136        let key_bytes = public_key_to_bytes(&self.key)?;
137        bytes.extend_from_slice(&(key_bytes.len() as i32).to_be_bytes());
138        bytes.extend_from_slice(&key_bytes);
139
140        // Key signature
141        bytes.extend_from_slice(&(self.key_signature.len() as i32).to_be_bytes());
142        bytes.extend_from_slice(&self.key_signature);
143
144        Ok(bytes)
145    }
146
147    /// Deserializes key data from bytes
148    ///
149    /// # Errors
150    /// Returns `ValidationError` if the byte format is invalid or key decoding fails
151    ///
152    /// # Panics
153    /// Panics if slice-to-array conversion fails (should not happen due to length checks)
154    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
155        if bytes.len() < 16 {
156            return Err(ValidationError::CryptoError(CryptError::InvalidKeyFormat));
157        }
158
159        let mut offset = 0;
160
161        // Read expiry timestamp
162        let expiry_millis = i64::from_be_bytes(
163            bytes[offset..offset + 8]
164                .try_into()
165                .expect("slice is exactly 8 bytes"),
166        );
167        offset += 8;
168        let expires_at = system_time_from_millis(expiry_millis);
169
170        // Read public key length
171        if bytes.len() < offset + 4 {
172            return Err(ValidationError::CryptoError(CryptError::InvalidKeyFormat));
173        }
174        let key_len = i32::from_be_bytes(
175            bytes[offset..offset + 4]
176                .try_into()
177                .expect("slice is exactly 4 bytes"),
178        ) as usize;
179        offset += 4;
180
181        // Read public key
182        if bytes.len() < offset + key_len {
183            return Err(ValidationError::CryptoError(CryptError::InvalidKeyFormat));
184        }
185        let key = public_key_from_bytes(&bytes[offset..offset + key_len])?;
186        offset += key_len;
187
188        // Read signature length
189        if bytes.len() < offset + 4 {
190            return Err(ValidationError::CryptoError(CryptError::InvalidKeyFormat));
191        }
192        let sig_len = i32::from_be_bytes(
193            bytes[offset..offset + 4]
194                .try_into()
195                .expect("slice is exactly 4 bytes"),
196        ) as usize;
197        offset += 4;
198
199        // Read signature
200        if bytes.len() < offset + sig_len {
201            return Err(ValidationError::CryptoError(CryptError::InvalidKeyFormat));
202        }
203        let key_signature = bytes[offset..offset + sig_len].to_vec();
204
205        Ok(Self {
206            expires_at,
207            key,
208            key_signature,
209        })
210    }
211}
212
213/// Validated profile public key.
214///
215/// Equivalent to `ProfilePublicKey` in Minecraft.
216#[derive(Clone, Debug)]
217pub struct ProfilePublicKey {
218    data: ProfilePublicKeyData,
219}
220
221impl ProfilePublicKey {
222    /// Creates a new validated profile public key.
223    ///
224    /// This should only be called after validating the signature.
225    #[must_use]
226    pub const fn new(data: ProfilePublicKeyData) -> Self {
227        Self { data }
228    }
229
230    /// Validates and creates a profile public key.
231    ///
232    /// Equivalent to `ProfilePublicKey.createValidated()` in Minecraft.
233    ///
234    /// # Errors
235    /// Returns `ValidationError` if signature validation fails
236    pub fn create_validated(
237        profile_id: Uuid,
238        data: ProfilePublicKeyData,
239        validator: &dyn SignatureValidator,
240    ) -> Result<Self, ValidationError> {
241        data.validate_signature(profile_id, validator)?;
242        Ok(Self::new(data))
243    }
244
245    /// Gets the underlying key data
246    #[must_use]
247    pub const fn data(&self) -> &ProfilePublicKeyData {
248        &self.data
249    }
250
251    /// Creates a signature validator for this key
252    #[must_use]
253    pub fn create_signature_validator(&self) -> RsaPublicKeyValidator {
254        RsaPublicKeyValidator::new(self.data.key.clone())
255    }
256}
257
258/// Remote chat session containing session ID and validated public key.
259///
260/// Equivalent to `RemoteChatSession` in Minecraft.
261#[derive(Clone, Debug)]
262pub struct RemoteChatSession {
263    /// The session ID
264    pub session_id: Uuid,
265    /// The validated profile public key
266    pub profile_public_key: ProfilePublicKey,
267}
268
269impl RemoteChatSession {
270    /// Creates a new remote chat session
271    #[must_use]
272    pub const fn new(session_id: Uuid, profile_public_key: ProfilePublicKey) -> Self {
273        Self {
274            session_id,
275            profile_public_key,
276        }
277    }
278
279    /// Checks if the key has expired
280    #[must_use]
281    pub fn has_expired(&self) -> bool {
282        self.profile_public_key.data().has_expired()
283    }
284
285    /// Converts to data for network transmission
286    #[must_use]
287    pub fn as_data(&self) -> RemoteChatSessionData {
288        RemoteChatSessionData {
289            session_id: self.session_id,
290            profile_public_key: self.profile_public_key.data().clone(),
291        }
292    }
293}
294
295/// Network-serializable chat session data.
296///
297/// Equivalent to `RemoteChatSession.Data` in Minecraft.
298#[derive(Clone, Debug)]
299pub struct RemoteChatSessionData {
300    /// The session ID
301    pub session_id: Uuid,
302    /// The profile public key data
303    pub profile_public_key: ProfilePublicKeyData,
304}
305
306impl RemoteChatSessionData {
307    /// Validates and creates a `RemoteChatSession`
308    ///
309    /// # Errors
310    /// Returns `ValidationError` if signature validation fails
311    pub fn validate(
312        self,
313        profile_id: Uuid,
314        validator: &dyn SignatureValidator,
315    ) -> Result<RemoteChatSession, ValidationError> {
316        let public_key =
317            ProfilePublicKey::create_validated(profile_id, self.profile_public_key, validator)?;
318        Ok(RemoteChatSession::new(self.session_id, public_key))
319    }
320
321    /// Converts to network-serializable format for transmission
322    ///
323    /// # Errors
324    /// Returns `ValidationError` if key encoding fails
325    pub fn to_protocol_data(&self) -> Result<ProtocolRemoteChatSessionData, ValidationError> {
326        let key_bytes = public_key_to_bytes(&self.profile_public_key.key)?;
327
328        Ok(ProtocolRemoteChatSessionData {
329            session_id: self.session_id,
330            expires_at_millis: system_time_to_millis(self.profile_public_key.expires_at),
331            public_key_bytes: key_bytes,
332            key_signature: self.profile_public_key.key_signature.clone(),
333        })
334    }
335}
336
337pub(super) fn system_time_from_millis(millis: i64) -> SystemTime {
338    if millis >= 0 {
339        UNIX_EPOCH + Duration::from_millis(millis as u64)
340    } else {
341        UNIX_EPOCH - Duration::from_millis(millis.unsigned_abs())
342    }
343}
344
345fn system_time_to_millis(time: SystemTime) -> i64 {
346    match time.duration_since(UNIX_EPOCH) {
347        Ok(duration) => i64::try_from(duration.as_millis()).unwrap_or(i64::MAX),
348        Err(error) => match i64::try_from(error.duration().as_millis()) {
349            Ok(millis) => millis.saturating_neg(),
350            Err(_) => i64::MIN,
351        },
352    }
353}
354
355// Helper struct for byte slice updater
356mod signature_helpers {
357    use steel_crypto::{
358        CryptError,
359        signature::{SignatureOutput, SignatureUpdater},
360    };
361
362    pub struct ByteSliceUpdater<'a>(pub &'a [u8]);
363
364    impl SignatureUpdater for ByteSliceUpdater<'_> {
365        fn update(&self, output: &mut dyn SignatureOutput) -> Result<(), CryptError> {
366            output.update(self.0)
367        }
368    }
369}
370
371use signature_helpers::ByteSliceUpdater;
372
373#[cfg(test)]
374mod tests {
375    use rsa::{
376        pkcs1v15::SigningKey,
377        signature::{SignatureEncoding as _, Signer as _},
378    };
379    use sha1::Sha1;
380    use steel_crypto::{generate_key_pair, public_key_to_bytes, signature::ProfileKeyValidator};
381    use uuid::Uuid;
382
383    use super::{ProfilePublicKeyData, ValidationError, system_time_from_millis};
384
385    fn signed_profile_key(
386        profile_id: Uuid,
387        expires_at_millis: i64,
388    ) -> (ProfilePublicKeyData, ProfileKeyValidator) {
389        let (service_private_key, service_public_key) =
390            generate_key_pair().expect("test service key should generate");
391        let (_, player_public_key) = generate_key_pair().expect("test player key should generate");
392        let player_key_der =
393            public_key_to_bytes(&player_public_key).expect("test player key should encode");
394        let mut payload = Vec::with_capacity(24 + player_key_der.len());
395        payload.extend_from_slice(&profile_id.as_u128().to_be_bytes());
396        payload.extend_from_slice(&expires_at_millis.to_be_bytes());
397        payload.extend_from_slice(&player_key_der);
398        let signature = SigningKey::<Sha1>::new(service_private_key)
399            .sign(&payload)
400            .to_bytes()
401            .to_vec();
402
403        (
404            ProfilePublicKeyData::new(
405                system_time_from_millis(expires_at_millis),
406                player_public_key,
407                signature,
408            ),
409            ProfileKeyValidator::new(vec![service_public_key])
410                .expect("test service key should create a validator"),
411        )
412    }
413
414    #[test]
415    fn profile_key_signature_binds_uuid_expiry_and_public_key() {
416        let profile_id = Uuid::from_u128(1);
417        let (data, validator) = signed_profile_key(profile_id, 1_234);
418
419        data.validate_signature(profile_id, &validator)
420            .expect("untampered profile key should validate");
421        assert!(matches!(
422            data.validate_signature(Uuid::from_u128(2), &validator),
423            Err(ValidationError::InvalidSignature)
424        ));
425
426        let mut changed_expiry = data.clone();
427        changed_expiry.expires_at = system_time_from_millis(1_235);
428        assert!(matches!(
429            changed_expiry.validate_signature(profile_id, &validator),
430            Err(ValidationError::InvalidSignature)
431        ));
432
433        let mut changed_key = data.clone();
434        changed_key.key = generate_key_pair()
435            .expect("replacement player key should generate")
436            .1;
437        assert!(matches!(
438            changed_key.validate_signature(profile_id, &validator),
439            Err(ValidationError::InvalidSignature)
440        ));
441    }
442
443    #[test]
444    fn profile_key_signature_rejects_tampered_signature() {
445        let profile_id = Uuid::from_u128(1);
446        let (mut data, validator) = signed_profile_key(profile_id, 1_234);
447        data.key_signature[0] ^= 1;
448
449        assert!(matches!(
450            data.validate_signature(profile_id, &validator),
451            Err(ValidationError::InvalidSignature)
452        ));
453    }
454
455    #[test]
456    fn signed_payload_preserves_negative_expiry_millis() {
457        let profile_id = Uuid::from_u128(1);
458        let (data, validator) = signed_profile_key(profile_id, -1);
459
460        data.validate_signature(profile_id, &validator)
461            .expect("pre-epoch expiry should retain its signed timestamp");
462    }
463}