Update iOS backup decryption for iphone_backup_decrypt 0.11.2

This commit is contained in:
Janik Besendorf committed 2026-09-29 18:55:09 +02:00
1 parent 563fe44bf3
commit 3efcfaf993
4 files changed
+165 -170

No files matched your search

+1 -1
View File
@@ -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",
+50 -161
View File
@@ -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."""
+110 -4
View File
@@ -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
Generated
+4 -4
View File
@@ -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" },