herringbone 0.5.0 → 0.6.0

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.
@@ -0,0 +1,716 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Herringbone
6
+ # Parquet modular encryption (Encryption.md in parquet-format): AES-GCM and AES-CTR on top of
7
+ # OpenSSL, and the AADs that tie every encrypted module to its file and its place in the file.
8
+ # Writer takes +encryption:+ and Reader +decryption:+; see FileEncryptor and FileDecryptor.
9
+ #
10
+ # An encrypted module is stored as a 4-byte little-endian length, then a 12-byte nonce, the
11
+ # ciphertext and (GCM only) a 16-byte tag.
12
+ module Encryption
13
+ # Module type: the FileMetaData
14
+ FOOTER = 0
15
+ # Module type: a ColumnMetaData encrypted on its own
16
+ COLUMN_META_DATA = 1
17
+ # Module type: a data page body
18
+ DATA_PAGE = 2
19
+ # Module type: a dictionary page body
20
+ DICTIONARY_PAGE = 3
21
+ # Module type: a data page header
22
+ DATA_PAGE_HEADER = 4
23
+ # Module type: a dictionary page header
24
+ DICTIONARY_PAGE_HEADER = 5
25
+ # Module type: a ColumnIndex
26
+ COLUMN_INDEX = 6
27
+ # Module type: an OffsetIndex
28
+ OFFSET_INDEX = 7
29
+ # Module type: a bloom filter header
30
+ BLOOM_FILTER_HEADER = 8
31
+ # Module type: a bloom filter bitset
32
+ BLOOM_FILTER_BITSET = 9
33
+ # Module type => what it is, for error messages
34
+ MODULE_NAMES = Ractor.make_shareable({
35
+ FOOTER => "footer", COLUMN_META_DATA => "column metadata", DATA_PAGE => "data page",
36
+ DICTIONARY_PAGE => "dictionary page", DATA_PAGE_HEADER => "data page header",
37
+ DICTIONARY_PAGE_HEADER => "dictionary page header", COLUMN_INDEX => "column index",
38
+ OFFSET_INDEX => "offset index", BLOOM_FILTER_HEADER => "bloom filter header",
39
+ BLOOM_FILTER_BITSET => "bloom filter bitset"
40
+ })
41
+
42
+ # Bytes of a GCM nonce, also the nonce part of a CTR IV
43
+ NONCE = 12
44
+ # Bytes of a GCM tag
45
+ TAG = 16
46
+ # Bytes of the footer signature in plaintext-footer files: nonce and tag
47
+ SIGNATURE = NONCE + TAG
48
+ # AES-128, AES-192 and AES-256
49
+ KEY_SIZES = [16, 24, 32].freeze
50
+ # Row group, column and page ordinals are stored in AADs as signed 16-bit integers
51
+ MAX_ORDINAL = 0x7FFF
52
+ # Encryptions allowed per key (NIST SP 800-38D, section 8.3)
53
+ MAX_INVOCATIONS = 1 << 32
54
+ # The last 4 bytes of a CTR IV: the counter starts at 1
55
+ CTR_COUNTER = "\x00\x00\x00\x01".b.freeze
56
+ # Bytes of the random file id that is part of every AAD
57
+ FILE_UNIQUE_BYTES = 8
58
+ # Magic bytes at both ends of a file with an encrypted footer
59
+ ENCRYPTED_MAGIC = Reader::ENCRYPTED_MAGIC
60
+
61
+ module_function
62
+
63
+ # @param key [Object] candidate key
64
+ # @param what [String] what the key is for, for the error message
65
+ # @return [String] the key as a binary String
66
+ # @raise [ArgumentError] when +key+ is not a String of 16, 24 or 32 bytes
67
+ def check_key!(key, what)
68
+ unless key.is_a?(String) && KEY_SIZES.include?(key.bytesize)
69
+ got = key.is_a?(String) ? "#{key.bytesize} bytes" : key.class.to_s
70
+ raise ArgumentError, "#{what} must be a 16, 24 or 32-byte String (AES-128/192/256), got #{got}"
71
+ end
72
+ key.b
73
+ end
74
+
75
+ # Leaf columns named by a column path (a leaf) or by a field (all of its leaves)
76
+ #
77
+ # @param schema [Schema] schema holding the columns
78
+ # @param name [String, Symbol, Array<String>] dotted path, or an Array of names
79
+ # @return [Array<Schema::Column>] the columns, empty when nothing matches
80
+ def columns_named(schema, name)
81
+ name = name.is_a?(Array) ? name.join(".") : name.to_s
82
+ prefix = "#{name}."
83
+ schema.columns.select { |c| c.dotted_path == name || c.dotted_path.start_with?(prefix) }
84
+ end
85
+
86
+ # Reads the footer region (everything between the data and the 4-byte footer length) of a
87
+ # plaintext or encrypted file. Column metadata that can be decrypted replaces the stripped or
88
+ # missing +meta_data+ of its ColumnChunk.
89
+ #
90
+ # @param region [String] the footer region, binary
91
+ # @param magic [String] the file's closing magic, "PAR1" or "PARE"
92
+ # @param decryption [DecryptionConfiguration, nil] the keys
93
+ # @return [Array(Format::FileMetaData, FileDecryptor)] the footer, and its decryptor (nil for
94
+ # a file that is not encrypted)
95
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted, or its
96
+ # signature does not match
97
+ # @raise [Thrift::Error] when the footer cannot be decoded
98
+ def read_footer(region, magic, decryption)
99
+ if magic == ENCRYPTED_MAGIC
100
+ crypto, pos = Format::FileCryptoMetaData.decode(region)
101
+ unless decryption
102
+ algorithm = crypto.encryption_algorithm&.aes_gcm_ctr_v1 ? "AES_GCM_CTR_V1" : "AES_GCM_V1"
103
+ metadata = crypto.key_metadata && ", footer key metadata #{crypto.key_metadata.inspect}"
104
+ raise DecryptionError, "The file is encrypted (#{algorithm}#{metadata}), footer included: pass decryption: with its keys"
105
+ end
106
+ decryptor = FileDecryptor.new(decryption, crypto.encryption_algorithm, footer_key_metadata: crypto.key_metadata)
107
+ meta = Format::FileMetaData.decode(decryptor.decrypt_footer(region.byteslice(pos..))).first
108
+ else
109
+ meta, pos = Format::FileMetaData.decode(region)
110
+ return [meta, nil] unless meta.encryption_algorithm
111
+ decryptor = FileDecryptor.new(decryption, meta.encryption_algorithm,
112
+ footer_key_metadata: meta.footer_signing_key_metadata, plaintext_footer: true)
113
+ signature = region.byteslice(pos, SIGNATURE)
114
+ raise FormatError, "Footer signature is missing" unless signature&.bytesize == SIGNATURE
115
+ decryptor.verify_footer(region.byteslice(0, pos), signature)
116
+ end
117
+ decryptor.decrypt_column_metadata(meta)
118
+ [meta, decryptor]
119
+ end
120
+
121
+ # AES with one key: GCM and CTR ciphers, reused from module to module, and the count of
122
+ # encryptions done with the key
123
+ class Cipher
124
+ # @param key [String] 16, 24 or 32 bytes
125
+ def initialize(key)
126
+ @key = key
127
+ @bits = key.bytesize * 8
128
+ @gcm = OpenSSL::Cipher.new("aes-#{@bits}-gcm")
129
+ @invocations = 0
130
+ end
131
+
132
+ # @param plain [String] bytes to encrypt
133
+ # @param aad [String] additional authenticated data
134
+ # @return [String] the module: length, nonce, ciphertext and tag
135
+ # @raise [Error] after MAX_INVOCATIONS encryptions with this key
136
+ def gcm_encrypt(plain, aad)
137
+ count!
138
+ nonce = OpenSSL::Random.random_bytes(NONCE)
139
+ c = @gcm
140
+ c.encrypt
141
+ c.key = @key
142
+ c.iv = nonce
143
+ c.auth_data = aad
144
+ out = String.new(capacity: plain.bytesize + NONCE + TAG + 4, encoding: Encoding::BINARY)
145
+ out << [plain.bytesize + NONCE + TAG].pack("V") << nonce
146
+ out << c.update(plain) unless plain.empty?
147
+ out << c.final << c.auth_tag
148
+ end
149
+
150
+ # @param buf [String] nonce, ciphertext and tag
151
+ # @param aad [String] additional authenticated data
152
+ # @return [String] the plaintext
153
+ # @raise [OpenSSL::Cipher::CipherError] when the tag does not match
154
+ # @raise [FormatError] when +buf+ is too short to hold a nonce and a tag
155
+ def gcm_decrypt(buf, aad)
156
+ raise FormatError, "Encrypted module of #{buf.bytesize} bytes is too short" if buf.bytesize < NONCE + TAG
157
+ c = @gcm
158
+ c.decrypt
159
+ c.key = @key
160
+ c.iv = buf.byteslice(0, NONCE)
161
+ c.auth_tag = buf.byteslice(-TAG, TAG)
162
+ c.auth_data = aad
163
+ ciphertext = buf.byteslice(NONCE, buf.bytesize - NONCE - TAG)
164
+ out = ciphertext.empty? ? "".b : c.update(ciphertext)
165
+ out << c.final
166
+ end
167
+
168
+ # The tag GCM gives +plain+ with +nonce+, for footer signatures
169
+ #
170
+ # @param plain [String] the signed bytes
171
+ # @param aad [String] additional authenticated data
172
+ # @param nonce [String, nil] the nonce to use, random when nil
173
+ # @return [String] nonce and tag
174
+ def gcm_sign(plain, aad, nonce = nil)
175
+ count!
176
+ nonce ||= OpenSSL::Random.random_bytes(NONCE)
177
+ c = @gcm
178
+ c.encrypt
179
+ c.key = @key
180
+ c.iv = nonce
181
+ c.auth_data = aad
182
+ c.update(plain) unless plain.empty?
183
+ c.final
184
+ nonce + c.auth_tag
185
+ end
186
+
187
+ # @param plain [String] bytes to encrypt
188
+ # @return [String] the module: length, nonce and ciphertext
189
+ def ctr_encrypt(plain)
190
+ count!
191
+ nonce = OpenSSL::Random.random_bytes(NONCE)
192
+ out = String.new(capacity: plain.bytesize + NONCE + 4, encoding: Encoding::BINARY)
193
+ out << [plain.bytesize + NONCE].pack("V") << nonce << ctr(nonce, plain)
194
+ end
195
+
196
+ # @param buf [String] nonce and ciphertext
197
+ # @return [String] the plaintext
198
+ # @raise [FormatError] when +buf+ is too short to hold a nonce
199
+ def ctr_decrypt(buf)
200
+ raise FormatError, "Encrypted page of #{buf.bytesize} bytes is too short" if buf.bytesize < NONCE
201
+ ctr(buf.byteslice(0, NONCE), buf.byteslice(NONCE..))
202
+ end
203
+
204
+ # @return [String] key size, without the key
205
+ def inspect = "#<#{self.class.name} AES-#{@bits}>"
206
+
207
+ private
208
+
209
+ # @param nonce [String] 12 bytes
210
+ # @param data [String] bytes to encrypt or decrypt (the same operation in CTR mode)
211
+ # @return [String]
212
+ def ctr(nonce, data)
213
+ c = (@ctr ||= OpenSSL::Cipher.new("aes-#{@bits}-ctr"))
214
+ c.encrypt
215
+ c.key = @key
216
+ c.iv = nonce + CTR_COUNTER
217
+ out = data.empty? ? "".b : c.update(data)
218
+ out << c.final
219
+ end
220
+
221
+ # @return [void]
222
+ # @raise [Error] after MAX_INVOCATIONS encryptions
223
+ def count!
224
+ @invocations += 1
225
+ return if @invocations <= MAX_INVOCATIONS
226
+ raise Error, "AES-GCM allows at most 2^32 encryptions per key: use another key"
227
+ end
228
+ end
229
+
230
+ # Encrypts and decrypts the modules of one column chunk (or the footer, without ordinals),
231
+ # building each module's AAD
232
+ class ModuleCrypto
233
+ # @param cipher [Cipher] the column's (or footer's) key
234
+ # @param file_aad [String] AAD prefix and file unique id
235
+ # @param ctr [Boolean] whether page bodies use AES-CTR (AES_GCM_CTR_V1)
236
+ # @param row_group [Integer, nil] row group ordinal; nil for the footer
237
+ # @param column [Integer, nil] column ordinal; nil for the footer
238
+ # @param what [String] the column and row group, for error messages
239
+ def initialize(cipher, file_aad, ctr, row_group = nil, column = nil, what = "the footer")
240
+ @cipher = cipher
241
+ @file_aad = file_aad
242
+ @ctr = ctr
243
+ @ordinals = row_group ? [row_group, column].pack("s<s<") : "".b
244
+ @what = what
245
+ end
246
+
247
+ # @param type [Integer] module type
248
+ # @param plain [String] the module's plaintext
249
+ # @param page [Integer, nil] data page ordinal within the chunk, for data pages and their headers
250
+ # @return [String] the encrypted module, length prefix included
251
+ # @raise [UnsupportedError] when +page+ is above MAX_ORDINAL
252
+ def encrypt(type, plain, page = nil)
253
+ return @cipher.ctr_encrypt(plain) if @ctr && (type == DATA_PAGE || type == DICTIONARY_PAGE)
254
+ @cipher.gcm_encrypt(plain, aad(type, page))
255
+ end
256
+
257
+ # @param type [Integer] module type
258
+ # @param mod [String] the encrypted module, length prefix included
259
+ # @param page [Integer, nil] data page ordinal within the chunk, for data pages and their headers
260
+ # @return [String] the plaintext
261
+ # @raise [DecryptionError] when the tag does not match (wrong key, or changed bytes)
262
+ # @raise [FormatError] when the length prefix does not match the module's size
263
+ def decrypt(type, mod, page = nil)
264
+ len = mod.byteslice(0, 4)&.unpack1("V")
265
+ unless len == mod.bytesize - 4
266
+ raise FormatError, "Encrypted #{MODULE_NAMES[type]} of #{@what} is truncated or corrupt"
267
+ end
268
+ body = mod.byteslice(4, len)
269
+ return @cipher.ctr_decrypt(body) if @ctr && (type == DATA_PAGE || type == DICTIONARY_PAGE)
270
+ @cipher.gcm_decrypt(body, aad(type, page))
271
+ rescue OpenSSL::Cipher::CipherError
272
+ raise DecryptionError, "Cannot decrypt the #{MODULE_NAMES[type]} of #{@what}: wrong key or AAD prefix, " \
273
+ "or the file was changed"
274
+ end
275
+
276
+ # @param plain [String] the footer as serialized
277
+ # @return [String] nonce and tag of the footer signature
278
+ def sign(plain) = @cipher.gcm_sign(plain, aad(FOOTER, nil))
279
+
280
+ # @param plain [String] the footer as serialized
281
+ # @param signature [String] nonce and tag stored after it
282
+ # @return [Boolean] whether the signature matches
283
+ def signed?(plain, signature)
284
+ expected = @cipher.gcm_sign(plain, aad(FOOTER, nil), signature.byteslice(0, NONCE))
285
+ OpenSSL.fixed_length_secure_compare(expected, signature)
286
+ end
287
+
288
+ private
289
+
290
+ # @param type [Integer] module type
291
+ # @param page [Integer, nil] page ordinal
292
+ # @return [String] file AAD, module type, row group and column ordinals, page ordinal
293
+ # @raise [UnsupportedError] when +page+ is above MAX_ORDINAL
294
+ def aad(type, page)
295
+ aad = @file_aad + type.chr + @ordinals
296
+ return aad unless page
297
+ raise UnsupportedError, "Encrypted column chunks hold at most #{MAX_ORDINAL + 1} pages" if page > MAX_ORDINAL
298
+ aad << [page].pack("s<")
299
+ end
300
+ end
301
+
302
+ # An EncryptionConfiguration applied to a schema: what the writer needs to encrypt each
303
+ # module and the footer
304
+ class FileEncryptor
305
+ # How one column is encrypted
306
+ #
307
+ # @!attribute cipher
308
+ # @return [Cipher] its key
309
+ # @!attribute key_metadata
310
+ # @return [String, nil] stored with the column (column keys only)
311
+ # @!attribute footer
312
+ # @return [Boolean] whether the key is the footer key
313
+ ColumnKey = Struct.new(:cipher, :key_metadata, :footer)
314
+
315
+ # @return [Boolean] whether the footer is stored in the clear (and signed)
316
+ attr_reader :plaintext_footer
317
+
318
+ # @param config [EncryptionConfiguration] the settings
319
+ # @param schema [Schema] the schema being written
320
+ # @raise [ArgumentError] for an unknown column, or columns named so that one is listed twice
321
+ # @raise [UnsupportedError] when the schema has more columns than AADs can number
322
+ def initialize(config, schema)
323
+ if schema.columns.size > MAX_ORDINAL + 1
324
+ raise UnsupportedError, "Encrypted files hold at most #{MAX_ORDINAL + 1} columns"
325
+ end
326
+ @ciphers = {}
327
+ @footer = ColumnKey.new(cipher_for(config.footer_key), nil, true)
328
+ @footer_key_metadata = config.footer_key_metadata
329
+ @plaintext_footer = config.plaintext_footer?
330
+ @ctr = config.algorithm == :aes_gcm_ctr
331
+ prefix = config.aad_prefix
332
+ store = config.store_aad_prefix?
333
+ unique = OpenSSL::Random.random_bytes(FILE_UNIQUE_BYTES)
334
+ @file_aad = (prefix || "".b) + unique
335
+ settings = {aad_prefix: store ? prefix : nil, aad_file_unique: unique, supply_aad_prefix: store ? nil : true}
336
+ @algorithm = if @ctr
337
+ Format::EncryptionAlgorithm.new(aes_gcm_ctr_v1: Format::AesGcmCtrV1.new(**settings))
338
+ else
339
+ Format::EncryptionAlgorithm.new(aes_gcm_v1: Format::AesGcmV1.new(**settings))
340
+ end
341
+ @schema = schema
342
+ @columns = column_keys(config.columns)
343
+ end
344
+
345
+ # @return [String] the magic bytes the file starts and ends with
346
+ def magic = @plaintext_footer ? Writer::MAGIC : ENCRYPTED_MAGIC.b
347
+
348
+ # @param index [Integer] leaf column index
349
+ # @return [Boolean] whether the column is encrypted
350
+ def encrypted?(index) = !@columns[index].nil?
351
+
352
+ # @param row_group [Integer] row group ordinal
353
+ # @param index [Integer] leaf column index
354
+ # @return [ModuleCrypto, nil] encryption of the chunk's modules, nil for a plaintext column
355
+ # @raise [UnsupportedError] when +row_group+ is above MAX_ORDINAL
356
+ def chunk(row_group, index)
357
+ setting = @columns[index] or return nil
358
+ if row_group > MAX_ORDINAL
359
+ raise UnsupportedError, "Encrypted files hold at most #{MAX_ORDINAL + 1} row groups"
360
+ end
361
+ ModuleCrypto.new(setting.cipher, @file_aad, @ctr, row_group, index,
362
+ "#{@schema.columns[index].dotted_path} (row group #{row_group})")
363
+ end
364
+
365
+ # Sets the crypto metadata of the encrypted column chunks, and encrypts their ColumnMetaData
366
+ # when it is not protected by the footer: for columns with their own key, and in plaintext
367
+ # footer mode, where the footer keeps a copy without statistics
368
+ #
369
+ # @param row_groups [Array<Format::RowGroup>] the file's row groups, changed in place
370
+ # @return [void]
371
+ def finish(row_groups)
372
+ row_groups.each do |rg|
373
+ rg.columns.each_with_index do |column_chunk, index|
374
+ setting = @columns[index] or next
375
+ meta = column_chunk.meta_data
376
+ column_chunk.crypto_metadata = if setting.footer
377
+ Format::ColumnCryptoMetaData.new(encryption_with_footer_key: Format::EncryptionWithFooterKey.new)
378
+ else
379
+ Format::ColumnCryptoMetaData.new(encryption_with_column_key: Format::EncryptionWithColumnKey.new(
380
+ path_in_schema: meta.path_in_schema, key_metadata: setting.key_metadata
381
+ ))
382
+ end
383
+ next if setting.footer && !@plaintext_footer
384
+ column_chunk.encrypted_column_metadata = chunk(rg.ordinal, index).encrypt(COLUMN_META_DATA, meta.encode)
385
+ column_chunk.meta_data = @plaintext_footer ? stripped(meta) : nil
386
+ end
387
+ end
388
+ end
389
+
390
+ # The bytes between the last data and the footer length: the encrypted footer after its
391
+ # FileCryptoMetaData, or the plaintext footer and its signature
392
+ #
393
+ # @param meta [Format::FileMetaData] the footer; gets the algorithm in plaintext mode
394
+ # @return [String]
395
+ def footer(meta)
396
+ crypto = ModuleCrypto.new(@footer.cipher, @file_aad, @ctr)
397
+ if @plaintext_footer
398
+ meta.encryption_algorithm = @algorithm
399
+ meta.footer_signing_key_metadata = @footer_key_metadata
400
+ bytes = meta.encode
401
+ bytes << crypto.sign(bytes)
402
+ else
403
+ Format::FileCryptoMetaData.new(encryption_algorithm: @algorithm, key_metadata: @footer_key_metadata).encode <<
404
+ crypto.encrypt(FOOTER, meta.encode)
405
+ end
406
+ end
407
+
408
+ # @return [String] algorithm and footer mode, without keys
409
+ def inspect
410
+ "#<#{self.class.name} #{@ctr ? "aes_gcm_ctr" : "aes_gcm"} footer=#{@plaintext_footer ? "plaintext" : "encrypted"} " \
411
+ "columns=#{@columns.count(&:itself)}/#{@columns.size}>"
412
+ end
413
+
414
+ private
415
+
416
+ # @param key [String] checked key
417
+ # @return [Cipher] one per distinct key, so encryptions are counted per key
418
+ def cipher_for(key)
419
+ @ciphers[key] ||= Cipher.new(key)
420
+ end
421
+
422
+ # @param requested [Hash{String => EncryptionConfiguration::ColumnKey, Symbol}, nil] the
423
+ # configuration's columns
424
+ # @return [Array<ColumnKey, nil>] per leaf column
425
+ # @raise [ArgumentError] for an unknown column, or a column named twice
426
+ def column_keys(requested)
427
+ return Array.new(@schema.columns.size, @footer) if requested.nil?
428
+ out = Array.new(@schema.columns.size)
429
+ requested.each do |name, setting|
430
+ columns = Encryption.columns_named(@schema, name)
431
+ raise ArgumentError, "encryption: no such column #{name.inspect}" if columns.empty?
432
+ key = (setting == :footer) ? @footer : ColumnKey.new(cipher_for(setting.key), setting.key_metadata, false)
433
+ columns.each do |col|
434
+ raise ArgumentError, "encryption: column #{col.dotted_path} is listed twice" if out[col.index]
435
+ out[col.index] = key
436
+ end
437
+ end
438
+ out
439
+ end
440
+
441
+ # @param meta [Format::ColumnMetaData] the column's metadata
442
+ # @return [Format::ColumnMetaData] a copy without statistics, for plaintext footers
443
+ def stripped(meta)
444
+ copy = Format::ColumnMetaData.decode(meta.encode).first
445
+ copy.statistics = copy.encoding_stats = copy.size_statistics = nil
446
+ copy
447
+ end
448
+ end
449
+
450
+ # A DecryptionConfiguration applied to one file: finds the keys (given, or looked up by their
451
+ # key metadata), checks the AAD prefix, decrypts the footer and the column metadata, and
452
+ # hands out a ModuleCrypto per column chunk
453
+ class FileDecryptor
454
+ # @return [Format::EncryptionAlgorithm] the file's algorithm
455
+ attr_reader :algorithm
456
+
457
+ # @return [String, nil] key metadata of the footer key
458
+ attr_reader :footer_key_metadata
459
+
460
+ # @return [Boolean] whether the footer is stored in the clear (and signed)
461
+ attr_reader :plaintext_footer
462
+
463
+ # @param config [DecryptionConfiguration, nil] the keys; nil for none
464
+ # @param algorithm [Format::EncryptionAlgorithm, nil] from the footer or the FileCryptoMetaData
465
+ # @param footer_key_metadata [String, nil] key metadata of the footer key
466
+ # @param plaintext_footer [Boolean] whether the footer is stored in the clear
467
+ # @raise [UnsupportedError] for an unknown algorithm
468
+ # @raise [DecryptionError] when the file needs an AAD prefix that was not given, or stores a
469
+ # different one
470
+ def initialize(config, algorithm, footer_key_metadata: nil, plaintext_footer: false)
471
+ config ||= DecryptionConfiguration.new
472
+ settings = algorithm&.settings or raise UnsupportedError, "Unknown encryption algorithm"
473
+ @algorithm = algorithm
474
+ @footer_key_metadata = footer_key_metadata
475
+ @plaintext_footer = plaintext_footer
476
+ @ctr = !algorithm.aes_gcm_ctr_v1.nil?
477
+ @footer_key = config.footer_key
478
+ @column_keys = config.columns
479
+ @resolver = config.keys
480
+ supplied = config.aad_prefix
481
+ stored = settings.aad_prefix
482
+ if settings.supply_aad_prefix && supplied.nil?
483
+ raise DecryptionError, "The file was encrypted with an AAD prefix it does not store: pass decryption: {aad_prefix: ...}"
484
+ end
485
+ if stored && supplied && stored != supplied
486
+ raise DecryptionError, "decryption: aad_prefix #{supplied.inspect} does not match the file's #{stored.inspect}"
487
+ end
488
+ @aad_prefix_used = supplied || stored
489
+ @file_aad = (@aad_prefix_used || "".b) + (settings.aad_file_unique || "".b)
490
+ @ciphers = {}
491
+ @resolved = {}
492
+ @footer_verified = false
493
+ end
494
+
495
+ # @return [Symbol] +:aes_gcm+ or +:aes_gcm_ctr+
496
+ def algorithm_name = @ctr ? :aes_gcm_ctr : :aes_gcm
497
+
498
+ # @return [String, nil] the AAD prefix stored in the file
499
+ def aad_prefix = @algorithm.settings.aad_prefix
500
+
501
+ # @return [Boolean] whether the footer signature was checked (plaintext footers, with the key)
502
+ def footer_verified? = @footer_verified
503
+
504
+ # @return [String, nil] the footer key, given or looked up; nil when not available
505
+ def footer_key
506
+ @footer_key ||= resolve(@footer_key_metadata, :footer)
507
+ end
508
+
509
+ # @param mod [String] the encrypted footer module
510
+ # @return [String] the serialized FileMetaData
511
+ # @raise [DecryptionError] without the footer key, or when it does not decrypt
512
+ def decrypt_footer(mod)
513
+ key = footer_key or raise DecryptionError, "The footer is encrypted and no key was given for it" \
514
+ "#{" (key metadata #{@footer_key_metadata.inspect})" if @footer_key_metadata}: pass decryption: {footer_key: ...} or {keys: ...}"
515
+ ModuleCrypto.new(cipher_for(key), @file_aad, @ctr).decrypt(FOOTER, mod)
516
+ end
517
+
518
+ # Checks the signature of a plaintext footer, when the footer key is available (without it
519
+ # the file is read like a legacy reader would)
520
+ #
521
+ # @param plain [String] the serialized FileMetaData
522
+ # @param signature [String] nonce and tag stored after it
523
+ # @return [void]
524
+ # @raise [DecryptionError] when the signature does not match
525
+ def verify_footer(plain, signature)
526
+ key = footer_key or return
527
+ unless ModuleCrypto.new(cipher_for(key), @file_aad, @ctr).signed?(plain, signature)
528
+ raise DecryptionError, "The footer signature does not match: wrong footer key or AAD prefix, or the footer was changed"
529
+ end
530
+ @footer_verified = true
531
+ end
532
+
533
+ # Replaces the +meta_data+ of chunks whose ColumnMetaData is encrypted on its own, when
534
+ # their key is available
535
+ #
536
+ # @param meta [Format::FileMetaData] the decoded footer, changed in place
537
+ # @return [void]
538
+ # @raise [DecryptionError] when a column's metadata does not decrypt with the key given for it
539
+ def decrypt_column_metadata(meta)
540
+ paths = nil
541
+ (meta.row_groups || []).each_with_index do |rg, i|
542
+ (rg.columns || []).each_with_index do |chunk, index|
543
+ bytes = chunk.encrypted_column_metadata
544
+ next unless chunk.crypto_metadata && bytes
545
+ paths ||= column_paths(meta)
546
+ cipher = chunk_cipher(chunk, paths[index]) or next
547
+ mod = Encryption.length_prefixed(bytes)
548
+ crypto = ModuleCrypto.new(cipher, @file_aad, @ctr, rg.ordinal || i, index, "#{paths[index]} (row group #{i})")
549
+ chunk.meta_data = Format::ColumnMetaData.decode(crypto.decrypt(COLUMN_META_DATA, mod)).first
550
+ end
551
+ end
552
+ rescue Thrift::Error => e
553
+ raise FormatError, "Corrupt encrypted column metadata: #{e.message}"
554
+ end
555
+
556
+ # @param row_group [Integer] row group index in the footer
557
+ # @param ordinal [Integer, nil] the row group's ordinal from the footer
558
+ # @param column [Schema::Column] the leaf column
559
+ # @param chunk [Format::ColumnChunk] its chunk in the row group
560
+ # @return [ModuleCrypto, nil] decryption of the chunk's modules, nil for a plaintext chunk
561
+ # @raise [DecryptionError] when the chunk is encrypted and its key is not available
562
+ def chunk(row_group, ordinal, column, chunk)
563
+ return nil unless chunk.crypto_metadata
564
+ cipher = chunk_cipher(chunk, column.dotted_path) or raise DecryptionError, missing_key(column.dotted_path, chunk)
565
+ ModuleCrypto.new(cipher, @file_aad, @ctr, ordinal || row_group, column.index,
566
+ "#{column.dotted_path} (row group #{row_group})")
567
+ end
568
+
569
+ # How the file is encrypted, see Reader#encryption
570
+ #
571
+ # @param schema [Schema] the file's schema, to name the columns
572
+ # @param chunks [Array<Format::ColumnChunk>] the column chunks of a row group
573
+ # @return [Hash{Symbol => Object}]
574
+ def describe(schema, chunks)
575
+ columns = schema.columns.each_with_object({}) do |col, out|
576
+ chunk = chunks[col.index]
577
+ crypto = chunk&.crypto_metadata or next
578
+ with_column_key = crypto.encryption_with_column_key
579
+ out[col.dotted_path] = {
580
+ key: with_column_key ? :column : :footer,
581
+ key_metadata: with_column_key&.key_metadata,
582
+ readable: !chunk_cipher(chunk, col.dotted_path).nil?
583
+ }
584
+ end
585
+ {
586
+ algorithm: algorithm_name, footer: @plaintext_footer ? :plaintext : :encrypted,
587
+ footer_key_metadata: @footer_key_metadata, aad_prefix: aad_prefix,
588
+ supply_aad_prefix: @algorithm.settings.supply_aad_prefix == true,
589
+ footer_verified: @footer_verified, columns: columns
590
+ }
591
+ end
592
+
593
+ # @param chunk [Format::ColumnChunk] the chunk's footer entry, with its crypto metadata
594
+ # @param path [String] its column's dotted path
595
+ # @return [String, nil] the chunk's key, nil when it is not encrypted or the key is not available
596
+ def chunk_key(chunk, path)
597
+ crypto = chunk.crypto_metadata or return nil
598
+ if crypto.encryption_with_column_key
599
+ column_key(path, crypto.encryption_with_column_key.key_metadata)
600
+ else
601
+ footer_key
602
+ end
603
+ end
604
+
605
+ # The configuration of a file encrypted like this one (see Redaction)
606
+ #
607
+ # @param columns [Hash{String => Hash, Symbol}, nil] encrypted columns of the new file, as the
608
+ # +columns:+ setting; nil to encrypt them all with the footer key
609
+ # @return [EncryptionConfiguration]
610
+ # @raise [DecryptionError] when the footer key is not available
611
+ def writer_settings(columns)
612
+ key = footer_key or raise DecryptionError, "The output is encrypted like the input, which needs the footer key: " \
613
+ "pass it in decryption:, or pass encryption: for the output"
614
+ settings = {footer_key: key, footer_key_metadata: @footer_key_metadata, plaintext_footer: @plaintext_footer,
615
+ algorithm: algorithm_name, columns: columns}
616
+ if @aad_prefix_used
617
+ settings[:aad_prefix] = @aad_prefix_used
618
+ settings[:store_aad_prefix] = false if @algorithm.settings.supply_aad_prefix
619
+ end
620
+ EncryptionConfiguration.new(**settings)
621
+ end
622
+
623
+ # @return [String] algorithm and footer mode, without keys
624
+ def inspect
625
+ "#<#{self.class.name} #{algorithm_name} footer=#{@plaintext_footer ? "plaintext" : "encrypted"}>"
626
+ end
627
+
628
+ private
629
+
630
+ # @param chunk [Format::ColumnChunk] an encrypted chunk
631
+ # @param path [String] its column's dotted path
632
+ # @return [Cipher, nil] nil when the key is not available
633
+ def chunk_cipher(chunk, path)
634
+ key = chunk_key(chunk, path)
635
+ key && cipher_for(key)
636
+ end
637
+
638
+ # @param path [String] dotted column path
639
+ # @param key_metadata [String, nil] the column's key metadata
640
+ # @return [String, nil] the given key for the column (or a field holding it), else the
641
+ # looked-up one
642
+ def column_key(path, key_metadata)
643
+ @column_keys.each do |name, key|
644
+ return key if path == name || path.start_with?("#{name}.")
645
+ end
646
+ resolve(key_metadata, path)
647
+ end
648
+
649
+ # @param key_metadata [String, nil] stored key metadata
650
+ # @param owner [Symbol, String] what the key is for: +:footer+ or a dotted column path
651
+ # @return [String, nil] the key from the +keys:+ resolver, nil when there is none. Keys
652
+ # without key metadata are looked up per owner.
653
+ # @raise [ArgumentError] when the resolver returns something that is not a key
654
+ def resolve(key_metadata, owner)
655
+ return nil unless @resolver
656
+ return nil if key_metadata.nil? && !@resolver.respond_to?(:call)
657
+ cache = key_metadata || [:owner, owner]
658
+ @resolved.fetch(cache) do
659
+ key = if @resolver.respond_to?(:call)
660
+ one_argument?(@resolver) ? @resolver.call(key_metadata) : @resolver.call(key_metadata, owner)
661
+ else
662
+ @resolver[key_metadata]
663
+ end
664
+ what = (owner == :footer) ? "the footer" : owner
665
+ key = key.bytes if key.is_a?(Key)
666
+ key &&= Encryption.check_key!(key, "decryption: keys: the key for #{what}")
667
+ @resolved[cache] = key
668
+ end
669
+ end
670
+
671
+ # @param callable [#call] the +keys:+ resolver
672
+ # @return [Boolean] whether it only takes the key metadata (procs drop extra arguments)
673
+ def one_argument?(callable)
674
+ callable = callable.method(:call) unless callable.is_a?(Proc) || callable.is_a?(Method)
675
+ return false if callable.is_a?(Proc) && !callable.lambda?
676
+ callable.arity == 1
677
+ end
678
+
679
+ # @param key [String] 16, 24 or 32 bytes
680
+ # @return [Cipher] one per distinct key
681
+ def cipher_for(key)
682
+ @ciphers[key] ||= Cipher.new(key)
683
+ end
684
+
685
+ # Dotted paths of the leaf columns, by column ordinal
686
+ #
687
+ # @param meta [Format::FileMetaData] the footer
688
+ # @return [Array<String>]
689
+ def column_paths(meta)
690
+ Schema.from_elements(meta.schema).columns.map(&:dotted_path)
691
+ end
692
+
693
+ # @param path [String] dotted column path
694
+ # @param chunk [Format::ColumnChunk] the encrypted chunk whose key is missing
695
+ # @return [String] what is missing, and how to give it
696
+ def missing_key(path, chunk)
697
+ with_column_key = chunk.crypto_metadata.encryption_with_column_key
698
+ metadata = with_column_key ? with_column_key.key_metadata : @footer_key_metadata
699
+ whose = with_column_key ? "its key" : "the footer key"
700
+ "Column #{path} is encrypted and #{whose} was not given#{" (key metadata #{metadata.inspect})" if metadata}: " \
701
+ "pass decryption: {#{with_column_key ? "columns: {#{path.inspect} => key}" : "footer_key: ..."}} or {keys: ...}, " \
702
+ "or leave the column out with columns:"
703
+ end
704
+ end
705
+
706
+ # A module stored in a Thrift binary field, with its length prefix (as parquet-mr and Arrow
707
+ # write it) or without
708
+ #
709
+ # @param bytes [String] the field's value
710
+ # @return [String] the module with its length prefix
711
+ def length_prefixed(bytes)
712
+ return bytes if bytes.bytesize >= 4 && bytes.unpack1("V") == bytes.bytesize - 4
713
+ [bytes.bytesize].pack("V") + bytes
714
+ end
715
+ end
716
+ end