From 1bf4674070cedefa998520da06c731a7af50fb4a Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 8 Aug 2022 11:43:17 +1200 Subject: [PATCH] First revision --- .github/workflows/ruby.yml | 28 + .gitignore | 11 + .rspec | 3 + .rubocop.yml | 13 + CHANGELOG.md | 3 + Gemfile | 10 + Gemfile.lock | 124 ++++ LICENSE | 21 + README.md | 106 +++ Rakefile | 16 + bin/console | 15 + bin/setup | 8 + lib/ooxml_encryption.rb | 663 ++++++++++++++++++ lib/ooxml_encryption/version.rb | 15 + ooxml_encryption.gemspec | 38 + spec/fixtures/node_encrypted_spreadsheet.xlsx | Bin 0 -> 6144 bytes spec/fixtures/readme.txt | 12 + spec/fixtures/unencrypted_spreadsheet.xlsx | Bin 0 -> 2611 bytes spec/lib/ooxml_encryption_spec.rb | 39 ++ spec/spec_helper.rb | 32 + 20 files changed, 1157 insertions(+) create mode 100644 .github/workflows/ruby.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 lib/ooxml_encryption.rb create mode 100644 lib/ooxml_encryption/version.rb create mode 100644 ooxml_encryption.gemspec create mode 100644 spec/fixtures/node_encrypted_spreadsheet.xlsx create mode 100644 spec/fixtures/readme.txt create mode 100644 spec/fixtures/unencrypted_spreadsheet.xlsx create mode 100644 spec/lib/ooxml_encryption_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..9a9b73d --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,28 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: ['2.7', '3.0', '3.1'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..835648e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/docs/ +/pkg/ +/spec/reports/ +/tmp/ +.DS_Store +*.gem + diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..e3462a7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,13 @@ +AllCops: + TargetRubyVersion: 2.6 + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/LineLength: + Max: 120 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..56e524f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.1.0] - 2022-08-08 + +- Initial release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..a4b6d77 --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Use a fork version of SDoc; can't use ":git" in ".gemspec" files, so do +# it here instead. +# +gem 'sdoc', git: 'https://github.com/pond/sdoc.git', branch: 'master' + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..88d03a6 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,124 @@ +GIT + remote: https://github.com/pond/sdoc.git + revision: 4197420bd974d1d5be9699ac67726297a7acabe7 + branch: master + specs: + sdoc (2.3.1) + rdoc (>= 5.0, < 7.0.0) + +PATH + remote: . + specs: + ooxml_encryption (0.1.0) + nokogiri (~> 1.13) + openssl (~> 3.0) + simple_cfb (~> 0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionpack (7.0.3.1) + actionview (= 7.0.3.1) + activesupport (= 7.0.3.1) + rack (~> 2.0, >= 2.2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actionview (7.0.3.1) + activesupport (= 7.0.3.1) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activesupport (7.0.3.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + builder (3.2.4) + byebug (11.1.3) + concurrent-ruby (1.1.10) + crass (1.0.6) + diff-lcs (1.5.0) + docile (1.4.0) + doggo (1.2.0) + rspec-core (~> 3.10) + erubi (1.11.0) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + loofah (2.18.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + method_source (1.0.0) + minitest (5.16.2) + nokogiri (1.13.8-x86_64-darwin) + racc (~> 1.4) + openssl (3.0.0) + psych (4.0.4) + stringio + racc (1.6.0) + rack (2.2.4) + rack-test (2.0.2) + rack (>= 1.3) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.3) + loofah (~> 2.3) + railties (7.0.3.1) + actionpack (= 7.0.3.1) + activesupport (= 7.0.3.1) + method_source + rake (>= 12.2) + thor (~> 1.0) + zeitwerk (~> 2.5) + rake (13.0.6) + rdoc (6.4.0) + psych (>= 4.0.0) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-rails (5.1.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + railties (>= 5.2) + rspec-core (~> 3.10) + rspec-expectations (~> 3.10) + rspec-mocks (~> 3.10) + rspec-support (~> 3.10) + rspec-support (3.11.0) + simple_cfb (0.1.0) + activesupport (> 5, < 8) + simplecov (0.21.2) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov-rcov (0.3.1) + simplecov (>= 0.4.1) + simplecov_json_formatter (0.1.4) + stringio (3.0.2) + thor (1.2.1) + tzinfo (2.0.5) + concurrent-ruby (~> 1.0) + zeitwerk (2.6.0) + +PLATFORMS + x86_64-darwin-21 + +DEPENDENCIES + byebug (~> 11.1) + doggo (~> 1.2) + ooxml_encryption! + rdoc (~> 6.4) + rspec-rails (~> 5.0) + sdoc! + simplecov-rcov (~> 0.2) + +BUNDLED WITH + 2.3.13 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96d6841 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 RIPA Global + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4eeacc9 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# OOXML Encryption +## Overview + +OOXML Encryption provides encryption and decryption support for OOXML (Microsoft Excel / `.xlsx` files) via full-spreadsheet password protection using AES encryption with SHA-512 hashes. This is a port of the encryption part of: + +* https://github.com/dtjohnson/xlsx-populate + +Using an input OOXML file generated by https://github.com/felixbuenemann/xlsxtream, encrypted output was tested on macOS 12.5 in Microsoft Excel 16.63.1, Apple Numbers 12.1 (and QuickLook from the Finder) and LibreOffice Vanilla 7.2.5.2, all of which prompted for a password, handled an incorrect password as expected and correctly opened the decrypted spreadsheet if given the correct password. + +For low-level file format details, see: + +* https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto +* https://github.com/RIPAGlobal/simple_cfb/ + + + +## Installation + +Install the gem and add to the application's `Gemfile` by executing: + + $ bundle add ooxml_encryption + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install ooxml_encryption + + + +## Usage +### Encrypting a spreadsheet + +If you have read a file containing an unprotected OOXML file into `unprotected_data` as a String using `ASCII-8BIT` encoding, or if you have generated such a string in memory directly, then using a `password` supplied as a String in an encoding of your choice: + +```ruby +require 'ooxml_encryption' + +encryptor = OoxmlEncryption.new +encrypted_data = encryptor.encrypt( + unencrypted_spreadsheet_data: unprotected_data, + password: password +) +``` + +...then write `encrypted_data` to a file using binary mode, e.g.: + +```ruby +File.open('/path/to/encrypted.xlsx', 'wb') do | file | + file.write(encrypted_data) +end +``` + +### Decrypting a spreadsheet + +If you have read a file containing an encrypted OOXML file into `encrypted_data` as a String using `ASCII-8BIT` encoding and have obtained a `password` from the spreadsheet's owning user as a String in an encoding of your choice, then: + +``` +require 'ooxml_encryption' + +decryptor = OoxmlEncryption.new +decrypted_data = decryptor.decrypt( + encrypted_spreadsheeet_data: encrypted_data, + password: password +) +``` + +...then write `decrypted_data` to a file using binary mode, e.g.: + +```ruby +File.open('/path/to/unprotected.xlsx', 'wb') do | file | + file.write(decrypted_data) +end +``` + + + +## Resource overhead + +Due to the nature of the underlying file format, which has various tables written at the start of the file that can only be built once the file contents are known, encrypted spreadsheets must be created or decoded in RAM. Streamed output or input is not possible. Attempting to create or read large spreadsheets is therefore not recommended - there could be very large RAM requirements arising. + + + +## Security concerns + +The level of security this provides should be assessed relative to your requirements; numerous articles are available online which discuss the pros and cons. The quality of the password will also have a big impact on the overall security of an output file. + + + +## Development + +Use `bundle exec rspec` to run tests. Run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. If you have sufficient RubyGems access to release a new version, update the version number and date in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +Locally generated RDoc HTML seems to contain a more comprehensive and inter-linked set of pages than those available from `rubydoc.info`. You can (re)generate the internal [`rdoc` documentation](https://ruby-doc.org/stdlib-2.4.1/libdoc/rdoc/rdoc/RDoc/Markup.html#label-Supported+Formats) with: + +```shell +bundle exec rake rerdoc +``` + +...yes, that's `rerdoc` - Re-R-Doc - then open `docs/rdoc/index.html`. + + + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/RIPAGlobal/ooxml_encryption. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b22dade --- /dev/null +++ b/Rakefile @@ -0,0 +1,16 @@ +require 'rake' +require 'rspec/core/rake_task' +require 'rdoc/task' +require 'sdoc' + +RSpec::Core::RakeTask.new(:default) do | t | +end + +Rake::RDocTask.new do | rd | + rd.rdoc_files.include('README.md', 'lib/**/*.rb') + + rd.title = 'OOXML Encryption' + rd.main = 'README.md' + rd.rdoc_dir = 'docs/rdoc' + rd.generator = 'sdoc' +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..350a4c2 --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "ooxml_encryption" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/ooxml_encryption.rb b/lib/ooxml_encryption.rb new file mode 100644 index 0000000..48f8399 --- /dev/null +++ b/lib/ooxml_encryption.rb @@ -0,0 +1,663 @@ +# frozen_string_literal: true + +require 'ostruct' +require 'stringio' +require 'base64' +require 'openssl' + +require 'simple_cfb' +require 'nokogiri' + +require_relative "ooxml_encryption/version" + +class OoxmlEncryption + + # First 4 bytes are the version number, second 4 bytes are reserved. + # + ENCRYPTION_INFO_PREFIX = [0x04, 0x00, 0x04, 0x00, 0x40, 0x00, 0x00, 0x00].pack('C*') + PACKAGE_ENCRYPTION_CHUNK_SIZE = 4096 + + # First 8 bytes are the size of the stream. + # + PACKAGE_OFFSET = 8 + + # Block keys used for encryption. + # + BLOCK_KEYS = OpenStruct.new({ + dataIntegrity: OpenStruct.new({ + hmacKey: [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6].pack('C*'), + hmacValue: [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33].pack('C*') + }), + verifierHash: OpenStruct.new({ + input: [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79].pack('C*'), + value: [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e].pack('C*') + }), + key: [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6].pack('C*'), + }) + + # This aids testing to ensure that deterministic results are generated. The + # performance overhead of a Proc is extremely low, especially compared to the + # overhead of the encryption or decryption calculations. + # + RANDOM_BYTES_PROC = if ENV['RACK_ENV'] = 'test' + -> (count) { '0' * count } + else + -> (count) { SecureRandom.random_bytes(count) } + end + + # Convenience accessor to binary-encoded NUL byte. + # + NUL = String.new("\x00", encoding: 'ASCII-8BIT') + + # =========================================================================== + # ENCRYPTION + # =========================================================================== + + # Encrypt an unencrypted OOXML blob, returning the binary result. This is NOT + # a streaming operation as the CFB format used to store the data is not + # streamable itself - see the SimpleCfb gem for details. + # + # +unencrypted_spreadsheet_data+:: Unprotected OOXML input data as an + # ASCII-8BIT encoded string. + # + # +password+:: Password for encryption in your choice of + # string encoding. + # + def encrypt(unencrypted_spreadsheet_data:, password:) + + # Generate a random key to use to encrypt the document. Excel uses 32 bytes. We'll use the password to encrypt this key. + # N.B. The number of bits needs to correspond to an algorithm available in crypto (e.g. aes-256-cbc). + # + package_key = RANDOM_BYTES_PROC.call(32) + + # Create the encryption info. We'll use this for all of the encryption operations and for building the encryption info XML entry + encryption_info = OpenStruct.new({ + package: OpenStruct.new({ # Info on the encryption of the package. + cipherAlgorithm: 'AES', # Cipher algorithm to use. Excel uses AES. + cipherChaining: 'ChainingModeCBC', # Cipher chaining mode to use. Excel uses CBC. + saltValue: RANDOM_BYTES_PROC.call(16), # Random value to use as encryption salt. Excel uses 16 bytes. + hashAlgorithm: 'SHA512', # Hash algorithm to use. Excel uses SHA512. + hashSize: 64, # The size of the hash in bytes. SHA512 results in 64-byte hashes + blockSize: 16, # The number of bytes used to encrypt one block of data. It MUST be at least 2, no greater than 4096, and a multiple of 2. Excel uses 16 + keyBits: package_key.size * 8 # The number of bits in the package key. + }), + key: OpenStruct.new({ # Info on the encryption of the package key. + cipherAlgorithm: 'AES', # Cipher algorithm to use. Excel uses AES. + cipherChaining: 'ChainingModeCBC', # Cipher chaining mode to use. Excel uses CBC. + saltValue: RANDOM_BYTES_PROC.call(16), # Random value to use as encryption salt. Excel uses 16 bytes. + hashAlgorithm: 'SHA512', # Hash algorithm to use. Excel uses SHA512. + hashSize: 64, # The size of the hash in bytes. SHA512 results in 64-byte hashes + blockSize: 16, # The number of bytes used to encrypt one block of data. It MUST be at least 2, no greater than 4096, and a multiple of 2. Excel uses 16 + spinCount: 100000, # The number of times to iterate on a hash of a password. It MUST NOT be greater than 10,000,000. Excel uses 100,000. + keyBits: 256 # The length of the key to generate from the password. Must be a multiple of 8. Excel uses 256. + }) + }) + + # ========================================================================= + # PACKAGE ENCRYPTION + # ========================================================================= + + # Encrypt package using the package key + # + encrypted_package = self.crypt_package( + method: :encrypt, + cipher_algorithm: encryption_info.package.cipherAlgorithm, + cipher_chaining: encryption_info.package.cipherChaining, + hash_algorithm: encryption_info.package.hashAlgorithm, + block_size: encryption_info.package.blockSize, + salt_value: encryption_info.package.saltValue, + key: package_key, + input: unencrypted_spreadsheet_data + ) + + # ========================================================================= + # KEY ENCRYPTION + # ========================================================================= + + # Convert the password to an encryption key + # + key = self.convert_password_to_key( + password, + encryption_info.key.hashAlgorithm, + encryption_info.key.saltValue, + encryption_info.key.spinCount, + encryption_info.key.keyBits, + BLOCK_KEYS.key + ) + + # Encrypt the package key + # + encryption_info.key.encryptedKeyValue = self.crypt( + method: :encrypt, + cipher_algorithm: encryption_info.key.cipherAlgorithm, + cipher_chaining: encryption_info.key.cipherChaining, + key: key, + iv: encryption_info.key.saltValue, + input: package_key + ) + + # ========================================================================= + # VERIFIER HASH + # ========================================================================= + + # Create a random byte array for hashing + # + verifier_hash_input = RANDOM_BYTES_PROC.call(16) + + # Create an encryption key from the password for the input + # + verifier_hash_input_key = self.convert_password_to_key( + password, + encryption_info.key.hashAlgorithm, + encryption_info.key.saltValue, + encryption_info.key.spinCount, + encryption_info.key.keyBits, + BLOCK_KEYS.verifierHash.input + ) + + # Use the key to encrypt the verifier input + # + encryption_info.key.encryptedVerifierHashInput = self.crypt( + method: :encrypt, + cipher_algorithm: encryption_info.key.cipherAlgorithm, + cipher_chaining: encryption_info.key.cipherChaining, + key: verifier_hash_input_key, + iv: encryption_info.key.saltValue, + input: verifier_hash_input + ) + + # Create a hash of the input + # + verifier_hash_value = self.hash( + encryption_info.key.hashAlgorithm, + verifier_hash_input + ) + + # Create an encryption key from the password for the hash + # + verifier_hash_value_key = self.convert_password_to_key( + password, + encryption_info.key.hashAlgorithm, + encryption_info.key.saltValue, + encryption_info.key.spinCount, + encryption_info.key.keyBits, + BLOCK_KEYS.verifierHash.value + ) + + # Use the key to encrypt the hash value + # + encryption_info.key.encryptedVerifierHashValue = self.crypt( + method: :encrypt, + cipher_algorithm: encryption_info.key.cipherAlgorithm, + cipher_chaining: encryption_info.key.cipherChaining, + key: verifier_hash_value_key, + iv: encryption_info.key.saltValue, + input: verifier_hash_value + ) + + # ========================================================================= + # DATA INTEGRITY + # ========================================================================= + + # Create the data integrity fields used by clients for integrity checks. + # + # First generate a random array of bytes to use in HMAC. The documentation + # says that we should use the same length as the key salt, but Excel seems + # to use 64. + # + hmac_key = RANDOM_BYTES_PROC.call(64) + + # Then create an initialization vector using the package encryption info + # and the appropriate block key. + # + hmac_key_iv = self.create_iv( + encryption_info.package.hashAlgorithm, + encryption_info.package.saltValue, + encryption_info.package.blockSize, + BLOCK_KEYS.dataIntegrity.hmacKey + ) + + # Use the package key and the IV to encrypt the HMAC key + # + encrypted_hmac_key = self.crypt( + method: :encrypt, + cipher_algorithm: encryption_info.package.cipherAlgorithm, + cipher_chaining: encryption_info.package.cipherChaining, + key: package_key, + iv: hmac_key_iv, + input: hmac_key + ) + + # Create the HMAC + # + hmac_value = self.hmac( + encryption_info.package.hashAlgorithm, + hmac_key, + encrypted_package + ) + + # Generate an initialization vector for encrypting the resulting HMAC value + # + hmac_value_iv = self.create_iv( + encryption_info.package.hashAlgorithm, + encryption_info.package.saltValue, + encryption_info.package.blockSize, + BLOCK_KEYS.dataIntegrity.hmacValue + ) + + # Encrypt that value + # + encrypted_hmac_value = self.crypt( + method: :encrypt, + cipher_algorithm: encryption_info.package.cipherAlgorithm, + cipher_chaining: encryption_info.package.cipherChaining, + key: package_key, + iv: hmac_value_iv, + input: hmac_value + ) + + # Add the encrypted key and value into the encryption info + # + encryption_info.dataIntegrity = OpenStruct.new({ + encryptedHmacKey: encrypted_hmac_key, + encryptedHmacValue: encrypted_hmac_value + }) + + # ========================================================================= + # OUTPUT + # ========================================================================= + + # Build the encryption info XML string + # + encryption_info = self.build_encryption_info(encryption_info) + + # Create a new CFB file + # + cfb = SimpleCfb.new + + # Add the encryption info and encrypted package + # + cfb.add('EncryptionInfo', encryption_info ) + cfb.add('EncryptedPackage', encrypted_package) + + # Compile and return the CFB file data + # + return cfb.write() + end + + # =========================================================================== + # DECRYPTION + # =========================================================================== + + # Decrypt encrypted file data assumed to be the result of a prior encryption. + # Returns the decrypted OOXML blob. This is NOT a streaming operation as the + # underlying CFB file format used to store the data is not streamable itself; + # see the SimpleCFB gem for details. + # + # +encrypted_spreadsheeet_data+:: Encrypted OOXML input data as an ASCII-8BIT + # encoded string. + # + # +password+:: Password for decryption in your choice of + # string encoding. + # + def decrypt(encrypted_spreadsheeet_data:, password:) + cfb = SimpleCfb.new + cfb.parse!(StringIO.new(encrypted_spreadsheeet_data)) + + encryption_info_xml = cfb.file_index.find { |f| f.name == 'EncryptionInfo' }&.content + encrypted_spreadsheet_data = cfb.file_index.find { |f| f.name == 'EncryptedPackage' }&.content + + raise 'Cannot read file - corrupted or not encrypted?' if encryption_info_xml.nil? || encrypted_spreadsheet_data.nil? + + encryption_info_xml.delete_prefix!(ENCRYPTION_INFO_PREFIX) + encryption_info = self.parse_encryption_info(encryption_info_xml) + + # Convert the password into an encryption key + # + key = self.convert_password_to_key( + password, + encryption_info.key.hashAlgorithm, + encryption_info.key.saltValue, + encryption_info.key.spinCount, + encryption_info.key.keyBits, + BLOCK_KEYS.key + ) + + # Use the key to decrypt the package key + # + package_key = self.crypt( + method: :decrypt, + cipher_algorithm: encryption_info.key.cipherAlgorithm, + cipher_chaining: encryption_info.key.cipherChaining, + key: key, + iv: encryption_info.key.saltValue, + input: encryption_info.key.encryptedKeyValue + ) + + # Use the package key to decrypt the package + # + return self.crypt_package( + method: :decrypt, + cipher_algorithm: encryption_info.package.cipherAlgorithm, + cipher_chaining: encryption_info.package.cipherChaining, + hash_algorithm: encryption_info.package.hashAlgorithm, + block_size: encryption_info.package.blockSize, + salt_value: encryption_info.package.saltValue, + key: package_key, + input: encrypted_spreadsheet_data + ) + end + + # =========================================================================== + # PRIVATE INSTANCE METHODS + # =========================================================================== + # + private + + # Calculate a hash of the concatenated buffers with the given algorithm. + # @param {string} algorithm - The hash algorithm. + # @param {Array.} buffers - The buffers to concat and hash + # @returns {Buffer} The hash + # + def hash(algorithm, *buffers) + hash = Digest.const_get(algorithm).new + + buffers.each do | buffer | + hash.update(buffer) + end + + return hash.digest() + end + + # Calculate an HMAC of the concatenated buffers with the given algorithm and key + # @param {string} algorithm - The algorithm. + # @param {string} key - The key + # @param {Array.} buffers - The buffer to concat and HMAC + # @returns {Buffer} The HMAC + # + def hmac(algorithm, key, *buffers) + digest = OpenSSL::Digest.const_get(algorithm).new + hmac = OpenSSL::HMAC.new(key, digest) + + buffers.each do | buffer | + hmac << buffer + end + + return hmac.digest() + end + + # Encrypt or decrypt input. Named parameters are: + # + # +method+:: Symbol :encrypt or :decrypt + # +cipher_algorithm+:: Cipher algorithm + # +cipher_chaining+:: Cipher chaining mode + # +key+:: Encryption key + # +iv+:: Initialization vector + # +input+:: Input data + # + # Returns the result. Input and output values are all ASCII-8BIT encoded + # Strings unless noted. + # + def crypt( + method:, + cipher_algorithm:, + cipher_chaining:, + key:, + iv:, + input: + ) + cipher_name = "#{cipher_algorithm}-#{key.size * 8}-#{cipher_chaining.gsub("ChainingMode", "")}" + cipher = OpenSSL::Cipher.new(cipher_name).send(method) + + cipher.key = key + cipher.iv = iv + cipher.padding = 0 # JavaScript source sets auto-padding to 'false', so padding is manually managed + + return cipher.update(input) + cipher.final() + end + + # Encrypt or decrypt an entire package. Named parameters are: + # + # +method+:: Symbol :encrypt or :decrypt + # +cipher_algorithm+:: Cipher algorithm + # +cipher_chaining+:: Cipher chaining mode + # +hash_algorithm+:: Hash algorithm + # +block_size+:: IV block size + # +salt_value+:: Salt + # +key+:: Encryption key + # +input+:: Package data + # + # Returns the result. Input and output values are all ASCII-8BIT encoded + # Strings unless noted. + # + def crypt_package( + method:, + cipher_algorithm:, + cipher_chaining:, + hash_algorithm:, + block_size:, + salt_value:, + key:, + input: + ) + # The package is encoded in chunks. Encrypt/decrypt each and concat. + # + output = String.new(encoding: 'ASCII-8BIT') + offset = method == :encrypt ? 0 : PACKAGE_OFFSET + + i = start = finish = 0 + + while finish < input.bytesize + start = finish + finish = start + PACKAGE_ENCRYPTION_CHUNK_SIZE + finish = input.bytesize if finish > input.bytesize + + input_chunk = input[(start + offset)...(finish + offset)] + + # Pad the chunk if it is not an integer multiple of the block size + # + remainder = input_chunk.bytesize % block_size; + + if remainder > 0 + input_chunk << NUL * (block_size - remainder) + end + + # Create the initialization vector + # + iv = self.create_iv(hash_algorithm, salt_value, block_size, i) + + # Encrypt the chunk and add it to the array + # + output << self.crypt( + method: method, + cipher_algorithm: cipher_algorithm, + cipher_chaining: cipher_chaining, + key: key, + iv: iv, + input: input_chunk + ) + + i += 1 + end + + # Put the length of the package in the first 8 bytes if encrypting. + # Truncate the data to the size in the prefix if decrypting. + # + if method == :encrypt + length_data = [input.bytesize].pack('V') # Unsigned 32-bit little-endian, bitwise truncated + length_data << NUL * 4 + + output.insert(0, length_data) + else + length = SimpleCfb.get_uint32le(input) + output.slice!(length..) # (sic.) + end + + return output; + end + + # Convert a password into an encryption key + # @param {string} password - The password + # @param {string} hash_algorithm - The hash algoritm + # @param {Buffer} salt_value - The salt value + # @param {number} spin_count - The spin count + # @param {number} key_bits - The length of the key in bits + # @param {Buffer} block_key - The block key + # @returns {Buffer} The encryption key + # + def convert_password_to_key(password, hash_algorithm, salt_value, spin_count, key_bits, block_key) + + # Password must little-endian UTF-16 + # + password_buffer = password.encode('UTF-16LE').force_encoding('ASCII-8BIT') + + # Generate the initial hash + # + key = self.hash(hash_algorithm, salt_value, password_buffer) + + # Now regenerate until spin count + # + 0.upto(spin_count - 1) do | i | + iterator = [i].pack('V') # Unsigned 32-bit little-endian, bitwise truncated + key = self.hash(hash_algorithm, iterator, key) + end + + # Now generate the final hash + # + key = self.hash(hash_algorithm, key, block_key) + + # Truncate or pad (with 0x36) as needed to get to length of key bits + # + key_bytes = key_bits / 8 + pad_byte = String.new("\x36", encoding: 'ASCII-8BIT') + + if key.bytesize < key_bytes + key = key.ljust(key_bytes, pad_byte) + elsif key.bytesize > key_bytes + key = key[0...key_bytes] + end + + return key + end + + # Create an initialization vector (IV) + # @param {string} hash_algorithm - The hash algorithm + # @param {Buffer} salt_value - The salt value + # @param {number} block_size - The size of the IV + # @param {Buffer|number} block_key - The block key or an int to convert to a buffer + # @returns {Buffer} The IV + # + def create_iv(hash_algorithm, salt_value, block_size, block_key) + unless block_key.is_a?(String) # (...then assume integer) + block_key = [block_key].pack('V') # Unsigned 32-bit little-endian, bitwise truncated + end + + # Create the initialization vector by hashing the salt with the block key. + # Truncate or pad as needed to meet the block size. + # + iv = self.hash(hash_algorithm, salt_value, block_key) + pad_byte = String.new("\x36", encoding: 'ASCII-8BIT') + + if iv.bytesize < block_size + iv = iv.ljust(block_size, pad_byte) + elsif iv.bytesize > block_size + iv = iv[0...block_size] + end + + return iv + end + + # Build the encryption info XML/buffer + # @param {{}} encryption_info - The encryption info object + # @returns {Buffer} The buffer + # + def build_encryption_info(encryption_info) + + # Map the object into the appropriate XML structure. Buffers are encoded in base 64. + # + preamble = Nokogiri::XML('') + builder = Nokogiri::XML::Builder.with(preamble) do |xml| + xml.encryption({ + xmlns: 'http://schemas.microsoft.com/office/2006/encryption', + 'xmlns:p': 'http://schemas.microsoft.com/office/2006/keyEncryptor/password', + 'xmlns:c': 'http://schemas.microsoft.com/office/2006/keyEncryptor/certificate' + }) do + + xml.keyData({ + saltSize: encryption_info.package.saltValue.length, + blockSize: encryption_info.package.blockSize, + keyBits: encryption_info.package.keyBits, + hashSize: encryption_info.package.hashSize, + cipherAlgorithm: encryption_info.package.cipherAlgorithm, + cipherChaining: encryption_info.package.cipherChaining, + hashAlgorithm: encryption_info.package.hashAlgorithm, + saltValue: Base64.strict_encode64(encryption_info.package.saltValue) + }) + + xml.dataIntegrity({ + encryptedHmacKey: Base64.strict_encode64(encryption_info.dataIntegrity.encryptedHmacKey), + encryptedHmacValue: Base64.strict_encode64(encryption_info.dataIntegrity.encryptedHmacValue) + }) + + xml.keyEncryptors do + xml.keyEncryptor(uri: 'http://schemas.microsoft.com/office/2006/keyEncryptor/password') do + xml.send('p:encryptedKey', { + spinCount: encryption_info.key.spinCount, + saltSize: encryption_info.key.saltValue.length, + blockSize: encryption_info.key.blockSize, + keyBits: encryption_info.key.keyBits, + hashSize: encryption_info.key.hashSize, + cipherAlgorithm: encryption_info.key.cipherAlgorithm, + cipherChaining: encryption_info.key.cipherChaining, + hashAlgorithm: encryption_info.key.hashAlgorithm, + saltValue: Base64.strict_encode64(encryption_info.key.saltValue), + encryptedVerifierHashInput: Base64.strict_encode64(encryption_info.key.encryptedVerifierHashInput), + encryptedVerifierHashValue: Base64.strict_encode64(encryption_info.key.encryptedVerifierHashValue), + encryptedKeyValue: Base64.strict_encode64(encryption_info.key.encryptedKeyValue) + }) + end + end + end + end + + xml_string = builder + .to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML) + .gsub("\n", '') + .force_encoding('ASCII-8BIT') + + return ENCRYPTION_INFO_PREFIX + xml_string + end + + # Pass a string containing raw encryption info data read from CFB input, + # with prefix removed (so basically - pass an XML document). + # + # Returns the parsed result as nested OpenStructs. + # + def parse_encryption_info(encryption_info_xml) + doc = Nokogiri.parse(encryption_info_xml, nil, 'UTF-8') + key_data_node = doc.css('keyData').first + key_encryptors_node = doc.css('keyEncryptors').first + key_encryptor_node = key_encryptors_node.css('keyEncryptor').first + encrypted_key_node = key_encryptor_node.xpath('//p:encryptedKey').first + + return OpenStruct.new({ + package: OpenStruct.new({ + cipherAlgorithm: key_data_node.attributes['cipherAlgorithm'].value, + cipherChaining: key_data_node.attributes['cipherChaining'].value, + saltValue: Base64.decode64(key_data_node.attributes['saltValue'].value), + hashAlgorithm: key_data_node.attributes['hashAlgorithm'].value, + blockSize: key_data_node.attributes['blockSize'].value.to_i + }), + key: OpenStruct.new({ + encryptedKeyValue: Base64.decode64(encrypted_key_node.attributes['encryptedKeyValue'].value), + cipherAlgorithm: encrypted_key_node.attributes['cipherAlgorithm'].value, + cipherChaining: encrypted_key_node.attributes['cipherChaining'].value, + saltValue: Base64.decode64(encrypted_key_node.attributes['saltValue'].value), + hashAlgorithm: encrypted_key_node.attributes['hashAlgorithm'].value, + spinCount: encrypted_key_node.attributes['spinCount'].value.to_i, + keyBits: encrypted_key_node.attributes['keyBits'].value.to_i + }) + }) + end + +end diff --git a/lib/ooxml_encryption/version.rb b/lib/ooxml_encryption/version.rb new file mode 100644 index 0000000..90e5b1c --- /dev/null +++ b/lib/ooxml_encryption/version.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class OoxmlEncryption + + # Gem version. If this changes, be sure to re-run "bundle install" or + # "bundle update". + # + VERSION = '0.1.0' + + # Date for VERSION. If this changes, be sure to re-run "bundle install" + # or "bundle update". + # + DATE = '2022-08-08' + +end diff --git a/ooxml_encryption.gemspec b/ooxml_encryption.gemspec new file mode 100644 index 0000000..456f022 --- /dev/null +++ b/ooxml_encryption.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'lib/ooxml_encryption/version' + +Gem::Specification.new do | s | + s.name = 'ooxml_encryption' + s.version = OoxmlEncryption::VERSION + s.date = OoxmlEncryption::DATE + s.authors = ['RIPA Global', 'Andrew David Hodgkinson'] + s.email = ['dev@ripaglobal.com'] + + s.summary = 'Encrypt or decrypt OOXML spreadsheets' + s.description = 'Encrypt or decrypt OOXML spreadsheets' + s.homepage = 'https://www.ripaglobal.com/' + s.license = 'MIT' + s.required_ruby_version = '>= 2.7.0' + + s.metadata['homepage_uri' ] = s.homepage + s.metadata['source_code_uri'] = 'https://github.com/RIPAGlobal/ooxml_encryption/' + s.metadata['bug_tracker_uri'] = 'https://github.com/RIPAGlobal/ooxml_encryption/issues/' + s.metadata['changelog_uri' ] = 'https://github.com/RIPAGlobal/ooxml_encryption/blob/master/CHANGELOG.md' + + s.files = Dir['lib/**/*', 'LICENSE', 'Rakefile', 'README.md'] + + s.bindir = 'exe' + s.executables = s.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + s.require_paths = ['lib'] + + s.add_dependency 'simple_cfb', '~> 0.1' + s.add_dependency 'openssl', '~> 3.0' + s.add_dependency 'nokogiri', '~> 1.13' + + s.add_development_dependency 'simplecov-rcov', '~> 0.2' + s.add_development_dependency 'rdoc', '~> 6.4' + s.add_development_dependency 'rspec-rails', '~> 5.0' + s.add_development_dependency 'byebug', '~> 11.1' + s.add_development_dependency 'doggo', '~> 1.2' +end diff --git a/spec/fixtures/node_encrypted_spreadsheet.xlsx b/spec/fixtures/node_encrypted_spreadsheet.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f476c024e388218092b89dcbaada29f195a33de4 GIT binary patch literal 6144 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 Hgh2lUgL^2E literal 0 HcmV?d00001 diff --git a/spec/lib/ooxml_encryption_spec.rb b/spec/lib/ooxml_encryption_spec.rb new file mode 100644 index 0000000..f170ee3 --- /dev/null +++ b/spec/lib/ooxml_encryption_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe OoxmlEncryption do + + # In test mode, all normally-random values are set to fixed strings. The + # Node-based JavaScript code was set to do the same and asked to encrypt + # the same spreadsheet. We therefore expect absolutely byte-identical + # results. + # + context 'end-to-end encryption' do + it 'generates the same data as its JavaScript counterpart' do + input_xlsx = File.open(File.join(__dir__, '..', 'fixtures', 'unencrypted_spreadsheet.xlsx'), 'rb') { | file | file.read() } + node_output = File.open(File.join(__dir__, '..', 'fixtures', 'node_encrypted_spreadsheet.xlsx'), 'rb') { | file | file.read() } + + encryptor = described_class.new + encrypted = encryptor.encrypt( + unencrypted_spreadsheet_data: input_xlsx, + password: 'secret' + ) + + expect(encrypted).to match(node_output) + end + end # "context 'end-to-end encryption' do" + + context 'end-to-end decryption' do + it 'generates the same data as its JavaScript counterpart' do + input_enc_xlsx = File.open(File.join(__dir__, '..', 'fixtures', 'node_encrypted_spreadsheet.xlsx'), 'rb') { | file | file.read() } + node_unenc_output = File.open(File.join(__dir__, '..', 'fixtures', 'unencrypted_spreadsheet.xlsx'), 'rb') { | file | file.read() } + + encryptor = described_class.new + decrypted = encryptor.decrypt( + encrypted_spreadsheeet_data: input_enc_xlsx, + password: 'secret' + ) + + expect(decrypted).to match(node_unenc_output) + end + end # "context 'end-to-end decryption' do" +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..3788cdf --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,32 @@ +# ============================================================================ +# PREAMBLE +# ============================================================================ + +ENV['RACK_ENV'] = 'test' + +# Get code coverage reports: +# +# https://github.com/colszowka/simplecov#getting-started +# +# "Load and launch SimpleCov at the very top of your test/test_helper.rb (or +# spec_helper.rb [...])" +# +require 'simplecov' +SimpleCov.start() + +require 'byebug' +require 'ooxml_encryption' + +# ============================================================================ +# MAIN RSPEC CONFIGURATION +# ============================================================================ + +RSpec.configure do | config | + config.disable_monkey_patching! + + config.color = true + config.tty = true + config.order = :random + + Kernel.srand config.seed +end