diff --git a/docs/ios/records.md b/docs/ios/records.md index 00f89c8..10ddab9 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -429,7 +429,7 @@ If indicators are provided through the command-line, they are checked against th Backup: :material-check: Full filesystem dump: :material-check: -This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Contacts with a disappearing messages timer set produce a `disappearing_mode_set` event in the timeline. +This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated). This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup. diff --git a/src/mvt/ios/modules/mixed/whatsapp_contacts.py b/src/mvt/ios/modules/mixed/whatsapp_contacts.py index ab750ce..309da22 100644 --- a/src/mvt/ios/modules/mixed/whatsapp_contacts.py +++ b/src/mvt/ios/modules/mixed/whatsapp_contacts.py @@ -53,6 +53,7 @@ COLUMN_CANDIDATES = { "disappearing_mode_duration": ["ZDISAPPEARINGMODEDURATION"], "disappearing_mode_timestamp": ["ZDISAPPEARINGMODETIMESTAMP"], "about_timestamp": ["ZABOUTTIMESTAMP"], + "about_expiration_timestamp": ["ZABOUTEXPIRATIONTIMESTAMP"], "last_updated": ["ZLASTUPDATED"], "phone_status": ["ZPHONESTATUS", "ZPHONENUMBERSTATUS"], "sync_policy": ["ZSYNCPOLICY"], @@ -76,6 +77,7 @@ STRING_FIELDS = [ DATE_FIELDS = [ "disappearing_mode_timestamp", "about_timestamp", + "about_expiration_timestamp", "last_updated", ] @@ -98,6 +100,19 @@ def _label_duration(duration) -> str: ) +def _describe_contact(record: ModuleAtomicResult) -> str: + contact = ( + record.get("whatsapp_id") + or record.get("lid") + or record.get("phone_number") + or "unknown" + ) + full_name = record.get("full_name") + if full_name: + contact = f"{contact} ({full_name})" + return contact + + class WhatsappContacts(IOSExtraction): """This module extracts WhatsApp contact records and per-contact disappearing-messages settings from ContactsV2.sqlite. @@ -127,30 +142,61 @@ class WhatsappContacts(IOSExtraction): ) def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: - timestamp = record.get("disappearing_mode_timestamp") - if not timestamp: - return {} + records = [] + contact = _describe_contact(record) - contact = ( - record.get("whatsapp_id") - or record.get("lid") - or record.get("phone_number") - or "unknown" - ) - data = ( - f"WhatsApp disappearing messages timer set to " - f"'{record.get('disappearing_mode_label')}' for {contact}" - ) - full_name = record.get("full_name") - if full_name: - data += f" ({full_name})" + if record.get("disappearing_mode_timestamp"): + records.append( + { + "timestamp": record["disappearing_mode_timestamp"], + "module": self.__class__.__name__, + "event": "disappearing_mode_set", + "data": ( + f"WhatsApp disappearing messages timer set to " + f"'{record.get('disappearing_mode_label')}' " + f"for {contact}" + ), + } + ) - return { - "timestamp": timestamp, - "module": self.__class__.__name__, - "event": "disappearing_mode_set", - "data": data, - } + if record.get("about_timestamp"): + data = f"WhatsApp about text of {contact} changed" + about_text = record.get("about_text") + if about_text: + data += f' to "{about_text}"' + records.append( + { + "timestamp": record["about_timestamp"], + "module": self.__class__.__name__, + "event": "about_changed", + "data": data, + } + ) + + if record.get("about_expiration_timestamp"): + records.append( + { + "timestamp": record["about_expiration_timestamp"], + "module": self.__class__.__name__, + "event": "about_expiration", + "data": ( + f"WhatsApp about text of {contact} scheduled " + f"to expire" + ), + } + ) + + if record.get("last_updated"): + records.append( + { + "timestamp": record["last_updated"], + "module": self.__class__.__name__, + "event": "contact_last_updated", + "data": f"WhatsApp contact record for {contact} updated", + } + ) + + return records def run(self) -> None: try: diff --git a/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f b/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f index 8522620..132059c 100644 Binary files a/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f and b/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f differ diff --git a/tests/ios_backup/test_whatsapp_contacts.py b/tests/ios_backup/test_whatsapp_contacts.py index 09590ba..a91b8f1 100644 --- a/tests/ios_backup/test_whatsapp_contacts.py +++ b/tests/ios_backup/test_whatsapp_contacts.py @@ -25,6 +25,8 @@ class TestWhatsappContactsModule: assert alice["disappearing_mode_is_on"] is True assert alice["disappearing_mode_label"] == "24 hours" assert alice["disappearing_mode_timestamp"] == "2025-07-23 21:46:40.000000" + assert alice["about_timestamp"] == "2025-07-12 08:00:00.000000" + assert alice["about_expiration_timestamp"] == "2025-08-16 01:20:00.000000" assert alice["last_updated"] == "2025-08-04 11:33:20.000000" bob = next(r for r in m.results if r["given_name"] == "Bob") @@ -34,11 +36,43 @@ class TestWhatsappContactsModule: assert bob["disappearing_mode_label"] == "off" assert bob["disappearing_mode_timestamp"] is None - assert len(m.timeline) == 1 - assert m.timeline[0]["event"] == "disappearing_mode_set" - assert m.timeline[0]["timestamp"] == "2025-07-23 21:46:40.000000" - assert "24 hours" in m.timeline[0]["data"] - assert "14155550100@s.whatsapp.net" in m.timeline[0]["data"] + # Alice: disappearing_mode_set, about_changed, about_expiration and + # contact_last_updated. Bob: contact_last_updated only. + assert len(m.timeline) == 5 + + events = { + (entry["event"], entry["timestamp"]): entry["data"] + for entry in m.timeline + } + assert ( + "24 hours" + in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")] + ) + assert ( + "14155550100@s.whatsapp.net (Alice Example)" + in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")] + ) + assert ( + 'changed to "Hey there! I am using WhatsApp."' + in events[("about_changed", "2025-07-12 08:00:00.000000")] + ) + assert ( + "scheduled to expire" + in events[("about_expiration", "2025-08-16 01:20:00.000000")] + ) + + updated = [ + entry["data"] + for entry in m.timeline + if entry["event"] == "contact_last_updated" + ] + assert len(updated) == 2 + assert all( + entry["timestamp"] == "2025-08-04 11:33:20.000000" + for entry in m.timeline + if entry["event"] == "contact_last_updated" + ) + assert any("14155550101@s.whatsapp.net (Bob Example)" in d for d in updated) assert len(m.alertstore.alerts) == 0