From 6b0c439d1ccb09c4c6bc203a50b9b1e811f00062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 11:53:50 +0200 Subject: [PATCH] Add timeline events for all WhatsApp contact timestamps Extract ZABOUTEXPIRATIONTIMESTAMP and emit a timeline event for each timestamp stored on a WhatsApp contact record: disappearing messages timer changes, "about" text changes and scheduled expiry, and contact record updates. ContactsV2.sqlite stores no other date attributes in any released schema version. --- docs/ios/records.md | 2 +- .../ios/modules/mixed/whatsapp_contacts.py | 90 +++++++++++++----- .../b8548dc30aa1030df0ce18ef08b882cf7ab5212f | Bin 8192 -> 8192 bytes tests/ios_backup/test_whatsapp_contacts.py | 44 ++++++++- 4 files changed, 108 insertions(+), 28 deletions(-) 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 8522620a203cfd63dee2386496d89c39757a8406..132059c9e044a30c9e8cf17697a344a73ccd596c 100644 GIT binary patch delta 122 zcmZp0XmFU2#iGx^tG}`EAP-loA0xZCxjAE(_2xugKPG-hC;!k8*N6bmAjc3-f4|Ao z_zTz`GVr_eJ=`oPaFvfgg-M)&lT%4Zih)5z!+~M)Pq_#&Ge(e@nwXRtNX*EKfq|KY Wg=KP@{B&+5-v+$^5eGeoOAG*v8XV97 delta 100 zcmZp0XmFU2#S+fI8@^dk;3W@Ns~RJ_xVbrFXZB`GUO%SE>-odkUNi6q@Vy4gU*MY< zz|WV=B*wtWsU#%Dz@VbxF!_UAgs3hfNJvdgN)04r