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,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
@@ -397,8 +397,56 @@ module Herringbone
397
397
  field 16, :size_statistics, SizeStatistics
398
398
  end
399
399
 
400
- # One column of a row group, plus the locations of its page index. Field id 8
401
- # (crypto_metadata) is not supported.
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.ensure_available!(Compression.codec_id(options.fetch(:compression, :snappy)))
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