Skip to content

Commit 9ffc3fe

Browse files
abrignoniclaude
andcommitted
feat: use a keychain the extraction already carries
Many extractions include their own keychain dump at extra/KeychainDump/backup_keychain_v2.plist, but the examiner still had to find it, pull it out and pass it with --keychain. That is avoidable work, and easy to not realise is possible. When no keychain is supplied, look for one in the extraction and use it if it yields readable entries. An explicitly supplied keychain always wins, so nothing changes for anyone already passing one, and the search result is cached so it runs once per run rather than once per artifact. The Signal artifacts also list the keychain locations in their paths, so the file is actually extracted from zip inputs rather than merely searched for. The GUI field is relabelled to say the keychain may be left blank. Verified three ways on corpus images: no argument now yields 151 messages, 582 contacts and 17 conversations where it previously needed a manual step; an explicit --keychain still takes precedence with no discovery attempted; and an extraction with no keychain reports how to supply one instead of failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent af1549a commit 9ffc3fe

3 files changed

Lines changed: 72 additions & 13 deletions

File tree

‎ileappGUI.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -907,7 +907,7 @@ def close_case_window():
907907
# that keep their database key in the keychain need it supplied here
908908
keychain_frame = ttk.LabelFrame(
909909
main_window,
910-
text=' Optional: select a keychain file captured from the device (used to decrypt apps such as Signal): ')
910+
text=' Optional: keychain file captured from the device, used to decrypt apps such as Signal. Leave blank to use one the extraction carries: ')
911911
keychain_frame.pack(padx=14, pady=2, fill='x')
912912
keychain_entry = ttk.Entry(keychain_frame)
913913
keychain_entry.pack(side='left', padx=5, pady=4, fill='x', expand=True)

‎scripts/artifacts/signalIOS.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
"requirements": "none",
99
"category": "Signal",
1010
"notes": "Signal encrypts its database with a key held in the iOS keychain. The keychain is captured separately from the file system extraction, so supply it with --keychain or the keychain field in the GUI.",
11-
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',),
11+
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',
12+
# so a keychain the extraction carries is available to decrypt with
13+
'*/extra/KeychainDump/backup_keychain_v2.plist',
14+
'*/keychain-backup.plist'),
1215
"output_types": "standard",
1316
"artifact_icon": "message-circle",
1417
},
@@ -21,7 +24,10 @@
2124
"requirements": "none",
2225
"category": "Signal",
2326
"notes": "Requires the keychain, supplied with --keychain or the keychain field in the GUI.",
24-
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',),
27+
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',
28+
# so a keychain the extraction carries is available to decrypt with
29+
'*/extra/KeychainDump/backup_keychain_v2.plist',
30+
'*/keychain-backup.plist'),
2531
"output_types": "standard",
2632
"artifact_icon": "users",
2733
},
@@ -34,7 +40,10 @@
3440
"requirements": "none",
3541
"category": "Signal",
3642
"notes": "Requires the keychain, supplied with --keychain or the keychain field in the GUI.",
37-
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',),
43+
"paths": ('*/AppGroup/*/grdb*/signal.sqlite*',
44+
# so a keychain the extraction carries is available to decrypt with
45+
'*/extra/KeychainDump/backup_keychain_v2.plist',
46+
'*/keychain-backup.plist'),
3847
"output_types": "standard",
3948
"artifact_icon": "message-square",
4049
},
@@ -45,8 +54,7 @@
4554
import sqlite3
4655
import tempfile
4756

48-
from scripts.context import Context
49-
from scripts.ios_keychain import get_app_secret
57+
from scripts.ios_keychain import get_app_secret, active_keychain_path
5058
from scripts.sqlcipher_decrypt import decrypt_sqlcipher_db
5159
from scripts.ilapfuncs import artifact_processor, logfunc, convert_unix_ts_to_utc
5260

@@ -79,12 +87,13 @@ def _decrypted_database(database_path):
7987
key_spec = get_app_secret(SIGNAL_ACCESS_GROUP, KEY_SPEC_ACCOUNT,
8088
expected_length=KEY_SPEC_LENGTH)
8189
if not key_spec:
82-
if Context.get_keychain_path():
83-
logfunc('Signal: the supplied keychain has no Signal database key, '
90+
if active_keychain_path():
91+
logfunc('Signal: the keychain in use has no Signal database key, '
8492
'the database stays encrypted')
8593
else:
86-
logfunc('Signal: found an encrypted database but no keychain was supplied. '
87-
'Pass one with --keychain, or the keychain field in the GUI, to decrypt it.')
94+
logfunc('Signal: found an encrypted database but no keychain is available. '
95+
'The extraction does not carry one, so supply it with --keychain or the '
96+
'keychain field in the GUI.')
8897
return None
8998

9099
digest = hashlib.sha1(database_path.encode('utf-8', 'replace')).hexdigest()[:12]

‎scripts/ios_keychain.py‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
implementation using iLEAPP's existing dependencies.
3131
"""
3232
import io
33+
import os
3334
import plistlib
3435

3536
import nska_deserialize
@@ -43,7 +44,16 @@
4344
ENCRYPTED_ENTRIES_KEY = 'keychainEntries'
4445
METADATA_CLASS_KEYS_KEY = 'classKeyIdxToUnwrappedMetadataClassKey'
4546

47+
# Some extractions carry a keychain dump of their own. These are the locations
48+
# worth looking in when the examiner did not supply one, matching the paths the
49+
# keychain artifact already uses.
50+
IN_EXTRACTION_KEYCHAIN_GLOBS = (
51+
'*/extra/KeychainDump/backup_keychain_v2.plist',
52+
'*/keychain-backup.plist',
53+
)
54+
4655
_cache = {}
56+
_discovered = {}
4757

4858

4959
def _decrypt_gcm(archived_blob, key):
@@ -152,6 +162,46 @@ def find_keychain_secrets(keychain_path, access_group=None, account=None, servic
152162
return matches
153163

154164

165+
def _discover_keychain():
166+
"""Look for a keychain the extraction carries, when none was supplied.
167+
168+
Returns a path, or None. The result is cached, including the negative case,
169+
so the search runs once per run rather than once per artifact.
170+
"""
171+
if 'path' in _discovered:
172+
return _discovered['path']
173+
_discovered['path'] = None
174+
175+
try:
176+
seeker = Context.get_seeker()
177+
except Exception: # pylint: disable=broad-except
178+
return None # called outside an artifact, nothing to search
179+
if seeker is None:
180+
return None
181+
182+
for glob in IN_EXTRACTION_KEYCHAIN_GLOBS:
183+
try:
184+
found = [str(path) for path in seeker.search(glob)]
185+
except Exception: # pylint: disable=broad-except
186+
continue
187+
for candidate in found:
188+
if not os.path.isfile(candidate):
189+
continue
190+
logfunc('Keychain: no keychain was supplied, trying the one carried by the '
191+
f'extraction at {candidate}')
192+
# Only accept it if it actually yields entries we can read
193+
if load_keychain_entries(candidate):
194+
_discovered['path'] = candidate
195+
return candidate
196+
logfunc('Keychain: that keychain yielded no readable entries, ignoring it')
197+
return None
198+
199+
200+
def active_keychain_path():
201+
"""The keychain to read: the supplied one, else one found in the extraction."""
202+
return Context.get_keychain_path() or _discover_keychain()
203+
204+
155205
def get_app_secret(access_group, account=None, service=None, expected_length=None):
156206
"""Return one secret for an app from the examiner-supplied keychain, or None.
157207
@@ -167,11 +217,11 @@ def get_app_secret(access_group, account=None, service=None, expected_length=Non
167217
Returns:
168218
The secret as bytes, or None when no keychain was supplied or nothing matched.
169219
"""
170-
keychain_path = Context.get_keychain_path()
171-
if not keychain_path:
220+
path = active_keychain_path()
221+
if not path:
172222
return None
173223

174-
matches = find_keychain_secrets(keychain_path, access_group, account, service)
224+
matches = find_keychain_secrets(path, access_group, account, service)
175225
if expected_length is not None:
176226
matches = [secret for secret in matches if len(secret) == expected_length]
177227
if not matches:

0 commit comments

Comments
 (0)