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,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
# How Writer encrypts a file (Parquet modular encryption), checked as soon as it is built. The
|
|
5
|
+
# +encryption:+ option of Writer, Herringbone.write, SimpleWriter and Herringbone.redact takes
|
|
6
|
+
# one, or a Hash of the same keywords, which is turned into one with EncryptionConfiguration.from.
|
|
7
|
+
#
|
|
8
|
+
# config = Herringbone::EncryptionConfiguration.new(
|
|
9
|
+
# footer_key: FOOTER_KEY, footer_key_metadata: "orders-footer",
|
|
10
|
+
# columns: { "ssn" => { key: SSN_KEY, key_metadata: "pii" }, "email" => :footer }
|
|
11
|
+
# )
|
|
12
|
+
# Herringbone::Writer.open(io, schema, encryption: config) { |w| ... }
|
|
13
|
+
#
|
|
14
|
+
# Which column names exist is only known once the schema is, so unknown columns raise when the
|
|
15
|
+
# Writer is created. Instances are frozen, and #inspect leaves the keys out.
|
|
16
|
+
class EncryptionConfiguration
|
|
17
|
+
# Values of +algorithm:+: AES_GCM_V1 and AES_GCM_CTR_V1
|
|
18
|
+
ALGORITHMS = %i[aes_gcm aes_gcm_ctr].freeze
|
|
19
|
+
|
|
20
|
+
# A column encrypted with a key of its own
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] key
|
|
23
|
+
# @return [String] 16, 24 or 32 bytes, binary
|
|
24
|
+
# @!attribute [r] key_metadata
|
|
25
|
+
# @return [String, nil] stored with the column, for readers to find the key by
|
|
26
|
+
ColumnKey = Struct.new(:key, :key_metadata) do
|
|
27
|
+
# @return [String] the key metadata, without the key
|
|
28
|
+
def inspect = "#<ColumnKey AES-#{key.bytesize * 8} key_metadata=#{key_metadata.inspect}>"
|
|
29
|
+
alias_method :to_s, :inspect
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String] key of the footer, and of the columns encrypted with it (binary)
|
|
33
|
+
attr_reader :footer_key
|
|
34
|
+
|
|
35
|
+
# @return [String, nil] stored for the footer key, for readers to find it by
|
|
36
|
+
attr_reader :footer_key_metadata
|
|
37
|
+
|
|
38
|
+
# @return [Hash{String => ColumnKey, Symbol}, nil] column path or field name => its own key, or
|
|
39
|
+
# +:footer+ for the footer key; nil when every column is encrypted with the footer key
|
|
40
|
+
attr_reader :columns
|
|
41
|
+
|
|
42
|
+
# @return [Symbol] +:aes_gcm+ (AES_GCM_V1) or +:aes_gcm_ctr+ (AES_GCM_CTR_V1, pages with AES-CTR)
|
|
43
|
+
attr_reader :algorithm
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] identity of the file, part of the AAD of every encrypted module
|
|
46
|
+
attr_reader :aad_prefix
|
|
47
|
+
|
|
48
|
+
# Encryption that the most Parquet readers can decrypt given nothing but the key: every
|
|
49
|
+
# column and the footer encrypted with one key, AES_GCM_V1, no AAD prefix. The key's id is
|
|
50
|
+
# stored in the file, so a reader holding several keys picks the right one. Passing a Key or
|
|
51
|
+
# the hex of a key as +encryption:+ does the same.
|
|
52
|
+
#
|
|
53
|
+
# key = Herringbone::Key.generate
|
|
54
|
+
# Herringbone.write(io, rows, encryption: key)
|
|
55
|
+
# Herringbone::Reader.new(io, decryption: key) # or [key, older_key, ...]
|
|
56
|
+
#
|
|
57
|
+
# Readers that take the key alone: pyarrow 25+
|
|
58
|
+
# (+pyarrow.parquet.encryption.create_decryption_properties(key.bytes)+), Arrow C++, arrow-go
|
|
59
|
+
# and ParquetSharp (as the footer key, or by id with a string key id retriever), arrow-rs and
|
|
60
|
+
# DataFusion, Trino 478+, and parquet-java / Spark with a decryption properties factory that
|
|
61
|
+
# returns the key.
|
|
62
|
+
#
|
|
63
|
+
# The finer settings are left out on purpose: pyarrow's single-key API, arrow-rs and DuckDB
|
|
64
|
+
# read neither per-column keys nor AES-CTR, DuckDB needs an encrypted footer and no AAD
|
|
65
|
+
# prefix, and arrow-rs has no 192-bit keys.
|
|
66
|
+
#
|
|
67
|
+
# @param key [Key, String] the key, or its hex (the id is then the key's fingerprint)
|
|
68
|
+
# @return [EncryptionConfiguration]
|
|
69
|
+
# @raise [ArgumentError] for a String that is not the hex of a key, or a key that is not 16 or
|
|
70
|
+
# 32 bytes long
|
|
71
|
+
def self.simple(key)
|
|
72
|
+
key = Key.from(key)
|
|
73
|
+
if key.bits == 192
|
|
74
|
+
raise ArgumentError, "encryption: use a 128 or 256-bit key: arrow-rs and DataFusion cannot read 192-bit keys"
|
|
75
|
+
end
|
|
76
|
+
new(footer_key: key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Turns the +encryption:+ option into a configuration
|
|
80
|
+
#
|
|
81
|
+
# @param value [EncryptionConfiguration, Hash{Symbol, String => Object}, Key, String] a
|
|
82
|
+
# configuration, the keywords of #initialize, or a key or its hex (see .simple)
|
|
83
|
+
# @return [EncryptionConfiguration]
|
|
84
|
+
# @raise [ArgumentError] for anything else, and for invalid settings
|
|
85
|
+
def self.from(value)
|
|
86
|
+
case value
|
|
87
|
+
when EncryptionConfiguration then value
|
|
88
|
+
when Key, String then simple(value)
|
|
89
|
+
when Hash
|
|
90
|
+
options = value.transform_keys(&:to_sym)
|
|
91
|
+
unknown = options.keys - instance_method(:initialize).parameters.map(&:last)
|
|
92
|
+
raise ArgumentError, "encryption: unknown option #{unknown.join(", ")}" unless unknown.empty?
|
|
93
|
+
new(**options)
|
|
94
|
+
else
|
|
95
|
+
raise ArgumentError, "encryption: expected a Herringbone::Key, an EncryptionConfiguration or a Hash, got #{value.class}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param footer_key [Key, String] the key, or 16, 24 or 32 bytes (AES-128, 192 or 256); required
|
|
100
|
+
# @param footer_key_metadata [String, nil] stored in the file for the footer key; defaults to
|
|
101
|
+
# the id of a Key
|
|
102
|
+
# @param columns [Hash{String, Symbol, Array<String> => String, Hash, Symbol}, nil] column path
|
|
103
|
+
# (+"address.city"+) or field name (all of its columns) => a Key (stored with its id), the
|
|
104
|
+
# bytes of a key, +{key:, key_metadata:}+, or +:footer+ for the footer key. Columns left out are not encrypted; nil encrypts them all
|
|
105
|
+
# with the footer key.
|
|
106
|
+
# @param plaintext_footer [Boolean] store the footer in the clear (signed with the footer key),
|
|
107
|
+
# so readers without keys can read the plaintext columns; it then keeps no statistics of the
|
|
108
|
+
# encrypted columns
|
|
109
|
+
# @param algorithm [Symbol] +:aes_gcm+ or +:aes_gcm_ctr+, which encrypts pages with AES-CTR:
|
|
110
|
+
# faster, but page contents are not authenticated
|
|
111
|
+
# @param aad_prefix [String, nil] identity of the file (a table and partition name, say), which
|
|
112
|
+
# binds the encrypted modules to it
|
|
113
|
+
# @param store_aad_prefix [Boolean] false leaves the AAD prefix out of the file, so readers must
|
|
114
|
+
# supply it
|
|
115
|
+
# @raise [ArgumentError] for a key of the wrong size, a bad column setting, an unknown algorithm,
|
|
116
|
+
# or +store_aad_prefix: false+ without an AAD prefix
|
|
117
|
+
def initialize(footer_key: nil, footer_key_metadata: nil, columns: nil, plaintext_footer: false,
|
|
118
|
+
algorithm: :aes_gcm, aad_prefix: nil, store_aad_prefix: true)
|
|
119
|
+
footer_key_metadata ||= footer_key.id if footer_key.is_a?(Key)
|
|
120
|
+
footer_key = footer_key.bytes if footer_key.is_a?(Key)
|
|
121
|
+
@footer_key = Encryption.check_key!(footer_key, "encryption: footer_key").freeze
|
|
122
|
+
@footer_key_metadata = footer_key_metadata&.to_s&.b&.freeze
|
|
123
|
+
@columns = columns.nil? ? nil : column_settings(columns)
|
|
124
|
+
@plaintext_footer = plaintext_footer ? true : false
|
|
125
|
+
algorithm = algorithm.to_sym if algorithm.is_a?(String)
|
|
126
|
+
unless ALGORITHMS.include?(algorithm)
|
|
127
|
+
raise ArgumentError, "encryption: algorithm must be :aes_gcm or :aes_gcm_ctr, got #{algorithm.inspect}"
|
|
128
|
+
end
|
|
129
|
+
@algorithm = algorithm
|
|
130
|
+
@aad_prefix = aad_prefix&.to_s&.b&.freeze
|
|
131
|
+
@store_aad_prefix = store_aad_prefix ? true : false
|
|
132
|
+
raise ArgumentError, "encryption: store_aad_prefix: false needs an aad_prefix" if !@store_aad_prefix && @aad_prefix.nil?
|
|
133
|
+
freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Boolean] whether the footer is stored in the clear (and signed)
|
|
137
|
+
def plaintext_footer? = @plaintext_footer
|
|
138
|
+
|
|
139
|
+
# @return [Boolean] whether the file stores the AAD prefix (false: readers must supply it)
|
|
140
|
+
def store_aad_prefix? = @store_aad_prefix
|
|
141
|
+
|
|
142
|
+
# @return [Boolean] whether every column is encrypted with the footer key
|
|
143
|
+
def uniform? = @columns.nil?
|
|
144
|
+
|
|
145
|
+
# @return [Hash{Symbol => Object}] the settings as keywords of #initialize, keys included
|
|
146
|
+
def to_h
|
|
147
|
+
{
|
|
148
|
+
footer_key: @footer_key, footer_key_metadata: @footer_key_metadata,
|
|
149
|
+
columns: @columns&.transform_values { |v| v.is_a?(ColumnKey) ? v.to_h : v },
|
|
150
|
+
plaintext_footer: @plaintext_footer, algorithm: @algorithm, aad_prefix: @aad_prefix,
|
|
151
|
+
store_aad_prefix: @store_aad_prefix
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param other [Object] object to compare with
|
|
156
|
+
# @return [Boolean] whether +other+ is a configuration with the same settings and keys
|
|
157
|
+
def ==(other) = other.is_a?(EncryptionConfiguration) && other.to_h == to_h
|
|
158
|
+
|
|
159
|
+
# @return [String] the settings, without keys
|
|
160
|
+
def inspect
|
|
161
|
+
columns = if @columns
|
|
162
|
+
@columns.map { |name, v| "#{name}=#{v.is_a?(ColumnKey) ? (v.key_metadata || "key").inspect : v}" }.join(",")
|
|
163
|
+
else
|
|
164
|
+
"all"
|
|
165
|
+
end
|
|
166
|
+
"#<#{self.class.name} #{@algorithm} footer=#{@plaintext_footer ? "plaintext" : "encrypted"}" \
|
|
167
|
+
"#{" footer_key_metadata=#{@footer_key_metadata.inspect}" if @footer_key_metadata} columns=#{columns}" \
|
|
168
|
+
"#{" aad_prefix=#{@aad_prefix.inspect}#{" (not stored)" unless @store_aad_prefix}" if @aad_prefix}>"
|
|
169
|
+
end
|
|
170
|
+
alias_method :to_s, :inspect
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# @param columns [Hash] the +columns:+ argument
|
|
175
|
+
# @return [Hash{String => ColumnKey, Symbol}] frozen, keyed by dotted path or field name
|
|
176
|
+
# @raise [ArgumentError] for a bad setting or a name given twice
|
|
177
|
+
def column_settings(columns)
|
|
178
|
+
raise ArgumentError, "encryption: columns: expected a Hash, got #{columns.class}" unless columns.is_a?(Hash)
|
|
179
|
+
out = {}
|
|
180
|
+
columns.each do |name, setting|
|
|
181
|
+
name = name.is_a?(Array) ? name.join(".") : name.to_s
|
|
182
|
+
raise ArgumentError, "encryption: column #{name} is listed twice" if out.key?(name)
|
|
183
|
+
out[name.freeze] = case setting
|
|
184
|
+
when :footer, true then :footer
|
|
185
|
+
when String then ColumnKey.new(Encryption.check_key!(setting, "encryption: key of #{name}").freeze, nil).freeze
|
|
186
|
+
when Key then ColumnKey.new(setting.bytes, setting.id.b.freeze).freeze
|
|
187
|
+
when ColumnKey then column_key(name, setting.to_h)
|
|
188
|
+
when Hash then column_key(name, setting.transform_keys(&:to_sym))
|
|
189
|
+
else
|
|
190
|
+
raise ArgumentError, "encryption: expected a key, {key:, key_metadata:} or :footer for #{name}, got #{setting.class}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
out.freeze
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @param name [String] the column
|
|
197
|
+
# @param setting [Hash{Symbol => Object}] +key:+ and +key_metadata:+
|
|
198
|
+
# @return [ColumnKey]
|
|
199
|
+
# @raise [ArgumentError] for unknown settings or a key of the wrong size
|
|
200
|
+
def column_key(name, setting)
|
|
201
|
+
unknown = setting.keys - ColumnKey.members
|
|
202
|
+
raise ArgumentError, "encryption: unknown option #{unknown.join(", ")} for #{name}" unless unknown.empty?
|
|
203
|
+
if setting[:key].is_a?(Key)
|
|
204
|
+
setting = {key_metadata: setting[:key].id}.merge(setting.compact).merge(key: setting[:key].bytes)
|
|
205
|
+
end
|
|
206
|
+
key = Encryption.check_key!(setting[:key], "encryption: key of #{name}").freeze
|
|
207
|
+
ColumnKey.new(key, setting[:key_metadata]&.to_s&.b&.freeze).freeze
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The keys Reader (and Inspector, Herringbone.redact) needs for an encrypted file. The
|
|
212
|
+
# +decryption:+ option takes one, a Hash of the same keywords, a Key (or an Array of them, a
|
|
213
|
+
# keyring), or a callable that returns the key for a key id (see DecryptionConfiguration.from).
|
|
214
|
+
#
|
|
215
|
+
# Herringbone::Reader.new(io, decryption: key)
|
|
216
|
+
# Herringbone::Reader.new(io, decryption: [key, older_key]) # picked by the id in the file
|
|
217
|
+
# Herringbone::Reader.new(io, decryption: ->(key_id) { vault.read("parquet/#{key_id}") })
|
|
218
|
+
#
|
|
219
|
+
# Herringbone::DecryptionConfiguration.new(footer_key: FOOTER_KEY, columns: { "ssn" => SSN_KEY })
|
|
220
|
+
# Herringbone::DecryptionConfiguration.new(keys: { "2026-10" => KEY })
|
|
221
|
+
# Herringbone::DecryptionConfiguration.new(keys: ->(key_id) { vault.read("parquet/#{key_id}") })
|
|
222
|
+
#
|
|
223
|
+
# Explicit keys win; the others are looked up with +keys:+. Instances are frozen, and #inspect
|
|
224
|
+
# leaves the keys out.
|
|
225
|
+
class DecryptionConfiguration
|
|
226
|
+
# @return [String, nil] key of the footer (and of the columns encrypted with it), binary
|
|
227
|
+
attr_reader :footer_key
|
|
228
|
+
|
|
229
|
+
# @return [Hash{String => String}] column path or field name => key
|
|
230
|
+
attr_reader :columns
|
|
231
|
+
|
|
232
|
+
# @return [#call, Hash{String => String}, nil] looks keys up by their key metadata
|
|
233
|
+
attr_reader :keys
|
|
234
|
+
|
|
235
|
+
# @return [String, nil] the file's AAD prefix: needed when the file does not store it, and
|
|
236
|
+
# checked against the stored one otherwise
|
|
237
|
+
attr_reader :aad_prefix
|
|
238
|
+
|
|
239
|
+
# Turns the +decryption:+ option into a configuration
|
|
240
|
+
#
|
|
241
|
+
# A keyring (a Key, the hex of a key, or an Array of those) looks keys up by the id stored
|
|
242
|
+
# in the file; a single key is also used when the file names no id or another one, as for
|
|
243
|
+
# files from tools that store none.
|
|
244
|
+
#
|
|
245
|
+
# @param value [DecryptionConfiguration, Hash{Symbol, String => Object}, Key, String,
|
|
246
|
+
# Array<Key, String>, #call, nil] a configuration, the keywords of #initialize, a keyring,
|
|
247
|
+
# a callable to use as +keys:+, or nil
|
|
248
|
+
# @return [DecryptionConfiguration, nil] nil for nil
|
|
249
|
+
# @raise [ArgumentError] for anything else, and for invalid settings
|
|
250
|
+
def self.from(value)
|
|
251
|
+
return new(keys: value) if value.respond_to?(:call)
|
|
252
|
+
case value
|
|
253
|
+
when nil, DecryptionConfiguration then value
|
|
254
|
+
when Key, String then from([value])
|
|
255
|
+
when Array
|
|
256
|
+
keys = value.map { |key| Key.from(key) }
|
|
257
|
+
raise ArgumentError, "decryption: the keyring is empty" if keys.empty?
|
|
258
|
+
new(footer_key: (keys.size == 1) ? keys.first : nil, keys: keys.to_h { |key| [key.id, key] })
|
|
259
|
+
when Hash
|
|
260
|
+
options = value.transform_keys(&:to_sym)
|
|
261
|
+
unknown = options.keys - instance_method(:initialize).parameters.map(&:last)
|
|
262
|
+
raise ArgumentError, "decryption: unknown option #{unknown.join(", ")}" unless unknown.empty?
|
|
263
|
+
new(**options)
|
|
264
|
+
else
|
|
265
|
+
raise ArgumentError, "decryption: expected a Herringbone::Key, a DecryptionConfiguration, a Hash or a callable, got #{value.class}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @param footer_key [Key, String, nil] 16, 24 or 32 bytes
|
|
270
|
+
# @param columns [Hash{String, Symbol, Array<String> => Key, String}] column path or field name => key
|
|
271
|
+
# @param keys [#call, Hash{String => Key, String}, nil] key metadata (a Key's id) => key, for the
|
|
272
|
+
# keys not given above. A callable gets the key metadata stored for the key (nil when the
|
|
273
|
+
# file stores none) and, when it takes a second parameter, what the key is for: +:footer+ or
|
|
274
|
+
# the dotted path of the column. It returns the key (a Key or bytes), or nil when it is not
|
|
275
|
+
# available. Each key is looked up once per Reader.
|
|
276
|
+
# @param aad_prefix [String, nil] the file's AAD prefix
|
|
277
|
+
# @raise [ArgumentError] for a key of the wrong size, or +keys:+ that is neither a Hash nor callable
|
|
278
|
+
def initialize(footer_key: nil, columns: {}, keys: nil, aad_prefix: nil)
|
|
279
|
+
footer_key = footer_key.bytes if footer_key.is_a?(Key)
|
|
280
|
+
@footer_key = footer_key && Encryption.check_key!(footer_key, "decryption: footer_key").freeze
|
|
281
|
+
raise ArgumentError, "decryption: columns: expected a Hash, got #{columns.class}" unless columns.is_a?(Hash)
|
|
282
|
+
@columns = columns.to_h do |name, key|
|
|
283
|
+
name = name.is_a?(Array) ? name.join(".") : name.to_s
|
|
284
|
+
key = key.bytes if key.is_a?(Key)
|
|
285
|
+
[name.freeze, Encryption.check_key!(key, "decryption: key of #{name}").freeze]
|
|
286
|
+
end.freeze
|
|
287
|
+
if keys && !keys.respond_to?(:call) && !keys.is_a?(Hash)
|
|
288
|
+
raise ArgumentError, "decryption: keys: expected a Hash or a callable, got #{keys.class}"
|
|
289
|
+
end
|
|
290
|
+
@keys = keys
|
|
291
|
+
@aad_prefix = aad_prefix&.to_s&.b&.freeze
|
|
292
|
+
freeze
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @return [String] what is configured, without keys
|
|
296
|
+
def inspect
|
|
297
|
+
"#<#{self.class.name}#{" footer_key" if @footer_key}" \
|
|
298
|
+
"#{" columns=#{@columns.keys.join(",")}" unless @columns.empty?}" \
|
|
299
|
+
"#{" keys=#{@keys.is_a?(Hash) ? "Hash" : "callable"}" if @keys}" \
|
|
300
|
+
"#{" aad_prefix=#{@aad_prefix.inspect}" if @aad_prefix}>"
|
|
301
|
+
end
|
|
302
|
+
alias_method :to_s, :inspect
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/herringbone/format.rb
CHANGED
|
@@ -397,8 +397,56 @@ module Herringbone
|
|
|
397
397
|
field 16, :size_statistics, SizeStatistics
|
|
398
398
|
end
|
|
399
399
|
|
|
400
|
-
#
|
|
401
|
-
|
|
400
|
+
# Modular encryption (Encryption.md)
|
|
401
|
+
|
|
402
|
+
# Algorithm settings shared by both algorithms: the AAD prefix (unless readers must supply
|
|
403
|
+
# it) and the file's unique id, which together make the file's part of every module's AAD.
|
|
404
|
+
class AesGcmV1 < S
|
|
405
|
+
field 1, :aad_prefix, :binary
|
|
406
|
+
field 2, :aad_file_unique, :binary
|
|
407
|
+
field 3, :supply_aad_prefix, :bool
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# AES_GCM_CTR_V1: like AesGcmV1, but pages are encrypted with AES-CTR, without a tag.
|
|
411
|
+
class AesGcmCtrV1 < S
|
|
412
|
+
field 1, :aad_prefix, :binary
|
|
413
|
+
field 2, :aad_file_unique, :binary
|
|
414
|
+
field 3, :supply_aad_prefix, :bool
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Union of encryption algorithms; exactly one member is set.
|
|
418
|
+
class EncryptionAlgorithm < S
|
|
419
|
+
field 1, :aes_gcm_v1, AesGcmV1
|
|
420
|
+
field 2, :aes_gcm_ctr_v1, AesGcmCtrV1
|
|
421
|
+
|
|
422
|
+
# @return [AesGcmV1, AesGcmCtrV1, nil] whichever member is set
|
|
423
|
+
def settings = aes_gcm_v1 || aes_gcm_ctr_v1
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# ColumnCryptoMetaData member: the column is encrypted with the footer key.
|
|
427
|
+
class EncryptionWithFooterKey < S; end
|
|
428
|
+
|
|
429
|
+
# ColumnCryptoMetaData member: the column is encrypted with a key of its own.
|
|
430
|
+
class EncryptionWithColumnKey < S
|
|
431
|
+
field 1, :path_in_schema, [:list, :string]
|
|
432
|
+
field 2, :key_metadata, :binary
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Union saying which key an encrypted column uses; exactly one member is set.
|
|
436
|
+
class ColumnCryptoMetaData < S
|
|
437
|
+
field 1, :encryption_with_footer_key, EncryptionWithFooterKey
|
|
438
|
+
field 2, :encryption_with_column_key, EncryptionWithColumnKey
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Stored in the clear before an encrypted footer (files with the PARE magic).
|
|
442
|
+
class FileCryptoMetaData < S
|
|
443
|
+
field 1, :encryption_algorithm, EncryptionAlgorithm
|
|
444
|
+
field 2, :key_metadata, :binary
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# One column of a row group, plus the locations of its page index. In encrypted columns
|
|
448
|
+
# +crypto_metadata+ names the key, and +encrypted_column_metadata+ holds the ColumnMetaData
|
|
449
|
+
# when it is encrypted on its own.
|
|
402
450
|
class ColumnChunk < S
|
|
403
451
|
field 1, :file_path, :string
|
|
404
452
|
field 2, :file_offset, :i64
|
|
@@ -407,6 +455,7 @@ module Herringbone
|
|
|
407
455
|
field 5, :offset_index_length, :i32
|
|
408
456
|
field 6, :column_index_offset, :i64
|
|
409
457
|
field 7, :column_index_length, :i32
|
|
458
|
+
field 8, :crypto_metadata, ColumnCryptoMetaData
|
|
410
459
|
field 9, :encrypted_column_metadata, :binary
|
|
411
460
|
end
|
|
412
461
|
|
|
@@ -464,7 +513,8 @@ module Herringbone
|
|
|
464
513
|
field 1, :type_order, TypeDefinedOrder
|
|
465
514
|
end
|
|
466
515
|
|
|
467
|
-
# The file footer: schema, row groups and file-level metadata.
|
|
516
|
+
# The file footer: schema, row groups and file-level metadata. The encryption fields are
|
|
517
|
+
# only set in encrypted files with a plaintext footer.
|
|
468
518
|
class FileMetaData < S
|
|
469
519
|
field 1, :version, :i32
|
|
470
520
|
field 2, :schema, [:list, SchemaElement]
|
|
@@ -473,6 +523,8 @@ module Herringbone
|
|
|
473
523
|
field 5, :key_value_metadata, [:list, KeyValue]
|
|
474
524
|
field 6, :created_by, :string
|
|
475
525
|
field 7, :column_orders, [:list, ColumnOrder]
|
|
526
|
+
field 8, :encryption_algorithm, EncryptionAlgorithm
|
|
527
|
+
field 9, :footer_signing_key_metadata, :binary
|
|
476
528
|
end
|
|
477
529
|
|
|
478
530
|
# Bloom filters (BloomFilter.md). Each union has a single member, an empty struct.
|
|
@@ -24,9 +24,14 @@ module Herringbone
|
|
|
24
24
|
# @yieldparam sample [Array] the rows held back, at most Schema::INFER_SAMPLE
|
|
25
25
|
# @yieldreturn [Schema]
|
|
26
26
|
# @raise [MissingCodecError] when the codec's optional gem is not loaded
|
|
27
|
+
# @raise [ArgumentError] for a compression level the codec does not take, or invalid
|
|
28
|
+
# encryption settings
|
|
27
29
|
def initialize(io, fix:, **options, &schema_for)
|
|
28
|
-
# Fail before reading any rows if the codec's library is missing
|
|
29
|
-
Compression.
|
|
30
|
+
# Fail before reading any rows if the codec's library is missing or the level is wrong
|
|
31
|
+
codec = Compression.codec_id(options.fetch(:compression, :snappy))
|
|
32
|
+
Compression.ensure_available!(codec)
|
|
33
|
+
Compression.check_level!(codec, options[:compression_level])
|
|
34
|
+
options = options.merge(encryption: EncryptionConfiguration.from(options[:encryption])) if options[:encryption]
|
|
30
35
|
@io = io
|
|
31
36
|
@fix = fix
|
|
32
37
|
@options = options
|
|
@@ -52,6 +57,16 @@ module Herringbone
|
|
|
52
57
|
self
|
|
53
58
|
end
|
|
54
59
|
|
|
60
|
+
# Sets the encryption before the first row
|
|
61
|
+
#
|
|
62
|
+
# @param config [EncryptionConfiguration] how to encrypt the file
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @raise [Error] once rows were given
|
|
65
|
+
def encryption=(config)
|
|
66
|
+
raise Error, "Encryption must be set before the first row" if @writer || !@sample.empty?
|
|
67
|
+
@options = @options.merge(encryption: config)
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# @return [Integer] rows accepted so far, including the ones held back
|
|
56
71
|
def rows_written = @writer ? @writer.rows_written : @sample.size
|
|
57
72
|
|