packages feed

wireform-parquet-0.1.0.0: src/Parquet/Write/Encrypted.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE PatternSynonyms #-}

{- | Finalize files from our single-data-page writers with modular encryption.
The private seam keeps nullable/nested encoding and compression independent
of encryption. It is not an API for rewriting arbitrary external files.
-}
module Parquet.Write.Encrypted (
  FooterMode (..),
  encryptWrittenFile,
  WriterOutput (..),
  nestedWriterOutput,
  encryptWriterOutput,
) where

import Control.Monad (unless)
import Crypto.Cipher.AES.GCM qualified as GCM
import Crypto.Error (CryptoFailable (..))
import Crypto.Random.Types qualified as RNG
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int16, Int32, Int64)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Vector qualified as V
import Data.Word (Word32)
import Parquet.Encryption qualified as E
import Parquet.Footer qualified as F
import Parquet.Page qualified as P
import Parquet.PageIndex qualified as I
import Parquet.Thrift.Schema
import Parquet.Types
import Parquet.Write (ColumnEncryption (..), FooterEncryption (..), encodePageHeader)
import Thrift.Decode (decodeCompactFrom)
import Thrift.Encode (compactEncodedSizeBounded, encodeCompactBounded)
import Thrift.Value qualified as TV
import Wireform.Builder qualified as B


data FooterMode = EncryptedFooter | SignedPlaintextFooter
  deriving (Show, Eq)


-- A package-private writer result: magic and page/auxiliary bytes, without
-- a serialized footer. The metadata is already available from the writer.
data WriterOutput = WriterOutput !FileMetadata ~ByteString


nestedWriterOutput :: FileMetadata -> Integer -> Either String () -> B.Builder -> Either String WriterOutput
nestedWriterOutput metadata size validate body = do
  require (size >= 0 && size + 4 <= toInteger (maxBound :: Int)) "complete writer body exceeds addressable size"
  validate
  pure (WriterOutput metadata (B.toStrictByteStringWith (fromInteger size + 4) (B.byteString F.parquetMagic <> body)))


data PreparedFile = PreparedFile
  { preparedMetadata :: !FileMetadata
  , preparedGroups :: !(V.Vector (V.Vector PreparedColumn))
  , preparedAlgorithm :: !E.EncryptionAlgorithm
  , preparedFooterContext :: !GCM.Context
  }


data PreparedColumn = PreparedColumn
  { originalChunk :: !ColumnChunk
  , originalMetadata :: !ColumnMetadata
  , originalHeader :: !P.PageHeader
  , originalHeaderSize :: {-# UNPACK #-} !Int
  , originalBody :: !ByteString
  , originalBloom :: !(Maybe BloomParts)
  , originalOffsets :: !(Maybe OffsetIndex)
  , originalIndex :: !(Maybe ByteString)
  , columnEncryption :: !(Maybe ColumnEncryption)
  , columnContext :: !(Maybe GCM.Context)
  }


data BloomParts = BloomParts !ByteString !ByteString


data FileAssembly = FileAssembly
  { fileCursor :: {-# UNPACK #-} !Int
  , fileParts :: ![ByteString]
  , fileGroups :: ![RowGroup]
  }


data GroupAssembly = GroupAssembly
  { groupCursor :: {-# UNPACK #-} !Int
  , groupParts :: ![ByteString]
  , groupColumns :: ![ColumnChunk]
  , groupUncompressed :: {-# UNPACK #-} !Int64
  }


data EmittedColumn = EmittedColumn
  { emittedChunk :: !ColumnChunk
  , emittedParts :: ![ByteString]
  , emittedEnd :: {-# UNPACK #-} !Int
  }


{- | All layout and key checks precede random generation. A failed request
returns no bytes; encryption never falls back to a plaintext output.
-}
encryptWrittenFile
  :: RNG.MonadRandom m
  => FooterMode
  -> FooterEncryption
  -> V.Vector (Maybe ColumnEncryption)
  -> ByteString
  -> m (Either String ByteString)
encryptWrittenFile mode footer columns plaintext =
  case F.readFooter plaintext of
    Left err -> pure (Left err)
    Right metadata -> encryptWriterOutput mode footer columns (WriterOutput metadata plaintext)


encryptWriterOutput
  :: RNG.MonadRandom m
  => FooterMode
  -> FooterEncryption
  -> V.Vector (Maybe ColumnEncryption)
  -> WriterOutput
  -> m (Either String ByteString)
encryptWriterOutput mode footer columns (WriterOutput metadata plaintext) =
  case prepare footer columns metadata plaintext of
    Left err -> pure (Left err)
    Right plan -> do
      nonces <- V.mapM (V.mapM (\column -> case columnEncryption column of Nothing -> pure BS.empty; Just _ -> RNG.getRandomBytes 96)) (preparedGroups plan)
      footerNonce <- RNG.getRandomBytes 12
      pure (assemble mode footer plan nonces footerNonce)


prepare :: FooterEncryption -> V.Vector (Maybe ColumnEncryption) -> FileMetadata -> ByteString -> Either String PreparedFile
prepare footer columns metadata plaintext = do
  validKey (feKey footer)
  require (not (BS.null (feFileId footer))) "a fresh random file identifier is required"
  require (BS.take 4 plaintext == F.parquetMagic) "expected a plaintext writer output"
  let leaves = V.filter (maybe False (const True) . seType) (fmSchema metadata)
      algorithm = fromMaybe E.AesGcmV1 (V.foldl' firstAlgorithm Nothing columns)
  require (V.null columns || V.length columns == V.length leaves) "column encryption vector must match the leaf-column count"
  V.imapM_ (checkColumn algorithm) columns
  footerContext <- newGcmContext (feKey footer)
  contexts <- V.mapM (traverse (\ce -> if ceKey ce == feKey footer then Right footerContext else newGcmContext (ceKey ce))) columns
  groups <- V.imapM (prepareGroup contexts) (fmRowGroups metadata)
  -- Eight module slots per chunk and one footer signature. Count actual keys
  -- before encryption, conservatively including optional/unused slots. Bound
  -- local invocations without constructing per-page counters in the hot loop.
  let counts = V.foldl' (V.foldl' countColumn) (Map.singleton (feKey footer) (1 :: Integer)) groups
  require (all (<= 4294967296) (Map.elems counts)) "AES key invocation limit exceeded"
  pure (PreparedFile metadata groups algorithm footerContext)
  where
    firstAlgorithm found entry = case found of
      Just _ -> found
      Nothing -> ceAlgorithm <$> entry
    countColumn counts column = case columnEncryption column of
      Nothing -> counts
      Just ce -> Map.insertWith (+) (ceKey ce) 8 counts
    checkColumn algorithm col entry = case entry of
      Nothing -> Right ()
      Just ce -> do
        require (col <= 32767) "encrypted column ordinal exceeds signed 16-bit range"
        validKey (ceKey ce)
        require (ceFileId ce == feFileId footer && ceAadPrefix ce == feAadPrefix footer) "column and footer AAD contexts must match"
        require (ceAlgorithm ce == algorithm) "encrypted columns must share one file-level algorithm"
    prepareGroup contexts rg group = do
      require (rg <= 32767) "row-group ordinal exceeds signed 16-bit range"
      V.imapM (prepareColumn contexts rg) (rgColumns group)
    prepareColumn contexts _ col chunk = do
      require (ccFilePath chunk == Nothing) "external column files are not writer outputs"
      cm <- maybe (failure "missing column metadata") Right (ccMetadata chunk)
      require (cmDictionaryPageOffset cm == Nothing) "expected one non-dictionary data page"
      raw <- slice plaintext (cmDataPageOffset cm) (cmTotalCompressedSize cm)
      (header, end) <- P.readPageHeaderAt raw 0
      size <- maybe (failure "missing compressed page size") (Right . fromIntegral) (P.phCompressedPageSize header)
      require (size >= 0 && end <= BS.length raw && size == BS.length raw - end) "expected exactly one complete data page"
      require (P.pageTypeIsDataPageV1 (P.phType header) || P.pageTypeIsDataPageV2 (P.phType header)) "expected a data page"
      bloom <- traverse (prepareBloom cm) (cmBloomFilterOffset cm)
      offsets <- auxiliary (ccOffsetIndexOffset chunk) (ccOffsetIndexLength chunk) >>= traverse I.decodeOffsetIndex
      case offsets of
        Nothing -> Right ()
        Just oi -> require (V.length (oiPageLocations oi) == 1) "offset index must describe the single data page"
      index <- auxiliary (ccColumnIndexOffset chunk) (ccColumnIndexLength chunk)
      pure (PreparedColumn chunk cm header end (BS.drop end raw) bloom offsets index (fromMaybe Nothing (columns V.!? col)) (fromMaybe Nothing (contexts V.!? col)))
    auxiliary Nothing Nothing = Right Nothing
    auxiliary (Just offset) (Just len) = Just <$> slice plaintext offset (fromIntegral len)
    auxiliary _ _ = failure "incomplete auxiliary location"
    prepareBloom cm offset = do
      raw <- slice plaintext offset (maybe (fromIntegral (BS.length plaintext) - offset) fromIntegral (cmBloomFilterLength cm))
      (value, end) <- decodeCompactFrom raw 0
      size <- case value of
        TV.Struct fields -> case V.find ((== fst (BloomFilterHeader_NumBytes 0)) . fst) fields of
          Just (BloomFilterHeader_NumBytes n) -> Right (fromIntegral n)
          _ -> failure "missing bloom bitset length"
        _ -> failure "invalid bloom header"
      require (size > 0 && size `rem` 32 == 0 && end <= BS.length raw && size <= BS.length raw - end) "invalid bloom bitset bounds"
      case cmBloomFilterLength cm of
        Just _ -> require (end + size == BS.length raw) "bloom size differs from metadata"
        Nothing -> Right ()
      pure (BloomParts (BS.take end raw) (BS.take size (BS.drop end raw)))


assemble :: FooterMode -> FooterEncryption -> PreparedFile -> V.Vector (V.Vector ByteString) -> ByteString -> Either String ByteString
assemble mode footer plan nonces footerNonce = do
  output <- V.ifoldM emitGroup (FileAssembly 4 [] []) (preparedGroups plan)
  let metadata = (preparedMetadata plan) {fmRowGroups = V.fromList (reverse (fileGroups output))}
  raw <- cryptoMetadata mode footer (preparedGroups plan) nonces metadata
  let algorithm = algorithmFields (preparedAlgorithm plan) footer
      aad = E.buildAad (feAadPrefix footer) (E.buildAadSuffix (feFileId footer) E.ModuleFooter 0 0 0)
      magic = case mode of EncryptedFooter -> F.parquetEncryptedMagic; SignedPlaintextFooter -> F.parquetMagic
  let footerLimit = fromInteger (min (toInteger (maxBound :: Word32)) (toInteger (maxBound :: Int) - toInteger (fileCursor output) - 8))
  require (footerLimit >= 32) "output has no room for an authenticated footer"
  finalFooter <- case mode of
    EncryptedFooter -> do
      let cryptoValue = TV.Struct (V.fromList [FileCryptoMetaData_EncryptionAlgorithm algorithm, FileCryptoMetaData_KeyMetadata (feKeyMetadata footer)])
      cryptoSize <- compactEncodedSizeBounded (footerLimit - 32) cryptoValue
      -- Validate the remaining combined budget while encoding the plain
      -- metadata once. Delay even the crypto-header allocation until that
      -- succeeds, since a legal header alone can exhaust the footer budget.
      plain <- encodeCompactBounded (footerLimit - 32 - cryptoSize) raw
      crypto <- encodeCompactBounded cryptoSize cryptoValue
      cipher <- encryptGcmFramed (preparedFooterContext plan) footerNonce aad plain
      F.writeRawFooterEither magic (crypto <> cipher)
    SignedPlaintextFooter -> do
      fields <- struct raw
      plain <- encodeCompactBounded (footerLimit - 28) (TV.Struct (fields <> V.fromList [FileMetadata_EncryptionAlgorithm algorithm, FileMetadata_FooterSigningKeyMetadata (feKeyMetadata footer)]))
      cipher <- encryptGcmTagged (preparedFooterContext plan) footerNonce aad plain
      F.writeRawFooterEither magic (plain <> footerNonce <> BS.takeEnd 16 cipher)
  _ <- checkedAdd (fileCursor output) (BS.length finalFooter)
  pure (BS.concat (magic : reverse (fileParts output) ++ [finalFooter]))
  where
    emitGroup acc rg columns = do
      out <- V.ifoldM (emitColumn rg) (GroupAssembly (fileCursor acc) (fileParts acc) [] 0) columns
      let original = fmRowGroups (preparedMetadata plan) V.! rg
          group = original {rgColumns = V.fromList (reverse (groupColumns out)), rgTotalByteSize = groupUncompressed out}
      pure (FileAssembly (groupCursor out) (groupParts out) (group : fileGroups acc))
    emitColumn rg acc col column = do
      out <- emitOne rg col (nonces V.! rg V.! col) (groupCursor acc) column
      cm <- maybe (failure "lost emitted metadata") Right (ccMetadata (emittedChunk out))
      let total = toInteger (groupUncompressed acc) + toInteger (cmTotalUncompressedSize cm)
      require (total <= toInteger (maxBound :: Int64)) "row-group uncompressed size exceeds signed 64-bit limit"
      pure (GroupAssembly (emittedEnd out) (reverse (emittedParts out) ++ groupParts acc) (emittedChunk out : groupColumns acc) (fromInteger total))


emitOne :: Int -> Int -> ByteString -> Int -> PreparedColumn -> Either String EmittedColumn
emitOne rg col nonces start column = do
  let ce = columnEncryption column
      cm = originalMetadata column
      original = originalChunk column
      body = originalBody column
      header = originalHeader column
      gcm slot kind = encryptCached ce (columnContext column) nonces slot kind rg col
  let overhead = case ce of
        Nothing -> 0
        Just settings -> case ceAlgorithm settings of E.AesGcmV1 -> 32; E.AesGcmCtrV1 -> 16
  expectedBodySize <- checkedAdd (BS.length body) overhead >>= size32
  encryptedBody <- case ce of
    Nothing -> Right body
    Just settings -> case ceAlgorithm settings of
      E.AesGcmV1 -> gcm 1 E.ModuleDataPage body
      E.AesGcmCtrV1 -> E.encryptCtrModuleFramedWithNonce (ceKey settings) (nonceAt nonces 1) body
  require (BS.length encryptedBody == fromIntegral expectedBodySize) "encrypted body size differs from planned framing"
  let plainHeader = encodePageHeader header {P.phCompressedPageSize = Just expectedBodySize}
  encryptedHeader <- gcm 0 E.ModuleDataPageHeader plainHeader
  pageLength <- checkedAdd (BS.length encryptedHeader) (BS.length encryptedBody)
  pageSize <- case originalOffsets column of
    Nothing -> Right 0
    Just _ -> size32 pageLength
  afterPage <- checkedAdd start pageLength
  bloom <- traverse (emitBloom gcm) (originalBloom column)
  let bloomBytes = fromMaybe BS.empty bloom
  afterBloom <- checkedAdd afterPage (BS.length bloomBytes)
  offsets <- traverse (emitOffsets gcm pageSize) (originalOffsets column)
  let offsetBytes = fromMaybe BS.empty offsets
  offsetSize <- size32 (BS.length offsetBytes)
  afterOffsets <- checkedAdd afterBloom (BS.length offsetBytes)
  index <- traverse (gcm 5 E.ModuleColumnIndex) (originalIndex column)
  let indexBytes = fromMaybe BS.empty index
  indexSize <- size32 (BS.length indexBytes)
  end <- checkedAdd afterOffsets (BS.length indexBytes)
  let sizeDelta = BS.length encryptedHeader - originalHeaderSize column
      uncompressed = toInteger (cmTotalUncompressedSize cm) + toInteger sizeDelta
  require (uncompressed >= 0 && uncompressed <= toInteger (maxBound :: Int64)) "column uncompressed size exceeds signed 64-bit limit"
  let updated =
        cm
          { cmDataPageOffset = fromIntegral start
          , cmTotalCompressedSize = fromIntegral pageLength
          , cmTotalUncompressedSize = fromInteger uncompressed
          , cmBloomFilterOffset = fromIntegral afterPage <$ bloom
          , -- Optional i32 length may be omitted for a legal large bitset whose
            -- two encrypted modules no longer fit in the signed length field.
            cmBloomFilterLength = if BS.length bloomBytes <= fromIntegral (maxBound :: Int32) then fromIntegral (BS.length bloomBytes) <$ bloom else Nothing
          }
      chunk =
        original
          { ccFileOffset = fromIntegral start
          , ccMetadata = Just updated
          , ccOffsetIndexOffset = fromIntegral afterBloom <$ offsets
          , ccOffsetIndexLength = offsetSize <$ offsets
          , ccColumnIndexOffset = fromIntegral afterOffsets <$ index
          , ccColumnIndexLength = indexSize <$ index
          }
  pure (EmittedColumn chunk [encryptedHeader, encryptedBody, bloomBytes, offsetBytes, indexBytes] end)
  where
    emitBloom gcm (BloomParts header bits) = (<>) <$> gcm 2 E.ModuleBloomFilterHeader header <*> gcm 3 E.ModuleBloomFilterBitset bits
    emitOffsets gcm size oi =
      gcm 4 E.ModuleOffsetIndex (I.encodeOffsetIndex oi {oiPageLocations = V.map (\p -> p {plOffset = fromIntegral start, plCompressedPageSize = size}) (oiPageLocations oi)})


cryptoMetadata :: FooterMode -> FooterEncryption -> V.Vector (V.Vector PreparedColumn) -> V.Vector (V.Vector ByteString) -> FileMetadata -> Either String TV.Value
cryptoMetadata mode footer plans nonces metadata = do
  fields <- struct (F.fileMetadataToThrift metadata)
  TV.Struct <$> V.mapM rewriteFile fields
  where
    rewriteFile (FileMetadata_RowGroups groups) = FileMetadata_RowGroups <$> V.imapM rewriteGroup groups
    rewriteFile field = Right field
    rewriteGroup rg value = do
      fields <- struct value
      updated <- V.mapM (rewriteGroupField rg) fields
      pure (TV.Struct (V.snoc updated (RowGroup_Ordinal (fromIntegral rg))))
    rewriteGroupField rg (RowGroup_Columns columns) = RowGroup_Columns <$> V.imapM (rewriteColumn rg) columns
    rewriteGroupField _ field = Right field
    rewriteColumn rg col value = case columnEncryption (plans V.! rg V.! col) of
      Nothing -> Right value
      Just ce -> do
        fields <- struct value
        cm <- maybe (failure "missing emitted column metadata") Right (ccMetadata (rgColumns (fmRowGroups metadata V.! rg) V.! col))
        cmFields <- maybe (failure "missing serialized column metadata") (struct . snd) (V.find ((== fst (ColumnChunk_MetaData V.empty)) . fst) fields)
        let footerKey = ceKey ce == feKey footer
            crypto =
              if footerKey
                then V.singleton (ColumnCryptoMetaData_EncryptionWithFooterKey V.empty)
                else V.singleton (ColumnCryptoMetaData_EncryptionWithColumnKey (V.fromList [EncryptionWithColumnKey_PathInSchema (V.map TV.String (cmPathInSchema cm)), EncryptionWithColumnKey_KeyMetadata (ceKeyMetadata ce)]))
            base = V.snoc fields (ColumnChunk_CryptoMetadata crypto)
        if mode == EncryptedFooter && footerKey
          then Right (TV.Struct base)
          else do
            plain <- encode (TV.Struct cmFields)
            encrypted <- encryptCached (Just ce) (columnContext (plans V.! rg V.! col)) (nonces V.! rg V.! col) 6 E.ModuleColumnMetaData rg col plain
            let strip (ColumnChunk_MetaData _) = mode == EncryptedFooter
                strip _ = False
                sanitize (ColumnChunk_MetaData entries) = ColumnChunk_MetaData (V.filter ((/= fst (ColumnMetaData_Statistics V.empty)) . fst) entries)
                sanitize entry = entry
            pure (TV.Struct (V.snoc (V.map sanitize (V.filter (not . strip) base)) (ColumnChunk_EncryptedColumnMetadata encrypted)))


algorithmFields :: E.EncryptionAlgorithm -> FooterEncryption -> V.Vector (Int16, TV.Value)
algorithmFields algorithm footer = V.singleton (variant fields)
  where
    variant = case algorithm of E.AesGcmV1 -> EncryptionAlgorithm_AesGcmV1; E.AesGcmCtrV1 -> EncryptionAlgorithm_AesGcmCtrV1
    fields = V.fromList ([AesGcmV1_AadFileUnique (feFileId footer)] ++ if BS.null (feAadPrefix footer) then [] else [AesGcmV1_AadPrefix (feAadPrefix footer)])


encryptCached :: Maybe ColumnEncryption -> Maybe GCM.Context -> ByteString -> Int -> E.ModuleType -> Int -> Int -> ByteString -> Either String ByteString
encryptCached Nothing _ _ _ _ _ _ bytes = Right bytes
encryptCached (Just ce) (Just context) nonces slot kind rg col bytes =
  encryptGcmFramed
    context
    (nonceAt nonces slot)
    (E.buildAad (ceAadPrefix ce) (E.buildAadSuffix (ceFileId ce) kind (fromIntegral rg) (fromIntegral col) 0))
    bytes
encryptCached _ _ _ _ _ _ _ _ = failure "missing prepared GCM context"


newGcmContext :: ByteString -> Either String GCM.Context
newGcmContext key = case GCM.newContext key of
  CryptoPassed context -> Right context
  CryptoFailed err -> failure ("GCM key initialization: " ++ show err)


-- Keep all C-width/address checks ahead of the one-shot call. The framing
-- length includes nonce and tag; each module still uses its own fresh nonce.
encryptGcmTagged :: GCM.Context -> ByteString -> ByteString -> ByteString -> Either String ByteString
encryptGcmTagged context nonce aad plain = do
  require (BS.length nonce == 12) "nonce must contain 12 bytes"
  require (toInteger (BS.length aad) <= toInteger (maxBound :: Word32) && toInteger (BS.length plain) <= toInteger (maxBound :: Word32)) "GCM input exceeds unsigned 32-bit C limit"
  _ <- checkedAdd (BS.length plain) 16
  pure (GCM.encrypt context nonce aad plain 16)


encryptGcmFramed :: GCM.Context -> ByteString -> ByteString -> ByteString -> Either String ByteString
encryptGcmFramed context nonce aad plain = do
  framedSize <- checkedAdd (BS.length plain) 28
  require (toInteger framedSize <= toInteger (maxBound :: Word32)) "encrypted module exceeds unsigned 32-bit length"
  total <- checkedAdd framedSize 4
  cipher <- encryptGcmTagged context nonce aad plain
  require (BS.length cipher == framedSize - 12) "GCM output differs from planned tag length"
  pure (B.toStrictByteStringExact total (B.word32LE (fromIntegral framedSize) <> B.byteString nonce <> B.byteString cipher))


nonceAt :: ByteString -> Int -> ByteString
nonceAt bytes slot = BS.take 12 (BS.drop (slot * 12) bytes)


validKey :: ByteString -> Either String ()
validKey key = require (BS.length key `elem` [16, 24, 32]) "AES key must contain 16, 24, or 32 bytes"


slice :: ByteString -> Int64 -> Int64 -> Either String ByteString
slice bytes offset size = do
  require (offset >= 4 && size >= 0 && offset <= fromIntegral (BS.length bytes) && size <= fromIntegral (BS.length bytes) - offset) "writer module lies outside input"
  pure (BS.take (fromIntegral size) (BS.drop (fromIntegral offset) bytes))


checkedAdd :: Int -> Int -> Either String Int
checkedAdd a b = do
  require (a >= 0 && b >= 0 && b <= maxBound - a) "output exceeds addressable byte size"
  pure (a + b)


size32 :: Int -> Either String Int32
size32 size = do
  require (size >= 0 && toInteger size <= toInteger (maxBound :: Int32)) "page or index size exceeds signed 32-bit limit"
  pure (fromIntegral size)


encode :: TV.Value -> Either String ByteString
encode = encodeCompactBounded (fromInteger (min (toInteger (maxBound :: Word32)) (toInteger (maxBound :: Int) - 8)))


struct :: TV.Value -> Either String (V.Vector (Int16, TV.Value))
struct (TV.Struct fields) = Right fields
struct _ = failure "expected metadata struct"


require :: Bool -> String -> Either String ()
require valid message = unless valid (failure message)


failure :: String -> Either String a
failure message = Left ("Parquet.Write.Encrypted: " ++ message)