Compare commits

...
4 changed files with 165 additions and 170 deletions

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"simplejson==4.1.1", "simplejson==4.1.1",
"packaging==26.3", "packaging==26.3",
"appdirs==1.4.4", "appdirs==1.4.4",
"iphone_backup_decrypt==0.10.0", "iphone_backup_decrypt==0.11.2",
"pycryptodome>=3.20.0", "pycryptodome>=3.20.0",
"cryptography==50.0.0", "cryptography==50.0.0",
"PyYAML>=6.0.2", "PyYAML>=6.0.2",
+50 -161
View File
@@ -8,10 +8,8 @@ import glob
import logging import logging
import os import os
import os.path import os.path
import plistlib
import shutil import shutil
import sqlite3 import sqlite3
import tempfile
from concurrent.futures import ( from concurrent.futures import (
ALL_COMPLETED, ALL_COMPLETED,
FIRST_COMPLETED, FIRST_COMPLETED,
@@ -22,100 +20,26 @@ from concurrent.futures import (
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from iphone_backup_decrypt import EncryptedBackup from iphone_backup_decrypt import (
from iphone_backup_decrypt import google_iphone_dataprotection BackupNotEncryptedError,
EncryptedBackup,
IncorrectPassphraseError,
NotABackupFolderError,
)
from iphone_backup_decrypt.utils import FilePlist from iphone_backup_decrypt.utils import FilePlist
from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS
log = logging.getLogger(__name__) 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): class MVTEncryptedBackup(EncryptedBackup):
"""Extends EncryptedBackup with derived key export/import. """Extract backup files by ID while MVT processes them concurrently."""
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
def get_decryption_key(self): def get_decryption_key(self):
"""Return derived key as hex string (64 chars / 32 bytes).""" """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") 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): def extract_file_by_id(self, *, file_id, file_bplist, output_filename):
"""Extract one manifest entry without loading the whole file into memory.""" """Extract one manifest entry without loading the whole file into memory."""
@@ -129,8 +53,9 @@ class MVTEncryptedBackup(EncryptedBackup):
shutil.copy2(source_filename, output_filename) shutil.copy2(source_filename, output_filename)
return return
inner_key = self._keybag.unwrapKeyForClass( inner_key = self.keybag.unwrap_key_for_class(
file_plist.protection_class, file_plist.encryption_key protection_class=file_plist.protection_class,
wrapped_file_key=file_plist.encryption_key,
) )
self._decrypt_file_to_disk( self._decrypt_file_to_disk(
file_id=file_id, file_id=file_id,
@@ -139,47 +64,6 @@ class MVTEncryptedBackup(EncryptedBackup):
output_filepath=output_filename, 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: class DecryptBackup:
"""This class provides functions to decrypt an encrypted iTunes backup """This class provides functions to decrypt an encrypted iTunes backup
using either a password or a key file. using either a password or a key file.
@@ -376,41 +260,36 @@ class DecryptBackup:
) )
return return
# Before proceeding, we check whether the backup is indeed encrypted.
if not self.is_encrypted(self.backup_path):
return
try: try:
self._backup = MVTEncryptedBackup( self._backup = MVTEncryptedBackup(
backup_directory=self.backup_path, backup_directory=self.backup_path,
passphrase=password, 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 # Eagerly trigger keybag unlock so wrong-password errors
# surface here rather than later during process_backup(). # surface here rather than later during process_backup().
self._backup.test_decryption() 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: except Exception as exc:
self._backup = None self._backup = None
if ( log.exception(exc)
isinstance(exc, ValueError) log.critical("Failed to decrypt backup.")
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?"
)
def decrypt_with_key_file(self, key_file: str) -> None: def decrypt_with_key_file(self, key_file: str) -> None:
"""Decrypts an encrypted iOS backup using a key file. """Decrypts an encrypted iOS backup using a key file.
@@ -424,10 +303,6 @@ class DecryptBackup:
key_file, 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: with open(key_file, "rb") as handle:
key_bytes = handle.read() key_bytes = handle.read()
@@ -442,16 +317,30 @@ class DecryptBackup:
key_bytes_raw = binascii.unhexlify(key_bytes) key_bytes_raw = binascii.unhexlify(key_bytes)
self._backup = MVTEncryptedBackup( self._backup = MVTEncryptedBackup(
backup_directory=self.backup_path, 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. # Eagerly trigger keybag unlock so wrong-key errors surface here.
self._backup.test_decryption() 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: except Exception as exc:
self._backup = None self._backup = None
log.exception(exc) log.exception(exc)
log.critical( log.critical("Failed to decrypt backup.")
"Failed to decrypt backup. Did you provide the correct key file?"
)
def get_key(self) -> None: def get_key(self) -> None:
"""Retrieve and prints the encryption key.""" """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 # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import hashlib
import logging import logging
import plistlib
import threading import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
from Crypto.Cipher import AES from Crypto.Cipher import AES
from iphone_backup_decrypt import IncorrectPassphraseError
from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup 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) 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( def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size(
mocker, tmp_path mocker, tmp_path
): ):
@@ -39,13 +125,15 @@ def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size(
mtime=None, mtime=None,
) )
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
(tmp_path / "Manifest.plist").touch()
(tmp_path / "Manifest.db").touch()
backup = MVTEncryptedBackup( 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) mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
backup._keybag = mocker.Mock() backup.keybag = mocker.Mock()
backup._keybag.unwrapKeyForClass.return_value = inner_key backup.keybag.unwrap_key_for_class.return_value = inner_key
streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk") streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk")
output_path = tmp_path / "output" 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 assert output_path.read_bytes() == plaintext
streaming_decrypt.assert_called_once() 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): 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) file_plist = mocker.Mock(encryption_key=None)
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
(tmp_path / "Manifest.plist").touch()
(tmp_path / "Manifest.db").touch()
backup = MVTEncryptedBackup( 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) mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
output_path = tmp_path / "output" 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" 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"]) @pytest.mark.parametrize("with_symlink", [False, True], ids=["file-id", "symlink"])
def test_process_backup_rejects_unsafe_file_ids_and_destinations( def test_process_backup_rejects_unsafe_file_ids_and_destinations(
mocker, tmp_path, with_symlink mocker, tmp_path, with_symlink
Generated
+4 -4
View File
@@ -609,14 +609,14 @@ wheels = [
[[package]] [[package]]
name = "iphone-backup-decrypt" name = "iphone-backup-decrypt"
version = "0.10.0" version = "0.11.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pycryptodome" }, { 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 = [ 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]] [[package]]
@@ -1038,7 +1038,7 @@ requires-dist = [
{ name = "betterproto2", specifier = "==0.10.0" }, { name = "betterproto2", specifier = "==0.10.0" },
{ name = "click", specifier = "==8.4.2" }, { name = "click", specifier = "==8.4.2" },
{ name = "cryptography", specifier = "==50.0.0" }, { 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 = "nskeyedunarchiver", specifier = "==1.5.2" },
{ name = "packaging", specifier = "==26.3" }, { name = "packaging", specifier = "==26.3" },
{ name = "pyahocorasick", specifier = "==2.3.1" }, { name = "pyahocorasick", specifier = "==2.3.1" },