diff --git a/scripts/artifacts/discordAccount.py b/scripts/artifacts/discordAccount.py index 2b5043e..965e355 100644 --- a/scripts/artifacts/discordAccount.py +++ b/scripts/artifacts/discordAccount.py @@ -6,16 +6,18 @@ "client itself recorded about the machine it was running " "on. They identify the installation that the other " "artifacts in this category were parsed from. The " - "time zone recorded here is the one the log-derived " - "artifacts (Channel Navigation, Gateway Sessions) must " - "be read against, since those are written in device " - "local time. The " + "time zone recorded here is the time zone the client " + "recorded when this file was last written; it is the " + "best available reference for the log-derived " + "artifacts (Channel Navigation, Gateway Sessions), " + "which are written in device local time, but the host " + "time zone may have changed over the log's span. The " "Sentry crash-reporting scope is the richest single " "source: it names the account, the app build and the " - "hardware, and it is written on every run.", + "hardware.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Reads sentry/scope_v3.json, settings.json, Preferences, " diff --git a/scripts/artifacts/discordCacheRecords.py b/scripts/artifacts/discordCacheRecords.py index 75fbb4d..acdb913 100644 --- a/scripts/artifacts/discordCacheRecords.py +++ b/scripts/artifacts/discordCacheRecords.py @@ -1,9 +1,10 @@ __artifacts_v2__ = { "discordCacheRecords": { "name": "Discord Cache Records", - "description": "Index of every response held in the Discord Desktop " - "HTTP cache, with the time the client requested it and " - "the time the response was stored. Discord has no " + "description": "Index of the cached responses held in the Discord " + "Desktop HTTP cache, excluding versioned application " + "bundle assets, with the time the client requested each " + "one and the time the response was stored. Discord has no " "browsing history database, so this index is the closest " "equivalent: it shows which API calls, CDN images, " "embedded links and third-party resources the client " @@ -14,7 +15,7 @@ "rather than a complete one.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Versioned application bundle assets (js, css, fonts, icons) " diff --git a/scripts/artifacts/discordContacts.py b/scripts/artifacts/discordContacts.py index 1ffae2a..91c1c83 100644 --- a/scripts/artifacts/discordContacts.py +++ b/scripts/artifacts/discordContacts.py @@ -1,11 +1,11 @@ __artifacts_v2__ = { "discordUsers": { "name": "Discord Users Seen", - "description": "Every Discord account observed anywhere in the cached " - "application data: message authors, mentioned users, " - "direct message recipients, people who reacted to a " - "message, invite creators and cached profiles. User IDs " - "are snowflakes, so each account's registration date is " + "description": "Every Discord account seen in the cached responses this " + "parser decodes: message authors, mentioned users, DM " + "recipients, reaction users, profiles and invite " + "creators. User IDs are snowflakes, so each account's " + "registration date is " "recoverable, and a cached avatar is embedded where one " "survives. Where a profile response was cached, the " "external accounts Discord recorded as connected to it " @@ -15,13 +15,16 @@ "account's activity window.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Profile fields (bio, pronouns, connected accounts) are only " "present where a profile response was cached, so a sparse row " "means no profile response survives, not that the account has " - "no profile.", + "no profile. " + "Reference: Discord Developer Documentation, " + "'Snowflakes (ID format)', " + "https://discord.com/developers/docs/reference#snowflakes", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', @@ -47,7 +50,7 @@ "covers only the recovered messages.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Channel names and topics are only known where the client " @@ -55,7 +58,10 @@ "otherwise only the ID is reported. The server a channel " "belongs to is resolved from cached channel objects, the " "renderer log's routing entries and the Local Storage channel " - "selection state.", + "selection state. " + "Reference: Discord Developer Documentation, " + "'Snowflakes (ID format)', " + "https://discord.com/developers/docs/reference#snowflakes", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', @@ -84,13 +90,16 @@ "for a server that was never joined.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Member counts are the approximate values Discord returned at " "the time the response was cached, not current figures. " "Channel and message counts are limited to what the cache and " - "the navigation log revealed about each server.", + "the navigation log revealed about each server. " + "Reference: Discord Developer Documentation, " + "'Snowflakes (ID format)', " + "https://discord.com/developers/docs/reference#snowflakes", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', @@ -106,22 +115,24 @@ }, "discordInvites": { "name": "Discord Invites", - "description": "Server invite links the client looked up. Discord " - "resolves an invite code through the API before showing " - "the join prompt, and that response names the server, the " - "channel the invite points at, who created it and when " - "it expires. A row records that the client resolved that " - "invite code and what Discord returned for it. It does " - "not establish that the user joined the server.", + "description": "Server invite links the client looked up. A cached " + "`/invites/` response records that the client " + "resolved that invite code and what Discord returned: " + "the server, the channel the invite points at, who " + "created it and when it expires. It does not establish " + "that the user joined the server.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "One row per invite code, from the most recent cached lookup. " "The expiry is the value Discord returned when the code was " "resolved, so an expired invite may still have been valid when " - "it was used.", + "it was used. " + "Reference: Discord Developer Documentation, " + "'Snowflakes (ID format)', " + "https://discord.com/developers/docs/reference#snowflakes", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', diff --git a/scripts/artifacts/discordLocalStorage.py b/scripts/artifacts/discordLocalStorage.py index b968e92..f5803d9 100644 --- a/scripts/artifacts/discordLocalStorage.py +++ b/scripts/artifacts/discordLocalStorage.py @@ -4,14 +4,14 @@ "description": "Message drafts held in Local Storage. Discord saves the " "draft box as text is typed, and Local Storage is a " "LevelDB, so superseded versions of the key stay on " - "disk. The result is a keystroke-level history of text " - "as it was composed, each version with its own " - "timestamp and target channel. A row records what was in " + "disk. The result is successive saved versions of the " + "draft text, each with its own stored timestamp and " + "target channel. A row records what was in " "the compose box at that time; whether it was ever sent " "cannot be determined from this artifact alone.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Rows come from every surviving version of the DraftStore key, " @@ -29,21 +29,27 @@ "name": "Discord Client Activity", "description": "Application usage reconstructed from the Local Storage " "state Discord keeps between runs: channels opened and " - "when, servers selected, voice channels joined, quick " - "switcher history and client session heartbeats. This is " + "when, servers selected, the selected voice channel, " + "quick switcher history and client session heartbeats. " + "This is " "usage state that exists independently of any message " "content: it records when the client opened channels, " "selected servers and started sessions, whether or not " "any message from those channels was cached.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Channel-open events come from the frecency store, which keeps " "a rolling window of recent usages; older versions of the key " "extend that window further back. Entries without a stored " - "timestamp are reported in order with no time.", + "timestamp are reported in order with no time. The 'Selected " + "voice channel (state)' row pairs the store's " + "lastConnectedTime with its selectedVoiceChannelId: both are " + "co-resident fields of one rolling state object, so the " + "pairing is inferred rather than a recorded association " + "between that time and that channel.", "paths": ('*/discord*/Local Storage/leveldb/*',), "output_types": ["html", "tsv", "timeline", "lava"], "artifact_icon": "activity", @@ -204,8 +210,14 @@ def add(timestamp, event, target, detail, record): elif record.key == "SelectedChannelStore": connected = state.get("lastConnectedTime") if isinstance(state, dict) else None if state.get("selectedVoiceChannelId"): - add(discord_api.epoch_ms_to_datetime(connected), "Voice channel connected", - str(state["selectedVoiceChannelId"]), "", record) + # lastConnectedTime and selectedVoiceChannelId are independent + # members of one rolling state object. The store does not record + # that this time belongs to this channel, so the row is labelled + # as state rather than as a join event. + add(discord_api.epoch_ms_to_datetime(connected), + "Selected voice channel (state)", + str(state["selectedVoiceChannelId"]), + "timestamp is the store's lastConnectedTime", record) for guild_id, channel_id in (state.get("selectedChannelIds") or {}).items(): add("", "Last channel for server", str(channel_id), f"server {guild_id}", record) diff --git a/scripts/artifacts/discordMedia.py b/scripts/artifacts/discordMedia.py index f338bf0..838d3d1 100644 --- a/scripts/artifacts/discordMedia.py +++ b/scripts/artifacts/discordMedia.py @@ -1,10 +1,11 @@ __artifacts_v2__ = { "discordRecoveredMedia": { "name": "Discord Recovered Media", - "description": "Every Discord image, video, avatar, emoji, sticker and " - "server icon still held in the application cache, " - "extracted and embedded in the report. Attachment URLs " - "carry the channel ID and an attachment snowflake, so a " + "description": "Every cached file this parser could identify as Discord " + "media and decode, extracted and embedded in the report: " + "images, video, avatars, emoji, stickers and server " + "icons. Attachment URLs carry the channel ID and an " + "attachment snowflake, so a " "cached file can be tied to its channel and dated even " "when the message that carried it is long gone. The " "'Message Recovered' column flags files whose " @@ -13,11 +14,12 @@ "file here does not indicate it was never present.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", - "notes": "Discord serves resized WebP copies to the client, so " - "recovered bytes are often a transcode rather than the " + "notes": "The served content type is frequently WebP rather than the " + "uploaded type, so recovered bytes are often a transcode " + "rather than the " "original upload and will not necessarily hash to the file as " "it was uploaded. One " "row per cached file: the same image appears more than once " @@ -83,8 +85,8 @@ def discordRecoveredMedia(context): content_type = (media.get("content_type") or "").split(";")[0].strip() filename = discord_api.attachment_filename(media["url"]) - # Prefer the served type for images and video (Discord transcodes to - # WebP), otherwise keep the extension the file was uploaded with. + # Prefer the served type for images and video (frequently WebP rather + # than the uploaded type), otherwise keep the uploaded extension. if content_type.startswith(("image/", "video/", "audio/")): extension = content_type.split("/")[-1] else: diff --git a/scripts/artifacts/discordMessages.py b/scripts/artifacts/discordMessages.py index cc4f833..09da1e0 100644 --- a/scripts/artifacts/discordMessages.py +++ b/scripts/artifacts/discordMessages.py @@ -14,7 +14,7 @@ "its message.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "Parses cached responses to /api/v*/channels//messages and " @@ -22,7 +22,13 @@ "point-in-time snapshot and the most recent one is reported, so " "an edit made after the last cache write is not reflected here. " "Direction is resolved against the signed-in account id taken " - "from the Sentry scope and Local Storage.", + "from the Sentry scope and Local Storage, so it is only " + "resolvable when that account id was found; where it was not, " + "Direction is left empty for every row rather than defaulted. " + "The Attachments and Attachment Names columns list at most ten " + "attachments per message. Ten is an observed cap rather than a " + "limit Discord documents; the Discord Attachments artifact " + "reports every attachment the API declared.", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', @@ -63,13 +69,16 @@ "not indicate it was never shared.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", - "notes": "Cached copies are frequently the WebP variant Discord serves " - "to the client rather than the original upload, so the " - "recovered bytes can differ from the file the sender chose. " - "The reported size is the size declared by the API.", + "notes": "Cached copies are frequently WebP rather than the uploaded " + "type, so the recovered bytes can differ from the file the " + "sender chose. " + "The reported size is the size declared by the API. " + "Reference: Discord Developer Documentation, " + "'Snowflakes (ID format)', " + "https://discord.com/developers/docs/reference#snowflakes", "paths": ( '*/discord*/Cache/Cache_Data/*_0', '*/discord*/Service Worker/CacheStorage/*/*/*_0', @@ -92,7 +101,9 @@ from scripts.chromium.simple_cache import read_entry from scripts.ilapfuncs import artifact_processor, check_in_embedded_media, logfunc -# Discord caps a message at ten attachments, so this never truncates a message. +# Attachments embedded per message row are capped. Ten is an observed cap, not +# a limit Discord documents, so a message carrying more would be truncated here. +# The Discord Attachments artifact reports every attachment without a cap. _MAX_MEDIA_PER_MESSAGE = 10 @@ -127,7 +138,8 @@ def _recover_media(path, media, filename): return None extension = os.path.splitext(filename)[1].lstrip(".") content_type = media.get("content_type", "").split(";")[0].strip() - # Discord transcodes to WebP on delivery; trust the served type over the name. + # The served type is frequently WebP rather than the uploaded type; trust + # the served type over the name. if content_type.startswith("image/") or content_type.startswith("video/"): extension = content_type.split("/")[-1] return check_in_embedded_media( @@ -201,6 +213,17 @@ def discordMessages(context): sent = discord_api.iso_to_datetime(message.get("timestamp")) \ or discord_api.snowflake_to_datetime(message_id) + # Direction is only resolvable against the signed-in account id. When + # find_local_account resolved nothing, local_ids is empty and every + # message would otherwise be labelled "Received", so the column is left + # empty rather than asserting a direction the data does not support. + if not local_ids: + direction = "" + elif author_id and author_id in local_ids: + direction = "Sent" + else: + direction = "Received" + media_refs = [] names = [] for attachment in (message.get("attachments") or [])[:_MAX_MEDIA_PER_MESSAGE]: @@ -216,7 +239,7 @@ def discordMessages(context): data_list.append(( sent, - "Sent" if author_id and author_id in local_ids else "Received", + direction, discord_api.user_display(author), _channel_label(scan, str(message.get("channel_id") or "")), message.get("content") or "", diff --git a/scripts/artifacts/discordSearches.py b/scripts/artifacts/discordSearches.py index 3e21b5b..1bd0470 100644 --- a/scripts/artifacts/discordSearches.py +++ b/scripts/artifacts/discordSearches.py @@ -36,13 +36,12 @@ "cached reaction listings. Each row places a named " "account on a specific message in a specific channel, so " "it records which accounts Discord reported as having " - "reacted to that message. Discord only requests this " - "listing when a reaction list is hovered or opened, so " - "coverage is limited to messages whose reactions were " - "inspected in the client.", + "reacted to that message. Rows exist only where a " + "reactions listing response was cached, so coverage is " + "limited to messages for which the client requested one.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Discord (macOS)", "notes": "The listing is capped by the limit Discord requested, so a " diff --git a/scripts/artifacts/robloxActivity.py b/scripts/artifacts/robloxActivity.py index 4947532..3dd0dde 100644 --- a/scripts/artifacts/robloxActivity.py +++ b/scripts/artifacts/robloxActivity.py @@ -47,10 +47,11 @@ "name": "Roblox Real-Time Notifications", "description": "Real-time notifications retained in Roblox embedded-browser " "Local Storage. Chat notifications can preserve the sender, " - "conversation ID and message text shown to the user.", + "conversation ID and the message text the notification " + "payload carried.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (macOS)", "notes": "Only live Local Storage versions are parsed. The key can be " diff --git a/scripts/artifacts/robloxLogs.py b/scripts/artifacts/robloxLogs.py index eefa494..1807a9e 100644 --- a/scripts/artifacts/robloxLogs.py +++ b/scripts/artifacts/robloxLogs.py @@ -2,12 +2,13 @@ "robloxGameJoins": { "name": "Roblox Game Joins", "description": "Roblox experience joins reconstructed from Player logs, " - "including UTC time, place and universe IDs, game instance, " - "account, join attempt, party, join origin, and the UDMUX and " - "RCC server addresses recorded by the client.", + "with the timestamp as written in the log (UTC where the " + "line carries a Z suffix), place and universe IDs, game " + "instance, account, join attempt, party, join origin, and " + "the UDMUX and RCC server addresses recorded by the client.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (macOS)", "notes": "UDMUX and RCC labels follow the source log. In the tested corpus " @@ -30,14 +31,16 @@ "server IP, elapsed time, body size and retry state.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (macOS)", "notes": "URLs and query parameters are reported verbatim for evidentiary " "analysis and may contain tokens or credential-like values. The " "parser does not determine whether those values remain valid or " "reusable. Player-log URLs form a partial activity record, not " - "browser history.", + "browser history. External is empty where the log line carried " + "no external: field, since an absent field is not a recorded " + "value.", "paths": ( "*/Library/Logs/Roblox/*_Player_*.log", ), @@ -49,12 +52,13 @@ }, "robloxPlayerLog": { "name": "Roblox Player Log", - "description": "All structured Roblox Player log events with their UTC " - "timestamp, process-relative elapsed time, severity, logging " - "component and message.", + "description": "All structured Roblox Player log events with the " + "timestamp as written in the log (UTC where the line " + "carries a Z suffix), process-relative elapsed time, " + "severity, logging component and message.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (macOS)", "notes": "Long messages are limited to 10,000 characters. The first " @@ -133,7 +137,7 @@ def _sort_time(value): def robloxGameJoins(context): data_headers = ( ("Joined", "datetime"), "Place ID", "Universe ID", "Game Instance ID", - "User ID", "Public Server", "Public Port", "RCC Server", "RCC Port", + "User ID", "UDMUX Address", "UDMUX Port", "RCC Server", "RCC Port", "Join Attempt ID", "Party ID", "Join Origin", "Source File", "Line", ) joins = [] @@ -232,7 +236,9 @@ def robloxHttpActivity(context): ip_address.group(1) if ip_address else "", timing.group(1) if timing else "", body_size.group(1) if body_size else "", - "Yes" if external and external.group(1) == "1" else "No", + # No external: field in the line means the log did not record the + # value; reporting "No" would assert a value the log never carried. + ("Yes" if external.group(1) == "1" else "No") if external else "", retries.group(1) if retries else "", context.get_relative_path(path), line_number, )) diff --git a/scripts/artifacts/robloxStorage.py b/scripts/artifacts/robloxStorage.py index 76973ce..6558137 100644 --- a/scripts/artifacts/robloxStorage.py +++ b/scripts/artifacts/robloxStorage.py @@ -56,11 +56,11 @@ "name": "Roblox Asset Cache Index", "description": "Roblox's rbx-storage cache index, recording cached object " "identifiers, last-access times, logical sizes, hit counts, " - "categories, raw score values, expiry values and stored-content " + "categories, raw score values, TTL values and stored-content " "signatures.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (macOS)", "notes": "The 16-byte IDs are opaque cache keys. Atime behaves as Unix " @@ -281,7 +281,8 @@ def _content_signature(content): @artifact_processor def robloxAssetCache(context): data_headers = ( - ("Last Access", "datetime"), ("Expires", "datetime"), "Cache ID", + ("Last Access", "datetime"), + ("TTL (database value, as Unix seconds)", "datetime"), "Cache ID", "Category", "Logical Size (bytes)", "Hit Count", "Score", "Stored Content Size (bytes)", "Content Signature", "Source File", ) diff --git a/scripts/artifacts/robloxWebView2.py b/scripts/artifacts/robloxWebView2.py index 4057a71..c1043c1 100644 --- a/scripts/artifacts/robloxWebView2.py +++ b/scripts/artifacts/robloxWebView2.py @@ -6,12 +6,17 @@ "and complete DPAPI-protected blob.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (Windows)", "notes": "CookiesData is encrypted with Windows DPAPI and cannot be decrypted " "from AppData alone. The complete base64 value is retained for " - "decryption when matching Windows account material is available.", + "decryption when matching Windows account material is available. " + "First 20 Bytes is the leading hex of the decoded value, reported " + "as stored; the parser does not verify it against the DPAPI " + "provider GUID. " + "Reference: Microsoft, 'Windows Data Protection (DPAPI)', " + "https://learn.microsoft.com/en-us/windows/win32/seccng/cng-dpapi", "paths": ("*/AppData/Local/Roblox/LocalStorage/RobloxCookies.dat",), "output_types": ["html", "tsv", "lava"], "artifact_icon": "key", @@ -48,13 +53,19 @@ "and referring visit identifiers.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (Windows)", "notes": "URLs and query parameters are reported in full and may contain " "challenge tokens or credential-like values. The parser does not " "determine whether those values remain valid or reusable. History " - "is a retained partial record and can include repeated visits.", + "is a retained partial record and can include repeated visits. " + "Transition names and the microsecond visit duration follow the " + "Chromium definitions. " + "Reference: Chromium, 'ui/base/page_transition_types.h and the " + "History database schema', " + "https://chromium.googlesource.com/chromium/src/+/main/ui/base/" + "page_transition_types.h", "paths": ( "*/AppData/Local/Roblox/UniversalApp/WebView2/EBWebView/Default/History", ), @@ -170,12 +181,17 @@ "from cached Roblox API responses.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (Windows)", "notes": "Only user-specific API endpoints are included; general feature " "configuration metadata is excluded. Nested JSON is flattened to " - "field paths. Empty collections are retained as negative evidence. " + "field paths. Empty collections are retained as stored rather " + "than dropped. " + "Responses with no user ID in the URL are attributed from the " + "current appStorage account, so Subject User ID can be " + "misattributed where the profile was used by more than one " + "account or the signed-in account was switched. " "Output can contain PII, contact details and privacy settings.", "paths": ( "*/AppData/Local/Roblox/UniversalApp/WebView2/EBWebView/Default/" @@ -220,13 +236,19 @@ "Storage.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-28", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Roblox (Windows)", "notes": "Purchase-flow telemetry documents page views, available products " "and session state; it does not by itself prove a completed purchase. " "Payment-card data was not present in the tested AppData corpus. " - "One row is emitted per balance, session or purchase-flow event.", + "One row is emitted per balance, session or purchase-flow event. " + "Responses with no user ID in the URL are attributed from the " + "current appStorage account, so Subject User ID can be " + "misattributed where the profile was used by more than one " + "account or the signed-in account was switched. " + "Client-Reported Event Time is the lt parameter of a " + "client-generated telemetry URL, not a server timestamp.", "paths": ( "*/AppData/Local/Roblox/UniversalApp/WebView2/EBWebView/Default/" "Cache/Cache_Data/*", @@ -456,7 +478,7 @@ def _cache_json(entry): def robloxWindowsCookieVault(context): data_headers = ( "Format Version", "DPAPI Blob (base64)", "Decoded Blob Size (bytes)", - "DPAPI Header", "Source File", + "First 20 Bytes (hex)", "Source File", ) data_list = [] source_paths = [] @@ -869,7 +891,8 @@ def _commerce_cache_type(url): @artifact_processor def robloxWebView2Commerce(context): data_headers = ( - ("Event Time", "datetime"), "Activity Type", "Subject User ID", + ("Client-Reported Event Time (lt)", "datetime"), "Activity Type", + "Subject User ID", "Payment Session ID", "Purchase Flow UUID", "View Name", "Event Type", "Message", "Status", "Current View", "Robux Balance", "Robux Package IDs", "Subscription Product IDs", "Application Type", diff --git a/scripts/artifacts/signalAccount.py b/scripts/artifacts/signalAccount.py index fda90c1..0db876f 100644 --- a/scripts/artifacts/signalAccount.py +++ b/scripts/artifacts/signalAccount.py @@ -5,12 +5,11 @@ "installation and how the client is configured, from the " "database's own key-value store together with the " "unencrypted profile files. Includes the linked device " - "name and when it was created, which dates the link " - "between this computer and the phone that owns the " - "account.", + "name and the time the client stored for this linked " + "device record.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "PyCryptodome; the Signal database credential for the " "account fields", "category": "Signal (macOS)", @@ -111,8 +110,8 @@ def add(prop, value, path): continue source_path = source_path or file_found if config.get("key"): - add("Database Key Storage", "Plaintext 'key' in config.json " - "(older Signal Desktop)", file_found) + add("Database Key Storage", "Plaintext 'key' in config.json", + file_found) if config.get("encryptedKey"): add("Database Key Storage", "'encryptedKey', wrapped with the OS " "credential store", file_found) diff --git a/scripts/artifacts/signalContacts.py b/scripts/artifacts/signalContacts.py index c75ccd4..12d7c2d 100644 --- a/scripts/artifacts/signalContacts.py +++ b/scripts/artifacts/signalContacts.py @@ -33,12 +33,16 @@ "direction, how it ended and when.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "PyCryptodome; the Signal database credential", "category": "Signal (macOS)", "notes": "Signal Desktop records calls it observed. A call placed or " "answered on a phone linked to the same account may not appear " - "here, so an absent call is not evidence no call took place.", + "here, so an absent call is not evidence no call took place. " + "Start To End (s) is the difference between the row's start " + "and end timestamps. It is reported for every row, including " + "missed, declined and unanswered outcomes, so it is not by " + "itself the length of a connected call.", "paths": ( '*/Signal*/sql/db.sqlite', '*/Signal*/config.json', @@ -55,13 +59,14 @@ "signalSessions": { "name": "Signal Sessions & Identity Keys", "description": "Signal Protocol sessions and identity keys the client " - "holds. A session exists for each device the client has " - "exchanged messages with, and an identity key record is " - "kept for each account whose key it has seen, together " - "with when that key was first recorded.", + "holds. A session row exists for each device this client " + "established a Signal Protocol session with, and an " + "identity key record is kept for each account whose key " + "it has seen, together with the timestamp stored on that " + "record.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "PyCryptodome; the Signal database credential", "category": "Signal (macOS)", "notes": "These records show cryptographic contact at the protocol " @@ -160,7 +165,7 @@ def signalConversations(context): def signalCalls(context): data_headers = ( ("Started", "datetime"), "With", "Call Type", "Direction", "Status", - ("Ended", "datetime"), "Duration (s)", "Ringer", "Call ID", "Peer ID", + ("Ended", "datetime"), "Start To End (s)", "Ringer", "Call ID", "Peer ID", "Source File", ) @@ -214,7 +219,7 @@ def signalCalls(context): @artifact_processor def signalSessions(context): data_headers = ( - "Record", "Account", "Service ID", "Device ID", ("First Seen", "datetime"), + "Record", "Account", "Service ID", "Device ID", ("Record Timestamp", "datetime"), "Verified State", "Non-Blocking Approval", "Source File", ) diff --git a/scripts/artifacts/signalMessages.py b/scripts/artifacts/signalMessages.py index a644057..93247a8 100644 --- a/scripts/artifacts/signalMessages.py +++ b/scripts/artifacts/signalMessages.py @@ -53,13 +53,18 @@ "recorded one, its SHA-256.", "author": "@AlexisBrignoni", "creation_date": "2026-07-26", - "last_update_date": "2026-07-26", + "last_update_date": "2026-08-01", "requirements": "PyCryptodome; the Signal database credential", "category": "Signal (macOS)", - "notes": "Signal pads a stored attachment with zeroes to hide its true " - "length, so the file is truncated to the size the database " - "records. The Verified column reports whether the recovered " - "bytes matched the SHA-256 the database holds for the original.", + "notes": "The stored plaintext is longer than the recorded size, so it " + "is truncated to the size the database records. The Verified " + "column reports whether the recovered bytes matched the " + "SHA-256 the database holds for the original. Where no file " + "was recovered it names only the causes that can be told " + "apart here (no attachments folder, no key or no path in the " + "database, or the file absent from disk); every other failure " + "is reported as 'Could not decrypt' rather than asserting a " + "single cause for all of them.", "paths": ( '*/Signal*/sql/db.sqlite', '*/Signal*/config.json', @@ -137,6 +142,28 @@ def _service_id_labels(connection, labels): return mapping +def _recovery_failure(root, relative_path, local_key): + """Why decrypt_attachment recovered nothing, limited to what is knowable. + + That function returns no plaintext for several distinct reasons: no + attachments root, no stored path, no key held in the database, the file + not being readable, a blob too short to hold the IV and MAC, key material + that is not valid base64 or is under 64 bytes, and an AES failure. Only + the causes that can be told apart from here are named. Everything else is + reported as a failed decryption rather than asserting one specific cause + for all of them. + """ + if not root: + return "Attachments folder not in extraction" + if not local_key: + return "No key recorded" + if not relative_path: + return "No stored path recorded" + if not os.path.exists(os.path.join(root, relative_path.replace("/", os.sep))): + return "File not in extraction" + return "Could not decrypt" + + @artifact_processor def signalMessages(context): data_headers = ( @@ -279,7 +306,7 @@ def signalAttachments(context): force_extension=extension) if plaintext is None: - verified = "File not in extraction" + verified = _recovery_failure(root, path, local_key) elif matched is True: verified = "Yes, SHA-256 matched" elif matched is False: diff --git a/scripts/artifacts/telegramCache.py b/scripts/artifacts/telegramCache.py index 92bec0f..6ac1282 100644 --- a/scripts/artifacts/telegramCache.py +++ b/scripts/artifacts/telegramCache.py @@ -8,7 +8,7 @@ "signatures. Recovered cleartext is embedded in the report.", "author": "@AlexisBrignoni, Codex", "creation_date": "2026-07-29", - "last_update_date": "2026-07-29", + "last_update_date": "2026-08-01", "requirements": "PyCryptodome", "category": "Telegram Desktop", "notes": "Only live binlog entries whose backing object exists and passes " @@ -17,7 +17,10 @@ "cache key is an opaque Telegram media identifier and does not " "by itself identify a chat or message. Cache tags come from " "Telegram's own image/sticker/voice/video-message/animation " - "classification.", + "classification. The tag-to-label mapping was established " + "from the Telegram Desktop cache implementation cited in " + "scripts/telegram.py; a tag outside that mapping is reported " + "as Unknown with the raw value shown alongside.", "paths": ( "*/Telegram Desktop/tdata/key_data*", "*/Telegram Desktop/tdata/user_data/cache/*/binlog*", diff --git a/scripts/artifacts/whatsappMedia.py b/scripts/artifacts/whatsappMedia.py index ef0c66b..2f1dc87 100644 --- a/scripts/artifacts/whatsappMedia.py +++ b/scripts/artifacts/whatsappMedia.py @@ -8,11 +8,12 @@ "title and contact-card name are reported alongside.", "author": "@AlexisBrignoni", "creation_date": "2026-07-27", - "last_update_date": "2026-07-27", + "last_update_date": "2026-08-01", "requirements": "none", "category": "WhatsApp (Apple)", - "notes": "The media kind shown is derived from the stored file's extension, " - "so it reflects the file itself. Latitude and longitude are shown " + "notes": "The media kind shown is derived from the file extension recorded " + "in the database path; it is not verified against the file's " + "content. Latitude and longitude are shown " "only when the row holds a non-zero coordinate. A row can list a " "media path whose file is no longer in the extraction, in which " "case no file is embedded.", diff --git a/scripts/artifacts/whatsappMessages.py b/scripts/artifacts/whatsappMessages.py index f76cb0d..ba8ce85 100644 --- a/scripts/artifacts/whatsappMessages.py +++ b/scripts/artifacts/whatsappMessages.py @@ -9,11 +9,16 @@ "against the message.", "author": "@AlexisBrignoni", "creation_date": "2026-07-27", - "last_update_date": "2026-07-27", + "last_update_date": "2026-08-01", "requirements": "none", "category": "WhatsApp (Apple)", "notes": "Direction is taken from the ZISFROMME column: a set flag is " - "reported as Outgoing, a clear flag as Incoming. Type Code is the " + "reported as Outgoing, a clear flag as Incoming. It is left " + "blank where ZISFROMME is NULL and on ZMESSAGETYPE 6 rows, " + "which in the tested corpus occurred only in group chats and " + "never carried a set flag, so reporting them as Incoming " + "would present a system entry as a received message. " + "Type Code is the " "ZMESSAGETYPE value the database stores and is left as the integer " "rather than a guessed label; where a message carries a file, the " "file itself is embedded so its kind is visible directly. A row " @@ -53,6 +58,11 @@ _EPOCH_MIN = datetime.min.replace(tzinfo=timezone.utc) +# ZMESSAGETYPE values that are not a sent or received message. Established by +# testing: every type 6 row in the tested corpus sat in a group chat and had +# ZISFROMME clear, which is the shape of a system entry rather than a message. +_NON_MESSAGE_TYPES = {6} + _QUERY = """ SELECT m.Z_PK, @@ -68,6 +78,18 @@ """ +def _direction(is_from_me, message_type): + """Direction only where the row is a message and the flag was recorded. + + A NULL ZISFROMME and a non-message row both fall through to Incoming if + the flag is read as a plain boolean, which would show a system entry as a + message the account received. + """ + if is_from_me is None or message_type in _NON_MESSAGE_TYPES: + return "" + return "Outgoing" if is_from_me else "Incoming" + + def _sender(is_from_me, chat_jid, partner_name, contact_jid, from_jid, push_name, member_name, member_jid): """Best available human label for who sent the message.""" @@ -116,7 +138,7 @@ def whatsappMessages(context): media_ref = reference embedded += 1 - direction = "Outgoing" if is_from_me else "Incoming" + direction = _direction(is_from_me, message_type) sender = _sender(is_from_me, contact_jid, partner_name, contact_jid, from_jid, push_name, member_name, member_jid) sender_jid = "" if is_from_me else ( diff --git a/scripts/artifacts/wireAppLog.py b/scripts/artifacts/wireAppLog.py index 7bb7eef..d11e38b 100644 --- a/scripts/artifacts/wireAppLog.py +++ b/scripts/artifacts/wireAppLog.py @@ -2,13 +2,13 @@ "wireDesktopLog": { "name": "Wire Desktop Log", "description": "Activity timeline parsed from the Wire desktop app log " - "(logs/electron.log and electron.old): app launches, " - "auth/login navigation, team info sync, updates and other " - "notable events. Timestamps are the device's local time as " - "written by the app.", + "(logs/electron.log and electron.old): every timestamped " + "line from the Wire desktop log except repetitive " + "config-restore entries. Timestamps are the device's " + "local time as written by the app.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Repetitive config-restore lines are filtered out.", diff --git a/scripts/artifacts/wireCookies.py b/scripts/artifacts/wireCookies.py index 8f99360..7bb0c47 100644 --- a/scripts/artifacts/wireCookies.py +++ b/scripts/artifacts/wireCookies.py @@ -2,13 +2,13 @@ "wireCookies": { "name": "Wire Cookies", "description": "Cookies from the Wire desktop app's network stores " - "(Network/Cookies, main profile and Electron partitions). " - "Includes the Wire 'zuid' auth session cookie with its " - "creation, expiry and last-access times. Cookie values are " + "(Network/Cookies, main profile and Electron partitions), " + "including the cookie named 'zuid', with its creation, " + "expiry and last-access times. Cookie values are " "OS-encrypted and are not decrypted here.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Only cookies for wire.com hosts are reported.", @@ -22,7 +22,8 @@ import sqlite3 from datetime import datetime, timezone -from scripts.ilapfuncs import artifact_processor, logfunc +from scripts.ilapfuncs import (artifact_processor, logfunc, + open_sqlite_db_readonly) # Chromium/WebKit timestamps: microseconds since 1601-01-01 UTC. _CHROME_EPOCH_OFFSET = 11644473600 @@ -66,8 +67,10 @@ def wireCookies(context): continue parsed.add(real) + con = open_sqlite_db_readonly(file_found) + if con is None: + continue try: - con = sqlite3.connect(f"file:{file_found}?mode=ro", uri=True) cur = con.cursor() cur.execute(""" SELECT host_key, name, path, creation_utc, expires_utc, diff --git a/scripts/artifacts/wireIndexedDb.py b/scripts/artifacts/wireIndexedDb.py index 44ac7b1..38ff7a0 100644 --- a/scripts/artifacts/wireIndexedDb.py +++ b/scripts/artifacts/wireIndexedDb.py @@ -7,11 +7,13 @@ "details. One LevelDB can hold more than one login.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Parses https_app.wire.com_0.indexeddb.leveldb via the vendored " - "CCL Chromium IndexedDB reader and Spyder Forensics IndexedDBtoJSON logic.", + "CCL Chromium IndexedDB reader and Spyder Forensics IndexedDBtoJSON logic. " + "'MLS Identity Created' is the mls_credentials created_at value " + "read as epoch seconds, the unit the sampled values match.", "paths": ('*/https_app.wire.com_0.indexeddb.leveldb/*',), "output_types": ["html", "tsv", "timeline", "lava"], "artifact_icon": "user", @@ -36,22 +38,20 @@ "description": "Client devices from the IndexedDB clients store, " "attributed to their owner: the signed-in account's own " "device(s) (local_identity) AND the contact devices the " - "account established end-to-end sessions with. Includes " - "class/model, registration, last active and the fingerprint " + "client has records for. Includes class/model, " + "registration, last active and the fingerprint " "verification state where known.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-24", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "'Relationship' separates the account owner's own device from a " "contact's device. A contact device is a recipient device the " "app set up encryption with; its class (e.g. 'phone') describes " "the CONTACT's device, NOT a device the account owner used. " - "'Fingerprint Verified' is whether that device's identity was " - "manually verified in Wire: own devices are trusted " - "automatically (true); contact devices are unverified (false) " - "unless the user verified them.", + "'Fingerprint Verified' is the meta.is_verified value the " + "client stored for that device record.", "paths": ('*/https_app.wire.com_0.indexeddb.leveldb/*',), "output_types": ["html", "tsv", "timeline", "lava"], "artifact_icon": "smartphone", @@ -78,11 +78,13 @@ "conversation names.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Recovered thumbnails are decrypted from the on-disk asset " - "caches when present.", + "caches when present. 'Outgoing' is left blank on events that " + "carry no sender, such as a conversation creation, because " + "those are not a message in either direction.", "paths": ( '*/https_app.wire.com_0.indexeddb.leveldb/*', '*/Service Worker/CacheStorage/*/*/*_0', @@ -154,15 +156,21 @@ "name": "Wire Calls", "description": "Voice and video calls recorded in the Wire IndexedDB " "(conversation voice-channel events): call end time, " - "conversation, initiator, duration and end reason.", + "conversation, the user each event came from, duration " + "and end reason.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Duration is taken from the voice-channel-deactivate event " - "(milliseconds). End-reason labels follow the Wire AVS reason " - "enum and are shown alongside the raw code.", + "(milliseconds). 'Event From User' is the event's 'from' " + "value, reported for the start and end events alike; the " + "'from' user on an end event is not established to be who " + "ended the call. End-reason labels are an interpretation of " + "the stored reason code and could not be tied to a published " + "Wire AVS enum, so the raw code is always shown alongside and " + "a code outside the mapping is left unlabelled.", "paths": ('*/https_app.wire.com_0.indexeddb.leveldb/*',), "output_types": ["html", "tsv", "timeline", "lava"], "artifact_icon": "phone", @@ -170,12 +178,14 @@ "wireProteusSessions": { "name": "Wire Proteus Sessions", "description": "Proteus end-to-end sessions the account established, one " - "per contact device (domain@user@client). Evidence of " - "which users and which of their devices were messaged " - "securely. Session key bytes are not exported.", + "per contact device (domain@user@client). A session " + "record exists per contact device the client set up " + "Proteus encryption with; it does not by itself " + "establish that a message was exchanged. Session key " + "bytes are not exported.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "", @@ -213,7 +223,9 @@ # Reference data # --------------------------------------------------------------------------- # -# Wire AVS call end-reason enum -> label (raw code is always shown too). +# Call end-reason code -> label. The labels are an interpretation and were not +# tied to a published Wire AVS enum, so the raw code is always shown too and an +# unrecognized code is left unlabelled. CALL_END_REASON = { 0: "Completed (normal)", 1: "Error", 2: "Timeout", 3: "Lost media", 4: "Canceled", 5: "Answered elsewhere", 6: "I/O error", 7: "Still ongoing", @@ -505,7 +517,10 @@ def wireAccountInfo(context): if uid not in local_clients or _completeness(v) > _completeness(local_clients[uid]["value"]): local_clients[uid] = rec - # client-id and MLS-identity creation time per account from mls_credentials + # client-id and MLS-identity creation time per account from mls_credentials. + # created_at is epoch SECONDS, not the milliseconds used elsewhere in this + # module: the values seen in a Wire IndexedDB sample were 10-digit numbers, + # which land in the present day as seconds and in 1970 as milliseconds. cred_clients = {} cred_created = {} for rec in stores.get("mls_credentials", []): @@ -786,12 +801,19 @@ def wireMessages(context): kind, text, attachment = _event_text(etype, d, users, self_ids) sender_id = v.get("from") or "" cid = v.get("conversation") or "" + # Only an event with a sender has a direction. Conversation creations + # and some member events carry no 'from', and scoring those 0 would + # render them as messages the account received. + if not sender_id: + outgoing = "" + else: + outgoing = 1 if sender_id in self_ids else 0 rows.append(( _iso_to_dt(v.get("time")), _account_label(users, self_ids, rec.get("db_name")), conv_names.get(cid, cid), _display_name(users, sender_id, self_ids), - 1 if sender_id in self_ids else 0, + outgoing, media_for(d) if etype == "conversation.asset-add" else "", kind, text or "", @@ -996,7 +1018,7 @@ def wireCalls(context): else datetime.min.replace(tzinfo=timezone.utc))) data_headers = ( - ("Timestamp", "datetime"), "Account", "Conversation", "Initiated/Ended By", + ("Timestamp", "datetime"), "Account", "Conversation", "Event From User", "Event", "Duration", "Duration (ms)", "End Reason", "Reason Code", "Call Event ID", "From User ID", "Conversation ID", ) diff --git a/scripts/artifacts/wireLocalStorage.py b/scripts/artifacts/wireLocalStorage.py index 23ac2ee..af48353 100644 --- a/scripts/artifacts/wireLocalStorage.py +++ b/scripts/artifacts/wireLocalStorage.py @@ -2,12 +2,14 @@ "wireLocalStorage": { "name": "Wire Local Storage", "description": "Key/value pairs from the Wire desktop app's Chromium " - "Local Storage (main profile and Electron partitions): " - "app-instance id, analytics (Countly) device id, favourite " - "camera/mic device hashes, UI language and preferences.", + "Local Storage (main profile and Electron partitions), " + "reported as keys and values as stored. Keys observed in " + "the tested corpus included an app-instance id, an " + "analytics (Countly) device id, favourite camera/mic " + "device hashes, UI language and preferences.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", "notes": "Only entries for wire.com origins are reported.", diff --git a/scripts/artifacts/wireServiceWorkerCache.py b/scripts/artifacts/wireServiceWorkerCache.py index 5dd54fc..84c3b9f 100644 --- a/scripts/artifacts/wireServiceWorkerCache.py +++ b/scripts/artifacts/wireServiceWorkerCache.py @@ -4,16 +4,25 @@ "description": "Assets cached on disk by the Wire web app's service " "worker (Service Worker/CacheStorage). Each entry records " "the requested asset URL, the resolved CDN (CloudFront) " - "download URL and its expiry. The cached bodies are Wire " + "download URL and its expiry. Cached asset bodies are Wire " "end-to-end-encrypted (application/octet-stream), so they " "cannot be rendered as images without the asset keys.", "author": "@AlexisBrignoni", "creation_date": "2026-07-23", - "last_update_date": "2026-07-23", + "last_update_date": "2026-08-01", "requirements": "none", "category": "Wire (Windows)", - "notes": "Parses Chromium Simple Cache entry files (*_0). Bodies remain " - "encrypted; only request/CDN URLs and metadata are extracted.", + "notes": "Parses Chromium Simple Cache entry files (*_0). No body is " + "decoded; only request/CDN URLs and metadata are extracted. An " + "entry can also be matched on any wire.com URL, which admits " + "responses that are not assets, so the encrypted-body note is " + "shown only where an /assets/ URL was resolved. 'Content Type " + "(heuristic)' is the first MIME-shaped string found anywhere in " + "the raw entry, not a parsed response header. 'CDN Expires' " + "reads the CloudFront Expires query parameter as Unix seconds. " + "Reference: AWS, 'CloudFront signed URLs (Expires is Unix time " + "in seconds)', https://docs.aws.amazon.com/AmazonCloudFront/" + "latest/DeveloperGuide/private-content-signed-urls.html", "paths": ('*/Service Worker/CacheStorage/*/*/*_0',), "output_types": ["html", "tsv", "timeline", "lava"], "artifact_icon": "hard-drive", @@ -72,6 +81,10 @@ def wireServiceWorkerCache(context): text = raw.decode("latin1", "replace") req = _ASSET_URL_RE.search(text) req_url = req.group(0) if req else "" + # The fallback below matches any wire.com URL, so it can pick up a + # response that is not an asset at all. Only a resolved /assets/ URL + # justifies saying anything about the body. + is_asset = bool(req_url) if not req_url: any_url = _ANY_WIRE_URL_RE.search(text) req_url = any_url.group(0) if any_url else "" @@ -101,12 +114,13 @@ def wireServiceWorkerCache(context): expires, ctype, len(raw), - "Body is Wire-encrypted (not an image)", + "Asset bodies in this cache are Wire-encrypted; not decoded here" + if is_asset else "", )) data_headers = ( "Cache Entry", "Request URL", "Asset ID", "CDN Download URL", - ("CDN Expires", "datetime"), "Content Type", "Entry Size (bytes)", - "Note", + ("CDN Expires", "datetime"), "Content Type (heuristic)", + "Entry Size (bytes)", "Note", ) return data_headers, data_list, source_path diff --git a/scripts/chromium/discord_api.py b/scripts/chromium/discord_api.py index cf478d5..34d15ba 100644 --- a/scripts/chromium/discord_api.py +++ b/scripts/chromium/discord_api.py @@ -85,7 +85,14 @@ def snowflake_to_datetime(snowflake): - """Convert a Discord snowflake ID to its embedded creation time (UTC).""" + """Convert a Discord snowflake ID to its embedded creation time (UTC). + + The 2015-01-01 epoch and the 22-bit shift are the documented snowflake + layout. + + Reference: Discord Developer Documentation, 'Snowflakes (ID format)', + https://discord.com/developers/docs/reference#snowflakes + """ try: value = int(snowflake) except (TypeError, ValueError):