fritzdecode 0.1.0

Tools for working with AVM FritzBox config file exports
Documentation
//! Utilities to handle encrypted values in config backup files.
//!
//! Every encrypted value is represented in the file using four dollar signs
//! `$$$$` followed by a [custom bas32 encoding](crate::fritz_base32) of a
//! random IV concatenated with the AES-encrypted data.
//!
//! The encryption key for values inside of file entries is stored in the config
//! backup's header; it is itself encrypted using a "bootstrap key" derived from
//! the password specified during export of the backup.
//! The bootstrap key can be obtained using [`generate_boostrap_key()`] and
//! subsequently used to decrypt the main encryption key using
//! [`decrypt_encryption_key()`].

use std::{array, iter::repeat, sync::LazyLock};

use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use md5::{Digest, Md5};
use rand::random;
use regex::Regex;

use crate::{
    errors::{DecryptionError, EncryptionError, Error, FormatError},
    fritz_base32,
};

/// Generates the bootstrap encryption key (used to decrypt the real, random
/// encryption key).
///
/// If a password was set during export of the backup, the bootstrap key is
/// simply the MD5 sum of the password.
#[must_use]
pub fn generate_boostrap_key(password: &str) -> [u8; 32] {
    let hash = Md5::digest(password.as_bytes());

    array::from_fn(|i| hash.get(i).copied().unwrap_or(0))
}

/// Decrypts the random encryption key stored in the `Password=` property of the
/// config export, using the bootstrap key derived from the user password (see
/// [`generate_boostrap_key()`]).
///
/// # Errors
///
/// Returns an error if the  bootstrap key is incorrect, or if the decrypted
/// encryption key is malformed.
pub fn decrypt_encryption_key(
    encrypted_key: &str,
    bootstrap_key: [u8; 32],
) -> Result<[u8; 32], Error> {
    let key = decode_secret(encrypted_key, bootstrap_key, IsStringValue::No)?;
    let mut key: [u8; 32] = key
        .as_slice()
        .try_into()
        .map_err(|_| FormatError::InvalidEncryptionKeyLength { length: key.len() })?;
    key[16..].fill(0);

    Ok(key)
}

type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;

/// Whether or not the plaintext has an implicit trailing null byte. This
/// should be set to `Yes` for secrets inside files, and to `No` for the
/// decryption key.
#[derive(Clone, Copy, Debug)]
pub enum IsStringValue {
    No,
    Yes,
}

/// Decrypts a secret, encoded as fritz-base32 with a `$$$$` prefix, using a
/// key.
///
/// # Errors
///
/// Returns an error if the data is malformed or could not be decoded using the
/// specified key.
pub fn decode_secret(
    secret: &str,
    key: [u8; 32],
    is_string_value: IsStringValue,
) -> Result<Vec<u8>, Error> {
    let secret = secret
        .strip_prefix("$$$$")
        .ok_or(FormatError::InvalidSecretPrefix {
            secret: secret.to_string(),
        })?;

    let secret = fritz_base32::ENCODING.decode(secret.as_bytes())?;

    // remove excess bytes that may have been added through base32 encoding
    let extra_len = secret.len() % 16;
    if extra_len > 4 {
        do yeet DecryptionError::BadCiphertextLength { excess: extra_len };
    }
    let (secret, _extra) = &secret.split_at(secret.len() - extra_len);

    // secret consists of 16 bytes IV concatenated with the ciphertext
    let (iv, ciphertext) = secret.split_array_ref();

    // decrypt ciphertext
    let decrypted = Aes256CbcDec::new(&key.into(), iv.into())
        .decrypt_padded_vec_mut::<NoPadding>(ciphertext)
        .expect("unpadding should not fail with NoPadding");

    // first 4 bytes of plaintext: hash of everything thereafter
    let (hash, decrypted) = decrypted.split_array_ref::<4>();
    let calculated_hash: [u8; 16] = Md5::digest(decrypted).into();
    if *hash != calculated_hash[..4] {
        do yeet DecryptionError::HashMismatch {
            expected: *hash,
            found: calculated_hash,
        };
    }

    // next 4 bytes of plaintext: length of data
    let (length, data) = decrypted.split_array_ref();
    let length: usize = u32::from_be_bytes(*length)
        .try_into()
        .expect("u32 should fit in usize");

    let mut data = &data[..length];
    if let IsStringValue::Yes = is_string_value {
        data = data
            .strip_suffix(b"\0")
            .ok_or(DecryptionError::MissingNullTerminator {
                data: data.to_vec(),
            })?;
    }

    Ok(data.to_vec())
}

pub(crate) fn decode_secrets_in_text(
    line: &str,
    encryption_key: [u8; 32],
) -> Result<Option<String>, Error> {
    static SECRET_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\${4}[A-Z1-6]+").unwrap());

    let mut it = SECRET_REGEX.captures_iter(line).peekable();
    if it.peek().is_none() {
        return Ok(None);
    }
    let mut new = String::with_capacity(line.len());
    let mut last_match = 0;
    for cap in it {
        // unwrap on 0 is OK because captures only reports matches
        let m = cap.get(0).unwrap();
        new.push_str(&line[last_match..m.start()]);

        let decoded = decode_secret(m.into(), encryption_key, IsStringValue::Yes)?;
        new.push_str(std::str::from_utf8(&decoded).map_err(FormatError::SecretNotUtf8)?);

        last_match = m.end();
    }
    new.push_str(&line[last_match..]);

    Ok(Some(new))
}

/// Encrypts a secret using the key, outputting it as a fritz-base32-encoded
/// value prefixed with `$$$$`.
///
/// # Errors
///
/// Returns an error if the length of the supplied data is too large to be
/// represented using a 32-bit integer.
pub fn encode_secret(
    mut data: Vec<u8>,
    key: [u8; 32],
    is_string_value: IsStringValue,
) -> Result<String, Error> {
    if let IsStringValue::Yes = is_string_value {
        data.push(b'\0');
    }

    let length = data.len();
    let length = u32::try_from(length)
        .map_err(|_| EncryptionError::DataTooLarge { length })?
        .to_be_bytes();

    let total_len = data.len() + 4 + 4;
    data.extend(repeat(0).take(16 - total_len % 16));

    let mut hash = Md5::new();
    hash.update(length);
    hash.update(&data);
    let hash: [u8; 16] = hash.finalize().into();

    let iv: [u8; 16] = random();
    let plaintext = [&hash[..4], &length, &data].concat();
    let encrypted =
        Aes256CbcEnc::new(&key.into(), &iv.into()).encrypt_padded_vec_mut::<NoPadding>(&plaintext);

    let mut result = "$$$$".to_string();
    fritz_base32::ENCODING
        .encode_append(&[iv.as_slice(), encrypted.as_slice()].concat(), &mut result);

    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decrypt_known() {
        let cases: &[(&[u8], &str, &str)] = &[
            (b"test", "$$$$5AEWFTDNEIXGOGVTKJYBXJ2JK1B1LDDUXDLOAQPSQTPGI6LCQRLP1E5DEPNJIF45F4ADNFTXZF31541T", "7695206a0422786fc612643ed2f337d900000000000000000000000000000000"),
            (b"testus", "$$$$4ZNKDABPVOF32CDHBPIGXRGUOPWFS3KYOUXXZK61GQ6TT1E4JYYUDOE6YMLBML6ZOJSO53NGG3HOS4ZY", "3cfb14830234eb5abf8de7570d47d79600000000000000000000000000000000"),
            (b"testuse", "$$$$UEUST1NGXTC1Y52R2CSML4TNJPT2HV54MGFUXRCYHH3ROBT3OMBLV66ZQ2XOGZURJTRTP3123QQB34Y5", "4b50c9683664a466c117d89986ef5c9300000000000000000000000000000000"),
            (b"testuser",             "$$$$XLSJ6KVR2KNNBUVJHBA1NPMDEZCHZI4GAMY3BMNUJ5CN4PAGLKWD5K5M5GJYBXKBF1BJE5JX5GEFA4W5", "19d1c0f994c4a7157a99363d8d60097500000000000000000000000000000000"),
        ];

        for &(plain, secret, key) in cases {
            let key = hex::decode(key).unwrap().try_into().unwrap();
            assert_eq!(
                decode_secret(secret, key, IsStringValue::Yes,).unwrap(),
                plain
            );
        }
    }

    #[test]
    fn encrypt() {
        let key = random();

        let encrypted = encode_secret(b"testuse".to_vec(), key, IsStringValue::Yes).unwrap();

        assert_eq!(
            decode_secret(&encrypted, key, IsStringValue::Yes,).unwrap(),
            b"testuse"
        );
    }
}