ooxml_encryption 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 27bb698ed6d311eec6ffc5f2eb1e41377b7aad14c3ce1ba8f27349a5ce89ff4b
4
+ data.tar.gz: 5f7fd916ee8fb2e44471a610b2a95283cd371f99e19bd96a538e383687a63f88
5
+ SHA512:
6
+ metadata.gz: adba2187f0c54e67b09c3e5d8ddcfc8727f4136ed256213e02a2f3c76097f9ddbaaebbd34d30b525e6f3d53db840c3ec6ae9102cfb3e27b24673c625b2159184
7
+ data.tar.gz: 6ab0e8d6dc6feba0fc5a260696f09c14bb36ac45686b1d693e886f32db0de70c56bf0dbdebedcc92fb2c60c45091b6065a702d739b354383abe6ebf88375083d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 RIPA Global
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # OOXML Encryption
2
+
3
+ [![License](https://img.shields.io/badge/license-mit-blue)](https://opensource.org/licenses/MIT)
4
+ [![Build Status](https://github.com/RIPAGlobal/ooxml_encryption/actions/workflows/ruby.yml/badge.svg)](https://github.com/RIPAGlobal/ooxml_encryption/actions)
5
+
6
+ ## Overview
7
+
8
+ 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:
9
+
10
+ * https://github.com/dtjohnson/xlsx-populate
11
+
12
+ 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.
13
+
14
+ For low-level file format details, see:
15
+
16
+ * https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto
17
+ * https://github.com/RIPAGlobal/simple_cfb/
18
+
19
+
20
+
21
+ ## Installation
22
+
23
+ Install the gem and add to the application's `Gemfile` by executing:
24
+
25
+ $ bundle add ooxml_encryption
26
+
27
+ If bundler is not being used to manage dependencies, install the gem by executing:
28
+
29
+ $ gem install ooxml_encryption
30
+
31
+
32
+
33
+ ## Usage
34
+ ### Encrypting a spreadsheet
35
+
36
+ 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:
37
+
38
+ ```ruby
39
+ require 'ooxml_encryption'
40
+
41
+ encryptor = OoxmlEncryption.new
42
+ encrypted_data = encryptor.encrypt(
43
+ unencrypted_spreadsheet_data: unprotected_data,
44
+ password: password
45
+ )
46
+ ```
47
+
48
+ ...then write `encrypted_data` to a file using binary mode, e.g.:
49
+
50
+ ```ruby
51
+ File.open('/path/to/encrypted.xlsx', 'wb') do | file |
52
+ file.write(encrypted_data)
53
+ end
54
+ ```
55
+
56
+ ### Decrypting a spreadsheet
57
+
58
+ 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:
59
+
60
+ ```
61
+ require 'ooxml_encryption'
62
+
63
+ decryptor = OoxmlEncryption.new
64
+ decrypted_data = decryptor.decrypt(
65
+ encrypted_spreadsheet_data: encrypted_data,
66
+ password: password
67
+ )
68
+ ```
69
+
70
+ ...then write `decrypted_data` to a file using binary mode, e.g.:
71
+
72
+ ```ruby
73
+ File.open('/path/to/unprotected.xlsx', 'wb') do | file |
74
+ file.write(decrypted_data)
75
+ end
76
+ ```
77
+
78
+
79
+
80
+ ## Resource overhead
81
+
82
+ 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.
83
+
84
+
85
+
86
+ ## Security concerns
87
+
88
+ 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.
89
+
90
+
91
+
92
+ ## Development
93
+
94
+ Use `bundle exec rspec` to run tests. Run `bin/console` for an interactive prompt that will allow you to experiment.
95
+
96
+ 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).
97
+
98
+ 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:
99
+
100
+ ```shell
101
+ bundle exec rake rerdoc
102
+ ```
103
+
104
+ ...yes, that's `rerdoc` - Re-R-Doc - then open `docs/rdoc/index.html`.
105
+
106
+
107
+
108
+ ## Contributing
109
+
110
+ Bug reports and pull requests are welcome on GitHub at https://github.com/RIPAGlobal/ooxml_encryption.
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ require 'rake'
2
+ require 'bundler/gem_tasks'
3
+ require 'rspec/core/rake_task'
4
+ require 'rdoc/task'
5
+ require 'sdoc'
6
+
7
+ RSpec::Core::RakeTask.new(:default) do | t |
8
+ end
9
+
10
+ Rake::RDocTask.new do | rd |
11
+ rd.rdoc_files.include('README.md', 'lib/**/*.rb')
12
+
13
+ rd.title = 'OOXML Encryption'
14
+ rd.main = 'README.md'
15
+ rd.rdoc_dir = 'docs/rdoc'
16
+ rd.generator = 'sdoc'
17
+ end
@@ -0,0 +1,668 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require 'stringio'
5
+ require 'base64'
6
+ require 'openssl'
7
+
8
+ require 'simple_cfb'
9
+ require 'nokogiri'
10
+
11
+ # Ported from https://github.com/dtjohnson/xlsx-populate.
12
+ #
13
+ # Implements OOXML whole-file encryption and decryption.
14
+ #
15
+ # For low-level file format details, see:
16
+ # https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto.
17
+ #
18
+ class OoxmlEncryption
19
+
20
+ # First 4 bytes are the version number, second 4 bytes are reserved.
21
+ #
22
+ ENCRYPTION_INFO_PREFIX = [0x04, 0x00, 0x04, 0x00, 0x40, 0x00, 0x00, 0x00].pack('C*')
23
+ PACKAGE_ENCRYPTION_CHUNK_SIZE = 4096
24
+
25
+ # First 8 bytes are the size of the stream.
26
+ #
27
+ PACKAGE_OFFSET = 8
28
+
29
+ # Block keys used for encryption.
30
+ #
31
+ BLOCK_KEYS = OpenStruct.new({
32
+ dataIntegrity: OpenStruct.new({
33
+ hmacKey: [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6].pack('C*'),
34
+ hmacValue: [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33].pack('C*')
35
+ }),
36
+ verifierHash: OpenStruct.new({
37
+ input: [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79].pack('C*'),
38
+ value: [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e].pack('C*')
39
+ }),
40
+ key: [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6].pack('C*'),
41
+ })
42
+
43
+ # This aids testing to ensure that deterministic results are generated. The
44
+ # performance overhead of a Proc is extremely low, especially compared to the
45
+ # overhead of the encryption or decryption calculations.
46
+ #
47
+ RANDOM_BYTES_PROC = if ENV['RACK_ENV'] = 'test'
48
+ -> (count) { '0' * count }
49
+ else
50
+ -> (count) { SecureRandom.random_bytes(count) }
51
+ end
52
+
53
+ # Convenience accessor to binary-encoded NUL byte.
54
+ #
55
+ NUL = String.new("\x00", encoding: 'ASCII-8BIT')
56
+
57
+ # ===========================================================================
58
+ # ENCRYPTION
59
+ # ===========================================================================
60
+
61
+ # Encrypt an unencrypted OOXML blob, returning the binary result. This is NOT
62
+ # a streaming operation as the CFB format used to store the data is not
63
+ # streamable itself - see the SimpleCfb gem for details.
64
+ #
65
+ # +unencrypted_spreadsheet_data+:: Unprotected OOXML input data as an
66
+ # ASCII-8BIT encoded string.
67
+ #
68
+ # +password+:: Password for encryption in your choice of
69
+ # string encoding.
70
+ #
71
+ def encrypt(unencrypted_spreadsheet_data:, password:)
72
+
73
+ # Generate a random key to use to encrypt the document. Excel uses 32 bytes. We'll use the password to encrypt this key.
74
+ # N.B. The number of bits needs to correspond to an algorithm available in crypto (e.g. aes-256-cbc).
75
+ #
76
+ package_key = RANDOM_BYTES_PROC.call(32)
77
+
78
+ # Create the encryption info. We'll use this for all of the encryption operations and for building the encryption info XML entry
79
+ encryption_info = OpenStruct.new({
80
+ package: OpenStruct.new({ # Info on the encryption of the package.
81
+ cipherAlgorithm: 'AES', # Cipher algorithm to use. Excel uses AES.
82
+ cipherChaining: 'ChainingModeCBC', # Cipher chaining mode to use. Excel uses CBC.
83
+ saltValue: RANDOM_BYTES_PROC.call(16), # Random value to use as encryption salt. Excel uses 16 bytes.
84
+ hashAlgorithm: 'SHA512', # Hash algorithm to use. Excel uses SHA512.
85
+ hashSize: 64, # The size of the hash in bytes. SHA512 results in 64-byte hashes
86
+ 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
87
+ keyBits: package_key.size * 8 # The number of bits in the package key.
88
+ }),
89
+ key: OpenStruct.new({ # Info on the encryption of the package key.
90
+ cipherAlgorithm: 'AES', # Cipher algorithm to use. Excel uses AES.
91
+ cipherChaining: 'ChainingModeCBC', # Cipher chaining mode to use. Excel uses CBC.
92
+ saltValue: RANDOM_BYTES_PROC.call(16), # Random value to use as encryption salt. Excel uses 16 bytes.
93
+ hashAlgorithm: 'SHA512', # Hash algorithm to use. Excel uses SHA512.
94
+ hashSize: 64, # The size of the hash in bytes. SHA512 results in 64-byte hashes
95
+ 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
96
+ 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.
97
+ keyBits: 256 # The length of the key to generate from the password. Must be a multiple of 8. Excel uses 256.
98
+ })
99
+ })
100
+
101
+ # =========================================================================
102
+ # PACKAGE ENCRYPTION
103
+ # =========================================================================
104
+
105
+ # Encrypt package using the package key
106
+ #
107
+ encrypted_package = self.crypt_package(
108
+ method: :encrypt,
109
+ cipher_algorithm: encryption_info.package.cipherAlgorithm,
110
+ cipher_chaining: encryption_info.package.cipherChaining,
111
+ hash_algorithm: encryption_info.package.hashAlgorithm,
112
+ block_size: encryption_info.package.blockSize,
113
+ salt_value: encryption_info.package.saltValue,
114
+ key: package_key,
115
+ input: unencrypted_spreadsheet_data
116
+ )
117
+
118
+ # =========================================================================
119
+ # KEY ENCRYPTION
120
+ # =========================================================================
121
+
122
+ # Convert the password to an encryption key
123
+ #
124
+ key = self.convert_password_to_key(
125
+ password,
126
+ encryption_info.key.hashAlgorithm,
127
+ encryption_info.key.saltValue,
128
+ encryption_info.key.spinCount,
129
+ encryption_info.key.keyBits,
130
+ BLOCK_KEYS.key
131
+ )
132
+
133
+ # Encrypt the package key
134
+ #
135
+ encryption_info.key.encryptedKeyValue = self.crypt(
136
+ method: :encrypt,
137
+ cipher_algorithm: encryption_info.key.cipherAlgorithm,
138
+ cipher_chaining: encryption_info.key.cipherChaining,
139
+ key: key,
140
+ iv: encryption_info.key.saltValue,
141
+ input: package_key
142
+ )
143
+
144
+ # =========================================================================
145
+ # VERIFIER HASH
146
+ # =========================================================================
147
+
148
+ # Create a random byte array for hashing
149
+ #
150
+ verifier_hash_input = RANDOM_BYTES_PROC.call(16)
151
+
152
+ # Create an encryption key from the password for the input
153
+ #
154
+ verifier_hash_input_key = self.convert_password_to_key(
155
+ password,
156
+ encryption_info.key.hashAlgorithm,
157
+ encryption_info.key.saltValue,
158
+ encryption_info.key.spinCount,
159
+ encryption_info.key.keyBits,
160
+ BLOCK_KEYS.verifierHash.input
161
+ )
162
+
163
+ # Use the key to encrypt the verifier input
164
+ #
165
+ encryption_info.key.encryptedVerifierHashInput = self.crypt(
166
+ method: :encrypt,
167
+ cipher_algorithm: encryption_info.key.cipherAlgorithm,
168
+ cipher_chaining: encryption_info.key.cipherChaining,
169
+ key: verifier_hash_input_key,
170
+ iv: encryption_info.key.saltValue,
171
+ input: verifier_hash_input
172
+ )
173
+
174
+ # Create a hash of the input
175
+ #
176
+ verifier_hash_value = self.hash(
177
+ encryption_info.key.hashAlgorithm,
178
+ verifier_hash_input
179
+ )
180
+
181
+ # Create an encryption key from the password for the hash
182
+ #
183
+ verifier_hash_value_key = self.convert_password_to_key(
184
+ password,
185
+ encryption_info.key.hashAlgorithm,
186
+ encryption_info.key.saltValue,
187
+ encryption_info.key.spinCount,
188
+ encryption_info.key.keyBits,
189
+ BLOCK_KEYS.verifierHash.value
190
+ )
191
+
192
+ # Use the key to encrypt the hash value
193
+ #
194
+ encryption_info.key.encryptedVerifierHashValue = self.crypt(
195
+ method: :encrypt,
196
+ cipher_algorithm: encryption_info.key.cipherAlgorithm,
197
+ cipher_chaining: encryption_info.key.cipherChaining,
198
+ key: verifier_hash_value_key,
199
+ iv: encryption_info.key.saltValue,
200
+ input: verifier_hash_value
201
+ )
202
+
203
+ # =========================================================================
204
+ # DATA INTEGRITY
205
+ # =========================================================================
206
+
207
+ # Create the data integrity fields used by clients for integrity checks.
208
+ #
209
+ # First generate a random array of bytes to use in HMAC. The documentation
210
+ # says that we should use the same length as the key salt, but Excel seems
211
+ # to use 64.
212
+ #
213
+ hmac_key = RANDOM_BYTES_PROC.call(64)
214
+
215
+ # Then create an initialization vector using the package encryption info
216
+ # and the appropriate block key.
217
+ #
218
+ hmac_key_iv = self.create_iv(
219
+ encryption_info.package.hashAlgorithm,
220
+ encryption_info.package.saltValue,
221
+ encryption_info.package.blockSize,
222
+ BLOCK_KEYS.dataIntegrity.hmacKey
223
+ )
224
+
225
+ # Use the package key and the IV to encrypt the HMAC key
226
+ #
227
+ encrypted_hmac_key = self.crypt(
228
+ method: :encrypt,
229
+ cipher_algorithm: encryption_info.package.cipherAlgorithm,
230
+ cipher_chaining: encryption_info.package.cipherChaining,
231
+ key: package_key,
232
+ iv: hmac_key_iv,
233
+ input: hmac_key
234
+ )
235
+
236
+ # Create the HMAC
237
+ #
238
+ hmac_value = self.hmac(
239
+ encryption_info.package.hashAlgorithm,
240
+ hmac_key,
241
+ encrypted_package
242
+ )
243
+
244
+ # Generate an initialization vector for encrypting the resulting HMAC value
245
+ #
246
+ hmac_value_iv = self.create_iv(
247
+ encryption_info.package.hashAlgorithm,
248
+ encryption_info.package.saltValue,
249
+ encryption_info.package.blockSize,
250
+ BLOCK_KEYS.dataIntegrity.hmacValue
251
+ )
252
+
253
+ # Encrypt that value
254
+ #
255
+ encrypted_hmac_value = self.crypt(
256
+ method: :encrypt,
257
+ cipher_algorithm: encryption_info.package.cipherAlgorithm,
258
+ cipher_chaining: encryption_info.package.cipherChaining,
259
+ key: package_key,
260
+ iv: hmac_value_iv,
261
+ input: hmac_value
262
+ )
263
+
264
+ # Add the encrypted key and value into the encryption info
265
+ #
266
+ encryption_info.dataIntegrity = OpenStruct.new({
267
+ encryptedHmacKey: encrypted_hmac_key,
268
+ encryptedHmacValue: encrypted_hmac_value
269
+ })
270
+
271
+ # =========================================================================
272
+ # OUTPUT
273
+ # =========================================================================
274
+
275
+ # Build the encryption info XML string
276
+ #
277
+ encryption_info = self.build_encryption_info(encryption_info)
278
+
279
+ # Create a new CFB file
280
+ #
281
+ cfb = SimpleCfb.new
282
+
283
+ # Add the encryption info and encrypted package
284
+ #
285
+ cfb.add('EncryptionInfo', encryption_info )
286
+ cfb.add('EncryptedPackage', encrypted_package)
287
+
288
+ # Compile and return the CFB file data
289
+ #
290
+ return cfb.write()
291
+ end
292
+
293
+ # ===========================================================================
294
+ # DECRYPTION
295
+ # ===========================================================================
296
+
297
+ # Decrypt encrypted file data assumed to be the result of a prior encryption.
298
+ # Returns the decrypted OOXML blob. This is NOT a streaming operation as the
299
+ # underlying CFB file format used to store the data is not streamable itself;
300
+ # see the SimpleCFB gem for details.
301
+ #
302
+ # +encrypted_spreadsheet_data+:: Encrypted OOXML input data as an ASCII-8BIT
303
+ # encoded string.
304
+ #
305
+ # +password+:: Password for decryption in your choice of
306
+ # string encoding.
307
+ #
308
+ def decrypt(encrypted_spreadsheet_data:, password:)
309
+ cfb = SimpleCfb.new
310
+ cfb.parse!(StringIO.new(encrypted_spreadsheet_data))
311
+
312
+ encryption_info_xml = cfb.file_index.find { |f| f.name == 'EncryptionInfo' }&.content
313
+ encrypted_spreadsheet_data = cfb.file_index.find { |f| f.name == 'EncryptedPackage' }&.content
314
+
315
+ raise 'Cannot read file - corrupted or not encrypted?' if encryption_info_xml.nil? || encrypted_spreadsheet_data.nil?
316
+
317
+ encryption_info_xml.delete_prefix!(ENCRYPTION_INFO_PREFIX)
318
+ encryption_info = self.parse_encryption_info(encryption_info_xml)
319
+
320
+ # Convert the password into an encryption key
321
+ #
322
+ key = self.convert_password_to_key(
323
+ password,
324
+ encryption_info.key.hashAlgorithm,
325
+ encryption_info.key.saltValue,
326
+ encryption_info.key.spinCount,
327
+ encryption_info.key.keyBits,
328
+ BLOCK_KEYS.key
329
+ )
330
+
331
+ # Use the key to decrypt the package key
332
+ #
333
+ package_key = self.crypt(
334
+ method: :decrypt,
335
+ cipher_algorithm: encryption_info.key.cipherAlgorithm,
336
+ cipher_chaining: encryption_info.key.cipherChaining,
337
+ key: key,
338
+ iv: encryption_info.key.saltValue,
339
+ input: encryption_info.key.encryptedKeyValue
340
+ )
341
+
342
+ # Use the package key to decrypt the package
343
+ #
344
+ return self.crypt_package(
345
+ method: :decrypt,
346
+ cipher_algorithm: encryption_info.package.cipherAlgorithm,
347
+ cipher_chaining: encryption_info.package.cipherChaining,
348
+ hash_algorithm: encryption_info.package.hashAlgorithm,
349
+ block_size: encryption_info.package.blockSize,
350
+ salt_value: encryption_info.package.saltValue,
351
+ key: package_key,
352
+ input: encrypted_spreadsheet_data
353
+ )
354
+ end
355
+
356
+ # ===========================================================================
357
+ # PRIVATE INSTANCE METHODS
358
+ # ===========================================================================
359
+ #
360
+ private
361
+
362
+ # Calculate a hash of the concatenated buffers with the given algorithm.
363
+ # @param {string} algorithm - The hash algorithm.
364
+ # @param {Array.<Buffer>} buffers - The buffers to concat and hash
365
+ # @returns {Buffer} The hash
366
+ #
367
+ def hash(algorithm, *buffers)
368
+ hash = Digest.const_get(algorithm).new
369
+
370
+ buffers.each do | buffer |
371
+ hash.update(buffer)
372
+ end
373
+
374
+ return hash.digest()
375
+ end
376
+
377
+ # Calculate an HMAC of the concatenated buffers with the given algorithm and key
378
+ # @param {string} algorithm - The algorithm.
379
+ # @param {string} key - The key
380
+ # @param {Array.<Buffer>} buffers - The buffer to concat and HMAC
381
+ # @returns {Buffer} The HMAC
382
+ #
383
+ def hmac(algorithm, key, *buffers)
384
+ digest = OpenSSL::Digest.const_get(algorithm).new
385
+ hmac = OpenSSL::HMAC.new(key, digest)
386
+
387
+ buffers.each do | buffer |
388
+ hmac << buffer
389
+ end
390
+
391
+ return hmac.digest()
392
+ end
393
+
394
+ # Encrypt or decrypt input. Named parameters are:
395
+ #
396
+ # +method+:: Symbol :encrypt or :decrypt
397
+ # +cipher_algorithm+:: Cipher algorithm
398
+ # +cipher_chaining+:: Cipher chaining mode
399
+ # +key+:: Encryption key
400
+ # +iv+:: Initialization vector
401
+ # +input+:: Input data
402
+ #
403
+ # Returns the result. Input and output values are all ASCII-8BIT encoded
404
+ # Strings unless noted.
405
+ #
406
+ def crypt(
407
+ method:,
408
+ cipher_algorithm:,
409
+ cipher_chaining:,
410
+ key:,
411
+ iv:,
412
+ input:
413
+ )
414
+ cipher_name = "#{cipher_algorithm}-#{key.size * 8}-#{cipher_chaining.gsub("ChainingMode", "")}"
415
+ cipher = OpenSSL::Cipher.new(cipher_name).send(method)
416
+
417
+ cipher.key = key
418
+ cipher.iv = iv
419
+ cipher.padding = 0 # JavaScript source sets auto-padding to 'false', so padding is manually managed
420
+
421
+ return cipher.update(input) + cipher.final()
422
+ end
423
+
424
+ # Encrypt or decrypt an entire package. Named parameters are:
425
+ #
426
+ # +method+:: Symbol :encrypt or :decrypt
427
+ # +cipher_algorithm+:: Cipher algorithm
428
+ # +cipher_chaining+:: Cipher chaining mode
429
+ # +hash_algorithm+:: Hash algorithm
430
+ # +block_size+:: IV block size
431
+ # +salt_value+:: Salt
432
+ # +key+:: Encryption key
433
+ # +input+:: Package data
434
+ #
435
+ # Returns the result. Input and output values are all ASCII-8BIT encoded
436
+ # Strings unless noted.
437
+ #
438
+ def crypt_package(
439
+ method:,
440
+ cipher_algorithm:,
441
+ cipher_chaining:,
442
+ hash_algorithm:,
443
+ block_size:,
444
+ salt_value:,
445
+ key:,
446
+ input:
447
+ )
448
+ # The package is encoded in chunks. Encrypt/decrypt each and concat.
449
+ #
450
+ output = String.new(encoding: 'ASCII-8BIT')
451
+ offset = method == :encrypt ? 0 : PACKAGE_OFFSET
452
+
453
+ i = start = finish = 0
454
+
455
+ while finish < input.bytesize
456
+ start = finish
457
+ finish = start + PACKAGE_ENCRYPTION_CHUNK_SIZE
458
+ finish = input.bytesize if finish > input.bytesize
459
+
460
+ input_chunk = input[(start + offset)...(finish + offset)]
461
+
462
+ # Pad the chunk if it is not an integer multiple of the block size
463
+ #
464
+ remainder = input_chunk.bytesize % block_size;
465
+
466
+ if remainder > 0
467
+ input_chunk << NUL * (block_size - remainder)
468
+ end
469
+
470
+ # Create the initialization vector
471
+ #
472
+ iv = self.create_iv(hash_algorithm, salt_value, block_size, i)
473
+
474
+ # Encrypt the chunk and add it to the array
475
+ #
476
+ output << self.crypt(
477
+ method: method,
478
+ cipher_algorithm: cipher_algorithm,
479
+ cipher_chaining: cipher_chaining,
480
+ key: key,
481
+ iv: iv,
482
+ input: input_chunk
483
+ )
484
+
485
+ i += 1
486
+ end
487
+
488
+ # Put the length of the package in the first 8 bytes if encrypting.
489
+ # Truncate the data to the size in the prefix if decrypting.
490
+ #
491
+ if method == :encrypt
492
+ length_data = [input.bytesize].pack('V') # Unsigned 32-bit little-endian, bitwise truncated
493
+ length_data << NUL * 4
494
+
495
+ output.insert(0, length_data)
496
+ else
497
+ length = SimpleCfb.get_uint32le(input)
498
+ output.slice!(length..) # (sic.)
499
+ end
500
+
501
+ return output;
502
+ end
503
+
504
+ # Convert a password into an encryption key
505
+ # @param {string} password - The password
506
+ # @param {string} hash_algorithm - The hash algoritm
507
+ # @param {Buffer} salt_value - The salt value
508
+ # @param {number} spin_count - The spin count
509
+ # @param {number} key_bits - The length of the key in bits
510
+ # @param {Buffer} block_key - The block key
511
+ # @returns {Buffer} The encryption key
512
+ #
513
+ def convert_password_to_key(password, hash_algorithm, salt_value, spin_count, key_bits, block_key)
514
+
515
+ # Password must little-endian UTF-16
516
+ #
517
+ password_buffer = password.encode('UTF-16LE').force_encoding('ASCII-8BIT')
518
+
519
+ # Generate the initial hash
520
+ #
521
+ key = self.hash(hash_algorithm, salt_value, password_buffer)
522
+
523
+ # Now regenerate until spin count
524
+ #
525
+ 0.upto(spin_count - 1) do | i |
526
+ iterator = [i].pack('V') # Unsigned 32-bit little-endian, bitwise truncated
527
+ key = self.hash(hash_algorithm, iterator, key)
528
+ end
529
+
530
+ # Now generate the final hash
531
+ #
532
+ key = self.hash(hash_algorithm, key, block_key)
533
+
534
+ # Truncate or pad (with 0x36) as needed to get to length of key bits
535
+ #
536
+ key_bytes = key_bits / 8
537
+ pad_byte = String.new("\x36", encoding: 'ASCII-8BIT')
538
+
539
+ if key.bytesize < key_bytes
540
+ key = key.ljust(key_bytes, pad_byte)
541
+ elsif key.bytesize > key_bytes
542
+ key = key[0...key_bytes]
543
+ end
544
+
545
+ return key
546
+ end
547
+
548
+ # Create an initialization vector (IV)
549
+ # @param {string} hash_algorithm - The hash algorithm
550
+ # @param {Buffer} salt_value - The salt value
551
+ # @param {number} block_size - The size of the IV
552
+ # @param {Buffer|number} block_key - The block key or an int to convert to a buffer
553
+ # @returns {Buffer} The IV
554
+ #
555
+ def create_iv(hash_algorithm, salt_value, block_size, block_key)
556
+ unless block_key.is_a?(String) # (...then assume integer)
557
+ block_key = [block_key].pack('V') # Unsigned 32-bit little-endian, bitwise truncated
558
+ end
559
+
560
+ # Create the initialization vector by hashing the salt with the block key.
561
+ # Truncate or pad as needed to meet the block size.
562
+ #
563
+ iv = self.hash(hash_algorithm, salt_value, block_key)
564
+ pad_byte = String.new("\x36", encoding: 'ASCII-8BIT')
565
+
566
+ if iv.bytesize < block_size
567
+ iv = iv.ljust(block_size, pad_byte)
568
+ elsif iv.bytesize > block_size
569
+ iv = iv[0...block_size]
570
+ end
571
+
572
+ return iv
573
+ end
574
+
575
+ # Build the encryption info XML/buffer
576
+ # @param {{}} encryption_info - The encryption info object
577
+ # @returns {Buffer} The buffer
578
+ #
579
+ def build_encryption_info(encryption_info)
580
+
581
+ # Map the object into the appropriate XML structure. Buffers are encoded in base 64.
582
+ #
583
+ preamble = Nokogiri::XML('<?xml version = "1.0" encoding="UTF-8" standalone="yes"?>')
584
+ builder = Nokogiri::XML::Builder.with(preamble) do |xml|
585
+ xml.encryption({
586
+ xmlns: 'http://schemas.microsoft.com/office/2006/encryption',
587
+ 'xmlns:p': 'http://schemas.microsoft.com/office/2006/keyEncryptor/password',
588
+ 'xmlns:c': 'http://schemas.microsoft.com/office/2006/keyEncryptor/certificate'
589
+ }) do
590
+
591
+ xml.keyData({
592
+ saltSize: encryption_info.package.saltValue.length,
593
+ blockSize: encryption_info.package.blockSize,
594
+ keyBits: encryption_info.package.keyBits,
595
+ hashSize: encryption_info.package.hashSize,
596
+ cipherAlgorithm: encryption_info.package.cipherAlgorithm,
597
+ cipherChaining: encryption_info.package.cipherChaining,
598
+ hashAlgorithm: encryption_info.package.hashAlgorithm,
599
+ saltValue: Base64.strict_encode64(encryption_info.package.saltValue)
600
+ })
601
+
602
+ xml.dataIntegrity({
603
+ encryptedHmacKey: Base64.strict_encode64(encryption_info.dataIntegrity.encryptedHmacKey),
604
+ encryptedHmacValue: Base64.strict_encode64(encryption_info.dataIntegrity.encryptedHmacValue)
605
+ })
606
+
607
+ xml.keyEncryptors do
608
+ xml.keyEncryptor(uri: 'http://schemas.microsoft.com/office/2006/keyEncryptor/password') do
609
+ xml.send('p:encryptedKey', {
610
+ spinCount: encryption_info.key.spinCount,
611
+ saltSize: encryption_info.key.saltValue.length,
612
+ blockSize: encryption_info.key.blockSize,
613
+ keyBits: encryption_info.key.keyBits,
614
+ hashSize: encryption_info.key.hashSize,
615
+ cipherAlgorithm: encryption_info.key.cipherAlgorithm,
616
+ cipherChaining: encryption_info.key.cipherChaining,
617
+ hashAlgorithm: encryption_info.key.hashAlgorithm,
618
+ saltValue: Base64.strict_encode64(encryption_info.key.saltValue),
619
+ encryptedVerifierHashInput: Base64.strict_encode64(encryption_info.key.encryptedVerifierHashInput),
620
+ encryptedVerifierHashValue: Base64.strict_encode64(encryption_info.key.encryptedVerifierHashValue),
621
+ encryptedKeyValue: Base64.strict_encode64(encryption_info.key.encryptedKeyValue)
622
+ })
623
+ end
624
+ end
625
+ end
626
+ end
627
+
628
+ xml_string = builder
629
+ .to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
630
+ .gsub("\n", '')
631
+ .force_encoding('ASCII-8BIT')
632
+
633
+ return ENCRYPTION_INFO_PREFIX + xml_string
634
+ end
635
+
636
+ # Pass a string containing raw encryption info data read from CFB input,
637
+ # with prefix removed (so basically - pass an XML document).
638
+ #
639
+ # Returns the parsed result as nested OpenStructs.
640
+ #
641
+ def parse_encryption_info(encryption_info_xml)
642
+ doc = Nokogiri.parse(encryption_info_xml, nil, 'UTF-8')
643
+ key_data_node = doc.css('keyData').first
644
+ key_encryptors_node = doc.css('keyEncryptors').first
645
+ key_encryptor_node = key_encryptors_node.css('keyEncryptor').first
646
+ encrypted_key_node = key_encryptor_node.xpath('//p:encryptedKey').first
647
+
648
+ return OpenStruct.new({
649
+ package: OpenStruct.new({
650
+ cipherAlgorithm: key_data_node.attributes['cipherAlgorithm'].value,
651
+ cipherChaining: key_data_node.attributes['cipherChaining'].value,
652
+ saltValue: Base64.decode64(key_data_node.attributes['saltValue'].value),
653
+ hashAlgorithm: key_data_node.attributes['hashAlgorithm'].value,
654
+ blockSize: key_data_node.attributes['blockSize'].value.to_i
655
+ }),
656
+ key: OpenStruct.new({
657
+ encryptedKeyValue: Base64.decode64(encrypted_key_node.attributes['encryptedKeyValue'].value),
658
+ cipherAlgorithm: encrypted_key_node.attributes['cipherAlgorithm'].value,
659
+ cipherChaining: encrypted_key_node.attributes['cipherChaining'].value,
660
+ saltValue: Base64.decode64(encrypted_key_node.attributes['saltValue'].value),
661
+ hashAlgorithm: encrypted_key_node.attributes['hashAlgorithm'].value,
662
+ spinCount: encrypted_key_node.attributes['spinCount'].value.to_i,
663
+ keyBits: encrypted_key_node.attributes['keyBits'].value.to_i
664
+ })
665
+ })
666
+ end
667
+
668
+ end # "class OoxmlEncryption"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OoxmlEncryption
4
+
5
+ # Gem version. If this changes, be sure to re-run "bundle install" or
6
+ # "bundle update".
7
+ #
8
+ VERSION = '0.3.0'
9
+
10
+ # Date for VERSION. If this changes, be sure to re-run "bundle install"
11
+ # or "bundle update".
12
+ #
13
+ DATE = '2024-10-22'
14
+
15
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ooxml_encryption/version"
4
+ require_relative "ooxml_encryption/ooxml_encryption.rb"
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ooxml_encryption
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - RIPA Global
8
+ - Andrew David Hodgkinson
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2024-10-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: simple_cfb
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '0.1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '0.1'
28
+ - !ruby/object:Gem::Dependency
29
+ name: openssl
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '3.0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: nokogiri
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '1.13'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '1.13'
56
+ - !ruby/object:Gem::Dependency
57
+ name: simplecov-rcov
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '0.3'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '0.3'
70
+ - !ruby/object:Gem::Dependency
71
+ name: rdoc
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '6.7'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '6.7'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rspec-rails
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '7.0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '7.0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: debug
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '1.9'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '1.9'
112
+ - !ruby/object:Gem::Dependency
113
+ name: doggo
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '1.4'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '1.4'
126
+ description: Encrypt or decrypt OOXML spreadsheets
127
+ email:
128
+ - dev@ripaglobal.com
129
+ executables: []
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - LICENSE
134
+ - README.md
135
+ - Rakefile
136
+ - lib/ooxml_encryption.rb
137
+ - lib/ooxml_encryption/ooxml_encryption.rb
138
+ - lib/ooxml_encryption/version.rb
139
+ homepage: https://www.ripaglobal.com/
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ homepage_uri: https://www.ripaglobal.com/
144
+ source_code_uri: https://github.com/RIPAGlobal/ooxml_encryption/
145
+ bug_tracker_uri: https://github.com/RIPAGlobal/ooxml_encryption/issues/
146
+ changelog_uri: https://github.com/RIPAGlobal/ooxml_encryption/blob/master/CHANGELOG.md
147
+ post_install_message:
148
+ rdoc_options: []
149
+ require_paths:
150
+ - lib
151
+ required_ruby_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: 2.7.0
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ requirements: []
162
+ rubygems_version: 3.5.16
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Encrypt or decrypt OOXML spreadsheets
166
+ test_files: []