Skip to main content

xlog_decoder/core/
decoder.rs

1use super::compress::{decompress_raw_zlib, decompress_zstd_frames, CompressError};
2use super::crypto::{tea_decrypt_in_place, CryptoError, EcdhTeaCipher};
3use super::protocol::{
4    MAGIC_ASYNC_NO_CRYPT_ZLIB_START, MAGIC_ASYNC_NO_CRYPT_ZSTD_START, MAGIC_ASYNC_ZLIB_START,
5    MAGIC_ASYNC_ZSTD_START, MAGIC_END, MAGIC_SYNC_NO_CRYPT_ZLIB_START,
6    MAGIC_SYNC_NO_CRYPT_ZSTD_START, MAGIC_SYNC_ZLIB_START, MAGIC_SYNC_ZSTD_START,
7};
8use hex::FromHexError;
9use thiserror::Error;
10
11const BASE_KEY: u8 = 0xcc;
12const LEGACY_MAGIC_CRYPT_START: u8 = 0x01;
13const LEGACY_MAGIC_COMPRESS_CRYPT_START: u8 = 0x02;
14const LEGACY_NEW_MAGIC_CRYPT_START: u8 = 0x03;
15const LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START: u8 = 0x04;
16const LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START1: u8 = 0x05;
17
18/// Summary of a decoded xlog file.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct DecodeSummary {
21    /// Number of complete blocks consumed.
22    pub blocks: usize,
23    /// Number of payload bytes written after decode/decrypt/decompress.
24    pub output_bytes: usize,
25}
26
27/// Options controlling xlog decode behavior.
28#[derive(Debug, Clone, Default)]
29pub struct DecodeOptions {
30    private_key: Option<[u8; 32]>,
31}
32
33impl DecodeOptions {
34    /// Creates decode options for plaintext xlog files.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Configures the 32-byte server private key used for encrypted async blocks.
40    pub fn private_key(mut self, private_key: [u8; 32]) -> Self {
41        self.private_key = Some(private_key);
42        self
43    }
44
45    /// Parses and configures a 64-hex-character server private key.
46    pub fn private_key_hex(self, private_key_hex: &str) -> Result<Self, DecodeError> {
47        Ok(self.private_key(parse_private_key_hex(private_key_hex)?))
48    }
49}
50
51/// Errors raised while decoding xlog files.
52#[derive(Debug, Error)]
53pub enum DecodeError {
54    /// The private key was not a 64-character hex string.
55    #[error("private key must be 64 hex chars")]
56    InvalidPrivateKeyLength,
57    /// The private key hex string could not be decoded.
58    #[error("invalid private key hex: {0}")]
59    InvalidPrivateKeyHex(#[from] FromHexError),
60    /// The private key or header public key was invalid secp256k1 material.
61    #[error("invalid secp256k1 key material")]
62    InvalidKeyMaterial,
63    /// The xlog input did not contain a decodable log block.
64    #[error("no valid xlog block found")]
65    NoValidBlock,
66    /// An encrypted async block was found without a configured private key.
67    #[error("encrypted xlog block requires --key/--private-key")]
68    MissingPrivateKey,
69    /// A log block declared a payload that extends beyond the input buffer.
70    #[error("truncated xlog block at offset {offset}: payload len {len}")]
71    TruncatedBlock {
72        /// Offset where the broken block starts.
73        offset: usize,
74        /// Payload length declared by the block header.
75        len: usize,
76    },
77    /// A log block was not followed by the expected end marker.
78    #[error("invalid xlog tail marker at offset {offset}")]
79    InvalidTail {
80        /// Offset where the invalid tail byte was found.
81        offset: usize,
82    },
83    /// Compression failed to decode.
84    #[error(transparent)]
85    Compress(#[from] CompressError),
86    /// ECDH/TEA setup failed.
87    #[error(transparent)]
88    Crypto(CryptoError),
89}
90
91impl From<CryptoError> for DecodeError {
92    fn from(value: CryptoError) -> Self {
93        match value {
94            CryptoError::InvalidKeyMaterial => Self::InvalidKeyMaterial,
95            other => Self::Crypto(other),
96        }
97    }
98}
99
100#[derive(Debug, Clone)]
101struct BlockHeader {
102    magic: u8,
103    header_len: usize,
104    len: usize,
105    seq: u16,
106    client_pubkey: [u8; 64],
107}
108
109/// Decodes `input` into `output` using the supplied options.
110pub fn decode_xlog(
111    input: &[u8],
112    output: &mut Vec<u8>,
113    opts: &DecodeOptions,
114) -> Result<DecodeSummary, DecodeError> {
115    let Some(mut offset) = find_log_start(input, 2) else {
116        return Err(DecodeError::NoValidBlock);
117    };
118
119    let mut blocks = 0usize;
120    let mut last_seq = 0u16;
121
122    loop {
123        if offset >= input.len() {
124            break;
125        }
126
127        if !is_good_log_buffer(input, offset, 1) {
128            let Some(fix_pos) = find_log_start(&input[offset..], 1) else {
129                break;
130            };
131            output.extend_from_slice(
132                format!("[F]decode_log_file.py decode error len={fix_pos}\n").as_bytes(),
133            );
134            offset += fix_pos;
135        }
136
137        let header = parse_header(input, offset)?;
138        let payload_start = offset + header.header_len;
139        let payload_end = payload_start + header.len;
140        if payload_end + 1 > input.len() {
141            return Err(DecodeError::TruncatedBlock {
142                offset,
143                len: header.len,
144            });
145        }
146        if input[payload_end] != MAGIC_END {
147            return Err(DecodeError::InvalidTail {
148                offset: payload_end,
149            });
150        }
151
152        if header.seq != 0
153            && header.seq != 1
154            && last_seq != 0
155            && header.seq != last_seq.wrapping_add(1)
156        {
157            output.extend_from_slice(
158                format!(
159                    "[F]decode_log_file.py log seq:{}-{} is missing\n",
160                    last_seq.wrapping_add(1),
161                    header.seq.wrapping_sub(1)
162                )
163                .as_bytes(),
164            );
165        }
166        if header.seq != 0 {
167            last_seq = header.seq;
168        }
169
170        let payload = &input[payload_start..payload_end];
171        let decoded = decode_payload(&header, payload, opts)?;
172        output.extend_from_slice(&decoded);
173        blocks += 1;
174        offset = payload_end + 1;
175    }
176
177    if blocks == 0 {
178        return Err(DecodeError::NoValidBlock);
179    }
180
181    Ok(DecodeSummary {
182        blocks,
183        output_bytes: output.len(),
184    })
185}
186
187/// Parses a 64-hex-character private key into raw bytes.
188pub fn parse_private_key_hex(private_key_hex: &str) -> Result<[u8; 32], DecodeError> {
189    let trimmed = private_key_hex.trim();
190    let trimmed = trimmed.strip_prefix("0x").unwrap_or(trimmed);
191    if trimmed.len() != 64 {
192        return Err(DecodeError::InvalidPrivateKeyLength);
193    }
194
195    let mut out = [0u8; 32];
196    hex::decode_to_slice(trimmed, &mut out)?;
197    Ok(out)
198}
199
200fn decode_payload(
201    header: &BlockHeader,
202    payload: &[u8],
203    opts: &DecodeOptions,
204) -> Result<Vec<u8>, DecodeError> {
205    match header.magic {
206        LEGACY_MAGIC_CRYPT_START | LEGACY_NEW_MAGIC_CRYPT_START => {
207            Ok(xor_payload(payload, legacy_xor_key(header)))
208        }
209        LEGACY_MAGIC_COMPRESS_CRYPT_START | LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START => {
210            let decoded = xor_payload(payload, legacy_xor_key(header));
211            Ok(decompress_raw_zlib(&decoded)?)
212        }
213        LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START1 => {
214            let joined = join_legacy_compressed_segments(payload);
215            let decoded = xor_payload(&joined, legacy_xor_key(header));
216            Ok(decompress_raw_zlib(&decoded)?)
217        }
218        MAGIC_SYNC_ZLIB_START
219        | MAGIC_SYNC_NO_CRYPT_ZLIB_START
220        | MAGIC_SYNC_ZSTD_START
221        | MAGIC_SYNC_NO_CRYPT_ZSTD_START => Ok(payload.to_vec()),
222        MAGIC_ASYNC_ZLIB_START | MAGIC_ASYNC_ZSTD_START => {
223            let private_key = opts.private_key.ok_or(DecodeError::MissingPrivateKey)?;
224            let cipher =
225                EcdhTeaCipher::new_with_server_private_key(header.client_pubkey, private_key)?;
226            let mut decoded = payload.to_vec();
227            let block_end = decoded.len() / 8 * 8;
228            tea_decrypt_in_place(&mut decoded[..block_end], &cipher.tea_key_words());
229            match header.magic {
230                MAGIC_ASYNC_ZLIB_START => Ok(decompress_raw_zlib(&decoded)?),
231                MAGIC_ASYNC_ZSTD_START => Ok(decompress_zstd_frames(&decoded)?),
232                _ => unreachable!(),
233            }
234        }
235        MAGIC_ASYNC_NO_CRYPT_ZLIB_START => Ok(decompress_raw_zlib(payload)?),
236        MAGIC_ASYNC_NO_CRYPT_ZSTD_START => Ok(decompress_zstd_frames(payload)?),
237        _ => Err(DecodeError::NoValidBlock),
238    }
239}
240
241fn parse_header(input: &[u8], offset: usize) -> Result<BlockHeader, DecodeError> {
242    let magic = input
243        .get(offset)
244        .copied()
245        .ok_or(DecodeError::NoValidBlock)?;
246    let Some((header_len, crypt_key_len)) = header_lengths_for_magic(magic) else {
247        return Err(DecodeError::NoValidBlock);
248    };
249    if offset + header_len > input.len() {
250        return Err(DecodeError::TruncatedBlock { offset, len: 0 });
251    }
252
253    let len_offset = offset + header_len - crypt_key_len - 4;
254    let len = u32::from_le_bytes([
255        input[len_offset],
256        input[len_offset + 1],
257        input[len_offset + 2],
258        input[len_offset + 3],
259    ]) as usize;
260
261    let seq = if header_len >= 1 + 2 + 1 + 1 + 4 {
262        let seq_offset = offset + header_len - crypt_key_len - 4 - 2 - 2;
263        u16::from_le_bytes([input[seq_offset], input[seq_offset + 1]])
264    } else {
265        0
266    };
267
268    let mut client_pubkey = [0u8; 64];
269    if crypt_key_len == 64 {
270        let key_start = offset + header_len - crypt_key_len;
271        client_pubkey.copy_from_slice(&input[key_start..key_start + 64]);
272    }
273
274    Ok(BlockHeader {
275        magic,
276        header_len,
277        len,
278        seq,
279        client_pubkey,
280    })
281}
282
283fn header_lengths_for_magic(magic: u8) -> Option<(usize, usize)> {
284    match magic {
285        LEGACY_MAGIC_CRYPT_START | LEGACY_MAGIC_COMPRESS_CRYPT_START => Some((1 + 4, 0)),
286        LEGACY_NEW_MAGIC_CRYPT_START
287        | LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START
288        | LEGACY_NEW_MAGIC_COMPRESS_CRYPT_START1 => Some((1 + 2 + 1 + 1 + 4, 0)),
289        MAGIC_SYNC_ZLIB_START
290        | MAGIC_SYNC_NO_CRYPT_ZLIB_START
291        | MAGIC_ASYNC_ZLIB_START
292        | MAGIC_ASYNC_NO_CRYPT_ZLIB_START
293        | MAGIC_SYNC_ZSTD_START
294        | MAGIC_SYNC_NO_CRYPT_ZSTD_START
295        | MAGIC_ASYNC_ZSTD_START
296        | MAGIC_ASYNC_NO_CRYPT_ZSTD_START => Some((1 + 2 + 1 + 1 + 4 + 64, 64)),
297        _ => None,
298    }
299}
300
301fn is_good_log_buffer(input: &[u8], offset: usize, count: usize) -> bool {
302    if offset == input.len() {
303        return true;
304    }
305    if count == 0 {
306        return true;
307    }
308    let Ok(header) = parse_header(input, offset) else {
309        return false;
310    };
311    let Some(payload_end) = offset
312        .checked_add(header.header_len)
313        .and_then(|value| value.checked_add(header.len))
314    else {
315        return false;
316    };
317    if payload_end + 1 > input.len() || input[payload_end] != MAGIC_END {
318        return false;
319    }
320    if count <= 1 {
321        return true;
322    }
323    is_good_log_buffer(input, payload_end + 1, count - 1)
324}
325
326fn find_log_start(input: &[u8], count: usize) -> Option<usize> {
327    let mut offset = 0usize;
328    while offset < input.len() {
329        if header_lengths_for_magic(input[offset]).is_some()
330            && is_good_log_buffer(input, offset, count)
331        {
332            return Some(offset);
333        }
334        offset += 1;
335    }
336    None
337}
338
339fn legacy_xor_key(header: &BlockHeader) -> u8 {
340    match header.magic {
341        LEGACY_MAGIC_CRYPT_START | LEGACY_MAGIC_COMPRESS_CRYPT_START => {
342            BASE_KEY ^ (header.len as u8) ^ header.magic
343        }
344        _ => BASE_KEY ^ (header.seq as u8) ^ header.magic,
345    }
346}
347
348fn xor_payload(payload: &[u8], key: u8) -> Vec<u8> {
349    payload.iter().map(|byte| byte ^ key).collect()
350}
351
352fn join_legacy_compressed_segments(payload: &[u8]) -> Vec<u8> {
353    let mut out = Vec::with_capacity(payload.len());
354    let mut offset = 0usize;
355    while offset + 2 <= payload.len() {
356        let len = u16::from_le_bytes([payload[offset], payload[offset + 1]]) as usize;
357        offset += 2;
358        let end = offset.saturating_add(len).min(payload.len());
359        out.extend_from_slice(&payload[offset..end]);
360        offset = end;
361    }
362    out
363}