Spracherkennung für: .rs vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
//! Implementation of the Threema ID Backup
use core::str;
use data_encoding::BASE32;
use libthreema_macros::concat_fixed_bytes;
use rand::RngCore as _;
use zeroize::ZeroizeOnDrop;
use crate::common::{ThreemaId, keys::ClientKey};
mod argon_chacha_poly_scheme;
mod legacy_scheme;
/// An error occurred while encrypting/decrypting an identity backup
///
/// Note: Errors can occur when using the API incorrectly or when the passed encrypted backups are
/// invalid.
///
/// When encountering an error:
///
/// 1. Let `error` be the provided [`IdentityBackupError`].
/// 2. Abort the encryption/decryption due to `error`.
#[derive(Debug, thiserror::Error)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))]
pub enum IdentityBackupError {
/// Invalid parameter provided by foreign code.
#[cfg(feature = "uniffi")]
#[error("Invalid parameter: {0}")]
InvalidParameter(&'static str),
/// The decoding failed.
#[error("Decoding failed: {0}")]
DecodingFailed(&'static str),
/// The decrypted backup scheme version is unknown.
#[error("Unknown backup scheme version: {0}")]
UnknownVersion(u8),
/// The key derivation failed. This is most likely an internal error.
#[error("Key derivation failed")]
KdfFailed,
/// The decryption failed, either due to an internal error or due to an invalid ciphertext.
#[error("Decryption failed")]
DecryptionFailed,
/// The encryption failed due to an internal error.
#[error("Encryption failed")]
EncryptionFailed,
}
#[derive(ZeroizeOnDrop)]
struct BackupKey([u8; BackupKey::LENGTH]);
impl BackupKey {
const LENGTH: usize = 32;
}
#[derive(Clone, Copy)]
struct Salt([u8; Salt::LENGTH]);
impl Salt {
const LENGTH: usize = 8;
fn random() -> Self {
let mut res = [0; Salt::LENGTH];
rand::thread_rng().fill_bytes(&mut res);
Self(res)
}
}
impl TryFrom<&[u8]> for Salt {
type Error = IdentityBackupError;
fn try_from(salt: &[u8]) -> Result<Self, Self::Error> {
Ok(Self(salt.try_into().map_err(|_| {
IdentityBackupError::DecodingFailed("Invalid salt length")
})?))
}
}
/// ID backup version
#[derive(Debug, PartialEq, Eq, strum::FromRepr)]
#[repr(u8)]
pub enum BackupVersion {
/// Legacy PBKDF2 scheme using XSalsa20 and a custom integrity hash
Legacy = 0x00,
/// Argon2id ChaCha20Poly1305 scheme V1
ArgonChachaPolyV1 = 0x01,
}
/// All information that is stored or can be derived from the ID backup.
#[derive(Debug)]
pub struct IdentityBackupData {
/// The user's identity.
pub threema_id: ThreemaId,
/// Client key.
pub client_key: ClientKey,
}
impl IdentityBackupData {
// Encoded length
const LENGTH: usize = ThreemaId::LENGTH + ClientKey::LENGTH;
fn decode(backup_data: &[u8]) -> Result<Self, IdentityBackupError> {
let (threema_id, client_key) = backup_data
.split_at_checked(ThreemaId::LENGTH)
.ok_or(IdentityBackupError::DecodingFailed("Invalid backup data length"))?;
let threema_id = ThreemaId::try_from(threema_id).map_err(|_| {
IdentityBackupError::DecodingFailed("Invalid Threema ID in decrypted backup data")
})?;
let client_key = <[u8; ClientKey::LENGTH]>::try_from(client_key)
.map_err(|_| IdentityBackupError::DecodingFailed("Invalid CK length in decrypted backup data"))?;
Ok(IdentityBackupData {
threema_id,
client_key: ClientKey::from(client_key),
})
}
fn encode(&self) -> [u8; Self::LENGTH] {
concat_fixed_bytes!(self.threema_id.to_bytes(), *self.client_key.as_bytes())
}
}
#[cfg(test)]
impl PartialEq for IdentityBackupData {
fn eq(&self, other: &Self) -> bool {
self.threema_id == other.threema_id && self.client_key.as_bytes() == other.client_key.as_bytes()
}
}
/// Encode the encrypted backup with Base32 and then separate every 4th character by a dash for
/// better readability, i.e., "ABCDEFGH..." becomes "ABCD-EFGH..."
fn encode_chunked_base32(encrypted_backup: &[u8]) -> String {
let mut chunks = String::with_capacity(encrypted_backup.len().saturating_mul(2));
for (position, chunk) in BASE32.encode(encrypted_backup).as_bytes().chunks(4).enumerate() {
if position != 0 {
// Prepend divider
chunks.push('-');
}
chunks.push_str(str::from_utf8(chunk).expect("Base32 should be ASCII"));
}
chunks
}
/// Strip the extra characters that were added for readability and then decode the encrypted backup
/// with Base32.
fn decode_chunked_base32(encrypted_backup: &str) -> Result<Vec<u8>, IdentityBackupError> {
BASE32
.decode(
encrypted_backup
.chars()
.filter(|char| *char != '-')
.collect::<String>()
.as_bytes(),
)
.map_err(|_| IdentityBackupError::DecodingFailed("Base32 decoding failed"))
}
/// Encrypt an [`IdentityBackupData`] with the provided password.
///
/// This automatically uses the best available encryption scheme.
///
/// # Errors
///
/// Returns [`IdentityBackupError`] if the backup data could not be encrypted, most likely due to an internal
/// error.
#[expect(
clippy::unnecessary_wraps,
reason = "Result needed when switching to new scheme"
)]
pub fn encrypt_identity_backup(
password: &str,
backup_data: &IdentityBackupData,
) -> Result<String, IdentityBackupError> {
// Encrypt using the legacy scheme for now
let encrypted_backup = legacy_scheme::encrypt(password, backup_data);
// Encode the encrypted backup with Base32 and then separate every 4th character by a dash for
// better readability, i.e., "ABCDEFGH..." becomes "ABCD-EFGH..."
Ok(encode_chunked_base32(&encrypted_backup))
}
/// Decrypt the encrypted backup from the provided password.
///
/// This detects the used backup scheme and handles it accordingly.
///
/// # Errors
///
/// Returns [`IdentityBackupError`] if the backup data could not be decrypted, decoded/parsed or
/// otherwise contained invalid data.
pub fn decrypt_identity_backup(
password: &str,
encrypted_backup: &str,
) -> Result<(BackupVersion, IdentityBackupData), IdentityBackupError> {
// All variants use Base32 with every 4th character separated by a dash, so decode it first.
let encrypted_backup = decode_chunked_base32(encrypted_backup)?;
// Determine the scheme and decrypt
if encrypted_backup.len() == legacy_scheme::ENCRYPTED_LENGTH {
let backup_data = legacy_scheme::decrypt(password, encrypted_backup)?;
Ok((BackupVersion::Legacy, backup_data))
} else {
let version = encrypted_backup
.first()
.ok_or(IdentityBackupError::DecodingFailed("Encrypted backup empty"))?;
let version =
BackupVersion::from_repr(*version).ok_or(IdentityBackupError::UnknownVersion(*version))?;
let backup_data = match version {
BackupVersion::Legacy => Err(IdentityBackupError::DecodingFailed(
"Unexpected legacy version in backup",
)),
BackupVersion::ArgonChachaPolyV1 => argon_chacha_poly_scheme::decrypt(password, encrypted_backup),
}?;
Ok((version, backup_data))
}
}
#[cfg(feature = "slow_tests")]
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use data_encoding::HEXLOWER;
use super::*;
const PASSWORD: &str = "ThisIsABadPassword";
pub(crate) fn backup_data() -> IdentityBackupData {
let threema_id = ThreemaId::try_from("0ZAHXXHB").expect("Threema ID should be valid");
let client_key = {
let client_key = HEXLOWER
.decode(b"255d619ebec82341a5abe0b3ff736f900faa3eda1cb86b34f102394f86a41b2c")
.unwrap();
let client_key: [u8; ClientKey::LENGTH] = client_key.as_slice().try_into().unwrap();
ClientKey::from(client_key)
};
IdentityBackupData {
threema_id,
client_key,
}
}
#[test]
fn invalid_base32() {
assert_matches!(
decrypt_identity_backup(
PASSWORD,
"4K4M-5Q6T-KFUH-KHL5-2VCJ-ZM57-NL7R-WJTA-V45L-NJAM-\
WLEU-5DS4-XF7S-OPH4-CTCL-N2CF-3C4C-HPB7-YZWW-U3S"
),
Err(IdentityBackupError::DecodingFailed(_))
);
}
#[test]
fn empty() {
assert_matches!(
decrypt_identity_backup(PASSWORD, ""),
Err(IdentityBackupError::DecodingFailed(_))
);
}
#[test]
fn unexpected_explicit_legacy_version() {
assert_matches!(
decrypt_identity_backup(PASSWORD, "AD77-7777"),
Err(IdentityBackupError::DecodingFailed(_))
);
}
#[test]
fn unknown_version() {
assert_matches!(
decrypt_identity_backup(PASSWORD, "7777-7777"),
Err(IdentityBackupError::UnknownVersion(0xff))
);
}
#[test]
fn default_scheme_roundtrip() -> anyhow::Result<()> {
let backup_data = backup_data();
let encrypted_backup = encrypt_identity_backup(PASSWORD, &backup_data)?;
let (backup_version, decrypted_backup) = decrypt_identity_backup(PASSWORD, &encrypted_backup)?;
assert_eq!(backup_version, BackupVersion::Legacy);
assert_eq!(decrypted_backup.threema_id, backup_data.threema_id);
assert_eq!(
decrypted_backup.client_key.as_bytes(),
backup_data.client_key.as_bytes(),
);
Ok(())
}
#[test]
fn legacy_static_decrypt() -> anyhow::Result<()> {
let backup_data = backup_data();
let encrypted_backup = "4K4M-5Q6T-KFUH-KHL5-2VCJ-ZM57-NL7R-WJTA-V45L-NJAM-\
WLEU-5DS4-XF7S-OPH4-CTCL-N2CF-3C4C-HPB7-YZWW-U3S6";
let (backup_version, decrypted_backup) = decrypt_identity_backup("testpassword", encrypted_backup)?;
assert_eq!(backup_version, BackupVersion::Legacy);
assert_eq!(decrypted_backup.threema_id, backup_data.threema_id);
assert_eq!(
decrypted_backup.client_key.as_bytes(),
backup_data.client_key.as_bytes(),
);
Ok(())
}
#[test]
fn argon_chacha_poly_static_decrypt() -> anyhow::Result<()> {
let backup_data = backup_data();
let encrypted_backup = "AHCV-YVN5-MZF6-H47E-BFDA-XPQ4-523T-QEJ7-Q7TB-5O2G-U3LM-IPAY-PMO3-\
RWYJ-FZ5F-VRAH-5MHT-IP2E-ODYI-GXW4-4NAF-EOCO-OZPR-NZK4-CHVE-LYVL";
let (backup_version, decrypted_backup) = decrypt_identity_backup(PASSWORD, encrypted_backup)?;
assert_eq!(backup_version, BackupVersion::ArgonChachaPolyV1);
assert_eq!(decrypted_backup.threema_id, backup_data.threema_id);
assert_eq!(
decrypted_backup.client_key.as_bytes(),
backup_data.client_key.as_bytes(),
);
Ok(())
}
}