Skip to content

Commit 022ca0b

Browse files
abrignoniclaude
andcommitted
feat: accept an examiner-supplied keychain and add a lookup helper
iOS keychains are captured separately from the file system extraction, so an app that keeps its database key in the keychain cannot find that key inside the extraction. iLEAPP had no way to take one. Adds --keychain on the command line and a matching field on the main GUI window, stored on Context as run-level state that survives clear() between artifacts, plus scripts/ios_keychain.py for artifacts to ask for a secret by access group and account. The lookup is deliberately generic rather than built around one app. A survey of the corpus keychains found key material for Signal, Session, Snapchat, Trust Wallet, what3words and Apple Notes, and Signal and Session use a byte-identical 48 byte GRDBDatabaseCipherKeySpec. No artifact consumes this yet; Signal for iOS follows in its own change. Verified against real keychains: the Signal, Session and Snapchat secrets are all retrieved from one file, a missing keychain returns None rather than raising, a wrong app returns None, and the path survives Context.clear(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0bfee31 commit 022ca0b

4 files changed

Lines changed: 175 additions & 0 deletions

File tree

‎ileapp.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ def validate_args(args):
7373
if args.load_profile and not os.path.exists(args.load_profile):
7474
raise argparse.ArgumentError(None, 'iLEAPP Profile file not found! Run the program again.')
7575

76+
if args.keychain and not os.path.isfile(args.keychain):
77+
raise argparse.ArgumentError(None, 'Keychain file not found! Run the program again.')
78+
7679
try:
7780
pytz.timezone(args.timezone)
7881
except pytz.UnknownTimeZoneError as ex:
@@ -195,6 +198,10 @@ def main():
195198
parser.add_argument('--custom_output_folder', required=False, action="store", help="Custom name for the output folder")
196199
parser.add_argument('--custom_artifacts_path', required=False, action="store", help="Additional path to load artifacts from (e.g., scripts/alternate_artifacts)")
197200
parser.add_argument('--itunes_password', required=False, action="store", help="Password used for encrypted iTunes backup")
201+
parser.add_argument('--keychain', required=False, action="store",
202+
help=("Path to a keychain file captured from the device. Some apps keep "
203+
"their database key in the keychain, which is collected separately "
204+
"from the file system extraction."))
198205

199206
# Check if no arguments were provided
200207
if len(sys.argv) == 1:
@@ -333,6 +340,7 @@ def main():
333340
time_offset = args.timezone
334341
custom_output_folder = args.custom_output_folder
335342
itunes_backup_password = args.itunes_password
343+
Context.set_keychain_path(args.keychain)
336344

337345
# ios file system extractions contain paths > 260 char, which causes problems
338346
# This fixes the problem by prefixing \\?\ on each windows path.

‎ileappGUI.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,13 @@ def process(casedata):
493493
casedata = {key: value.get() for key, value in casedata.items()}
494494
out_params = OutputParameters(output_folder, output_folder_name_entry.get().strip())
495495
Context.set_output_params(out_params)
496+
keychain_path = keychain_entry.get().strip()
497+
if keychain_path and not os.path.isfile(keychain_path):
498+
tk_msgbox.showerror(title='Error',
499+
message=f'Keychain file not found:\n{keychain_path}',
500+
parent=main_window)
501+
return
502+
Context.set_keychain_path(keychain_path or None)
496503
wrap_text = True
497504
time_offset = timezone_set.get()
498505
if time_offset == '':
@@ -594,6 +601,22 @@ def select_input(button_type):
594601
history.record_input_path(input_filename)
595602

596603

604+
def select_keychain():
605+
'''Select a keychain file and insert its path into the keychain field'''
606+
keychain_filename = tk_filedialog.askopenfilename(
607+
parent=main_window,
608+
title='Select a keychain file',
609+
filetypes=(('Keychain files', '*.plist *.xml *.json'), ('All files', '*.*')))
610+
if keychain_filename:
611+
keychain_entry.delete(0, 'end')
612+
keychain_entry.insert(0, keychain_filename)
613+
614+
615+
def clear_keychain():
616+
'''Forget the selected keychain so it is not reused on the next case'''
617+
keychain_entry.delete(0, 'end')
618+
619+
597620
def select_output():
598621
'''Select target and insert its path into output field'''
599622
output_filename = tk_filedialog.askdirectory(parent=main_window, title='Select a folder')
@@ -879,6 +902,20 @@ def close_case_window():
879902
input_folder_button = ttk.Button(input_frame, text='Browse Folder', command=lambda: select_input('folder'))
880903
input_folder_button.pack(side='left', padx=5, pady=4)
881904

905+
### Keychain (optional)
906+
# iOS keychains are captured separately from the file system extraction, so apps
907+
# that keep their database key in the keychain need it supplied here
908+
keychain_frame = ttk.LabelFrame(
909+
main_window,
910+
text=' Optional: select a keychain file captured from the device (used to decrypt apps such as Signal): ')
911+
keychain_frame.pack(padx=14, pady=2, fill='x')
912+
keychain_entry = ttk.Entry(keychain_frame)
913+
keychain_entry.pack(side='left', padx=5, pady=4, fill='x', expand=True)
914+
keychain_clear_button = ttk.Button(keychain_frame, text='Clear', width=6, command=lambda: clear_keychain())
915+
keychain_clear_button.pack(side='left', padx=2, pady=4)
916+
keychain_file_button = ttk.Button(keychain_frame, text='Browse File', command=lambda: select_keychain())
917+
keychain_file_button.pack(side='left', padx=5, pady=4)
918+
882919
output_frame = ttk.LabelFrame(main_window, text=' Select Output Path ')
883920
output_frame.pack(padx=14, pady=5, fill='x')
884921
output_path_row = ttk.Frame(output_frame)

‎scripts/context.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class Context:
2727
_data_folder = None
2828
_metadata = {}
2929
_installed_os_version = ""
30+
# Run-level, like the output parameters: set once from the CLI or GUI and
31+
# deliberately not reset by clear() between artifacts
32+
_keychain_path = None
3033

3134
@staticmethod
3235
def set_output_params(output_params):
@@ -186,6 +189,27 @@ def set_installed_os_version(os_version):
186189
if not Context._installed_os_version:
187190
Context._installed_os_version = os_version
188191

192+
@staticmethod
193+
def set_keychain_path(keychain_path):
194+
"""
195+
Records the path to a keychain file supplied by the examiner.
196+
197+
iOS keychains are captured separately from the file system extraction,
198+
so apps that keep their database key in the keychain cannot find it in
199+
the extraction itself.
200+
"""
201+
Context._keychain_path = keychain_path
202+
203+
@staticmethod
204+
def get_keychain_path():
205+
"""
206+
Returns the examiner-supplied keychain path, or None when none was given.
207+
208+
Artifacts must treat None as "no keychain available" and degrade rather
209+
than fail, since supplying one is optional.
210+
"""
211+
return Context._keychain_path
212+
189213
@staticmethod
190214
def _build_lookup_map():
191215
"""Builds and returns a dictionary mapping filenames to a list

‎scripts/ios_keychain.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Lookup of app secrets in an examiner-supplied iOS keychain.
2+
3+
iOS keychains are captured separately from the file system extraction, so an
4+
app that keeps its database key in the keychain cannot find that key inside the
5+
extraction. The examiner supplies the keychain with --keychain (or the field in
6+
the GUI) and artifacts read it through here.
7+
8+
Apps whose keys live in the keychain include Signal and Session (both a 48 byte
9+
GRDBDatabaseCipherKeySpec that is a 32 byte key followed by a 16 byte salt),
10+
Snapchat, Trust Wallet and what3words, so the lookup is deliberately generic
11+
rather than written around one app.
12+
13+
Supported inputs are the plist keychain dumps produced by extraction tools,
14+
which hold generic passwords under a 'genp' list. Entries carry an access group
15+
('agrp') naming the owning app, an account ('acct'), a service ('svce') and the
16+
secret itself in 'v_Data'.
17+
"""
18+
import plistlib
19+
20+
from scripts.context import Context
21+
from scripts.ilapfuncs import logfunc
22+
23+
GENERIC_PASSWORD_KEY = 'genp'
24+
25+
_cache = {}
26+
27+
28+
def _text(entry, field):
29+
"""Keychain string fields are sometimes bytes and sometimes str."""
30+
value = entry.get(field)
31+
if isinstance(value, bytes):
32+
return value.decode('utf-8', 'ignore').rstrip('\x00')
33+
return str(value) if value is not None else ''
34+
35+
36+
def load_keychain_entries(keychain_path):
37+
"""Return the generic-password entries of a keychain plist, cached per path."""
38+
if keychain_path in _cache:
39+
return _cache[keychain_path]
40+
41+
_cache[keychain_path] = []
42+
try:
43+
with open(keychain_path, 'rb') as keychain_file:
44+
parsed = plistlib.load(keychain_file)
45+
except (OSError, ValueError) as error:
46+
logfunc(f'Keychain: could not read {keychain_path}: {error}')
47+
return _cache[keychain_path]
48+
49+
if not isinstance(parsed, dict) or GENERIC_PASSWORD_KEY not in parsed:
50+
logfunc(f'Keychain: {keychain_path} is not a supported keychain dump '
51+
'(no generic password entries found)')
52+
return _cache[keychain_path]
53+
54+
entries = parsed.get(GENERIC_PASSWORD_KEY) or []
55+
_cache[keychain_path] = [entry for entry in entries if isinstance(entry, dict)]
56+
return _cache[keychain_path]
57+
58+
59+
def find_keychain_secrets(keychain_path, access_group=None, account=None, service=None):
60+
"""Return the raw secrets matching the given access group, account and service.
61+
62+
Each filter is optional and matched case-insensitively as a substring, so
63+
'whispersystems' matches the full 'TEAMID.org.whispersystems.signal'.
64+
"""
65+
matches = []
66+
for entry in load_keychain_entries(keychain_path):
67+
if access_group and access_group.lower() not in _text(entry, 'agrp').lower():
68+
continue
69+
if account and account.lower() not in _text(entry, 'acct').lower():
70+
continue
71+
if service and service.lower() not in _text(entry, 'svce').lower():
72+
continue
73+
secret = entry.get('v_Data')
74+
if isinstance(secret, (bytes, bytearray)):
75+
matches.append(bytes(secret))
76+
return matches
77+
78+
79+
def get_app_secret(access_group, account=None, service=None, expected_length=None):
80+
"""Return one secret for an app from the examiner-supplied keychain, or None.
81+
82+
Args:
83+
access_group: substring of the owning app's access group, for example
84+
'org.whispersystems.signal'.
85+
account: substring of the account name, for example
86+
'GRDBDatabaseCipherKeySpec'.
87+
service: substring of the service name, when needed to disambiguate.
88+
expected_length: if given, only a secret of exactly this many bytes is
89+
returned, which guards against picking up an unrelated entry.
90+
91+
Returns:
92+
The secret as bytes, or None when no keychain was supplied or nothing matched.
93+
"""
94+
keychain_path = Context.get_keychain_path()
95+
if not keychain_path:
96+
return None
97+
98+
matches = find_keychain_secrets(keychain_path, access_group, account, service)
99+
if expected_length is not None:
100+
matches = [secret for secret in matches if len(secret) == expected_length]
101+
if not matches:
102+
return None
103+
if len(matches) > 1:
104+
logfunc(f'Keychain: {len(matches)} entries matched {access_group}/{account}, '
105+
'using the first')
106+
return matches[0]

0 commit comments

Comments
 (0)