otr_utils/
decoding.rs

1// SPDX-FileCopyrightText: 2024 Michael Picht <mipi@fsfe.org>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
6use anyhow::{anyhow, Context};
7use base64::{engine::general_purpose, Engine};
8use blowfish::{cipher::KeyInit, BlowfishLE};
9use cbc;
10use chrono::Datelike;
11use ecb;
12use log::*;
13use md5::{Digest, Md5};
14use std::{
15    clone::Clone,
16    collections::HashMap,
17    fmt::Debug,
18    fs::remove_file,
19    fs::File,
20    io::prelude::*,
21    marker::Copy,
22    path::Path,
23    str,
24    sync::mpsc::{channel, Receiver},
25    thread,
26};
27
28/// URL of OTR web service to request decoding key
29const OTR_URL: &str = "http://onlinetvrecorder.com/quelle_neu1.php";
30/// Decoder version to be used in decoding key requests
31const DECODER_VERSION: &str = "0.4.1133";
32/// Sizes of different parts of the encoded video file
33const FILETYPE_LENGTH: usize = 10;
34const PREAMBLE_LENGTH: usize = 512;
35const HEADER_LENGTH: usize = FILETYPE_LENGTH + PREAMBLE_LENGTH;
36/// OTR key values
37const PREAMBLE_KEY: &str = "EF3AB29CD19F0CAC5759C7ABD12CC92BA3FE0AFEBF960D63FEBD0F45";
38const IK: &str = "aFzW1tL7nP9vXd8yUfB5kLoSyATQ";
39/// String to verify that the encoded video file has the right type
40const OTRKEY_FILETYPE: &str = "OTRKEYFILE";
41/// error indicator in OTR response
42const OTR_ERROR_INDICATOR: &str = "MessageToBePrintedInDecoder";
43/// Keys of parameters contained in the file header
44const PARAM_FILENAME: &str = "FN";
45const PARAM_FILESIZE: &str = "SZ";
46const PARAM_ENCODED_HASH: &str = "OH";
47const PARAM_DECODED_HASH: &str = "FH";
48/// Keys of parameters contained in the response to the decoding key request
49const PARAM_DECODING_KEY: &str = "HP";
50/// Sizes for decoding. MAX_CHUNK_SIZE must be a multiple of BLOCK_SIZE
51const BLOCK_SIZE: usize = 8;
52const MAX_CHUNK_SIZE: usize = 10 * 1024 * 1024;
53
54const HEX_CHARS: &str = "0123456789abcdef";
55
56/// Map parameter keys to its values (key->value)
57type OTRParams = HashMap<String, String>;
58
59/// Part of a video file for concurrent decoding
60type Chunk = Vec<u8>;
61
62/// Decode a encoded video file. in_video is the path of the decoded video file.
63/// out_video is the path of the cut video file.
64pub fn decode<P, Q>(in_video: P, out_video: Q, user: &str, password: &str) -> anyhow::Result<()>
65where
66    P: AsRef<Path>,
67    Q: AsRef<Path> + Debug + Copy,
68{
69    // MAX_CHUNK_SIZE must be a multiple of BLOCK_SIZE
70    if MAX_CHUNK_SIZE % BLOCK_SIZE != 0 {
71        return Err(anyhow!(
72            "Chunk size [{}] is not a multiple of block size [{}]",
73            MAX_CHUNK_SIZE,
74            BLOCK_SIZE
75        ));
76    }
77
78    // Retrieve parameters from header of encoded video file
79    let mut in_file = File::open(&in_video)?;
80    let header_params =
81        header_params(&mut in_file).with_context(|| "Could not extract video header from")?;
82
83    // Check size of encoded video file
84    if (in_file.metadata()?.len() as usize) < file_size_from_params(&header_params) {
85        return Err(anyhow!("Video file seems to be corrupt: it is too small"));
86    }
87
88    // Current date
89    let now = current_date();
90    // Get key that is needed to encrypt the payload of the decoding key request
91    let cbc_key = cbc_key(user, password, &now).with_context(|| {
92        "Could not determine CBC key for encryption of decoding key request payload"
93    })?;
94    // Get parameters for decoding (particularly the decoding key)
95    let decoding_params = decoding_params(
96        &cbc_key,
97        &decoding_params_request(&cbc_key, &header_params, user, password, &now)
98            .with_context(|| "Could not assemble request for decoding key")?,
99    )
100    .with_context(|| "Could not retrieve decoding key")?;
101
102    // Decode encoded video file in concurrent threads using the decoding key
103    if let Err(err) = decode_in_parallel(
104        &mut in_file,
105        out_video,
106        &header_params,
107        decoding_params.get(PARAM_DECODING_KEY).unwrap(),
108    ) {
109        remove_file(out_video).unwrap_or_else(|_| {
110            panic!(
111                "Could not delete file \"{}\" after error when decoding video",
112                out_video.as_ref().display()
113            )
114        });
115        return Err(err);
116    }
117
118    // Remove encoded video file
119    remove_file(&in_video).with_context(|| "Could not delete video after successful decoding")?;
120
121    Ok(())
122}
123
124/// Key that is needed to encrypt the payload of the decoding key request
125fn cbc_key(user: &str, password: &str, now: &str) -> anyhow::Result<String> {
126    let user_hash = format!("{:02x}", Md5::digest(user.as_bytes()));
127    let password_hash = format!("{:02x}", Md5::digest(password.as_bytes()));
128    let cbc_key: String = user_hash[0..13].to_string()
129        + &now[..4]
130        + &password_hash[0..11]
131        + &now[4..6]
132        + &user_hash[21..32]
133        + &now[6..]
134        + &password_hash[19..32];
135
136    debug!(cbc_key:serde = cbc_key; "CBC_KEY");
137
138    Ok(cbc_key)
139}
140
141/// Calculate the sizes of the different chunks for parallel decoding. The
142/// result is a vector [MAX_CHUNK_SIZE, ..., MAX_CHUNK_SIZE, CHUNK_SIZE,
143/// REMAINDER], whereas CHUNK_SIZE is less than MAX_CHUNK_SIZE but is a multiple
144/// of BLOCK_SIZE. REMAINDER is less than BLOCK_SIZE.
145fn chunk_sizes(file_size: usize) -> Vec<usize> {
146    let (full_chunks, remainder) = (file_size / MAX_CHUNK_SIZE, file_size % MAX_CHUNK_SIZE);
147    let mut sizes: Vec<usize> = vec![MAX_CHUNK_SIZE; full_chunks];
148    if remainder / BLOCK_SIZE > 0 {
149        sizes.push(remainder / BLOCK_SIZE * BLOCK_SIZE);
150    }
151    if remainder % BLOCK_SIZE > 0 {
152        sizes.push(remainder % BLOCK_SIZE);
153    }
154    sizes
155}
156
157/// Current date and returns it as numeric string of format "YYYYMMDD"
158fn current_date() -> String {
159    let now = chrono::Local::now().date_naive();
160    format!("{:04}{:02}{:02}", now.year(), now.month(), now.day())
161}
162
163/// Decode one chunk of an encoded video file and return the corresponding
164/// decoded chunk. This function is called in a dedicated thread for each
165/// chunk.
166fn decode_chunk(key: &str, mut chunk: Chunk) -> Chunk {
167    // Chunks can only be decoded if their size is greater than
168    // BLOCK_SIZE. Otherwise, the chunk is returned encoded
169    if chunk.capacity() >= BLOCK_SIZE {
170        ecb::Decryptor::<BlowfishLE>::new_from_slice(
171            &hex::decode(key).expect("Could not turn decoding key into hex string"),
172        )
173        .unwrap_or_else(|_| panic!("Could not create cipher object for decoding of chunk"))
174        .decrypt_padded_mut::<NoPadding>(&mut chunk)
175        .unwrap_or_else(|_| panic!("Could not decode chunk"));
176    }
177    chunk
178}
179
180/// Decode a video file (in_file) in concurrent threads using key as decoding
181/// key and write the result to out_video
182fn decode_in_parallel<P>(
183    in_file: &mut File,
184    out_video: P,
185    header_params: &OTRParams,
186    key: &str,
187) -> anyhow::Result<()>
188where
189    P: AsRef<Path> + Debug,
190{
191    // Output file
192    let mut out_file = File::create(&out_video).with_context(|| {
193        format!(
194            "Could not create result file \"{}\"",
195            out_video.as_ref().display()
196        )
197    })?;
198
199    // Thread handle to be able to wait until all threads are done
200    let mut thread_handles = vec![];
201
202    // Create channels and start threads to determine the checksums of the video
203    // file before and after decoding, if that is required
204    let (enc_hash_sender, enc_hash_receiver) = channel();
205    let (dec_hash_sender, dec_hash_receiver) = channel();
206    let (enc_hash_handle, dec_hash_handle) = (
207        thread::spawn(move || -> [u8; 16] { hashing_queue(enc_hash_receiver) }),
208        thread::spawn(move || -> [u8; 16] { hashing_queue(dec_hash_receiver) }),
209    );
210
211    // Read the chunks sequentially and start and decode each chunk in a
212    // separate thread
213    for chunk_size in chunk_sizes(file_size_from_params(header_params) - HEADER_LENGTH) {
214        // Allocate next chunk
215        let mut chunk = vec![0u8; chunk_size];
216
217        // Read chunk from encoded file and check number of bytes that were read
218        if in_file
219            .read(&mut chunk[..chunk_size])
220            .with_context(|| "Could not read chunk")?
221            < chunk_size
222        {
223            return Err(anyhow!("Chunk is too short"));
224        }
225
226        // Update hasher to determine the checksum of the encoded file
227        enc_hash_sender.send(chunk.clone()).unwrap();
228
229        // Decode chunk in new thread. Each thread returns the decoded chunk
230        let dec_key = key.to_string();
231        thread_handles.push(thread::spawn(move || -> Chunk {
232            decode_chunk(&dec_key, chunk)
233        }));
234    }
235
236    // Sender must be dropped explicitly to make hasher thread terminating
237    drop(enc_hash_sender);
238
239    // Join thread results. I.e., receive chunks and write them to the output
240    // file. The chunk sequence is kept by the sequence of thread handles in the
241    // thread handles vector
242    for handle in thread_handles {
243        match handle.join() {
244            Ok(chunk) => {
245                // update hasher to determine the checksum of the decoded file
246                dec_hash_sender.send(chunk.clone()).unwrap();
247                // write content to output file
248                out_file.write_all(&chunk).with_context(|| {
249                    format!(
250                        "Could not write to decoded video file \"{}\"",
251                        out_video.as_ref().display(),
252                    )
253                })?;
254            }
255            Err(_) => {
256                return Err(anyhow!(
257                    "Could not create decoded video file \"{}\"",
258                    out_video.as_ref().display()
259                ));
260            }
261        }
262    }
263
264    // Sender must be dropped explicitly to make hasher thread terminating
265    drop(dec_hash_sender);
266
267    // Check MD5 checksums
268    if !verify_checksum(
269        &enc_hash_handle.join().unwrap(),
270        &header_params[PARAM_ENCODED_HASH],
271    )
272    .context("Could not verify checksum of encoded video file")?
273    {
274        return Err(anyhow!("MD5 checksum of encoded video file is not correct"));
275    }
276    if !verify_checksum(
277        &dec_hash_handle.join().unwrap(),
278        &header_params[PARAM_DECODED_HASH],
279    )
280    .context("Could not verify checksum of decoded video file")?
281    {
282        return Err(anyhow!("MD5 checksum of decoded video file is not correct"));
283    }
284
285    Ok(())
286}
287
288/// Request decoding parameters (incl. decoding key) via OTR web service and
289/// return them as hash map: key -> value.
290fn decoding_params(cbc_key: &str, request: &str) -> anyhow::Result<OTRParams> {
291    // Request decoding key from OTR
292    let response = reqwest::blocking::Client::builder()
293        .user_agent("Windows-OTR-Decoder/".to_string() + DECODER_VERSION)
294        .build()
295        .with_context(|| "Could not create HTTP client to request decoding key")?
296        .get(request)
297        .send()
298        .with_context(|| "Did not get a response for decoding key request")?
299        .text()
300        .with_context(|| {
301            "Response to decoding key request is corrupted: could not turn into text"
302        })?;
303
304    // Check for error reported by OTR web service. The first if statement checks
305    // if there was an error message at all.
306    if response.len() < OTR_ERROR_INDICATOR.len() {
307        return Err(anyhow!(
308            "Unidentifiable error while requesting decoding key"
309        ));
310    }
311    if &response[..OTR_ERROR_INDICATOR.len()] == OTR_ERROR_INDICATOR {
312        return Err(anyhow!(
313            "Error while requesting decoding key: \"{}\"",
314            response[OTR_ERROR_INDICATOR.len()..].to_string()
315        ));
316    }
317
318    // Decode response from base64 format
319    let mut response = general_purpose::STANDARD
320        .decode(&response)
321        .with_context(|| "Could not decode response to decoding key request from base64")?;
322
323    // Check response length
324    if response.len() < 2 * BLOCK_SIZE || response.len() % BLOCK_SIZE != 0 {
325        return Err(anyhow!(
326            "Response to decoding key request is corrupted: must be a multiple of {}",
327            BLOCK_SIZE
328        ));
329    }
330
331    // Decode response
332    let init_vector = &response[..BLOCK_SIZE];
333    let response_decrypted = cbc::Decryptor::<BlowfishLE>::new_from_slices(
334        &hex::decode(cbc_key).with_context(|| "Could not turn CBC key into byte array")?,
335        init_vector,
336    )
337    .with_context(|| "Could not create cipher object for decryption of decoding key response")?
338    .decrypt_padded_mut::<NoPadding>(&mut response[BLOCK_SIZE..])
339    .unwrap_or_else(|_| panic!("Could not decrypt decryption key response"));
340
341    // Extract parameters into hash map
342    let decoding_params = params_from_str(
343        str::from_utf8(response_decrypted)
344            .with_context(|| "Reponse to decoding key request is corrupt")?,
345        vec![PARAM_DECODING_KEY],
346    )
347    .with_context(|| "Could not extract decoding parameters")?;
348
349    Ok(decoding_params)
350}
351
352/// Assemble the URL for requesting the decoding key via the OTR web service.
353fn decoding_params_request(
354    cbc_key: &str,
355    header: &OTRParams,
356    user: &str,
357    password: &str,
358    now: &str,
359) -> anyhow::Result<String> {
360    // Assemble payload
361    let mut payload: String = "&A=".to_string()
362        + user
363        + "&P="
364        + password
365        + "&FN="
366        + header.get(PARAM_FILENAME).unwrap()
367        + "&OH="
368        + header.get(PARAM_ENCODED_HASH).unwrap()
369        + "&M="
370        + &format!("{:02x}", Md5::digest(b"something"))
371        + "&OS="
372        + &format!("{:02x}", Md5::digest(b"Windows"))
373        + "&LN=DE"
374        + "&VN="
375        + DECODER_VERSION
376        + "&IR=TRUE"
377        + "&IK="
378        + IK
379        + "&D=";
380    payload += &generate_hex_string(512 - BLOCK_SIZE - payload.len());
381
382    debug!(payload:serde=payload;
383        "Payload for decoding parameters request"
384    );
385
386    // Encrypt payload
387    let init_vector = generate_byte_vector(BLOCK_SIZE);
388    let payload_as_bytes = unsafe { payload.as_bytes_mut() };
389    let payload_encrypted = cbc::Encryptor::<BlowfishLE>::new_from_slices(
390        &hex::decode(cbc_key).with_context(|| "Could not turn CBC key into byte array")?,
391        &init_vector,
392    )
393    .with_context(|| {
394        "Could not create cipher object for encryption of decryption key request payload"
395    })?
396    .encrypt_padded_mut::<NoPadding>(payload_as_bytes, 512 - BLOCK_SIZE)
397    .unwrap_or_else(|_| panic!("Could not encrypt decryption key request payload"));
398
399    // Assemble value for code parameter
400    let mut code = init_vector;
401    code.extend_from_slice(payload_encrypted);
402
403    // Finally assemble URL
404    let request: String = OTR_URL.to_string()
405        + "?code="
406        + &general_purpose::STANDARD.encode(code)
407        + "&AA="
408        + user
409        + "&ZZ="
410        + now;
411
412    Ok(request)
413}
414
415/// Extract the parameter SZ (= file size) from the header parameter hash map
416/// and return it as unsigned integer
417fn file_size_from_params(header_params: &OTRParams) -> usize {
418    header_params
419        .get(PARAM_FILESIZE)
420        .unwrap()
421        .parse::<usize>()
422        .unwrap()
423}
424
425/// Calculate the MD5 checksum of video file (in this case the data is received
426/// via a queue)
427fn hashing_queue(queue: Receiver<Chunk>) -> [u8; 16] {
428    let mut hasher = Md5::new();
429
430    for data in queue {
431        hasher.update(data);
432    }
433
434    // Retrieve and return checksum
435    let mut checksum = [0u8; 16];
436    checksum.clone_from_slice(&hasher.finalize()[..]);
437    checksum
438}
439
440/// Extract parameters from the beginning of the OTRKEY file and return them in
441/// a hash map: key -> value.
442fn header_params(in_file: &mut File) -> anyhow::Result<OTRParams> {
443    let mut buffer = [0; HEADER_LENGTH];
444
445    // Read file header
446    if in_file
447        .read(&mut buffer)
448        .with_context(|| "Could not read file")?
449        < HEADER_LENGTH
450    {
451        return Err(anyhow!("File is too short"));
452    }
453
454    // Check if file header starts with OTRKEY indicator
455    if str::from_utf8(&buffer[0..FILETYPE_LENGTH])? != OTRKEY_FILETYPE {
456        debug!(
457            "OTRKEY file header is: \"{}\"",
458            str::from_utf8(&buffer[0..FILETYPE_LENGTH]).unwrap()
459        );
460
461        return Err(anyhow!("File does not start with \"{}\"", OTRKEY_FILETYPE));
462    }
463
464    // Create Blowfish little endian cypher and decrypt rest of file header
465    ecb::Decryptor::<BlowfishLE>::new_from_slice(
466        &hex::decode(PREAMBLE_KEY).with_context(|| "Could not decrypt preamble key")?,
467    )
468    .with_context(|| "Could not create cipher object for header decryption")?
469    .decrypt_padded_mut::<NoPadding>(&mut buffer[FILETYPE_LENGTH..])
470    .unwrap_or_else(|_| panic!("Could not decode file header"));
471
472    // Extract parameters
473    let header_params = params_from_str(
474        str::from_utf8(&buffer[FILETYPE_LENGTH..])
475            .with_context(|| "Decrypted file header is corrupt")?,
476        vec![
477            PARAM_FILENAME,
478            PARAM_FILESIZE,
479            PARAM_ENCODED_HASH,
480            PARAM_DECODED_HASH,
481        ],
482    )
483    .with_context(|| "Could not extract parameters from file header")?;
484
485    Ok(header_params)
486}
487
488/// Assemble a random byte vector of length len
489fn generate_byte_vector(len: usize) -> Vec<u8> {
490    let mut bytes = Vec::<u8>::new();
491    for i in 0..len {
492        bytes.push((i % 256).try_into().unwrap());
493    }
494    bytes
495}
496
497/// Assemble a random hexadecimal string of length len
498fn generate_hex_string(len: usize) -> String {
499    let mut result = String::with_capacity(len);
500    for i in 0..len {
501        result.push(HEX_CHARS.chars().nth(i % HEX_CHARS.len()).unwrap());
502    }
503    result
504}
505
506/// Extract parameters from a string of the "key1=value1&key2=value2&..." into
507/// a hash map: key -> value. must_have is a list of keys that must be present.
508/// If any of these keys is missing, an error is returned
509fn params_from_str(params_str: &str, must_have: Vec<&str>) -> anyhow::Result<OTRParams> {
510    let mut params: OTRParams = HashMap::new();
511
512    for param in params_str.split('&') {
513        if param.is_empty() {
514            continue;
515        }
516        // Split in key / value and add parameter to map
517        let a: Vec<&str> = param.split('=').collect();
518        params.insert(a[0].to_string(), a[1].to_string());
519    }
520
521    debug!(params:serde = params; "OTRKEY file parameters");
522
523    // Check if all parameters are there
524    for key in must_have {
525        if !params.contains_key(key) {
526            return Err(anyhow!("Parameter \"{}\" could not be extracted", key));
527        }
528    }
529
530    Ok(params)
531}
532
533/// Check if checksum fits to hash. The hash must be a 48 character hex string.
534fn verify_checksum(checksum: &[u8], hash: &str) -> anyhow::Result<bool> {
535    if hash.len() != 48 {
536        return Err(anyhow!("MD5 hash must be 48 characters long"));
537    }
538
539    // Reduce hash length to 32 characters and convert it into bytes array
540    let reduced_hash = hex::decode(
541        hash.chars()
542            .enumerate()
543            .filter_map(|(i, c)| if (i + 1) % 3 != 0 { Some(c) } else { None })
544            .collect::<String>(),
545    )
546    .context("Could not turn hash {} into bytes")?;
547
548    Ok(checksum == reduced_hash)
549}