16 | IMAGE_NAME: ${{ github.repository }}
17 |
18 |
19 | jobs:
20 | build:
21 |
22 | runs-on: ubuntu-latest
23 | permissions:
24 | contents: read
25 | packages: write
26 | # This is used to complete the identity challenge
27 | # with sigstore/fulcio when running outside of PRs.
28 | id-token: write
29 |
30 | steps:
31 | - name: Checkout repository
32 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
33 |
34 | - name: Set up QEMU
35 | uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3
36 |
37 | # Install the cosign tool except on PR
38 | # https://github.com/sigstore/cosign-installer
39 | - name: Install cosign
40 | if: github.event_name != 'pull_request'
41 | uses: sigstore/cosign-installer@v3.6.0
42 | with:
43 | cosign-release: 'v2.4.0'
44 |
45 |
46 | # Workaround: https://github.com/docker/build-push-action/issues/461
47 | - name: Setup Docker buildx
48 | uses: docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20
49 |
50 | # Login against a Docker registry except on PR
51 | # https://github.com/docker/login-action
52 | - name: Log into registry ${{ env.REGISTRY }}
53 | if: github.event_name != 'pull_request'
54 | uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20
55 | with:
56 | registry: ${{ env.REGISTRY }}
57 | username: ${{ github.actor }}
58 | password: ${{ secrets.GITHUB_TOKEN }}
59 |
60 | # Extract metadata (tags, labels) for Docker
61 | # https://github.com/docker/metadata-action
62 | - name: Extract Docker metadata
63 | id: meta
64 | uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81
65 | with:
66 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
67 |
68 | # Build and push Docker image with Buildx (don't push on PR)
69 | # https://github.com/docker/build-push-action
70 | - name: Build and push Docker image
71 | id: build-and-push
72 | uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0
73 | with:
74 | context: .
75 | push: ${{ github.event_name != 'pull_request' }}
76 | platforms: linux/amd64,linux/arm64
77 | tags: ${{ steps.meta.outputs.tags }}
78 | labels: ${{ steps.meta.outputs.labels }}
79 | cache-from: type=gha
80 | cache-to: type=gha,mode=max
81 |
82 | # Sign the resulting Docker image digest except on PRs.
83 | # This will only write to the public Rekor transparency log when the Docker
84 | # repository is public to avoid leaking data. If you would like to publish
85 | # transparency data even for private images, pass --force to cosign below.
86 | # https://github.com/sigstore/cosign
87 | - name: Sign the published Docker image
88 | if: ${{ github.event_name != 'pull_request' }}
89 | env:
90 | COSIGN_EXPERIMENTAL: "true"
91 | # This step uses the identity token to provision an ephemeral certificate
92 | # against the sigstore community Fulcio instance.
93 | run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push.outputs.digest }}
94 |
95 |
--------------------------------------------------------------------------------
/Install.md:
--------------------------------------------------------------------------------
1 | # OS-specific Installation Instructions
2 |
3 | ## Debian-based Linux (Debian, Ubuntu, Mint, etc)
4 |
5 | ### With Git (If you want to stay up to date)
6 |
7 | ```bash
8 | sudo apt-get install build-essential libsqlite3-dev zlib1g-dev git ruby-full ruby-bundler
9 | git clone https://github.com/threeplanetssoftware/apple_cloud_notes_parser.git
10 | cd apple_cloud_notes_parser
11 | bundle install
12 | ```
13 |
14 | ### Without Git (If you want to download it every now and then)
15 |
16 | ```bash
17 | sudo apt-get install build-essential libsqlite3-dev zlib1g-dev git ruby-full ruby-bundler
18 | curl https://codeload.github.com/threeplanetssoftware/apple_cloud_notes_parser/zip/master -o apple_cloud_notes_parser.zip
19 | unzip apple_cloud_notes_parser.zip
20 | cd apple_cloud_notes_parser-master
21 | bundle install
22 | ```
23 | ## Red Hat-based Linux (Red Hat, CentOS, etc)
24 |
25 | ### With Git (If you want to stay up to date)
26 |
27 | ```bash
28 | sudo yum groupinstall "Development Tools"
29 | sudo yum install sqlite sqlite-devel zlib zlib-devel openssl openssl-devel ruby ruby-devel rubygem-bundler
30 | git clone https://github.com/threeplanetssoftware/apple_cloud_notes_parser.git
31 | cd apple_cloud_notes_parser
32 | bundle install
33 | sudo gem pristine sqlite3 zlib openssl aes_key_wrap keyed_archive
34 | ```
35 |
36 | ### Without Git (If you want to download it every now and then)
37 |
38 | ```bash
39 | sudo yum groupinstall "Development Tools"
40 | sudo yum install sqlite sqlite-devel zlib zlib-devel openssl openssl-devel ruby ruby-devel rubygem-bundler
41 | curl https://codeload.github.com/threeplanetssoftware/apple_cloud_notes_parser/zip/master -o apple_cloud_notes_parser.zip
42 | unzip apple_cloud_notes_parser.zip
43 | cd apple_cloud_notes_parser-master
44 | bundle install
45 | sudo gem pristine sqlite3 zlib openssl aes_key_wrap keyed_archive
46 | ```
47 |
48 | ## MacOS
49 |
50 | ### With Git (If you want to stay up to date)
51 |
52 | ```bash
53 | git clone https://github.com/threeplanetssoftware/apple_cloud_notes_parser.git
54 | cd apple_cloud_notes_parser
55 | bundle install
56 | ```
57 |
58 | ### Without Git (If you want to download it every now and then)
59 |
60 | ```bash
61 | curl https://codeload.github.com/threeplanetssoftware/apple_cloud_notes_parser/zip/master -o apple_cloud_notes_parser.zip
62 | unzip apple_cloud_notes_parser.zip
63 | cd apple_cloud_notes_parser-master
64 | bundle install
65 | ```
66 |
67 | ## Windows
68 |
69 | 1. Download the 2.7.2 64-bit [RubyInstaller with DevKit](https://github.com/oneclick/rubyinstaller2/releases/download/RubyInstaller-3.2.5-1/rubyinstaller-3.2.5-1-x64.exe)
70 | 2. Run RubyInstaller using default settings.
71 | 3. Download the latest [SQLite amalgamation souce code](https://sqlite.org/2024/sqlite-amalgamation-3460000.zip) and [64-bit SQLite Precompiled DLL](https://sqlite.org/2024/sqlite-dll-win-x64-3460000.zip)
72 | 4. Install SQLite
73 | 1. Create a folder C:\sqlite
74 | 2. Unzip the source code into C:\sqlite (you should now have C:\sqlite\sqlite3.c and C:\sqlite\sqlite.h, among others)
75 | 3. Unzip the DLL into C:\sqlite (you should now have C:\sqlite\sqlite3.dll, among others)
76 | 5. Download [this Apple Cloud Notes Parser as a zip archive](https://github.com/threeplanetssoftware/apple_cloud_notes_parser/archive/master.zip)
77 | 6. Unzip the Zip archive
78 | 7. Launch a command prompt window with "Start a command prompt wqith ruby" from the Start menu and navigate to where you unzipped the archive
79 | 9. Execute the following commands (these set the PATH so SQLite files can be found install SQLite's Gem specifically pointing to them, and then installs the rest of the gems):
80 |
81 | ```powershell
82 | powershell
83 | $env:Path += ";C:\sqlite"
84 | [Environment]::SetEnvironmentVariable("Path", $env:Path + ";C:\sqlite", "User")
85 | gem install sqlite3 --platform=ruby -- --with-sqlite-3-dir=C:/sqlite --with-sqlite-3-include=C:/sqlite
86 | bundle install
87 | ```
88 |
89 |
--------------------------------------------------------------------------------
/spec/base_classes/apple_cloud_kit_record.rb:
--------------------------------------------------------------------------------
1 | require_relative '../../lib/AppleCloudKitRecord.rb'
2 |
3 | describe AppleCloudKitRecord do
4 |
5 | let(:cloud_kit_record) { AppleCloudKitRecord.new }
6 | let(:empty_cloud_kit_record) { AppleCloudKitRecord.new }
7 |
8 | context "server record data" do
9 | it "identifies the last modified information" do
10 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERRECORDDATA.bin")
11 | cloud_kit_record.add_cloudkit_server_record_data(binary_plist)
12 | expect(cloud_kit_record.cloudkit_last_modified_device).to eql("Tester’s iPhone")
13 | expect(cloud_kit_record.instance_variable_get(:@cloudkit_modifier_record_id)).to eql("__defaultOwner__")
14 | end
15 |
16 | it "identifies the creator's information" do
17 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERRECORDDATA.bin")
18 | cloud_kit_record.add_cloudkit_server_record_data(binary_plist)
19 | expect(cloud_kit_record.instance_variable_get(:@cloudkit_creator_record_id)).to eql("__defaultOwner__")
20 | end
21 | end
22 |
23 | context "cloudkit sharing data" do
24 | it "reads participants from ZSERVERSHAREDATA" do
25 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERSHAREDATA.bin")
26 | expect(cloud_kit_record.add_cloudkit_sharing_data(binary_plist)).to be 2
27 | expect(cloud_kit_record.cloud_kit_record_known?("__defaultOwner__")).to be_kind_of(AppleCloudKitShareParticipant)
28 | end
29 |
30 | it "parses user contact information" do
31 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERSHAREDATA.bin")
32 | cloud_kit_record.add_cloudkit_sharing_data(binary_plist)
33 | expect(cloud_kit_record.share_participants[0].email).to eql("fake_email2@fake_domain.fake")
34 | expect(cloud_kit_record.share_participants[1].email).to eql("fake_email@fake_domain.fake")
35 | end
36 |
37 | it "parses user personal information" do
38 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERSHAREDATA.bin")
39 | cloud_kit_record.add_cloudkit_sharing_data(binary_plist)
40 | expect(cloud_kit_record.share_participants[0].first_name).to eql("Mr")
41 | expect(cloud_kit_record.share_participants[0].last_name).to eql("Tester")
42 | expect(cloud_kit_record.share_participants[1].first_name).to eql("F")
43 | expect(cloud_kit_record.share_participants[1].last_name).to eql("P")
44 | end
45 |
46 | it "parses user record ids" do
47 | binary_plist = File.read(TEST_BLOB_DATA_DIR + "ZSERVERSHAREDATA.bin")
48 | cloud_kit_record.add_cloudkit_sharing_data(binary_plist)
49 | expect(cloud_kit_record.share_participants[0].record_id).to eql("__defaultOwner__")
50 | expect(cloud_kit_record.share_participants[1].record_id).to eql("_dfe6a1b5e8bc40359c323b0357e3f04d")
51 | expect(cloud_kit_record.cloud_kit_record_known?("__defaultOwner__")).to be_kind_of(AppleCloudKitShareParticipant)
52 | end
53 | end
54 |
55 | context "helper functions" do
56 | before(:each) do
57 | @tmp_participant = AppleCloudKitShareParticipant.new
58 | @tmp_participant.record_id = "9c21782a-ec9e-424a-b8e4-6ab473b84cdb"
59 | cloud_kit_record.share_participants.push(@tmp_participant)
60 | end
61 |
62 | it "returns false if the Array is nil" do
63 | expect(empty_cloud_kit_record.cloud_kit_record_known?("9c21782a-ec9e-424a-b8e4-6ab473b84cdb")).to be false
64 | end
65 |
66 | it "returns false from an empty Array" do
67 | expect(empty_cloud_kit_record.cloud_kit_record_known?("9c21782a-ec9e-424a-b8e4-6ab473b84cdb")).to be false
68 | end
69 |
70 | it "returns flase if the id is nil" do
71 | expect(empty_cloud_kit_record.cloud_kit_record_known?(nil)).to be false
72 | end
73 |
74 | it "returns true if the given id is a hash key" do
75 | expect(cloud_kit_record.cloud_kit_record_known?("9c21782a-ec9e-424a-b8e4-6ab473b84cdb")).to be @tmp_participant
76 | end
77 | end
78 |
79 | end
80 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedInlineAttachment.rb:
--------------------------------------------------------------------------------
1 | require 'keyed_archive'
2 | require 'sqlite3'
3 | require_relative 'AppleCloudKitRecord'
4 |
5 | ##
6 | # This class represents an inline text enhancement embedded in an AppleNote.
7 | # These were added in iOS 15 and represent things like hashtags and @ mentions.
8 | class AppleNotesEmbeddedInlineAttachment < AppleCloudKitRecord
9 |
10 | attr_accessor :primary_key,
11 | :uuid,
12 | :type,
13 | :parent,
14 | :conforms_to
15 |
16 | ##
17 | # Creates a new AppleNotesEmbeddedInlineAttachment.
18 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
19 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote,
20 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from
21 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the text stands for.
22 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier)
23 | # Set this object's variables
24 | @primary_key = primary_key
25 | @uuid = uuid
26 | @type = uti
27 | @conforms_to = uti
28 | @note = note
29 | @alt_text = alt_text
30 | @token_identifier = token_identifier
31 | @backup = @note.backup
32 | @database = @note.database
33 | @logger = @backup.logger
34 |
35 | @logger.debug("Note #{@note.note_id}: Created a new Embedded Inline Attachment of type #{@type}")
36 | end
37 |
38 | ##
39 | # This method just returns a readable String for the object.
40 | # By default it just lists the +alt_text+. Subclasses
41 | # should override this.
42 | def to_s
43 | @alt_text
44 | end
45 |
46 | ##
47 | # Class method to return an Array of the headers used on CSVs for this class
48 | def self.to_csv_headers
49 | ["Object Primary Key",
50 | "Note ID",
51 | "Parent Object ID",
52 | "Object UUID",
53 | "Object Type",
54 | "Object Filename",
55 | "Object Filepath on Phone",
56 | "Object Filepath on Computer",
57 | "Object User Title",
58 | "Object Alt Text",
59 | "Object Token Identifier"]
60 | end
61 |
62 | ##
63 | # This method returns an Array of the fields used in CSVs for this class
64 | # Currently spits out the +primary_key+, AppleNote +note_id+, AppleNotesEmbeddedObject parent +primary_key+,
65 | # +uuid+, +type+, +filepath+, +filename+, and +backup_location+ on the computer. Also computes these for
66 | # any children and thumbnails.
67 | def to_csv
68 | to_return =[[@primary_key,
69 | @note.note_id,
70 | "", # Placeholder for parent ID
71 | @uuid,
72 | @type,
73 | "", # Placeholder for filename
74 | "", # Placeholder for filepath on phone
75 | "", # Placeholder for filepath on computer
76 | "", # Placeholder for user title
77 | @alt_text,
78 | @token_identifier]]
79 |
80 | return to_return
81 | end
82 |
83 | ##
84 | # This method generates the HTML to be embedded into an AppleNote's HTML.
85 | def generate_html(individual_files=false)
86 | return self.to_s
87 | end
88 |
89 | ##
90 | # This method prepares the data structure that JSON will use to generate JSON later.
91 | def prepare_json
92 | to_return = Hash.new()
93 | to_return[:primary_key] = @primary_key
94 | to_return[:note_id] = @note.note_id
95 | to_return[:uuid] = @uuid
96 | to_return[:type] = @type
97 | to_return[:conforms_to] = @conforms_to
98 | to_return[:alt_text] = @alt_text
99 | to_return[:token_identifier] = @token_identifier
100 | to_return[:html] = generate_html
101 |
102 | to_return
103 | end
104 |
105 | end
106 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedPublicVideo.rb:
--------------------------------------------------------------------------------
1 | require_relative 'AppleNotesEmbeddedThumbnail.rb'
2 |
3 | ##
4 | # This class represents a public.video object embedded
5 | # in an AppleNote.
6 | class AppleNotesEmbeddedPublicVideo < AppleNotesEmbeddedObject
7 |
8 | attr_accessor :reference_location
9 |
10 | ##
11 | # Creates a new AppleNotesEmbeddedPublicVideo object.
12 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
13 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, AppleNote +note+ object representing the parent AppleNote,
14 | # AppleBackup +backup+ from the parent AppleNote, and AppleEmbeddedObject +parent+ (or nil).
15 | # Immediately sets the +filename+ and +filepath+ to point to were the media is stored.
16 | # Finally, it attempts to copy the file to the output folder.
17 | def initialize(primary_key, uuid, uti, note, backup, parent)
18 | # Set this object's variables
19 | @parent = parent # Do this first so thumbnails don't break
20 |
21 | super(primary_key, uuid, uti, note)
22 | @filename = get_media_filename
23 | @filepath = get_media_filepath
24 |
25 | add_possible_location(@filepath)
26 |
27 | # Find where on this computer that file is stored
28 | tmp_stored_file_result = find_valid_file_path
29 |
30 | if tmp_stored_file_result
31 | @backup_location = tmp_stored_file_result.backup_location
32 | @filepath = tmp_stored_file_result.filepath
33 | @filename = tmp_stored_file_result.filename
34 |
35 | # Copy the file to our output directory if we can
36 | @reference_location = @backup.back_up_file(@filepath,
37 | @filename,
38 | @backup_location,
39 | @is_password_protected,
40 | @crypto_password,
41 | @crypto_salt,
42 | @crypto_iterations,
43 | @crypto_key,
44 | @crypto_asset_iv,
45 | @crypto_asset_tag)
46 | end
47 | end
48 |
49 | ##
50 | # This method just returns a readable String for the object.
51 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
52 | def to_s
53 | to_s_with_data("video")
54 | end
55 |
56 | ##
57 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
58 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
59 | # and reading the ZICCOUDSYNCINGOBJECT.ZIDENTIFIER of the row identified by that number
60 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
61 | def get_media_uuid
62 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZMEDIA " +
63 | "FROM ZICCLOUDSYNCINGOBJECT " +
64 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
65 | @uuid) do |row|
66 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER " +
67 | "FROM ZICCLOUDSYNCINGOBJECT " +
68 | "WHERE ZICCLOUDSYNCINGOBJECT.Z_PK=?",
69 | row["ZMEDIA"]) do |media_row|
70 | return media_row["ZIDENTIFIER"]
71 | end
72 | end
73 | end
74 |
75 | ##
76 | # This method returns the +filepath+ of this object.
77 | # This is computed based on the assumed default storage location.
78 | def get_media_filepath
79 | get_media_filepath_with_uuid_and_filename
80 | end
81 |
82 | ##
83 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
84 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
85 | # and reading the ZICCOUDSYNCINGOBJECT.ZFILENAME of the row identified by that number
86 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
87 | def get_media_filename
88 | get_media_filename_from_zfilename
89 | end
90 |
91 | ##
92 | # This method generates the HTML necessary to display the image inline.
93 | def generate_html(individual_files=false)
94 | generate_html_with_images(individual_files)
95 | end
96 |
97 | end
98 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedPublicAudio.rb:
--------------------------------------------------------------------------------
1 | require_relative 'AppleNotesEmbeddedThumbnail.rb'
2 |
3 | ##
4 | # This class represents a public.audio object embedded
5 | # in an AppleNote. Todo: Add the ZDURATION column to the output.
6 | class AppleNotesEmbeddedPublicAudio < AppleNotesEmbeddedObject
7 |
8 | attr_accessor :reference_location
9 |
10 | ##
11 | # Creates a new AppleNotesEmbeddedPublicAudio object.
12 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
13 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, AppleNote +note+ object representing the parent AppleNote,
14 | # AppleBackup +backup+ from the parent AppleNote, and AppleEmbeddedObject +parent+ (or nil).
15 | # Immediately sets the +filename+ and +filepath+ to point to were the media is stored.
16 | # Finally, it attempts to copy the file to the output folder.
17 | def initialize(primary_key, uuid, uti, note, backup, parent)
18 | # Set this object's variables
19 | @parent = parent # Do this first so thumbnails don't break
20 |
21 | super(primary_key, uuid, uti, note)
22 | @filename = get_media_filename
23 | @filepath = get_media_filepath
24 |
25 | add_possible_location(@filepath)
26 |
27 | # Find where on this computer that file is stored
28 | tmp_stored_file_result = find_valid_file_path
29 |
30 | if tmp_stored_file_result
31 | @backup_location = tmp_stored_file_result.backup_location
32 | @filepath = tmp_stored_file_result.filepath
33 | @filename = tmp_stored_file_result.filename
34 |
35 | # Copy the file to our output directory if we can
36 | @reference_location = @backup.back_up_file(@filepath,
37 | @filename,
38 | @backup_location,
39 | @is_password_protected,
40 | @crypto_password,
41 | @crypto_salt,
42 | @crypto_iterations,
43 | @crypto_key,
44 | @crypto_asset_iv,
45 | @crypto_asset_tag)
46 | end
47 | end
48 |
49 | ##
50 | # This method just returns a readable String for the object.
51 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
52 | def to_s
53 | to_s_with_data("audio")
54 | end
55 |
56 | ##
57 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
58 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
59 | # and reading the ZICCOUDSYNCINGOBJECT.ZIDENTIFIER of the row identified by that number
60 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
61 | def get_media_uuid
62 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZMEDIA " +
63 | "FROM ZICCLOUDSYNCINGOBJECT " +
64 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
65 | @uuid) do |row|
66 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER " +
67 | "FROM ZICCLOUDSYNCINGOBJECT " +
68 | "WHERE ZICCLOUDSYNCINGOBJECT.Z_PK=?",
69 | row["ZMEDIA"]) do |media_row|
70 | return media_row["ZIDENTIFIER"]
71 | end
72 | end
73 | end
74 |
75 | ##
76 | # This method returns the +filepath+ of this object.
77 | # This is computed based on the assumed default storage location.
78 | def get_media_filepath
79 | get_media_filepath_with_uuid_and_filename
80 | end
81 |
82 | ##
83 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
84 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
85 | # and reading the ZICCOUDSYNCINGOBJECT.ZFILENAME of the row identified by that number
86 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
87 | def get_media_filename
88 | get_media_filename_from_zfilename
89 | end
90 |
91 | ##
92 | # This method generates the HTML necessary to display the image inline.
93 | def generate_html(individual_files=false)
94 | generate_html_with_link("Audio", individual_files)
95 | end
96 |
97 | end
98 |
--------------------------------------------------------------------------------
/lib/AppleBackupPhysical.rb:
--------------------------------------------------------------------------------
1 | require 'fileutils'
2 | require 'pathname'
3 | require_relative 'AppleBackup.rb'
4 | require_relative 'AppleNote.rb'
5 | require_relative 'AppleNoteStore.rb'
6 |
7 | ##
8 | # This class represents an Apple physical backup.
9 | # This class will abstract away figuring out how to get the right media files to embed back into an AppleNote.
10 | class AppleBackupPhysical < AppleBackup
11 |
12 | ##
13 | # Creates a new AppleBackupPhysical. Expects a Pathname +root_folder+ that represents the root
14 | # of the physical backup and a Pathname +output_folder+ which will hold the results of this run.
15 | # Immediately sets the NoteStore database file to be the appropriate application's NoteStore.sqlite.
16 | def initialize(root_folder, output_folder, decrypter=AppleDecrypter.new)
17 |
18 | super(root_folder, AppleBackup::PHYSICAL_BACKUP_TYPE, output_folder, decrypter)
19 |
20 | @physical_backup_app_folder = nil
21 | @physical_backup_app_uuid = find_physical_backup_app_uuid
22 |
23 | # Check to make sure we're all good
24 | if self.valid?
25 | puts "Created a new AppleBackup from physical backup: #{@root_folder}"
26 |
27 | # Set the app's folder for ease of reference later
28 | @physical_backup_app_folder = (@root_folder + "private" + "var" + "mobile" + "Containers" + "Shared" + "AppGroup" + @physical_backup_app_uuid)
29 |
30 | # Copy the modern NoteStore to our output directory
31 | copy_notes_database(@physical_backup_app_folder + "NoteStore.sqlite", @note_store_modern_location)
32 | modern_note_version = AppleNoteStore.guess_ios_version(@note_store_modern_location)
33 |
34 | # Copy the legacy notes.sqlite to our output directory
35 | copy_notes_database(@root_folder + "private" + "var" + "mobile" + "Library" + "Notes" + "notes.sqlite", @note_store_legacy_location)
36 | legacy_note_version = AppleNoteStore.guess_ios_version(@note_store_legacy_location)
37 |
38 | # Create the AppleNoteStore objects
39 | create_and_add_notestore(@note_store_modern_location, modern_note_version)
40 | create_and_add_notestore(@note_store_legacy_location, legacy_note_version)
41 |
42 | # Call this a second time, now that we know we are valid and have the right file path
43 | @uses_account_folder = check_for_accounts_folder
44 | end
45 | end
46 |
47 | ##
48 | # This method returns true if it is a value backup of the specified type. For PHYSICAL_BACKUP_TYPE this means
49 | # that the +root_folder+ given is where the root of the directory structure is, i.e one step above private.
50 | def valid?
51 | return (@physical_backup_app_uuid != nil)
52 | end
53 |
54 | ##
55 | # This method iterates through the app UUIDs of a physical backup to
56 | # identify which one contains Notes. It does it this way to ensure that all
57 | # files were correctly pulled. It returns the String representing the UUID or
58 | # nil if not appropriate.
59 | def find_physical_backup_app_uuid
60 |
61 | # Bail out if this doesn't look obviously right
62 | return nil if (!@root_folder or !@root_folder.directory? or !(@root_folder + "private" + "var" + "mobile" + "Containers" + "Shared" + "AppGroup").directory?)
63 |
64 | # Create a variable to return
65 | app_uuid = nil
66 |
67 | # Create a variable for simplicity
68 | app_folder = @root_folder + "private" + "var" + "mobile" + "Containers" + "Shared" + "AppGroup"
69 |
70 | # Loop over each child entry to check them for what we want
71 | app_folder.children.each do |child_entry|
72 | if child_entry.directory? and (child_entry + "NoteStore.sqlite").exist?
73 | app_uuid = child_entry.basename
74 | end
75 | end
76 |
77 | return app_uuid
78 | end
79 |
80 | ##
81 | # This method overrides the default check_for_accounts_folder to determine
82 | # if this backup uses an accounts folder or not. It takes no arguments and
83 | # returns true if an accounts folder is used and false if not.
84 | def check_for_accounts_folder
85 | return true if !@physical_backup_app_folder
86 |
87 | accounts_folder = @physical_backup_app_folder + "Accounts"
88 | return accounts_folder.exist?
89 | end
90 |
91 | ##
92 | # This method returns a Pathname that represents the location on this disk of the requested file or nil.
93 | # It expects a String +filename+ to look up.
94 | def get_real_file_path(filename)
95 | return @physical_backup_app_folder + filename
96 | end
97 |
98 | end
99 |
--------------------------------------------------------------------------------
/lib/AppleCloudKitRecord.rb:
--------------------------------------------------------------------------------
1 | require 'keyed_archive'
2 | require_relative 'AppleCloudKitShareParticipant'
3 |
4 | ##
5 | # This class represents a generic CloudKit Record.
6 | class AppleCloudKitRecord
7 |
8 | attr_accessor :share_participants,
9 | :server_record_data,
10 | :cloudkit_last_modified_device
11 |
12 | ##
13 | # Creates a new AppleCloudKitRecord.
14 | # Requires nothing and initializes the share_participants.
15 | def initialize()
16 | # Tracks the AppleCloudKitParticipants this is shared with
17 | @share_participants = Array.new()
18 | @server_record_data = nil
19 | @cloudkit_last_modified_device = nil
20 | @cloudkit_creator_record_id = nil
21 | @cloudkit_modifier_record_id = nil
22 | end
23 |
24 | ##
25 | # This method adds CloudKit Share data to an AppleCloudKitRecord. It requires
26 | # a binary String +cloudkit_data+ from the ZSERVERSHAREDATA column.
27 | def add_cloudkit_sharing_data(cloudkit_data)
28 | keyed_archive = KeyedArchive.new(:data => cloudkit_data)
29 | unpacked_top = keyed_archive.unpacked_top()
30 | total_added = 0
31 | if unpacked_top
32 | unpacked_top["Participants"]["NS.objects"].each do |participant|
33 |
34 | # Pull out the relevant values
35 | if participant["UserIdentity"]
36 | participant_user_identity = participant["UserIdentity"]
37 |
38 | # Initialize a new AppleCloudKitShareParticipant
39 | tmp_participant = AppleCloudKitShareParticipant.new()
40 |
41 | # Read in the user's contact information
42 | if participant_user_identity["LookupInfo"]
43 | tmp_participant.email = participant_user_identity["LookupInfo"]["EmailAddress"]
44 | tmp_participant.phone = participant_user_identity["LookupInfo"]["PhoneNumber"]
45 | end
46 |
47 | # Read in user's record id
48 | if participant_user_identity["UserRecordID"]
49 | tmp_participant.record_id = participant_user_identity["UserRecordID"]["RecordName"]
50 | end
51 |
52 | # Read in name components
53 | if participant_user_identity["NameComponents"]
54 | participant_name_components = participant["UserIdentity"]["NameComponents"]["NS.nameComponentsPrivate"]
55 |
56 | # Split the name up into its components
57 | tmp_participant.name_prefix = participant_name_components["NS.namePrefix"]
58 | tmp_participant.first_name = participant_name_components["NS.givenName"]
59 | tmp_participant.middle_name = participant_name_components["NS.middleName"]
60 | tmp_participant.last_name = participant_name_components["NS.familyName"]
61 | tmp_participant.name_suffix = participant_name_components["NS.nameSuffix"]
62 | tmp_participant.nickname = participant_name_components["NS.nickname"]
63 | tmp_participant.name_phonetic = participant_name_components["NS.phoneticRepresentation"]
64 | end
65 |
66 | # Add them to this object
67 | @share_participants.push(tmp_participant)
68 | total_added += 1
69 | end
70 | end
71 | end
72 | total_added
73 | end
74 |
75 | ##
76 | # This method takes a the binary String +server_record_data+ which is stored
77 | # in ZSERVERRECORDDATA. Currently just pulls out the last modified device.
78 | def add_cloudkit_server_record_data(server_record_data)
79 | @server_record_data = server_record_data
80 |
81 | keyed_archive = KeyedArchive.new(:data => server_record_data)
82 | unpacked_top = keyed_archive.unpacked_top()
83 | if unpacked_top
84 | @cloudkit_last_modified_device = unpacked_top["ModifiedByDevice"]
85 | @cloudkit_creator_record_id = unpacked_top["CreatorUserRecordID"]["RecordName"]
86 | @cloudkit_modifier_record_id = unpacked_top["LastModifiedUserRecordID"]["RecordName"]
87 |
88 | # Sometimes folders don't have their parent reflected in the ZPARENT column and instead
89 | # are reflected in this field. Let's set the parent_uuid field and let AppleNoteStore
90 | # play cleanup later.
91 | if unpacked_top["RecordType"] == "Folder" and unpacked_top["ParentReference"]
92 | @parent_uuid = unpacked_top["ParentReference"]["recordID"]["RecordName"]
93 | end
94 | end
95 | end
96 |
97 | ##
98 | # This method takes a String +record_id+ to determine if the particular cloudkit
99 | # record is known. It returns an AppleCloudKitParticipant object, or False.
100 | def cloud_kit_record_known?(record_id)
101 | @share_participants.each do |participant|
102 | return participant if participant.record_id.eql?(record_id)
103 | end
104 | return false
105 | end
106 |
107 |
108 |
109 | end
110 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedPublicURL.rb:
--------------------------------------------------------------------------------
1 | require 'json'
2 | require 'keyed_archive'
3 |
4 | ##
5 | # This class represents a public.url object embedded
6 | # in an AppleNote. This means you used another application to 'share' something that
7 | # resolved to a URL, such as a website in Safari, or a place in Maps.
8 | class AppleNotesEmbeddedPublicURL < AppleNotesEmbeddedObject
9 |
10 | attr_accessor :primary_key,
11 | :uuid,
12 | :type,
13 | :url
14 |
15 | ##
16 | # Creates a new AppleNotesEmbeddedURL object.
17 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
18 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, and an AppleNote +note+ object representing the parent AppleNote.
19 | # Immediately sets the URL variable to where this points at.
20 | def initialize(primary_key, uuid, uti, note)
21 | # Set this folder's variables
22 | super(primary_key, uuid, uti, note)
23 |
24 | @url = get_referenced_url
25 | end
26 |
27 | ##
28 | # This method just returns a readable String for the object.
29 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
30 | def to_s
31 | return super + " pointing to #{@url}"
32 | end
33 |
34 | ##
35 | # Uses database calls to fetch the object's ZICCLOUDSYNCINGOBJECT.ZURLSTRING +url+.
36 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER field on the entry with this object's +uuid+
37 | # and reading the ZICCOUDSYNCINGOBJECT.ZURLSTRING of the row identified by that number.
38 | def get_referenced_url
39 | referenced_url = nil
40 |
41 |
42 | # If this URL is password protected, fetch the URL from the
43 | # ZICCLOUDSYNCINGOBJECT.ZENCRYPTEDVALUESJSON column and decrypt it.
44 | if @is_password_protected
45 | unapplied_encrypted_record_column = "ZUNAPPLIEDENCRYPTEDRECORD"
46 | unapplied_encrypted_record_column = unapplied_encrypted_record_column + "DATA" if @version >= AppleNoteStoreVersion::IOS_VERSION_18
47 |
48 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZENCRYPTEDVALUESJSON, ZICCLOUDSYNCINGOBJECT.#{unapplied_encrypted_record_column} " +
49 | "FROM ZICCLOUDSYNCINGOBJECT " +
50 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
51 | @uuid) do |row|
52 |
53 | encrypted_values = row["ZENCRYPTEDVALUESJSON"]
54 |
55 | if row[unapplied_encrypted_record_column]
56 | keyed_archive = KeyedArchive.new(:data => row[unapplied_encrypted_record_column])
57 | unpacked_top = keyed_archive.unpacked_top()
58 | ns_keys = unpacked_top["root"]["ValueStore"]["RecordValues"]["NS.keys"]
59 | ns_values = unpacked_top["root"]["ValueStore"]["RecordValues"]["NS.objects"]
60 | encrypted_values = ns_values[ns_keys.index("EncryptedValues")]
61 | end
62 |
63 | decrypt_result = @backup.decrypter.decrypt_with_password(@crypto_password,
64 | @crypto_salt,
65 | @crypto_iterations,
66 | @crypto_key,
67 | @crypto_iv,
68 | @crypto_tag,
69 | encrypted_values,
70 | "#{self.class} #{@uuid}")
71 | parsed_json = JSON.parse(decrypt_result[:plaintext])
72 | referenced_url = parsed_json["urlString"]
73 | end
74 | else
75 |
76 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZURLSTRING " +
77 | "FROM ZICCLOUDSYNCINGOBJECT " +
78 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
79 | @uuid) do |row|
80 | referenced_url = row["ZURLSTRING"]
81 | end
82 |
83 | end
84 | return referenced_url
85 | end
86 |
87 | ##
88 | # This method generates the HTML necessary to display the image inline.
89 | def generate_html(individual_files=false)
90 | builder = Nokogiri::HTML::Builder.new(encoding: "utf-8") do |doc|
91 | root = @note.folder.to_relative_root(individual_files)
92 | doc.span {
93 | if (@thumbnails.length > 0 and @thumbnails.first.reference_location)
94 | doc.img(src: "#{root}#{@thumbnails.first.reference_location}")
95 | end
96 |
97 | doc.a(href: @url) {
98 | doc.text @url
99 | }
100 | }
101 | end
102 |
103 | return builder.doc.root
104 | end
105 |
106 | ##
107 | # Generates the data structure used lated by JSON to create a JSON object.
108 | def prepare_json
109 | to_return = super()
110 | to_return[:url] = @url
111 |
112 | to_return
113 | end
114 |
115 | end
116 |
--------------------------------------------------------------------------------
/JSON.md:
--------------------------------------------------------------------------------
1 |
2 | # JSON Format
3 |
4 | ## AppleNoteStore
5 |
6 | The JSON file's overall output is what is generated by the `AppleNoteStore` class, as noted below.
7 |
8 | ``` json
9 | {
10 | "version": "[integer listing iOS version]",
11 | "file_path": "[filepath to OUTPUT database]",
12 | "backup_type": "[integer indicating the type of backup]",
13 | "html": "[full HTML goes here]",
14 | "accounts": {
15 | "[account z_pk]": "[AppleNotesAccount object JSON]"
16 | },
17 | "cloudkit_participants": {
18 | "[cloudkit identifier]": "[AppleCloudKitShareParticipant object JSON]"
19 | },
20 | "folders": {
21 | "[folder z_pk]": "[AppleNotesFolder object JSON]"
22 | },
23 | "notes": {
24 | "[note id]": "[AppleNote object JSON]"
25 | }
26 | }
27 | ```
28 | The JSON output of `AppleNotesAccount` is as follows.
29 |
30 | ``` json
31 | {
32 | "primary_key": "[integer account z_pk]",
33 | "name": "[account name]",
34 | "identifier": "[account ZIDENTIFIER]",
35 | "cloudkit_identifier": "[account Cloudkit identifier]",
36 | "cloudkit_last_modified_device": "[account last modified device]",
37 | "html": "[HTML generated for the specific account goes here]"
38 | }
39 | ```
40 |
41 | ## AppleCloudKitShareParticipant
42 |
43 | The JSON output of `AppleCloudKitShareParticipant` is as follows.
44 |
45 | ``` json
46 | {
47 | "email": "[user's email, if available]",
48 | "record_id": "cloudkit identifier",
49 | "first_name": "[user's first name, if available]",
50 | "last_name": "[user's last name, if available]",
51 | "middle_name": "[user's middle name, if available]",
52 | "name_prefix": "[user's name prefix, if available]",
53 | "name_suffix": "[user's name suffix, if available]",
54 | "name_phonetic": "[user's name pronunciation, if available]",
55 | "phone": "[user's phone number, if available]"
56 | }
57 | ```
58 |
59 | ## AppleNotesFolder
60 |
61 | The JSON output of `AppleNotesFolder` is as follows.
62 |
63 | ``` json
64 | {
65 | "primary_key": "[integer folder z_pk]",
66 | "uuid": "[folder uuid from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER]",
67 | "name": "[folder name]",
68 | "account_id": "[integer z_pk for the account this belongs to]",
69 | "account": "[account name]",
70 | "parent_folder_id": "[integer of parent folder's z_pk, if applicable]",
71 | "child_folders": {
72 | "[folder z_pk]": "[AppleNotesFolder object JSON]"
73 | },
74 | "html": "[folder HTML output]",
75 | "query": "[query string if folder is a smart folder]"
76 | }
77 | ```
78 |
79 | ## AppleNote
80 |
81 | The JSON output of `AppleNote` is as follows.
82 |
83 | ``` json
84 | {
85 | "account_key": "[z_pk of the account]",
86 | "account": "[account name]",
87 | "folder_key": "[z_pk of the folder]",
88 | "folder": "[Folder name]",
89 | "note_id": "[note ID]",
90 | "uuid": "[note uuid from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER]",
91 | "primary_key": "[z_pk of the note]",
92 | "creation_time": "[creation time in YYYY-MM-DD HH:MM:SS TZ format]",
93 | "modify_time": "[modify time in YYYY-MM-DD HH:MM:SS TZ format]",
94 | "cloudkit_creator_id": "[cloudkit ID of the note creator, if applicable]",
95 | "cloudkit_modifier_id": "[cloudkit ID of the last note modifier, if applicable]",
96 | "cloudkit_last_modified_device": "[last modified device, according to CloudKit]",
97 | "is_pinned": "[boolean, whether pinned or not]",
98 | "is_password_protected": "[boolean, whether password protected or not]",
99 | "title": "[Note title]",
100 | "plaintext": "[plaintext of the note",
101 | "html": "[HTML of the note]",
102 | "note_proto": "[NoteStoreProto dump of the decoded protobuf]",
103 | "embedded_objects": [
104 | "[Array of AppleNotesEmbeddedObject object JSON]"
105 | ],
106 | "hashtags": [
107 | "#Test"
108 | ],
109 | "mentions": [
110 | "@FirstName [CloudKit Email]"
111 | ]
112 | }
113 | ```
114 |
115 | ## AppleNotesEmbeddedObject
116 |
117 | The JSON output of `AppleNotesEmbeddedObject` is as follows.
118 |
119 | ``` json
120 | {
121 | "primary_key": "[z_pk of the object]",
122 | "parent_primary_key": "[z_pk of the object's parent, if applicable]",
123 | "note_id": "[note ID]",
124 | "uuid": "[ZIDENTIFIER of the object]",
125 | "type": "[ZTYPEUTI of the object]",
126 | "filename": "[filename of the object, if applicable]",
127 | "filepath": "[filepath of the object, including filename, if applicable]",
128 | "backup_location": "[Filepath of the original backup location, if applicable]",
129 | "user_title": "[The alternate filename given by the user, if applicable]",
130 | "is_password_protected": "[boolean, whether password protected or not]",
131 | "html": "[generated HTML for the object]",
132 | "thumbnails": [
133 | "[Array of AppleNotesEmbeddedObject object JSON, if applicable]"
134 | ],
135 | "child_objects": [
136 | "[Array of AppleNotesEmbeddedObject object JSON, if applicable]"
137 | ],
138 | "table": [
139 | [
140 | "row 0", "column 0",
141 | "row 0", "column 1... etc"
142 | ]
143 | ],
144 | "url": "[url, if applicable]"
145 | }
146 | ```
147 |
148 |
--------------------------------------------------------------------------------
/spec/base_classes/apple_notes_account.rb:
--------------------------------------------------------------------------------
1 | require_relative '../../lib/AppleNotesAccount.rb'
2 | require_relative '../../lib/AppleNotesFolder.rb'
3 | require 'securerandom'
4 |
5 | describe AppleNotesAccount do
6 |
7 | let(:tmp_account) { AppleNotesAccount.new(1, "Account Name", SecureRandom.uuid) }
8 | let(:tmp_folder) { AppleNotesFolder.new(1, "Folder Name", tmp_account) }
9 |
10 | context "file paths" do
11 | it "removes any dirty characters from a potential filename" do
12 | expect(AppleNotesAccount.new(1, "C:\\Users\\Testing\\password.txt", "44198920-571d-467a-b96b-e3e9ccdce13e").clean_name).to eql("C__Users_Testing_password.txt")
13 | expect(AppleNotesAccount.new(1, "/etc/passwd", "44198920-571d-467a-b96b-e3e9ccdce13e").clean_name).to eql("_etc_passwd")
14 | end
15 |
16 | it "faithfully carries non-dirty account names through to the clean_name" do
17 | random_account_name = SecureRandom.alphanumeric(10)
18 | expect(AppleNotesAccount.new(1, random_account_name, "44198920-571d-467a-b96b-e3e9ccdce13e").clean_name).to eql(random_account_name)
19 | end
20 |
21 | it "appropriately creates an account's filepath" do
22 | tmp_uuid = SecureRandom.uuid
23 | expect(AppleNotesAccount.new(1, "Account Name", tmp_uuid).account_folder).to eql("Accounts/#{tmp_uuid}/")
24 | end
25 | end
26 |
27 | context "folders" do
28 |
29 | let(:tmp_folder2) { AppleNotesFolder.new(2, "Folder Name 2", tmp_account) }
30 | let(:tmp_folder3) { AppleNotesFolder.new(1, "Folder Name 3", tmp_account) }
31 |
32 | it "uses the order folders were added if retain_order is false" do
33 | tmp_folder.sort_order = 6
34 | tmp_folder2.sort_order = 5
35 | expect(tmp_account.retain_order).to be false
36 | expect(tmp_account.sorted_folders).to eql([tmp_folder, tmp_folder2])
37 | end
38 |
39 | it "uses the order folders were sorted if retain_order is true" do
40 | tmp_account.retain_order = true
41 | tmp_folder.sort_order = 6
42 | tmp_folder2.sort_order = 5
43 | expect(tmp_account.retain_order).to be true
44 | expect(tmp_account.sorted_folders).to eql([tmp_folder2, tmp_folder])
45 | end
46 |
47 | it "adds a folder" do
48 | tmp_account.add_folder(tmp_folder)
49 | expect(tmp_account.add_folder(tmp_folder2).length).to be 2
50 | end
51 |
52 | it "overwrites a folder if it has the same ID as an existing folder" do
53 | tmp_account.add_folder(tmp_folder)
54 | expect(tmp_account.add_folder(tmp_folder3).length).to be 1
55 | end
56 |
57 | end
58 |
59 | context "notes" do
60 | it "starts with no notes" do
61 | expect(tmp_account.notes.length).to be 0
62 | end
63 |
64 | it "adds a note to its list" do
65 | tmp_note = AppleNote.new(1, 1, "Note Title", "", 0, 0, tmp_account, tmp_folder)
66 | tmp_account.add_note(tmp_note)
67 | expect(tmp_account.notes.length).to be 1
68 | end
69 | end
70 |
71 | context "output" do
72 | it "has no pre-cached HTML" do
73 | expect(tmp_account.instance_variable_get(:@html)).to be nil
74 | end
75 |
76 | it "caches results to make things quicker" do
77 | TEST_HTML_GENERATION_OPTIONS.each do |option|
78 | if tmp_account.instance_variable_get(:@html)
79 | expect(tmp_account.instance_variable_get(:@html)[option]).to be nil
80 | end
81 | tmp_account.generate_html(individual_files: option[0], use_uuid: option[1])
82 | expect(tmp_account.instance_variable_get(:@html)[option]).to be_a Nokogiri::XML::Element
83 | end
84 | end
85 |
86 | it "correctly counts the number of notes" do
87 | TEST_HTML_GENERATION_OPTIONS.each do |option|
88 | expect(tmp_account.generate_html(individual_files: option[0], use_uuid: option[1]).text).to include "Number of Notes: #{tmp_account.notes.length}"
89 | end
90 | end
91 |
92 | it "correctly lists the account name" do
93 | TEST_HTML_GENERATION_OPTIONS.each do |option|
94 | expect(tmp_account.generate_html(individual_files: option[0], use_uuid: option[1]).text).to include "#{tmp_account.name}"
95 | end
96 | end
97 |
98 | it "correctly lists the account ID" do
99 | TEST_HTML_GENERATION_OPTIONS.each do |option|
100 | expect(tmp_account.generate_html(individual_files: option[0], use_uuid: option[1]).text).to include "Account Identifier: #{tmp_account.identifier}"
101 | end
102 | end
103 |
104 | it "correctly lists the account's folders" do
105 | tmp_account.add_folder(tmp_folder)
106 | expect(tmp_account.generate_html(individual_files: false, use_uuid: false).to_html).to include "#{tmp_folder.name}"
107 | expect(tmp_account.generate_html(individual_files: false, use_uuid: true).to_html).to include "#{tmp_folder.name}"
108 | expect(tmp_account.generate_html(individual_files: true, use_uuid: false).to_html).to include "#{tmp_folder.name}"
109 | expect(tmp_account.generate_html(individual_files: true, use_uuid: true).to_html).to include "#{tmp_folder.name}"
110 | end
111 |
112 | end
113 |
114 | end
115 |
--------------------------------------------------------------------------------
/lib/AppleUniformTypeIdentifier.rb:
--------------------------------------------------------------------------------
1 | ##
2 | # This class represents a uniform type identifier which Apple uses
3 | # to identify the type of materials being described. Apple documents
4 | # its UTIs here:
5 | # https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers
6 | class AppleUniformTypeIdentifier
7 |
8 | attr_accessor :uti
9 |
10 | ##
11 | # Creates a new AppleUniformTypeIdentifier.
12 | # Expects a String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI.
13 | def initialize(uti)
14 | # Set this object's variables
15 | @uti = uti
16 | end
17 |
18 | ##
19 | # This method returns a string indicating roughly how this UTI
20 | # should be treated.
21 | def get_conforms_to_string
22 | return "bad_uti" if bad_uti?
23 | return "audio" if conforms_to_audio
24 | return "document" if conforms_to_document
25 | return "dynamic" if is_dynamic?
26 | return "image" if conforms_to_image
27 | return "inline" if conforms_to_inline_attachment
28 | return "other public" if is_public?
29 | return "video" if conforms_to_audiovisual
30 | return "uti: #{@uti}"
31 | end
32 |
33 | ##
34 | # Checks for a UTI that shouldn't exist or won't behave nicely.
35 | def bad_uti?
36 | return false if @uti.is_a?(String)
37 | return true
38 | end
39 |
40 | ##
41 | # This method returns true if the UTI represented is dynamic.
42 | def is_dynamic?
43 | return false if bad_uti?
44 | return @uti.start_with?("dyn.")
45 | end
46 |
47 | ##
48 | # This method returns true if the UTI represented is public.
49 | def is_public?
50 | return false if bad_uti?
51 | return @uti.start_with?("public.")
52 | end
53 |
54 | ##
55 | # This method returns true if the UTI conforms to public.audio
56 | def conforms_to_audio
57 | return false if bad_uti?
58 | return true if @uti == "com.apple.m4a-audio"
59 | return true if @uti == "com.microsoft.waveform-audio"
60 | return true if @uti == "public.aiff-audio"
61 | return true if @uti == "public.midi-audio"
62 | return true if @uti == "public.mp3"
63 | return true if @uti == "org.xiph.ogg-audio"
64 | return false
65 | end
66 |
67 | ##
68 | # This method returns true if the UTI conforms to public.video
69 | # or public.movie.
70 | def conforms_to_audiovisual
71 | return false if bad_uti?
72 | return true if @uti == "com.apple.m4v-video"
73 | return true if @uti == "com.apple.protected-mpeg-4-video"
74 | return true if @uti == "com.apple.protected-mpeg-4-audio"
75 | return true if @uti == "com.apple.quicktime-movie"
76 | return true if @uti == "public.avi"
77 | return true if @uti == "public.mpeg"
78 | return true if @uti == "public.mpeg-2-video"
79 | return true if @uti == "public.mpeg-2-transport-stream"
80 | return true if @uti == "public.mpeg-4"
81 | return true if @uti == "public.mpeg-4-audio"
82 | return false
83 | end
84 |
85 | ##
86 | # This method returns true if the UTI conforms to public.data objets that are likely documents
87 | def conforms_to_document
88 | return false if bad_uti?
89 | return true if @uti == "com.apple.iwork.numbers.sffnumbers"
90 | return true if @uti == "com.apple.log"
91 | return true if @uti == "com.apple.rtfd"
92 | return true if @uti == "com.microsoft.word.doc"
93 | return true if @uti == "com.microsoft.excel.xls"
94 | return true if @uti == "com.microsoft.powerpoint.ppt"
95 | return true if @uti == "com.netscape.javascript-source"
96 | return true if @uti == "net.daringfireball.markdown"
97 | return true if @uti == "net.openvpn.formats.ovpn"
98 | return true if @uti == "org.idpf.epub-container"
99 | return true if @uti == "org.oasis-open.opendocument.text"
100 | return true if @uti == "org.openxmlformats.wordprocessingml.document"
101 | return false
102 | end
103 |
104 | ##
105 | # This method returns true if the UTI conforms to public.image
106 | def conforms_to_image
107 | return false if bad_uti?
108 | return true if @uti == "com.adobe.illustrator.ai-image"
109 | return true if @uti == "com.adobe.photoshop-image"
110 | return true if @uti == "com.adobe.raw-image"
111 | return true if @uti == "com.apple.icns"
112 | return true if @uti == "com.apple.macpaint-image"
113 | return true if @uti == "com.apple.pict"
114 | return true if @uti == "com.apple.quicktime-image"
115 | return true if @uti == "com.apple.notes.sketch"
116 | return true if @uti == "com.compuserve.gif"
117 | return true if @uti == "com.ilm.openexr-image"
118 | return true if @uti == "com.kodak.flashpix.image"
119 | return true if @uti == "com.microsoft.bmp"
120 | return true if @uti == "com.microsoft.ico"
121 | return true if @uti == "com.sgi.sgi-image"
122 | return true if @uti == "com.truevision.tga-image"
123 | return true if @uti == "public.camera-raw-image"
124 | return true if @uti == "public.fax"
125 | return true if @uti == "public.heic"
126 | return true if @uti == "public.jpeg"
127 | return true if @uti == "public.jpeg-2000"
128 | return true if @uti == "public.png"
129 | return true if @uti == "public.svg-image"
130 | return true if @uti == "public.tiff"
131 | return true if @uti == "public.xbitmap-image"
132 | return true if @uti == "org.webmproject.webp"
133 | return false
134 | end
135 |
136 | ##
137 | # This method returns true if the UTI represents Apple text enrichment.
138 | def conforms_to_inline_attachment
139 | return false if bad_uti?
140 | return true if @uti.start_with?("com.apple.notes.inlinetextattachment")
141 | return false
142 | end
143 |
144 | ##
145 | # This method just returns a readable String for the object.
146 | def to_s
147 | "#{@uti}"
148 | end
149 |
150 | end
151 |
--------------------------------------------------------------------------------
/spec/embedded_objects/tables.rb:
--------------------------------------------------------------------------------
1 | require_relative '../../lib/AppleNotesEmbeddedObject.rb'
2 |
3 | describe AppleNotesEmbeddedObject, :missing_data => !TEST_FILE_VERSIONS_CURRENT_FILE_EXIST do
4 |
5 | before(:context) do
6 | TEST_OUTPUT_DIR.mkpath
7 | end
8 | after(:context) do
9 | TEST_OUTPUT_DIR.rmtree
10 | end
11 |
12 | before(:context) do
13 | @tmp_backup = AppleBackupFile.new(TEST_FILE_VERSIONS[TEST_CURRENT_VERSION], TEST_OUTPUT_DIR)
14 | @tmp_notestore = AppleNoteStore.new(TEST_FILE_VERSIONS[TEST_CURRENT_VERSION], TEST_CURRENT_VERSION)
15 | @tmp_account = AppleNotesAccount.new(1, "Note Account", SecureRandom.uuid)
16 | @tmp_folder = AppleNotesFolder.new(2, "Note Folder", @tmp_account)
17 | @tmp_notestore.backup = @tmp_backup
18 | @tmp_notestore.open
19 | @tmp_note = AppleNote.new(1,
20 | 1,
21 | "Note Title",
22 | File.read(TEST_BLOB_DATA_DIR + "simple_note_protobuf_gzipped.bin"),
23 | 608413790,
24 | 608413790,
25 | @tmp_account,
26 | @tmp_folder)
27 | @tmp_note.notestore = @tmp_notestore
28 | @simple_table = AppleNotesEmbeddedTable.new(1, SecureRandom.uuid, "com.apple.notes.table", @tmp_note)
29 | @simple_table.instance_variable_set(:@gzipped_data, File.read(TEST_BLOB_DATA_DIR + "table_gzipped.bin"))
30 | @simple_table.rebuild_table
31 | @rectangular_table = AppleNotesEmbeddedTable.new(2, SecureRandom.uuid, "com.apple.notes.table", @tmp_note)
32 | @rectangular_table.instance_variable_set(:@gzipped_data, File.read(TEST_BLOB_DATA_DIR + "table_formats_gzipped.bin"))
33 | @rectangular_table.rebuild_table
34 | @right_to_left_table = AppleNotesEmbeddedTable.new(3, SecureRandom.uuid, "com.apple.notes.table", @tmp_note)
35 | @right_to_left_table.instance_variable_set(:@gzipped_data, File.read(TEST_BLOB_DATA_DIR + "right_to_left_table_gzipped.bin"))
36 | @right_to_left_table.rebuild_table
37 | end
38 |
39 | context "table creation" do
40 |
41 | it "reconstructs the whole table" do
42 | expect(@simple_table.instance_variable_get(:@total_rows)).to be 2
43 | expect(@simple_table.instance_variable_get(:@total_columns)).to be 2
44 | expect(@rectangular_table.instance_variable_get(:@total_rows)).to be 3
45 | expect(@rectangular_table.instance_variable_get(:@total_columns)).to be 2
46 | end
47 |
48 | it "properly orders rows" do
49 | (0..1).each do |row|
50 | expect(@simple_table.instance_variable_get(:@reconstructed_table)[row]).to be_a Array
51 | expect(@simple_table.instance_variable_get(:@reconstructed_table_html)[row]).to be_a Array
52 | (0..1).each do |column|
53 | expect(@simple_table.instance_variable_get(:@reconstructed_table)[row][column]).to eql "Row #{row + 1} Column #{column + 1}"
54 | expect(@simple_table.instance_variable_get(:@reconstructed_table_html)[row][column].text).to eql @simple_table.instance_variable_get(:@reconstructed_table)[row][column]
55 | end
56 | end
57 | expect(@right_to_left_table.instance_variable_get(:@reconstructed_table)[0][1]).to eql "اول"
58 | expect(@right_to_left_table.instance_variable_get(:@reconstructed_table)[1][0]).to eql "نهاية"
59 | end
60 |
61 | it "has different values for the html table" do
62 | expect(@simple_table.instance_variable_get(:@reconstructed_table)).not_to eql @simple_table.instance_variable_get(:@reconstructed_table_html)
63 | end
64 |
65 | end
66 |
67 | context "output" do
68 | it "only has plain text in its to_s output" do
69 | tmp_string = @simple_table.to_s
70 | expect(tmp_string).to be_a String
71 | expect(tmp_string).to eql "Embedded Object com.apple.notes.table: #{@simple_table.uuid} with cells: \n\tRow 1 Column 1\tRow 1 Column 2\n\tRow 2 Column 1\tRow 2 Column 2"
72 | expect(@right_to_left_table.to_s).to eql "Embedded Object com.apple.notes.table: #{@right_to_left_table.uuid} with cells: \n\t\tاول\n\tنهاية\t"
73 | end
74 |
75 | it "displays right-to-left tables in the right direction in to_s output" do
76 | expect(@right_to_left_table.to_s).to eql "Embedded Object com.apple.notes.table: #{@right_to_left_table.uuid} with cells: \n\t\tاول\n\tنهاية\t"
77 | end
78 |
79 | it "generates decent looking HTML" do
80 | [true, false].each do |option|
81 | tmp_html = @simple_table.generate_html(individual_files: option)
82 | expect(tmp_html).to be_a Nokogiri::XML::Element
83 | expect(tmp_html.to_html).to eql "\n\n| Row 1 Column 1 | \nRow 1 Column 2 | \n
\n\n| Row 2 Column 1 | \nRow 2 Column 2 | \n
\n
"
84 | end
85 | end
86 |
87 | it "respects text formatting in HTML output" do
88 | [true, false].each do |option|
89 | tmp_html = @rectangular_table.generate_html(individual_files: option).to_html
90 | expect(tmp_html).to include "Bold italics | "
91 | expect(tmp_html).to include "Underline | "
92 | expect(tmp_html).to include "Bold | "
93 | expect(tmp_html).to include "Italics | "
94 | expect(tmp_html).to include "Mixed bold italics underline\n | "
95 | expect(tmp_html).to include(" | ").once
96 | end
97 | end
98 |
99 | it "properly includes data in its JSON" do
100 | tmp_json = @simple_table.prepare_json
101 | expect(tmp_json).to be_a Hash
102 | expect(tmp_json[:type]).to eql "com.apple.notes.table"
103 | expect(tmp_json[:html]).to be_a Nokogiri::XML::Element
104 | expect(tmp_json[:table]).to eql [["Row 1 Column 1", "Row 1 Column 2"], ["Row 2 Column 1", "Row 2 Column 2"]]
105 | end
106 |
107 | end
108 |
109 | end
110 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedDrawing.rb:
--------------------------------------------------------------------------------
1 | require_relative 'AppleNotesEmbeddedThumbnail.rb'
2 |
3 | #
4 | # This class represents a com.apple.drawing2 object embedded
5 | # in an AppleNote. This means you drew on the screen
6 | class AppleNotesEmbeddedDrawing < AppleNotesEmbeddedObject
7 |
8 | attr_accessor :primary_key,
9 | :uuid,
10 | :type,
11 | :reference_location
12 |
13 | ##
14 | # Creates a new AppleNotesEmbeddedDrawing object.
15 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
16 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, AppleNote +note+ object representing the parent AppleNote, and
17 | # AppleBackup +backup+ from the parent AppleNote. Immediately sets the +filename+ and +filepath+ to point to were the media is stored.
18 | # Finally, it attempts to copy the file to the output folder.
19 | def initialize(primary_key, uuid, uti, note, backup)
20 | # Set this folder's variables
21 | super(primary_key, uuid, uti, note)
22 | @filename = ""
23 | @filepath = ""
24 | @backup = backup
25 | @zgeneration = get_zgeneration_for_fallback_image
26 |
27 | compute_all_filepaths
28 | tmp_stored_file_result = find_valid_file_path
29 |
30 | if tmp_stored_file_result
31 | @filepath = tmp_stored_file_result.filepath
32 | @filename = tmp_stored_file_result.filename
33 | @backup_location = tmp_stored_file_result.backup_location
34 | @reference_location = @backup.back_up_file(@filepath,
35 | @filename,
36 | @backup_location,
37 | @is_password_protected,
38 | @crypto_password,
39 | @crypto_salt,
40 | @crypto_iterations,
41 | @crypto_key,
42 | @crypto_fallback_iv,
43 | @crypto_fallback_tag)
44 | end
45 | end
46 |
47 | ##
48 | # This function overrides the default AppleNotesEmbeddedObject add_cryptographic_settings
49 | # to include the fallback image settings from ZFALLBACKIMAGECRYPTOTAG and
50 | # ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR for content on disk.
51 | def add_cryptographic_settings
52 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZCRYPTOINITIALIZATIONVECTOR, ZICCLOUDSYNCINGOBJECT.ZCRYPTOTAG, " +
53 | "ZICCLOUDSYNCINGOBJECT.ZCRYPTOSALT, ZICCLOUDSYNCINGOBJECT.ZCRYPTOITERATIONCOUNT, " +
54 | "ZICCLOUDSYNCINGOBJECT.ZCRYPTOVERIFIER, ZICCLOUDSYNCINGOBJECT.ZCRYPTOWRAPPEDKEY, " +
55 | "ZICCLOUDSYNCINGOBJECT.ZFALLBACKIMAGECRYPTOTAG, ZICCLOUDSYNCINGOBJECT.ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR " +
56 | "FROM ZICCLOUDSYNCINGOBJECT " +
57 | "WHERE Z_PK=?",
58 | @primary_key) do |media_row|
59 | @crypto_iv = media_row["ZCRYPTOINITIALIZATIONVECTOR"]
60 | @crypto_tag = media_row["ZCRYPTOTAG"]
61 | @crypto_fallback_iv = media_row["ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR"]
62 | @crypto_fallback_tag = media_row["ZFALLBACKIMAGECRYPTOTAG"]
63 | @crypto_salt = media_row["ZCRYPTOSALT"]
64 | @crypto_iterations = media_row["ZCRYPTOITERATIONCOUNT"]
65 | @crypto_key = media_row["ZCRYPTOVERIFIER"] if media_row["ZCRYPTOVERIFIER"]
66 | @crypto_key = media_row["ZCRYPTOWRAPPEDKEY"] if media_row["ZCRYPTOWRAPPEDKEY"]
67 | end
68 |
69 | @crypto_password = @note.crypto_password
70 | end
71 |
72 | ##
73 | # This method just returns a readable String for the object.
74 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
75 | def to_s
76 | to_s_with_data("drawing")
77 | end
78 |
79 | ##
80 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
81 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
82 | # and reading the ZICCOUDSYNCINGOBJECT.ZIDENTIFIER of the row identified by that number
83 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
84 | def get_media_uuid
85 | return get_media_uuid_from_zidentifer(@uuid)
86 | end
87 |
88 | ##
89 | # This method fetches the appropriate ZFALLBACKGENERATION string to compute
90 | # media location for iOS 17 and later.
91 | def get_zgeneration_for_fallback_image
92 | return "" if @note.notestore.version < AppleNoteStoreVersion::IOS_VERSION_17
93 |
94 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZFALLBACKIMAGEGENERATION " +
95 | "FROM ZICCLOUDSYNCINGOBJECT " +
96 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
97 | @uuid) do |row|
98 | return row["ZFALLBACKIMAGEGENERATION"]
99 | end
100 | end
101 |
102 | ##
103 | # This method computes the various filename permutations seen in iOS.
104 | def compute_all_filepaths
105 |
106 | # Set up account folder location, default to no where
107 | tmp_account_string = "[Unknown Account]/FallbackImages/"
108 | tmp_account_string = "#{@note.account.account_folder}FallbackImages/" if @note # Update to somewhere if we know where
109 |
110 | ["jpeg","png", "jpg"].each do |extension|
111 | add_possible_location("#{tmp_account_string}#{@uuid}.#{extension}.encrypted") if @is_password_protected
112 | add_possible_location("#{tmp_account_string}#{@uuid}.#{extension}") if !@is_password_protected
113 | add_possible_location("#{tmp_account_string}#{@uuid}/#{@zgeneration}/FallbackImage.#{extension}.encrypted") if @is_password_protected
114 | add_possible_location("#{tmp_account_string}#{@uuid}/#{@zgeneration}/FallbackImage.#{extension}") if !@is_password_protected
115 | end
116 | end
117 |
118 | ##
119 | # This method generates the HTML necessary to display the image inline.
120 | def generate_html(individual_files=false)
121 | generate_html_with_images(individual_files)
122 | end
123 |
124 | end
125 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedPaperDocScan.rb:
--------------------------------------------------------------------------------
1 | require_relative 'AppleNotesEmbeddedThumbnail.rb'
2 |
3 | #
4 | # This class represents a com.apple.paper.scan.doc object embedded
5 | # in an AppleNote. This comes from scanning a paper and then editing
6 | # the scan.
7 | class AppleNotesEmbeddedPaperDocScan < AppleNotesEmbeddedObject
8 |
9 | attr_accessor :primary_key,
10 | :uuid,
11 | :type,
12 | :reference_location
13 |
14 | ##
15 | # Creates a new AppleNotesEmbeddedPaperDocScan object.
16 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
17 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, AppleNote +note+ object representing the parent AppleNote, and
18 | # AppleBackup +backup+ from the parent AppleNote. Immediately sets the +filename+ and +filepath+ to point to were the media is stored.
19 | # Finally, it attempts to copy the file to the output folder.
20 | def initialize(primary_key, uuid, uti, note, backup)
21 | # Set this objects's variables
22 | super(primary_key, uuid, uti, note)
23 | @filename = ""
24 | @filepath = ""
25 | @backup = backup
26 | @zgeneration = get_zgeneration_for_fallback_pdf
27 |
28 | compute_all_filepaths
29 | tmp_stored_file_result = find_valid_file_path
30 |
31 | if tmp_stored_file_result
32 | @filepath = tmp_stored_file_result.filepath
33 | @filename = tmp_stored_file_result.filename
34 | @backup_location = tmp_stored_file_result.backup_location
35 | @reference_location = @backup.back_up_file(@filepath,
36 | @filename,
37 | @backup_location,
38 | @is_password_protected,
39 | @crypto_password,
40 | @crypto_salt,
41 | @crypto_iterations,
42 | @crypto_key,
43 | @crypto_iv,
44 | @crypto_tag)
45 | end
46 | end
47 |
48 | ##
49 | # This function overrides the default AppleNotesEmbeddedObject add_cryptographic_settings
50 | # to include the fallback image settings from ZFALLBACKIMAGECRYPTOTAG and
51 | # ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR for content on disk.
52 | def add_cryptographic_settings
53 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZCRYPTOINITIALIZATIONVECTOR, ZICCLOUDSYNCINGOBJECT.ZCRYPTOTAG, " +
54 | "ZICCLOUDSYNCINGOBJECT.ZCRYPTOSALT, ZICCLOUDSYNCINGOBJECT.ZCRYPTOITERATIONCOUNT, " +
55 | "ZICCLOUDSYNCINGOBJECT.ZCRYPTOVERIFIER, ZICCLOUDSYNCINGOBJECT.ZCRYPTOWRAPPEDKEY, " +
56 | "ZICCLOUDSYNCINGOBJECT.ZFALLBACKIMAGECRYPTOTAG, ZICCLOUDSYNCINGOBJECT.ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR " +
57 | "FROM ZICCLOUDSYNCINGOBJECT " +
58 | "WHERE Z_PK=?",
59 | @primary_key) do |media_row|
60 | @crypto_iv = media_row["ZCRYPTOINITIALIZATIONVECTOR"]
61 | @crypto_tag = media_row["ZCRYPTOTAG"]
62 | @crypto_fallback_iv = media_row["ZFALLBACKIMAGECRYPTOINITIALIZATIONVECTOR"]
63 | @crypto_fallback_tag = media_row["ZFALLBACKIMAGECRYPTOTAG"]
64 | @crypto_salt = media_row["ZCRYPTOSALT"]
65 | @crypto_iterations = media_row["ZCRYPTOITERATIONCOUNT"]
66 | @crypto_key = media_row["ZCRYPTOVERIFIER"] if media_row["ZCRYPTOVERIFIER"]
67 | @crypto_key = media_row["ZCRYPTOWRAPPEDKEY"] if media_row["ZCRYPTOWRAPPEDKEY"]
68 | end
69 |
70 | @crypto_password = @note.crypto_password
71 | end
72 |
73 | ##
74 | # This method just returns a readable String for the object.
75 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
76 | def to_s
77 | to_s_with_data("scan")
78 | end
79 |
80 | ##
81 | # Uses database calls to fetch the actual media object's ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
82 | # This requires taking the ZICCLOUDSYNCINGOBJECT.ZMEDIA field on the entry with this object's +uuid+
83 | # and reading the ZICCOUDSYNCINGOBJECT.ZIDENTIFIER of the row identified by that number
84 | # in the ZICCLOUDSYNCINGOBJECT.Z_PK field.
85 | def get_media_uuid
86 | return get_media_uuid_from_zidentifer(@uuid)
87 | end
88 |
89 | ##
90 | # This method fetches the appropriate ZFALLBACKGENERATION string to compute
91 | # media location for iOS 17 and later.
92 | def get_zgeneration_for_fallback_pdf
93 | return "" if @note.notestore.version < AppleNoteStoreVersion::IOS_VERSION_17
94 |
95 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZFALLBACKPDFGENERATION " +
96 | "FROM ZICCLOUDSYNCINGOBJECT " +
97 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
98 | @uuid) do |row|
99 | return row["ZFALLBACKPDFGENERATION"]
100 | end
101 | end
102 |
103 | ##
104 | # This method computes the various filename permutations seen in iOS.
105 | def compute_all_filepaths
106 |
107 | # Set up account folder location, default to no where
108 | tmp_account_string = "[Unknown Account]/FallbackPDFs/"
109 | tmp_account_string = "#{@note.account.account_folder}FallbackPDFs/" if @note # Update to somewhere if we know where
110 | zgeneration = get_zgeneration_for_fallback_pdf
111 |
112 | add_possible_location("#{tmp_account_string}#{@uuid}.pdf.encrypted") if @is_password_protected
113 | add_possible_location("#{tmp_account_string}#{@uuid}.pdf") if !@is_password_protected
114 | add_possible_location("#{tmp_account_string}#{@uuid}/#{zgeneration}/FallbackPDF.pdf.encrypted") if (@is_password_protected and zgeneration and zgeneration.length > 0)
115 | add_possible_location("#{tmp_account_string}#{@uuid}/#{zgeneration}/FallbackPDF.pdf") if (!@is_password_protected and zgeneration and zgeneration.length > 0)
116 |
117 | end
118 |
119 | ##
120 | # This method generates the HTML necessary to display the image inline.
121 | def generate_html(individual_files=false)
122 | generate_html_with_images(individual_files)
123 | end
124 |
125 | end
126 |
--------------------------------------------------------------------------------
/spec/embedded_objects/thumbnail.rb:
--------------------------------------------------------------------------------
1 | require_relative '../../lib/AppleNotesEmbeddedThumbnail.rb'
2 |
3 | describe AppleNotesEmbeddedThumbnail do
4 |
5 | let(:primary_key) {1}
6 | let(:uuid) {SecureRandom.uuid}
7 | let(:uti) {"thumbnail"}
8 | let(:note) {nil}
9 | let(:backup) {nil}
10 | let(:height) {640}
11 | let(:width) {480}
12 | let(:parent) {nil}
13 | let(:gallery_parent) {double(AppleNotesEmbeddedGallery)}
14 | let(:mocked_note) {double(AppleNote)}
15 | let(:mocked_account) {double(AppleNotesAccount)}
16 | let(:account_uuid) {SecureRandom.uuid}
17 |
18 | context "creation" do
19 | it "creates a thumbnail without needing other classes" do
20 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
21 | expect(tmp_thumbnail).to be_a AppleNotesEmbeddedThumbnail
22 | end
23 | end
24 |
25 | context "filepaths" do
26 |
27 | let(:version_16) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_16)}
28 | let(:version_17) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_17)}
29 |
30 | xit "guesses the right filepath for iOS 16 thumbnails without a note" do
31 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
32 | tmp_thumbnail.instance_variable_set(:@version,version_16)
33 | expect(tmp_thumbnail.get_media_filepath).to eql "[Unknown Account]/Previews/#{uuid}.png"
34 | end
35 |
36 | xit "guesses the right filepath for iOS 17 thumbnails with an account" do
37 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
38 | tmp_thumbnail.instance_variable_set(:@note, mocked_note)
39 |
40 | allow(mocked_note).to receive(:account).and_return(mocked_account)
41 | allow(mocked_account).to receive(:account_folder).and_return("Accounts/#{account_uuid}/")
42 |
43 | tmp_thumbnail.instance_variable_set(:@version,version_16)
44 | expect(tmp_thumbnail.get_media_filepath).to eql "Accounts/#{account_uuid}/Previews/#{uuid}.png"
45 | end
46 |
47 | xit "guesses the right filename for iOS 16 normal thumbnails" do
48 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
49 | tmp_thumbnail.instance_variable_set(:@version,version_16)
50 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.png"
51 | end
52 |
53 | it "guesses the right filename for iOS 16 gallery thumbnails" do
54 | allow(gallery_parent).to receive(:type).and_return("com.apple.notes.gallery")
55 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, gallery_parent)
56 | tmp_thumbnail.instance_variable_set(:@version,version_16)
57 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.jpg"
58 | end
59 |
60 | it "guesses the right filename for iOS 16 encrypted note thumbnails" do
61 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
62 | tmp_thumbnail.instance_variable_set(:@version,version_16)
63 | tmp_thumbnail.instance_variable_set(:@is_password_protected,true)
64 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.png.encrypted"
65 | end
66 |
67 | it "guesses the right filename for iOS 16 encrypted gallery thumbnails" do
68 | allow(gallery_parent).to receive(:type).and_return("com.apple.notes.gallery")
69 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, gallery_parent)
70 | tmp_thumbnail.instance_variable_set(:@version,version_16)
71 | tmp_thumbnail.instance_variable_set(:@is_password_protected,true)
72 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.jpg.encrypted"
73 | end
74 |
75 | ## iOS 17 follows
76 |
77 | xit "guesses the right filepath for iOS 17 thumbnails without a note" do
78 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
79 | tmp_thumbnail.instance_variable_set(:@version,version_17)
80 | expect(tmp_thumbnail.get_media_filepath).to eql "[Unknown Account]/Previews/#{uuid}.png"
81 | end
82 |
83 | xit "guesses the right filepath for iOS 17 thumbnails with zgeneration" do
84 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
85 | tmp_thumbnail.instance_variable_set(:@note, mocked_note)
86 |
87 | allow(mocked_note).to receive(:account).and_return(mocked_account)
88 | allow(mocked_account).to receive(:account_folder).and_return("Accounts/#{account_uuid}/")
89 |
90 | tmp_thumbnail.instance_variable_set(:@version,version_17)
91 | expect(tmp_thumbnail.get_media_filepath).to eql "Accounts/#{account_uuid}/Previews/#{uuid}.png"
92 | end
93 |
94 | xit "guesses the right filepath for iOS 17 thumbnails without zgeneration" do
95 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
96 | tmp_thumbnail.instance_variable_set(:@version,version_17)
97 | expect(tmp_thumbnail.get_media_filepath).to eql "[Unknown Account]/Previews/#{uuid}.png"
98 | end
99 |
100 | it "guesses the right filename for iOS 17 normal thumbnails" do
101 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
102 | tmp_thumbnail.instance_variable_set(:@version,version_17)
103 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.png"
104 | end
105 |
106 | it "guesses the right filename for iOS 17 gallery thumbnails" do
107 | allow(gallery_parent).to receive(:type).and_return("com.apple.notes.gallery")
108 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, gallery_parent)
109 | tmp_thumbnail.instance_variable_set(:@version,version_17)
110 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.jpeg"
111 | end
112 |
113 | it "guesses the right filename for iOS 17 encrypted note thumbnails" do
114 | tmp_thumbnail = AppleNotesEmbeddedThumbnail.new(primary_key, uuid, uti, note, backup, height, width, parent)
115 | tmp_thumbnail.instance_variable_set(:@version,version_17)
116 | tmp_thumbnail.instance_variable_set(:@is_password_protected,true)
117 | expect(tmp_thumbnail.get_media_filename).to eql "#{uuid}.png.encrypted"
118 | end
119 |
120 |
121 | end
122 |
123 | end
124 |
--------------------------------------------------------------------------------
/lib/AppleBackupHashedManifestPlist.rb:
--------------------------------------------------------------------------------
1 | require 'fileutils'
2 | require 'cfpropertylist'
3 | require 'pathname'
4 | require_relative 'AppleDecrypter.rb'
5 |
6 | # Big thanks to https://stackoverflow.com/questions/1498342/how-to-decrypt-an-encrypted-apple-itunes-iphone-backup/13793043#13793043
7 |
8 | class AppleProtectionClass
9 |
10 | attr_accessor :uuid,
11 | :clas,
12 | :wrap,
13 | :ktyp,
14 | :wrapped_key,
15 | :unwrapped_key
16 |
17 | def initialize(uuid)
18 | @uuid = uuid
19 | @clas = nil
20 | @wrap = nil
21 | @ktyp = nil
22 | @wrapped_key = nil
23 | @unwrapped_key = nil
24 | end
25 |
26 | end
27 |
28 | class AppleBackupHashedManifestPlist
29 |
30 | attr_accessor :encrypted,
31 | :manifest_key,
32 | :manifest_key_class,
33 | :protection_classes
34 |
35 | def initialize(manifest_path, decrypter, logger=nil)
36 |
37 | # Read in the Manifest Plist for later use
38 | @manifest_plist_data = nil
39 | tmp_plist = CFPropertyList::List.new(:file => manifest_path);
40 | @manifest_plist_data = CFPropertyList.native_types(tmp_plist.value);
41 |
42 | @logger = Logger.new(STDOUT)
43 | @logger = logger if logger
44 |
45 | # Set up the encryption variables
46 | @encrypted = @manifest_plist_data["IsEncrypted"]
47 | @pbkdf2_salt = nil
48 | @pbkdf2_iter = nil
49 | @pbkdf2_double_protection_salt = nil
50 | @pbkdf2_double_protection_iter = nil
51 |
52 | @decrypter = decrypter
53 |
54 | @protection_classes = Array.new
55 |
56 | if @encrypted
57 | tmp_manifest_string = @manifest_plist_data["ManifestKey"]
58 | @manifest_key_class = tmp_manifest_string[0,4].unpack("V")[0]
59 | @manifest_key = tmp_manifest_string[4,40]
60 | self.parse_keybag
61 | end
62 |
63 | end
64 |
65 | ##
66 | # This method parses the manifest's keybag into internal data structures.
67 | def parse_keybag
68 | current_location = 0
69 | tmp_keybag = @manifest_plist_data["BackupKeyBag"]
70 |
71 | pbkdf2_salt = nil
72 | pbkdf2_iters = 0
73 | pbkdf2_double_protection_salt = nil
74 | pbkdf2_double_protection_iters = 0
75 |
76 | @keybag_uuid = nil
77 | @keybag_wrap = nil
78 |
79 | tmp_protection_class = nil
80 |
81 | while current_location < tmp_keybag.length
82 | # First four bytes are the string type
83 | tmp_string_type = tmp_keybag[current_location, 4]
84 | current_location += 4
85 |
86 | # Next four bytes are the length, in big-endian
87 | tmp_length = tmp_keybag[current_location, 4].unpack("N")[0]
88 | current_location += 4
89 |
90 | # next X bytes are the value itself
91 | tmp_value = tmp_keybag[current_location, tmp_length]
92 | current_location += tmp_length
93 |
94 | # Read in values
95 | case tmp_string_type
96 | when "VERS"
97 | @keybag_version = tmp_value.unpack("N")[0]
98 | when "HMCK"
99 | @keybag_hmac = tmp_value
100 | when "TYPE"
101 | @keybag_type = tmp_value.unpack("N")[0]
102 | when "SALT"
103 | @pbkdf2_salt = tmp_value
104 | when "ITER"
105 | @pbkdf2_iter = tmp_value.unpack("N")[0]
106 | when "DPSL"
107 | @pbkdf2_double_protection_salt = tmp_value
108 | when "DPIC"
109 | @pbkdf2_double_protection_iter = tmp_value.unpack("N")[0]
110 | when "UUID"
111 | if not @keybag_uuid
112 | @keybag_uuid = tmp_value
113 | else
114 | # We have a new protection class
115 | @protection_classes.push(tmp_protection_class) if tmp_protection_class
116 | tmp_protection_class = AppleProtectionClass.new(tmp_value)
117 | end
118 | when "CLAS"
119 | tmp_protection_class.clas = tmp_value.unpack("N")[0]
120 | when "KTYP"
121 | tmp_protection_class.ktyp = tmp_value.unpack("N")[0]
122 | when "WPKY"
123 | tmp_protection_class.wrapped_key = tmp_value
124 | when "WRAP"
125 | if not @keybag_wrap
126 | @keybag_wrap = tmp_value
127 | else
128 | tmp_protection_class.wrap = tmp_value
129 | end
130 | end
131 | end
132 |
133 | @protection_classes.push(tmp_protection_class) if tmp_protection_class
134 |
135 | if self.key_values_present
136 |
137 | # First use a SHA256 round with DPSL and DPIC
138 | @logger.debug("AppleBackupHashedManifestPlist: Generating key, step 1")
139 | initial_key_size = 32
140 | initial_unwrapped_key = nil
141 | puts "Checking #{@decrypter.passwords.length} passwords, be aware that the initial step for each password is computationally intensive."
142 | @decrypter.passwords.each do |password|
143 | initial_unwrapped_key = @decrypter.generate_key_encrypting_key(password, @pbkdf2_double_protection_salt, @pbkdf2_double_protection_iter, '', initial_key_size) if !initial_unwrapped_key
144 | end
145 |
146 | return if !initial_unwrapped_key
147 | puts "Successfully generated encrypted iTunes key encrypting key using password"
148 |
149 | # then a SHA1 round with ITER and SALT
150 | @logger.debug("AppleBackupHashedManifestPlist: Generating key, step 2")
151 | @unwrapped_key = @decrypter.generate_key_encrypting_key(initial_unwrapped_key, @pbkdf2_salt, @pbkdf2_iter, '', initial_key_size, OpenSSL::Digest::SHA1.new)
152 |
153 | # Unwrap every key
154 | @protection_classes.each do |protection_class|
155 | protection_class.unwrapped_key = @decrypter.aes_key_unwrap(protection_class.wrapped_key, @unwrapped_key)
156 | @logger.debug("AppleBackupHashedManifestPlist: Unwrapped key for protection class #{protection_class.clas}")
157 | end
158 | end
159 |
160 | end
161 |
162 | ##
163 | # This method takes an Integer +class_id+ and returns the AppleProtectionClass that corresponds to that
164 | # class id. Returns nil if not found.
165 | def get_class_by_id(class_id)
166 | @protection_classes.each do |protection_class|
167 | return protection_class if protection_class.clas == class_id
168 | end
169 |
170 | return nil
171 | end
172 |
173 | def key_values_present
174 | (@pbkdf2_iter and @pbkdf2_salt and @pbkdf2_double_protection_iter and @pbkdf2_double_protection_salt)
175 | end
176 |
177 | ##
178 | # This method returns the @encrypted variable.
179 | def encrypted?
180 | @encrypted
181 | end
182 |
183 | ##
184 | # This method identifies if we can decrypt the file. It solely checks if an +@unwrapped_key+ exists.
185 | def can_decrypt?
186 | return @unwrapped_key != nil
187 | end
188 |
189 | end
190 |
--------------------------------------------------------------------------------
/proto/protobuf_config.py:
--------------------------------------------------------------------------------
1 | # This has been used to quickly prototype the .proto file.
2 | # This file is messy, but if you check out the https://github.com/jmendeth/protobuf-inspector
3 | # repo and use this as your config, it will nominally parse specific notes or embedded objects.
4 | # To parse a note (if this file is in the root of protobuf-inspector): python3 main.py < your_extracted_note.pb
5 | # To parse an embedded object: python3 main.py mergeabledata1_proto < your_extracted_object.pb
6 |
7 | types = {
8 | # Main Note Data protobuf
9 | "root": {
10 | 2: ("document"),
11 | },
12 |
13 | # Related to a Note
14 | "document": { #
15 | # 1: unknown?
16 | 2: ("varint", "Version"),
17 | 3: ("note", "Note"),
18 | },
19 |
20 | "note": { #
21 | 2: ("string", "Note Text"),
22 | 3: ("unknown_chunk", "Unknown Chunk"),
23 | 4: ("unknown_note_stuff", "Unknown Stuff"),
24 | 5: ("attribute_run", "Attribute Run"),
25 | },
26 |
27 | "unknown_chunk": {
28 | 5: ("varint", "One-up ID"),
29 | },
30 |
31 | "unknown_note_stuff": {
32 | 1: ("unknown_note_stuff_entry"),
33 | },
34 |
35 | "unknown_note_stuff_entry": {
36 | },
37 |
38 | "attribute_run": { #
39 | 1: ("varint", "Length"),
40 | 2: ("paragraph_style", "Paragraph Style"),
41 | 3: ("note_font", "Font"),
42 | 5: ("enum formatting_enum", "Font Hints"), # 1 is bold, 2 is italics, 3 is both
43 | 6: ("varint", "Underlined"),
44 | 7: ("varint", "Strikethrough"),
45 | 8: ("int32", "superscript"), # Sign indicates super/sub
46 | 9: ("string", "Link"),
47 | 10: ("color", "Color"),
48 | 12: ("attachment_info", "Attachment Info"),
49 | 13: ("varint", "Unknown identifier"),
50 | 14: ("enum emphasis_enum", "Emphasis Color"),
51 | },
52 |
53 | "enum emphasis_enum": {
54 | 1: "1: Purple",
55 | 2: "2: Pink",
56 | 3: "3: Orange",
57 | 4: "4: Mint",
58 | 5: "5: Blue",
59 | },
60 |
61 | "paragraph_style": { #
62 | 1: ("enum style_enum", "Style Type"),
63 | # 3: unknown?
64 | 4: ("varint", "Indent Number"),
65 | 5: ("paragraph_todo", "Todo"),
66 | 8: ("varint", "Block Quote"),
67 | },
68 |
69 | "enum style_enum": { #
70 | 0: "0: Title",
71 | 1: "1: Heading",
72 | 2: "2: Subheading",
73 | 4: "4: Monospaced",
74 | 100: "100: Dotted list",
75 | 101: "101: Dashed list",
76 | 102: "102: Ordered list",
77 | 103: "103: Checkbox",
78 | },
79 |
80 | "paragraph_todo": { #
81 | 1: ("bytes", "Todo UUID"),
82 | 2: ("varint", "Done"),
83 | },
84 |
85 | "note_font": { #
86 | 1: ("string", "Font Name"),
87 | 2: ("varint", "Point Size"),
88 | 3: ("varint", "Font Hints"),
89 | },
90 |
91 | "attachment_info": { #
92 | 1: ("string", "Attachment Identifier"),
93 | 2: ("string", "Type UTI"),
94 | },
95 |
96 | "enum formatting_enum": { #
97 | 0: "0: --UNKNOWN--",
98 | 1: "1: BOLD",
99 | 2: "2: ITALIC",
100 | 3: "3: BOLD ITALIC",
101 | },
102 |
103 | # Common types
104 |
105 | "color": {
106 | 1: ("32bit", "Red"),
107 | 2: ("32bit", "Green"),
108 | 3: ("32bit", "Blue"),
109 | 4: ("32bit", "Alpha"),
110 | },
111 |
112 | "object_id": {
113 | 2: ("varint", "Unsigned Integer Value"),
114 | 4: ("string", "String Value"),
115 | 6: ("varint", "Object Index"),
116 | },
117 |
118 | "dictionary": {
119 | 1: ("dictionary_element", "Dictionary Element"),
120 | },
121 |
122 | "dictionary_element": { #
123 | 1: ("object_id", "Key"),
124 | 2: ("object_id", "Value"),
125 | },
126 |
127 | "map_entry": { #
128 | 1: ("varint", "Key"),
129 | 2: ("object_id", "Value"),
130 | },
131 |
132 | "ordered_set": { #
133 | 1: ("ordered_set_ordering", "Ordering"),
134 | 2: ("dictionary", "Elements"),
135 | },
136 |
137 | "ordered_set_ordering": { #
138 | 1: ("ordered_set_ordering_array", "Array"),
139 | 2: ("dictionary", "Contents"),
140 | },
141 |
142 | "ordered_set_ordering_array": {
143 | 1: ("note", "Contents"),
144 | 2: ("ordered_set_ordering_array_attachments", "Attachments"),
145 | },
146 |
147 | "ordered_set_ordering_array_attachments": {
148 | 1: ("varint", "Index"),
149 | 2: ("bytes", "UUID"),
150 | },
151 |
152 | "register_latest": {
153 | 2: ("object_id", "Contents"),
154 | },
155 |
156 | # Mergeabledata1 blob for a ZICCLOUDSYNCINGOBJECTS.ZMERGEABLEDATA1 object
157 | "mergeabledata1_proto": { #
158 | 2: ("mergeable_data_object", "Mergeable Data Object"),
159 | },
160 |
161 | "mergeable_data_object": { #
162 | 3: ("mergeable_data_object_data", "Mergeable Data Object Data"),
163 | },
164 |
165 | "mergeable_data_object_data": { #
166 | 1: ("mergeable_data_object_stats", "Mergeable Data Object Stats"),
167 | 3: ("mergeable_data_object_entry", "Mergeable Data Object Entry"),
168 | 4: ("string", "Mergeable Data Object Key Item"),
169 | 5: ("string", "Mergeable Data Object Type Item"),
170 | 6: ("bytes", "Mergeable Data Object UUID Item"),
171 | # 7: unknown?
172 | },
173 |
174 | "enum stat_enum": { #
175 | 0: "0: ??",
176 | 1: "1: Total Mergeable Data Objects",
177 | },
178 |
179 | "mergeable_data_object_stats": { #
180 | 1: ("mergeable_data_object_stat", "Mergeable Data Object Stat"),
181 | },
182 |
183 | "mergeable_data_object_stat": { #
184 | 1: ("int32", "Stat Type"), # This may actually be an enum
185 | 2: ("int32", "Value"),
186 | },
187 |
188 | "mergeable_data_object_entry": { #
189 | 1: ("register_latest", "Register Latest"),
190 | 5: ("list", "List"),
191 | 6: ("dictionary", "Dictionary"),
192 | 9: ("mergeable_data_object_unknown_message", "Unknown Message"),
193 | 10: ("note", "Note"),
194 | 13: ("mergeable_data_object_custom_map", "Object Map"),
195 | 16: ("ordered_set", "Ordered Set"),
196 | },
197 |
198 | "mergeable_data_object_unknown_message": {
199 | 1: ("mergeable_data_object_unknown_message_entry", "Unknown Entry"),
200 | #1: ("bytes", "bytes"),
201 | #1: ("map_entry", "Unknown Entry"),
202 | },
203 |
204 | "mergeable_data_object_unknown_message_entry": {
205 | 1: ("int32", "Unknown Int"),
206 | 2: ("int64", "Unknown Int 2"),
207 | },
208 |
209 | "list": {
210 | 1: ("list_entry", "List Entry"),
211 | },
212 |
213 | "list_entry": {
214 | 2: ("object_id", "Object ID"),
215 | 3: ("list_entry_details", "List Entry Details"),
216 | 4: ("list_entry_details", "List Entry Additional Details"),
217 | },
218 |
219 | "list_entry_details": {
220 | 1: ("list_entry_details_key", "List Entry Details Key"),
221 | 2: ("object_id", "Object ID"),
222 | },
223 |
224 | "list_entry_details_key": {
225 | 1: ("varint", "list_entry_details_type_index"),
226 | 2: ("varint", "list_entry_details_key"),
227 | },
228 |
229 | "mergeable_data_object_custom_map": {
230 | 1: ("varint", "Type"),
231 | 3: ("map_entry", "Map Entry"),
232 | },
233 | }
234 |
--------------------------------------------------------------------------------
/lib/AppleNotesAccount.rb:
--------------------------------------------------------------------------------
1 | require 'fileutils'
2 | require_relative 'AppleCloudKitRecord'
3 |
4 | ##
5 | # This class represents an Apple Notes Account.
6 | # Generally this is just a local and an iCloud account.
7 | # This class has an Array of the AppleNote objects that
8 | # belong to this account.
9 | class AppleNotesAccount < AppleCloudKitRecord
10 |
11 | attr_accessor :primary_key,
12 | :name,
13 | :notes,
14 | :identifier,
15 | :user_record_name,
16 | :sort_order_name,
17 | :retain_order,
18 | :account_folder
19 |
20 | ##
21 | # This creates a new AppleNotesAccount.
22 | # It requires an Integer +primary_key+, a String +name+,
23 | # and a String +identifier+ representing the ZIDENTIFIER column.
24 | def initialize(primary_key, name, identifier)
25 | # Initialize some variables we may need later
26 | @crypto_salt = nil
27 | @crypto_iterations = nil
28 | @crypto_key = nil
29 | @password = nil
30 | @server_record_data = nil
31 |
32 | # Initialize notes and folders Arrays for this account
33 | @notes = Array.new()
34 | @folders = Array.new()
35 | @retain_order = false
36 |
37 | # Set this account's variables
38 | @primary_key = primary_key
39 | @name = name
40 |
41 | # Default html to empty until we build it
42 | @html = nil
43 |
44 | # Defaulting to the same value as the name, this can be overridden if the sort order is known
45 | @sort_order_name = name
46 | @identifier = identifier
47 | @user_record_name = ""
48 |
49 | # Figure out the Account's folder for attachments
50 | @account_folder = "Accounts/#{@identifier}/"
51 |
52 | # Uncomment the below line if you want to see the account names during creation
53 | # puts "Account #{@primary_key} is called #{@name}"
54 | end
55 |
56 | ##
57 | # This method adds the cryptographic variables to the account.
58 | # This is outside of initialize as older Apple Notes didn't have this functionality.
59 | # This requires a String of binary +crypto_salt+, an Integer of the number of +iterations+,
60 | # and a String of binary +crypto_key+. Do not feed in hex.
61 | def add_crypto_variables(crypto_salt, crypto_iterations, crypto_key)
62 | @crypto_salt = crypto_salt
63 | @crypto_iterations = crypto_iterations
64 | @crypto_key = crypto_key
65 | end
66 |
67 | ##
68 | # Returns a name with things removed that might allow for poorly placed files
69 | def clean_name
70 | @name.tr('/:\\', '_')
71 | end
72 |
73 | ##
74 | # This function takes a String +password+.
75 | # It is unclear how or if this password matters right now.
76 | def add_password(password)
77 | @password = password
78 | end
79 |
80 | ##
81 | # This method requies an AppleNote object as +note+ and adds it to the accounts's Array.
82 | def add_note(note)
83 | @notes.push(note)
84 | end
85 |
86 | ##
87 | # This method requies an AppleNotesFolder object as +folder+ and adds it to the accounts's Array.
88 | def add_folder(folder)
89 | # Remove any copy if we already have it
90 | @folders.delete_if {|old_folder| old_folder.primary_key == folder.primary_key}
91 | @folders.push(folder)
92 | end
93 |
94 | ##
95 | # This class method spits out an Array containing the CSV headers needed to describe all of these objects.
96 | def self.to_csv_headers
97 | ["Account Primary Key",
98 | "Account Name",
99 | "Account Cloudkit Identifier",
100 | "Account Identifier",
101 | "Last Modified Device",
102 | "Number of Notes",
103 | "Crypto Salt (hex)",
104 | "Crypto Iteration Count (hex)",
105 | "Crypto Key (hex)"]
106 | end
107 |
108 | ##
109 | # This method generates an Array containing the information needed for CSV generation.
110 | def to_csv
111 | [@primary_key,
112 | @name,
113 | @user_record_name,
114 | @identifier,
115 | @cloudkit_last_modified_device,
116 | @notes.length,
117 | get_crypto_salt_hex,
118 | @crypto_iterations,
119 | get_crypto_key_hex]
120 | end
121 |
122 | ##
123 | # This returns the account's cryptowrapped key, if one exists, in hex.
124 | def get_crypto_key_hex
125 | return @crypto_key if ! @crypto_key
126 | @crypto_key.unpack("H*")
127 | end
128 |
129 | ##
130 | # This returns the account's salt, if one exists, in hex.
131 | def get_crypto_salt_hex
132 | return @crypto_salt if ! @crypto_salt
133 | @crypto_salt.unpack("H*")
134 | end
135 |
136 | ##
137 | # This method returns an Array containing the AppleNotesFolders for the account, sorted in appropriate order
138 | def sorted_folders
139 | return @folders if !@retain_order
140 | @folders.sort_by{|folder| [folder.sort_order]}
141 | end
142 |
143 | ##
144 | # This method generates HTML to display on the overall output.
145 | def generate_html(individual_files: false, use_uuid: false)
146 | params = [individual_files, use_uuid]
147 | if @html && @html[params]
148 | return @html[params]
149 | end
150 |
151 | builder = Nokogiri::HTML::Builder.new(encoding: "utf-8") do |doc|
152 | doc.div {
153 | doc.h1 {
154 | doc.a(id: "account_#{@primary_key}") {
155 | doc.text @name
156 | }
157 | }
158 |
159 | if @user_record_name.length > 0
160 | doc.div {
161 | doc.b {
162 | doc.text "Cloudkit Identifier:"
163 | }
164 |
165 | doc.text " "
166 | doc.text @user_record_name
167 | }
168 | end
169 |
170 | doc.div {
171 | doc.b {
172 | doc.text "Account Identifier:"
173 | }
174 |
175 | doc.text " "
176 | doc.text @identifier
177 | }
178 |
179 | if @cloudkit_last_modified_device
180 | doc.div {
181 | doc.b {
182 | doc.text "Last Modified Device:"
183 | }
184 |
185 | doc.text " "
186 | doc.text @cloudkit_last_modified_device
187 | }
188 | end
189 |
190 | doc.div {
191 | doc.b {
192 | doc.text "Number of Notes:"
193 | }
194 |
195 | doc.text " "
196 | doc.text @notes.length
197 | }
198 |
199 | doc.div {
200 | doc.b {
201 | doc.text "Folders:"
202 | }
203 |
204 | doc.ul {
205 | sorted_folders.each do |folder|
206 | doc << folder.generate_folder_hierarchy_html(individual_files: individual_files, use_uuid: use_uuid) if !folder.is_child?
207 | end
208 | }
209 | }
210 | }
211 | end
212 |
213 | @html ||= {}
214 | @html[params] = builder.doc.root
215 | end
216 |
217 | ##
218 | # This method prepares the data structure that JSON will use to generate JSON later.
219 | def prepare_json
220 | to_return = Hash.new()
221 | to_return[:primary_key] = @primary_key
222 | to_return[:name] = @name
223 | to_return[:identifier] = @identifier
224 | to_return[:cloudkit_identifier] = @user_record_name
225 | to_return[:cloudkit_last_modified_device] = @cloudkit_last_modified_device
226 | to_return[:html] = generate_html
227 |
228 | to_return
229 | end
230 |
231 | end
232 |
--------------------------------------------------------------------------------
/proto/notestore.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto2";
2 |
3 | //
4 | // Common classes used across a few types
5 | //
6 |
7 | //Represents a color
8 | message Color {
9 | required float red = 1;
10 | required float green = 2;
11 | required float blue = 3;
12 | required float alpha = 4;
13 | }
14 |
15 | // Represents an attachment (embedded object)
16 | message AttachmentInfo {
17 | optional string attachment_identifier = 1;
18 | optional string type_uti = 2;
19 | }
20 |
21 | // Represents a font
22 | message Font {
23 | optional string font_name = 1;
24 | optional float point_size = 2;
25 | optional int32 font_hints = 3;
26 | }
27 |
28 | // Styles a "Paragraph" (any run of characters in an AttributeRun)
29 | message ParagraphStyle {
30 | optional int32 style_type = 1 [default = -1];
31 | optional int32 alignment = 2;
32 | optional int32 indent_amount = 4;
33 | optional Checklist checklist = 5;
34 | optional int32 block_quote = 8;
35 | }
36 |
37 | // Represents a checklist item
38 | message Checklist {
39 | required bytes uuid = 1;
40 | required int32 done = 2;
41 | }
42 |
43 | // Represents an object that has pointers to a key and a value, asserting
44 | // somehow that the key object has to do with the value object.
45 | message DictionaryElement {
46 | required ObjectID key = 1;
47 | required ObjectID value = 2;
48 | }
49 |
50 | // A Dictionary holds many DictionaryElements
51 | message Dictionary {
52 | repeated DictionaryElement element = 1;
53 | }
54 |
55 | // ObjectIDs are used to identify objects within the protobuf, offsets in an arry, or
56 | // a simple String.
57 | message ObjectID {
58 | required uint64 unsigned_integer_value = 2;
59 | required string string_value = 4;
60 | required int32 object_index = 6;
61 | }
62 |
63 | // Register Latest is used to identify the most recent version
64 | message RegisterLatest {
65 | required ObjectID contents = 2;
66 | }
67 |
68 | // MapEntries have a key that maps to an array of key items and a value that points to an object.
69 | message MapEntry {
70 | required int32 key = 1;
71 | required ObjectID value = 2;
72 | }
73 |
74 | // Represents a "run" of characters that need to be styled/displayed/etc
75 | message AttributeRun {
76 | required int32 length = 1;
77 | optional ParagraphStyle paragraph_style = 2;
78 | optional Font font = 3;
79 | optional int32 font_weight = 5;
80 | optional int32 underlined = 6;
81 | optional int32 strikethrough = 7;
82 | optional int32 superscript = 8; //Sign indicates super/sub
83 | optional string link = 9;
84 | optional Color color = 10;
85 | optional AttachmentInfo attachment_info = 12;
86 | optional int32 unknown_identifier = 13;
87 | optional int32 emphasis_style = 14;
88 | }
89 |
90 | //
91 | // Classes related to the overall Note protobufs
92 | //
93 |
94 | // Overarching object in a ZNOTEDATA.ZDATA blob
95 | message NoteStoreProto {
96 | required Document document = 2;
97 | }
98 |
99 | // A Document has a Note within it.
100 | message Document {
101 | required int32 version = 2;
102 | required Note note = 3;
103 | }
104 |
105 | // A Note has both text, and then a lot of formatting entries.
106 | // Other fields are present and not yet included in this proto.
107 | message Note {
108 | required string note_text = 2;
109 | repeated AttributeRun attribute_run = 5;
110 | }
111 |
112 | //
113 | // Classes related to embedded objects
114 | //
115 |
116 | // Represents the top level object in a ZMERGEABLEDATA cell
117 | message MergableDataProto {
118 | required MergableDataObject mergable_data_object = 2;
119 | }
120 |
121 | // Similar to Document for Notes, this is what holds the mergeable object
122 | message MergableDataObject {
123 | required int32 version = 2; // Asserted to be version in https://github.com/dunhamsteve/notesutils
124 | required MergeableDataObjectData mergeable_data_object_data = 3;
125 | }
126 |
127 | // This is the mergeable data object itself and has a lot of entries that are the parts of it
128 | // along with arrays of key, type, and UUID items, depending on type.
129 | message MergeableDataObjectData {
130 | repeated MergeableDataObjectEntry mergeable_data_object_entry = 3;
131 | repeated string mergeable_data_object_key_item = 4;
132 | repeated string mergeable_data_object_type_item = 5;
133 | repeated bytes mergeable_data_object_uuid_item = 6;
134 | }
135 |
136 | // Each entry is part of the pbject. For example, one entry might be identifying which
137 | // UUIDs are rows, and another might hold the text of a cell.
138 | message MergeableDataObjectEntry {
139 | required RegisterLatest register_latest = 1;
140 | optional List list = 5;
141 | optional Dictionary dictionary = 6;
142 | optional UnknownMergeableDataObjectEntryMessage unknown_message = 9;
143 | optional Note note = 10;
144 | optional MergeableDataObjectMap custom_map = 13;
145 | optional OrderedSet ordered_set = 16;
146 | }
147 |
148 | // This is unknown, it first was noticed in folder order analysis.
149 | message UnknownMergeableDataObjectEntryMessage {
150 | optional UnknownMergeableDataObjectEntryMessageEntry unknown_entry = 1;
151 | }
152 |
153 | // This is unknown, it first was noticed in folder order analysis.
154 | // "unknown_int2" is where the folder order is stored
155 | message UnknownMergeableDataObjectEntryMessageEntry {
156 | optional int32 unknown_int1 = 1;
157 | optional int64 unknown_int2 = 2;
158 | }
159 |
160 |
161 | // The Object Map uses its type to identify what you are looking at and
162 | // then a map entry to do something with that value.
163 | message MergeableDataObjectMap {
164 | required int32 type = 1;
165 | repeated MapEntry map_entry = 3;
166 | }
167 |
168 | // An ordered set is used to hold structural information for embedded tables
169 | message OrderedSet {
170 | required OrderedSetOrdering ordering = 1;
171 | required Dictionary elements = 2;
172 | }
173 |
174 |
175 | // The ordered set ordering identifies rows and columns in embedded tables, with an array
176 | // of the objects and contents that map lookup values to originals.
177 | message OrderedSetOrdering {
178 | required OrderedSetOrderingArray array = 1;
179 | required Dictionary contents = 2;
180 | }
181 |
182 | // This array holds both the text to replace and the array of UUIDs to tell what
183 | // embedded rows and columns are.
184 | message OrderedSetOrderingArray {
185 | required Note contents = 1;
186 | repeated OrderedSetOrderingArrayAttachment attachment = 2;
187 | }
188 |
189 | // This array identifies the UUIDs that are embedded table rows or columns
190 | message OrderedSetOrderingArrayAttachment {
191 | required int32 index = 1;
192 | required bytes uuid = 2;
193 | }
194 |
195 | // A List holds details about multiple objects
196 | message List {
197 | repeated ListEntry list_entry = 1;
198 | }
199 |
200 | // A list Entry holds details about a specific object
201 | message ListEntry {
202 | required ObjectID id = 2;
203 | optional ListEntryDetails details = 3; // I dislike this naming, but don't have better information
204 | required ListEntryDetails additional_details = 4;
205 | }
206 |
207 | // List Entry Details hold another object ID and unidentified mapping
208 | message ListEntryDetails {
209 | optional ListEntryDetailsKey list_entry_details_key= 1;
210 | optional ObjectID id = 2;
211 | }
212 |
213 | message ListEntryDetailsKey {
214 | required int32 list_entry_details_type_index = 1;
215 | required int32 list_entry_details_key = 2;
216 | }
217 |
--------------------------------------------------------------------------------
/lib/notestore_pb.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Generated by the protocol buffer compiler. DO NOT EDIT!
3 | # source: notestore.proto
4 |
5 | require 'google/protobuf'
6 |
7 |
8 | descriptor_data = "\n\x0fnotestore.proto\"@\n\x05\x43olor\x12\x0b\n\x03red\x18\x01 \x02(\x02\x12\r\n\x05green\x18\x02 \x02(\x02\x12\x0c\n\x04\x62lue\x18\x03 \x02(\x02\x12\r\n\x05\x61lpha\x18\x04 \x02(\x02\"A\n\x0e\x41ttachmentInfo\x12\x1d\n\x15\x61ttachment_identifier\x18\x01 \x01(\t\x12\x10\n\x08type_uti\x18\x02 \x01(\t\"A\n\x04\x46ont\x12\x11\n\tfont_name\x18\x01 \x01(\t\x12\x12\n\npoint_size\x18\x02 \x01(\x02\x12\x12\n\nfont_hints\x18\x03 \x01(\x05\"\x86\x01\n\x0eParagraphStyle\x12\x16\n\nstyle_type\x18\x01 \x01(\x05:\x02-1\x12\x11\n\talignment\x18\x02 \x01(\x05\x12\x15\n\rindent_amount\x18\x04 \x01(\x05\x12\x1d\n\tchecklist\x18\x05 \x01(\x0b\x32\n.Checklist\x12\x13\n\x0b\x62lock_quote\x18\x08 \x01(\x05\"\'\n\tChecklist\x12\x0c\n\x04uuid\x18\x01 \x02(\x0c\x12\x0c\n\x04\x64one\x18\x02 \x02(\x05\"E\n\x11\x44ictionaryElement\x12\x16\n\x03key\x18\x01 \x02(\x0b\x32\t.ObjectID\x12\x18\n\x05value\x18\x02 \x02(\x0b\x32\t.ObjectID\"1\n\nDictionary\x12#\n\x07\x65lement\x18\x01 \x03(\x0b\x32\x12.DictionaryElement\"V\n\x08ObjectID\x12\x1e\n\x16unsigned_integer_value\x18\x02 \x02(\x04\x12\x14\n\x0cstring_value\x18\x04 \x02(\t\x12\x14\n\x0cobject_index\x18\x06 \x02(\x05\"-\n\x0eRegisterLatest\x12\x1b\n\x08\x63ontents\x18\x02 \x02(\x0b\x32\t.ObjectID\"1\n\x08MapEntry\x12\x0b\n\x03key\x18\x01 \x02(\x05\x12\x18\n\x05value\x18\x02 \x02(\x0b\x32\t.ObjectID\"\xb5\x02\n\x0c\x41ttributeRun\x12\x0e\n\x06length\x18\x01 \x02(\x05\x12(\n\x0fparagraph_style\x18\x02 \x01(\x0b\x32\x0f.ParagraphStyle\x12\x13\n\x04\x66ont\x18\x03 \x01(\x0b\x32\x05.Font\x12\x13\n\x0b\x66ont_weight\x18\x05 \x01(\x05\x12\x12\n\nunderlined\x18\x06 \x01(\x05\x12\x15\n\rstrikethrough\x18\x07 \x01(\x05\x12\x13\n\x0bsuperscript\x18\x08 \x01(\x05\x12\x0c\n\x04link\x18\t \x01(\t\x12\x15\n\x05\x63olor\x18\n \x01(\x0b\x32\x06.Color\x12(\n\x0f\x61ttachment_info\x18\x0c \x01(\x0b\x32\x0f.AttachmentInfo\x12\x1a\n\x12unknown_identifier\x18\r \x01(\x05\x12\x16\n\x0e\x65mphasis_style\x18\x0e \x01(\x05\"-\n\x0eNoteStoreProto\x12\x1b\n\x08\x64ocument\x18\x02 \x02(\x0b\x32\t.Document\"0\n\x08\x44ocument\x12\x0f\n\x07version\x18\x02 \x02(\x05\x12\x13\n\x04note\x18\x03 \x02(\x0b\x32\x05.Note\"?\n\x04Note\x12\x11\n\tnote_text\x18\x02 \x02(\t\x12$\n\rattribute_run\x18\x05 \x03(\x0b\x32\r.AttributeRun\"F\n\x11MergableDataProto\x12\x31\n\x14mergable_data_object\x18\x02 \x02(\x0b\x32\x13.MergableDataObject\"c\n\x12MergableDataObject\x12\x0f\n\x07version\x18\x02 \x02(\x05\x12<\n\x1amergeable_data_object_data\x18\x03 \x02(\x0b\x32\x18.MergeableDataObjectData\"\xd3\x01\n\x17MergeableDataObjectData\x12>\n\x1bmergeable_data_object_entry\x18\x03 \x03(\x0b\x32\x19.MergeableDataObjectEntry\x12&\n\x1emergeable_data_object_key_item\x18\x04 \x03(\t\x12\'\n\x1fmergeable_data_object_type_item\x18\x05 \x03(\t\x12\'\n\x1fmergeable_data_object_uuid_item\x18\x06 \x03(\x0c\"\xa0\x02\n\x18MergeableDataObjectEntry\x12(\n\x0fregister_latest\x18\x01 \x02(\x0b\x32\x0f.RegisterLatest\x12\x13\n\x04list\x18\x05 \x01(\x0b\x32\x05.List\x12\x1f\n\ndictionary\x18\x06 \x01(\x0b\x32\x0b.Dictionary\x12@\n\x0funknown_message\x18\t \x01(\x0b\x32\'.UnknownMergeableDataObjectEntryMessage\x12\x13\n\x04note\x18\n \x01(\x0b\x32\x05.Note\x12+\n\ncustom_map\x18\r \x01(\x0b\x32\x17.MergeableDataObjectMap\x12 \n\x0bordered_set\x18\x10 \x01(\x0b\x32\x0b.OrderedSet\"m\n&UnknownMergeableDataObjectEntryMessage\x12\x43\n\runknown_entry\x18\x01 \x01(\x0b\x32,.UnknownMergeableDataObjectEntryMessageEntry\"Y\n+UnknownMergeableDataObjectEntryMessageEntry\x12\x14\n\x0cunknown_int1\x18\x01 \x01(\x05\x12\x14\n\x0cunknown_int2\x18\x02 \x01(\x03\"D\n\x16MergeableDataObjectMap\x12\x0c\n\x04type\x18\x01 \x02(\x05\x12\x1c\n\tmap_entry\x18\x03 \x03(\x0b\x32\t.MapEntry\"R\n\nOrderedSet\x12%\n\x08ordering\x18\x01 \x02(\x0b\x32\x13.OrderedSetOrdering\x12\x1d\n\x08\x65lements\x18\x02 \x02(\x0b\x32\x0b.Dictionary\"\\\n\x12OrderedSetOrdering\x12\'\n\x05\x61rray\x18\x01 \x02(\x0b\x32\x18.OrderedSetOrderingArray\x12\x1d\n\x08\x63ontents\x18\x02 \x02(\x0b\x32\x0b.Dictionary\"j\n\x17OrderedSetOrderingArray\x12\x17\n\x08\x63ontents\x18\x01 \x02(\x0b\x32\x05.Note\x12\x36\n\nattachment\x18\x02 \x03(\x0b\x32\".OrderedSetOrderingArrayAttachment\"@\n!OrderedSetOrderingArrayAttachment\x12\r\n\x05index\x18\x01 \x02(\x05\x12\x0c\n\x04uuid\x18\x02 \x02(\x0c\"&\n\x04List\x12\x1e\n\nlist_entry\x18\x01 \x03(\x0b\x32\n.ListEntry\"u\n\tListEntry\x12\x15\n\x02id\x18\x02 \x02(\x0b\x32\t.ObjectID\x12\"\n\x07\x64\x65tails\x18\x03 \x01(\x0b\x32\x11.ListEntryDetails\x12-\n\x12\x61\x64\x64itional_details\x18\x04 \x02(\x0b\x32\x11.ListEntryDetails\"_\n\x10ListEntryDetails\x12\x34\n\x16list_entry_details_key\x18\x01 \x01(\x0b\x32\x14.ListEntryDetailsKey\x12\x15\n\x02id\x18\x02 \x01(\x0b\x32\t.ObjectID\"\\\n\x13ListEntryDetailsKey\x12%\n\x1dlist_entry_details_type_index\x18\x01 \x02(\x05\x12\x1e\n\x16list_entry_details_key\x18\x02 \x02(\x05"
9 |
10 | pool = Google::Protobuf::DescriptorPool.generated_pool
11 | pool.add_serialized_file(descriptor_data)
12 |
13 | Color = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Color").msgclass
14 | AttachmentInfo = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("AttachmentInfo").msgclass
15 | Font = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Font").msgclass
16 | ParagraphStyle = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("ParagraphStyle").msgclass
17 | Checklist = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Checklist").msgclass
18 | DictionaryElement = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("DictionaryElement").msgclass
19 | Dictionary = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Dictionary").msgclass
20 | ObjectID = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("ObjectID").msgclass
21 | RegisterLatest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("RegisterLatest").msgclass
22 | MapEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MapEntry").msgclass
23 | AttributeRun = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("AttributeRun").msgclass
24 | NoteStoreProto = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("NoteStoreProto").msgclass
25 | Document = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Document").msgclass
26 | Note = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Note").msgclass
27 | MergableDataProto = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MergableDataProto").msgclass
28 | MergableDataObject = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MergableDataObject").msgclass
29 | MergeableDataObjectData = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MergeableDataObjectData").msgclass
30 | MergeableDataObjectEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MergeableDataObjectEntry").msgclass
31 | UnknownMergeableDataObjectEntryMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("UnknownMergeableDataObjectEntryMessage").msgclass
32 | UnknownMergeableDataObjectEntryMessageEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("UnknownMergeableDataObjectEntryMessageEntry").msgclass
33 | MergeableDataObjectMap = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("MergeableDataObjectMap").msgclass
34 | OrderedSet = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("OrderedSet").msgclass
35 | OrderedSetOrdering = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("OrderedSetOrdering").msgclass
36 | OrderedSetOrderingArray = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("OrderedSetOrderingArray").msgclass
37 | OrderedSetOrderingArrayAttachment = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("OrderedSetOrderingArrayAttachment").msgclass
38 | List = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("List").msgclass
39 | ListEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("ListEntry").msgclass
40 | ListEntryDetails = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("ListEntryDetails").msgclass
41 | ListEntryDetailsKey = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("ListEntryDetailsKey").msgclass
42 |
--------------------------------------------------------------------------------
/lib/AppleNotesEmbeddedGallery.rb:
--------------------------------------------------------------------------------
1 | require 'zlib'
2 | require_relative 'notestore_pb.rb'
3 | require_relative 'AppleNotesEmbeddedThumbnail.rb'
4 |
5 | ##
6 | # This class represents a com.apple.notes.gallery object embedded
7 | # in an AppleNote. This means you scanned a document in (via taking a picture).
8 | class AppleNotesEmbeddedGallery < AppleNotesEmbeddedObject
9 |
10 | ##
11 | # Creates a new AppleNotesEmbeddedGallery object.
12 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER,
13 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, AppleNote +note+ object representing the parent AppleNote, and
14 | # AppleBackup +backup+ from the parent AppleNote. Immediately finds the children picture objects and adds them.
15 | def initialize(primary_key, uuid, uti, note, backup)
16 | # Set this folder's variables
17 | super(primary_key, uuid, uti, note)
18 |
19 | # Gallery has no direct filename or path, just pointers to other pictures
20 | @filename = nil
21 | @filepath = nil
22 | @backup = backup
23 |
24 | # Add all the children
25 | add_gallery_children
26 | end
27 |
28 | ##
29 | # This method just returns a readable String for the object.
30 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is.
31 | def to_s
32 | super
33 | end
34 |
35 | ##
36 | # Uses database calls to fetch the actual child objects' ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER +uuid+.
37 | # This requires opening the protobuf inside of ZICCLOUDSYNCINGOBJECT.ZMERGEABLEDATA1 or
38 | # ZICCLOUDSYNCINGOBJECT.ZMERGEABLEDATA column (if older than iOS13)
39 | # and returning the referenced ZIDENTIFIER in that object.
40 | def add_gallery_children
41 |
42 | gzipped_data = nil
43 |
44 | # If this Gallery is password protected, fetch the mergeable data from the
45 | # ZICCLOUDSYNCINGOBJECT.ZENCRYPTEDVALUESJSON column and decrypt it.
46 | if @is_password_protected
47 | unapplied_encrypted_record_column = "ZUNAPPLIEDENCRYPTEDRECORD"
48 | unapplied_encrypted_record_column = unapplied_encrypted_record_column + "DATA" if @version >= AppleNoteStoreVersion::IOS_VERSION_18
49 |
50 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.ZENCRYPTEDVALUESJSON, ZICCLOUDSYNCINGOBJECT.#{unapplied_encrypted_record_column} " +
51 | "FROM ZICCLOUDSYNCINGOBJECT " +
52 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
53 | @uuid) do |row|
54 |
55 | encrypted_values = row["ZENCRYPTEDVALUESJSON"]
56 |
57 | if row[unapplied_encrypted_record_column]
58 | keyed_archive = KeyedArchive.new(:data => row[unapplied_encrypted_record_column])
59 | unpacked_top = keyed_archive.unpacked_top()
60 | ns_keys = unpacked_top["root"]["ValueStore"]["RecordValues"]["NS.keys"]
61 | ns_values = unpacked_top["root"]["ValueStore"]["RecordValues"]["NS.objects"]
62 | encrypted_values = ns_values[ns_keys.index("EncryptedValues")]
63 | end
64 |
65 | decrypt_result = @backup.decrypter.decrypt_with_password(@crypto_password,
66 | @crypto_salt,
67 | @crypto_iterations,
68 | @crypto_key,
69 | @crypto_iv,
70 | @crypto_tag,
71 | encrypted_values,
72 | "AppleNotesEmbeddedGallery #{@uuid}")
73 | parsed_json = JSON.parse(decrypt_result[:plaintext])
74 | gzipped_data = Base64.decode64(parsed_json["mergeableData"])
75 | end
76 |
77 | # Otherwise, pull from the ZICCLOUDSYNCINGOBJECT.ZMERGEABLEDATA column
78 | else
79 | # Set the appropriate column to find the data in
80 | mergeable_column = "ZMERGEABLEDATA1"
81 | mergeable_column = "ZMERGEABLEDATA" if @note.version < AppleNoteStoreVersion::IOS_VERSION_13
82 |
83 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.#{mergeable_column} " +
84 | "FROM ZICCLOUDSYNCINGOBJECT " +
85 | "WHERE ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER=?",
86 | @uuid) do |row|
87 |
88 | # Extract the blob
89 | gzipped_data = row[mergeable_column]
90 |
91 | end
92 | end
93 |
94 | # Inflate the GZip if it exists, deleted objects won't
95 | if gzipped_data
96 | zlib_inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
97 | gunzipped_data = zlib_inflater.inflate(gzipped_data)
98 |
99 | tmp_order = Hash.new
100 | tmp_current_uuid = ''
101 | tmp_current_order = ''
102 |
103 | # Read the protobuff
104 | mergabledata_proto = MergableDataProto.decode(gunzipped_data)
105 |
106 | # Loop over the entries to pull out the UUIDs for each child, as well as their ordering information
107 | mergabledata_proto.mergable_data_object.mergeable_data_object_data.mergeable_data_object_entry.each do |mergeable_data_object_entry|
108 |
109 | # This section holds an obvious UUID that matches the ZIDENTIFIER column
110 | if mergeable_data_object_entry.custom_map
111 | tmp_current_uuid = mergeable_data_object_entry.custom_map.map_entry.first.value.string_value
112 | end
113 |
114 | # This section holds what appears to be ordering information
115 | if mergeable_data_object_entry.unknown_message
116 | tmp_current_order = mergeable_data_object_entry.unknown_message.unknown_entry.unknown_int2
117 | end
118 |
119 | # If we ever have both the UUID and order, set them in the hash and clear them
120 | if tmp_current_order != '' and tmp_current_uuid != ''
121 | tmp_order[tmp_current_order] = tmp_current_uuid
122 | tmp_current_uuid = ''
123 | tmp_current_order = ''
124 | end
125 |
126 | end
127 |
128 | # Loop over the Hash to put the images into the right order
129 | tmp_order.keys.sort.each do |key|
130 | create_child_from_uuid(tmp_order[key])
131 | end
132 |
133 | end
134 | nil
135 | end
136 |
137 | ##
138 | # This method takes a String +uuid+ and looks up the necessary information in
139 | # ZICCLOUDSYNCINGOBJECTs to make a new child object of the appropriate type.
140 | def create_child_from_uuid(uuid)
141 | @database.execute("SELECT ZICCLOUDSYNCINGOBJECT.Z_PK, ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, " +
142 | "ZICCLOUDSYNCINGOBJECT.ZTYPEUTI " +
143 | "FROM ZICCLOUDSYNCINGOBJECT " +
144 | "WHERE ZIDENTIFIER=?", uuid) do |row|
145 | @logger.debug("Creating gallery child from #{row["Z_PK"]}: #{uuid}")
146 | tmp_child = AppleNotesEmbeddedPublicJpeg.new(row["Z_PK"],
147 | row["ZIDENTIFIER"],
148 | row["ZTYPEUTI"],
149 | @note,
150 | @backup,
151 | self)
152 | tmp_child.search_and_add_thumbnails # This will cause it to regenerate the thumbnail array knowing that this is the parent
153 | add_child(tmp_child)
154 | end
155 | end
156 |
157 | ##
158 | # This method generates the HTML necessary to display the image inline.
159 | def generate_html(individual_files=false)
160 | builder = Nokogiri::HTML::Builder.new(encoding: "utf-8") do |doc|
161 | doc.div {
162 | @child_objects.each do |child_object|
163 | doc << child_object.generate_html(individual_files)
164 | end
165 | }
166 | end
167 |
168 | return builder.doc.root
169 | end
170 |
171 | end
172 |
--------------------------------------------------------------------------------
/spec/utilities/apple_decrypter.rb:
--------------------------------------------------------------------------------
1 | gem 'openssl'
2 | require 'openssl'
3 |
4 | require_relative '../../lib/AppleBackup.rb'
5 | require_relative '../../lib/AppleDecrypter.rb'
6 |
7 | TEST_PASSWORD_DIR = TEST_DATA_DIR + "password_examples"
8 | TEST_PASSWORD_FILE = TEST_PASSWORD_DIR + "multiple_passwords"
9 |
10 | describe AppleDecrypter do
11 |
12 | before(:context) do
13 | TEST_OUTPUT_DIR.mkpath
14 | end
15 | after(:context) do
16 | TEST_OUTPUT_DIR.rmtree
17 | end
18 |
19 | before(:all) do
20 | @backup = AppleBackup.new(Pathname.new(""), 0, TEST_OUTPUT_DIR)
21 | @decrypter = AppleDecrypter.new()
22 | @decrypter.add_passwords_from_file(TEST_PASSWORD_FILE)
23 |
24 | @test_password = "password"
25 | @test_salt = "\x11\x65\x10\x6b\x6b\x28\x8b\xda\x1e\x6e\xcb\x18\xe6\x5c\x78\x76".force_encoding("US-ASCII")
26 | @test_bad_salt = "\x11\x65\x10\x6b\x6b\x28\x8b\xda\x1e\x6e\xcb\x18\xe6\x5c\x78\x75".force_encoding("US-ASCII")
27 | @test_iterations = 20000
28 | @test_key_encrypting_key = "\x65\x2b\xe8\x61\x43\x34\x8a\x6e\x01\x06\x09\x58\x80\xbc\xf3\x1b".force_encoding("US-ASCII")
29 | @test_bad_key_encrypting_key = "\xC5\x15\xE6\x55\x61\xDD\xE6\x9D\x9E\xB9\xC3\xF8\x4A\x22\x56\xA0".force_encoding("US-ASCII")
30 | @test_wrapped_key = "\x98\xc0\xe5\x6b\x43\xb5\x07\xe6\x0c\x54\x65\xec\x5e\x1b\xb0\xc7\x4b\x75\x6f\x7d\x4f\x4a\x9b\xff".force_encoding("US-ASCII")
31 | @test_bad_wrapped_key = "\x98\xc0\xe5\x6b\x43\xb5\x07\xe6\x0c\x54\x65\xec\x5e\x1b\xb0\xc7\x4b\x75\x6f\x7d\x4f\x4a\x9c\xff".force_encoding("US-ASCII")
32 | @test_unwrapped_key = "\x02\x3a\xae\x7c\x45\x0a\x28\x3b\x23\xe3\xd7\xc1\x41\x6a\xd6\x44".force_encoding("US-ASCII")
33 | @test_iv = "\x15\x1f\x64\xde\x7b\xe3\x4d\x15\xda\xcd\xae\xa9\xb3\x34\x71\xf9".force_encoding("US-ASCII")
34 | @test_tag = "\x80\x6b\xf2\xbb\xd3\xbf\x83\xcf\x12\x40\xb0\x3e\x7c\x4d\x6a\xb1".force_encoding("US-ASCII")
35 | @test_encrypted_blob = ("\x13\x1b\x03\x57\x1f\xc9\xec\x47\xef\x58\xe5\x8e\x21\xfc\xe5\xc1" +
36 | "\x0a\xa7\x3a\x62\xb9\xe5\x8a\x74\x3b\xcd\xcc\x3a\xff\x1e\xa8\xab" +
37 | "\x99\x64\xf4\x53\x5b\x85\x97\x73\x5f\x3d\xa5\xf6\xae\x63\xb9\x37" +
38 | "\x06\x25\xa2\x0d\x63\x3e\x9c\xf2\x98\x6d\x4d\x11\x89\x89\x12\x4f" +
39 | "\x0d\xdf\xee\x95\x6e\x47\xcb\x5c\xbc\x36\x17\xc5\x20\xb0\x75\x62" +
40 | "\x0b\x37\xae\x40\x56\xf3\xa1\xaf\x83\x35\x1f\xda\x63\x4d\xfb\x44" +
41 | "\x60\x55\xc7\x5f\x71\x43\xa5\x60\x01\x49\xdb\x33\x38\x93\xc0\xec" +
42 | "\xb0\xef\x39\x44\xe2\xa6\x45\x42\xe9\xa4\x37\x5b\xf1\x52\x68\x98" +
43 | "\x58\xfe\xd8\xb2\x1a\xde\xd0\xea\xb0\xaf\xb1\x11\x90").force_encoding("US-ASCII")
44 | @test_decrypted_blob = ("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x13\xe3\x60\x10\x9a" +
45 | "\xc1\xc8\xc1\x20\xc0\x20\x35\x91\x51\x48\xde\x35\x2f\xb9" +
46 | "\xa8\xb2\xa0\x24\x35\x45\xa1\x24\xb3\x24\x27\x95\x8b\x0b" +
47 | "\x21\x90\x94\x9f\x52\x29\x25\xc0\xc5\x02\x52\x0b\x54\x0d" +
48 | "\xa6\x35\x18\xc1\x22\x8c\x40\x11\x79\x29\x30\xad\xc1\x24" +
49 | "\x25\xc6\xc5\x01\x94\xfb\x0f\x04\xfc\x40\x75\x70\xb6\x92" +
50 | "\x0c\x97\x14\x97\xc0\xbb\x7f\x02\xb7\xa2\x2a\x9d\x55\x3b" +
51 | "\x76\xe5\x9e\x7a\xf4\x72\xfb\x1b\x21\x26\x0e\x79\x20\x66" +
52 | "\xd4\xe2\xe0\x10\x10\x02\x9a\x29\xc1\xa8\x05\xe2\xb1\x71" +
53 | "\xf0\x09\x31\x49\x30\x02\x00\xd1\x69\x5a\x2d\x9d\x00\x00\x00").force_encoding("US-ASCII")
54 |
55 | #AES 256 CBC Test Vectors
56 | # https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Algorithm-Validation-Program/documents/aes/AESAVS.pdf
57 | @aes_266_key = Array.new(40, 0x00)
58 | #@aes_256_iv =
59 | end
60 |
61 | #let!(:decrypter) {AppleDecrypter.new(AppleBackup.new(Pathname.new(""), 0, TEST_OUTPUT_DIR))}
62 |
63 | #let(:password_file) { "password\nroot\nsuper_secret" }
64 | #before { allow(File).to receive(:readlines).with("passwords") { StringIO.new(password_file) }}
65 |
66 | context "passwords" do
67 | it "loads passwords from a file" do
68 | tmp_decrypter = AppleDecrypter.new()
69 | expect(tmp_decrypter.add_passwords_from_file(TEST_PASSWORD_FILE)).to be 3
70 | end
71 |
72 | it "doesn't truncate passwords" do
73 | expect(@decrypter.instance_variable_get(:@passwords)[0]).to eql("password")
74 | expect(@decrypter.instance_variable_get(:@passwords)[1]).to eql("root")
75 | expect(@decrypter.instance_variable_get(:@passwords)[2]).to eql("super_secret")
76 | end
77 |
78 | it "handles passwords with right to left languages well" do
79 | tmp_decrypter = AppleDecrypter.new()
80 | expect(tmp_decrypter.add_passwords_from_file(TEST_PASSWORD_DIR + "right_to_left_password")).to be 1
81 | expect(tmp_decrypter.instance_variable_get(:@passwords)[0].bytes).to eql([217, 131, 217, 132, 217, 133, 216, 169, 32, 216, 167, 217, 132, 217, 133, 216, 177, 217, 136, 216, 177])
82 | end
83 |
84 | it "handles passwords with wide characters well" do
85 | tmp_decrypter = AppleDecrypter.new()
86 | expect(tmp_decrypter.add_passwords_from_file(TEST_PASSWORD_DIR + "wide_character_password")).to be 1
87 | expect(tmp_decrypter.instance_variable_get(:@passwords)[0].bytes).to eql([229, 175, 134, 231, 160, 129])
88 | end
89 |
90 | # Please, please, please, do not ever use emojis in passwords. You really never know what character codes
91 | # your device of choice is going to use.
92 | it "handles passwords with emojis well" do
93 | tmp_decrypter = AppleDecrypter.new()
94 | expect(tmp_decrypter.add_passwords_from_file(TEST_PASSWORD_DIR + "emoji_password")).to be 1
95 | expect(tmp_decrypter.instance_variable_get(:@passwords)[0].bytes).to eql([226, 140, 155, 239, 184, 142, 226, 157, 164, 239, 184, 142, 226, 156, 146, 239, 184, 142])
96 | end
97 |
98 | it "doesn't split password at spaces" do
99 | tmp_decrypter = AppleDecrypter.new()
100 | expect(tmp_decrypter.add_passwords_from_file(TEST_PASSWORD_DIR + "spaces_in_password")).to be 1
101 | expect(tmp_decrypter.instance_variable_get(:@passwords)[0].length).to be 23
102 | end
103 | end
104 |
105 | context "encryption functions" do
106 |
107 | it "properly generates a key encrypting key" do
108 | expect(@decrypter.generate_key_encrypting_key(@test_password, @test_salt, @test_iterations).force_encoding("US-ASCII")).to eql(@test_key_encrypting_key)
109 | end
110 |
111 | it "properly unwraps a wrapped key" do
112 | expect(@decrypter.aes_key_unwrap(@test_wrapped_key, @test_key_encrypting_key).force_encoding("US-ASCII")).to eql(@test_unwrapped_key)
113 | end
114 |
115 | it "properly returns nil when unable to unwrap a key" do
116 | expect(@decrypter.aes_key_unwrap(@test_wrapped_key, @test_bad_key_encrypting_key)).to eql(nil)
117 | end
118 |
119 | it "properly identifies good cryptographic settings as good" do
120 | expect(@decrypter.check_cryptographic_settings(@test_password, @test_salt, @test_iterations, @test_wrapped_key)).to be true
121 | end
122 |
123 | it "properly identifies bad cryptographic settings (salt) as bad" do
124 | expect(@decrypter.check_cryptographic_settings(@test_password, @test_bad_salt, @test_iterations, @test_wrapped_key)).to be false
125 | end
126 |
127 | it "properly identifies bad cryptographic settings (iterations) as bad" do
128 | expect(@decrypter.check_cryptographic_settings(@test_password, @test_salt, @test_iterations - 1, @test_wrapped_key)).to be false
129 | end
130 |
131 | it "properly identifies bad cryptographic settings (key) as bad" do
132 | expect(@decrypter.check_cryptographic_settings(@test_password, @test_salt, @test_iterations, @test_bad_wrapped_key)).to be false
133 | end
134 |
135 | it "properly uses an unwrapped key to decrypt a blob" do
136 | expect(@decrypter.aes_gcm_decrypt(@test_unwrapped_key, @test_iv, @test_tag, @test_encrypted_blob).force_encoding("US-ASCII")).to eql(@test_decrypted_blob)
137 | end
138 |
139 | it "returns false if it does not decrypt" do
140 | expect(@decrypter.decrypt_with_password(@test_password + "fake", @test_salt, @test_iterations, @test_wrapped_key, @test_iv, @test_tag, @test_encrypted_blob)).to be false
141 | end
142 |
143 | it "returns a hash if it successfully decrypts" do
144 | results = @decrypter.decrypt_with_password(@test_password, @test_salt, @test_iterations, @test_wrapped_key, @test_iv, @test_tag, @test_encrypted_blob)
145 | expect(results).to be_a Hash
146 | expect(results[:plaintext].force_encoding("US-ASCII")).to eql(@test_decrypted_blob)
147 | expect(results[:password]).to eql(@test_password)
148 | end
149 | end
150 | end
151 |
--------------------------------------------------------------------------------