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.
@@ -119,7 +119,29 @@ module Herringbone
119
119
  @row_group = row_group
120
120
  @column = column
121
121
  @chunk = chunk
122
- @meta = chunk.meta_data
122
+ # An encrypted footer leaves out the metadata of columns with their own key; without that
123
+ # key nothing is known about the chunk
124
+ @meta = chunk.meta_data || Format::ColumnMetaData.new(path_in_schema: column.path)
125
+ end
126
+
127
+ # @return [Boolean] whether the chunk is encrypted
128
+ def encrypted? = !@chunk.crypto_metadata.nil?
129
+
130
+ # Decryption of the chunk's modules (memoized)
131
+ # @return [Encryption::ModuleCrypto, nil] nil when the chunk is not encrypted or its key was
132
+ # not given
133
+ def crypto
134
+ return @crypto if defined?(@crypto)
135
+ @crypto = @inspector.chunk_crypto(self)
136
+ end
137
+
138
+ # How the chunk is encrypted, without its key
139
+ # @return [Hash{Symbol => Object}, nil] { key: :footer or :column, key_metadata:, readable: },
140
+ # nil for a plaintext chunk
141
+ def encryption
142
+ c = @chunk.crypto_metadata or return nil
143
+ with_column_key = c.encryption_with_column_key
144
+ {key: with_column_key ? :column : :footer, key_metadata: with_column_key&.key_metadata, readable: !crypto.nil?}
123
145
  end
124
146
 
125
147
  # @return [String] dotted path of the column, e.g. +"address.city"+
@@ -163,15 +185,16 @@ module Herringbone
163
185
  end
164
186
 
165
187
  # Where the chunk's first page starts
166
- # @return [Integer] file offset
188
+ # @return [Integer, nil] file offset; nil when the chunk's metadata is encrypted and its key
189
+ # was not given
167
190
  def start_offset = dictionary_page_offset || data_page_offset
168
191
 
169
192
  # The end according to the metadata; some writers under-report it (see #end_offset)
170
- # @return [Integer] file offset just past the chunk
171
- def declared_end_offset = start_offset + compressed_size
193
+ # @return [Integer, nil] file offset just past the chunk; nil when it is not known
194
+ def declared_end_offset = start_offset && start_offset + compressed_size
172
195
 
173
196
  # The end of the last page actually found (the declared end if the pages could not be walked)
174
- # @return [Integer] file offset just past the chunk, never before #declared_end_offset
197
+ # @return [Integer, nil] file offset just past the chunk, never before #declared_end_offset
175
198
  def end_offset
176
199
  last = pages.last
177
200
  last ? [last.end_offset, declared_end_offset].max : declared_end_offset
@@ -215,7 +238,7 @@ module Herringbone
215
238
  return nil unless bloom_filter_offset
216
239
  return @meta.bloom_filter_length if @meta.bloom_filter_length
217
240
  return @bloom_filter_length if defined?(@bloom_filter_length)
218
- @bloom_filter_length = @inspector.bloom_filter_size(bloom_filter_offset)
241
+ @bloom_filter_length = @inspector.bloom_filter_size(bloom_filter_offset, encrypted?)
219
242
  end
220
243
 
221
244
  # @return [Array(Integer, Integer), nil] [offset, length] of the chunk's ColumnIndex, nil when
@@ -268,9 +291,11 @@ module Herringbone
268
291
  end
269
292
 
270
293
  # Null count from the chunk statistics, else the sum over the data page headers
271
- # @return [Integer, nil] nil when neither the statistics nor every data page has it
294
+ # @return [Integer, nil] nil when neither the statistics nor every data page has it, or the
295
+ # chunk is encrypted and its key was not given
272
296
  def null_count
273
297
  return statistics.null_count if statistics&.null_count
298
+ return nil if encrypted? && !crypto
274
299
  counts = data_pages.map(&:num_nulls)
275
300
  counts.all? ? counts.sum : nil
276
301
  end
@@ -327,6 +352,7 @@ module Herringbone
327
352
  offset_index_offset: offset_index_range&.first,
328
353
  offset_index_length: offset_index_range&.last,
329
354
  external_file: external_file,
355
+ encryption: encryption,
330
356
  num_pages: pages.size,
331
357
  num_data_pages: data_pages.size,
332
358
  index_mismatches: index_mismatches.empty? ? nil : index_mismatches,
@@ -397,10 +423,10 @@ module Herringbone
397
423
  def uncompressed_size = @columns.sum { |c| c.uncompressed_size.to_i }
398
424
 
399
425
  # @return [Integer, nil] lowest start offset of the row group's chunks; nil without chunks
400
- def start_offset = @columns.map(&:start_offset).min
426
+ def start_offset = @columns.filter_map(&:start_offset).min
401
427
 
402
428
  # @return [Integer, nil] highest end offset of the row group's chunks; nil without chunks
403
- def end_offset = @columns.map(&:end_offset).max
429
+ def end_offset = @columns.filter_map(&:end_offset).max
404
430
 
405
431
  # @param path [String, Array<String>] dotted path (+"a.b"+) or path segments (+["a", "b"]+)
406
432
  # @return [ColumnChunkInfo, nil] the chunk of that column, nil when there is none
@@ -447,12 +473,19 @@ module Herringbone
447
473
 
448
474
  # +io+ is a random-access IO (responds to #seek and #read, e.g. File.open(path, "rb")). It is
449
475
  # left open.
476
+ #
477
+ # An encrypted file needs +decryption:+ (see Reader.new) when its footer is encrypted. With a
478
+ # plaintext footer it opens without keys, and the chunks whose key is missing are shown
479
+ # without their pages, page indexes and statistics.
480
+ #
450
481
  # @param io [IO] random-access IO positioned anywhere; only the footer is read here
482
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, nil] keys of an encrypted file, see Reader.new
451
483
  # @raise [ArgumentError] when +io+ does not support #seek and #read
452
484
  # @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
453
- # @raise [UnsupportedError] when the file is encrypted (PARE magic)
454
- def initialize(io)
485
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
486
+ def initialize(io, decryption: nil)
455
487
  @io = io
488
+ @decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
456
489
  unless @io.respond_to?(:read) && @io.respond_to?(:seek)
457
490
  raise ArgumentError, "Herringbone::Inspector expects an IO that supports #seek and #read " \
458
491
  "(e.g. File.open(path, \"rb\")), got #{io.class}"
@@ -510,6 +543,12 @@ module Herringbone
510
543
  # @return [Boolean] whether any column chunk has a bloom filter
511
544
  def bloom_filters? = column_chunks.any?(&:bloom_filter_offset)
512
545
 
546
+ # How the file is encrypted (see Reader#encryption), nil for a file that is not
547
+ # @return [Hash{Symbol => Object}, nil] +:algorithm+, +:footer+, +:footer_key_metadata+,
548
+ # +:aad_prefix+, +:supply_aad_prefix+, +:footer_verified+ and +:columns+ (path => { key:,
549
+ # key_metadata:, readable: } for the encrypted columns of the first row group)
550
+ def encryption = @decryptor&.describe(@schema, @metadata.row_groups&.first&.columns || [])
551
+
513
552
  # The file-level key/value metadata, each entry described: ARROW:schema is decoded, JSON
514
553
  # values are parsed (pandas metadata summarized), binary values are shown as hex
515
554
  # @return [Array<Hash{Symbol => Object}>] each with :key, :bytesize, :format ("arrow_schema",
@@ -531,7 +570,7 @@ module Herringbone
531
570
  # Walks no page headers.
532
571
  # @return [Hash{Symbol => Object}]
533
572
  def summary
534
- codecs = column_chunks.map(&:codec).uniq
573
+ codecs = column_chunks.filter_map(&:codec).uniq
535
574
  {
536
575
  name: @name,
537
576
  file_size: @file_size,
@@ -547,7 +586,8 @@ module Herringbone
547
586
  uncompressed_size: column_chunks.sum { |c| c.uncompressed_size.to_i },
548
587
  page_index: page_index?,
549
588
  bloom_filters: bloom_filters?,
550
- column_orders: column_orders
589
+ column_orders: column_orders,
590
+ encryption: encryption
551
591
  }.tap { |h| h[:checksums] = checksum_summary.except(:mismatches) if checksums_verified? }
552
592
  end
553
593
 
@@ -656,7 +696,7 @@ module Herringbone
656
696
  column: col.index,
657
697
  path: col.dotted_path,
658
698
  type: Inspector.type_name(col),
659
- codecs: chunks.map(&:codec).uniq,
699
+ codecs: chunks.filter_map(&:codec).uniq,
660
700
  encodings: chunks.flat_map(&:encodings).uniq,
661
701
  num_values: chunks.sum { |c| c.num_values.to_i },
662
702
  null_count: nulls.all? ? nulls.sum : nil,
@@ -668,7 +708,8 @@ module Herringbone
668
708
  dictionary_pages: chunks.count(&:dictionary_page),
669
709
  dictionary_bytes: chunks.sum { |c| c.dictionary_page&.total_size.to_i },
670
710
  min: mins.include?(nil) ? nil : safe_extreme(mins, :min),
671
- max: maxes.include?(nil) ? nil : safe_extreme(maxes, :max)
711
+ max: maxes.include?(nil) ? nil : safe_extreme(maxes, :max),
712
+ metadata_unavailable: (!chunks.empty? && chunks.none?(&:start_offset)) || nil
672
713
  }.compact
673
714
  end
674
715
  end
@@ -685,6 +726,7 @@ module Herringbone
685
726
  rg = c.row_group.index
686
727
  col = c.column.index
687
728
  if c.pages.empty?
729
+ next unless c.start_offset
688
730
  segs << {kind: :chunk, start: c.start_offset, length: c.compressed_size, row_group: rg, column: col}
689
731
  else
690
732
  c.pages.each_with_index do |p, i|
@@ -711,7 +753,7 @@ module Herringbone
711
753
  # right after the chunk, at ColumnChunk.file_offset
712
754
  meta_copies = column_chunks.each_with_object({}) do |c, h|
713
755
  fo = c.chunk.file_offset
714
- h[fo] = c if fo && fo >= c.end_offset
756
+ h[fo] = c if fo && c.end_offset && fo >= c.end_offset
715
757
  end
716
758
  out = []
717
759
  pos = 0
@@ -766,6 +808,17 @@ module Herringbone
766
808
  out << "codecs: #{s[:codecs].join(", ")}; data #{Inspector.human_bytes(s[:compressed_size])} compressed, " \
767
809
  "#{Inspector.human_bytes(s[:uncompressed_size])} uncompressed#{ratio_text(s[:uncompressed_size], s[:compressed_size])}"
768
810
  out << "page index: #{s[:page_index] ? "yes" : "no"}, bloom filters: #{s[:bloom_filters] ? "yes" : "no"}"
811
+ if (e = s[:encryption])
812
+ out << "encryption: #{e[:algorithm]}, #{e[:footer]} footer" \
813
+ "#{" (signature verified)" if e[:footer_verified]}" \
814
+ "#{", footer key metadata #{Inspector.display(e[:footer_key_metadata])}" if e[:footer_key_metadata]}" \
815
+ "#{", AAD prefix #{Inspector.display(e[:aad_prefix])}" if e[:aad_prefix]}" \
816
+ "#{", AAD prefix not stored" if e[:supply_aad_prefix]}"
817
+ e[:columns].each do |path, c|
818
+ out << " #{path}: #{c[:key]} key#{" #{Inspector.display(c[:key_metadata])}" if c[:key_metadata]}" \
819
+ "#{" (no key given)" unless c[:readable]}"
820
+ end
821
+ end
769
822
  if (cs = checksum_summary)
770
823
  out << "page CRCs: #{cs[:ok]} ok, #{cs[:mismatch]} mismatched, #{cs[:absent]} without a CRC"
771
824
  cs[:mismatches].each do |m|
@@ -800,6 +853,10 @@ module Herringbone
800
853
  schema_tree.each { |n| walk.call(n, 1) }
801
854
  out << "columns:"
802
855
  column_totals.each do |t|
856
+ if t[:metadata_unavailable]
857
+ out << " #{t[:path]}: #{t[:type]} encrypted, metadata unavailable without its key"
858
+ next
859
+ end
803
860
  range = t.key?(:min) ? " [#{Inspector.display(t[:min])} .. #{Inspector.display(t[:max])}]" : ""
804
861
  out << " #{t[:path]}: #{t[:type]} #{t[:codecs].join(",")} #{t[:encodings].join(",")} " \
805
862
  "#{Inspector.human_bytes(t[:compressed_size])}/#{Inspector.human_bytes(t[:uncompressed_size])}" \
@@ -818,7 +875,12 @@ module Herringbone
818
875
  extras << "column index" if c.column_index_range
819
876
  extras << "offset index" if c.offset_index_range
820
877
  extras << "bloom filter" if c.bloom_filter_offset
878
+ extras << "encrypted" if c.encrypted?
821
879
  extras << "ERROR: #{c.error}" if c.error
880
+ unless c.start_offset
881
+ out << " #{c.path}: encrypted, metadata and pages unavailable without its key"
882
+ next
883
+ end
822
884
  out << " #{c.path}: #{c.codec} #{c.encodings.join(",")} " \
823
885
  "#{c.compressed_size}/#{c.uncompressed_size} bytes#{ratio_text(c.uncompressed_size, c.compressed_size)}, " \
824
886
  "#{c.num_values} values, #{c.pages.size} pages#{", #{extras.join(", ")}" unless extras.empty?}#{range}"
@@ -842,6 +904,15 @@ module Herringbone
842
904
 
843
905
  # ---- used by the info objects ----
844
906
 
907
+ # Decryption of an encrypted chunk's modules
908
+ # @param chunk [ColumnChunkInfo] an encrypted chunk
909
+ # @return [Encryption::ModuleCrypto, nil] nil when the chunk's key was not given
910
+ def chunk_crypto(chunk)
911
+ @decryptor&.chunk(chunk.row_group.index, chunk.row_group.row_group.ordinal, chunk.column, chunk.chunk)
912
+ rescue DecryptionError
913
+ nil
914
+ end
915
+
845
916
  # Walks page headers from the chunk's first page. Returns [pages, error_message_or_nil].
846
917
  # Mirrors the reader's tolerance: a chunk may extend past its declared total_compressed_size.
847
918
  # @param chunk [ColumnChunkInfo] chunk whose pages to walk
@@ -849,6 +920,10 @@ module Herringbone
849
920
  # the walk stopped early (nil when every value was accounted for)
850
921
  def walk_pages(chunk)
851
922
  return [[], "column chunk stored in external file #{chunk.external_file}"] if chunk.external_file
923
+ if chunk.encrypted?
924
+ return [[], "encrypted, and its key was not given"] unless chunk.crypto
925
+ return [[], "encrypted, and its metadata with it"] unless chunk.start_offset
926
+ end
852
927
  pages = []
853
928
  pos = chunk.start_offset
854
929
  limit = footer_offset
@@ -860,9 +935,13 @@ module Herringbone
860
935
  while pos < limit && (seen < total || pos < declared_end)
861
936
  trailing = seen >= total
862
937
  begin
863
- header, header_size = read_page_header(pos, limit)
938
+ header, header_size = if chunk.crypto
939
+ read_encrypted_page_header(chunk, pos, limit, pages)
940
+ else
941
+ read_page_header(pos, limit)
942
+ end
864
943
  page = page_info(pages.size, header, pos, header_size, chunk.column)
865
- rescue Thrift::Error, FormatError => e
944
+ rescue Thrift::Error, FormatError, DecryptionError => e
866
945
  break if trailing
867
946
  return [pages, "corrupt page header at #{pos}: #{e.message}"]
868
947
  end
@@ -883,7 +962,8 @@ module Herringbone
883
962
  def read_column_index(chunk)
884
963
  offset, length = chunk.column_index_range
885
964
  return nil unless offset && length.positive?
886
- ci = Format::ColumnIndex.decode(read_at(offset, length)).first
965
+ bytes = read_module(chunk, offset, length, Encryption::COLUMN_INDEX) or return nil
966
+ ci = Format::ColumnIndex.decode(bytes).first
887
967
  col = chunk.column
888
968
  mins = ci.min_values || []
889
969
  maxes = ci.max_values || []
@@ -898,7 +978,7 @@ module Herringbone
898
978
  repetition_level_histograms: ci.repetition_level_histograms,
899
979
  definition_level_histograms: ci.definition_level_histograms
900
980
  )
901
- rescue Thrift::Error
981
+ rescue Thrift::Error, FormatError, DecryptionError
902
982
  nil
903
983
  end
904
984
 
@@ -908,7 +988,8 @@ module Herringbone
908
988
  def read_offset_index(chunk)
909
989
  offset, length = chunk.offset_index_range
910
990
  return nil unless offset && length.positive?
911
- oi = Format::OffsetIndex.decode(read_at(offset, length)).first
991
+ bytes = read_module(chunk, offset, length, Encryption::OFFSET_INDEX) or return nil
992
+ oi = Format::OffsetIndex.decode(bytes).first
912
993
  OffsetIndexInfo.new(
913
994
  offset: offset, length: length,
914
995
  page_locations: (oi.page_locations || []).map do |l|
@@ -916,14 +997,21 @@ module Herringbone
916
997
  end,
917
998
  unencoded_byte_array_data_bytes: oi.unencoded_byte_array_data_bytes
918
999
  )
919
- rescue Thrift::Error
1000
+ rescue Thrift::Error, FormatError, DecryptionError
920
1001
  nil
921
1002
  end
922
1003
 
923
- # Size in bytes of the bloom filter at +offset+ (its Thrift header plus the bitset)
1004
+ # Size in bytes of the bloom filter at +offset+ (its Thrift header plus the bitset). An
1005
+ # encrypted filter is two modules, whose lengths are stored in the clear.
924
1006
  # @param offset [Integer] file offset of the BloomFilterHeader
1007
+ # @param encrypted [Boolean] whether the filter is encrypted
925
1008
  # @return [Integer, nil] nil when the header can't be decoded or has no num_bytes
926
- def bloom_filter_size(offset)
1009
+ def bloom_filter_size(offset, encrypted = false)
1010
+ if encrypted
1011
+ header = read_at(offset, 4).unpack1("V") or return nil
1012
+ bitset = read_at(offset + 4 + header, 4).unpack1("V") or return nil
1013
+ return 8 + header + bitset
1014
+ end
927
1015
  buf = read_at(offset, 64)
928
1016
  header, size = BloomFilterHeader.decode(buf)
929
1017
  header.num_bytes ? size + header.num_bytes : nil
@@ -1617,19 +1705,25 @@ module Herringbone
1617
1705
  end
1618
1706
 
1619
1707
  # Reads the file size, the footer length and magic, and decodes the FileMetaData into @metadata
1708
+ # (decrypting it, or checking its signature, in an encrypted file)
1620
1709
  # @return [void]
1621
1710
  # @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
1622
- # @raise [UnsupportedError] when the file is encrypted (PARE magic)
1711
+ # @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
1623
1712
  def read_footer
1624
1713
  @io.seek(0, IO::SEEK_END)
1625
1714
  @file_size = @io.pos
1626
1715
  raise FormatError, "File too small to be Parquet (#{@file_size} bytes)" if @file_size < 12
1627
1716
  tail = read_at(@file_size - 8, 8)
1628
- raise UnsupportedError, "Encrypted Parquet files are not supported" if tail.byteslice(4, 4) == "PARE"
1629
- raise FormatError, "Missing PAR1 footer magic" unless tail.byteslice(4, 4) == MAGIC
1717
+ magic = tail.byteslice(4, 4)
1718
+ raise FormatError, "Missing PAR1 footer magic" unless magic == MAGIC || magic == Reader::ENCRYPTED_MAGIC
1630
1719
  @footer_size = tail.unpack1("V")
1631
1720
  raise FormatError, "Footer length #{@footer_size} exceeds file size" if @footer_size + 12 > @file_size
1632
- @metadata = Format::FileMetaData.decode(read_at(footer_offset, @footer_size)).first
1721
+ footer = read_at(footer_offset, @footer_size)
1722
+ if magic == MAGIC
1723
+ @metadata = Format::FileMetaData.decode(footer).first
1724
+ return unless @metadata.encryption_algorithm
1725
+ end
1726
+ @metadata, @decryptor = Encryption.read_footer(footer, magic, @decryption)
1633
1727
  rescue Thrift::Error => e
1634
1728
  raise FormatError, "Corrupt file metadata: #{e.message}"
1635
1729
  end
@@ -1653,6 +1747,42 @@ module Herringbone
1653
1747
  end
1654
1748
  end
1655
1749
 
1750
+ # A module of an encrypted chunk, decrypted
1751
+ # @param chunk [ColumnChunkInfo] the chunk the module belongs to
1752
+ # @param offset [Integer] file offset of the module
1753
+ # @param length [Integer] its length, length prefix included
1754
+ # @param type [Integer] module type
1755
+ # @return [String, nil] the plaintext (the bytes as stored for a plaintext chunk), nil when the
1756
+ # chunk's key was not given
1757
+ # @raise [DecryptionError] when the module does not decrypt
1758
+ def read_module(chunk, offset, length, type)
1759
+ bytes = read_at(offset, length)
1760
+ return bytes unless chunk.encrypted?
1761
+ chunk.crypto&.decrypt(type, bytes)
1762
+ end
1763
+
1764
+ # Decrypts and decodes the encrypted page header at +pos+. Its AAD depends on whether it is the
1765
+ # dictionary page's (the first page, when the chunk has a dictionary) and on the number of
1766
+ # data pages before it.
1767
+ # @param chunk [ColumnChunkInfo] the chunk, whose key is available
1768
+ # @param pos [Integer] file offset of the header module
1769
+ # @param limit [Integer] offset the header must not extend past (the footer's start)
1770
+ # @param pages [Array<PageInfo>] the chunk's pages before this one
1771
+ # @return [Array(Format::PageHeader, Integer)] the header and the module's size in bytes
1772
+ # @raise [FormatError] when the module does not fit before +limit+
1773
+ # @raise [DecryptionError] when it does not decrypt
1774
+ def read_encrypted_page_header(chunk, pos, limit, pages)
1775
+ raise FormatError, "no room for a page header" if pos + 4 > limit
1776
+ size = read_at(pos, 4).unpack1("V") + 4
1777
+ raise FormatError, "encrypted page header overruns the data section" if pos + size > limit
1778
+ plain = if pages.empty? && chunk.dictionary_page_offset == pos
1779
+ chunk.crypto.decrypt(Encryption::DICTIONARY_PAGE_HEADER, read_at(pos, size))
1780
+ else
1781
+ chunk.crypto.decrypt(Encryption::DATA_PAGE_HEADER, read_at(pos, size), pages.count(&:data?))
1782
+ end
1783
+ [Format::PageHeader.decode(plain).first, size]
1784
+ end
1785
+
1656
1786
  # Decodes the page header at +pos+, reading more bytes when it is larger than the first guess
1657
1787
  # (page statistics of long strings can make headers big)
1658
1788
  # @param pos [Integer] file offset of the header
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Herringbone
6
+ # An encryption key and its id. The id is stored in the files the key encrypts (in the clear),
7
+ # so a reader holding several keys can pick the right one; without an id of your own it is a
8
+ # fingerprint of the key (an HMAC, which reveals nothing about the key), the same every time.
9
+ #
10
+ # key = Herringbone::Key.generate # random AES-256 key, fingerprint id
11
+ # key = Herringbone::Key.from_hex(ENV["PARQUET_KEY"]) # 32 or 64 hex digits
12
+ # key = Herringbone::Key.new(bytes, id: "2026-10")
13
+ #
14
+ # Herringbone.write(io, rows, encryption: key)
15
+ # Herringbone::Reader.new(io, decryption: key) # or [new_key, old_key, ...]
16
+ #
17
+ # Where a key may be given as a String instead (+encryption:+, +decryption:+,
18
+ # SimpleWriter#encrypt!), the String must be its hex: raw bytes are easy to mangle and to confuse
19
+ # with text, so they go through Key.new.
20
+ #
21
+ # Instances are frozen; #inspect and #to_s show the id but not the key.
22
+ class Key
23
+ # Message the fingerprint id is an HMAC of
24
+ FINGERPRINT_MESSAGE = "herringbone key id"
25
+
26
+ # @return [String] the id stored in files encrypted with the key
27
+ attr_reader :id
28
+
29
+ # @return [String] the key material, 16, 24 or 32 bytes (binary)
30
+ attr_reader :bytes
31
+
32
+ # @param id [String, nil] the id; nil for the key's fingerprint
33
+ # @param bits [Integer] 128, 192 or 256
34
+ # @return [Key] a random key
35
+ # @raise [ArgumentError] for another size
36
+ def self.generate(id: nil, bits: 256)
37
+ raise ArgumentError, "Key.generate: bits must be 128, 192 or 256, got #{bits.inspect}" unless [128, 192, 256].include?(bits)
38
+ new(OpenSSL::Random.random_bytes(bits / 8), id: id)
39
+ end
40
+
41
+ # @param hex [String] 32, 48 or 64 hex digits
42
+ # @param id [String, nil] the id; nil for the key's fingerprint
43
+ # @return [Key]
44
+ # @raise [ArgumentError] when +hex+ is not hex, or not of a key's length
45
+ def self.from_hex(hex, id: nil)
46
+ hex = hex.to_s.strip
47
+ raise ArgumentError, "Key.from_hex: expected 32, 48 or 64 hex digits, got #{hex.size} characters" unless hex.match?(/\A(\h\h)+\z/)
48
+ new([hex].pack("H*"), id: id)
49
+ end
50
+
51
+ # A Key, or a key given as its hex (with the fingerprint id): 32, 48 or 64 hex digits, as
52
+ # +Key#hex+ gives them and as keys usually sit in environment variables. Any other String is
53
+ # refused, raw key bytes included: those go through Key.new, so a key is never guessed at.
54
+ #
55
+ # @param value [Key, String] a key, or its hex
56
+ # @return [Key]
57
+ # @raise [ArgumentError] for anything else
58
+ def self.from(value)
59
+ return value if value.is_a?(Key)
60
+ unless value.is_a?(String)
61
+ raise ArgumentError, "Expected a Herringbone::Key or the hex of a key, got #{value.class}"
62
+ end
63
+ hex = value.strip
64
+ return from_hex(hex) if hex.match?(/\A(?:\h{32}|\h{48}|\h{64})\z/)
65
+ got = if hex.match?(/\A\h*\z/) then "#{hex.size} hex digits"
66
+ elsif Encryption::KEY_SIZES.include?(value.bytesize) then "#{value.bytesize} bytes that are not hex (raw key bytes?)"
67
+ else "a #{value.bytesize}-byte String that is not hex"
68
+ end
69
+ raise ArgumentError, "Give a key as 32 or 64 hex digits (Herringbone::Key#hex) or as a Herringbone::Key " \
70
+ "(Herringbone::Key.new(bytes) for raw bytes), got #{got}"
71
+ end
72
+
73
+ # @param bytes [String] 16, 24 or 32 bytes (AES-128, 192 or 256)
74
+ # @return [String] the fingerprint of +bytes+: 16 hex digits of an HMAC-SHA256 keyed with them
75
+ def self.fingerprint(bytes)
76
+ OpenSSL::HMAC.digest("SHA256", bytes, FINGERPRINT_MESSAGE).unpack1("H16")
77
+ end
78
+
79
+ # @param bytes [String] 16, 24 or 32 bytes (AES-128, 192 or 256); +Key.generate+ makes one
80
+ # @param id [String, nil] the id; nil for the key's fingerprint
81
+ # @raise [ArgumentError] when +bytes+ is not 16, 24 or 32 bytes long, or +id+ is empty
82
+ def initialize(bytes, id: nil)
83
+ @bytes = Encryption.check_key!(bytes, "Key").freeze
84
+ @id = (id.nil? ? Key.fingerprint(@bytes) : id.to_s).dup.freeze
85
+ raise ArgumentError, "Key: id must not be empty" if @id.empty?
86
+ freeze
87
+ end
88
+
89
+ # @return [String] the key material as hex
90
+ def hex = @bytes.unpack1("H*")
91
+
92
+ # @return [Integer] 128, 192 or 256
93
+ def bits = @bytes.bytesize * 8
94
+
95
+ # @param other [Object] object to compare with
96
+ # @return [Boolean] whether +other+ is a Key with the same id and bytes
97
+ def ==(other) = other.is_a?(Key) && other.id == @id && other.bytes == @bytes
98
+ alias_method :eql?, :==
99
+
100
+ # @return [Integer] hash of the id and bytes
101
+ def hash = [Key, @id, @bytes].hash
102
+
103
+ # @return [String] the id and size, without the key
104
+ def inspect = "#<#{self.class.name} id=#{@id.inspect} AES-#{bits}>"
105
+ alias_method :to_s, :inspect
106
+ end
107
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ class Reader
5
+ # Internal (used by reads with where:): the bloom filter of a column chunk. +column+ is a
6
+ # dotted path ("a.b"), an Array path or a Schema::Column. Returns a BloomFilter, or nil when
7
+ # the chunk has none (or one of an unknown kind).
8
+ #
9
+ # @param row_group_index [Integer] index of the row group
10
+ # @param column [String, Array<String, Symbol>, Symbol, Schema::Column] the leaf column
11
+ # @return [BloomFilter, nil] the filter, or nil when there is none or it is unsupported
12
+ # @raise [IndexError] when there is no such row group
13
+ # @raise [ArgumentError] when there is no such column
14
+ # @raise [FormatError] when the filter is truncated or its header cannot be decoded
15
+ # @raise [DecryptionError] when the column is encrypted and its key was not given, or the
16
+ # filter does not decrypt
17
+ def bloom_filter(row_group_index, column)
18
+ col = bloom_filter_column(column)
19
+ rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
20
+ # Raises for an encrypted column without its key, whose metadata may be encrypted too
21
+ crypto = chunk_crypto(row_group_index, col)
22
+ meta = rg.columns.fetch(col.index).meta_data
23
+ offset = meta&.bloom_filter_offset
24
+ return nil unless offset
25
+ return encrypted_bloom_filter(offset, col, crypto) if crypto
26
+ length = meta.bloom_filter_length
27
+ @io.seek(offset)
28
+ if length
29
+ buf = @io.read(length)
30
+ raise FormatError, "Truncated bloom filter" if buf.nil? || buf.bytesize < length
31
+ return BloomFilter.decode(buf, column: col)
32
+ end
33
+ # Without a length (older writers), read the header first, then the bitset it announces
34
+ head = @io.read(256) || "".b
35
+ reader = Thrift::Reader.new(head)
36
+ header = reader.read_struct(Format::BloomFilterHeader)
37
+ return nil unless BloomFilter.supported_header?(header)
38
+ @io.seek(offset + reader.pos)
39
+ bitset = @io.read(header.num_bytes)
40
+ raise FormatError, "Truncated bloom filter" if bitset.nil? || bitset.bytesize != header.num_bytes
41
+ BloomFilter.new(bitset: bitset, column: col)
42
+ rescue Thrift::Error => e
43
+ raise FormatError, "Corrupt bloom filter header for #{col.dotted_path}: #{e.message}"
44
+ end
45
+
46
+ private
47
+
48
+ # An encrypted bloom filter: the header and the bitset are two modules, one after the other
49
+ #
50
+ # @param offset [Integer] file offset of the header module
51
+ # @param col [Schema::Column] the leaf column
52
+ # @param crypto [Encryption::ModuleCrypto] decryption of the chunk's modules
53
+ # @return [BloomFilter, nil] nil when the filter is of an unsupported kind
54
+ # @raise [FormatError] when a module is truncated
55
+ # @raise [DecryptionError] when a module does not decrypt
56
+ def encrypted_bloom_filter(offset, col, crypto)
57
+ header_module = read_module_at(offset)
58
+ header = Format::BloomFilterHeader.decode(crypto.decrypt(Encryption::BLOOM_FILTER_HEADER, header_module)).first
59
+ return nil unless BloomFilter.supported_header?(header)
60
+ bitset = crypto.decrypt(Encryption::BLOOM_FILTER_BITSET, read_module_at(offset + header_module.bytesize))
61
+ raise FormatError, "Truncated bloom filter" unless bitset.bytesize == header.num_bytes
62
+ BloomFilter.new(bitset: bitset, column: col)
63
+ end
64
+
65
+ # @param offset [Integer] file offset of an encrypted module
66
+ # @return [String] the module, length prefix included
67
+ # @raise [FormatError] when it is cut off
68
+ def read_module_at(offset)
69
+ @io.seek(offset)
70
+ prefix = @io.read(4)
71
+ raise FormatError, "Truncated bloom filter" unless prefix&.bytesize == 4
72
+ len = prefix.unpack1("V")
73
+ body = @io.read(len)
74
+ raise FormatError, "Truncated bloom filter" unless body&.bytesize == len
75
+ prefix.b << body
76
+ end
77
+
78
+ # Resolves the +column+ argument of #bloom_filter to a leaf column
79
+ #
80
+ # @param column [String, Array<String, Symbol>, Symbol, Schema::Column] dotted path, path
81
+ # Array or column
82
+ # @return [Schema::Column] the column
83
+ # @raise [ArgumentError] when the schema has no such column
84
+ def bloom_filter_column(column)
85
+ return column if column.is_a?(Schema::Column)
86
+ col = schema.column(column.is_a?(Array) ? column.map(&:to_s) : column.to_s)
87
+ raise ArgumentError, "No column #{column.inspect}" unless col
88
+ col
89
+ end
90
+ end
91
+ end