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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/MANUAL.md +773 -0
- data/README.md +68 -437
- data/bin/herringbone +86 -7
- data/lib/herringbone/bloom_filter.rb +0 -55
- data/lib/herringbone/compression.rb +27 -4
- data/lib/herringbone/encryption.rb +716 -0
- data/lib/herringbone/encryption_configuration.rb +304 -0
- data/lib/herringbone/format.rb +55 -3
- data/lib/herringbone/inferring_writer.rb +17 -2
- data/lib/herringbone/inspector.rb +158 -28
- data/lib/herringbone/key.rb +107 -0
- data/lib/herringbone/reader/bloom_filters.rb +91 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +55 -1
- data/lib/herringbone/reader.rb +100 -15
- data/lib/herringbone/redaction/rewriter.rb +59 -7
- data/lib/herringbone/redaction.rb +20 -5
- data/lib/herringbone/schema.rb +2 -0
- data/lib/herringbone/simple_writer.rb +28 -0
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +102 -24
- data/lib/herringbone.rb +69 -25
- metadata +7 -2
|
@@ -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
|