Compare commits

..
23 changed files with 209 additions and 444 deletions

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.11"
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.11"
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
with:
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14', '3.15']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
env:
# Takes precedence over .python-version, which otherwise makes `uv run`
# rebuild the venv with 3.11 and test every matrix entry on 3.11.
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
UV_PYTHON: ${{ matrix.python-version }}
steps:
+1 -1
View File
@@ -1 +1 @@
3.11
3.10
+2 -2
View File
@@ -1,6 +1,6 @@
# Create main image
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -62,4 +62,4 @@ WORKDIR /home/cases
RUN echo 'echo "Mobile Verification Toolkit @ Docker\n------------------------------------\n\nYou can find information about how to use this image for Android (https://github.com/mvt-project/mvt/tree/master/docs/android) and iOS (https://github.com/mvt-project/mvt/tree/master/docs/ios) in the official docs of the project.\n"' >> ~/.bashrc \
&& echo 'echo "Note that to perform the debug via USB you might need to give the Docker image access to the USB using \"docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt\" or, preferably, the \"--device=\" parameter.\n"' >> ~/.bashrc
CMD [ "/bin/bash" ]
CMD /bin/bash
+3 -3
View File
@@ -1,6 +1,6 @@
# Create main image
FROM python:3.11.17-alpine3.23 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
FROM python:3.10.14-alpine3.20 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -8,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (Android)"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.11.17-alpine3.23
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
# Install runtime dependencies
RUN apk add --no-cache \
+1 -1
View File
@@ -1,7 +1,7 @@
# Main image
# ----------
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
# Installation
Before proceeding, please note that MVT requires Python 3.11 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
## Dependencies on Linux
-20
View File
@@ -18,28 +18,8 @@ markdown_extensions:
use_pygments: true
theme:
name: material
logo: mvt.png
favicon: favicon.png
features:
- tabs
palette:
- media: "(prefers-color-scheme)"
primary: black
toggle:
icon: material/link
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: black
toggle:
icon: material/toggle-switch
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
toggle:
icon: material/toggle-switch-off
name: Switch to system preference
plugins:
- search
- mkdocstrings
+2 -2
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",
@@ -36,7 +36,7 @@ dependencies = [
"python-dateutil==2.9.0.post0",
"tzdata==2026.3",
]
requires-python = ">= 3.11"
requires-python = ">= 3.10"
[project.urls]
homepage = "https://docs.mvt.re/en/latest/"
+4 -12
View File
@@ -160,9 +160,7 @@ def check_adb(ctx):
"zone is read from persist.sys.timezone in the bugreport itself."
),
)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(
@@ -237,9 +235,7 @@ def check_bugreport(
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -314,9 +310,7 @@ def check_backup(
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context
def check_androidqf(
@@ -403,9 +397,7 @@ def check_androidqf(
"time instead of UTC."
),
)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
+19 -42
View File
@@ -27,8 +27,6 @@ from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__)
MAX_BACKUP_PASSWORD_ATTEMPTS = 3
class InvalidAndroidBackup(Exception):
pass
@@ -74,15 +72,6 @@ class CmdAndroidCheckBackup(Command):
self.__tar: Optional[tarfile.TarFile] = None
self.__files: List[str] = []
def _get_backup_password(self) -> str:
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
if self.sub_command:
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.")
sys.exit(1)
return password
def from_ab(self, ab_file_bytes: bytes) -> None:
self.__type = "ab"
header = parse_ab_header(ab_file_bytes)
@@ -95,40 +84,28 @@ class CmdAndroidCheckBackup(Command):
sys.exit(1)
password = None
# A password typed at the prompt is asked for again if it is wrong.
# One given on the command line or in the environment is not.
max_attempts = 1
if header["encryption"] != "none":
if not self.module_options.get("backup_password", None):
max_attempts = MAX_BACKUP_PASSWORD_ATTEMPTS
password = self._get_backup_password()
attempt = 1
while True:
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
break
except InvalidBackupPassword:
if attempt < max_attempts:
attempt += 1
log.error(
"Invalid backup password, try again (attempt %d of %d)",
attempt,
max_attempts,
)
password = self._get_backup_password()
continue
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.")
sys.exit(1)
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
except InvalidBackupPassword:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
dbytes = io.BytesIO(tardata)
try:
+1 -1
View File
@@ -59,7 +59,7 @@ class SMS(BackupModule):
def collect_url_results(self) -> None:
for message in self.results:
for url in message.get("links") or []:
for url in message.get("links", []):
self.add_url_result(url, message.get("isodate"), "sms")
def run(self) -> None:
+1 -1
View File
@@ -6,7 +6,7 @@
"""Password prompts with masked keyboard feedback.
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
3.14. MVT supports Python 3.11 and later, so it cannot use that API directly.
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
"""
import contextlib
+3 -9
View File
@@ -278,9 +278,7 @@ def extract_key(password, key_file, backup_path):
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -349,9 +347,7 @@ def check_backup(
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(
@@ -420,9 +416,7 @@ def check_fs(
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option(
"--verbose", "-v", is_flag=True, hidden=True, help=HELP_MSG_VERBOSE_COMMAND
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
@click.pass_context
def check_sysdiagnose(
-12
View File
@@ -1224,10 +1224,6 @@
"version": "18.7.10",
"build": "22H374"
},
{
"version": "18.7.11",
"build": "22H384"
},
{
"version": "26",
"build": "23A341"
@@ -1287,13 +1283,5 @@
{
"version": "27.0.1",
"build": "24A446"
},
{
"version": "27.2",
"build": "24B91020"
},
{
"version": "27.2",
"build": "24B119"
}
]
+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."""
-101
View File
@@ -4,118 +4,17 @@
# https://license.mvt.re/1.1/
import io
import json
import logging
import os
import tarfile
import zlib
import pytest
from mvt.android.modules.backup.sms import SMS
from mvt.android.parsers.backup import parse_backup_file
from mvt.common.config import settings
from mvt.common.module import run_module
from ..utils import get_android_backup_folder
class TestBackupModule:
@pytest.mark.parametrize("link_fields", [{}, {"links": None}, {"links": []}])
def test_collect_urls_with_empty_links(self, link_fields):
mod = SMS()
mod.results = [
{"links": ["https://example.org/first"], "isodate": "2026-11-04 12:00:00"},
{"body": "", **link_fields},
{"links": ["https://example.org/last"], "isodate": "2026-11-04 13:00:00"},
]
mod.collect_url_results()
assert mod.url_results == [
{
"url": "https://example.org/first",
"expanded_url": None,
"timestamp": "2026-11-04 12:00:00",
"source": "sms",
},
{
"url": "https://example.org/last",
"expanded_url": None,
"timestamp": "2026-11-04 13:00:00",
"source": "sms",
},
]
@pytest.mark.parametrize("message_type", ["sms", "mms"])
@pytest.mark.parametrize("with_indicators", [False, True])
def test_blank_message_backup(
self,
tmp_path,
caplog,
monkeypatch,
indicators_factory,
message_type,
with_indicators,
):
monkeypatch.setattr(settings, "NETWORK_ACCESS_ALLOWED", False)
backup_path = tmp_path / "backup"
relative_path = (
f"apps/com.android.providers.telephony/d_f/000000_{message_type}_backup"
)
message_file = backup_path / relative_path
message_file.parent.mkdir(parents=True)
body_key = "body" if message_type == "sms" else "mms_body"
messages = [
{
body_key: body,
"date": str(1735689600000 + index * 1000),
"date_sent": "0",
}
for index, body in enumerate(
[
"",
"https://suspicious.example.org/first",
"ordinary message",
" \t ",
"",
"https://suspicious.example.org/last",
]
)
]
message_file.write_bytes(zlib.compress(json.dumps(messages).encode()))
results_path = tmp_path / "results"
results_path.mkdir()
mod = SMS(target_path=str(backup_path), results_path=str(results_path))
mod.from_dir(str(backup_path), [relative_path])
if with_indicators:
mod.indicators = indicators_factory(domains=["suspicious.example.org"])
with caplog.at_level(logging.ERROR):
run_module(mod)
assert not [
record for record in caplog.records if record.levelno == logging.ERROR
]
assert len(mod.results) == len(messages)
assert mod.results[0]["links"] is None
assert mod.url_results == [
{
"url": f"https://suspicious.example.org/{suffix}",
"expanded_url": None,
"timestamp": mod.results[index]["isodate"],
"source": "sms",
}
for index, suffix in [(1, "first"), (5, "last")]
]
assert len(mod.alertstore.alerts) == (2 if with_indicators else 0)
if with_indicators:
assert [alert.event for alert in mod.alertstore.alerts] == [
mod.results[1],
mod.results[5],
]
assert json.loads((results_path / "sms.json").read_text()) == mod.results
def test_module_folder(self):
backup_path = get_android_backup_folder()
mod = SMS(target_path=backup_path)
+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
-60
View File
@@ -59,66 +59,6 @@ class TestCheckAndroidBackupCommand:
assert result.exit_code == 1
assert "Invalid backup password" in caplog.text
def test_check_encrypted_backup_prompt_retry(self, mocker):
"""A wrong password at the prompt is asked for again"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
side_effect=["wrong_password", TEST_BACKUP_PASSWORD],
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 2
assert result.exit_code == 0
def test_check_encrypted_backup_prompt_gives_up(self, mocker, caplog):
"""The prompt gives up after three wrong passwords"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
return_value="wrong_password",
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
with caplog.at_level(logging.CRITICAL):
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 3
assert result.exit_code == 1
assert "Invalid backup password" in caplog.text
def test_check_encrypted_backup_prompt_retry_empty(self, mocker, caplog):
"""An empty answer on a retry ends the check"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
side_effect=["wrong_password", ""],
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
with caplog.at_level(logging.CRITICAL):
result = runner.invoke(check_backup, [path])
assert prompt_mock.call_count == 2
assert result.exit_code == 1
assert "No backup password provided" in caplog.text
def test_check_encrypted_backup_cli_invalid_no_prompt(self, mocker):
"""A password given on the command line is not asked for again"""
prompt_mock = mocker.patch(
"mvt.android.modules.backup.helpers.prompt_password",
return_value=TEST_BACKUP_PASSWORD,
)
runner = CliRunner()
path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab")
result = runner.invoke(
check_backup, ["--backup-password", "invalid_password", path]
)
assert prompt_mock.call_count == 0
assert result.exit_code == 1
def test_check_encrypted_backup_env(self, mocker):
"""Provide password as environment variable"""
prompt_mock = mocker.patch(
+2 -2
View File
@@ -100,8 +100,8 @@ class TestVerboseOnTheCheckCommands:
assert result.exit_code == 0
assert _console_level() == logging.DEBUG
def test_the_command_option_is_hidden_from_the_help(self):
def test_the_command_option_says_it_is_kept_for_compatibility(self):
result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"])
assert result.exit_code == 0
assert "--verbose" not in result.output
assert "kept for compatibility" in result.output
Generated
+5 -5
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.11"
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
@@ -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" },