Skip to content

Commit b6c08f1

Browse files
committed
Issue #24: add legacy support and proper argument configurations upport to decrypter python tools
1 parent 94f3419 commit b6c08f1

File tree

3 files changed

+216
-27
lines changed

3 files changed

+216
-27
lines changed

python/README.md

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,57 @@ Reasoning for this: I don't know Swift (and I don't even use MacOS) and I need t
4949
```bash
5050
pip install -r requirements.txt
5151
```
52-
- **(OPTIONAL)** Change your output path on [airtag_decryptor.py:30](https://github.com/parawanderer/OpenTagViewer/blob/main/python/airtag_decryptor.py#L30) if wanted
53-
- Default output path: `~/plist_decrypt_output`
5452
- Run the script:
5553
```bash
56-
python main/airtag_decryptor.py
54+
python main/airtag_decryptor.py --rename-legacy
5755
```
58-
- Note that it will prompt your password twice.
56+
Default output path is: `~/plist_decrypt_output`.
57+
58+
`--rename-legacy` is used to make it automatically perform folder rename logic for MacOS 11.x (see [issue #24](https://github.com/parawanderer/OpenTagViewer/issues/24)). It has no effects for later versions of MacOS.
59+
60+
<details>
61+
<summary><b>Q: How to provide custom output path?</b></summary>
62+
<br>
63+
64+
If you'd like to provide an alternative output path, use optional argument `--path`
65+
```bash
66+
python main/airtag_decryptor.py --rename-legacy --path='/your/alternative/path'
67+
```
68+
</details>
69+
70+
<details>
71+
<summary><b>Q: How to provide it a custom decryption key?</b></summary>
72+
<br>
73+
74+
If you used some custom method to get the `BeaconStore` keystore key (e.g. on MacOS 15 using [this approach](https://github.com/pajowu/beaconstorekey-extractor)), you can provide it directly as a **[Base64-encoded](https://www.base64encode.org/) string** string using the optional `--key` argument
75+
76+
77+
```bash
78+
python main/airtag_decryptor.py --rename-legacy --key='SGVsbG8gV29ybGQ='
79+
```
80+
</details>
81+
82+
<details>
83+
<summary><b>Q: How to make it decrypt all .plist folders?</b></summary>
84+
<br>
85+
86+
If you'd like to decrypt all `.plist` files and not just the ones in `OwnedBeacons` and `BeaconNamingRecord`, you can use the `--all` flag
87+
```bash
88+
python main/airtag_decryptor.py --rename-legacy --all
89+
```
90+
</details>
91+
92+
<details>
93+
<summary><b>Q: What other options are available?</b></summary>
94+
<br>
95+
96+
For more help and options, run the script with `--help`:
97+
```bash
98+
python main/airtag_decryptor.py --help
99+
```
100+
</details>
101+
102+
59103
- The script will open the specified output folder on success
60104

61105

@@ -106,7 +150,7 @@ pyinstaller \
106150

107151
Zip up result (MacOS):
108152
```shell
109-
APP_VERSION=1.0.4
153+
APP_VERSION=1.0.5
110154
cd ./dist
111155
zip -r OpenTagViewer-ExportWizardMacOS-$APP_VERSION.zip OpenTagViewer.app/ OpenTagViewer
112156
```

python/main/airtag_decryptor.py

Lines changed: 155 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from abc import ABC, abstractmethod
2+
import base64
23
import os
34
import re
45
import shlex
56
import subprocess
67
import plistlib
8+
import argparse
9+
import traceback
10+
from pathlib import Path
711
from Crypto.Cipher import AES
812

13+
from main.utils import MACOS_VER
14+
915

1016
# Author: Shane B. <[email protected]>
1117
#
@@ -26,11 +32,20 @@
2632
HOME = '' if os.getenv('HOME') is None else os.getenv('HOME')
2733
INPUT_PATH = os.path.join(HOME, 'Library', BASE_FOLDER)
2834

29-
# NOTE FROM AUTHOR: For my purposes these are sufficient.
30-
# You can add more if you need more, or remove the filter entirely below
31-
WHITELISTED_DIRS = {"OwnedBeacons", "BeaconNamingRecord"}
35+
OWNED_BEACONS = "OwnedBeacons"
36+
MASTER_BEACONS = "MasterBeacons"
37+
BEACON_NAMING_RECORD = "BeaconNamingRecord"
38+
39+
WHITELISTED_DIRS = {
40+
OWNED_BEACONS,
41+
MASTER_BEACONS, # <- MacOS 11 (see: https://github.com/parawanderer/OpenTagViewer/issues/24)
42+
BEACON_NAMING_RECORD
43+
}
44+
45+
RENAME_LEGACY_MAP = {
46+
MASTER_BEACONS: OWNED_BEACONS # <- MacOS 11 (see: https://github.com/parawanderer/OpenTagViewer/issues/24)
47+
}
3248

33-
# NOTE FROM AUTHOR: PROVIDE YOUR OWN OUTPUT PATH HERE IF DESIRED!!!
3449
OUTPUT_PATH = os.path.join(HOME, "plist_decrypt_output")
3550

3651

@@ -230,26 +245,47 @@ def dump_plist(plist: dict, out_file_path: str) -> None:
230245
plistlib.dump(plist, out_f)
231246

232247

233-
def make_output_path(output_root: str, input_file_path: str, input_root_folder: str) -> str:
248+
def make_output_path(
249+
output_root: str,
250+
input_file_path: str,
251+
input_root_folder: str,
252+
rename_legacy: bool = False) -> str:
234253
"""
235254
Transforms `input_file_path` into a dumping `output_file_path` along the lines of this idea (but it works
236255
generically for any level of nesting):
237256
238257
Given:
239-
- `input_file_path` = `/Users/<user>/Library/com.apple.icloud.searchpartyd/SomeFolder/.../<UUID>.record`
240258
- `output_root` = `/Users/<user>/my-target-folder`
259+
- `input_file_path` = `/Users/<user>/Library/com.apple.icloud.searchpartyd/SomeFolder/.../<UUID>.record`
241260
- `input_root_folder` = `/Users/<user>/Library/com.apple.icloud.searchpartyd`
242261
243262
This will create the path:
244263
`/Users/<user>/my-target-folder/SomeFolder/.../<UUID>.plist`
245264
265+
266+
`rename_legacy` controls the behaviour to do some legacy-related folder name remapping
267+
246268
"""
269+
270+
# Given the sample inputs, this would produce: `SomeFolder/.../<UUID>.record`
247271
rel_path: str = os.path.relpath(input_file_path, input_root_folder)
272+
273+
# This would extract the `SomeFolder` part
274+
first_path_part: str = Path(rel_path).parts[0]
275+
276+
if rename_legacy and first_path_part in RENAME_LEGACY_MAP:
277+
# this is to solve issues like https://github.com/parawanderer/OpenTagViewer/issues/24
278+
# basically a rename, like `SomeFolder/UUID.record` -> `AnotherFolder/UUID.record`
279+
rel_path = RENAME_LEGACY_MAP[first_path_part] + rel_path[rel_path.index("/"):]
280+
281+
# replace the file extension: `SomeFolder/.../<UUID>.record` -> `SomeFolder/.../<UUID>.plist`
248282
replace_file_ext: str = os.path.splitext(rel_path)[0] + ".plist"
283+
284+
# absolutify it again
249285
return os.path.join(output_root, replace_file_ext)
250286

251287

252-
def decrypt_folder(input_base_path: str, folder_name: str, key: bytearray, output_to: str):
288+
def decrypt_folder(input_base_path: str, folder_name: str, key: bytearray, output_to: str, rename_legacy: bool = False):
253289
"""
254290
Decrypt contents of folder `<input_base_path>/<folder_name>` to file path `output_to` recursively using `key`
255291
"""
@@ -266,34 +302,136 @@ def decrypt_folder(input_base_path: str, folder_name: str, key: bytearray, outpu
266302
file_dumpath: str = make_output_path(
267303
output_to,
268304
file_fullpath,
269-
input_base_path
305+
input_base_path,
306+
rename_legacy
270307
)
271308
print(f"Now trying to dump decrypted plist file to: {file_dumpath}")
272309
dump_plist(plist, file_dumpath)
273310

274311
print("Success!")
275-
except Exception as e:
276-
print(f"ERROR decrypting plist file: {e}")
312+
except Exception:
313+
print(_red("ERROR decrypting plist file"))
314+
traceback.print_exc()
277315

278316

279-
def main():
280-
os.makedirs(OUTPUT_PATH, exist_ok=True)
317+
def _red(text: str) -> str:
318+
return f"\033[91m{text}\n\033[0m"
319+
320+
321+
def _parse_b64_key(key: str) -> bytearray:
322+
if len(key) == 0:
323+
return None
324+
325+
try:
326+
return bytearray(base64.b64decode(key))
327+
except Exception:
328+
traceback.print_exc()
329+
return None
330+
331+
332+
def _determine_key_to_use(args: argparse.Namespace) -> bytearray:
333+
key: bytearray
334+
335+
if args.key is not None:
336+
337+
key = _parse_b64_key(args.key)
338+
339+
if key is None:
340+
print(_red("Invalid base64 key provided via --key argument"))
341+
exit(1)
342+
else:
343+
344+
if MACOS_VER[0] >= 15:
345+
print(_red(f"For MacOS >= 15, extracting the '{KEYCHAIN_LABEL}' key automatically is not supported due to newly introduced OS keychain access limitations. \n\nPlease consider using the --key argument (see --help) and see alternative key retrieval strategies here:\n\n\thttps://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Manually-Export-AirTags")) # noqa: E501
346+
exit(1)
347+
348+
# this thing will pop up 1 or 2 Password Input windows...
349+
key = get_key_fallback(KEYCHAIN_LABEL)
281350

282-
# this thing will pop up 2 Password Input windows...
283-
key: bytearray = get_key_fallback(KEYCHAIN_LABEL)
351+
return key
352+
353+
354+
def _assert_output_path_valid(output_to: str) -> None:
355+
if not os.path.exists(output_to):
356+
print("foo")
357+
return # Valid because doesn't exist yet, we can just init it, probably
358+
359+
if os.path.isdir(output_to):
360+
if os.listdir(output_to):
361+
print(_red(f"Output path '{output_to}' already exists and this directory is not empty. To prevent overwriting files we will avoid running the script. \n\nEither purge the contents of the directory, like so:\n\n\trm -rf '{output_to}'\n\n or use --output to specify an alternative folder first.")) # noqa: E501
362+
exit(1)
363+
364+
elif os.path.isfile(output_to):
365+
print(_red(f"Output path '{output_to}' already existed and is a file. Delete the file or use --output to provide an alternative output path")) # noqa: E501
366+
exit(1)
367+
368+
369+
def main():
370+
if MACOS_VER is None:
371+
print(_red("This tool is only supported on MacOS machines"))
372+
exit(1)
373+
374+
parser = argparse.ArgumentParser(
375+
description="CLI utility for decrypting/dumping MacOS <= 14.x FindMy cache .plist files into a folder",
376+
add_help=True,
377+
epilog="More here: https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Manually-Export-AirTags"
378+
)
379+
380+
parser.add_argument(
381+
"-o",
382+
"--output",
383+
type=str,
384+
default=OUTPUT_PATH,
385+
help=f"which folder to output the decrypted data to. Defaults to '{OUTPUT_PATH}'"
386+
)
387+
388+
parser.add_argument(
389+
"-a",
390+
"--all",
391+
action='store_true',
392+
default=False,
393+
help=f"whether to decrypt all folders or just the standard subset ({', '.join(WHITELISTED_DIRS)})"
394+
)
395+
396+
parser.add_argument(
397+
"-k",
398+
"--key",
399+
type=str,
400+
default=None,
401+
help="base64 key belonging to BeaconStore keystore record, in case it is impossible to extract the BeaconStore keychain key but you managed to obtain the key through other means. More here: https://github.com/parawanderer/OpenTagViewer/tree/main/python#-python-utility-scripts", # noqa: E501
402+
)
403+
404+
parser.add_argument(
405+
"--rename-legacy",
406+
default=False,
407+
action='store_true',
408+
help="whether to remap old MacOS folders like 'MasterBeacons' to the new name 'OwnedBeacons'. Required for use in the OpenTagViewer Android app." # noqa: E501
409+
)
410+
411+
args = parser.parse_args()
412+
413+
# args
414+
output_to: str = args.output
415+
_assert_output_path_valid(output_to)
416+
417+
rename_legacy: bool = args.rename_legacy
418+
decode_all: bool = args.all
419+
key: bytearray = _determine_key_to_use(args)
420+
421+
os.makedirs(output_to, exist_ok=True)
284422

285423
for path, folders, _ in os.walk(INPUT_PATH):
286424
for foldername in folders:
287425

288-
if foldername not in WHITELISTED_DIRS:
426+
if not decode_all and foldername not in WHITELISTED_DIRS:
289427
continue
290428

291-
decrypt_folder(path, foldername, key, OUTPUT_PATH)
429+
decrypt_folder(path, foldername, key, output_to, rename_legacy)
292430

293431
break
294432

295433
print("DONE")
296-
os.system(f'open {shlex.quote(OUTPUT_PATH)}')
434+
os.system(f'open {shlex.quote(output_to)}')
297435

298436

299437
if __name__ == '__main__':

python/main/wizard.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
from tkinter import messagebox
1414

1515
from main.airtag_decryptor import (
16+
BEACON_NAMING_RECORD,
1617
KEYCHAIN_LABEL,
1718
INPUT_PATH,
19+
MASTER_BEACONS,
20+
OWNED_BEACONS,
1821
WHITELISTED_DIRS,
1922
decrypt_plist,
2023
get_key_fallback,
@@ -25,7 +28,7 @@
2528

2629
# Wrapper around the main decryptor implementation that allows to filter which beacon files get exported/zipped
2730

28-
VERSION = "1.0.4"
31+
VERSION = "1.0.5"
2932

3033
APP_TITLE = f"OpenTagViewer AirTag Exporter {VERSION}"
3134

@@ -134,6 +137,7 @@ def _read_all_plists(self, beacon_store_key: bytearray) -> tuple[list[PListFileI
134137
beacon_naming_records: list[PListFileInfo]
135138
for path, folders, _ in os.walk(INPUT_PATH):
136139
for foldername in folders:
140+
137141
if foldername not in WHITELISTED_DIRS:
138142
continue
139143

@@ -143,9 +147,10 @@ def _read_all_plists(self, beacon_store_key: bytearray) -> tuple[list[PListFileI
143147
beacon_store_key
144148
)
145149

146-
if foldername == "OwnedBeacons":
150+
if foldername == OWNED_BEACONS or foldername == MASTER_BEACONS:
151+
# MasterBeacons is the legacy name (see: https://github.com/parawanderer/OpenTagViewer/issues/24)
147152
owned_beacons = plists
148-
elif foldername == "BeaconNamingRecord":
153+
elif foldername == BEACON_NAMING_RECORD:
149154
beacon_naming_records = plists
150155
break
151156

@@ -263,15 +268,17 @@ def _create_zip(self, output_zip_path: str, whitelisted_beacon_ids: list[str]):
263268
output_file1: str = make_output_path(
264269
tmpdirname,
265270
beacon.beacon_naming_record.filepath,
266-
INPUT_PATH
271+
INPUT_PATH,
272+
rename_legacy=True
267273
)
268274
print(f"Now dumping '{beacon.beacon_naming_record.filepath}' to {output_file1}...")
269275
dump_plist(beacon.beacon_naming_record.data, output_file1)
270276

271277
output_file2: str = make_output_path(
272278
tmpdirname,
273279
beacon.owned_beacon.filepath,
274-
INPUT_PATH
280+
INPUT_PATH,
281+
rename_legacy=True
275282
)
276283
print(f"Now dumping '{beacon.owned_beacon.filepath}' to {output_file2}...")
277284
dump_plist(beacon.owned_beacon.data, output_file2)

0 commit comments

Comments
 (0)