Skip to content

Commit 9f2b38e

Browse files
Added ability to decrypt encrypted iTunes backups for a more secure process. Added a --manual-password switch to the program to allow for command-line specification of password and not needing a file on disk. Added AppleBackupHashedManifestPlist class to manage interactions with the Manifest Plist file used for the keybags.
1 parent 2e7a3d8 commit 9f2b38e

14 files changed

+518
-78
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ The options that are currently supported are:
6464
|-o|--output-dir DIRECTORY|Change the output directory from the default ./output|
6565
|-w|--password-file FILE|File with plaintext passwords, one per line.|
6666
|-r|--retain-display-order|Retain the display order for folders and notes, not the database's order.|
67+
||--manual-password|Enter a password from the command prompt, without having to use a file on disk.|
6768
||--show-password-successes|Toggle the display of password success ON.|
6869
||--range-start DATE|Set the start date of the date range to extract. Must use YYYY-MM-DD format, defaults to 1970-01-01.|
6970
||--range-end DATE|Set the end date of the date range to extract. Must use YYYY-MM-DD format, defaults to 2024-08-09.|

lib/AppleBackup.rb

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class AppleBackup
3838
# which will hold the results of this run. Backup +types+
3939
# are defined in this class. The child classes will immediately set the NoteStore database file, based on the +type+
4040
# of backup.
41-
def initialize(root_folder, type, output_folder)
41+
def initialize(root_folder, type, output_folder, decrypter=AppleDecrypter.new)
4242
@root_folder = root_folder
4343
@type = type
4444
@output_folder = output_folder
@@ -47,7 +47,8 @@ def initialize(root_folder, type, output_folder)
4747
@note_store_modern_location = @output_folder + "NoteStore.sqlite"
4848
@note_store_legacy_location = @output_folder + "notes.sqlite"
4949
@note_store_temporary_location = @output_folder + "test.sqlite"
50-
@decrypter = AppleDecrypter.new(self)
50+
@decrypter = decrypter
51+
@decrypter.logger = @logger
5152

5253
# Set up date ranges, if desired
5354
@range_start = 0
@@ -239,18 +240,22 @@ def back_up_file(filepath_on_phone, filename_on_phone, filepath_on_disk,
239240
# Decrypt and write a new file, or copy the file depending on if we are password protected
240241
tmp_target_filepath = file_output_directory + filename_on_phone
241242
@logger.debug("Copying #{filepath_on_disk} to #{tmp_target_filepath}")
243+
begin
244+
FileUtils.cp(filepath_on_disk, tmp_target_filepath)
245+
rescue
246+
@logger.error("Failed to copy #{filepath_on_disk} to #{tmp_target_filepath}")
247+
end
248+
249+
# Handle encrypted iTunes backups
250+
if (@type == HASHED_BACKUP_TYPE and is_encrypted?)
251+
decrypt_in_place(phone_filepath.to_s, 'files')
252+
end
253+
254+
# If the file was password protected, go ahead and decrypt it
242255
if is_password_protected
243-
File.open(filepath_on_disk, 'rb') do |file|
244-
encrypted_data = file.read
245-
decrypt_result = @decrypter.decrypt_with_password(password, salt, iterations, key, iv, tag, encrypted_data, "Apple Backup encrypted file")
246-
File.write(file_output_directory + filename_on_phone.sub(/\.encrypted$/,""), decrypt_result[:plaintext])
247-
end
248-
else
249-
begin
250-
FileUtils.cp(filepath_on_disk, tmp_target_filepath)
251-
rescue
252-
@logger.error("Failed to copy #{filepath_on_disk} to #{tmp_target_filepath}")
253-
end
256+
encrypted_data = File.read(tmp_target_filepath)
257+
decrypt_result = @decrypter.decrypt_with_password(password, salt, iterations, key, iv, tag, encrypted_data, "Apple Backup encrypted file")
258+
File.write(tmp_target_filepath.sub(/\.encrypted$/,""), decrypt_result[:plaintext]) if decrypt_result
254259
end
255260

256261
# return where we put it

lib/AppleBackupFile.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ class AppleBackupFile < AppleBackup
1313
# Creates a new AppleBackupFile. Expects a Pathname +root_folder+ that represents the root
1414
# of the backup and a Pathname +output_folder+ which will hold the results of this run.
1515
# Immediately sets the NoteStore database file.
16-
def initialize(root_folder, output_folder)
16+
def initialize(root_folder, output_folder, decrypter=AppleDecrypter.new)
1717

18-
super(root_folder, AppleBackup::SINGLE_FILE_BACKUP_TYPE, output_folder)
18+
super(root_folder, AppleBackup::SINGLE_FILE_BACKUP_TYPE, output_folder, decrypter)
1919

2020
# Check to make sure we're all good
2121
if self.valid?

lib/AppleBackupHashed.rb

Lines changed: 199 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require 'fileutils'
2+
require 'cfpropertylist'
23
require 'pathname'
34
require_relative 'AppleBackup.rb'
5+
require_relative 'AppleBackupHashedManifestPlist.rb'
46
require_relative 'AppleNote.rb'
57
require_relative 'AppleNoteStore.rb'
68

@@ -11,63 +13,127 @@ class AppleBackupHashed < AppleBackup
1113

1214
##
1315
# Creates a new AppleBackupHashed. Expects a Pathname +root_folder+ that represents the root
14-
# of the backup and a Pathname +output_folder+ which will hold the results of this run.
16+
# of the backup, a Pathname +output_folder+ which will hold the results of this run, and
17+
# an AppleDecrypter +decrypter+ to assist in decrypting files.
1518
# Immediately sets the NoteStore database file to be the appropriate hashed file.
16-
def initialize(root_folder, output_folder)
19+
def initialize(root_folder, output_folder, decrypter=AppleDecrypter.new)
1720

18-
super(root_folder, AppleBackup::HASHED_BACKUP_TYPE, output_folder)
21+
super(root_folder, AppleBackup::HASHED_BACKUP_TYPE, output_folder, decrypter)
1922

2023
@hashed_backup_manifest_database = nil
2124

2225
# Check to make sure we're all good
2326
if self.valid?
27+
2428
puts "Created a new AppleBackup from iTunes backup: #{@root_folder}"
2529

26-
# Copy the modern NoteStore to our output directory
30+
# Snag the manifest.plist file
31+
@manifest_plist = AppleBackupHashedManifestPlist.new((@root_folder + "Manifest.plist"), @decrypter, @logger)
32+
33+
if (@manifest_plist.encrypted? and !@manifest_plist.can_decrypt?)
34+
@logger.error("Manifest Plist file cannot be decrypted, likely due to a bad password.")
35+
puts "Manifest PList file cannot be decrypted, have you included any passwords?"
36+
exit
37+
end
38+
39+
# Define where the modern notes live
2740
hashed_note_store = @root_folder + "4f" + "4f98687d8ab0d6d1a371110e6b7300f6e465bef2"
2841
hashed_note_store_wal = @root_folder + "7d" + "7dc1d0fa6cd437c0ad9b9b573ea59c5e62373e92"
2942
hashed_note_store_shm = @root_folder + "55" + "55901f4cbd89916628b4ec30bf19717aca78fb2c"
3043

31-
FileUtils.cp(hashed_note_store, @note_store_modern_location)
32-
FileUtils.cp(hashed_note_store_wal, @output_folder + "NoteStore.sqlite-wal") if hashed_note_store_wal.exist?
33-
FileUtils.cp(hashed_note_store_shm, @output_folder + "NoteStore.sqlite-shm") if hashed_note_store_shm.exist?
34-
modern_note_version = AppleNoteStore.guess_ios_version(@note_store_modern_location)
44+
# Define where the Manifest file lives
45+
manifest_db = @root_folder + "Manifest.db"
46+
manifest_db_wal = @root_folder + "Manifest.db-wal"
47+
manifest_db_shm = @root_folder + "Manifest.db-shm"
3548

36-
# Copy the legacy NoteStore to our output directory
49+
# Define where the legacy notes live
3750
hashed_legacy_note_store = @root_folder + "ca" + "ca3bc056d4da0bbf88b5fb3be254f3b7147e639c"
3851
hashed_legacy_note_store_wal = @root_folder + "12" + "12be33d156731173c5ec6ea09ab02f07a98179ed"
3952
hashed_legacy_note_store_shm = @root_folder + "ef" + "efaa1bfb59fcb943689733e2ca1595db52462fb9"
4053

54+
# Copy the NoteStore.sqlite file
55+
FileUtils.cp(hashed_note_store, @note_store_modern_location)
56+
FileUtils.cp(hashed_note_store_wal, @output_folder + "NoteStore.sqlite-wal") if hashed_note_store_wal.exist?
57+
FileUtils.cp(hashed_note_store_shm, @output_folder + "NoteStore.sqlite-shm") if hashed_note_store_shm.exist?
58+
59+
# Copy the legacy NoteStore to our output directory
4160
FileUtils.cp(hashed_legacy_note_store, @note_store_legacy_location)
4261
FileUtils.cp(hashed_legacy_note_store_wal, @output_folder + "notes.sqlite-wal") if hashed_legacy_note_store_wal.exist?
4362
FileUtils.cp(hashed_legacy_note_store_shm, @output_folder + "notes.sqlite-shm") if hashed_legacy_note_store_shm.exist?
63+
64+
# Copy the Manifest.db to our output directory in case we want to look up files
65+
FileUtils.cp(manifest_db, @output_folder + "Manifest.db")
66+
FileUtils.cp(manifest_db_wal, @output_folder + "Manifest.db-wal") if manifest_db_wal.exist?
67+
FileUtils.cp(manifest_db_shm, @output_folder + "manifest.db-shm") if manifest_db_shm.exist?
68+
69+
# Check if we have to decrypt the relevant file(s)
70+
if @manifest_plist.encrypted?
71+
@logger.debug("Detected encrypted iTunes backup. Attempting to decrypt.")
72+
73+
# Snag the encrypted database into memory
74+
encrypted_data = ''
75+
File.open(@output_folder + "Manifest.db", 'rb') do |file|
76+
encrypted_data = file.read
77+
end
78+
79+
# Fetch the right AppleProtectionClass from the manifest plist
80+
protection_class = @manifest_plist.get_class_by_id(@manifest_plist.manifest_key_class)
81+
82+
# Bail out if we don't have the right key for some reason
83+
if !protection_class
84+
@logger.error("Unable to locate the appropriate protection class to decrypt the Manifest.db. Unfortunately, this is the end.")
85+
puts "Unable to decrypt the Manifest.db file, we cannot continue."
86+
exit
87+
end
88+
89+
# Unwrap the key protecting the Manifest.db file
90+
manifest_key = @decrypter.aes_key_unwrap(@manifest_plist.manifest_key, protection_class.unwrapped_key)
91+
92+
# Decrypt the database into memory
93+
decrypted_manifest = @decrypter.aes_cbc_decrypt(manifest_key, encrypted_data)
94+
95+
# Write the file out to where we expect the manifest to be
96+
File.open(@output_folder + "Manifest.db", 'wb') do |output|
97+
@logger.debug("Wrote out decrypted Manifest database to #{@output_folder + "Manifest.db"}")
98+
output.write(decrypted_manifest)
99+
end
100+
101+
@hashed_backup_manifest_database = SQLite3::Database.new((@output_folder + "Manifest.db").to_s, {results_as_hash: true})
102+
103+
# Overwrite the other critical files
104+
decrypt_in_place("NoteStore.sqlite")
105+
decrypt_in_place("notes.sqlite")
106+
else
107+
@hashed_backup_manifest_database = SQLite3::Database.new((@output_folder + "Manifest.db").to_s, {results_as_hash: true})
108+
end
109+
110+
modern_note_version = AppleNoteStore.guess_ios_version(@note_store_modern_location)
44111
legacy_note_version = AppleNoteStore.guess_ios_version(@note_store_legacy_location)
45112

46-
# Copy the Manifest.db to our output directry in case we want to look up files
47-
manifest_db = @root_folder + "Manifest.db"
48-
manifest_db_wal = @root_folder + "Manifest.db-wal"
49-
manifest_db_shm = @root_folder + "Manifest.db-shm"
50-
FileUtils.cp(manifest_db, @output_folder)
51-
FileUtils.cp(manifest_db_wal, @output_folder) if manifest_db_wal.exist?
52-
FileUtils.cp(manifest_db_shm, @output_folder) if manifest_db_shm.exist?
53113

54114
# Create the AppleNoteStore objects
55115
create_and_add_notestore(@note_store_modern_location, modern_note_version)
56116
create_and_add_notestore(@note_store_legacy_location, legacy_note_version)
57-
@hashed_backup_manifest_database = SQLite3::Database.new((@output_folder + "Manifest.db").to_s, {results_as_hash: true})
58117

59118
# Rerun the check for an Accounts folder now that the database is open
60119
@uses_account_folder = check_for_accounts_folder
61120
end
62121
end
63122

123+
##
124+
# This method is a helper to identify if this is an encrypted iTunes backup.
125+
def is_encrypted?
126+
return (@manifest_plist and @manifest_plist.encrypted?)
127+
end
128+
64129
##
65130
# This method returns true if it is a valid backup of the specified type. For a HASHED_BACKUP_TYPE,
66131
# that means it has a Manifest.db at the root level.
67132
def valid?
68133
return (@root_folder.directory? and (@root_folder + "Manifest.db").file?)
69134
end
70135

136+
71137
##
72138
# This method overrides the default check_for_accounts_folder to determine
73139
# if this backup uses an accounts folder or not. It takes no arguments and
@@ -100,4 +166,120 @@ def get_real_file_path(filename)
100166
return nil
101167
end
102168

169+
##
170+
# This method returns a binary plist object that represents the data in the Files.file column.
171+
# It expects a String +filename+ to look up. For hashed backups, that involves checking Manifest.db
172+
# to get the appropriate hash value.
173+
def get_file_plist(filename)
174+
@hashed_backup_manifest_database.execute("SELECT file FROM Files WHERE (relativePath=? AND domain='AppDomainGroup-group.com.apple.notes') OR (relativePath LIKE ? AND domain='HomeDomain')", filename) do |row|
175+
file_plist = row["file"]
176+
tmp_plist = CFPropertyList::List.new
177+
tmp_plist.load_binary_str(file_plist)
178+
return CFPropertyList.native_types(tmp_plist.value)
179+
end
180+
181+
# If we get this far, consider if it is a legacy notes file, unsure if this is the best way to go about it
182+
filename = "Library/Notes/#{filename}"
183+
@hashed_backup_manifest_database.execute("SELECT file FROM Files WHERE relativePath=? AND domain='HomeDomain'", filename) do |row|
184+
file_plist = row["file"]
185+
tmp_plist = CFPropertyList::List.new
186+
tmp_plist.load_binary_str(file_plist)
187+
return CFPropertyList.native_types(tmp_plist.value)
188+
end
189+
190+
# If we get here, we just need to give up
191+
return nil
192+
end
193+
194+
##
195+
# This method fetches the encryption key for a file from the file's plist in Manifest.db.
196+
# It expects a CFProperty +plist+ and returns the wrapped encryption key as a binary string.
197+
def get_file_encryption_key(plist)
198+
# Find the root object ID
199+
tmp_root = plist["$top"]["root"]
200+
201+
# Use the root object ID to find the Encryption Key index
202+
tmp_key_position = plist["$objects"][tmp_root]["EncryptionKey"]
203+
204+
# Get the data for the encryption key, this has the protection class at the start
205+
tmp_wrapped_key = plist["$objects"][tmp_key_position]["NS.data"]
206+
207+
# Return the key itself
208+
return tmp_wrapped_key[4,tmp_wrapped_key.length - 4]
209+
end
210+
211+
##
212+
# This method pulls the relevant protection class from the Manifest.db file plist
213+
# for an encrypted file. It expects a CFPropertyList +plist+ and returns the protection
214+
# class as an Integer.
215+
def get_file_protection_class(plist)
216+
# Find the root object ID
217+
tmp_root = plist["$top"]["root"]
218+
219+
# Use the root object ID to find the Encryption Key index
220+
tmp_key_position = plist["$objects"][tmp_root]["EncryptionKey"]
221+
222+
# Get the data for the encryption key, this has the protection class at the start
223+
tmp_wrapped_key = plist["$objects"][tmp_key_position]["NS.data"]
224+
225+
# Return the key itself
226+
return tmp_wrapped_key[0,4].reverse.unpack("N")[0]
227+
end
228+
229+
##
230+
# This method pulls the expected file size from the Manifest.db file plist
231+
# for an encrypted file. It expects a CFPropertyList +plist+ and returns the file size
232+
# as an Integer.
233+
def get_file_expected_size(plist)
234+
# Find the root object ID
235+
tmp_root = plist["$top"]["root"]
236+
237+
# Use the root object ID to find the Encryption Key index
238+
return plist["$objects"][tmp_root]["Size"]
239+
end
240+
241+
##
242+
# This method is used to decrypt an iTunes encrypted backup file in place.
243+
# It expects the file to already have been copied to the output folder and to receive the
244+
# +filename+ as a String. It checks the filename in Manifest.db, looks up the corresponding
245+
# encryption key, and uses that to decrypt the file contents, overwriting was was in output.
246+
def decrypt_in_place(filename, folder='')
247+
@logger.debug("Attempting to decrypt in place #{filename}")
248+
249+
target_destination = @output_folder + folder + filename
250+
return if !target_destination.exist?
251+
252+
# Snag the File Plist for this file from the Manifest.db file
253+
tmp_plist = get_file_plist(filename)
254+
if !tmp_plist
255+
@logger.error("Unable to find the file plist for #{filename}.")
256+
return
257+
end
258+
259+
tmp_wrapped_key = get_file_encryption_key(tmp_plist)
260+
tmp_class = get_file_protection_class(tmp_plist)
261+
tmp_size = get_file_expected_size(tmp_plist)
262+
263+
# Fetch the protection class from the manifest Plist to get its unwrapped key
264+
tmp_protection_class = @manifest_plist.get_class_by_id(tmp_class)
265+
tmp_unwrapped_key = @decrypter.aes_key_unwrap(tmp_wrapped_key, tmp_protection_class.unwrapped_key)
266+
267+
# Actually decrypt the file itself
268+
decrypted_file = @decrypter.aes_cbc_decrypt(tmp_unwrapped_key, File.read(@output_folder + folder + filename))
269+
270+
if !decrypted_file
271+
@logger.error("Failed to decrypt #{target_destination}")
272+
return
273+
end
274+
275+
# Overwrite the results
276+
File.open(target_destination, 'wb') do |output|
277+
@logger.debug("Wrote out decrypted #{filename} to #{target_destination}")
278+
# Only write the first tmp_size bytes, don't write the padding that decrypting introduced.
279+
# This ensures encrypted files can be decrypted, as they were encrypted prior to the padding
280+
# being introduced.
281+
output.write(decrypted_file[0, tmp_size])
282+
end
283+
end
284+
103285
end

0 commit comments

Comments
 (0)