packages feed

age-0.0.1.0: test/Test/Crypto/Age/TestVector/Header.hs

{-# LANGUAGE LambdaCase #-}

module Test.Crypto.Age.TestVector.Header
  ( Expect (..)
  , Compressed (..)
  , Header (..)
  , headerParser
  ) where

import Control.Monad ( void )
import Crypto.Age.Identity
  ( Identity (..), ScryptIdentity (..), X25519Identity, decodeX25519Identity )
import Crypto.Age.Scrypt ( Passphrase (..), WorkFactor (..), mkWorkFactor )
import qualified Crypto.Hash as Crypto
import Data.Attoparsec.ByteString
  ( Parser, many1', notInClass, skipWhile, takeTill, takeWhile )
import Data.Attoparsec.ByteString.Char8
  ( char8, endOfLine, isEndOfLine, isHorizontalSpace )
import Data.Base16.Types ( Base16, assertBase16 )
import qualified Data.ByteArray as BA
import Data.ByteString ( ByteString )
import Data.ByteString.Base16 ( decodeBase16Untyped, isValidBase16 )
import Data.Foldable ( foldl' )
import Data.Text ( Text )
import qualified Data.Text.Encoding as TE
import Data.Word ( Word8 )
import Prelude hiding ( takeWhile )

data Expect
  = ExpectSuccess
  | ExpectNoMatch
  | ExpectHmacFailure
  | ExpectHeaderFailure
  | ExpectPayloadFailure
  | ExpectArmorFailure
  deriving stock (Show)

parseExpect :: ByteString -> Maybe Expect
parseExpect = \case
  "success" -> Just ExpectSuccess
  "no match" -> Just ExpectNoMatch
  "HMAC failure" -> Just ExpectHmacFailure
  "header failure" -> Just ExpectHeaderFailure
  "payload failure" -> Just ExpectPayloadFailure
  "armor failure" -> Just ExpectArmorFailure
  _ -> Nothing

data Compressed
  = CompressedGzip
  | CompressedZlib
  deriving stock (Show)

parseCompressed :: ByteString -> Maybe Compressed
parseCompressed = \case
  "gzip" -> Just CompressedGzip
  "zlib" -> Just CompressedZlib
  _ -> Nothing

data RawHeaderLine = RawHeaderLine
  { rhlName :: !ByteString
  , rhlValue :: !ByteString
  }

rawHeaderLineParser :: Parser RawHeaderLine
rawHeaderLineParser =
  RawHeaderLine
    <$> (takeWhile isToken <* char8 ':' <* skipWhile isHorizontalSpace)
    <*> (takeTill isEndOfLine <* endOfLine)
  where
    isToken :: Word8 -> Bool
    isToken w = w <= 127 && notInClass "\0-\31()<>@,;:\\\"/[]?={}\t\127" w

data HeaderLine
  = HeaderLineExpect !Expect
  | HeaderLineCompressed !Compressed
  | HeaderLinePayload !(Crypto.Digest Crypto.SHA256)
  | HeaderLineIdentity !X25519Identity
  | HeaderLinePassphrase !Passphrase
  | HeaderLineArmored !Bool
  | HeaderLineFileKey !(Base16 ByteString)
  | HeaderLineComment !Text

fromRawHeaderLine :: RawHeaderLine -> Maybe HeaderLine
fromRawHeaderLine r =
  case rhlName of
    "expect" -> HeaderLineExpect <$> parseExpect rhlValue
    "compressed" -> HeaderLineCompressed <$> parseCompressed rhlValue
    "payload" -> do
      decoded <- eitherToMaybe (decodeBase16Untyped rhlValue)
      HeaderLinePayload <$> (Crypto.digestFromByteString decoded)
    "identity" -> HeaderLineIdentity <$> eitherToMaybe (decodeX25519Identity $ TE.decodeUtf8 rhlValue)
    "passphrase" -> Just . HeaderLinePassphrase . Passphrase $ BA.convert rhlValue
    "armored" ->
      Just $
        case rhlValue of
          "yes" -> HeaderLineArmored True
          _ -> HeaderLineArmored False
    "file key" ->
      if isValidBase16 rhlValue
        then Just $ HeaderLineFileKey (assertBase16 rhlValue)
        else Nothing
    "comment" -> Just $ HeaderLineComment (TE.decodeUtf8 rhlValue)
    _ -> Nothing
  where
    RawHeaderLine
      { rhlName
      , rhlValue
      } = r

    eitherToMaybe :: Either a b -> Maybe b
    eitherToMaybe (Left _) = Nothing
    eitherToMaybe (Right a) = Just a

headerLineParser :: Parser HeaderLine
headerLineParser = do
  line@RawHeaderLine{rhlName, rhlValue} <- rawHeaderLineParser
  case fromRawHeaderLine line of
    Just x -> pure x
    Nothing ->
      fail $
        "failed to parse header line with name "
          <> show (TE.decodeUtf8 rhlName)
          <> " and value: "
          <> show (TE.decodeUtf8 rhlValue)

newtype HeaderLines = HeaderLines
  { unHeaderLines :: [HeaderLine] }

headerLinesParser :: Parser HeaderLines
headerLinesParser = HeaderLines <$> many1' headerLineParser

data HeaderOptions = HeaderOptions
  { hoExpect :: !(Maybe Expect)
  , hoCompressed :: !(Maybe Compressed)
  , hoPayload :: !(Maybe (Crypto.Digest Crypto.SHA256))
  , hoIdentity :: !(Maybe Identity)
  , hoArmored :: !(Maybe Bool)
  , hoFileKey :: !(Maybe (Base16 ByteString))
  , hoComment :: !(Maybe Text)
  }

emptyHeaderOptions :: HeaderOptions
emptyHeaderOptions =
  HeaderOptions
    { hoExpect = Nothing
    , hoCompressed = Nothing
    , hoPayload = Nothing
    , hoIdentity = Nothing
    , hoArmored = Nothing
    , hoFileKey = Nothing
    , hoComment = Nothing
    }

toHeaderOptions :: HeaderLines -> HeaderOptions
toHeaderOptions = foldl' accumHeaderOptions emptyHeaderOptions . unHeaderLines
  where
    -- The test vector, @scrypt_work_factor_23@, expects us to fail given an
    -- scrypt stanza with a work factor of 23.
    --
    -- So we just set our maximum to 22 in order to ensure that we return the
    -- expected error.
    workFactor22 :: WorkFactor
    workFactor22 =
      case mkWorkFactor 22 of
        Just x -> x
        Nothing -> error "toHeaderOptions: impossible: could not construct work factor of 22"

    accumHeaderOptions :: HeaderOptions -> HeaderLine -> HeaderOptions
    accumHeaderOptions acc line =
      case line of
        HeaderLineExpect x -> acc { hoExpect = Just x }
        HeaderLineCompressed x -> acc { hoCompressed = Just x }
        HeaderLinePayload x -> acc { hoPayload = Just x }
        HeaderLineIdentity x -> acc { hoIdentity = Just (IdentityX25519 x) }
        HeaderLinePassphrase x -> acc { hoIdentity = Just $ IdentityScrypt (ScryptIdentity x workFactor22) }
        HeaderLineArmored x -> acc { hoArmored = Just x }
        HeaderLineFileKey x -> acc { hoFileKey = Just x }
        HeaderLineComment x -> acc { hoComment = Just x }

data Header = Header
  { hExpect :: !Expect
  , hCompressed :: !(Maybe Compressed)
  , hPayload :: !(Maybe (Crypto.Digest Crypto.SHA256))
  , hIdentity :: !Identity
  , hArmored :: !Bool
  , hFileKey :: !(Maybe (Base16 ByteString))
  , hComment :: !(Maybe Text)
  }

toHeader :: HeaderOptions -> Maybe Header
toHeader h =
  case hoExpect of
    Just expect ->
      Just $
        Header
          { hExpect = expect
          , hCompressed = hoCompressed
          , hPayload = hoPayload
          , hIdentity =
              case hoIdentity of
                Just identity -> identity
                Nothing ->
                  -- There are a couple test vectors where neither an X25519
                  -- identity nor scrypt passphrase are specified:
                  --
                  -- - armor_empty
                  -- - empty
                  --
                  -- These are expected to fail parsing due to being empty, so
                  -- we'll just default to some invalid identity since it
                  -- doesn't matter.
                  defaultIdentity
          , hArmored =
              case hoArmored of
                Just x -> x
                _ -> False
          , hFileKey = hoFileKey
          , hComment = hoComment
          }
    Nothing -> Nothing
  where
    HeaderOptions
      { hoExpect
      , hoCompressed
      , hoPayload
      , hoIdentity
      , hoArmored
      , hoFileKey
      , hoComment
      } = h

    defaultIdentity :: Identity
    defaultIdentity =
      IdentityScrypt $
        ScryptIdentity
          { siPassphrase = Passphrase "aaaa"
          , siMaxWorkFactor = maxBound
          }

headerParser :: Parser Header
headerParser = do
  headerLines <- headerLinesParser
  void endOfLine
  case toHeader (toHeaderOptions headerLines) of
    Just x -> pure x
    Nothing -> fail "Header is missing required fields"