packages feed

wireform-parquet-0.1.0.0: src/Parquet/Encryption.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

{- | Apache Parquet modular encryption (parquet-format/Encryption.md).

Implements the spec's two encryption algorithms:

- 'AesGcmV1' (full AES-GCM): every encrypted module - footer, column
  metadata, column index, offset index, bloom filter, and each page -
  is encrypted as @nonce(12) || ciphertext || tag(16)@. The data and the
  AAD are both authenticated, so any tampering with the layout aborts
  the read.

- 'AesGcmCtrV1' (mixed mode): metadata modules (footer, column metadata,
  page headers, indexes, bloom filter) use AES-GCM as above; data
  pages and dictionary pages use AES-CTR for speed (no authentication on
  the data bytes themselves, only on the encrypted page header).

The 'AAD' (Additional Authenticated Data) for each module is the
concatenation of:

@
AAD = aad_prefix || aad_suffix
aad_suffix = aad_file_id(variable) || module_type(1)
             || row_group_ordinal(2) || column_ordinal(2)
             || page_ordinal(2)
@

not all modules use every suffix component; this module exposes
'buildAadSuffix' / 'buildAad' helpers that match the spec's wire layout.

The actual key material is supplied by the caller through
'EncryptionKeys'; this module does not assume a particular KMS. The
opaque @key_metadata@ bytes that should be stored on the encrypted
column chunks (and on the Iceberg @data_file.key_metadata@ field) round-
trip unchanged through 'EncryptionConfig'.
-}
module Parquet.Encryption (
  -- * Algorithms
  EncryptionAlgorithm (..),
  ModuleType (..),
  moduleTypeId,

  -- * AAD construction
  buildAadSuffix,
  buildAad,

  -- * Encrypt / decrypt
  encryptModule,
  decryptModule,
  encryptModuleCtr,
  decryptModuleCtr,

  -- * Explicit nonces (must be freshly generated for every module)
  encryptGcmModuleWithNonce,
  encryptCtrModuleWithNonce,
  decryptGcmModule,
  decryptCtrModule,

  -- * Wire framing (4-byte length prefix per the spec, section 5.1)
  encryptGcmModuleFramed,
  encryptCtrModuleFramed,
  encryptGcmModuleFramedWithNonce,
  encryptCtrModuleFramedWithNonce,
  readFramedModule,

  -- * File footer authentication
  FooterCryptoMetadata (..),
  AuthenticatedFooter (..),
  readFooterCryptoMetadata,
  authenticateFooter,

  -- * Key configuration
  EncryptionKeys (..),
  EncryptionConfig (..),
  unencrypted,
) where

import Crypto.Cipher.AES qualified as Crypto
import Crypto.Cipher.Types (AEADMode (..), BlockCipher, IV)
import Crypto.Cipher.Types qualified as Cipher
import Crypto.Error (CryptoFailable (..))
import Crypto.Random.Types qualified as RNG
import Data.ByteArray qualified as BA
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.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Data.Word (Word32)
import Parquet.Footer qualified as Footer
import Parquet.Thrift.Schema
import Thrift.Decode (decodeCompactFrom)
import Thrift.Value qualified as TV
import Wireform.Builder qualified as B


{- | Unauthenticated routing information read from the file. The key metadata
is opaque: callers may resolve it with their own KMS before authenticating.
Neither this structure nor its AAD fields establish the file's identity
until 'authenticateFooter' has succeeded.
-}
data FooterCryptoMetadata = FooterCryptoMetadata
  { fcmAlgorithm :: !EncryptionAlgorithm
  , fcmFileId :: !ByteString
  , fcmStoredAadPrefix :: !(Maybe ByteString)
  , fcmSupplyAadPrefix :: !Bool
  , fcmKeyMetadata :: !(Maybe ByteString)
  }
  deriving stock (Show, Eq)


{- | The authenticated Thrift footer, retaining its crypto fields for column
decryption. Ordinary, unsigned files have no crypto metadata.
-}
data AuthenticatedFooter = AuthenticatedFooter
  { afValue :: !TV.Value
  , afCryptoMetadata :: !(Maybe FooterCryptoMetadata)
  }
  deriving stock (Show, Eq)


{- | Inspect the untrusted key metadata and AAD description. This does not
decrypt the footer or verify its signature. A returned file identifier is
supplied to the cipher, never used as evidence that the file is authentic.
-}
readFooterCryptoMetadata :: ByteString -> Either String (Maybe FooterCryptoMetadata)
readFooterCryptoMetadata bytes = do
  trailer <- checkedCryptoTrailer bytes
  if Footer.ftMagic trailer == Footer.parquetEncryptedMagic
    then do
      (value, _) <- decodeCompactFrom (Footer.ftBytes trailer) 0
      Just <$> encryptedFooterMetadata value
    else do
      (value, _) <- decodeCompactFrom (Footer.ftBytes trailer) 0
      plaintextFooterMetadata value


{- | Authenticate an encrypted footer or a signed plaintext footer. The
optional expected prefix is compared with a stored prefix, or supplied
when the writer omitted it. The optional expected file identifier supports
callers migrating from the original explicit-AAD reader; normally the
identifier comes from the file's crypto metadata.

Plaintext footer signatures authenticate the original serialized bytes,
not a re-encoding of the parsed Thrift value. No column pages are decrypted
by this function. Unsigned ordinary files remain accepted.
-}
authenticateFooter
  :: ByteString
  -> Maybe ByteString
  -> Maybe ByteString
  -> ByteString
  -> Either String AuthenticatedFooter
authenticateFooter key expectedPrefix expectedFileId bytes = do
  trailer <- checkedCryptoTrailer bytes
  let footerBytes = Footer.ftBytes trailer
  if Footer.ftMagic trailer == Footer.parquetEncryptedMagic
    then do
      (cryptoValue, encryptedStart) <- decodeCompactFrom footerBytes 0
      metadata <- encryptedFooterMetadata cryptoValue
      aad <- footerAad metadata expectedPrefix expectedFileId
      (ciphertext, afterModule) <- readFramedModule footerBytes encryptedStart
      requireCrypto (afterModule == BS.length footerBytes) "trailing bytes after encrypted footer"
      plaintext <- decryptGcmModule key aad ciphertext
      (value, end) <- decodeCompactFrom plaintext 0
      -- parquet-mr encrypts a capacity-sized serialization buffer in several
      -- official fixtures. Its unused tail consists of authenticated zeros.
      -- Check the complete GCM module first, then permit only that padding.
      requireCrypto (BS.all (== 0) (BS.drop end plaintext)) "nonzero trailing bytes in decrypted footer"
      pure (AuthenticatedFooter value (Just metadata))
    else do
      (value, end) <- decodeCompactFrom footerBytes 0
      metadata <- plaintextFooterMetadata value
      case metadata of
        Nothing -> do
          requireCrypto (end == BS.length footerBytes) "trailing bytes after unsigned footer"
          pure (AuthenticatedFooter value Nothing)
        Just crypto -> do
          aad <- footerAad crypto expectedPrefix expectedFileId
          let signature = BS.drop end footerBytes
          requireCrypto (BS.length signature == 28) "plaintext footer signature must contain 28 bytes"
          let nonce = BS.take 12 signature
          encrypted <- encryptGcmModuleWithNonce key nonce aad (BS.take end footerBytes)
          requireCrypto (BA.constEq (BS.drop 12 signature) (BS.takeEnd 16 encrypted)) "plaintext footer signature mismatch"
          pure (AuthenticatedFooter value metadata)


checkedCryptoTrailer :: ByteString -> Either String Footer.FooterTrailer
checkedCryptoTrailer bytes = do
  trailer <- Footer.readFooterTrailer bytes
  requireCrypto (BS.take 4 bytes == Footer.ftMagic trailer) "leading and trailing magic differ"
  requireCrypto (BS.length (Footer.ftBytes trailer) <= BS.length bytes - 12) "footer overlaps leading magic"
  pure trailer


encryptedFooterMetadata :: TV.Value -> Either String FooterCryptoMetadata
encryptedFooterMetadata value = do
  fields <- cryptoStruct value
  algorithm <- requiredCryptoField (FileCryptoMetaData_EncryptionAlgorithm V.empty) fields
  keyMetadata <- optionalCryptoField (FileCryptoMetaData_KeyMetadata BS.empty) fields >>= traverse cryptoBytes
  parseFooterAlgorithm keyMetadata algorithm


plaintextFooterMetadata :: TV.Value -> Either String (Maybe FooterCryptoMetadata)
plaintextFooterMetadata value = do
  fields <- cryptoStruct value
  algorithm <- optionalCryptoField (FileMetadata_EncryptionAlgorithm V.empty) fields
  keyMetadata <- optionalCryptoField (FileMetadata_FooterSigningKeyMetadata BS.empty) fields >>= traverse cryptoBytes
  case algorithm of
    Nothing -> do
      requireCrypto (keyMetadata == Nothing) "footer signing key metadata lacks an encryption algorithm"
      pure Nothing
    Just description -> Just <$> parseFooterAlgorithm keyMetadata description


parseFooterAlgorithm :: Maybe ByteString -> TV.Value -> Either String FooterCryptoMetadata
parseFooterAlgorithm keyMetadata value = do
  union <- cryptoStruct value
  requireCrypto (V.length union == 1) "encryption algorithm must have exactly one variant"
  (algorithm, description) <- case V.head union of
    EncryptionAlgorithm_AesGcmV1 fields -> Right (AesGcmV1, TV.Struct fields)
    EncryptionAlgorithm_AesGcmCtrV1 fields -> Right (AesGcmCtrV1, TV.Struct fields)
    _ -> Left "Parquet.Encryption: unsupported encryption algorithm"
  fields <- cryptoStruct description
  prefix <- optionalCryptoField (AesGcmV1_AadPrefix BS.empty) fields >>= traverse cryptoBytes
  fileId <- requiredCryptoField (AesGcmV1_AadFileUnique BS.empty) fields >>= cryptoBytes
  requireCrypto (not (BS.null fileId)) "missing unique file identifier"
  supplied <-
    optionalCryptoField (AesGcmV1_SupplyAadPrefix False) fields >>= \case
      Nothing -> Right False
      Just (TV.Bool flag) -> Right flag
      _ -> Left "Parquet.Encryption: supply_aad_prefix must be boolean"
  requireCrypto (not supplied || prefix == Nothing) "AAD prefix is both stored and required from caller"
  pure (FooterCryptoMetadata algorithm fileId prefix supplied keyMetadata)


footerAad :: FooterCryptoMetadata -> Maybe ByteString -> Maybe ByteString -> Either String ByteString
footerAad metadata expectedPrefix expectedFileId = do
  case expectedFileId of
    Nothing -> pure ()
    Just expected -> requireCrypto (expected == fcmFileId metadata) "file identifier differs from expected value"
  prefix <- case fcmStoredAadPrefix metadata of
    Just stored -> do
      case expectedPrefix of
        Nothing -> pure ()
        Just expected -> requireCrypto (expected == stored) "AAD prefix differs from expected value"
      pure stored
    Nothing -> do
      requireCrypto (not (fcmSupplyAadPrefix metadata) || expectedPrefix /= Nothing) "file requires an AAD prefix from caller"
      let supplied = fromMaybe BS.empty expectedPrefix
      requireCrypto (fcmSupplyAadPrefix metadata || BS.null supplied) "file has no AAD prefix"
      pure supplied
  pure (buildAad prefix (buildAadSuffix (fcmFileId metadata) ModuleFooter 0 0 0))


cryptoStruct :: TV.Value -> Either String (V.Vector (Int16, TV.Value))
cryptoStruct (TV.Struct fields) = do
  let unique = V.foldl' (\known (tag, _) -> Map.insert tag () known) Map.empty fields
  requireCrypto (Map.size unique == V.length fields) "duplicate crypto metadata field"
  pure fields
cryptoStruct _ = Left "Parquet.Encryption: expected crypto metadata struct"


optionalCryptoField :: (Int16, TV.Value) -> V.Vector (Int16, TV.Value) -> Either String (Maybe TV.Value)
optionalCryptoField (wanted, _) = pure . fmap snd . V.find ((== wanted) . fst)


requiredCryptoField :: (Int16, TV.Value) -> V.Vector (Int16, TV.Value) -> Either String TV.Value
requiredCryptoField tag fields = optionalCryptoField tag fields >>= maybe (Left "Parquet.Encryption: missing crypto metadata field") Right


cryptoBytes :: TV.Value -> Either String ByteString
cryptoBytes (TV.Binary bytes) = Right bytes
cryptoBytes (TV.String text) = Right (TE.encodeUtf8 text)
cryptoBytes _ = Left "Parquet.Encryption: expected binary crypto metadata"


requireCrypto :: Bool -> String -> Either String ()
requireCrypto True _ = Right ()
requireCrypto False message = Left ("Parquet.Encryption: " ++ message)


-- ============================================================
-- Spec types
-- ============================================================

data EncryptionAlgorithm = AesGcmV1 | AesGcmCtrV1
  deriving (Show, Eq)


data ModuleType
  = ModuleFooter
  | ModuleColumnMetaData
  | ModuleDataPage
  | ModuleDictionaryPage
  | ModuleDataPageHeader
  | ModuleDictionaryPageHeader
  | ModuleColumnIndex
  | ModuleOffsetIndex
  | ModuleBloomFilterHeader
  | ModuleBloomFilterBitset
  deriving (Show, Eq)


-- | The byte constant the spec assigns to each module type.
moduleTypeId :: ModuleType -> Int
moduleTypeId = \case
  ModuleFooter -> 0
  ModuleColumnMetaData -> 1
  ModuleDataPage -> 2
  ModuleDictionaryPage -> 3
  ModuleDataPageHeader -> 4
  ModuleDictionaryPageHeader -> 5
  ModuleColumnIndex -> 6
  ModuleOffsetIndex -> 7
  ModuleBloomFilterHeader -> 8
  ModuleBloomFilterBitset -> 9


-- ============================================================
-- AAD framing
-- ============================================================

buildAadSuffix
  :: ByteString
  -- ^ aad_file_unique, preserved verbatim (the format leaves its length to the writer).
  -> ModuleType
  -> Int16
  -- ^ row group ordinal.
  -> Int16
  -- ^ column ordinal.
  -> Int16
  -- ^ page ordinal.
  -> ByteString
buildAadSuffix fileId mt rg col pg
  | BS.length fileId < 0 || BS.length fileId > maxBound - extra = error "Parquet.Encryption: AAD suffix exceeds addressable size"
  | otherwise =
      B.toStrictByteStringExact (BS.length fileId + extra) $
        B.byteString fileId
          <> B.word8 (fromIntegral (moduleTypeId mt))
          <> (if mt == ModuleFooter then mempty else B.int16LE rg <> B.int16LE col)
          <> ( case mt of
                 ModuleDataPage -> B.int16LE pg
                 ModuleDataPageHeader -> B.int16LE pg
                 _ -> mempty
             )
  where
    extra = case mt of
      ModuleFooter -> 1
      ModuleDataPage -> 7
      ModuleDataPageHeader -> 7
      _ -> 5


buildAad
  :: ByteString
  -- ^ aad_prefix (caller-defined; can be empty).
  -> ByteString
  -- ^ aad_suffix produced by 'buildAadSuffix'.
  -> ByteString
buildAad = (<>)


-- ============================================================
-- AES-GCM
-- ============================================================

-- | Encrypt a module with AES-GCM. Output: @nonce(12) || ciphertext || tag(16)@.
encryptModule
  :: RNG.MonadRandom m
  => ByteString
  -- ^ AES key (16, 24, or 32 bytes).
  -> ByteString
  -- ^ AAD.
  -> ByteString
  -- ^ Plaintext.
  -> m (Either String ByteString)
encryptModule key aad plaintext = do
  nonce <- RNG.getRandomBytes 12 :: RNG.MonadRandom m => m BS.ByteString
  pure (encryptGcmWithNonce key nonce aad plaintext)


decryptModule
  :: ByteString
  -- ^ key
  -> ByteString
  -- ^ aad
  -> ByteString
  -- ^ wire bytes (nonce || ciphertext || tag)
  -> Either String ByteString
decryptModule key aad bs = case BS.length bs of
  n
    | n < 28 -> Left "Parquet.Encryption: ciphertext too short"
    | otherwise ->
        let (nonce, rest) = BS.splitAt 12 bs
            (ct, tagBs) = BS.splitAt (BS.length rest - 16) rest
        in withAes
             key
             (\(c :: Crypto.AES128) -> gcmDecrypt c nonce aad ct tagBs)
             (\(c :: Crypto.AES192) -> gcmDecrypt c nonce aad ct tagBs)
             (\(c :: Crypto.AES256) -> gcmDecrypt c nonce aad ct tagBs)


encryptGcmWithNonce :: ByteString -> ByteString -> ByteString -> ByteString -> Either String ByteString
encryptGcmWithNonce key nonce aad plaintext =
  withAes
    key
    (\(c :: Crypto.AES128) -> gcmEncrypt c nonce aad plaintext)
    (\(c :: Crypto.AES192) -> gcmEncrypt c nonce aad plaintext)
    (\(c :: Crypto.AES256) -> gcmEncrypt c nonce aad plaintext)


gcmEncrypt :: forall c. BlockCipher c => c -> ByteString -> ByteString -> ByteString -> Either String ByteString
gcmEncrypt cipher nonce aad plaintext = do
  -- AES-GCM uses a 12-byte nonce; aeadInit accepts any ByteArrayAccess
  -- so we pass the nonce bytes directly. The IV-length restriction in
  -- @makeIV@ doesn't apply to AEAD initialisation.
  aead0 <- adapt (Cipher.aeadInit AEAD_GCM cipher nonce)
  let aead1 = Cipher.aeadAppendHeader aead0 aad
      (ciphertext, aead2) = Cipher.aeadEncrypt aead1 plaintext
      tag = Cipher.aeadFinalize aead2 16
  Right $ BS.concat [nonce, ciphertext, BA.convert tag]


gcmDecrypt :: forall c. BlockCipher c => c -> ByteString -> ByteString -> ByteString -> ByteString -> Either String ByteString
gcmDecrypt cipher nonce aad ct tagBs = do
  aead0 <- adapt (Cipher.aeadInit AEAD_GCM cipher nonce)
  let aead1 = Cipher.aeadAppendHeader aead0 aad
      (plaintext, aead2) = Cipher.aeadDecrypt aead1 ct
      expected = Cipher.aeadFinalize aead2 16
  if BA.constEq expected tagBs
    then Right plaintext
    else Left "Parquet.Encryption: GCM tag mismatch"


-- ============================================================
-- AES-CTR (mixed-mode payload encryption)
-- ============================================================

{- | Encrypt a module with AES-CTR (no authentication). Wire format:
@nonce(12) || ciphertext@. Used for data\/dictionary pages in
'AesGcmCtrV1'.
-}
encryptModuleCtr
  :: RNG.MonadRandom m
  => ByteString
  -- ^ key
  -> ByteString
  -- ^ plaintext
  -> m (Either String ByteString)
encryptModuleCtr key plaintext = do
  nonce <- RNG.getRandomBytes 12 :: RNG.MonadRandom m => m BS.ByteString
  pure (ctrEncryptWithNonce key nonce plaintext)


decryptModuleCtr :: ByteString -> ByteString -> Either String ByteString
decryptModuleCtr key bs
  | BS.length bs < 12 = Left "Parquet.Encryption: CTR ciphertext too short"
  | otherwise =
      let (nonce, ct) = BS.splitAt 12 bs
      in withAes
           key
           (\(c :: Crypto.AES128) -> ctrCombine c nonce ct)
           (\(c :: Crypto.AES192) -> ctrCombine c nonce ct)
           (\(c :: Crypto.AES256) -> ctrCombine c nonce ct)


ctrEncryptWithNonce :: ByteString -> ByteString -> ByteString -> Either String ByteString
ctrEncryptWithNonce key nonce plaintext =
  fmap
    (BS.append nonce)
    ( withAes
        key
        (\(c :: Crypto.AES128) -> ctrCombine c nonce plaintext)
        (\(c :: Crypto.AES192) -> ctrCombine c nonce plaintext)
        (\(c :: Crypto.AES256) -> ctrCombine c nonce plaintext)
    )


ctrCombine :: forall c. BlockCipher c => c -> ByteString -> ByteString -> Either String ByteString
ctrCombine cipher nonce buf = do
  -- Per Parquet spec: the 16-byte AES-CTR IV is the 12-byte module nonce
  -- followed by the big-endian initial counter 1 (Encryption.md §4.2.2).
  iv <- mkIVFor cipher (BS.append nonce (BS.pack [0, 0, 0, 1]))
  Right (Cipher.ctrCombine cipher iv buf)


-- ============================================================
-- Key configuration
-- ============================================================

data EncryptionKeys = EncryptionKeys
  { ekFooterKey :: !ByteString
  , ekColumnKeys :: !(Map Text ByteString)
  }
  deriving (Show, Eq)


data EncryptionConfig = EncryptionConfig
  { encAlgorithm :: !EncryptionAlgorithm
  , encKeys :: !EncryptionKeys
  , encAadFileId :: !ByteString
  , encAadPrefix :: !ByteString
  , encKeyMetadata :: !ByteString
  }
  deriving (Show, Eq)


unencrypted :: EncryptionConfig
unencrypted =
  EncryptionConfig
    { encAlgorithm = AesGcmV1
    , encKeys = EncryptionKeys BS.empty Map.empty
    , encAadFileId = BS.empty
    , encAadPrefix = BS.empty
    , encKeyMetadata = BS.empty
    }


-- ============================================================
-- Explicit nonce primitives
-- ============================================================

{- | Encrypt one GCM module. The caller must supply a fresh, random
12-byte nonce for every invocation with a given key. Prefer
'encryptModule', which obtains that nonce from 'RNG.MonadRandom'.
-}
encryptGcmModuleWithNonce
  :: ByteString -> ByteString -> ByteString -> ByteString -> Either String ByteString
encryptGcmModuleWithNonce key nonce aad plaintext
  | BS.length nonce /= 12 = Left "Parquet.Encryption: nonce must contain 12 bytes"
  | otherwise = encryptGcmWithNonce key nonce aad plaintext


{- | Explicit-nonce CTR counterpart of 'encryptModuleCtr'. The nonce
must be fresh, random, and exactly 12 bytes. CTR does not authenticate.
-}
encryptCtrModuleWithNonce
  :: ByteString -> ByteString -> ByteString -> Either String ByteString
encryptCtrModuleWithNonce key nonce plaintext
  | BS.length nonce /= 12 = Left "Parquet.Encryption: nonce must contain 12 bytes"
  | otherwise = ctrEncryptWithNonce key nonce plaintext


{- | Decrypt an @nonce || ciphertext || tag@ blob. Same as
'decryptModule' but exported under a name that pairs nicely with
'encryptGcmModuleWithNonce'.
-}
decryptGcmModule :: ByteString -> ByteString -> ByteString -> Either String ByteString
decryptGcmModule = decryptModule


-- | Decrypt an @nonce || ciphertext@ blob.
decryptCtrModule :: ByteString -> ByteString -> Either String ByteString
decryptCtrModule = decryptModuleCtr


-- ============================================================
-- Spec-compliant wire framing (length-prefixed)
-- ============================================================
--
-- Per parquet-format Encryption.md §5.1, every encrypted module on
-- the wire has a 4-byte little-endian length prefix giving the
-- length of the @nonce || ct || (tag)@ blob. The previous helpers
-- emit the raw blob; the *Framed wrappers add the length prefix so
-- the writer stays byte-compatible with parquet-mr / arrow-rs.

-- | Encrypt a module using a fresh random nonce and the section 5.1 framing.
encryptGcmModuleFramed
  :: RNG.MonadRandom m
  => ByteString -> ByteString -> ByteString -> m (Either String ByteString)
encryptGcmModuleFramed key aad plain = case checkFramedPlainSize 28 plain of
  Left err -> pure (Left err)
  Right () -> fmap frameModule <$> encryptModule key aad plain


-- | CTR counterpart of 'encryptGcmModuleFramed'.
encryptCtrModuleFramed
  :: RNG.MonadRandom m
  => ByteString -> ByteString -> m (Either String ByteString)
encryptCtrModuleFramed key plain = case checkFramedPlainSize 12 plain of
  Left err -> pure (Left err)
  Right () -> fmap frameModule <$> encryptModuleCtr key plain


-- | Frame a GCM module encrypted with an explicitly supplied fresh nonce.
encryptGcmModuleFramedWithNonce
  :: ByteString -> ByteString -> ByteString -> ByteString -> Either String ByteString
encryptGcmModuleFramedWithNonce key nonce aad plain = do
  checkFramedPlainSize 28 plain
  frameModule <$> encryptGcmModuleWithNonce key nonce aad plain


-- | Frame a CTR module encrypted with an explicitly supplied fresh nonce.
encryptCtrModuleFramedWithNonce
  :: ByteString -> ByteString -> ByteString -> Either String ByteString
encryptCtrModuleFramedWithNonce key nonce plain = do
  checkFramedPlainSize 12 plain
  frameModule <$> encryptCtrModuleWithNonce key nonce plain


{- | Read one length-prefixed encrypted module from @bs@ starting at
@off@. Returns the raw module bytes (nonce || ct || (tag)) plus
the byte offset just past the module. Caller decrypts via
'decryptGcmModule' / 'decryptCtrModule'.
-}
readFramedModule :: ByteString -> Int -> Either String (ByteString, Int)
readFramedModule bs off
  | off < 0 || off > BS.length bs - 4 =
      Left "Parquet.Encryption: short buffer reading framed module length"
  | otherwise =
      let !len = readLE32 bs off
      in if len > BS.length bs - off - 4
           then Left "Parquet.Encryption: framed module runs past end of buffer"
           else Right (BS.take len (BS.drop (off + 4) bs), off + 4 + len)


-- | Prepend a 4-byte little-endian length to the module bytes.
frameModule :: ByteString -> ByteString
frameModule bs
  | toInteger len > min (toInteger (maxBound :: Word32)) (toInteger (maxBound :: Int) - 4) || len < 0 = error "Parquet.Encryption: encrypted module exceeds framing limit"
  | otherwise =
      B.toStrictByteStringExact (len + 4) $
        B.word32LE (fromIntegral len)
          <> B.byteString bs
  where
    !len = BS.length bs


-- Reject before random generation, cipher allocation or a payload read.
-- The prefix counts nonce/ciphertext/tag; the complete buffer also needs
-- four bytes for that prefix and must fit the host address space.
checkFramedPlainSize :: Int -> ByteString -> Either String ()
checkFramedPlainSize overhead plain =
  let size = toInteger (BS.length plain)
      limit = min (toInteger (maxBound :: Word32) - toInteger overhead) (toInteger (maxBound :: Int) - toInteger overhead - 4)
  in if size < 0 || size > limit
       then Left "Parquet.Encryption: encrypted module exceeds framing limit"
       else Right ()


-- | Read a 32-bit little-endian length from @bs@ at @off@.
readLE32 :: ByteString -> Int -> Int
readLE32 bs off =
  let !b0 = fromIntegral (BS.index bs off) :: Int
      !b1 = fromIntegral (BS.index bs (off + 1)) :: Int
      !b2 = fromIntegral (BS.index bs (off + 2)) :: Int
      !b3 = fromIntegral (BS.index bs (off + 3)) :: Int
  in b0 + b1 * 256 + b2 * 65536 + b3 * 16777216


-- ============================================================
-- Internals
-- ============================================================

{- | Dispatch on key length to one of the three concrete AES variants.
The three branches exist because @crypton@ uses different types for
AES-128 / 192 / 256 to encode key sizes statically; the call-site
continues with whichever branch was selected.
-}
withAes
  :: ByteString
  -> (Crypto.AES128 -> Either String r)
  -> (Crypto.AES192 -> Either String r)
  -> (Crypto.AES256 -> Either String r)
  -> Either String r
withAes key k128 k192 k256 = case BS.length key of
  16 -> case Cipher.cipherInit key :: CryptoFailable Crypto.AES128 of
    CryptoPassed c -> k128 c
    CryptoFailed e -> Left ("Parquet.Encryption: AES128 init: " ++ show e)
  24 -> case Cipher.cipherInit key :: CryptoFailable Crypto.AES192 of
    CryptoPassed c -> k192 c
    CryptoFailed e -> Left ("Parquet.Encryption: AES192 init: " ++ show e)
  32 -> case Cipher.cipherInit key :: CryptoFailable Crypto.AES256 of
    CryptoPassed c -> k256 c
    CryptoFailed e -> Left ("Parquet.Encryption: AES256 init: " ++ show e)
  n -> Left ("Parquet.Encryption: unsupported key length " ++ show n)


{- | Construct an IV whose phantom cipher type matches the supplied cipher.
Forces the @IV@'s polymorphic block size to the concrete one that
'aeadInit' / 'ctrCombine' expect later.
-}
mkIVFor :: forall c. BlockCipher c => c -> ByteString -> Either String (IV c)
mkIVFor _ bs = case Cipher.makeIV bs of
  Just v -> Right v
  Nothing -> Left "Parquet.Encryption: invalid IV length"


adapt :: CryptoFailable a -> Either String a
adapt (CryptoPassed a) = Right a
adapt (CryptoFailed e) = Left ("Parquet.Encryption: " ++ show e)