xlsxrb 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -2
- data/README.md +125 -36
- data/Rakefile +36 -7
- data/benchmark.rb +32 -4
- data/docs/ARCHITECTURE.md +32 -30
- data/docs/DEVELOPMENT.md +11 -3
- data/docs/QUALITY_ASSURANCE.md +3 -4
- data/docs/SPEC_SOURCES.md +7 -1
- data/docs/visual/VisualGallery.md +157 -157
- data/docs/wasm/ruby.wasm +0 -0
- data/lib/ruby_lsp/xlsxrb/addon.rb +1 -1
- data/lib/ruby_lsp/xlsxrb/completion_listener.rb +11 -13
- data/lib/xlsxrb/elements/coordinate_access.rb +101 -0
- data/lib/xlsxrb/elements/row.rb +2 -2
- data/lib/xlsxrb/elements/workbook.rb +20 -2
- data/lib/xlsxrb/elements/worksheet.rb +94 -124
- data/lib/xlsxrb/elements.rb +1 -0
- data/lib/xlsxrb/ooxml/cfb.rb +490 -0
- data/lib/xlsxrb/ooxml/crypto/agile.rb +285 -0
- data/lib/xlsxrb/ooxml/crypto/standard.rb +152 -0
- data/lib/xlsxrb/ooxml/crypto.rb +60 -0
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +21 -15
- data/lib/xlsxrb/stream_row.rb +181 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb.rb +271 -122
- data/sig/generated/xlsxrb/elements/coordinate_access.rbs +70 -0
- data/sig/generated/xlsxrb/elements/worksheet.rbs +119 -14
- data/sig/generated/xlsxrb/ooxml/cfb.rbs +121 -0
- data/sig/generated/xlsxrb/ooxml/crypto/agile.rbs +46 -0
- data/sig/generated/xlsxrb/ooxml/crypto/standard.rbs +28 -0
- data/sig/generated/xlsxrb/ooxml/crypto.rbs +17 -0
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +2 -0
- data/sig/generated/xlsxrb/stream_row.rbs +127 -0
- data/sig/generated/xlsxrb.rbs +135 -71
- data/vendor/sdk_runner/Program.cs +3 -1
- metadata +13 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "rexml/document"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
module Xlsxrb
|
|
10
|
+
module Ooxml
|
|
11
|
+
module Crypto
|
|
12
|
+
# Implements Microsoft Office Agile Encryption specified in [MS-OFFCRYPTO] Section 2.3.4.
|
|
13
|
+
class Agile
|
|
14
|
+
BLOCK_KEY_VERIFIER_INPUT = "\xfe\xa7\xd2\x76\x3b\x4b\x9e\x79".b.freeze
|
|
15
|
+
BLOCK_KEY_VERIFIER_VALUE = "\xd7\xaa\x0f\x6d\x30\x61\x34\x4e".b.freeze
|
|
16
|
+
BLOCK_KEY_KEY = "\x14\x6e\x0b\xe7\xab\xac\xd0\xd6".b.freeze
|
|
17
|
+
BLOCK_KEY_INTEGRITY_KEY = "\x5f\xb2\xad\x01\x0c\xb9\xe1\xf6".b.freeze
|
|
18
|
+
BLOCK_KEY_INTEGRITY_VALUE = "\xa0\x67\x7f\x02\xb2\x2c\x84\x33".b.freeze
|
|
19
|
+
|
|
20
|
+
SEGMENT_SIZE = 4096
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Decrypts an encrypted package given the EncryptionInfo stream data and EncryptedPackage stream data.
|
|
24
|
+
def decrypt(encryption_info_bytes, encrypted_package_bytes, password)
|
|
25
|
+
info = parse_encryption_info(encryption_info_bytes)
|
|
26
|
+
password_str = password.to_s
|
|
27
|
+
|
|
28
|
+
digest = digest_for(info[:hash_algorithm])
|
|
29
|
+
cipher_name = cipher_name_for(info[:cipher_algorithm], info[:key_bits])
|
|
30
|
+
|
|
31
|
+
# 1. Derive intermediate hash H_final from password and keyEncryptor salt
|
|
32
|
+
h_final = derive_h_final(digest, password_str, info[:encryptor_salt], info[:spin_count])
|
|
33
|
+
|
|
34
|
+
# 2. Verify password with verifier input and hash
|
|
35
|
+
key_bytes = info[:key_bits] / 8
|
|
36
|
+
k_ver_input = derive_key(digest, h_final, BLOCK_KEY_VERIFIER_INPUT, key_bytes)
|
|
37
|
+
verifier_input = aes_decrypt(cipher_name, k_ver_input, info[:encryptor_salt], info[:encrypted_verifier_hash_input])
|
|
38
|
+
|
|
39
|
+
expected_hash = digest.digest(verifier_input)
|
|
40
|
+
|
|
41
|
+
k_ver_val = derive_key(digest, h_final, BLOCK_KEY_VERIFIER_VALUE, key_bytes)
|
|
42
|
+
decrypted_hash = aes_decrypt(cipher_name, k_ver_val, info[:encryptor_salt], info[:encrypted_verifier_hash_value])
|
|
43
|
+
|
|
44
|
+
hash_size = info[:hash_size]
|
|
45
|
+
raise Xlsxrb::InvalidPasswordError, "Password verification failed" unless OpenSSL.secure_compare(decrypted_hash[0, hash_size], expected_hash[0, hash_size])
|
|
46
|
+
|
|
47
|
+
# 3. Decrypt package master key
|
|
48
|
+
k_key = derive_key(digest, h_final, BLOCK_KEY_KEY, key_bytes)
|
|
49
|
+
package_key = aes_decrypt(cipher_name, k_key, info[:encryptor_salt], info[:encrypted_key_value])[0, key_bytes]
|
|
50
|
+
|
|
51
|
+
# 4. Decrypt package stream
|
|
52
|
+
raise Xlsxrb::DecryptionError, "Encrypted package stream is too short" if encrypted_package_bytes.bytesize < 8
|
|
53
|
+
|
|
54
|
+
total_size = encrypted_package_bytes[0, 8].unpack1("Q<")
|
|
55
|
+
raise Xlsxrb::DecryptionError, "Encrypted package size is invalid or exceeds limits" if total_size.negative? || total_size > 0x400_000_000
|
|
56
|
+
|
|
57
|
+
encrypted_data = encrypted_package_bytes[8..] || "".b
|
|
58
|
+
|
|
59
|
+
pkg_cipher_name = cipher_name_for(info[:key_data_cipher_algorithm], info[:key_data_key_bits])
|
|
60
|
+
pkg_digest = digest_for(info[:key_data_hash_algorithm])
|
|
61
|
+
pkg_salt = info[:key_data_salt]
|
|
62
|
+
|
|
63
|
+
decrypted = +""
|
|
64
|
+
num_blocks = (encrypted_data.bytesize + SEGMENT_SIZE - 1) / SEGMENT_SIZE
|
|
65
|
+
num_blocks.times do |i|
|
|
66
|
+
chunk = encrypted_data[i * SEGMENT_SIZE, SEGMENT_SIZE]
|
|
67
|
+
break if chunk.nil? || chunk.empty?
|
|
68
|
+
|
|
69
|
+
iv = pkg_digest.digest(pkg_salt + [i].pack("V"))[0, 16]
|
|
70
|
+
decrypted << aes_decrypt_block(pkg_cipher_name, package_key, iv, chunk)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
decrypted[0, total_size] || "".b
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Encrypts a plain zip payload with the given password into EncryptionInfo and EncryptedPackage streams.
|
|
77
|
+
def encrypt(plain_bytes, password)
|
|
78
|
+
password_str = password.to_s
|
|
79
|
+
spin_count = 100_000
|
|
80
|
+
key_bits = 256
|
|
81
|
+
key_bytes = key_bits / 8
|
|
82
|
+
hash_algorithm = "SHA512"
|
|
83
|
+
cipher_algorithm = "AES"
|
|
84
|
+
cipher_chaining = "ChainingModeCBC"
|
|
85
|
+
hash_size = 64
|
|
86
|
+
block_size = 16
|
|
87
|
+
|
|
88
|
+
digest = digest_for(hash_algorithm)
|
|
89
|
+
cipher_name = "aes-256-cbc"
|
|
90
|
+
|
|
91
|
+
encryptor_salt = SecureRandom.random_bytes(16)
|
|
92
|
+
key_data_salt = SecureRandom.random_bytes(16)
|
|
93
|
+
package_key = SecureRandom.random_bytes(key_bytes)
|
|
94
|
+
verifier_input = SecureRandom.random_bytes(16)
|
|
95
|
+
|
|
96
|
+
# Derive intermediate hash H_final
|
|
97
|
+
h_final = derive_h_final(digest, password_str, encryptor_salt, spin_count)
|
|
98
|
+
|
|
99
|
+
# Encrypt verifier input
|
|
100
|
+
k_ver_input = derive_key(digest, h_final, BLOCK_KEY_VERIFIER_INPUT, key_bytes)
|
|
101
|
+
encrypted_verifier_input = aes_encrypt(cipher_name, k_ver_input, encryptor_salt, verifier_input)
|
|
102
|
+
|
|
103
|
+
# Encrypt verifier hash value
|
|
104
|
+
expected_hash = digest.digest(verifier_input)
|
|
105
|
+
k_ver_val = derive_key(digest, h_final, BLOCK_KEY_VERIFIER_VALUE, key_bytes)
|
|
106
|
+
encrypted_verifier_val = aes_encrypt(cipher_name, k_ver_val, encryptor_salt, expected_hash)
|
|
107
|
+
|
|
108
|
+
# Encrypt package master key
|
|
109
|
+
k_key = derive_key(digest, h_final, BLOCK_KEY_KEY, key_bytes)
|
|
110
|
+
encrypted_key_value = aes_encrypt(cipher_name, k_key, encryptor_salt, package_key)
|
|
111
|
+
|
|
112
|
+
# Encrypt plain package data in 4096-byte segments
|
|
113
|
+
encrypted_package_stream = +""
|
|
114
|
+
encrypted_package_stream << [plain_bytes.bytesize].pack("Q<")
|
|
115
|
+
|
|
116
|
+
num_blocks = (plain_bytes.bytesize + SEGMENT_SIZE - 1) / SEGMENT_SIZE
|
|
117
|
+
num_blocks.times do |i|
|
|
118
|
+
chunk = plain_bytes[i * SEGMENT_SIZE, SEGMENT_SIZE] || "".b
|
|
119
|
+
# Pad last chunk to multiple of block_size if necessary
|
|
120
|
+
if (chunk.bytesize % block_size) != 0
|
|
121
|
+
pad_len = block_size - (chunk.bytesize % block_size)
|
|
122
|
+
chunk += ("\x00".b * pad_len)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
iv = digest.digest(key_data_salt + [i].pack("V"))[0, 16]
|
|
126
|
+
encrypted_package_stream << aes_encrypt_block(cipher_name, package_key, iv, chunk)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Data integrity HMAC
|
|
130
|
+
hmac_key = SecureRandom.random_bytes(64)
|
|
131
|
+
hmac_value = OpenSSL::HMAC.digest(hash_algorithm, hmac_key, plain_bytes)
|
|
132
|
+
|
|
133
|
+
k_integ_key = derive_key(digest, h_final, BLOCK_KEY_INTEGRITY_KEY, key_bytes)
|
|
134
|
+
encrypted_hmac_key = aes_encrypt(cipher_name, k_integ_key, encryptor_salt, hmac_key)
|
|
135
|
+
|
|
136
|
+
k_integ_val = derive_key(digest, h_final, BLOCK_KEY_INTEGRITY_VALUE, key_bytes)
|
|
137
|
+
encrypted_hmac_val = aes_encrypt(cipher_name, k_integ_val, encryptor_salt, hmac_value)
|
|
138
|
+
|
|
139
|
+
# Build EncryptionInfo XML
|
|
140
|
+
xml = <<~XML
|
|
141
|
+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
142
|
+
<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">
|
|
143
|
+
<keyData saltSize="#{key_data_salt.bytesize}" blockSize="#{block_size}" keyBits="#{key_bits}" hashSize="#{hash_size}" cipherAlgorithm="#{cipher_algorithm}" cipherChaining="#{cipher_chaining}" hashAlgorithm="#{hash_algorithm}" saltValue="#{[key_data_salt].pack("m0")}"/>
|
|
144
|
+
<dataIntegrity encryptedHmacKey="#{[encrypted_hmac_key].pack("m0")}" encryptedHmacValue="#{[encrypted_hmac_val].pack("m0")}"/>
|
|
145
|
+
<keyEncryptors>
|
|
146
|
+
<keyEncryptor uri="http://schemas.microsoft.com/office/2006/keyEncryptor/password">
|
|
147
|
+
<p:encryptedKey spinCount="#{spin_count}" saltSize="#{encryptor_salt.bytesize}" blockSize="#{block_size}" keyBits="#{key_bits}" hashSize="#{hash_size}" cipherAlgorithm="#{cipher_algorithm}" cipherChaining="#{cipher_chaining}" hashAlgorithm="#{hash_algorithm}" saltValue="#{[encryptor_salt].pack("m0")}" encryptedVerifierHashInput="#{[encrypted_verifier_input].pack("m0")}" encryptedVerifierHashValue="#{[encrypted_verifier_val].pack("m0")}" encryptedKeyValue="#{[encrypted_key_value].pack("m0")}"/>
|
|
148
|
+
</keyEncryptor>
|
|
149
|
+
</keyEncryptors>
|
|
150
|
+
</encryption>
|
|
151
|
+
XML
|
|
152
|
+
|
|
153
|
+
# EncryptionInfo stream header: versionMajor=4, versionMinor=4, flags=0x40
|
|
154
|
+
info_stream = [4, 4, 0x40].pack("vvV") + xml.b
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
"EncryptionInfo" => info_stream,
|
|
158
|
+
"EncryptedPackage" => encrypted_package_stream
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def parse_encryption_info(bytes)
|
|
165
|
+
raise Xlsxrb::DecryptionError, "EncryptionInfo stream is too short" if bytes.bytesize < 8
|
|
166
|
+
|
|
167
|
+
major, minor, = bytes[0, 8].unpack("vvV")
|
|
168
|
+
raise Xlsxrb::DecryptionError, "Unsupported encryption version #{major}.#{minor}" unless major == 4 && minor == 4
|
|
169
|
+
|
|
170
|
+
xml_str = bytes[8..] || ""
|
|
171
|
+
begin
|
|
172
|
+
doc = REXML::Document.new(xml_str)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
raise Xlsxrb::DecryptionError, "Malformed EncryptionInfo XML: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
root = doc.root
|
|
178
|
+
raise Xlsxrb::DecryptionError, "Invalid EncryptionInfo XML" unless root
|
|
179
|
+
|
|
180
|
+
key_data = root.elements["keyData"]
|
|
181
|
+
key_encryptor = root.elements["keyEncryptors/keyEncryptor/encryptedKey"] || root.elements["keyEncryptors/keyEncryptor/*"]
|
|
182
|
+
raise Xlsxrb::DecryptionError, "Missing keyEncryptor element" unless key_encryptor
|
|
183
|
+
|
|
184
|
+
spin_count = key_encryptor.attributes["spinCount"]&.to_i || 100_000
|
|
185
|
+
raise Xlsxrb::DecryptionError, "spinCount out of allowed bounds: #{spin_count}" if spin_count.negative? || spin_count > 10_000_000
|
|
186
|
+
|
|
187
|
+
key_attrs = key_data&.attributes
|
|
188
|
+
{
|
|
189
|
+
key_data_salt: key_attrs&.[]("saltValue").to_s.unpack1("m0") || "".b,
|
|
190
|
+
key_data_hash_algorithm: key_attrs&.[]("hashAlgorithm") || "SHA512",
|
|
191
|
+
key_data_cipher_algorithm: key_attrs&.[]("cipherAlgorithm") || "AES",
|
|
192
|
+
key_data_key_bits: key_attrs&.[]("keyBits")&.to_i || 256,
|
|
193
|
+
key_data_block_size: key_attrs&.[]("blockSize")&.to_i || 16,
|
|
194
|
+
|
|
195
|
+
spin_count: spin_count,
|
|
196
|
+
encryptor_salt: key_encryptor.attributes["saltValue"].to_s.unpack1("m0") || "".b,
|
|
197
|
+
hash_algorithm: key_encryptor.attributes["hashAlgorithm"] || "SHA512",
|
|
198
|
+
cipher_algorithm: key_encryptor.attributes["cipherAlgorithm"] || "AES",
|
|
199
|
+
key_bits: key_encryptor.attributes["keyBits"]&.to_i || 256,
|
|
200
|
+
hash_size: key_encryptor.attributes["hashSize"]&.to_i || 64,
|
|
201
|
+
encrypted_verifier_hash_input: key_encryptor.attributes["encryptedVerifierHashInput"].to_s.unpack1("m0") || "".b,
|
|
202
|
+
encrypted_verifier_hash_value: key_encryptor.attributes["encryptedVerifierHashValue"].to_s.unpack1("m0") || "".b,
|
|
203
|
+
encrypted_key_value: key_encryptor.attributes["encryptedKeyValue"].to_s.unpack1("m0") || "".b
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def digest_for(name)
|
|
208
|
+
case name.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
|
209
|
+
when "SHA512" then OpenSSL::Digest.new("SHA512")
|
|
210
|
+
when "SHA384" then OpenSSL::Digest.new("SHA384")
|
|
211
|
+
when "SHA256" then OpenSSL::Digest.new("SHA256")
|
|
212
|
+
when "SHA1" then OpenSSL::Digest.new("SHA1")
|
|
213
|
+
else
|
|
214
|
+
raise Xlsxrb::DecryptionError, "Unsupported hash algorithm: #{name}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def cipher_name_for(algo, key_bits)
|
|
219
|
+
case algo.to_s.upcase
|
|
220
|
+
when "AES"
|
|
221
|
+
"aes-#{key_bits}-cbc"
|
|
222
|
+
else
|
|
223
|
+
raise Xlsxrb::DecryptionError, "Unsupported cipher algorithm: #{algo}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def derive_h_final(digest, password, salt, spin_count)
|
|
228
|
+
pw_utf16 = password.encode("UTF-16LE").b
|
|
229
|
+
h = digest.digest(salt + pw_utf16)
|
|
230
|
+
|
|
231
|
+
spin_count.times do |i|
|
|
232
|
+
h = digest.digest([i].pack("V") + h)
|
|
233
|
+
end
|
|
234
|
+
h
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def derive_key(digest, h_final, block_key, key_bytes)
|
|
238
|
+
digest.digest(h_final + block_key)[0, key_bytes]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def aes_decrypt(cipher_name, key, init_vector, data)
|
|
242
|
+
cipher = OpenSSL::Cipher.new(cipher_name)
|
|
243
|
+
cipher.decrypt
|
|
244
|
+
cipher.key = key
|
|
245
|
+
cipher.iv = init_vector[0, 16]
|
|
246
|
+
cipher.padding = 0
|
|
247
|
+
cipher.update(data) + cipher.final
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def aes_encrypt(cipher_name, key, init_vector, data)
|
|
251
|
+
cipher = OpenSSL::Cipher.new(cipher_name)
|
|
252
|
+
cipher.encrypt
|
|
253
|
+
cipher.key = key
|
|
254
|
+
cipher.iv = init_vector[0, 16]
|
|
255
|
+
cipher.padding = 0
|
|
256
|
+
# Ensure data is multiple of 16 bytes
|
|
257
|
+
if (data.bytesize % 16) != 0
|
|
258
|
+
pad_len = 16 - (data.bytesize % 16)
|
|
259
|
+
data += ("\x00".b * pad_len)
|
|
260
|
+
end
|
|
261
|
+
cipher.update(data) + cipher.final
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def aes_decrypt_block(cipher_name, key, init_vector, data)
|
|
265
|
+
cipher = OpenSSL::Cipher.new(cipher_name)
|
|
266
|
+
cipher.decrypt
|
|
267
|
+
cipher.key = key
|
|
268
|
+
cipher.iv = init_vector[0, 16]
|
|
269
|
+
cipher.padding = 0
|
|
270
|
+
cipher.update(data) + cipher.final
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def aes_encrypt_block(cipher_name, key, init_vector, data)
|
|
274
|
+
cipher = OpenSSL::Cipher.new(cipher_name)
|
|
275
|
+
cipher.encrypt
|
|
276
|
+
cipher.key = key
|
|
277
|
+
cipher.iv = init_vector[0, 16]
|
|
278
|
+
cipher.padding = 0
|
|
279
|
+
cipher.update(data) + cipher.final
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Xlsxrb
|
|
9
|
+
module Ooxml
|
|
10
|
+
module Crypto
|
|
11
|
+
# Implements Microsoft Office Standard Encryption specified in [MS-OFFCRYPTO] Section 2.3.6.
|
|
12
|
+
class Standard
|
|
13
|
+
CSP_NAME = "Microsoft Enhanced RSA and AES Cryptographic Provider\x00".encode("UTF-16LE").b.freeze
|
|
14
|
+
ITERATION_COUNT = 50_000
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Decrypts a Standard-encrypted package.
|
|
18
|
+
def decrypt(encryption_info_bytes, encrypted_package_bytes, password)
|
|
19
|
+
salt, encrypted_verifier, verifier_hash_size, encrypted_verifier_hash = parse_encryption_info(encryption_info_bytes)
|
|
20
|
+
password_str = password.to_s
|
|
21
|
+
|
|
22
|
+
# Derive key K
|
|
23
|
+
k = derive_key(password_str, salt)
|
|
24
|
+
|
|
25
|
+
# Verify password
|
|
26
|
+
verifier = aes_ecb_decrypt(k, encrypted_verifier)
|
|
27
|
+
expected_hash = OpenSSL::Digest::SHA1.digest(verifier)
|
|
28
|
+
|
|
29
|
+
decrypted_hash = aes_ecb_decrypt(k, encrypted_verifier_hash)[0, verifier_hash_size]
|
|
30
|
+
raise Xlsxrb::InvalidPasswordError, "Standard encryption password verification failed" unless OpenSSL.secure_compare(decrypted_hash, expected_hash)
|
|
31
|
+
|
|
32
|
+
# Decrypt package stream
|
|
33
|
+
raise Xlsxrb::DecryptionError, "Encrypted package stream is too short" if encrypted_package_bytes.bytesize < 8
|
|
34
|
+
|
|
35
|
+
total_size = encrypted_package_bytes[0, 8].unpack1("Q<")
|
|
36
|
+
raise Xlsxrb::DecryptionError, "Encrypted package size is invalid or exceeds limits" if total_size.negative? || total_size > 0x400_000_000
|
|
37
|
+
|
|
38
|
+
encrypted_data = encrypted_package_bytes[8..] || "".b
|
|
39
|
+
|
|
40
|
+
decrypted = aes_ecb_decrypt(k, encrypted_data)
|
|
41
|
+
decrypted[0, total_size] || "".b
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Encrypts plain zip data into Standard Encryption streams.
|
|
45
|
+
def encrypt(plain_bytes, password)
|
|
46
|
+
password_str = password.to_s
|
|
47
|
+
salt = SecureRandom.random_bytes(16)
|
|
48
|
+
verifier = SecureRandom.random_bytes(16)
|
|
49
|
+
|
|
50
|
+
# Derive key K
|
|
51
|
+
k = derive_key(password_str, salt)
|
|
52
|
+
|
|
53
|
+
# Encrypt verifier
|
|
54
|
+
encrypted_verifier = aes_ecb_encrypt(k, verifier)
|
|
55
|
+
|
|
56
|
+
# Encrypt verifier hash
|
|
57
|
+
verifier_hash = OpenSSL::Digest::SHA1.digest(verifier)
|
|
58
|
+
padded_hash = verifier_hash.ljust(32, "\x00".b)
|
|
59
|
+
encrypted_verifier_hash = aes_ecb_encrypt(k, padded_hash)
|
|
60
|
+
|
|
61
|
+
# Build EncryptionInfo Stream
|
|
62
|
+
header_size = 32 + CSP_NAME.bytesize
|
|
63
|
+
info_stream = +""
|
|
64
|
+
# Version & Flags: vMajor=3, vMinor=2, Flags=0x24 (CryptoAPI AES-128)
|
|
65
|
+
info_stream << [3, 2, 0x24].pack("vvV")
|
|
66
|
+
# EncryptionHeader
|
|
67
|
+
info_stream << [header_size].pack("V")
|
|
68
|
+
info_stream << [0x24, 0, 0x0000660E, 0x00008004, 128, 0x00000018, 0, 0].pack("V8")
|
|
69
|
+
info_stream << CSP_NAME
|
|
70
|
+
# EncryptionVerifier
|
|
71
|
+
info_stream << [16].pack("V") # Salt size
|
|
72
|
+
info_stream << salt
|
|
73
|
+
info_stream << encrypted_verifier # 16 bytes
|
|
74
|
+
info_stream << [20].pack("V") # Verifier hash size (SHA-1 = 20)
|
|
75
|
+
info_stream << encrypted_verifier_hash # 32 bytes
|
|
76
|
+
|
|
77
|
+
# Build EncryptedPackage Stream
|
|
78
|
+
padded_plain = plain_bytes
|
|
79
|
+
padded_plain = padded_plain.ljust(padded_plain.bytesize + 16 - (padded_plain.bytesize % 16), "\x00".b) if (padded_plain.bytesize % 16) != 0
|
|
80
|
+
|
|
81
|
+
encrypted_pkg_data = aes_ecb_encrypt(k, padded_plain)
|
|
82
|
+
pkg_stream = [plain_bytes.bytesize].pack("Q<") + encrypted_pkg_data
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
"EncryptionInfo" => info_stream,
|
|
86
|
+
"EncryptedPackage" => pkg_stream
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def parse_encryption_info(bytes)
|
|
93
|
+
raise Xlsxrb::DecryptionError, "EncryptionInfo stream too short" if bytes.bytesize < 40
|
|
94
|
+
|
|
95
|
+
major, minor, = bytes[0, 8].unpack("vvV")
|
|
96
|
+
raise Xlsxrb::DecryptionError, "Unsupported standard encryption version #{major}.#{minor}" unless [2, 3, 4].include?(major) && minor == 2
|
|
97
|
+
|
|
98
|
+
header_size = bytes[8, 4].unpack1("V") || 0
|
|
99
|
+
verifier_offset = 12 + header_size
|
|
100
|
+
raise Xlsxrb::DecryptionError, "EncryptionInfo header offset out of bounds" if verifier_offset + 56 > bytes.bytesize
|
|
101
|
+
|
|
102
|
+
salt_size = bytes[verifier_offset, 4].unpack1("V") || 0
|
|
103
|
+
raise Xlsxrb::DecryptionError, "Invalid salt size in Standard encryption" if salt_size > 64 || (verifier_offset + 4 + salt_size + 48) > bytes.bytesize
|
|
104
|
+
|
|
105
|
+
salt = bytes[verifier_offset + 4, salt_size]
|
|
106
|
+
encrypted_verifier = bytes[verifier_offset + 4 + salt_size, 16]
|
|
107
|
+
|
|
108
|
+
hash_size_offset = verifier_offset + 4 + salt_size + 16
|
|
109
|
+
verifier_hash_size = bytes[hash_size_offset, 4].unpack1("V") || 0
|
|
110
|
+
encrypted_verifier_hash = bytes[hash_size_offset + 4, 32]
|
|
111
|
+
|
|
112
|
+
[salt, encrypted_verifier, verifier_hash_size, encrypted_verifier_hash]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def derive_key(password, salt, block_num = 0)
|
|
116
|
+
pw_utf16 = password.encode("UTF-16LE").b
|
|
117
|
+
h = OpenSSL::Digest::SHA1.digest(salt + pw_utf16)
|
|
118
|
+
|
|
119
|
+
ITERATION_COUNT.times do |i|
|
|
120
|
+
h = OpenSSL::Digest::SHA1.digest([i].pack("V") + h)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
x = OpenSSL::Digest::SHA1.digest(h + [block_num].pack("V"))
|
|
124
|
+
buf1 = x.ljust(64, "\x00".b).bytes.map { |b| b ^ 0x36 }.pack("C*")
|
|
125
|
+
k1 = OpenSSL::Digest::SHA1.digest(buf1)
|
|
126
|
+
|
|
127
|
+
buf2 = x.ljust(64, "\x00".b).bytes.map { |b| b ^ 0x5C }.pack("C*")
|
|
128
|
+
k2 = OpenSSL::Digest::SHA1.digest(buf2)
|
|
129
|
+
|
|
130
|
+
(k1 + k2)[0, 16]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def aes_ecb_encrypt(key, data)
|
|
134
|
+
cipher = OpenSSL::Cipher.new("aes-128-ecb")
|
|
135
|
+
cipher.encrypt
|
|
136
|
+
cipher.key = key
|
|
137
|
+
cipher.padding = 0
|
|
138
|
+
cipher.update(data) + cipher.final
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def aes_ecb_decrypt(key, data)
|
|
142
|
+
cipher = OpenSSL::Cipher.new("aes-128-ecb")
|
|
143
|
+
cipher.decrypt
|
|
144
|
+
cipher.key = key
|
|
145
|
+
cipher.padding = 0
|
|
146
|
+
cipher.update(data) + cipher.final
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require_relative "cfb"
|
|
6
|
+
require_relative "crypto/agile"
|
|
7
|
+
require_relative "crypto/standard"
|
|
8
|
+
|
|
9
|
+
module Xlsxrb
|
|
10
|
+
module Ooxml
|
|
11
|
+
# High-level encryption and decryption facade for [MS-OFFCRYPTO] Excel document protection.
|
|
12
|
+
module Crypto
|
|
13
|
+
class << self
|
|
14
|
+
# Checks if the binary data is an encrypted Compound File Binary package.
|
|
15
|
+
def encrypted?(data)
|
|
16
|
+
return false unless Cfb::Reader.cfb?(data)
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
reader = Cfb::Reader.new(data)
|
|
20
|
+
reader.stream_names.any? { |n| n.casecmp?("EncryptionInfo") }
|
|
21
|
+
rescue StandardError
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decrypts an encrypted XLSX (CFB) package with the given password.
|
|
27
|
+
def decrypt(cfb_data, password)
|
|
28
|
+
raise Xlsxrb::EncryptedFileError, "Password is required to decrypt this file" if password.nil? || password.to_s.empty?
|
|
29
|
+
|
|
30
|
+
reader = Cfb::Reader.new(cfb_data)
|
|
31
|
+
encryption_info = reader.read_stream("EncryptionInfo")
|
|
32
|
+
encrypted_package = reader.read_stream("EncryptedPackage")
|
|
33
|
+
|
|
34
|
+
raise Xlsxrb::DecryptionError, "Missing EncryptionInfo or EncryptedPackage stream" if encryption_info.nil? || encrypted_package.nil?
|
|
35
|
+
|
|
36
|
+
major, minor = encryption_info[0, 4].unpack("vv")
|
|
37
|
+
if major == 4 && minor == 4
|
|
38
|
+
Agile.decrypt(encryption_info, encrypted_package, password)
|
|
39
|
+
elsif minor == 2
|
|
40
|
+
Standard.decrypt(encryption_info, encrypted_package, password)
|
|
41
|
+
else
|
|
42
|
+
raise Xlsxrb::DecryptionError, "Unsupported encryption version #{major}.#{minor}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Encrypts a plain ZIP payload with the given password into an encrypted CFB package.
|
|
47
|
+
def encrypt(plain_zip_data, password, mode: :standard)
|
|
48
|
+
return plain_zip_data if password.nil? || password.to_s.empty?
|
|
49
|
+
|
|
50
|
+
streams = if mode == :agile
|
|
51
|
+
Agile.encrypt(plain_zip_data, password)
|
|
52
|
+
else
|
|
53
|
+
Standard.encrypt(plain_zip_data, password)
|
|
54
|
+
end
|
|
55
|
+
Cfb::Writer.write(streams)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -515,17 +515,17 @@ module Xlsxrb
|
|
|
515
515
|
row_end = xml.index("</row>", tag_end + 1)
|
|
516
516
|
break unless row_end
|
|
517
517
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
row_obj = Elements::Row.new(
|
|
518
|
+
{ part: part_name, row: row_index }
|
|
519
|
+
row_obj = StreamRow.new(
|
|
522
520
|
index: row_index,
|
|
523
|
-
|
|
521
|
+
xml_bytes: xml,
|
|
522
|
+
from: tag_end + 1,
|
|
523
|
+
to: row_end,
|
|
524
|
+
shared_strings: shared_strings,
|
|
524
525
|
height: attrs[:height],
|
|
525
526
|
hidden: attrs[:hidden] || false,
|
|
526
527
|
custom_height: attrs[:custom_height] || false,
|
|
527
|
-
outline_level: attrs[:outline_level]
|
|
528
|
-
errors: Elements::EMPTY_ERRORS
|
|
528
|
+
outline_level: attrs[:outline_level]
|
|
529
529
|
)
|
|
530
530
|
block.call(row_obj)
|
|
531
531
|
|
|
@@ -537,7 +537,13 @@ module Xlsxrb
|
|
|
537
537
|
|
|
538
538
|
def self.fast_parse_cells_direct(xml, from, to, shared_strings, row_source)
|
|
539
539
|
cells = []
|
|
540
|
+
fast_scan_cells_direct(xml, from, to, shared_strings, row_source) { |c| cells << c }
|
|
541
|
+
cells
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, &block)
|
|
540
545
|
pos = from
|
|
546
|
+
cell_count = 0
|
|
541
547
|
|
|
542
548
|
while pos < to
|
|
543
549
|
c_start = xml.index("<c", pos)
|
|
@@ -589,7 +595,6 @@ module Xlsxrb
|
|
|
589
595
|
|
|
590
596
|
row_idx = (row_idx * 10) + (cb - 48)
|
|
591
597
|
ai += 1
|
|
592
|
-
|
|
593
598
|
end
|
|
594
599
|
row_idx -= 1
|
|
595
600
|
ai += 1 if xml.getbyte(ai) == 34
|
|
@@ -619,7 +624,6 @@ module Xlsxrb
|
|
|
619
624
|
|
|
620
625
|
style_index = (style_index * 10) + (cb - 48)
|
|
621
626
|
ai += 1
|
|
622
|
-
|
|
623
627
|
end
|
|
624
628
|
ai += 1 if xml.getbyte(ai) == 34
|
|
625
629
|
else
|
|
@@ -629,13 +633,15 @@ module Xlsxrb
|
|
|
629
633
|
|
|
630
634
|
# Self-closing <c ... />
|
|
631
635
|
if xml.getbyte(c_tag_end - 1) == 47
|
|
632
|
-
|
|
636
|
+
cell_obj = Elements::Cell.new(
|
|
633
637
|
row_index: row_idx || row_source[:row],
|
|
634
|
-
column_index: col_idx ||
|
|
638
|
+
column_index: col_idx || cell_count,
|
|
635
639
|
value: nil,
|
|
636
640
|
style_index: style_index,
|
|
637
641
|
errors: Elements::EMPTY_ERRORS
|
|
638
642
|
)
|
|
643
|
+
cell_count += 1
|
|
644
|
+
block.call(cell_obj)
|
|
639
645
|
pos = c_tag_end + 1
|
|
640
646
|
next
|
|
641
647
|
end
|
|
@@ -721,19 +727,19 @@ module Xlsxrb
|
|
|
721
727
|
end
|
|
722
728
|
|
|
723
729
|
val_to_use = inline_str || value
|
|
724
|
-
|
|
730
|
+
cell_obj = Elements::Cell.new(
|
|
725
731
|
row_index: row_idx || row_source[:row],
|
|
726
|
-
column_index: col_idx ||
|
|
732
|
+
column_index: col_idx || cell_count,
|
|
727
733
|
value: val_to_use,
|
|
728
734
|
formula: formula,
|
|
729
735
|
style_index: style_index,
|
|
730
736
|
errors: Elements::EMPTY_ERRORS
|
|
731
737
|
)
|
|
738
|
+
cell_count += 1
|
|
739
|
+
block.call(cell_obj)
|
|
732
740
|
|
|
733
741
|
pos = c_end + 4
|
|
734
742
|
end
|
|
735
|
-
|
|
736
|
-
cells
|
|
737
743
|
end
|
|
738
744
|
|
|
739
745
|
private_class_method :fast_parse_cells_direct
|