feat(bedrock): Add Protocol encryption

This commit is contained in:
Alexander Medvedev 2026-07-04 15:01:42 +02:00
parent 52838d945b
commit c60fa90ed8
17 changed files with 443 additions and 41 deletions

22
Cargo.lock generated
View File

@ -431,6 +431,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"inout", "inout",
] ]
@ -964,6 +965,15 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@ -1111,6 +1121,7 @@ dependencies = [
"ff", "ff",
"generic-array", "generic-array",
"group", "group",
"hkdf",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"rand_core 0.6.4", "rand_core 0.6.4",
"sec1", "sec1",
@ -1513,6 +1524,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -2810,6 +2830,7 @@ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"cfb8", "cfb8",
"ctr",
"flate2", "flate2",
"hybrid-array", "hybrid-array",
"pumpkin-data", "pumpkin-data",
@ -2818,6 +2839,7 @@ dependencies = [
"pumpkin-util", "pumpkin-util",
"pumpkin-world", "pumpkin-world",
"serde", "serde",
"sha2 0.11.0",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",

View File

@ -123,6 +123,7 @@ base64 = { version = "0.22.1", default-features = false, features = ["std"] }
bitflags = { version = "2.13.0", default-features = false, features = ["std"] } bitflags = { version = "2.13.0", default-features = false, features = ["std"] }
cesu8 = { version = "1.1", default-features = false } cesu8 = { version = "1.1", default-features = false }
cfb8 = { version = "0.9", default-features = false } cfb8 = { version = "0.9", default-features = false }
ctr = { version = "0.10.1", default-features = false }
colored = { version = "3.1", default-features = false } colored = { version = "3.1", default-features = false }
console-subscriber = { version = "0.5.0", default-features = false } console-subscriber = { version = "0.5.0", default-features = false }
crc-fast = { version = "1.10.0", default-features = false, features = ["std"] } crc-fast = { version = "1.10.0", default-features = false, features = ["std"] }
@ -146,7 +147,7 @@ md5 = { version = "0.8", default-features = false }
num-bigint = { version = "0.5", default-features = false, features = ["std"] } num-bigint = { version = "0.5", default-features = false, features = ["std"] }
num-derive = { version = "0.4", default-features = false } num-derive = { version = "0.4", default-features = false }
num-traits = { version = "0.2", default-features = false, features = ["std"] } num-traits = { version = "0.2", default-features = false, features = ["std"] }
p384 = { version = "0.13.1", default-features = false, features = ["std", "arithmetic", "pkcs8"] } p384 = { version = "0.13.1", default-features = false, features = ["std", "arithmetic", "pkcs8", "ecdh"] }
phf = { version = "0.14.0", default-features = false, features = ["std"] } phf = { version = "0.14.0", default-features = false, features = ["std"] }
proc-macro2 = { version = "1.0", default-features = false, features = ["proc-macro"] } proc-macro2 = { version = "1.0", default-features = false, features = ["proc-macro"] }
pumpkin-codecs = { path = "pumpkin-codecs", default-features = false } pumpkin-codecs = { path = "pumpkin-codecs", default-features = false }

View File

@ -114,6 +114,8 @@ pub struct BasicConfiguration {
pub bedrock_edition: bool, pub bedrock_edition: bool,
/// Whether Bedrock Edition Clients are Accepted. /// Whether Bedrock Edition Clients are Accepted.
pub bedrock_edition_address: SocketAddr, pub bedrock_edition_address: SocketAddr,
/// Whether packet encryption is enabled for Bedrock Edition.
pub bedrock_encryption: bool,
/// The seed for the world generation. /// The seed for the world generation.
pub seed: Seed, pub seed: Seed,
/// The maximum number of players allowed on the server. Specifying `0` disables the limit. /// The maximum number of players allowed on the server. Specifying `0` disables the limit.
@ -167,6 +169,7 @@ impl Default for BasicConfiguration {
java_edition_address: "0.0.0.0:25565".parse().unwrap(), java_edition_address: "0.0.0.0:25565".parse().unwrap(),
bedrock_edition: true, bedrock_edition: true,
bedrock_edition_address: "0.0.0.0:19132".parse().unwrap(), bedrock_edition_address: "0.0.0.0:19132".parse().unwrap(),
bedrock_encryption: true,
seed: Seed(random::get_seed()), seed: Seed(random::get_seed()),
max_players: 1000, max_players: 1000,
view_distance: NonZeroU8::new(16).unwrap(), view_distance: NonZeroU8::new(16).unwrap(),
@ -217,6 +220,10 @@ impl BasicConfiguration {
self.encryption, self.encryption,
"When online mode is enabled, encryption must be enabled" "When online mode is enabled, encryption must be enabled"
); );
assert!(
self.bedrock_encryption,
"When online mode is enabled, bedrock_encryption must be enabled"
);
} }
if self.allow_chat_reports { if self.allow_chat_reports {
assert!( assert!(

View File

@ -27,6 +27,8 @@ bytes.workspace = true
# encryption # encryption
aes.workspace = true aes.workspace = true
cfb8.workspace = true cfb8.workspace = true
ctr.workspace = true
sha2.workspace = true
hybrid-array = "0.4" hybrid-array = "0.4"
# compression # compression

View File

@ -1,7 +1,7 @@
use crate::serial::PacketWrite;
use pumpkin_macros::packet; use pumpkin_macros::packet;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(PacketWrite)]
#[packet(0x03)] #[packet(0x03)]
pub struct CHandshake { pub struct CHandshake {
jwt_data: String, jwt_data: String,

View File

@ -0,0 +1,121 @@
use aes::Aes256;
use ctr::Ctr128BE;
use ctr::cipher::{KeyIvInit, StreamCipher};
use sha2::{Digest, Sha256};
type BedrockCtr = Ctr128BE<Aes256>;
pub struct BedrockEncryptor {
cipher: BedrockCtr,
key: [u8; 32],
send_counter: u64,
}
impl BedrockEncryptor {
#[must_use]
pub fn new(key: &[u8; 32]) -> Self {
let mut iv = [0u8; 16];
iv[..12].copy_from_slice(&key[..12]);
iv[12..].copy_from_slice(&[0, 0, 0, 2]);
Self {
cipher: BedrockCtr::new(key.into(), &iv.into()),
key: *key,
send_counter: 0,
}
}
pub fn encrypt(&mut self, data: &mut Vec<u8>) {
// data contains the payload after 0xfe
let mut hasher = Sha256::new();
hasher.update(self.send_counter.to_le_bytes());
hasher.update(&data[..]);
hasher.update(self.key);
let hash = hasher.finalize();
data.extend_from_slice(&hash[..8]);
self.cipher.apply_keystream(data);
self.send_counter += 1;
}
}
pub struct BedrockDecryptor {
cipher: BedrockCtr,
key: [u8; 32],
send_counter: u64,
}
impl BedrockDecryptor {
#[must_use]
pub fn new(key: &[u8; 32]) -> Self {
let mut iv = [0u8; 16];
iv[..12].copy_from_slice(&key[..12]);
iv[12..].copy_from_slice(&[0, 0, 0, 2]);
Self {
cipher: BedrockCtr::new(key.into(), &iv.into()),
key: *key,
send_counter: 0,
}
}
#[expect(clippy::needless_borrow)] // False positive
pub fn decrypt(&mut self, data: &mut Vec<u8>) -> Result<(), String> {
let ciphertext = data.clone();
self.cipher.apply_keystream(data);
if data.len() < 8 {
return Err("Encrypted packet must be at least 8 bytes long".to_string());
}
let (payload, checksum) = data.split_at(data.len() - 8);
let mut hasher = Sha256::new();
hasher.update(self.send_counter.to_le_bytes());
hasher.update(payload);
hasher.update(self.key);
let our_checksum = &hasher.finalize()[..8];
if checksum != our_checksum {
let cipher_prefix = if ciphertext.len() > 16 {
&ciphertext[..16]
} else {
&ciphertext
};
let plain_prefix = if data.len() > 16 { &data[..16] } else { &data };
return Err(format!(
"Invalid checksum: expected {:x?}, got {:x?}. Cipher prefix: {:x?}, Plain prefix: {:x?}, Key: {:x?}, Counter: {}",
our_checksum, checksum, cipher_prefix, plain_prefix, self.key, self.send_counter
));
}
data.truncate(payload.len());
self.send_counter += 1;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gcm_compatibility() {
let key = [1u8; 32];
let mut iv = [0u8; 16];
iv[..12].copy_from_slice(&key[..12]);
iv[12..].copy_from_slice(&[0, 0, 0, 2]);
let mut cipher = BedrockCtr::new((&key).into(), (&iv).into());
let mut data = b"Hello Bedrock encryption!".to_vec();
cipher.apply_keystream(&mut data);
let expected = &[
0xfa, 0x1c, 0xd6, 0xf6, 0x06, 0xd7, 0x47, 0x96, 0x8e, 0xd7, 0x60, 0xfe, 0xc5, 0x1c,
0x2b, 0xc7, 0x7e, 0x46, 0x17, 0x74, 0x25, 0x96, 0x34, 0x0c, 0xac,
];
assert_eq!(data, expected);
}
}

View File

@ -1,5 +1,6 @@
pub mod ack; pub mod ack;
pub mod client; pub mod client;
pub mod crypto;
pub mod frame_set; pub mod frame_set;
pub mod network_item; pub mod network_item;
pub mod packet_decoder; pub mod packet_decoder;

View File

@ -47,11 +47,14 @@ impl<R: AsyncRead + Unpin> AsyncRead for DecryptionReader<R> {
} }
} }
use crate::bedrock::crypto::BedrockDecryptor;
/// Decoder: Client -> Server /// Decoder: Client -> Server
/// Supports `ZLib` decoding/decompression /// Supports `ZLib` decoding/decompression
/// Supports Aes128 Encryption /// Supports Aes256 Encryption
pub struct UDPNetworkDecoder { pub struct UDPNetworkDecoder {
compression: Option<CompressionThreshold>, compression: Option<CompressionThreshold>,
decryptor: Option<BedrockDecryptor>,
} }
impl Default for UDPNetworkDecoder { impl Default for UDPNetworkDecoder {
@ -69,18 +72,21 @@ pub struct EncryptionAlreadyEnabledError;
impl UDPNetworkDecoder { impl UDPNetworkDecoder {
#[must_use] #[must_use]
pub const fn new() -> Self { pub const fn new() -> Self {
Self { compression: None } Self {
compression: None,
decryptor: None,
}
} }
pub const fn set_compression(&mut self, threshold: CompressionThreshold) { pub const fn set_compression(&mut self, threshold: CompressionThreshold) {
self.compression = Some(threshold); self.compression = Some(threshold);
} }
/// NOTE: Encryption can only be set; a minecraft stream cannot go back to being unencrypted pub fn set_encryption(&mut self, key: &[u8; 32]) -> Result<(), EncryptionAlreadyEnabledError> {
pub const fn set_encryption( if self.decryptor.is_some() {
&mut self, return Err(EncryptionAlreadyEnabledError);
_key: &[u8; 16], }
) -> Result<(), EncryptionAlreadyEnabledError> { self.decryptor = Some(BedrockDecryptor::new(key));
Ok(()) Ok(())
} }
@ -101,25 +107,34 @@ impl UDPNetworkDecoder {
))); )));
} }
// If compression is NOT enabled yet, the payload starts at index 1. let mut data_to_decrypt = full_packet[1..].to_vec();
if let Some(decryptor) = &mut self.decryptor {
decryptor
.decrypt(&mut data_to_decrypt)
.map_err(PacketDecodeError::Message)?;
}
let full_packet_payload = data_to_decrypt;
// If compression is NOT enabled yet, the payload starts at index 0 of full_packet_payload
if self.compression.is_none() { if self.compression.is_none() {
let payload = &full_packet[1..]; let payload = &full_packet_payload[..];
if payload.len() > MAX_PACKET_DATA_SIZE { if payload.len() > MAX_PACKET_DATA_SIZE {
return Err(PacketDecodeError::TooLong); return Err(PacketDecodeError::TooLong);
} }
return Ok(payload.to_vec()); return Ok(payload.to_vec());
} }
// If compression IS enabled, Bedrock expects a compression method byte at index 1. // If compression IS enabled, Bedrock expects a compression method byte at index 0 of full_packet_payload.
let compression_method = *full_packet.get(1).ok_or_else(|| { let compression_method = full_packet_payload.first().ok_or_else(|| {
PacketDecodeError::MalformedLength("Missing Bedrock compression method".into()) PacketDecodeError::MalformedLength("Missing Bedrock compression method".into())
})?; })?;
let data_start = 2; let data_start = 1;
match compression_method { match compression_method {
0x00 => { 0x00 => {
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let compressed_payload = &full_packet[data_start..]; let compressed_payload = &full_packet_payload[data_start..];
let mut decoder = DeflateDecoder::new(BufReader::new(compressed_payload)) let mut decoder = DeflateDecoder::new(BufReader::new(compressed_payload))
.take(MAX_PACKET_DATA_SIZE as u64 + 1); .take(MAX_PACKET_DATA_SIZE as u64 + 1);
let mut decompressed = Vec::new(); let mut decompressed = Vec::new();
@ -134,7 +149,7 @@ impl UDPNetworkDecoder {
} }
0xff => { 0xff => {
// None (Compression enabled but this specific packet is raw) // None (Compression enabled but this specific packet is raw)
let payload = &full_packet[data_start..]; let payload = &full_packet_payload[data_start..];
if payload.len() > MAX_PACKET_DATA_SIZE { if payload.len() > MAX_PACKET_DATA_SIZE {
return Err(PacketDecodeError::TooLong); return Err(PacketDecodeError::TooLong);
} }
@ -211,7 +226,7 @@ mod tests {
const PAYLOAD_LEN: usize = 2 * 1024 * 1024 + 1; const PAYLOAD_LEN: usize = 2 * 1024 * 1024 + 1;
let payload = vec![0x2a; PAYLOAD_LEN]; let payload = vec![0x2a; PAYLOAD_LEN];
let mut wire_buf = Vec::new(); let mut wire_buf = Vec::new();
let network_encoder = UDPNetworkEncoder::new(); let mut network_encoder = UDPNetworkEncoder::new();
network_encoder network_encoder
.write_game_packet( .write_game_packet(
0x01, 0x01,

View File

@ -75,12 +75,15 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for EncryptionWriter<W> {
} }
} }
use crate::bedrock::crypto::BedrockEncryptor;
/// Encoder: Server -> Client /// Encoder: Server -> Client
/// Supports `ZLib` endecoding/compression /// Supports `ZLib` endecoding/compression
/// Supports Aes128 Encryption /// Supports Aes256 Encryption
pub struct UDPNetworkEncoder { pub struct UDPNetworkEncoder {
// compression and compression threshold // compression and compression threshold
compression: Option<(CompressionThreshold, CompressionLevel)>, compression: Option<(CompressionThreshold, CompressionLevel)>,
encryptor: Option<BedrockEncryptor>,
} }
impl Default for UDPNetworkEncoder { impl Default for UDPNetworkEncoder {
@ -92,7 +95,10 @@ impl Default for UDPNetworkEncoder {
impl UDPNetworkEncoder { impl UDPNetworkEncoder {
#[must_use] #[must_use]
pub const fn new() -> Self { pub const fn new() -> Self {
Self { compression: None } Self {
compression: None,
encryptor: None,
}
} }
pub const fn set_compression( pub const fn set_compression(
@ -102,16 +108,19 @@ impl UDPNetworkEncoder {
self.compression = Some(compression_info); self.compression = Some(compression_info);
} }
/// NOTE: Encryption can only be set; a minecraft stream cannot go back to being unencrypted pub fn set_encryption(
pub const fn set_encryption(
&mut self, &mut self,
_key: &[u8; 16], key: &[u8; 32],
) -> Result<(), crate::bedrock::packet_decoder::EncryptionAlreadyEnabledError> { ) -> Result<(), crate::bedrock::packet_decoder::EncryptionAlreadyEnabledError> {
if self.encryptor.is_some() {
return Err(crate::bedrock::packet_decoder::EncryptionAlreadyEnabledError);
}
self.encryptor = Some(BedrockEncryptor::new(key));
Ok(()) Ok(())
} }
pub fn write_game_packet( pub fn write_game_packet(
&self, &mut self,
packet_id: u16, packet_id: u16,
sub_client_sender: SubClient, sub_client_sender: SubClient,
sub_client_target: SubClient, sub_client_target: SubClient,
@ -142,21 +151,27 @@ impl UDPNetworkEncoder {
.write_u8(0xfe) .write_u8(0xfe)
.map_err(|e| Error::other(e.to_string()))?; // Bedrock Game Packet Header .map_err(|e| Error::other(e.to_string()))?; // Bedrock Game Packet Header
let mut data_to_encrypt = Vec::new();
if let Some((_threshold, level)) = self.compression { if let Some((_threshold, level)) = self.compression {
// Write Compression Method (0x00 for Zlib) // Write Compression Method (0x00 for Zlib)
writer data_to_encrypt.push(0x00);
.write_u8(0x00)
.map_err(|e| Error::other(e.to_string()))?;
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::new(level)); let mut encoder = DeflateEncoder::new(Vec::new(), Compression::new(level));
encoder.write_all(&inner_buffer)?; encoder.write_all(&inner_buffer)?;
let compressed_data = encoder.finish()?; let compressed_data = encoder.finish()?;
writer.write_all(&compressed_data)?; data_to_encrypt.extend_from_slice(&compressed_data);
} else { } else {
writer.write_all(&inner_buffer)?; data_to_encrypt.extend_from_slice(&inner_buffer);
} }
if let Some(encryptor) = &mut self.encryptor {
encryptor.encrypt(&mut data_to_encrypt);
}
writer.write_all(&data_to_encrypt)?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,7 @@
use pumpkin_macros::packet;
use crate::serial::PacketRead;
#[derive(PacketRead)]
#[packet(0x04)]
pub struct SClientToServerHandshake;

View File

@ -2,6 +2,7 @@ pub mod actor_event;
pub mod animate; pub mod animate;
pub mod block_pick_request; pub mod block_pick_request;
pub mod client_cache_status; pub mod client_cache_status;
pub mod client_to_server_handshake;
pub mod command_request; pub mod command_request;
pub mod container_close; pub mod container_close;
pub mod emote; pub mod emote;
@ -28,6 +29,7 @@ pub use actor_event::*;
pub use animate::*; pub use animate::*;
pub use block_pick_request::*; pub use block_pick_request::*;
pub use client_cache_status::*; pub use client_cache_status::*;
pub use client_to_server_handshake::*;
pub use command_request::*; pub use command_request::*;
pub use container_close::*; pub use container_close::*;
pub use emote::*; pub use emote::*;

View File

@ -25,7 +25,7 @@ enum_dispatch.workspace = true
uuid.workspace = true uuid.workspace = true
tokio = { workspace = true, features = ["sync"] } tokio = { workspace = true, features = ["sync"] }
base64.workspace = true base64.workspace = true
p384 = { workspace = true, features = ["ecdsa"] } p384 = { workspace = true, features = ["ecdsa", "ecdh"] }
thiserror.workspace = true thiserror.workspace = true
ecdsa.workspace = true ecdsa.workspace = true
rsa.workspace = true rsa.workspace = true

View File

@ -93,7 +93,10 @@ pub fn decode_b64_standard(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
/// ///
/// A `Result` containing the `p384::PublicKey` or an `AuthError`. /// A `Result` containing the `p384::PublicKey` or an `AuthError`.
pub fn build_public_key_from_b64(b64: &str) -> Result<PublicKey, AuthError> { pub fn build_public_key_from_b64(b64: &str) -> Result<PublicKey, AuthError> {
let bytes = decode_b64_standard(b64)?; let bytes = decode_b64_standard(b64)
.or_else(|_| general_purpose::URL_SAFE.decode(b64))
.or_else(|_| general_purpose::URL_SAFE_NO_PAD.decode(b64))
.map_err(AuthError::Base64Decode)?;
if !bytes.is_empty() && bytes[0] == 0x30 { if !bytes.is_empty() && bytes[0] == 0x30 {
PublicKey::from_public_key_der(&bytes).map_err(|e| AuthError::PublicKeyBuild(e.to_string())) PublicKey::from_public_key_der(&bytes).map_err(|e| AuthError::PublicKeyBuild(e.to_string()))
@ -500,3 +503,67 @@ fn xuid_to_uuid(xuid: &str) -> String {
bytes[8] = (bytes[8] & 0x3f) | 0x80; bytes[8] = (bytes[8] & 0x3f) | 0x80;
uuid::Uuid::from_bytes(bytes).to_string() uuid::Uuid::from_bytes(bytes).to_string()
} }
pub fn extract_cpk_from_token(token: &str) -> Result<PublicKey, AuthError> {
let mut parts = token.split('.');
parts.next().ok_or(AuthError::InvalidTokenFormat)?;
let payload_b64 = parts.next().ok_or(AuthError::InvalidTokenFormat)?;
let payload_bytes = decode_b64_url_nopad(payload_b64)?;
let v: Value = serde_json::from_slice(&payload_bytes)?;
let cpk_b64 = v
.get("cpk")
.and_then(|v| v.as_str())
.ok_or_else(|| AuthError::PublicKeyBuild("OIDC payload missing cpk".into()))?;
build_public_key_from_b64(cpk_b64)
}
pub fn generate_handshake_jwt(
signing_key: &p384::ecdsa::SigningKey,
salt: &[u8],
) -> Result<String, AuthError> {
use p384::ecdsa::signature::Signer;
use p384::pkcs8::EncodePublicKey;
let public_key = p384::PublicKey::from(signing_key.verifying_key());
let der_bytes = public_key
.to_public_key_der()
.map_err(|e| AuthError::PublicKeyBuild(e.to_string()))?;
let x5u = general_purpose::STANDARD.encode(der_bytes.as_bytes());
let salt_b64 = general_purpose::STANDARD_NO_PAD.encode(salt);
let header_json = serde_json::json!({
"alg": "ES384",
"x5u": x5u
});
let payload_json = serde_json::json!({
"salt": salt_b64
});
let header_b64 = general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header_json)?);
let payload_b64 = general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json)?);
let signing_input = format!("{header_b64}.{payload_b64}");
let signature: p384::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let signature_bytes = signature.to_bytes();
let signature_b64 = general_purpose::URL_SAFE_NO_PAD.encode(signature_bytes);
Ok(format!("{signing_input}.{signature_b64}"))
}
#[must_use]
pub fn compute_shared_secret(
signing_key: &p384::ecdsa::SigningKey,
client_public_key: &p384::PublicKey,
) -> [u8; 48] {
let secret = p384::SecretKey::from(signing_key);
let shared_secret =
p384::ecdh::diffie_hellman(secret.to_nonzero_scalar(), client_public_key.as_affine());
let mut secret_bytes = [0u8; 48];
secret_bytes.copy_from_slice(&shared_secret.raw_secret_bytes()[..]);
secret_bytes
}

View File

@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ops::{Index, IndexMut}; use std::ops::{Index, IndexMut};
pub use p384;
pub use serde_json; pub use serde_json;
pub use difficulty::Difficulty; pub use difficulty::Difficulty;

View File

@ -5,6 +5,7 @@ use crate::{
server::Server, server::Server,
}; };
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use pumpkin_protocol::bedrock::client::handshake::CHandshake;
use pumpkin_protocol::bedrock::{ use pumpkin_protocol::bedrock::{
client::{ client::{
network_settings::CNetworkSettings, play_status::CPlayStatus, network_settings::CNetworkSettings, play_status::CPlayStatus,
@ -21,8 +22,10 @@ use pumpkin_protocol::bedrock::{
use pumpkin_util::jwt::AuthError; use pumpkin_util::jwt::AuthError;
use pumpkin_util::version::BedrockMinecraftVersion; use pumpkin_util::version::BedrockMinecraftVersion;
use pumpkin_world::{CURRENT_BEDROCK_MC_PROTOCOL, CURRENT_BEDROCK_MC_VERSION}; use pumpkin_world::{CURRENT_BEDROCK_MC_PROTOCOL, CURRENT_BEDROCK_MC_VERSION};
use rand::RngExt;
use serde::{Deserialize, de::Error}; use serde::{Deserialize, de::Error};
use serde_repr::Deserialize_repr; use serde_repr::Deserialize_repr;
use sha2::{Digest, Sha256};
use std::sync::Arc; use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use tracing::debug; use tracing::debug;
@ -123,15 +126,16 @@ impl BedrockClient {
self: &Arc<Self>, self: &Arc<Self>,
packet: SLogin, packet: SLogin,
server: &Server, server: &Server,
) -> Result<PacketHandlerResult, LoginError> { ) -> Result<Option<PacketHandlerResult>, LoginError> {
self.try_handle_login(packet, server).await self.try_handle_login(packet, server).await
} }
#[expect(clippy::too_many_lines)]
pub async fn try_handle_login( pub async fn try_handle_login(
self: &Arc<Self>, self: &Arc<Self>,
packet: SLogin, packet: SLogin,
server: &Server, server: &Server,
) -> Result<PacketHandlerResult, LoginError> { ) -> Result<Option<PacketHandlerResult>, LoginError> {
let auth_payload: AuthPayload = serde_json::from_slice(&packet.jwt)?; let auth_payload: AuthPayload = serde_json::from_slice(&packet.jwt)?;
let player_data = if server.basic_config.online_mode { let player_data = if server.basic_config.online_mode {
match auth_payload.authentication_type { match auth_payload.authentication_type {
@ -178,6 +182,80 @@ impl BedrockClient {
profile_actions: None, profile_actions: None,
}; };
if server.basic_config.bedrock_encryption {
let client_public_key = pumpkin_util::jwt::extract_cpk_from_token(&auth_payload.token)
.map_err(LoginError::ChainValidationFailed)?;
let (server_private_key, salt) = {
let server_key_arc = server
.bedrock_private_key
.get_or_init(|| async {
let mut rng = rand::rng();
loop {
let mut private_key_bytes = [0u8; 48];
for b in &mut private_key_bytes {
*b = rng.random();
}
if let Ok(key) = pumpkin_util::p384::ecdsa::SigningKey::from_slice(
&private_key_bytes,
) {
break Arc::new(key);
}
}
})
.await
.clone();
let mut salt = [0u8; 16];
for b in &mut salt {
*b = rand::rng().random();
}
(server_key_arc, salt)
};
let handshake_jwt =
pumpkin_util::jwt::generate_handshake_jwt(&*server_private_key, &salt)
.map_err(LoginError::ChainValidationFailed)?;
let handshake_packet = CHandshake::new(handshake_jwt);
self.send_game_packet(&handshake_packet).await;
let shared_secret =
pumpkin_util::jwt::compute_shared_secret(&*server_private_key, &client_public_key);
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(shared_secret);
let key_bytes: [u8; 32] = hasher.finalize().into();
self.network_reader
.lock()
.await
.set_encryption(&key_bytes)
.map_err(|_| {
LoginError::ChainValidationFailed(AuthError::PublicKeyBuild(
"encryption enable error".into(),
))
})?;
self.network_writer
.write()
.await
.set_encryption(&key_bytes)
.map_err(|_| {
LoginError::ChainValidationFailed(AuthError::PublicKeyBuild(
"encryption enable error".into(),
))
})?;
let new_config = PlayerConfig {
locale: client_data.language_code.clone(),
..Default::default()
};
self.client_data
.store(std::sync::Arc::new(Some(std::sync::Arc::new(client_data))));
*self.pending_profile.lock().await = Some((profile, new_config));
return Ok(None);
}
let mut frame_set = FrameSet::default(); let mut frame_set = FrameSet::default();
self.write_game_packet_to_set(&CPlayStatus::LoginSuccess, &mut frame_set) self.write_game_packet_to_set(&CPlayStatus::LoginSuccess, &mut frame_set)
@ -224,7 +302,7 @@ impl BedrockClient {
self.client_data self.client_data
.store(std::sync::Arc::new(Some(std::sync::Arc::new(client_data)))); .store(std::sync::Arc::new(Some(std::sync::Arc::new(client_data))));
Ok(PacketHandlerResult::ReadyToPlay(profile, new_config)) Ok(Some(PacketHandlerResult::ReadyToPlay(profile, new_config)))
} }
pub async fn handle_resource_pack_response( pub async fn handle_resource_pack_response(

View File

@ -22,8 +22,11 @@ use pumpkin_protocol::{
SubClient, UDP_HEADER_SIZE, SubClient, UDP_HEADER_SIZE,
ack::Acknowledge, ack::Acknowledge,
client::{ client::{
disconnect_player::CDisconnectPlayer, level_chunk::CLevelChunk, disconnect_player::CDisconnectPlayer,
level_chunk::CLevelChunk,
play_status::CPlayStatus,
raknet::connection::CConnectionRequestAccepted, raknet::connection::CConnectionRequestAccepted,
resource_packs_info::{CResourcePacksInfo, ResourcePackEntry},
}, },
frame_set::{Frame, FrameSet}, frame_set::{Frame, FrameSet},
packet_decoder::UDPNetworkDecoder, packet_decoder::UDPNetworkDecoder,
@ -32,6 +35,7 @@ use pumpkin_protocol::{
animate::SAnimate, animate::SAnimate,
block_pick_request::SBlockPickRequest, block_pick_request::SBlockPickRequest,
client_cache_status::SClientCacheStatus, client_cache_status::SClientCacheStatus,
client_to_server_handshake::SClientToServerHandshake,
command_request::SCommandRequest, command_request::SCommandRequest,
container_close::SContainerClose, container_close::SContainerClose,
emote::SEmote, emote::SEmote,
@ -78,7 +82,7 @@ pub mod open_connection;
pub mod unconnected; pub mod unconnected;
use crate::{ use crate::{
entity::player::Player, entity::player::Player,
net::{DisconnectReason, PacketHandlerResult}, net::{DisconnectReason, GameProfile, PacketHandlerResult, PlayerConfig},
plugin::api::events::world::chunk_send::ChunkSend, plugin::api::events::world::chunk_send::ChunkSend,
server::Server, server::Server,
}; };
@ -157,6 +161,7 @@ pub struct BedrockClient {
ordered_queues: Mutex<HashMap<u8, BTreeMap<u32, Frame>>>, ordered_queues: Mutex<HashMap<u8, BTreeMap<u32, Frame>>>,
incoming_game_packet_send: Sender<RawPacket>, incoming_game_packet_send: Sender<RawPacket>,
incoming_game_packet_recv: Mutex<Option<Receiver<RawPacket>>>, incoming_game_packet_recv: Mutex<Option<Receiver<RawPacket>>>,
pending_profile: Mutex<Option<(GameProfile, PlayerConfig)>>,
} }
impl BedrockClient { impl BedrockClient {
@ -205,6 +210,7 @@ impl BedrockClient {
//input_sequence_number: AtomicU32::new(0), //input_sequence_number: AtomicU32::new(0),
incoming_game_packet_send: incoming_send, incoming_game_packet_send: incoming_send,
incoming_game_packet_recv: Mutex::new(Some(incoming_recv)), incoming_game_packet_recv: Mutex::new(Some(incoming_recv)),
pending_profile: Mutex::new(None),
} }
} }
@ -277,7 +283,7 @@ impl BedrockClient {
} }
if !resend.is_empty() { if !resend.is_empty() {
let encoder = client.network_writer.read().await; let encoder = client.network_writer.write().await;
for (seq, id, data) in resend { for (seq, id, data) in resend {
debug!("Resending reliable sequence {} (ID: {})", seq, id); debug!("Resending reliable sequence {} (ID: {})", seq, id);
if let Err(err) = encoder.write_packet(&data, client.address, &client.socket).await { if let Err(err) = encoder.write_packet(&data, client.address, &client.socket).await {
@ -382,7 +388,7 @@ impl BedrockClient {
let mut packets_to_enqueue = Vec::with_capacity(encoded_payloads.len()); let mut packets_to_enqueue = Vec::with_capacity(encoded_payloads.len());
{ {
let encoder = self.network_writer.read().await; let mut encoder = self.network_writer.write().await;
for payload in encoded_payloads { for payload in encoded_payloads {
let mut packet_buf = Vec::new(); let mut packet_buf = Vec::new();
match encoder.write_game_packet( match encoder.write_game_packet(
@ -440,7 +446,7 @@ impl BedrockClient {
} }
{ {
let Ok(network_writer) = self.network_writer.try_read() else { let Ok(mut network_writer) = self.network_writer.try_write() else {
debug!("Failed to lock network writer for try_enqueue_packet"); debug!("Failed to lock network writer for try_enqueue_packet");
return; return;
}; };
@ -517,7 +523,7 @@ impl BedrockClient {
let mut packet_payload = Vec::new(); let mut packet_payload = Vec::new();
packet.write_packet(&mut packet_payload)?; packet.write_packet(&mut packet_payload)?;
let encoder = self.network_writer.read().await; let mut encoder = self.network_writer.write().await;
encoder.write_game_packet( encoder.write_game_packet(
P::PACKET_ID as u16, P::PACKET_ID as u16,
SubClient::Main, SubClient::Main,
@ -996,13 +1002,67 @@ impl BedrockClient {
} }
}; };
match self.handle_login(packet, server).await { match self.handle_login(packet, server).await {
Ok(result) => return result, Ok(Some(result)) => return result,
Ok(None) => {} // encryption enabled, wait for handshake
Err(err) => { Err(err) => {
self.kick(DisconnectReason::Unknown, err.to_string()).await; self.kick(DisconnectReason::Unknown, err.to_string()).await;
return PacketHandlerResult::Stop; return PacketHandlerResult::Stop;
} }
} }
} }
SClientToServerHandshake::PACKET_ID => {
let _packet = match SClientToServerHandshake::read(payload) {
Ok(p) => p,
Err(err) => {
error!("Failed to read SClientToServerHandshake: {err}");
continue;
}
};
let pending = self.pending_profile.lock().await.take();
if let Some((profile, new_config)) = pending {
let mut frame_set = FrameSet::default();
self.write_game_packet_to_set(&CPlayStatus::LoginSuccess, &mut frame_set)
.await;
let br_config = &server.advanced_config.resource_pack.bedrock;
let mut entries = Vec::new();
if br_config.enabled {
for pack in &br_config.packs {
entries.push(ResourcePackEntry {
uuid: pack.uuid,
version: pack.version.clone(),
size: pack.size,
download_url: pack.download_url.clone(),
content_key: pack.content_key.clone(),
sub_pack_name: pack.sub_pack_name.clone(),
content_id: pack.content_id.clone(),
has_scripts: pack.has_scripts,
addon_pack: pack.addon_pack,
rtx_enabled: pack.rtx_enabled,
});
}
}
let packs_info = CResourcePacksInfo {
resource_pack_required: br_config.force,
has_addon_packs: false,
has_scripts: false,
is_vibrant_visuals_force_disabled: false,
world_template_id: uuid::Uuid::nil(),
world_template_version: String::new(),
resource_packs: entries,
};
self.write_game_packet_to_set(&packs_info, &mut frame_set)
.await;
self.send_frame_set(frame_set, 0x84).await;
return PacketHandlerResult::ReadyToPlay(profile, new_config);
}
error!("Received ClientToServerHandshake but no pending profile was found.");
self.kick(DisconnectReason::BadPacket, "Handshake error".into())
.await;
return PacketHandlerResult::Stop;
}
_ => { _ => {
debug!( debug!(
"Received unexpected game packet {} during login sequence", "Received unexpected game packet {} during login sequence",

View File

@ -87,6 +87,8 @@ pub struct Server {
key_store: OnceCell<Arc<KeyStore>>, key_store: OnceCell<Arc<KeyStore>>,
/// Bedrock OIDC provider keys, fetched on startup for 1.26.10+ token validation. /// Bedrock OIDC provider keys, fetched on startup for 1.26.10+ token validation.
pub bedrock_oidc_keys: OnceCell<(String, pumpkin_util::jwt::Jwks)>, pub bedrock_oidc_keys: OnceCell<(String, pumpkin_util::jwt::Jwks)>,
/// Cached Bedrock server private key (process-lifetime). Generated on first Bedrock login and reused.
pub bedrock_private_key: OnceCell<Arc<pumpkin_util::p384::ecdsa::SigningKey>>,
/// Manages server status information. /// Manages server status information.
listing: Mutex<CachedStatus>, listing: Mutex<CachedStatus>,
/// Saves server branding information. /// Saves server branding information.
@ -259,6 +261,7 @@ impl Server {
item_registry: super::item::items::default_registry(), item_registry: super::item::items::default_registry(),
key_store: OnceCell::new(), key_store: OnceCell::new(),
bedrock_oidc_keys: OnceCell::new(), bedrock_oidc_keys: OnceCell::new(),
bedrock_private_key: OnceCell::new(),
listing, listing,
branding: CachedBranding::new(), branding: CachedBranding::new(),
bossbars: Mutex::new(CustomBossbars::new()), bossbars: Mutex::new(CustomBossbars::new()),