From 3efcfaf993e29167297e1e5e043fb00535e694bc Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 29 Sep 2026 18:55:09 +0200 Subject: [PATCH] Update iOS backup decryption for iphone_backup_decrypt 0.11.2 --- pyproject.toml | 2 +- src/mvt/ios/decrypt.py | 211 ++++++++----------------------- tests/ios_backup/test_decrypt.py | 114 ++++++++++++++++- uv.lock | 8 +- 4 files changed, 165 insertions(+), 170 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4154662b..fa7efdd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "simplejson==4.1.1", "packaging==26.3", "appdirs==1.4.4", - "iphone_backup_decrypt==0.10.0", + "iphone_backup_decrypt==0.11.2", "pycryptodome>=3.20.0", "cryptography==50.0.0", "PyYAML>=6.0.2", diff --git a/src/mvt/ios/decrypt.py b/src/mvt/ios/decrypt.py index ffc7bfa0..68017e27 100644 --- a/src/mvt/ios/decrypt.py +++ b/src/mvt/ios/decrypt.py @@ -8,10 +8,8 @@ import glob import logging import os import os.path -import plistlib import shutil import sqlite3 -import tempfile from concurrent.futures import ( ALL_COMPLETED, FIRST_COMPLETED, @@ -22,100 +20,26 @@ from concurrent.futures import ( from pathlib import Path from typing import Optional -from iphone_backup_decrypt import EncryptedBackup -from iphone_backup_decrypt import google_iphone_dataprotection +from iphone_backup_decrypt import ( + BackupNotEncryptedError, + EncryptedBackup, + IncorrectPassphraseError, + NotABackupFolderError, +) from iphone_backup_decrypt.utils import FilePlist from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS log = logging.getLogger(__name__) -# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally, -# so our key derivation is consistent with theirs. -try: - from fastpbkdf2 import pbkdf2_hmac -except ImportError: - import Crypto.Hash.SHA1 - import Crypto.Hash.SHA256 - import Crypto.Protocol.KDF - - _HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256} - - def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None): - return Crypto.Protocol.KDF.PBKDF2( - password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name] - ) - - class MVTEncryptedBackup(EncryptedBackup): - """Extends EncryptedBackup with derived key export/import. - - NOTE: This subclass relies on internal APIs of iphone_backup_decrypt - (specifically _read_and_unlock_keybag, _keybag, and the Keybag class - internals). Pinned to iphone_backup_decrypt==0.10.0. - """ - - def __init__(self, *, backup_directory, passphrase=None, derived_key=None): - if passphrase: - super().__init__(backup_directory=backup_directory, passphrase=passphrase) - self._derived_key = None # Will be set after keybag unlock - elif derived_key: - self._init_without_passphrase(backup_directory, derived_key) - else: - raise ValueError("Either passphrase or derived_key must be provided") - - def _init_without_passphrase(self, backup_directory, derived_key): - """Replicate parent __init__ state without requiring a passphrase.""" - self.decrypted = False - self._backup_directory = os.path.expandvars(backup_directory) - self._passphrase = None - self._manifest_plist_path = os.path.join( - self._backup_directory, "Manifest.plist" - ) - self._manifest_plist = None - self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db") - self._keybag = None - self._unlocked = False - self._temporary_folder = tempfile.mkdtemp() - self._temp_decrypted_manifest_db_path = os.path.join( - self._temporary_folder, "Manifest.db" - ) - self._temp_manifest_db_conn = None - self._derived_key = derived_key # 32 raw bytes - - def _read_and_unlock_keybag(self): - """Override to capture derived key on password unlock, or use - a pre-derived key to skip PBKDF2.""" - if self._unlocked: - return self._unlocked - - with open(self._manifest_plist_path, "rb") as infile: - self._manifest_plist = plistlib.load(infile) - self._keybag = google_iphone_dataprotection.Keybag( - self._manifest_plist["BackupKeyBag"] - ) - - if self._derived_key: - # Skip PBKDF2, unwrap class keys directly with pre-derived key - self._unlocked = _unlock_keybag_with_derived_key( - self._keybag, self._derived_key - ) - else: - # Normal path: full PBKDF2 derivation, capturing the intermediate key - self._unlocked, self._derived_key = _unlock_keybag_and_capture_key( - self._keybag, self._passphrase - ) - self._passphrase = None - - if not self._unlocked: - raise ValueError("Failed to decrypt keys: incorrect passphrase?") - return True + """Extract backup files by ID while MVT processes them concurrently.""" def get_decryption_key(self): """Return derived key as hex string (64 chars / 32 bytes).""" - if self._derived_key is None: + if self.keybag is None or self.keybag.passphrase_key is None: raise ValueError("No derived key available") - return self._derived_key.hex() + return self.keybag.passphrase_key.hex() def extract_file_by_id(self, *, file_id, file_bplist, output_filename): """Extract one manifest entry without loading the whole file into memory.""" @@ -129,8 +53,9 @@ class MVTEncryptedBackup(EncryptedBackup): shutil.copy2(source_filename, output_filename) return - inner_key = self._keybag.unwrapKeyForClass( - file_plist.protection_class, file_plist.encryption_key + inner_key = self.keybag.unwrap_key_for_class( + protection_class=file_plist.protection_class, + wrapped_file_key=file_plist.encryption_key, ) self._decrypt_file_to_disk( file_id=file_id, @@ -139,47 +64,6 @@ class MVTEncryptedBackup(EncryptedBackup): output_filepath=output_filename, ) - -def _unlock_keybag_with_derived_key(keybag, passphrase_key): - """Unlock keybag class keys using a pre-derived passphrase_key, - skipping the expensive PBKDF2 rounds.""" - WRAP_PASSPHRASE = 2 - for classkey in keybag.classKeys.values(): - if b"WPKY" not in classkey: - continue - if classkey[b"WRAP"] & WRAP_PASSPHRASE: - k = google_iphone_dataprotection._AESUnwrap( - passphrase_key, classkey[b"WPKY"] - ) - if not k: - return False - classkey[b"KEY"] = k - return True - - -def _unlock_keybag_and_capture_key(keybag, passphrase): - """Run full PBKDF2 key derivation and AES unwrap, returning - (success, passphrase_key) so the derived key can be exported.""" - passphrase_round1 = pbkdf2_hmac( - "sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32 - ) - passphrase_key = pbkdf2_hmac( - "sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32 - ) - WRAP_PASSPHRASE = 2 - for classkey in keybag.classKeys.values(): - if b"WPKY" not in classkey: - continue - if classkey[b"WRAP"] & WRAP_PASSPHRASE: - k = google_iphone_dataprotection._AESUnwrap( - passphrase_key, classkey[b"WPKY"] - ) - if not k: - return False, None - classkey[b"KEY"] = k - return True, passphrase_key - - class DecryptBackup: """This class provides functions to decrypt an encrypted iTunes backup using either a password or a key file. @@ -376,41 +260,36 @@ class DecryptBackup: ) return - # Before proceeding, we check whether the backup is indeed encrypted. - if not self.is_encrypted(self.backup_path): - return - try: self._backup = MVTEncryptedBackup( backup_directory=self.backup_path, passphrase=password, ) + # Manifest.db is a more reliable indication of encryption than + # the IsEncrypted field in Manifest.plist. + if not self.is_encrypted(self.backup_path): + self._backup = None + return # Eagerly trigger keybag unlock so wrong-password errors # surface here rather than later during process_backup(). self._backup.test_decryption() + except IncorrectPassphraseError: + self._backup = None + log.critical("Failed to decrypt backup. Password is probably wrong.") + except (NotABackupFolderError, FileNotFoundError): + self._backup = None + log.critical( + "Failed to find a valid backup at %s. " + "Did you point to the right backup path?", + self.backup_path, + ) + except BackupNotEncryptedError: + self._backup = None + log.critical("The backup does not seem encrypted!") except Exception as exc: self._backup = None - if ( - isinstance(exc, ValueError) - and "passphrase" in str(exc).lower() - ): - log.critical("Failed to decrypt backup. Password is probably wrong.") - elif ( - isinstance(exc, FileNotFoundError) - and hasattr(exc, "filename") - and os.path.basename(exc.filename) == "Manifest.plist" - ): - log.critical( - "Failed to find a valid backup at %s. " - "Did you point to the right backup path?", - self.backup_path, - ) - else: - log.exception(exc) - log.critical( - "Failed to decrypt backup. Did you provide the correct password? " - "Did you point to the right backup path?" - ) + log.exception(exc) + log.critical("Failed to decrypt backup.") def decrypt_with_key_file(self, key_file: str) -> None: """Decrypts an encrypted iOS backup using a key file. @@ -424,10 +303,6 @@ class DecryptBackup: key_file, ) - # Before proceeding, we check whether the backup is indeed encrypted. - if not self.is_encrypted(self.backup_path): - return - with open(key_file, "rb") as handle: key_bytes = handle.read() @@ -442,16 +317,30 @@ class DecryptBackup: key_bytes_raw = binascii.unhexlify(key_bytes) self._backup = MVTEncryptedBackup( backup_directory=self.backup_path, - derived_key=key_bytes_raw, + passphrase_key=key_bytes_raw, ) + if not self.is_encrypted(self.backup_path): + self._backup = None + return # Eagerly trigger keybag unlock so wrong-key errors surface here. self._backup.test_decryption() + except (binascii.Error, IncorrectPassphraseError): + self._backup = None + log.critical("Failed to decrypt backup. Key file is probably wrong.") + except (NotABackupFolderError, FileNotFoundError): + self._backup = None + log.critical( + "Failed to find a valid backup at %s. " + "Did you point to the right backup path?", + self.backup_path, + ) + except BackupNotEncryptedError: + self._backup = None + log.critical("The backup does not seem encrypted!") except Exception as exc: self._backup = None log.exception(exc) - log.critical( - "Failed to decrypt backup. Did you provide the correct key file?" - ) + log.critical("Failed to decrypt backup.") def get_key(self) -> None: """Retrieve and prints the encryption key.""" diff --git a/tests/ios_backup/test_decrypt.py b/tests/ios_backup/test_decrypt.py index 1e2ce148..2a7ebf9a 100644 --- a/tests/ios_backup/test_decrypt.py +++ b/tests/ios_backup/test_decrypt.py @@ -3,12 +3,15 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import hashlib import logging +import plistlib import threading from pathlib import Path import pytest from Crypto.Cipher import AES +from iphone_backup_decrypt import IncorrectPassphraseError from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup @@ -24,6 +27,89 @@ def _encrypted_file(backup_path, file_id, key, plaintext): source_path.write_bytes(encrypted) +def _keybag_tlv(tag, value): + return tag + len(value).to_bytes(4, "big") + value + + +def test_derived_key_can_unlock_keybag(mocker, tmp_path, caplog): + password = b"backup password" + salt = b"salt" * 4 + dpsl = b"dpsl" * 4 + round1 = hashlib.pbkdf2_hmac("sha256", password, dpsl, 1, 32) + derived_key = hashlib.pbkdf2_hmac("sha1", round1, salt, 1, 32) + wrapped_class_key = AES.new(derived_key, AES.MODE_KW).seal(b"k" * 32) + keybag_data = b"".join( + [ + _keybag_tlv(b"TYPE", (1).to_bytes(4, "big")), + _keybag_tlv(b"DPSL", dpsl), + _keybag_tlv(b"DPIC", (1).to_bytes(4, "big")), + _keybag_tlv(b"SALT", salt), + _keybag_tlv(b"ITER", (1).to_bytes(4, "big")), + _keybag_tlv(b"UUID", b"g" * 16), + _keybag_tlv(b"WRAP", (2).to_bytes(4, "big")), + _keybag_tlv(b"UUID", b"c" * 16), + _keybag_tlv(b"CLAS", (1).to_bytes(4, "big")), + _keybag_tlv(b"WRAP", (2).to_bytes(4, "big")), + _keybag_tlv(b"WPKY", wrapped_class_key), + ] + ) + (tmp_path / "Manifest.plist").write_bytes( + plistlib.dumps({"IsEncrypted": True, "BackupKeyBag": keybag_data}) + ) + (tmp_path / "Manifest.db").touch() + + with pytest.raises(IncorrectPassphraseError): + MVTEncryptedBackup( + backup_directory=str(tmp_path), passphrase="wrong" + )._read_and_unlock_keybag() + + password_backup = MVTEncryptedBackup( + backup_directory=str(tmp_path), passphrase=password + ) + password_backup._read_and_unlock_keybag() + assert password_backup.get_decryption_key() == derived_key.hex() + + key_backup = MVTEncryptedBackup( + backup_directory=str(tmp_path), passphrase_key=derived_key + ) + key_backup._read_and_unlock_keybag() + assert key_backup.get_decryption_key() == derived_key.hex() + + # Exercise MVT's password and key-file entry points with the native unlock. + mocker.patch.object(DecryptBackup, "is_encrypted", return_value=True) + mocker.patch.object( + MVTEncryptedBackup, + "test_decryption", + MVTEncryptedBackup._read_and_unlock_keybag, + ) + password_decryptor = DecryptBackup(str(tmp_path)) + password_decryptor.decrypt_with_password(password) + password_decryptor.get_key() + assert password_decryptor._decryption_key == derived_key.hex() + + key_file = tmp_path / "key.txt" + key_file.write_text(derived_key.hex()) + key_decryptor = DecryptBackup(str(tmp_path)) + key_decryptor.decrypt_with_key_file(str(key_file)) + assert key_decryptor.can_process() + assert key_decryptor._backup.get_decryption_key() == derived_key.hex() + + with caplog.at_level(logging.CRITICAL, logger="mvt.ios.decrypt"): + wrong_password = DecryptBackup(str(tmp_path)) + wrong_password.decrypt_with_password("wrong") + assert not wrong_password.can_process() + assert "Password is probably wrong" in caplog.text + + +def test_invalid_backup_folder_uses_specific_error(tmp_path, caplog): + decryptor = DecryptBackup(str(tmp_path)) + with caplog.at_level(logging.CRITICAL, logger="mvt.ios.decrypt"): + decryptor.decrypt_with_password("password") + + assert not decryptor.can_process() + assert "Failed to find a valid backup" in caplog.text + + def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size( mocker, tmp_path ): @@ -39,13 +125,15 @@ def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size( mtime=None, ) mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) + (tmp_path / "Manifest.plist").touch() + (tmp_path / "Manifest.db").touch() backup = MVTEncryptedBackup( - backup_directory=str(tmp_path), derived_key=b"d" * 32 + backup_directory=str(tmp_path), passphrase_key=b"d" * 32 ) mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True) - backup._keybag = mocker.Mock() - backup._keybag.unwrapKeyForClass.return_value = inner_key + backup.keybag = mocker.Mock() + backup.keybag.unwrap_key_for_class.return_value = inner_key streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk") output_path = tmp_path / "output" @@ -57,6 +145,9 @@ def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size( assert output_path.read_bytes() == plaintext streaming_decrypt.assert_called_once() + backup.keybag.unwrap_key_for_class.assert_called_once_with( + protection_class=1, wrapped_file_key=b"wrapped-key" + ) def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path): @@ -67,8 +158,10 @@ def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path): file_plist = mocker.Mock(encryption_key=None) mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) + (tmp_path / "Manifest.plist").touch() + (tmp_path / "Manifest.db").touch() backup = MVTEncryptedBackup( - backup_directory=str(tmp_path), derived_key=b"d" * 32 + backup_directory=str(tmp_path), passphrase_key=b"d" * 32 ) mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True) output_path = tmp_path / "output" @@ -82,6 +175,19 @@ def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path): assert output_path.read_bytes() == b"plain content" +def test_get_decryption_key_uses_unlocked_keybag(tmp_path): + (tmp_path / "Manifest.plist").touch() + (tmp_path / "Manifest.db").touch() + backup = MVTEncryptedBackup( + backup_directory=str(tmp_path), passphrase_key=b"d" * 32 + ) + with pytest.raises(ValueError, match="No derived key available"): + backup.get_decryption_key() + + backup.keybag = type("Keybag", (), {"passphrase_key": b"d" * 32})() + assert backup.get_decryption_key() == "64" * 32 + + @pytest.mark.parametrize("with_symlink", [False, True], ids=["file-id", "symlink"]) def test_process_backup_rejects_unsafe_file_ids_and_destinations( mocker, tmp_path, with_symlink diff --git a/uv.lock b/uv.lock index 085b1a35..01dd1e5e 100644 --- a/uv.lock +++ b/uv.lock @@ -609,14 +609,14 @@ wheels = [ [[package]] name = "iphone-backup-decrypt" -version = "0.10.0" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycryptodome" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/b8/a2ec2b10d792403d36bb6661fea6357179c31ece1ed3285c442e636d6335/iphone_backup_decrypt-0.10.0.tar.gz", hash = "sha256:5f2799e58f908cce28d7929dd82231abc8acd57c25b37bce6a2d6a82e018d343", size = 20000, upload-time = "2026-09-17T21:46:44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/a2c3e8bfd937346d87753dd2e7e51efff14744ec268ebafe8e7be0269b9e/iphone_backup_decrypt-0.11.2.tar.gz", hash = "sha256:5f631927419e670e461bc0b9e299fae396445a3c84e0afe747a3464ccbb23ed1", size = 23923, upload-time = "2026-09-24T21:02:17.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/10/349b2a720e012921d1457e1d1c2b921b16300a5f3570c4c64d0d3787539f/iphone_backup_decrypt-0.10.0-py3-none-any.whl", hash = "sha256:1ac4345a07b104a3eacab20932b8621cd1e0164e0f03d913e66eed6ea5ee173e", size = 17400, upload-time = "2026-09-17T21:46:42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/33/72414c56bc507b0c104605e16fab2c86465b55567e1aaead8371a4c5884d/iphone_backup_decrypt-0.11.2-py3-none-any.whl", hash = "sha256:f18281997a94313c174aa3839d42db292d198e879fa570799c151ac36ac9c9d7", size = 21779, upload-time = "2026-09-24T21:02:15.878Z" }, ] [[package]] @@ -1038,7 +1038,7 @@ requires-dist = [ { name = "betterproto2", specifier = "==0.10.0" }, { name = "click", specifier = "==8.4.2" }, { name = "cryptography", specifier = "==50.0.0" }, - { name = "iphone-backup-decrypt", specifier = "==0.10.0" }, + { name = "iphone-backup-decrypt", specifier = "==0.11.2" }, { name = "nskeyedunarchiver", specifier = "==1.5.2" }, { name = "packaging", specifier = "==26.3" }, { name = "pyahocorasick", specifier = "==2.3.1" },