├── spec ├── data │ ├── password_examples │ │ ├── emoji_password │ │ ├── wide_character_password │ │ ├── right_to_left_password │ │ ├── spaces_in_password │ │ └── multiple_passwords │ ├── exported_blobs │ │ ├── html_gzipped.bin │ │ ├── url_gzipped.bin │ │ ├── table_gzipped.bin │ │ ├── ZSERVERRECORDDATA.bin │ │ ├── ZSERVERSHAREDATA.bin │ │ ├── block_quotes_gzipped.bin │ │ ├── list_indents_gzipped.bin │ │ ├── simple_note_protobuf.bin │ │ ├── table_formats_gzipped.bin │ │ ├── color_formatting_gzipped.bin │ │ ├── text_decorations_gzipped.bin │ │ ├── wide_characters_gzipped.bin │ │ ├── emoji_formatting_1_gzipped.bin │ │ ├── emoji_formatting_2_gzipped.bin │ │ ├── emoji_formatting_3_gzipped.bin │ │ ├── right_to_left_table_gzipped.bin │ │ └── simple_note_protobuf_gzipped.bin │ └── README.md ├── utilities │ ├── utilities.rb │ ├── apple_uniform_type_identifier.rb │ └── apple_decrypter.rb ├── embedded_objects │ ├── embedded_objects.rb │ ├── tables.rb │ └── thumbnail.rb ├── backup │ ├── backup.rb │ ├── apple_backup.rb │ ├── apple_backup_file.rb │ ├── apple_backup_physical.rb │ ├── apple_backup_hashed.rb │ └── apple_backup_mac.rb ├── base_classes │ ├── base_classes.rb │ ├── apple_notes_smart_folder.rb │ ├── apple_notes_version.rb │ ├── apple_cloud_kit_record.rb │ └── apple_notes_account.rb ├── integration │ └── integration.rb └── spec.rb ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── ztypeuti-addition.md │ └── bug_report.md └── workflows │ ├── ruby.yml │ └── docker_publish.yml ├── .travis.yml ├── dockerfiles ├── Dockerfile-3.0 ├── Dockerfile-3.1 ├── Dockerfile-3.2 ├── Dockerfile-3.3 ├── Dockerfile-3.4 ├── Dockerfile-Test-3.0 ├── Dockerfile-Test-3.1 ├── Dockerfile-Test-3.2 ├── Dockerfile-Test-3.3 └── Dockerfile-Test-3.4 ├── docker_scripts ├── run_tests.sh ├── linux_run_file.sh ├── mac_run_file.sh ├── build_all.sh ├── build_tests.sh ├── mac_run_notes.sh └── mac_run_itunes.sh ├── Dockerfile ├── Rakefile ├── Gemfile ├── .gitignore ├── LICENSE ├── lib ├── AppleNotesEmbeddedInlineHashtag.rb ├── AppleNotesEmbeddedDeletedObject.rb ├── AppleNotesEmbeddedInlineCalculateResult.rb ├── AppleNotesEmbeddedInlineCalculateGraphExpression.rb ├── AppleNotesEmbeddedInlineLink.rb ├── AppleNoteStoreVersion.rb ├── AppleStoredFileResult.rb ├── AppleNotesSmartFolder.rb ├── AppleNotesEmbeddedInlineMention.rb ├── AppleCloudKitShareParticipant.rb ├── AppleBackupFile.rb ├── AppleBackupMac.rb ├── AppleNotesEmbeddedPDF.rb ├── AppleNotesEmbeddedDocument.rb ├── AppleNotesEmbeddedPublicVCard.rb ├── AppleNotesEmbeddedCalendar.rb ├── AppleNotesEmbeddedPublicObject.rb ├── AppleNotesEmbeddedInlineAttachment.rb ├── AppleNotesEmbeddedPublicVideo.rb ├── AppleNotesEmbeddedPublicAudio.rb ├── AppleBackupPhysical.rb ├── AppleCloudKitRecord.rb ├── AppleNotesEmbeddedPublicURL.rb ├── AppleUniformTypeIdentifier.rb ├── AppleNotesEmbeddedDrawing.rb ├── AppleNotesEmbeddedPaperDocScan.rb ├── AppleBackupHashedManifestPlist.rb ├── AppleNotesAccount.rb ├── notestore_pb.rb └── AppleNotesEmbeddedGallery.rb ├── FolderStructure.md ├── Install.md ├── JSON.md └── proto ├── protobuf_config.py └── notestore.proto /spec/data/password_examples/emoji_password: -------------------------------------------------------------------------------- 1 | ⌛︎❤︎✒︎ 2 | -------------------------------------------------------------------------------- /spec/data/password_examples/wide_character_password: -------------------------------------------------------------------------------- 1 | 密码 2 | -------------------------------------------------------------------------------- /spec/data/password_examples/right_to_left_password: -------------------------------------------------------------------------------- 1 | كلمة المرور 2 | -------------------------------------------------------------------------------- /spec/data/password_examples/spaces_in_password: -------------------------------------------------------------------------------- 1 | numera e le iloa e sesi 2 | -------------------------------------------------------------------------------- /spec/data/password_examples/multiple_passwords: -------------------------------------------------------------------------------- 1 | password 2 | root 3 | super_secret 4 | -------------------------------------------------------------------------------- /spec/utilities/utilities.rb: -------------------------------------------------------------------------------- 1 | require_relative 'apple_decrypter.rb' 2 | require_relative 'apple_uniform_type_identifier.rb' 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [threeplanetssoftware] 4 | ko_fi: threeplanetssoftware 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | os: 4 | - linux 5 | - osx 6 | 7 | rvm: 8 | - "3.0" 9 | - "3.1" 10 | - "3.2" 11 | - "3.3" 12 | -------------------------------------------------------------------------------- /spec/embedded_objects/embedded_objects.rb: -------------------------------------------------------------------------------- 1 | require_relative 'inline_objects.rb' 2 | require_relative 'tables.rb' 3 | require_relative 'thumbnail.rb' 4 | -------------------------------------------------------------------------------- /spec/data/exported_blobs/html_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/html_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/url_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/url_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/table_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/table_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/ZSERVERRECORDDATA.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/ZSERVERRECORDDATA.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/ZSERVERSHAREDATA.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/ZSERVERSHAREDATA.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/block_quotes_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/block_quotes_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/list_indents_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/list_indents_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/simple_note_protobuf.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/simple_note_protobuf.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/table_formats_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/table_formats_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/color_formatting_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/color_formatting_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/text_decorations_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/text_decorations_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/wide_characters_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/wide_characters_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/emoji_formatting_1_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/emoji_formatting_1_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/emoji_formatting_2_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/emoji_formatting_2_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/emoji_formatting_3_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/emoji_formatting_3_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/right_to_left_table_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/right_to_left_table_gzipped.bin -------------------------------------------------------------------------------- /spec/data/exported_blobs/simple_note_protobuf_gzipped.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threeplanetssoftware/apple_cloud_notes_parser/HEAD/spec/data/exported_blobs/simple_note_protobuf_gzipped.bin -------------------------------------------------------------------------------- /spec/backup/backup.rb: -------------------------------------------------------------------------------- 1 | require_relative 'apple_backup.rb' 2 | require_relative 'apple_backup_file.rb' 3 | require_relative 'apple_backup_hashed.rb' 4 | require_relative 'apple_backup_mac.rb' 5 | require_relative 'apple_backup_physical.rb' 6 | -------------------------------------------------------------------------------- /spec/base_classes/base_classes.rb: -------------------------------------------------------------------------------- 1 | require_relative './apple_cloud_kit_record.rb' 2 | require_relative './apple_note.rb' 3 | require_relative './apple_notes_account.rb' 4 | require_relative './apple_notes_folder.rb' 5 | require_relative './apple_notes_smart_folder.rb' 6 | require_relative './apple_notes_version.rb' 7 | require_relative './proto_patches.rb' 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ztypeuti-addition.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ZTYPEUTI Addition 3 | about: Use this template to request the addition of a new ZTYPEUTI. Commonly used 4 | if you received a message like "[UTI] is unrecognized ZTYPEUTI..." 5 | title: "[UTI to add] is Unrecognized ZTYPEUTI" 6 | labels: '' 7 | assignees: '' 8 | 9 | --- 10 | 11 | **ZTYPEUTI to add**: [such as "com.apple.thingy"] 12 | 13 | **Anticipated type of file**: [Such as "image", "document", etc] 14 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-3.0: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-3.1: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-3.2: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-3.3: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-3.4: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-Test-3.0: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | COPY spec/ /app/spec 13 | COPY Rakefile /app 14 | ENTRYPOINT ["rake", "test_all"] 15 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-Test-3.1: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | COPY spec/ /app/spec 13 | COPY Rakefile /app 14 | ENTRYPOINT ["rake", "test_all"] 15 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-Test-3.2: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | COPY spec/ /app/spec 13 | COPY Rakefile /app 14 | ENTRYPOINT ["rake", "test_all"] 15 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-Test-3.3: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | COPY spec/ /app/spec 13 | COPY Rakefile /app 14 | ENTRYPOINT ["rake", "test_all"] 15 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile-Test-3.4: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4-slim 2 | 3 | WORKDIR /app 4 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 5 | COPY lib/ /app/lib 6 | RUN apt update && \ 7 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 8 | bundle config set force_ruby_platform true && \ 9 | bundle install && \ 10 | apt-get remove -y build-essential pkg-config && \ 11 | apt autoremove -y 12 | COPY spec/ /app/spec 13 | COPY Rakefile /app 14 | ENTRYPOINT ["rake", "test_all"] 15 | -------------------------------------------------------------------------------- /docker_scripts/run_tests.sh: -------------------------------------------------------------------------------- 1 | for VERSION in "3.0" "3.1" "3.2" "3.3" "3.4" 2 | do 3 | IMAGE_NAME="apple_notes_cloud_parser:test-"$VERSION 4 | CONTAINER_NAME="apple_cloud_notes_parser-test" 5 | 6 | echo "\n\n###############################\nUsing Docker to test notes_cloud_parser.rb on Ruby "$VERSION"\n###############################\n\n" 7 | 8 | docker run --rm --name \ 9 | $CONTAINER_NAME \ 10 | --volume "$(pwd):/data:ro" \ 11 | --volume "$(pwd)/output:/app/output" \ 12 | $IMAGE_NAME 13 | done 14 | -------------------------------------------------------------------------------- /docker_scripts/linux_run_file.sh: -------------------------------------------------------------------------------- 1 | 2 | COMMAND="-f /data/NoteStore.sqlite" 3 | COMMAND="$COMMAND --one-output-folder" 4 | CONTAINER_NAME="apple_cloud_notes_parser" 5 | IMAGE_NAME="ghcr.io/threeplanetssoftware/apple_cloud_notes_parser" 6 | #IMAGE_NAME="apple_cloud_notes_parser" 7 | 8 | echo "Using Docker to run ruby notes_cloud_parser.rb $COMMAND" 9 | 10 | docker run --rm \ 11 | --name $CONTAINER_NAME \ 12 | --volume "$(pwd):/data:ro" \ 13 | --volume "$(pwd)/output:/app/output" \ 14 | $IMAGE_NAME \ 15 | $COMMAND 16 | -------------------------------------------------------------------------------- /docker_scripts/mac_run_file.sh: -------------------------------------------------------------------------------- 1 | 2 | #IMAGE_NAME="apple_cloud_notes_parser" 3 | IMAGE_NAME="ghcr.io/threeplanetssoftware/apple_cloud_notes_parser" 4 | CONTAINER_NAME="apple_cloud_notes_parser" 5 | COMMAND="-f /data/NoteStore.sqlite" 6 | COMMAND="$COMMAND --one-output-folder" 7 | 8 | echo "Using Docker to run ruby notes_cloud_parser.rb $COMMAND" 9 | 10 | docker run --rm --name \ 11 | $CONTAINER_NAME \ 12 | --volume "$(pwd):/data:ro" \ 13 | --volume "$(pwd)/output:/app/output" \ 14 | $IMAGE_NAME \ 15 | $COMMAND 16 | -------------------------------------------------------------------------------- /docker_scripts/build_all.sh: -------------------------------------------------------------------------------- 1 | # Ruby 3.4 2 | docker build -f dockerfiles/Dockerfile-3.4 -t apple_notes_cloud_parser:ruby-3.4 . 3 | 4 | # Ruby 3.3 5 | docker build -f dockerfiles/Dockerfile-3.3 -t apple_notes_cloud_parser:ruby-3.3 . 6 | 7 | # Ruby 3.2 8 | docker build -f dockerfiles/Dockerfile-3.2 -t apple_notes_cloud_parser:ruby-3.2 . 9 | 10 | # Ruby 3.1 11 | docker build -f dockerfiles/Dockerfile-3.1 -t apple_notes_cloud_parser:ruby-3.1 . 12 | 13 | # Ruby 3.0 14 | docker build -f dockerfiles/Dockerfile-3.0 -t apple_notes_cloud_parser:ruby-3.0 . 15 | -------------------------------------------------------------------------------- /docker_scripts/build_tests.sh: -------------------------------------------------------------------------------- 1 | # Ruby 3.4 2 | docker build -f dockerfiles/Dockerfile-Test-3.4 -t apple_notes_cloud_parser:test-3.4 . 3 | 4 | # Ruby 3.3 5 | docker build -f dockerfiles/Dockerfile-Test-3.3 -t apple_notes_cloud_parser:test-3.3 . 6 | 7 | # Ruby 3.2 8 | docker build -f dockerfiles/Dockerfile-Test-3.2 -t apple_notes_cloud_parser:test-3.2 . 9 | 10 | # Ruby 3.1 11 | docker build -f dockerfiles/Dockerfile-Test-3.1 -t apple_notes_cloud_parser:test-3.1 . 12 | 13 | # Ruby 3.0 14 | docker build -f dockerfiles/Dockerfile-Test-3.0 -t apple_notes_cloud_parser:test-3.0 . 15 | -------------------------------------------------------------------------------- /docker_scripts/mac_run_notes.sh: -------------------------------------------------------------------------------- 1 | 2 | IMAGE_NAME="ghcr.io/threeplanetssoftware/apple_cloud_notes_parser" 3 | CONTAINER_NAME="apple_cloud_notes_parser" 4 | COMMAND="--mac /data" 5 | COMMAND="$COMMAND --one-output-folder" 6 | NOTES="/Users/$(whoami)/Library/Group Containers/group.com.apple.notes" 7 | TMP_FOLDER="$(pwd)/.tmp_notes_input" 8 | 9 | echo "Using Docker to run ruby notes_cloud_parser.rb $COMMAND" 10 | 11 | echo "Creating temporary storage: $TMP_FOLDER" 12 | mkdir -p "$TMP_FOLDER" 13 | cp -r "$NOTES"/* "$TMP_FOLDER" 14 | docker run --rm \ 15 | --name $CONTAINER_NAME \ 16 | --volume "$TMP_FOLDER:/data:ro" \ 17 | --volume "$(pwd)/output:/app/output" \ 18 | $IMAGE_NAME \ 19 | $COMMAND 20 | 21 | echo "Removing temporary storage: $TMP_FOLDER" 22 | rm -rf "$TMP_FOLDER" 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-slim 2 | 3 | LABEL org.opencontainers.image.source=https://github.com/threeplanetssoftware/apple_cloud_notes_parser 4 | LABEL org.opencontainers.image.description="This program is a parser for the current version of Apple Notes data syncable with iCloud as seen on Apple handsets in iOS 9 and later." 5 | LABEL org.opencontainers.image.licenses=MIT 6 | 7 | WORKDIR /app 8 | COPY Gemfile LICENSE notes_cloud_ripper.rb ./ 9 | COPY lib/ /app/lib 10 | RUN apt update && \ 11 | apt-get install -y build-essential pkg-config libsqlite3-dev zlib1g-dev libssl-dev && \ 12 | bundle config set force_ruby_platform true && \ 13 | bundle install && \ 14 | apt-get remove -y build-essential pkg-config && \ 15 | apt autoremove -y 16 | ENTRYPOINT ["ruby", "notes_cloud_ripper.rb"] 17 | -------------------------------------------------------------------------------- /spec/backup/apple_backup.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleBackup.rb' 2 | 3 | describe AppleBackup, :expensive => true do 4 | before(:context) do 5 | TEST_OUTPUT_DIR.mkpath 6 | end 7 | after(:context) do 8 | TEST_OUTPUT_DIR.rmtree 9 | end 10 | 11 | let(:backup) { AppleBackup.new(TEST_DATA_DIR, 0, TEST_OUTPUT_DIR) } 12 | 13 | context "validations" do 14 | it "raises an error rather than failing validation" do 15 | expect{backup.valid?}.to raise_error("AppleBackup cannot stand on its own") 16 | end 17 | 18 | end 19 | 20 | context "files" do 21 | it "raises an error since it has no real file path" do 22 | expect{backup.get_real_file_path("test.tmp")}.to raise_error("Cannot return file_path for AppleBackup") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/base_classes/apple_notes_smart_folder.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleNotesAccount.rb' 2 | require_relative '../../lib/AppleNotesSmartFolder.rb' 3 | require 'securerandom' 4 | 5 | describe AppleNotesSmartFolder do 6 | 7 | let(:tmp_account) { AppleNotesAccount.new(1, "Account Name", SecureRandom.uuid) } 8 | let(:tmp_query) { "{\"entity\":\"note\",\"type\":{\"and\":[{\"deleted\":false},{\"and\":[{\"mention\":true}]}]}}" } 9 | let(:tmp_folder) { AppleNotesSmartFolder.new(1, "SmartFolder Name", tmp_account, tmp_query) } 10 | 11 | context "output" do 12 | it "includes the query in JSON" do 13 | expect(tmp_folder.prepare_json()[:query]).to eql(tmp_query) 14 | end 15 | 16 | it "includes the query in CSV" do 17 | expect(tmp_folder.to_csv[AppleNotesFolder.csv_smart_folder_query_column]).to eql(tmp_query) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core' 2 | 3 | # By default, execute "run" 4 | task default: %w[run] 5 | 6 | # By default "run" is the same as the old script's default option, 7 | # which looks for NoteStore.sqlite in this folder and executes. 8 | task :run do 9 | ruby "notes_cloud_ripper.rb --file NoteStore.sqlite" 10 | end 11 | 12 | # rake help will display the help message 13 | task :help do 14 | ruby "notes_cloud_ripper.rb --help" 15 | end 16 | 17 | # "rake clean" will delete the output folder 18 | task :clean do 19 | FileUtils.rm_rf('output') 20 | end 21 | 22 | task :test do 23 | RSpec::Core::Runner.run(["spec/spec.rb", "--tag", "~expensive"]) 24 | end 25 | 26 | task :test_expensive do 27 | RSpec::Core::Runner.run(["spec/spec.rb", "--tag", "expensive"]) 28 | end 29 | 30 | task :test_all do 31 | RSpec::Core::Runner.run(["spec/spec.rb", "--tag", "~missing_data"]) 32 | end 33 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Gems owned by Ruby directly 4 | gem 'cgi',"~> 0.3" # Ruby official 5 | gem 'csv', "~> 3.3" # Ruby Official 6 | gem 'fileutils', "~> 1.4" # Ruby official 7 | gem 'openssl', ">= 3.2" # Ruby official 8 | gem 'rake', "~> 13.2" # Ruby official 9 | gem 'zlib', "~> 1.1" # Ruby official 10 | 11 | # Gems owned by Google 12 | gem 'google-protobuf', ">= 4.26" # Google official 13 | 14 | # Gems owned by some "organization" on GitHub 15 | gem 'rspec' # Owned by rspec 16 | gem 'sqlite3', ">= 1.4" # Owned by SparkleMotion 17 | 18 | # Gems with multiple possible maintainers 19 | gem 'nokogiri', ">= 1.14" # Multiple maintainers 20 | gem 'keyed_archive', "~> 1.0" # Owned by me and one other 21 | 22 | # Gems with just one maintainer 23 | gem 'CFPropertyList', ">= 3.0.7" # Single owner: @ckruse 24 | gem 'aes_key_wrap', "~> 1.1" # Single owner: @tomdalling 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.blob 2 | *.blob.gz 3 | *~ 4 | *swp 5 | 6 | *.gem 7 | *.rbc 8 | /.config 9 | /coverage/ 10 | /InstalledFiles 11 | /pkg/ 12 | /spec/reports/ 13 | /spec/examples.txt 14 | /test/tmp/ 15 | /test/version_tmp/ 16 | /tmp/ 17 | *.lock 18 | *.swp 19 | /output/ 20 | .rake_tasks* 21 | 22 | ## Database files 23 | *.sqlite 24 | *.db 25 | *.csv 26 | *-shm 27 | *-wal 28 | *-wal2 29 | 30 | ## Stuff I shouldn't index 31 | passwords.txt 32 | spec/data/itunes_backup 33 | spec/data/itunes_backup_no_account 34 | spec/data/physical_backup 35 | spec/data/physical_backup_no_account 36 | spec/data/mac_backup 37 | spec/data/mac_backup_old 38 | spec/data/mac_backup_no_account 39 | 40 | ## Documentation cache and generated files: 41 | /.yardoc/ 42 | /_yardoc/ 43 | /doc/ 44 | /rdoc/ 45 | 46 | ## Environment normalization: 47 | /.bundle/ 48 | /vendor/bundle 49 | /lib/bundler/man/ 50 | 51 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 52 | .rvmrc 53 | -------------------------------------------------------------------------------- /docker_scripts/mac_run_itunes.sh: -------------------------------------------------------------------------------- 1 | 2 | #IMAGE_NAME="apple_cloud_notes_parser" 3 | IMAGE_NAME="ghcr.io/threeplanetssoftware/apple_cloud_notes_parser" 4 | CONTAINER_NAME="apple_cloud_notes_parser" 5 | COMMAND="--itunes /data" 6 | ITUNES="/Users/$(whoami)/Library/Application Support/MobileSync/Backup" 7 | TMP_FOLDER="$(pwd)/.tmp_notes_input" 8 | 9 | echo "Using Docker to run ruby notes_cloud_parser.rb $COMMAND" 10 | echo "NOTE: Because there may be multiple backups, this will NOT use the '--one-output-folder' option." 11 | 12 | echo "Creating temporary storage: $TMP_FOLDER" 13 | mkdir -p "$TMP_FOLDER" 14 | for itunes_backup in "$ITUNES"/*/; do 15 | echo "Copying $itunes_backup to temporary storage: $TMP_FOLDER" 16 | cp -r "$itunes_backup"/* "$TMP_FOLDER" 17 | docker run --rm \ 18 | --name $CONTAINER_NAME \ 19 | --volume "$TMP_FOLDER:/data:ro" \ 20 | --volume "$(pwd)/output:/app/output" \ 21 | $IMAGE_NAME \ 22 | $COMMAND 23 | rm -rf "$TMP_FOLDER"/* 24 | done 25 | 26 | rm -rf "$TMP_FOLDER" 27 | -------------------------------------------------------------------------------- /spec/utilities/apple_uniform_type_identifier.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleUniformTypeIdentifier.rb' 2 | 3 | describe AppleUniformTypeIdentifier do 4 | 5 | context "types" do 6 | it "refuses to recognize anything that is not a String" do 7 | tmp_uti = AppleUniformTypeIdentifier.new(Array.new()) 8 | expect(tmp_uti.bad_uti?).to be true 9 | end 10 | 11 | it "identifies the UTI if it doesn't know what it is" do 12 | tmp_uti = AppleUniformTypeIdentifier.new("thisisamadeuputi") 13 | expect(tmp_uti.get_conforms_to_string).to eql("uti: thisisamadeuputi") 14 | end 15 | 16 | it "recognizes 'public' UTIs" do 17 | tmp_uti = AppleUniformTypeIdentifier.new("public.thisisamadeuputi") 18 | expect(tmp_uti.get_conforms_to_string).to eql("other public") 19 | expect(tmp_uti.is_public?).to be true 20 | end 21 | 22 | it "recognizes dynamic UTIs" do 23 | tmp_uti = AppleUniformTypeIdentifier.new("dyn.thisisamadeuputi") 24 | expect(tmp_uti.get_conforms_to_string).to eql("dynamic") 25 | expect(tmp_uti.is_dynamic?).to be true 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Three Planets Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedInlineHashtag.rb: -------------------------------------------------------------------------------- 1 | require_relative 'AppleNotesEmbeddedInlineAttachment' 2 | 3 | ## 4 | # This class represents an inline hashtag enhancement embedded in an AppleNote. 5 | # These were added in iOS 15. 6 | class AppleNotesEmbeddedInlineHashtag < AppleNotesEmbeddedInlineAttachment 7 | 8 | ## 9 | # Creates a new AppleNotesEmbeddedInlineHashtag. 10 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 11 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote, 12 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from 13 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the text stands for. 14 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier) 15 | super(primary_key, uuid, uti, note, alt_text, token_identifier) 16 | end 17 | 18 | ## 19 | # This method just returns the hashtag's text, which is found in alt_text. 20 | def to_s 21 | return "" if !@alt_text 22 | @alt_text 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedDeletedObject.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents an embedded object which was part of a deleted 3 | # AppleNote. Apple does not delete the note text, just the objects from the 4 | # ZICCLOUDSYNCINGOBJECTS table. 5 | class AppleNotesEmbeddedDeletedObject < AppleNotesEmbeddedObject 6 | 7 | attr_accessor :primary_key, 8 | :uuid, 9 | :type, 10 | :url 11 | 12 | ## 13 | # Creates a new AppleNotesEmbeddedDeletedObject object. 14 | # Expects a +uuid+ that would have been the ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 15 | # String +uti+ that would have been the ZICCLOUDSYNCINGOBJECT.ZTYPEUTI, and an AppleNote +note+ object representing the parent AppleNote. 16 | def initialize(uuid, uti, note) 17 | # Set this object's variables 18 | super("Deleted", uuid, uti, note) 19 | end 20 | 21 | ## 22 | # This method just returns a readable String for the object. 23 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 24 | def to_s 25 | return "{Deleted embedded #{@type} object which had ZICCLOUDSYNCINGOBJECTS.ZIDENTIFIER: #{uuid}}" 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 2 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 3 | 4 | name: Ruby 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest] 18 | ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4'] 19 | 20 | runs-on: ${{ matrix.os }} 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Ruby 25 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 26 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 27 | uses: ruby/setup-ruby@v1 28 | # uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0 29 | with: 30 | ruby-version: ${{ matrix.ruby-version }} 31 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 32 | - name: Run tests 33 | run: bundle exec rspec spec/spec.rb 34 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedInlineCalculateResult.rb: -------------------------------------------------------------------------------- 1 | require_relative 'AppleNotesEmbeddedInlineAttachment' 2 | 3 | ## 4 | # This class represents an inline interactive math result embedded in an AppleNote. 5 | # These were added in iOS 18. 6 | class AppleNotesEmbeddedInlineCalculateResult < AppleNotesEmbeddedInlineAttachment 7 | 8 | ## 9 | # Creates a new AppleNotesEmbeddedInlineCalculateResult. 10 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 11 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote, 12 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from 13 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the result stands for. 14 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier) 15 | super(primary_key, uuid, uti, note, alt_text, token_identifier) 16 | end 17 | 18 | ## 19 | # This method just returns the calculation result's text, which is found in alt_text. 20 | def to_s 21 | return "" if !@alt_text 22 | @alt_text 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedInlineCalculateGraphExpression.rb: -------------------------------------------------------------------------------- 1 | require_relative 'AppleNotesEmbeddedInlineAttachment' 2 | 3 | ## 4 | # This class represents an inline interactive math graph embedded in an AppleNote. 5 | # These were added in iOS 18. 6 | class AppleNotesEmbeddedInlineCalculateGraphExpression < AppleNotesEmbeddedInlineAttachment 7 | 8 | ## 9 | # Creates a new AppleNotesEmbeddedInlineCalculateGraphExpression. 10 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 11 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote, 12 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from 13 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the result stands for. 14 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier) 15 | super(primary_key, uuid, uti, note, alt_text, token_identifier) 16 | end 17 | 18 | ## 19 | # This method just returns the graph equation's variable, which is found in alt_text. 20 | def to_s 21 | return "" if !@alt_text 22 | @alt_text 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Windows] 28 | - Version [e.g. 11] 29 | - Ruby Version [e.g. 3.2] 30 | 31 | **Smartphone Source (please complete the following information, if applicable):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS 16.0] 34 | - Type of backup: [e.g. iTunes] 35 | 36 | **Command used** 37 | [Please provide the command used to execute this program, e.g. `ruby notes_cloud_parser.rb -g -r -f NoteStore.sqlite`] 38 | 39 | **Please confirm the following** 40 | - Error occurs on the latest version of this program on GitHub [Y/N] 41 | - You have run `bundle install` [Y/N] 42 | 43 | **Additional context** 44 | Add any other context about the problem here. 45 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedInlineLink.rb: -------------------------------------------------------------------------------- 1 | require 'keyed_archive' 2 | require 'sqlite3' 3 | require_relative 'AppleCloudKitRecord' 4 | 5 | ## 6 | # This class represents an inline link pointing to another note, or the like. 7 | # These were added in iOS 17 and allow users to directly link to other notes from the GUI. 8 | class AppleNotesEmbeddedInlineLink < AppleNotesEmbeddedInlineAttachment 9 | 10 | ## 11 | # Creates a new AppleNotesEmbeddedInlineLink. 12 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 13 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote, 14 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from 15 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the text stands for. 16 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier) 17 | super(primary_key, uuid, uti, note, alt_text, token_identifier) 18 | end 19 | 20 | ## 21 | # This method just returns a readable String for the object. 22 | def to_s 23 | return "#{@alt_text} [#{@token_identifier}]" 24 | end 25 | 26 | ## 27 | # This method generates the HTML to be embedded into an AppleNote's HTML. 28 | def generate_html(individual_files=false) 29 | return self.to_s 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /lib/AppleNoteStoreVersion.rb: -------------------------------------------------------------------------------- 1 | class AppleNoteStoreVersion include Comparable 2 | 3 | attr_accessor :version_number, 4 | :platform 5 | 6 | VERSION_PLATFORM_IOS = 1 7 | VERSION_PLATFORM_MAC = 2 8 | 9 | IOS_VERSION_26 = 26 10 | IOS_VERSION_18 = 18 11 | IOS_VERSION_17 = 17 12 | IOS_VERSION_16 = 16 13 | IOS_VERSION_15 = 15 14 | IOS_VERSION_14 = 14 15 | IOS_VERSION_13 = 13 16 | IOS_VERSION_12 = 12 17 | IOS_VERSION_11 = 11 18 | IOS_VERSION_10 = 10 19 | IOS_VERSION_9 = 9 20 | IOS_LEGACY_VERSION = 8 21 | IOS_VERSION_UNKNOWN = -1 22 | 23 | def initialize(version_number=-1, platform=VERSION_PLATFORM_IOS) 24 | @platform = platform 25 | @version_number = version_number 26 | end 27 | 28 | def <=>(other) 29 | return @version_number <=> other if other.is_a? Integer 30 | return @version_number <=> other.version_number if other.is_a? AppleNoteStoreVersion 31 | return nil 32 | end 33 | 34 | def legacy? 35 | return @version_number == IOS_LEGACY_VERSION 36 | end 37 | 38 | def modern? 39 | return @version_number > IOS_LEGACY_VERSION 40 | end 41 | 42 | def unknown? 43 | return @version_number < 0 44 | end 45 | 46 | def same_platform(other) 47 | return @platform == other.platform 48 | end 49 | 50 | def to_s 51 | to_return = "#{@version_number}" 52 | to_return += " on Mac" if @platform == VERSION_PLATFORM_MAC 53 | return to_return 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/AppleStoredFileResult.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class is a helper to represent the filepaths on the phone and on disk for 3 | # files that have been backed up. 4 | class AppleStoredFileResult 5 | 6 | attr_accessor :original_filepath, 7 | :original_filename, 8 | :storage_filepath 9 | 10 | ## 11 | # Creates a new AppleStoredFileResilt. 12 | # Requires nothing and initiailizes some variables 13 | def initialize() 14 | original_filepath = nil # The filepath, as it was originally stored on the phone or Mac 15 | original_filename = nil # The filename, as it was originally stored on the phone or Mac 16 | storage_filepath = nil # The filepath of the file on disk in the backup 17 | end 18 | 19 | ## 20 | # Helper function that returns true only if this has 21 | # the paths needed for success 22 | def has_paths? 23 | return (original_filepath and original_filename and storage_filepath) 24 | end 25 | 26 | ## 27 | # Helper function that returns true only if the original filepath exists 28 | def exist? 29 | return storage_filepath.exist? 30 | end 31 | 32 | ## 33 | # Because of the common typoe 34 | def exists? 35 | return self.exist? 36 | end 37 | 38 | ## 39 | # To make it in line with legacy calls to variable names 40 | def backup_location 41 | return storage_filepath 42 | end 43 | 44 | ## 45 | # To make it in line with legacy calls to variable names 46 | def filepath 47 | return original_filepath 48 | end 49 | 50 | ## 51 | # To make it in line with legacy calls to variable names 52 | def filename 53 | return original_filename 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/AppleNotesSmartFolder.rb: -------------------------------------------------------------------------------- 1 | require_relative 'AppleNotesFolder.rb' 2 | 3 | ## 4 | # This class represents a smart folder within Apple Notes. 5 | # It subclasses AppleNotesFolder and is mainly used to represent 6 | # the difference in output since a smart folder doesn't have 7 | # any AppleNotes within it directly. 8 | class AppleNotesSmartFolder < AppleNotesFolder 9 | 10 | attr_accessor :query 11 | 12 | ## 13 | # Creates a new AppleNotesSmartFolder. 14 | # Requires the folder's +primary_key+ as an Integer, +name+ as a String, 15 | # +account+ as an AppleNotesAccount, and +query+ as a String representing 16 | # how this folder selects notes to display. 17 | def initialize(folder_primary_key, folder_name, folder_account, query) 18 | super(folder_primary_key, folder_name, folder_account) 19 | 20 | @query = query 21 | end 22 | 23 | ## 24 | # This method generates an Array containing the information needed for CSV generation 25 | def to_csv 26 | # Get the parent's CSV and overwrite the query field 27 | to_return = super() 28 | to_return[AppleNotesFolder.csv_smart_folder_query_column] = @query 29 | 30 | return to_return 31 | end 32 | 33 | def generate_html(individual_files: false, use_uuid: false) 34 | builder = Nokogiri::HTML::Builder.new(encoding: "utf-8") do |doc| 35 | doc.div { 36 | doc.h1 { 37 | doc.a(id: "folder_#{unique_id(use_uuid)}") { 38 | doc.text "#{@account.name} - #{full_name}" 39 | } 40 | } 41 | doc.div { 42 | doc.text "A smart folder looking for notes matching: " 43 | doc.code { 44 | doc.text query 45 | } 46 | } 47 | } 48 | end 49 | 50 | return builder.doc.children 51 | end 52 | 53 | def prepare_json 54 | to_return = super() 55 | to_return[:query] = @query 56 | 57 | to_return 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /spec/data/README.md: -------------------------------------------------------------------------------- 1 | 2 | This folder contains a combination of actual test data and symlinks to data that cannot be checked into the repository. 3 | Any tests that require files which cannot be distributed have corresponding globals defined and will be filtered out of the run with the `missing_data` filter applied. 4 | The files that are needed and their corresponding globals are: 5 | 6 | |File name|Purpose|Symlink?|Global| 7 | |---------|-------|--------|------| 8 | |NoteStore-tests.sqlite|Valid NoteStore file with some odd parsing examples hard coded into the fields.|Y|`TEST_FORMATTING_FILE`| 9 | |notta-NoteStore.sqlite|Valid SQLite file that isn't a NoteStore.|N|`TEST_FALSE_SQLITE_FILE`|` 10 | |itunes_backup|Folder containing an iTunes backup|Y|`TEST_ITUNES_DIR`| 11 | |itunes_backup_no_account|Folder containing an iTunes backup without an Accounts folder|Y|`TEST_ITUNES_NO_ACCOUNT_DIR`| 12 | |mac_backup|Folder containing a Mac backup of group.com.apple.notes|Y|`TEST_MAC_DIR`| 13 | |mac_backup_no_account|Folder containing a Mac backup of group.com.apple.notes without an Accounts folder|Y|`TEST_MAC_NO_ACCOUNT_DIR`| 14 | |physical_backup|Folder containing the contents of a physical backup|Y|`TEST_PHYSICAL_DIR`| 15 | |physical_backup_no_account|Folder containing the contents of a physical backup without an Accounts folder|Y|`TEST_PHYSICAL_NO_ACCOUNT_DIR`| 16 | |NoteStore.legacy.sqlite|Valid NoteStore file from a legacy iOS version|Y|| 17 | |NoteStore.11.sqlite|Valid NoteStore file from iOS 11|Y|| 18 | |NoteStore.12.sqlite|Valid NoteStore file from iOS 12|Y|| 19 | |NoteStore.13.sqlite|Valid NoteStore file from iOS 13|Y|| 20 | |NoteStore.14.sqlite|Valid NoteStore file from iOS 14|Y|| 21 | |NoteStore.15.sqlite|Valid NoteStore file from iOS 15|Y|| 22 | |NoteStore.16.sqlite|Valid NoteStore file from iOS 16|Y|| 23 | |NoteStore.17.sqlite|Valid NoteStore file from iOS 17|Y|| 24 | |NoteStore.18.sqlite|Valid NoteStore file from iOS 18|Y|| 25 | |NoteStore.26.sqlite|Valid NoteStore file from iOS 26|Y|| 26 | 27 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedInlineMention.rb: -------------------------------------------------------------------------------- 1 | require 'keyed_archive' 2 | require 'sqlite3' 3 | require_relative 'AppleCloudKitRecord' 4 | 5 | ## 6 | # This class represents an inline text mention embedded in an AppleNote. 7 | # These were added in iOS 15 and allow users with shared notes to '@' other users that the note is shared with. 8 | class AppleNotesEmbeddedInlineMention < AppleNotesEmbeddedInlineAttachment 9 | 10 | attr_accessor :target_account 11 | 12 | ## 13 | # Creates a new AppleNotesEmbeddedInlineMention. 14 | # Expects an Integer +primary_key+ from ZICCLOUDSYNCINGOBJECT.Z_PK, String +uuid+ from ZICCLOUDSYNCINGOBJECT.ZIDENTIFIER, 15 | # String +uti+ from ZICCLOUDSYNCINGOBJECT.ZTYPEUTI1, AppleNote +note+ object representing the parent AppleNote, 16 | # a String +alt_text+ from ZICCLOUDSYNCINGOBJECT.ZALTTEXT, and a String +token_identifier+ from 17 | # ZICCLOUDSYNCINGOBJECT.ZTOKENCONTENTIDENTIFIER representing what the text stands for. 18 | def initialize(primary_key, uuid, uti, note, alt_text, token_identifier) 19 | super(primary_key, uuid, uti, note, alt_text, token_identifier) 20 | 21 | @target_account = nil 22 | @target_account = @note.notestore.cloud_kit_participants[@token_identifier] 23 | 24 | # Fall back to just displaying a local account, this generally appears as __default_owner__ 25 | @target_account = @note.notestore.get_account_by_user_record_name(@token_identifier) if !@target_account 26 | end 27 | 28 | ## 29 | # This method just returns a readable String for the object. 30 | # By default it just lists the +alt_text+. Subclasses 31 | # should override this. 32 | def to_s 33 | return "#{@alt_text} [#{@target_account.email}]" if @target_account and @target_account.is_a?(AppleCloudKitShareParticipant) and @target_account.email 34 | return "#{@alt_text} [Local Account: #{@target_account.name}]" if @target_account and @target_account.is_a?(AppleNotesAccount) and @target_account.name 35 | return "#{@alt_text} [#{@token_identifier}]" 36 | end 37 | 38 | ## 39 | # This method generates the HTML to be embedded into an AppleNote's HTML. 40 | def generate_html(individual_files=false) 41 | return self.to_s 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /lib/AppleCloudKitShareParticipant.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents a member of the CKShareParticipant class, 3 | # which is used to track who is a participant on items shared with 4 | # Apple's Cloud Kit. 5 | class AppleCloudKitShareParticipant 6 | 7 | attr_accessor :email, 8 | :first_name, 9 | :last_name, 10 | :middle_name, 11 | :nickname, 12 | :name_prefix, 13 | :name_suffix, 14 | :name_phonetic, 15 | :phone, 16 | :record_id 17 | ## 18 | # Creates a new AppleCloudKitShareParticipant. 19 | def initialize() 20 | @email = nil 21 | @first_name = nil 22 | @last_name = nil 23 | @middle_name = nil 24 | @nickname = nil 25 | @name_prefix = nil 26 | @name_suffix = nil 27 | @name_phonetic = nil 28 | @phone = nil 29 | @record_id = nil 30 | end 31 | 32 | ## 33 | # Compares to AppleCloudKitParticipant objects, based on their +record_id+. 34 | def ==(other_participant) 35 | return (other_participant.is_a? AppleCloudKitShareParticipant and other_participant.record_id == @record_id) 36 | end 37 | 38 | ## 39 | # This class method spits out an Array containing the CSV headers needed to describe all of these objects 40 | def self.to_csv_headers 41 | ["Account Record ID", "Account Email", "Account Phone", "Prefix", "First Name", "Middle Name", "Last Name", "Suffix", "Phonetic"] 42 | end 43 | 44 | ## 45 | # This method generates an Array containing the information necessary to build a CSV 46 | def to_csv 47 | [@record_id, @email, @phone, @name_prefix, @first_name, @middle_name, @last_name, @name_suffix, @name_phonetic] 48 | end 49 | 50 | ## 51 | # This method prepares the data structure that JSON will use to generate JSON later. 52 | def prepare_json 53 | to_return = Hash.new() 54 | to_return[:email] = @email 55 | to_return[:record_id] = @record_id 56 | to_return[:first_name] = @first_name 57 | to_return[:last_name] = @last_name 58 | to_return[:middle_name] = @middle_name 59 | to_return[:name_prefix] = @name_prefix 60 | to_return[:name_suffix] = @name_suffix 61 | to_return[:name_phonetic] = @name_phonetic 62 | to_return[:phone] = @phone 63 | 64 | to_return 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /spec/integration/integration.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleNoteStore.rb' 2 | require_relative '../../lib/AppleNote.rb' 3 | require_relative '../../lib/AppleNotesFolder.rb' 4 | require_relative '../../lib/AppleNotesAccount.rb' 5 | 6 | describe AppleBackupHashed, :expensive => true, :missing_data => !TEST_ITUNES_DIR_EXIST do 7 | 8 | before(:context) do 9 | TEST_OUTPUT_DIR.mkpath 10 | @tmp_backup = AppleBackupHashed.new(TEST_ITUNES_DIR, TEST_OUTPUT_DIR) 11 | @tmp_notestore = @tmp_backup.note_stores[0] 12 | end 13 | 14 | after(:context) do 15 | TEST_OUTPUT_DIR.rmtree 16 | end 17 | 18 | context "full iTunes integration test" do 19 | it "is a valid backup" do 20 | expect(@tmp_backup).to be_valid 21 | end 22 | 23 | it "is a valid backup" do 24 | expect(@tmp_backup).to be_valid 25 | end 26 | 27 | it "has two notestores" do 28 | expect(@tmp_backup.note_stores.length).to be 2 29 | end 30 | 31 | it "Copied NoteStore.sqlite to the output folder" do 32 | notestore_location = TEST_OUTPUT_DIR + "NoteStore.sqlite" 33 | expect(notestore_location.exist?).to be true 34 | end 35 | 36 | it "Copied NoteStore.sqlite to the output folder" do 37 | notestore_location = TEST_OUTPUT_DIR + "NoteStore.sqlite" 38 | expect(notestore_location.exist?).to be true 39 | expect(@tmp_backup.is_sqlite?(notestore_location)).to be true 40 | end 41 | 42 | it "successfully rips all the notes" do 43 | expect{@tmp_backup.rip_notes}.not_to raise_exception 44 | end 45 | 46 | it "doesn't have any empty tags in the html", :skip => "fix this one" do 47 | puts "Checking note html" 48 | expect(@tmp_backup.note_stores.first.valid_notes?).to be true 49 | expect(@tmp_backup.rip_notes).to be 5 50 | #@tmp_backup.rip_notes 51 | #expect(@tmp_notestore.notes.first.title).to be "asdasdasd" 52 | #aggregate_failures "checking note HTML" do 53 | #@tmp_notestore.notes.each do |note| 54 | #puts note.note_id 55 | #TEST_HTML_GENERATION_OPTIONS.each do |option| 56 | #html = note.generate_html(individual_files: option[0], use_uuid: option[1]).html 57 | #html = "" 58 | #expect(html).not_to include "" 59 | #expect(html).not_to include "" 60 | #expect(html).not_to include "" 61 | #expect(html).not_to include "" 62 | #end 63 | #end 64 | #end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /FolderStructure.md: -------------------------------------------------------------------------------- 1 | # Folder Structure 2 | 3 | For reference, the structure of this program is as follows: 4 | 5 | ``` 6 | apple_cloud_notes_parser 7 | | 8 | |-docker_scripts 9 | | | 10 | | |-build_all.sh: A shell script to build all relevant versions of the Docker container. 11 | | |-linux_run_file.sh: Execute the docker version on NoteStore.sqlite in the present working directory 12 | | |-mac_run_file.sh: Execute the docker version on NoteStore.sqlite in the present working directory 13 | | |-mac_run_itunes.sh: Execute the docker version on each of the local Mac user's mobile backups 14 | | |-mac_run_notes.sh: Execute the docker version on the local Mac user's Notes folder 15 | | 16 | |-lib 17 | | | 18 | | |-notestore_pb.rb: Protobuf representation generated with protoc 19 | | |-Apple\*.rb: Ruby classes dealing with various aspects of Notes 20 | | 21 | |-output (created after run) 22 | | | 23 | | |-[folders for each date/time run] 24 | | | 25 | | |-csv: This folder holds the CSV output 26 | | |-debug_log.txt: A more verbose log to assist with debugging 27 | | |-files: This folder holds files copied out of the backup, such as pictures 28 | | |-html: This folder holds the generated HTML copy of the Notestore 29 | | |-json: This folder holds the generated JSON summary of the Notestore 30 | | |-Manifest.db: If run on an iTunes backup, this is a copy of the Manifest.db 31 | | |-NoteStore.sqlite: If run on a modern version, this copy of the target file will include plaintext versions of the Notes 32 | | |-notes.sqlite: If run on a legacy version, this copy is just a copy for ease of use 33 | | 34 | |-spec 35 | | | 36 | | |-backup: Test specs related to Backup classes. 37 | | |-base_classes: Test specs related to foundational classes like AppleNote. 38 | | |-data: Folder container test data. See [this file](spec/data/README.md) for the folder structure if you want to add data. 39 | | |-embedded_objects: Test specs related to EmbeddedObjects. This needs the most work. 40 | | |-integration: Test specs related to the full program running. 41 | | |-output: Test specs related to how output should appear. WARNING, this may change as things are stablized. 42 | | |-spec.rb: The base RSpec file. 43 | | |-utilities: Test specs related to utility classes like AppleDecrypter. 44 | | 45 | |-.gitignore 46 | |-.travis.yml 47 | |-Dockerfile: The Dockerfile for Github's Docker registry 48 | |-Gemfile 49 | |-LICENSE 50 | |-README.md 51 | |-Rakefile 52 | |-notes_cloud_ripper.rb: The main program itself 53 | ``` 54 | -------------------------------------------------------------------------------- /lib/AppleBackupFile.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 reading a single NoteStore.sqlite file. 9 | # This class will abstract away figuring out how to get the right media files to embed back into an AppleNote. 10 | class AppleBackupFile < AppleBackup 11 | 12 | ## 13 | # Creates a new AppleBackupFile. Expects a Pathname +root_folder+ that represents the root 14 | # of the backup and a Pathname +output_folder+ which will hold the results of this run. 15 | # Immediately sets the NoteStore database file. 16 | def initialize(root_folder, output_folder, decrypter=AppleDecrypter.new) 17 | 18 | super(root_folder, AppleBackup::SINGLE_FILE_BACKUP_TYPE, output_folder, decrypter) 19 | 20 | # Check to make sure we're all good 21 | if self.valid? 22 | puts "Created a new AppleBackup from single file: #{@root_folder}" 23 | 24 | # Copy the database to a temporary spot to fingerprint 25 | copy_notes_database(@root_folder, @note_store_temporary_location) 26 | 27 | # Ensure this looks like a NoteStore database 28 | if !has_correct_columns?(@note_store_temporary_location) 29 | @logger.error("The alleged backup file doesn't have the right columns for a NoteStore database.") 30 | return 31 | end 32 | 33 | # Fingerprint it 34 | note_version = AppleNoteStore.guess_ios_version(@note_store_temporary_location) 35 | 36 | # Move that to the right name, based on the version 37 | note_store_new_location = @note_store_modern_location if note_version.modern? 38 | note_store_new_location = @note_store_legacy_location if note_version.legacy? 39 | 40 | # Rename the file to be the right database 41 | FileUtils.mv(@note_store_temporary_location, note_store_new_location) 42 | 43 | # Create the AppleNoteStore object 44 | create_and_add_notestore(@note_store_modern_location, note_version) 45 | end 46 | end 47 | 48 | ## 49 | # This method returns true if it is a value backup of the specified type. For the SINGLE_FILE_BACKUP_TYPE this means 50 | # that the +root_folder+ given is the NoteStore.sqlite directly. 51 | def valid? 52 | return (@root_folder.file? and is_sqlite?(@root_folder)) 53 | end 54 | 55 | ## 56 | # This method returns a Pathname that represents the location on this disk of the requested file or nil. 57 | # It expects a String +filename+ to look up. For single file backups, this will always be nil. 58 | def get_real_file_path(filename) 59 | return nil 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /spec/backup/apple_backup_file.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleBackupFile.rb' 2 | 3 | describe AppleBackupFile, :expensive => true do 4 | before(:context) do 5 | TEST_OUTPUT_DIR.mkpath 6 | end 7 | after(:context) do 8 | TEST_OUTPUT_DIR.rmtree 9 | end 10 | 11 | let(:valid_backup) { AppleBackupFile.new(TEST_FORMATTING_FILE, TEST_OUTPUT_DIR) } 12 | 13 | context "validations" do 14 | it "validates a NoteStore.sqlite file", :missing_data => !TEST_FORMATTING_FILE_EXIST do 15 | expect(valid_backup.valid?).to be true 16 | end 17 | 18 | xit "fails to validate a non-NoteStore sqlite file", :missing_data => !TEST_FALSE_SQLITE_FILE_EXIST do 19 | backup = AppleBackupFile.new(TEST_FALSE_SQLITE_FILE, TEST_OUTPUT_DIR) 20 | expect(backup.valid?).to be false 21 | end 22 | 23 | it "fails to validate a non-sqlite file", :missing_data => !TEST_README_FILE_EXIST do 24 | backup = AppleBackupFile.new(TEST_README_FILE, TEST_OUTPUT_DIR) 25 | expect(backup.valid?).to be false 26 | end 27 | 28 | it "fails to validate an itunes backup folder", :missing_data => !TEST_ITUNES_DIR_EXIST do 29 | backup = AppleBackupFile.new(TEST_ITUNES_DIR, TEST_OUTPUT_DIR) 30 | expect(backup.valid?).to be false 31 | end 32 | 33 | it "fails to validate a physical backup folder", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 34 | backup = AppleBackupFile.new(TEST_PHYSICAL_DIR, TEST_OUTPUT_DIR) 35 | expect(backup.valid?).to be false 36 | end 37 | 38 | it "fails to validate a mac backup folder", :missing_data => !TEST_MAC_DIR_EXIST do 39 | backup = AppleBackupFile.new(TEST_MAC_DIR, TEST_OUTPUT_DIR) 40 | expect(backup.valid?).to be false 41 | end 42 | end 43 | 44 | context "versions" do 45 | 46 | it "correctly identifies all major versions" do 47 | # To do: acquire iOS 11 sample for here 48 | TEST_FILE_VERSIONS.each_pair do |version, version_file| 49 | backup = AppleBackupFile.new(version_file, TEST_OUTPUT_DIR) 50 | expect(backup.note_stores[0].version.version_number).to be version 51 | end 52 | end 53 | end 54 | 55 | context "files", :missing_data => !TEST_FORMATTING_FILE_EXIST do 56 | it "does not try to assert where a file is" do 57 | backup = AppleBackupFile.new(TEST_FORMATTING_FILE, TEST_OUTPUT_DIR) 58 | expect(valid_backup.get_real_file_path("NoteStore.sqlite")).to be nil 59 | end 60 | end 61 | 62 | context "note stores", :missing_data => !TEST_FORMATTING_FILE_EXIST do 63 | it "knows how to find just a modern note store" do 64 | expect(valid_backup.note_stores.length).to be 1 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/base_classes/apple_notes_version.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleNoteStoreVersion.rb' 2 | 3 | describe AppleNoteStoreVersion do 4 | 5 | let(:high_version) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_17, AppleNoteStoreVersion::VERSION_PLATFORM_IOS)} 6 | let(:low_version) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_12, AppleNoteStoreVersion::VERSION_PLATFORM_IOS)} 7 | let(:mac_version) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_17, AppleNoteStoreVersion::VERSION_PLATFORM_MAC)} 8 | let(:legacy_version) {AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_LEGACY_VERSION, AppleNoteStoreVersion::VERSION_PLATFORM_IOS)} 9 | 10 | context "creation" do 11 | 12 | it "defaults the version to a negative value if the version isn't given" do 13 | expect(AppleNoteStoreVersion.new.version_number).to be < 0 14 | end 15 | 16 | it "defaults the platform to iOS if it isn't given" do 17 | expect(AppleNoteStoreVersion.new.platform).to be == AppleNoteStoreVersion::VERSION_PLATFORM_IOS 18 | end 19 | 20 | it "can take a version number alone in initialization" do 21 | expect(AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_17)).to be_a AppleNoteStoreVersion 22 | end 23 | 24 | it "sets both version_number and platform if both are given" do 25 | tmp_version = AppleNoteStoreVersion.new(AppleNoteStoreVersion::IOS_VERSION_17, AppleNoteStoreVersion::VERSION_PLATFORM_MAC) 26 | expect(tmp_version.version_number).to be == AppleNoteStoreVersion::IOS_VERSION_17 27 | expect(tmp_version.platform).to be == AppleNoteStoreVersion::VERSION_PLATFORM_MAC 28 | end 29 | 30 | end 31 | 32 | context "comparison" do 33 | 34 | it "orders lower version numbers less than greater ones" do 35 | expect(low_version < high_version).to be true 36 | end 37 | 38 | it "orders higher version numbers more than lesser ones" do 39 | expect(high_version > low_version).to be true 40 | end 41 | 42 | it "identifies the same version numbers as equal" do 43 | expect(high_version == mac_version).to be true 44 | end 45 | 46 | it "identifies that iOS and MAC platforms are different" do 47 | expect(high_version.same_platform(mac_version)).to be false 48 | end 49 | 50 | it "identifies that iOS and iOS platforms are the same" do 51 | expect(high_version.same_platform(low_version)).to be true 52 | end 53 | 54 | end 55 | 56 | context "helpers" do 57 | it "identifies as legacy if the version is prior to 9" do 58 | expect(legacy_version.legacy?).to be true 59 | end 60 | 61 | it "does not identify as legacy if the version is 9 or later" do 62 | expect(high_version.legacy?).to be false 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /lib/AppleBackupMac.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 running Cloud Notes Parser on a logical backup of an Apple computer's Notes application, not the mobile version. 9 | # This class expects to be pointed at the root of the Notes folder, generally /Users/{username}/Library/Group Containers/group.com.apple.notes/. 10 | # This class will abstract away figuring out how to get the right media files to embed back into an AppleNote. 11 | class AppleBackupMac < AppleBackup 12 | 13 | ## 14 | # Creates a new AppleBackupMac. Expects a Pathname +root_folder+ that represents the root 15 | # of the physical backup and a Pathname +output_folder+ which will hold the results of this run. 16 | # Immediately sets the NoteStore database file to be the appropriate application's NoteStore.sqlite. 17 | def initialize(root_folder, output_folder, decrypter=AppleDecrypter.new) 18 | 19 | super(root_folder, AppleBackup::MAC_BACKUP_TYPE, output_folder, decrypter) 20 | 21 | # Check to make sure we're all good 22 | if self.valid? 23 | puts "Created a new AppleBackup from Mac backup: #{@root_folder}" 24 | 25 | # Copy the modern NoteStore to our output directory 26 | copy_notes_database(@root_folder + "NoteStore.sqlite", @note_store_modern_location) 27 | modern_note_version = AppleNoteStore.guess_ios_version(@note_store_modern_location) 28 | modern_note_version.platform=(AppleNoteStoreVersion::VERSION_PLATFORM_MAC) 29 | 30 | # Create the AppleNoteStore objects 31 | create_and_add_notestore(@note_store_modern_location, modern_note_version) 32 | 33 | @uses_account_folder = check_for_accounts_folder 34 | end 35 | end 36 | 37 | ## 38 | # This method returns true if it is a value backup of the specified type. For MAC_BACKUP_TYPE this means 39 | # that the +root_folder+ given is where the root of the directory structure is and the NoteStore.sqlite 40 | # file is directly underneath. 41 | def valid? 42 | return (@root_folder.directory? and 43 | (@root_folder + "NoteStore.sqlite").file? and 44 | (@root_folder + "NotesIndexerState-Modern").file?) 45 | end 46 | 47 | ## 48 | # This method overrides the default check_for_accounts_folder to determine 49 | # if this backup uses an accounts folder or not. It takes no arguments and 50 | # returns true if an accounts folder is used and false if not. 51 | def check_for_accounts_folder 52 | accounts_folder = @root_folder + "Accounts" 53 | return accounts_folder.exist? 54 | end 55 | 56 | ## 57 | # This method returns a Pathname that represents the location on this disk of the requested file or nil. 58 | # It expects a String +filename+ to look up. 59 | def get_real_file_path(filename) 60 | pathname = @root_folder + filename 61 | return pathname if pathname and pathname.exist? 62 | 63 | return nil 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /spec/spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../lib/AppleNoteStore.rb' 2 | require_relative '../lib/AppleNoteStoreVersion.rb' 3 | 4 | # Edit RSpec configurations 5 | RSpec.configure do |config| 6 | 7 | # Bounce out any AppleBackup* tests that have missing data so others can run the test suite 8 | config.filter_run_excluding :missing_data => true 9 | end 10 | 11 | # Create pathnames for various base locations 12 | TEST_OUTPUT_DIR = Pathname.new("spec") + "tmp_output" 13 | TEST_DATA_DIR = Pathname.new("spec") + "data" 14 | TEST_BLOB_DATA_DIR = TEST_DATA_DIR + "exported_blobs" 15 | 16 | # Remember where specific versions are kept so we can test for their existence and skip 17 | # if they aren't around. 18 | TEST_MAC_DIR = TEST_DATA_DIR + "mac_backup" 19 | TEST_MAC_DIR_EXIST = TEST_MAC_DIR.exist? 20 | TEST_MAC_NO_ACCOUNT_DIR = TEST_DATA_DIR + "mac_backup_no_account" 21 | TEST_MAC_NO_ACCOUNT_DIR_EXIST = TEST_MAC_DIR.exist? 22 | TEST_ITUNES_DIR = TEST_DATA_DIR + "itunes_backup" 23 | TEST_ITUNES_DIR_EXIST = TEST_ITUNES_DIR.exist? 24 | TEST_ITUNES_NO_ACCOUNT_DIR = TEST_DATA_DIR + "itunes_backup_no_account" 25 | TEST_ITUNES_NO_ACCOUNT_DIR_EXIST = TEST_ITUNES_NO_ACCOUNT_DIR.exist? 26 | TEST_PHYSICAL_DIR = TEST_DATA_DIR + "physical_backup" 27 | TEST_PHYSICAL_DIR_EXIST = TEST_PHYSICAL_DIR.exist? 28 | TEST_PHYSICAL_NO_ACCOUNT_DIR = TEST_DATA_DIR + "physical_backup_no_account" 29 | TEST_PHYSICAL_NO_ACCOUNT_DIR_EXIST = TEST_PHYSICAL_NO_ACCOUNT_DIR.exist? 30 | 31 | TEST_FORMATTING_FILE = TEST_DATA_DIR + "NoteStore-tests.sqlite" 32 | TEST_FORMATTING_FILE_EXIST = TEST_FORMATTING_FILE.exist? 33 | 34 | TEST_FALSE_SQLITE_FILE = TEST_DATA_DIR + "notta-NoteStore.sqlite" 35 | TEST_FALSE_SQLITE_FILE_EXIST = TEST_FALSE_SQLITE_FILE.exist? 36 | 37 | TEST_README_FILE = TEST_DATA_DIR + "README.md" 38 | TEST_README_FILE_EXIST = TEST_README_FILE.exist? 39 | 40 | # The latest version 41 | TEST_CURRENT_VERSION = 26 42 | 43 | # Build an array of all valid NoteStore.sqlite versions for testing 44 | TEST_FILE_VERSIONS = Hash.new 45 | versions_to_test = (12..TEST_CURRENT_VERSION).to_a 46 | versions_to_test.each do |version| 47 | version_file = TEST_DATA_DIR + "NoteStore.#{version}.sqlite" 48 | TEST_FILE_VERSIONS[version] = version_file if version_file.exist? 49 | end 50 | 51 | TEST_FILE_VERSIONS_CURRENT_FILE = TEST_FILE_VERSIONS[TEST_CURRENT_VERSION] 52 | TEST_FILE_VERSIONS_CURRENT_FILE_EXIST = (TEST_FILE_VERSIONS_CURRENT_FILE != nil) 53 | 54 | legacy_version_file = TEST_DATA_DIR + "NoteStore.legacy.sqlite" 55 | TEST_FILE_VERSIONS[AppleNoteStoreVersion::IOS_LEGACY_VERSION] = legacy_version_file if legacy_version_file.exist? 56 | 57 | # Used to indicate the various ways we can call generate_html 58 | TEST_HTML_GENERATION_OPTIONS = [[false, false], [true, false], [false, true], [true, true]] 59 | 60 | # Require this later so that it can use the globals we set above 61 | require_relative 'backup/backup.rb' 62 | require_relative 'base_classes/base_classes.rb' 63 | require_relative 'embedded_objects/embedded_objects.rb' 64 | require_relative 'integration/integration.rb' 65 | require_relative 'utilities/utilities.rb' 66 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedPDF.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents a com.adobe.pdf object embedded 3 | # in an AppleNote. This means you shared a PDF from another application. 4 | class AppleNotesEmbeddedPDF < AppleNotesEmbeddedObject 5 | 6 | attr_accessor :primary_key, 7 | :uuid, 8 | :type, 9 | :filepath, 10 | :filename, 11 | :reference_location 12 | 13 | ## 14 | # Creates a new AppleNotesEmbeddedPDF 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 vcard 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 = get_media_filename 23 | @filepath = get_media_filepath 24 | @backup = backup 25 | 26 | # Find where on this computer that file is stored 27 | add_possible_location(@filepath) 28 | 29 | tmp_stored_file_result = find_valid_file_path 30 | 31 | # Copy the file to our output directory if we can 32 | if tmp_stored_file_result 33 | @backup_location = tmp_stored_file_result.backup_location 34 | @filepath = tmp_stored_file_result.filepath 35 | @filename = tmp_stored_file_result.filename 36 | 37 | # Copy the file to our output directory if we can 38 | @reference_location = @backup.back_up_file(@filepath, 39 | @filename, 40 | @backup_location) 41 | end 42 | end 43 | 44 | ## 45 | # This method just returns a readable String for the object. 46 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 47 | def to_s 48 | to_s_with_data("PDF") 49 | end 50 | 51 | ## 52 | # This method returns the +uuid+ of the media. 53 | def get_media_uuid 54 | get_media_uuid_from_zidentifier 55 | end 56 | 57 | ## 58 | # This method returns the +filepath+ of this object. 59 | # This is computed based on the assumed default storage location. 60 | def get_media_filepath 61 | get_media_filepath_with_uuid_and_filename 62 | end 63 | 64 | ## 65 | # This method returns the +filename+ of this object. 66 | # This requires looking up the referenced ZICCLOUDSYNCINGOBJECT in the row 67 | # identified by +uuid+. After that, the ZICCLOUDSYNCINGOBJECT.ZFILENAME 68 | # field holds the answer. 69 | def get_media_filename 70 | get_media_filename_from_zfilename 71 | end 72 | 73 | ## 74 | # This method generates the HTML necessary to display the file download link. 75 | def generate_html(individual_files=false) 76 | generate_html_with_link("PDF", individual_files) 77 | end 78 | 79 | end 80 | -------------------------------------------------------------------------------- /spec/backup/apple_backup_physical.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleBackupPhysical.rb' 2 | 3 | describe AppleBackupPhysical, :expensive => true do 4 | before(:context) do 5 | TEST_OUTPUT_DIR.mkpath 6 | end 7 | after(:context) do 8 | TEST_OUTPUT_DIR.rmtree 9 | end 10 | 11 | let(:valid_backup) { AppleBackupPhysical.new(TEST_PHYSICAL_DIR, TEST_OUTPUT_DIR) } 12 | let(:no_accounts_backup) { AppleBackupPhysical.new(TEST_PHYSICAL_NO_ACCOUNT_DIR, TEST_OUTPUT_DIR) } 13 | let(:test_files_to_find) { ["Accounts/LocalAccount/FallbackImages/8FD55434-3E94-4818-8784-132F41B480DD.jpg"] } 14 | 15 | context "validations" do 16 | it "validates a physical backup folder", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 17 | expect(valid_backup.valid?).to be true 18 | end 19 | 20 | it "fails to validate an itunes backup folder", :missing_data => !TEST_ITUNES_DIR_EXIST do 21 | backup = AppleBackupPhysical.new(TEST_ITUNES_DIR, TEST_OUTPUT_DIR) 22 | expect(backup.valid?).to be false 23 | end 24 | 25 | it "fails to validate a mac backup folder", :missing_data => !TEST_MAC_DIR_EXIST do 26 | backup = AppleBackupPhysical.new(TEST_MAC_DIR, TEST_OUTPUT_DIR) 27 | expect(backup.valid?).to be false 28 | end 29 | 30 | it "fails to validate a valid NoteStore.sqlite file", :missing_data => !TEST_FORMATTING_FILE_EXIST do 31 | backup = AppleBackupPhysical.new(TEST_FORMATTING_FILE, TEST_OUTPUT_DIR) 32 | expect(backup.valid?).to be false 33 | end 34 | end 35 | 36 | context "files with accounts", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 37 | it "knows how to find an appropriate file" do 38 | expect(valid_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/private\/var\/mobile\/Containers\/Shared\/AppGroup\/[A-F0-9\-]{36}\/NoteStore.sqlite/) 39 | expect(valid_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/private\/var\/mobile\/Containers\/Shared\/AppGroup\/[A-F0-9\-]{36}\/Accounts\/LocalAccount\/FallbackImages\/8FD55434-3E94-4818-8784-132F41B480DD.jpg/) 40 | end 41 | 42 | it "correctly identifies the use of an accounts folder when one exists" do 43 | expect(valid_backup.uses_account_folder).to be true 44 | end 45 | end 46 | 47 | context "files without accounts", :missing_data => !TEST_PHYSICAL_NO_ACCOUNT_DIR_EXIST do 48 | it "knows how to find an appropriate file" do 49 | expect(no_accounts_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/private\/var\/mobile\/Containers\/Shared\/AppGroup\/[A-F0-9\-]{36}\/NoteStore.sqlite/) 50 | expect(no_accounts_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/private\/var\/mobile\/Containers\/Shared\/AppGroup\/[A-F0-9\-]{36}\/FallbackImages\/8FD55434-3E94-4818-8784-132F41B480DD.jpg/) 51 | end 52 | 53 | it "correctly identifies the lack of an accounts folder when one does not exist" do 54 | expect(no_accounts_backup.uses_account_folder).to be false 55 | end 56 | end 57 | 58 | context "note stores", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 59 | it "knows how to find both legacy and modern note stores" do 60 | expect(valid_backup.note_stores.length).to be 2 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/backup/apple_backup_hashed.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleBackupHashed.rb' 2 | 3 | describe AppleBackupHashed, :expensive => true do 4 | before(:context) do 5 | TEST_OUTPUT_DIR.mkpath 6 | end 7 | after(:context) do 8 | TEST_OUTPUT_DIR.rmtree 9 | end 10 | 11 | let(:valid_backup) { AppleBackupHashed.new(TEST_ITUNES_DIR, TEST_OUTPUT_DIR) } 12 | let(:no_account_backup) { AppleBackupHashed.new(TEST_ITUNES_NO_ACCOUNT_DIR, TEST_OUTPUT_DIR) } 13 | let(:test_files_to_find) { ["Accounts/LocalAccount/FallbackImages/8FD55434-3E94-4818-8784-132F41B480DD.jpg"] } 14 | 15 | context "validations" do 16 | it "validates an itunes backup folder", :missing_data => !TEST_ITUNES_DIR_EXIST do 17 | expect(valid_backup.valid?).to be true 18 | end 19 | 20 | it "validates an itunes backup folder without accounts", :missing_data => !TEST_ITUNES_NO_ACCOUNT_DIR_EXIST do 21 | expect(no_account_backup.valid?).to be true 22 | end 23 | 24 | it "fails to validate a physical backup folder", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 25 | backup = AppleBackupHashed.new(TEST_PHYSICAL_DIR, TEST_OUTPUT_DIR) 26 | expect(backup.valid?).to be false 27 | end 28 | 29 | it "fails to validate a mac backup folder", :missing_data => !TEST_MAC_DIR_EXIST do 30 | backup = AppleBackupHashed.new(TEST_MAC_DIR, TEST_OUTPUT_DIR) 31 | expect(backup.valid?).to be false 32 | end 33 | 34 | it "fails to validate a valid NoteStore.sqlite file", :missing_data => !TEST_FORMATTING_FILE_EXIST do 35 | backup = AppleBackupHashed.new(TEST_FORMATTING_FILE, TEST_OUTPUT_DIR) 36 | expect(backup.valid?).to be false 37 | end 38 | end 39 | 40 | context "files with account folders", :missing_data => !TEST_ITUNES_DIR_EXIST do 41 | it "knows how to find an appropriate file" do 42 | expect(valid_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/spec\/data\/itunes_backup\/4f\/4f98687d8ab0d6d1a371110e6b7300f6e465bef2/) 43 | expect(valid_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/spec\/data\/itunes_backup\/10\/1097c74e05dccdf5bd77ca48d22f6116854b78d2/) 44 | end 45 | 46 | it "correctly identifies the use of an accounts folder when one exists" do 47 | expect(valid_backup.uses_account_folder).to be true 48 | end 49 | end 50 | 51 | context "files without account folders", :missing_data => !TEST_ITUNES_NO_ACCOUNT_DIR_EXIST do 52 | it "knows how to find an appropriate file" do 53 | expect(no_account_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/spec\/data\/itunes_backup_no_account\/4f\/4f98687d8ab0d6d1a371110e6b7300f6e465bef2/) 54 | expect(no_account_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/spec\/data\/itunes_backup_no_account\/fc\/fc97b386b2a39b503682d5fb9c20f684dfe1ed93/) 55 | end 56 | 57 | it "correctly identifies the lack of an accounts folder when one doesn't exist" do 58 | expect(no_account_backup.uses_account_folder).to be false 59 | end 60 | end 61 | 62 | context "note stores", :missing_data => !TEST_ITUNES_DIR_EXIST do 63 | it "knows how to find both legacy and modern note stores" do 64 | expect(valid_backup.note_stores.length).to be 2 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/backup/apple_backup_mac.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/AppleBackupMac.rb' 2 | 3 | describe AppleBackupMac, :expensive => true do 4 | before(:context) do 5 | TEST_OUTPUT_DIR.mkpath 6 | end 7 | after(:context) do 8 | TEST_OUTPUT_DIR.rmtree 9 | end 10 | 11 | let(:valid_backup) { AppleBackupMac.new(TEST_MAC_DIR, TEST_OUTPUT_DIR) } 12 | let(:no_account_backup) { AppleBackupMac.new(TEST_MAC_NO_ACCOUNT_DIR, TEST_OUTPUT_DIR) } 13 | let(:test_files_to_find) { ["Accounts/LocalAccount/FallbackImages/8FD55434-3E94-4818-8784-132F41B480DD.jpg"] } 14 | 15 | context "validations" do 16 | it "validates a mac backup folder", :missing_data => !TEST_MAC_DIR_EXIST do 17 | expect(valid_backup.valid?).to be true 18 | end 19 | 20 | it "validates a mac backup folder without accounts", :missing_data => !TEST_MAC_NO_ACCOUNT_DIR_EXIST do 21 | expect(no_account_backup.valid?).to be true 22 | end 23 | 24 | it "validates a mac backup folder without accounts", :missing_data => !TEST_MAC_NO_ACCOUNT_DIR_EXIST do 25 | expect(no_account_backup.valid?).to be true 26 | end 27 | 28 | it "fails to validate an itunes backup folder", :missing_data => !TEST_ITUNES_DIR_EXIST do 29 | backup = AppleBackupMac.new(TEST_ITUNES_DIR, TEST_OUTPUT_DIR) 30 | expect(backup.valid?).to be false 31 | end 32 | 33 | it "fails to validate a physical backup folder", :missing_data => !TEST_PHYSICAL_DIR_EXIST do 34 | backup = AppleBackupMac.new(TEST_PHYSICAL_DIR, TEST_OUTPUT_DIR) 35 | expect(backup.valid?).to be false 36 | end 37 | 38 | it "fails to validate a valid NoteStore.sqlite file", :missing_data => !TEST_FORMATTING_FILE_EXIST do 39 | backup = AppleBackupMac.new(TEST_FORMATTING_FILE, TEST_OUTPUT_DIR) 40 | expect(backup.valid?).to be false 41 | end 42 | end 43 | 44 | context "files with account folders", :missing_data => !TEST_MAC_DIR_EXIST do 45 | it "knows how to find an appropriate file" do 46 | expect(valid_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/spec\/data\/mac_backup\/NoteStore.sqlite/) 47 | expect(valid_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/spec\/data\/mac_backup\/Accounts\/LocalAccount\/FallbackImages\/8FD55434-3E94-4818-8784-132F41B480DD.jpg/) 48 | end 49 | 50 | it "correctly identifies the use of an accounts folder when one exists" do 51 | expect(valid_backup.uses_account_folder).to be true 52 | end 53 | end 54 | 55 | context "files without account folders", :missing_data => !TEST_MAC_DIR_EXIST do 56 | it "knows how to find an appropriate file" do 57 | expect(no_account_backup.get_real_file_path("NoteStore.sqlite").to_s).to match(/spec\/data\/mac_backup_no_account\/NoteStore.sqlite/) 58 | expect(no_account_backup.find_valid_file_path(test_files_to_find).backup_location.to_s).to match(/spec\/data\/mac_backup_no_account\/FallbackImages\/8FD55434-3E94-4818-8784-132F41B480DD.jpg/) 59 | end 60 | 61 | it "correctly identifies the lack of an accounts folder when one doesn't exist" do 62 | expect(no_account_backup.uses_account_folder).to be false 63 | end 64 | end 65 | 66 | context "note stores", :missing_data => !TEST_MAC_DIR_EXIST do 67 | it "knows how to find just a modern note store" do 68 | expect(valid_backup.note_stores.length).to be 1 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedDocument.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents a document-ish object embedded 3 | # in an AppleNote. This means you shared something like an Office document from another application. 4 | class AppleNotesEmbeddedDocument < AppleNotesEmbeddedObject 5 | 6 | attr_accessor :primary_key, 7 | :uuid, 8 | :type, 9 | :filepath, 10 | :filename, 11 | :reference_location 12 | 13 | ## 14 | # Creates a new AppleNotesEmbeddedDocument 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 vcard 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 = get_media_filename 23 | @filepath = get_media_filepath 24 | @backup = backup 25 | 26 | add_possible_location(@filepath) 27 | 28 | # Find where on this computer that file is stored 29 | tmp_stored_file_result = find_valid_file_path 30 | 31 | if tmp_stored_file_result 32 | @backup_location = tmp_stored_file_result.backup_location 33 | @filepath = tmp_stored_file_result.filepath 34 | @filename = tmp_stored_file_result.filename 35 | 36 | # Copy the file to our output directory if we can 37 | @reference_location = @backup.back_up_file(@filepath, 38 | @filename, 39 | @backup_location, 40 | @is_password_protected, 41 | @crypto_password, 42 | @crypto_salt, 43 | @crypto_iterations, 44 | @crypto_key, 45 | @crypto_asset_iv, 46 | @crypto_asset_tag) 47 | end 48 | end 49 | 50 | ## 51 | # This method just returns a readable String for the object. 52 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 53 | def to_s 54 | to_s_with_data("document") 55 | end 56 | 57 | ## 58 | # This method returns the +uuid+ of the media. 59 | def get_media_uuid 60 | get_media_uuid_from_zidentifier 61 | end 62 | 63 | ## 64 | # This method returns the +filepath+ of this object. 65 | # This is computed based on the assumed default storage location. 66 | def get_media_filepath 67 | get_media_filepath_with_uuid_and_filename 68 | end 69 | 70 | ## 71 | # This method returns the +filename+ of this object. 72 | # This requires looking up the referenced ZICCLOUDSYNCINGOBJECT in the row 73 | # identified by +uuid+. After that, the ZICCLOUDSYNCINGOBJECT.ZFILENAME 74 | # field holds the answer. 75 | def get_media_filename 76 | get_media_filename_from_zfilename 77 | end 78 | 79 | ## 80 | # This method generates the HTML necessary to display the file download link. 81 | def generate_html(individual_files=false) 82 | generate_html_with_link("Document", individual_files) 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedPublicVCard.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents a public.vcard object embedded 3 | # in an AppleNote. This means you added a contact to a note from another application, 4 | # like Contacts. 5 | class AppleNotesEmbeddedPublicVCard < AppleNotesEmbeddedObject 6 | 7 | attr_accessor :primary_key, 8 | :uuid, 9 | :type, 10 | :filepath, 11 | :filename, 12 | :reference_location 13 | 14 | ## 15 | # Creates a new AppleNotesEmbeddedPublicVCard 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 vcard 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 folder's variables 22 | super(primary_key, uuid, uti, note) 23 | @filename = get_media_filename 24 | @filepath = get_media_filepath 25 | @backup = backup 26 | 27 | add_possible_location(@filepath) 28 | 29 | # Find where on this computer that file is stored 30 | tmp_stored_file_result = find_valid_file_path 31 | 32 | if tmp_stored_file_result 33 | @backup_location = tmp_stored_file_result.backup_location 34 | @filepath = tmp_stored_file_result.filepath 35 | @filename = tmp_stored_file_result.filename 36 | 37 | # Copy the file to our output directory if we can 38 | @reference_location = @backup.back_up_file(@filepath, 39 | @filename, 40 | @backup_location, 41 | @is_password_protected, 42 | @crypto_password, 43 | @crypto_salt, 44 | @crypto_iterations, 45 | @crypto_key, 46 | @crypto_asset_iv, 47 | @crypto_asset_tag) 48 | end 49 | end 50 | 51 | ## 52 | # This method just returns a readable String for the object. 53 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 54 | def to_s 55 | to_s_with_data("VCard") 56 | end 57 | 58 | ## 59 | # This method returns the +uuid+ of the media. 60 | def get_media_uuid 61 | get_media_uuid_from_zidentifier 62 | end 63 | 64 | ## 65 | # This method returns the +filepath+ of this object. 66 | # This is computed based on the assumed default storage location. 67 | def get_media_filepath 68 | get_media_filepath_with_uuid_and_filename 69 | end 70 | 71 | ## 72 | # This method returns the +filename+ of this object. 73 | # This requires looking up the referenced ZICCLOUDSYNCINGOBJECT in the row 74 | # identified by +uuid+. After that, the ZICCLOUDSYNCINGOBJECT.ZFILENAME 75 | # field holds the answer. 76 | def get_media_filename 77 | get_media_filename_from_zfilename 78 | end 79 | 80 | ## 81 | # This method generates the HTML necessary to display the image inline. 82 | def generate_html(individual_files=false) 83 | generate_html_with_link("VCard", individual_files) 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedCalendar.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents a com.apple.ical.ics object embedded 3 | # in an AppleNote. This means you added a calendar object to a note from another application, 4 | # like iCal. 5 | class AppleNotesEmbeddedCalendar < AppleNotesEmbeddedObject 6 | 7 | attr_accessor :primary_key, 8 | :uuid, 9 | :type, 10 | :filepath, 11 | :filename, 12 | :reference_location 13 | 14 | ## 15 | # Creates a new AppleNotesEmbeddedCalendar 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 ics file 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 folder's variables 22 | super(primary_key, uuid, uti, note) 23 | @filename = get_media_filename 24 | @filepath = get_media_filepath 25 | @backup = backup 26 | 27 | add_possible_location(@filepath) 28 | 29 | # Find where on this computer that file is stored 30 | tmp_stored_file_result = find_valid_file_path 31 | 32 | if tmp_stored_file_result 33 | @backup_location = tmp_stored_file_result.backup_location 34 | @filepath = tmp_stored_file_result.filepath 35 | @filename = tmp_stored_file_result.filename 36 | 37 | # Copy the file to our output directory if we can 38 | @reference_location = @backup.back_up_file(@filepath, 39 | @filename, 40 | @backup_location, 41 | @is_password_protected, 42 | @crypto_password, 43 | @crypto_salt, 44 | @crypto_iterations, 45 | @crypto_key, 46 | @crypto_asset_iv, 47 | @crypto_asset_tag) 48 | end 49 | end 50 | 51 | ## 52 | # This method just returns a readable String for the object. 53 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 54 | def to_s 55 | to_s_with_data("iCal ICS") 56 | end 57 | 58 | ## 59 | # This method returns the +uuid+ of the media. 60 | def get_media_uuid 61 | get_media_uuid_from_zidentifier 62 | end 63 | 64 | ## 65 | # This method returns the +filepath+ of this object. 66 | # This is computed based on the assumed default storage location. 67 | def get_media_filepath 68 | get_media_filepath_with_uuid_and_filename 69 | end 70 | 71 | ## 72 | # This method returns the +filename+ of this object. 73 | # This requires looking up the referenced ZICCLOUDSYNCINGOBJECT in the row 74 | # identified by +uuid+. After that, the ZICCLOUDSYNCINGOBJECT.ZFILENAME 75 | # field holds the answer. 76 | def get_media_filename 77 | get_media_filename_from_zfilename 78 | end 79 | 80 | ## 81 | # This method generates the HTML necessary to display the image inline. 82 | def generate_html(individual_files=false) 83 | generate_html_with_link("iCal ICS", individual_files) 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /lib/AppleNotesEmbeddedPublicObject.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This class represents an object that is derived from public.data in https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html#//apple_ref/doc/uid/TP40009259-SW1 3 | # embedded in an AppleNote. This has a host of types of data under it, 4 | # some of which will be handled by specific classes, such as URLs. 5 | class AppleNotesEmbeddedPublicObject < AppleNotesEmbeddedObject 6 | 7 | attr_accessor :primary_key, 8 | :uuid, 9 | :type, 10 | :filepath, 11 | :filename, 12 | :reference_location 13 | 14 | ## 15 | # Creates a new AppleNotesEmbeddedPublicObject 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 object 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 folder's variables 22 | super(primary_key, uuid, uti, note) 23 | @filename = get_media_filename 24 | @filepath = get_media_filepath 25 | @backup = backup 26 | 27 | add_possible_location(@filepath) 28 | 29 | # Find where on this computer that file is stored 30 | tmp_stored_file_result = find_valid_file_path 31 | 32 | if tmp_stored_file_result 33 | @backup_location = tmp_stored_file_result.backup_location 34 | @filepath = tmp_stored_file_result.filepath 35 | @filename = tmp_stored_file_result.filename 36 | 37 | # Copy the file to our output directory if we can 38 | @reference_location = @backup.back_up_file(@filepath, 39 | @filename, 40 | @backup_location, 41 | @is_password_protected, 42 | @crypto_password, 43 | @crypto_salt, 44 | @crypto_iterations, 45 | @crypto_key, 46 | @crypto_asset_iv, 47 | @crypto_asset_tag) 48 | end 49 | end 50 | 51 | ## 52 | # This method just returns a readable String for the object. 53 | # Adds to the AppleNotesEmbeddedObject.to_s by pointing to where the media is. 54 | def to_s 55 | to_s_with_data("object") 56 | end 57 | 58 | ## 59 | # This method returns the +uuid+ of the media. 60 | def get_media_uuid 61 | get_media_uuid_from_zidentifier 62 | end 63 | 64 | ## 65 | # This method returns the +filepath+ of this object. 66 | # This is computed based on the assumed default storage location. 67 | def get_media_filepath 68 | get_media_filepath_with_uuid_and_filename 69 | end 70 | 71 | ## 72 | # This method returns the +filename+ of this object. 73 | # This requires looking up the referenced ZICCLOUDSYNCINGOBJECT in the row 74 | # identified by +uuid+. After that, the ZICCLOUDSYNCINGOBJECT.ZFILENAME 75 | # field holds the answer. 76 | def get_media_filename 77 | get_media_filename_from_zfilename 78 | end 79 | 80 | ## 81 | # This method generates the HTML necessary to display the image inline. 82 | def generate_html(individual_files=false) 83 | generate_html_with_link("File", individual_files) 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /.github/workflows/docker_publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | release: 10 | types: [ published, edited ] 11 | 12 | env: 13 | # Use docker.io for Docker Hub if empty 14 | REGISTRY: ghcr.io 15 | # github.repository as / 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\n\n\n\n\n\n\n
    Row 1 Column 1Row 1 Column 2
    Row 2 Column 1Row 2 Column 2
    " 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 | --------------------------------------------------------------------------------