wireform-parquet-0.1.0.0: src/Parquet/Decryption.hs
{-# LANGUAGE PatternSynonyms #-}
{- | Key routing and authenticated metadata for Parquet Modular Encryption.
Key metadata is opaque; applications can resolve it through their own KMS
and supply the resulting keys. This module performs no network access.
-}
module Parquet.Decryption (
DecryptionConfig (..),
defaultDecryptionConfig,
DecryptedMetadata (..),
DecryptionState,
ColumnCrypto,
ColumnKeyMetadata (..),
columnKeyMetadata,
decryptFileMetadata,
columnCrypto,
cryptoColumnMetadata,
decryptColumnModule,
decryptColumnPageBody,
decryptColumnPageHeader,
) where
import Control.Monad (unless)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int16)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Parquet.Encryption qualified as E
import Parquet.Footer qualified as F
import Parquet.Levels qualified as Levels
import Parquet.Thrift.Schema
import Parquet.Types
import Thrift.Decode (decodeCompactFrom)
import Thrift.Value qualified as TV
data DecryptionConfig = DecryptionConfig
{ dcFooterKey :: !(Maybe ByteString)
-- ^ Explicit footer encryption/signing key, overriding key metadata.
, dcColumnKeys :: !(Map (V.Vector Text) ByteString)
-- ^ Explicit keys by complete schema path, overriding key metadata.
, dcKeyMetadataKeys :: !(Map ByteString ByteString)
-- ^ Keys resolved by the application from opaque key metadata.
, dcAadPrefix :: !(Maybe ByteString)
-- ^ Expected stored prefix, or the prefix omitted by the writer.
, dcExpectedFileId :: !(Maybe ByteString)
-- ^ Optional identity check for callers using the original explicit-AAD API.
}
deriving stock (Eq)
instance Show DecryptionConfig where
show config =
"DecryptionConfig {footerKey = "
++ maybe "absent" (const "<redacted>") (dcFooterKey config)
++ ", columnKeys = "
++ show (Map.size (dcColumnKeys config))
++ ", metadataKeys = "
++ show (Map.size (dcKeyMetadataKeys config))
++ "}"
defaultDecryptionConfig :: DecryptionConfig
defaultDecryptionConfig = DecryptionConfig Nothing Map.empty Map.empty Nothing Nothing
data ColumnOrdinal = ColumnOrdinal
{ ordinalGroup :: {-# UNPACK #-} !Int
, ordinalColumn :: {-# UNPACK #-} !Int
}
deriving stock (Eq, Ord)
newtype DecryptionState = DecryptionState (Map ColumnOrdinal ColumnCrypto)
deriving stock (Eq)
instance Show DecryptionState where
show (DecryptionState columns) = "DecryptionState {encryptedColumns = " ++ show (Map.size columns) ++ "}"
data ColumnCrypto = ColumnCrypto
{ cryptoKey :: !(Either String ByteString)
, cryptoAlgorithm :: !E.EncryptionAlgorithm
, cryptoFileId :: !ByteString
, cryptoPrefix :: !ByteString
, cryptoRowGroup :: {-# UNPACK #-} !Int16
, cryptoOrdinal :: {-# UNPACK #-} !Int16
, cryptoColumnMetadata :: !(Either String ColumnMetadata)
, cryptoKeyPath :: !(Maybe (V.Vector Text))
, cryptoKeyMetadata :: !(Maybe ByteString)
}
deriving stock (Eq)
{- | Authenticated routing information, without the resolved encryption key.
Inspect these entries after opening a file with its footer key, resolve
their opaque metadata through an application KMS, then supply the keys in
a configuration for the file. Entries are per row group/physical column.
-}
data ColumnKeyMetadata = ColumnKeyMetadata
{ ckmRowGroup :: {-# UNPACK #-} !Int
, ckmColumn :: {-# UNPACK #-} !Int
, ckmPath :: !(V.Vector Text)
, ckmKeyMetadata :: !(Maybe ByteString)
}
deriving stock (Show, Eq)
columnKeyMetadata :: DecryptionState -> V.Vector ColumnKeyMetadata
columnKeyMetadata (DecryptionState columns) = V.fromList (Map.foldrWithKey collect [] columns)
where
collect ordinal context entries = case cryptoKeyPath context of
Nothing -> entries
Just path -> ColumnKeyMetadata (ordinalGroup ordinal) (ordinalColumn ordinal) path (cryptoKeyMetadata context) : entries
data DecryptedMetadata = DecryptedMetadata
{ decryptedMetadata :: !FileMetadata
, decryptionState :: !DecryptionState
}
deriving stock (Show, Eq)
columnCrypto :: DecryptionState -> Int -> Int -> Maybe ColumnCrypto
columnCrypto (DecryptionState columns) group column = Map.lookup (ColumnOrdinal group column) columns
{- | Authenticate the footer and resolve available column metadata. An absent
or incorrect key for an unrequested column does not prevent projection of
other columns. Its metadata remains absent, so statistics cannot prune on
unreadable data; requesting that column reports its key/authentication error.
-}
decryptFileMetadata :: DecryptionConfig -> ByteString -> Either String DecryptedMetadata
decryptFileMetadata config bytes = do
routing <- E.readFooterCryptoMetadata bytes
key <- case routing of
Nothing -> Right BS.empty
Just info -> resolveKey (dcFooterKey config) (E.fcmKeyMetadata info)
authenticated <- E.authenticateFooter key (dcAadPrefix config) (dcExpectedFileId config) bytes
metadata <- F.thriftToFileMetadata (E.afValue authenticated)
case E.afCryptoMetadata authenticated of
Nothing -> Right (DecryptedMetadata metadata (DecryptionState Map.empty))
Just info -> do
fields <- struct (E.afValue authenticated)
rawGroups <- required (FileMetadata_RowGroups V.empty) fields >>= list
unless (V.length rawGroups == V.length (fmRowGroups metadata)) (Left "Parquet.Decryption: row-group count mismatch")
let prefix = maybe (maybe BS.empty id (E.fcmStoredAadPrefix info)) id (dcAadPrefix config)
groups <- V.imapM (prepareGroup metadata info prefix key) rawGroups
let updated = V.map groupMetadata groups
contexts = V.foldl' (\allContexts group -> Map.union allContexts (groupContexts group)) Map.empty groups
sanitized = case fmColumnOrders metadata of
Nothing -> updated
Just orders -> V.map (F.sanitizeColumnOrders (fmSchema metadata) orders) updated
Right (DecryptedMetadata metadata {fmRowGroups = sanitized} (DecryptionState contexts))
where
resolveKey explicit opaque = case explicit of
Just key -> Right key
Nothing -> maybe (Left "Parquet.Decryption: no key for encrypted metadata") Right (opaque >>= (`Map.lookup` dcKeyMetadataKeys config))
prepareGroup metadataForGroup info prefix footerKey groupIndex raw = do
fields <- struct raw
rawColumns <- required (RowGroup_Columns V.empty) fields >>= list
let group = fmRowGroups metadataForGroup V.! groupIndex
unless (V.length rawColumns == V.length (rgColumns group)) (Left "Parquet.Decryption: column count mismatch")
ordinal <-
optional (RowGroup_Ordinal 0) fields >>= \case
Nothing -> Right groupIndex
Just (TV.I16 value) -> Right (fromIntegral value)
_ -> Left "Parquet.Decryption: invalid row-group ordinal"
columns <- V.imapM (prepareColumn metadataForGroup info prefix footerKey groupIndex ordinal) rawColumns
Right (PreparedGroup group {rgColumns = V.map preparedColumn columns} (V.foldl' (\m col -> maybe m (\context -> Map.insert (preparedOrdinal col) context m) (preparedCrypto col)) Map.empty columns))
prepareColumn metadata info prefix footerKey groupIndex groupOrdinal columnIndex raw = do
fields <- struct raw
description <- optional (ColumnChunk_CryptoMetadata V.empty) fields
-- The ordinary footer parser conservatively removes index locations
-- when column metadata is unavailable. Start with the original chunk,
-- decrypt its metadata, then apply that ordering policy to the result.
column <- F.thriftToColumnChunk raw
let ordinal = ColumnOrdinal groupIndex columnIndex
case description of
Nothing -> do
encrypted <- optional (ColumnChunk_EncryptedColumnMetadata BS.empty) fields
unless (encrypted == Nothing) (Left "Parquet.Decryption: encrypted metadata lacks crypto description")
Right (PreparedColumn column ordinal Nothing)
Just descriptionValue -> do
variants <- struct descriptionValue
unless (V.length variants == 1) (Left "Parquet.Decryption: column crypto union needs one variant")
colOrdinal <- checkedOrdinal columnIndex
rowOrdinal <- checkedOrdinal groupOrdinal
keyChoice <- case V.head variants of
ColumnCryptoMetaData_EncryptionWithFooterKey _ -> Right (KeyChoice (Right footerKey) Nothing Nothing)
ColumnCryptoMetaData_EncryptionWithColumnKey entries -> do
keyFields <- struct (TV.Struct entries)
path <- required (EncryptionWithColumnKey_PathInSchema V.empty) keyFields >>= list >>= V.mapM text
unless (not (V.null path)) (Left "Parquet.Decryption: empty encrypted column path")
_ <- Levels.maxLevelsForColumnPath (fmSchema metadata) path
opaque <- optional (EncryptionWithColumnKey_KeyMetadata BS.empty) keyFields >>= traverse binary
Right (KeyChoice (resolveKey (Map.lookup path (dcColumnKeys config)) opaque) (Just path) opaque)
_ -> Left "Parquet.Decryption: unsupported column crypto variant"
encryptedMetadata <- optional (ColumnChunk_EncryptedColumnMetadata BS.empty) fields >>= traverse binary
let baseContext = ColumnCrypto (chosenKey keyChoice) (E.fcmAlgorithm info) (E.fcmFileId info) prefix rowOrdinal colOrdinal (Left "Parquet.Decryption: column metadata has not been resolved") (chosenPath keyChoice) (chosenMetadata keyChoice)
context = baseContext {cryptoColumnMetadata = resolved}
resolved = do
_ <- chosenKey keyChoice
decoded <- case encryptedMetadata of
Nothing
| chosenPath keyChoice /= Nothing || BS.take 4 bytes == F.parquetMagic -> Left "Parquet.Decryption: missing encrypted column metadata"
| otherwise -> maybe (Left "Parquet.Decryption: missing column metadata") Right (ccMetadata column)
Just ciphertext -> do
plaintext <- decryptColumnModule baseContext E.ModuleColumnMetaData ciphertext
(value, end) <- decodeCompactFrom plaintext 0
unless (BS.all (== 0) (BS.drop end plaintext)) (Left "Parquet.Decryption: trailing encrypted column metadata")
F.thriftToColumnMetadata value
case chosenPath keyChoice of
Nothing -> Right ()
Just path -> unless (path == cmPathInSchema decoded) (Left "Parquet.Decryption: encrypted column path mismatch")
Right decoded
Right (PreparedColumn column {ccMetadata = either (const Nothing) Just resolved} ordinal (Just context))
data KeyChoice = KeyChoice
{ chosenKey :: !(Either String ByteString)
, chosenPath :: !(Maybe (V.Vector Text))
, chosenMetadata :: !(Maybe ByteString)
}
data PreparedGroup = PreparedGroup
{ groupMetadata :: !RowGroup
, groupContexts :: !(Map ColumnOrdinal ColumnCrypto)
}
data PreparedColumn = PreparedColumn
{ preparedColumn :: !ColumnChunk
, preparedOrdinal :: !ColumnOrdinal
, preparedCrypto :: !(Maybe ColumnCrypto)
}
checkedOrdinal :: Int -> Either String Int16
checkedOrdinal value
| value < 0 || value > 32767 = Left "Parquet.Decryption: encryption ordinal exceeds signed 16-bit range"
| otherwise = Right (fromIntegral value)
decryptColumnModule :: ColumnCrypto -> E.ModuleType -> ByteString -> Either String ByteString
decryptColumnModule context kind bytes = do
key <- cryptoKey context
(ciphertext, end) <- E.readFramedModule bytes 0
unless (end == BS.length bytes) (Left "Parquet.Decryption: trailing encrypted module bytes")
E.decryptGcmModule key (columnAad context kind 0) ciphertext
decryptColumnPageBody :: ColumnCrypto -> E.ModuleType -> Int -> ByteString -> Either String ByteString
decryptColumnPageBody context kind page bytes = do
ordinal <- checkedOrdinal page
key <- cryptoKey context
case cryptoAlgorithm context of
E.AesGcmV1 -> E.decryptGcmModule key (columnAad context kind ordinal) bytes
E.AesGcmCtrV1 -> E.decryptCtrModule key bytes
decryptColumnPageHeader :: ColumnCrypto -> E.ModuleType -> Int -> ByteString -> Either String ByteString
decryptColumnPageHeader context kind page bytes = do
ordinal <- checkedOrdinal page
key <- cryptoKey context
E.decryptGcmModule key (columnAad context kind ordinal) bytes
columnAad :: ColumnCrypto -> E.ModuleType -> Int16 -> ByteString
columnAad context kind page = E.buildAad (cryptoPrefix context) (E.buildAadSuffix (cryptoFileId context) kind (cryptoRowGroup context) (cryptoOrdinal context) page)
struct :: TV.Value -> Either String (V.Vector (Int16, TV.Value))
struct (TV.Struct fields) = do
unless (Map.size (V.foldl' (\seen (tag, _) -> Map.insert tag () seen) Map.empty fields) == V.length fields) (Left "Parquet.Decryption: duplicate metadata field")
Right fields
struct _ = Left "Parquet.Decryption: expected metadata struct"
list :: TV.Value -> Either String (V.Vector TV.Value)
list (TV.List _ values) = Right values
list _ = Left "Parquet.Decryption: expected metadata list"
optional :: (Int16, TV.Value) -> V.Vector (Int16, TV.Value) -> Either String (Maybe TV.Value)
optional (tag, _) = Right . fmap snd . V.find ((== tag) . fst)
required :: (Int16, TV.Value) -> V.Vector (Int16, TV.Value) -> Either String TV.Value
required tag fields = optional tag fields >>= maybe (Left "Parquet.Decryption: missing metadata field") Right
binary :: TV.Value -> Either String ByteString
binary (TV.Binary bytes) = Right bytes
binary (TV.String value) = Right (TE.encodeUtf8 value)
binary _ = Left "Parquet.Decryption: expected binary metadata"
text :: TV.Value -> Either String Text
text (TV.String value) = Right value
text (TV.Binary bytes) = either (const (Left "Parquet.Decryption: invalid UTF-8 column path")) Right (TE.decodeUtf8' bytes)
text _ = Left "Parquet.Decryption: expected text column path"