diff --git a/.gitattributes b/.gitattributes index 3e3a203c2..73d03d4d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ # Prevent auto-merging of generated acknowledgment files acknowledgments/acknowledgments.* -merge -text acknowledgments/acknowledgments.*.hbs merge text=auto + +# Treat encrypted and unencrypted message backup files as binary +*.binproto binary +*.binproto.encrypted binary diff --git a/Cargo.lock b/Cargo.lock index 757db7d14..befc23899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,6 +203,21 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "assert_cmd" +version = "2.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ad3f3a942eee60335ab4342358c161ee296829e0d16ff42fc1d6cb07815467" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -408,6 +423,17 @@ dependencies = [ "fslock", ] +[[package]] +name = "bstr" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -894,6 +920,12 @@ dependencies = [ "libc", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -937,6 +969,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "downcast-rs" version = "1.2.0" @@ -1793,6 +1831,7 @@ dependencies = [ "aes", "array-concat", "arrayvec", + "assert_cmd", "assert_matches", "async-compression", "cbc", @@ -2505,6 +2544,33 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e851c7654eed9e68d7d27164c454961a616cf8c203d500607ef22c737b51bb" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.16" @@ -3247,6 +3313,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "test-case" version = "3.3.1" diff --git a/rust/message-backup/Cargo.toml b/rust/message-backup/Cargo.toml index e75597137..3a15ebb4d 100644 --- a/rust/message-backup/Cargo.toml +++ b/rust/message-backup/Cargo.toml @@ -39,6 +39,7 @@ uuid = "1.1.2" signal-crypto = { path = "../crypto" } array-concat = "0.5.2" +assert_cmd = "2.0.13" assert_matches = "1.5.0" dir-test = "0.2.0" futures = { version = "0.3.29", features = ["executor"] } @@ -48,4 +49,4 @@ test-log = "0.2.14" [build-dependencies] protobuf = "3.3.0" -protobuf-codegen = "3.3.0" \ No newline at end of file +protobuf-codegen = "3.3.0" diff --git a/rust/message-backup/examples/encrypt_backup.rs b/rust/message-backup/examples/encrypt_backup.rs new file mode 100644 index 000000000..ef5f5d73c --- /dev/null +++ b/rust/message-backup/examples/encrypt_backup.rs @@ -0,0 +1,136 @@ +// +// Copyright 2024 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +use std::fmt::Display; +use std::io::{stdout, Read as _, Write}; + +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockEncryptMut, KeyIvInit}; +use aes::Aes256; +use async_compression::futures::bufread::GzipEncoder; +use clap::builder::TypedValueParser; +use clap::Parser; +use clap_stdin::FileOrStdin; +use futures::io::Cursor; +use futures::AsyncReadExt; +use hmac::Mac; +use libsignal_message_backup::args::{parse_aci, parse_hex_bytes}; +use libsignal_message_backup::key::{BackupKey, MessageBackupKey}; +use libsignal_protocol::Aci; +use sha2::Sha256; + +const DEFAULT_ACI: Aci = Aci::from_uuid_bytes([0x11; 16]); +const DEFAULT_MASTER_KEY: [u8; 32] = [b'M'; 32]; + +#[derive(Parser)] +/// Compresses and encrypts an unencrypted backup file. +struct CliArgs { + /// the file to read from, or '-' to read from stdin + filename: FileOrStdin, + + /// the ACI to encrypt the backup file for + #[arg( + long, + value_parser=parse_aci.map(WrapCliArg), + default_value_t=WrapCliArg(DEFAULT_ACI) + )] + aci: WrapCliArg, + + /// master key used (with the ACI) to derive the backup keys + #[arg( + long, + value_parser=parse_hex_bytes::<32>.map(WrapCliArg), + default_value_t=WrapCliArg(DEFAULT_MASTER_KEY) + )] + master_key: WrapCliArg<[u8; BackupKey::MASTER_KEY_LEN]>, +} + +fn main() { + let CliArgs { + filename, + master_key: WrapCliArg(master_key), + aci: WrapCliArg(aci), + } = CliArgs::parse(); + + let backup_key = BackupKey::derive_from_master_key(&master_key); + let backup_id = backup_key.derive_backup_id(&aci); + let key = MessageBackupKey::derive(&backup_key, &backup_id); + + eprintln!("reading from {:?}", filename.source); + + let contents = read_file(filename); + eprintln!("read {} bytes", contents.len()); + + let compressed_contents = gzip_compress(contents); + eprintln!("compressed to {} bytes", compressed_contents.len()); + + let MessageBackupKey { + hmac_key, + aes_key, + iv, + } = &key; + + let encrypted_contents = aes_cbc_encrypt(aes_key, iv, compressed_contents); + eprintln!("encrypted to {} bytes", encrypted_contents.len()); + + let hmac = hmac_checksum(hmac_key, &encrypted_contents); + write_bytes("encrypted", encrypted_contents); + + write_bytes("HMAC", hmac); +} + +fn read_file(filename: FileOrStdin) -> Vec { + let source = filename.source.clone(); + let mut contents = Vec::new(); + filename + .into_reader() + .unwrap_or_else(|e| panic!("failed to read {source:?}: {e}")) + .read_to_end(&mut contents) + .expect("IO error"); + contents +} + +fn aes_cbc_encrypt(aes_key: &[u8; 32], iv: &[u8; 16], compressed_contents: Vec) -> Vec { + let encryptor = cbc::Encryptor::::new(aes_key.into(), iv.into()); + + encryptor.encrypt_padded_vec_mut::(&compressed_contents) +} +fn hmac_checksum(hmac_key: &[u8; 32], encrypted_contents: &[u8]) -> [u8; 32] { + let mut hmac = hmac::Hmac::::new_from_slice(hmac_key).expect("correct key size"); + hmac.update(encrypted_contents); + hmac.finalize().into_bytes().into() +} + +fn gzip_compress(contents: Vec) -> Vec { + let mut compressed_contents = Vec::new(); + futures::executor::block_on( + GzipEncoder::new(Cursor::new(contents)).read_to_end(&mut compressed_contents), + ) + .expect("failed to compress"); + + compressed_contents +} + +fn write_bytes(label: &'static str, bytes: impl AsRef<[u8]>) { + let bytes = bytes.as_ref(); + stdout().write_all(bytes).expect("failed to write"); + eprintln!("wrote {} {label} bytes", bytes.len()) +} + +/// Wrapper struct to provide custom [`Display`] impls. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct WrapCliArg(T); + +impl Display for WrapCliArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.service_id_string()) + } +} + +impl Display for WrapCliArg<[u8; 32]> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", hex::ToHex::encode_hex::(&self.0)) + } +} diff --git a/rust/message-backup/src/args.rs b/rust/message-backup/src/args.rs new file mode 100644 index 000000000..a990e4d52 --- /dev/null +++ b/rust/message-backup/src/args.rs @@ -0,0 +1,36 @@ +// +// Copyright 2024 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +use libsignal_protocol::Aci; + +#[derive(Debug, thiserror::Error)] +pub enum ParseHexError { + #[error("character {c} at position {index} is not a hex digit")] + InvalidHexCharacter { c: char, index: usize }, + #[error("got {count} hex digits, expected {}", 2 * N)] + WrongNumberOfDigits { count: usize }, +} + +pub fn parse_hex_bytes(input: &str) -> Result<[u8; N], ParseHexError> +where + [u8; N]: hex::FromHex, +{ + hex::FromHex::from_hex(input).map_err(|e| match e { + hex::FromHexError::InvalidHexCharacter { c, index } => { + ParseHexError::InvalidHexCharacter { c, index } + } + hex::FromHexError::InvalidStringLength | hex::FromHexError::OddLength => { + ParseHexError::WrongNumberOfDigits { count: input.len() } + } + }) +} + +pub fn parse_aci(input: &str) -> Result { + Aci::parse_from_service_id_string(input).ok_or(AciParseError) +} + +/// invalid ACI, expected a UUID like "55555555-5555-5555-5555-555555555555" +#[derive(Debug, thiserror::Error, displaydoc::Display)] +pub struct AciParseError; diff --git a/rust/message-backup/src/bin/validator/args.rs b/rust/message-backup/src/bin/validator/args.rs index 832bf1c6a..92008e1ff 100644 --- a/rust/message-backup/src/bin/validator/args.rs +++ b/rust/message-backup/src/bin/validator/args.rs @@ -3,38 +3,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // -use libsignal_protocol::Aci; - -#[derive(Debug, thiserror::Error)] -pub(crate) enum ParseHexError { - #[error("character {c} at position {index} is not a hex digit")] - InvalidHexCharacter { c: char, index: usize }, - #[error("got {count} hex digits, expected {}", 2 * N)] - WrongNumberOfDigits { count: usize }, -} - -pub(crate) fn parse_hex_bytes(input: &str) -> Result<[u8; N], ParseHexError> -where - [u8; N]: hex::FromHex, -{ - hex::FromHex::from_hex(input).map_err(|e| match e { - hex::FromHexError::InvalidHexCharacter { c, index } => { - ParseHexError::InvalidHexCharacter { c, index } - } - hex::FromHexError::InvalidStringLength | hex::FromHexError::OddLength => { - ParseHexError::WrongNumberOfDigits { count: input.len() } - } - }) -} - -pub(crate) fn parse_aci(input: &str) -> Result { - Aci::parse_from_service_id_string(input).ok_or(AciParseError) -} - -/// invalid ACI, expected a UUID like "55555555-5555-5555-5555-555555555555" -#[derive(Debug, thiserror::Error, displaydoc::Display)] -pub(crate) struct AciParseError; - pub(crate) enum ParseVerbosity { None, PrintOneLine, diff --git a/rust/message-backup/src/bin/validator/main.rs b/rust/message-backup/src/bin/validator/main.rs index 27846e9bb..35b6d0eb2 100644 --- a/rust/message-backup/src/bin/validator/main.rs +++ b/rust/message-backup/src/bin/validator/main.rs @@ -5,17 +5,19 @@ use std::io::Read as _; -use args::ParseVerbosity; use clap::{Args, Parser}; use futures::io::{AllowStdIo, Cursor}; use futures::AsyncRead; +use libsignal_message_backup::args::{parse_aci, parse_hex_bytes}; use libsignal_message_backup::frame::FramesReader; use libsignal_message_backup::key::{BackupKey, MessageBackupKey}; use libsignal_message_backup::unknown::FormatPath; use libsignal_message_backup::{BackupReader, Error, FoundUnknownField, ReadResult}; use libsignal_protocol::Aci; +use crate::args::ParseVerbosity; + mod args; /// Validates, and optionally prints the contents of, message backup files. @@ -51,10 +53,10 @@ struct Cli { #[group(conflicts_with = "KeyParts")] struct DeriveKey { /// account master key, used with the ACI to derive the message backup key - #[arg(long, value_parser=args::parse_hex_bytes::<32>, requires="aci")] + #[arg(long, value_parser=parse_hex_bytes::<32>, requires="aci")] master_key: Option<[u8; BackupKey::MASTER_KEY_LEN]>, /// ACI for the backup creator - #[arg(long, value_parser=args::parse_aci, requires="master_key")] + #[arg(long, value_parser=parse_aci, requires="master_key")] aci: Option, } @@ -62,13 +64,13 @@ struct DeriveKey { #[group(conflicts_with = "DeriveKey")] struct KeyParts { /// HMAC key, used if the master key is not provided - #[arg(long, value_parser=args::parse_hex_bytes::<32>, requires_all=["aes_key", "iv"])] + #[arg(long, value_parser=parse_hex_bytes::<32>, requires_all=["aes_key", "iv"])] hmac_key: Option<[u8; MessageBackupKey::HMAC_KEY_LEN]>, /// AES encryption key, used if the master key is not provided - #[arg(long, value_parser=args::parse_hex_bytes::<32>, requires_all=["hmac_key", "iv"])] + #[arg(long, value_parser=parse_hex_bytes::<32>, requires_all=["hmac_key", "iv"])] aes_key: Option<[u8; MessageBackupKey::AES_KEY_LEN]>, /// AES IV bytes, used if the master key is not provided - #[arg(long, value_parser=args::parse_hex_bytes::<16>, requires_all=["hmac_key", "aes_key"])] + #[arg(long, value_parser=parse_hex_bytes::<16>, requires_all=["hmac_key", "aes_key"])] iv: Option<[u8; MessageBackupKey::IV_LEN]>, } diff --git a/rust/message-backup/src/lib.rs b/rust/message-backup/src/lib.rs index 258699dc3..cf4b5988d 100644 --- a/rust/message-backup/src/lib.rs +++ b/rust/message-backup/src/lib.rs @@ -12,6 +12,7 @@ use crate::key::MessageBackupKey; use crate::parse::VarintDelimitedReader; use crate::unknown::{PathPart, UnknownValue, VisitUnknownFieldsExt as _}; +pub mod args; pub mod backup; pub mod frame; pub mod key; diff --git a/rust/message-backup/tests/res/test-cases/valid/new_account.binproto.encrypted b/rust/message-backup/tests/res/test-cases/valid/new_account.binproto.encrypted new file mode 100644 index 000000000..18e52455a --- /dev/null +++ b/rust/message-backup/tests/res/test-cases/valid/new_account.binproto.encrypted @@ -0,0 +1,3 @@ +ҝajޝ bF`uUɡ"%e +q4L7ҏ3Ķ!f-# # W2CQw)N|<#@La4O]Gӛ +v4LgB7F2x!dV5FDt\- 8[rK˚Jo NQ+n:Ws;G+FX):O2 KlenκWNǛ{V| \ No newline at end of file diff --git a/rust/message-backup/tests/test_cases.rs b/rust/message-backup/tests/test_cases.rs index 734e8c640..1777bdb9a 100644 --- a/rust/message-backup/tests/test_cases.rs +++ b/rust/message-backup/tests/test_cases.rs @@ -3,20 +3,67 @@ // SPDX-License-Identifier: AGPL-3.0-only // +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; use dir_test::{dir_test, Fixture}; use futures::io::AllowStdIo; - +use futures::AsyncRead; +use libsignal_message_backup::key::{BackupKey, MessageBackupKey}; use libsignal_message_backup::{BackupReader, ReadResult}; +use libsignal_protocol::Aci; #[dir_test( - dir: "$CARGO_MANIFEST_DIR/tests/res/test-cases", - glob: "valid/*.binproto", - loader: read_file_async, - postfix: "binproto" -)] -fn is_valid_binproto(input: Fixture>) { - let input = input.into_content(); - let mut reader = BackupReader::new_unencrypted(input); + dir: "$CARGO_MANIFEST_DIR/tests/res/test-cases", + glob: "valid/*.binproto", + loader: PathBuf::from, + postfix: "binproto" + )] +fn is_valid_binproto(input: Fixture) { + let path = input.into_content(); + // Check via the library interface. + let reader = BackupReader::new_unencrypted(read_file_async(&path)); + validate(reader); + + // The CLI tool should agree. + validator_command().arg(path).ok().expect("command failed"); +} + +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/tests/res/test-cases", + glob: "valid/*.binproto.encrypted", + loader: PathBuf::from, + postfix: "encrypted" + )] +fn is_valid_encrypted_proto(input: Fixture) { + const ACI: Aci = Aci::from_uuid_bytes([0x11; 16]); + const MASTER_KEY: [u8; 32] = [b'M'; 32]; + let backup_key = BackupKey::derive_from_master_key(&MASTER_KEY); + let key = MessageBackupKey::derive(&backup_key, &backup_key.derive_backup_id(&ACI)); + + let path = input.into_content(); + // Check via the library interface. + let reader = futures::executor::block_on(BackupReader::new_encrypted_compressed( + &key, + read_file_async(&path), + )) + .expect("invalid HMAC"); + validate(reader); + + // The CLI tool should agree. + validator_command() + .args([ + "--aci".to_string(), + ACI.service_id_string(), + "--master-key".to_string(), + hex::encode(MASTER_KEY), + path.to_string_lossy().into_owned(), + ]) + .ok() + .expect("command failed"); +} + +fn validate(mut reader: BackupReader) { reader.visitor = |msg| println!("{msg:#?}"); let ReadResult { @@ -29,7 +76,11 @@ fn is_valid_binproto(input: Fixture>) { println!("got backup:\n{backup:#?}"); } -fn read_file_async(path: &str) -> AllowStdIo { +fn validator_command() -> Command { + Command::cargo_bin("validator").expect("bin not found") +} + +fn read_file_async(path: &Path) -> AllowStdIo { let file = std::fs::File::open(path).expect("can read"); AllowStdIo::new(file) }