packages feed

age-0.0.1.0: src/Crypto/Age/Header.hs

-- | age file header.
module Crypto.Age.Header
  ( -- * Header
    Header (..)
  , headerBuilder
  , headerParser
    -- ** Stanza
  , Stanza (..)
  , stanzaBuilder
  , stanzaParser
    -- ** Header MAC
  , HeaderMac (..)
  , computeHeaderMac
  , headerMacBuilder
  , headerMacParser
  ) where

import Control.Monad ( void )
import Crypto.Age.Key ( FileKey, fileKeyToBytes )
import qualified Crypto.Hash as Crypto
import qualified Crypto.KDF.HKDF as HKDF
import qualified Crypto.MAC.HMAC as Crypto
import Data.Attoparsec.ByteString
  ( Parser, many', many1', sepBy1', string, take, takeWhile1 )
import Data.Attoparsec.ByteString.Base64 ( takeMNBase64Chars, takeNBase64Chars )
import Data.Attoparsec.ByteString.Char8 ( char8 )
import Data.ByteArray ( ScrubbedBytes, constEq )
import qualified Data.ByteArray as BA
import Data.ByteString ( ByteString )
import qualified Data.ByteString as BS
import Data.ByteString.Base64.Extra
  ( decodeBase64StdUnpadded, encodeBase64StdUnpadded )
import Data.ByteString.Builder ( Builder )
import qualified Data.ByteString.Builder as Builder
import Data.ByteString.Extra ( chunksOf )
import Data.Foldable ( foldMap' )
import qualified Data.List as L
import qualified Data.List.Compat as LC
import Data.List.NonEmpty ( NonEmpty )
import qualified Data.List.NonEmpty as NE
import qualified Data.Text as T
import Prelude hiding ( take )

-- | Parse an ABNF @VCHAR@ string as specified in
-- [RFC 2234 section 6.1](https://www.rfc-editor.org/rfc/rfc2234#section-6.1).
vcharStringParser :: Parser ByteString
vcharStringParser = takeWhile1 (`elem` [0x21 .. 0x7E])

-- | Parse and decode @n@ characters of unpadded base64.
base64UnpaddedParser :: Int -> Parser ByteString
base64UnpaddedParser n = do
  b64 <- take n
  case decodeBase64StdUnpadded b64 of
    Left err -> fail (T.unpack err)
    Right bs -> pure bs

-- | Stanza.
data Stanza = Stanza
  { -- | Stanza tag (technically, the first argument of the stanza).
    sTag :: !ByteString
  , -- | Stanza arguments (every argument after the tag).
    sArgs :: ![ByteString]
  , -- | Base64-decoded stanza body.
    sBody :: !ByteString
  }
  deriving stock (Show, Eq)

stanzaBegin :: ByteString
stanzaBegin = "-> "

stanzaBuilder :: Stanza -> Builder
stanzaBuilder s = argLineBuilder <> bodyBuilder
  where
    Stanza
      { sTag
      , sArgs
      , sBody
      } = s

    bodyB64 :: ByteString
    bodyB64 = encodeBase64StdUnpadded sBody

    bodyB64Chunks :: [ByteString]
    bodyB64Chunks = chunksOf 64 bodyB64

    argLineBuilder :: Builder
    argLineBuilder =
      Builder.byteString stanzaBegin
        <> Builder.byteString (BS.intercalate " " (sTag : sArgs))
        <> Builder.byteString "\n"

    fullBodyLinesBuilder :: [ByteString] -> Builder
    fullBodyLinesBuilder [] = mempty
    fullBodyLinesBuilder xs =
      Builder.byteString (BS.intercalate "\n" xs)
        <> Builder.byteString "\n"

    finalBodyLineBuilder :: ByteString -> Builder
    finalBodyLineBuilder bs
      | BS.length bs == 64 = Builder.byteString (bs <> "\n\n")
      | otherwise = Builder.byteString (bs <> "\n")

    bodyBuilder :: Builder
    bodyBuilder =
      case LC.unsnoc bodyB64Chunks of
        Nothing -> Builder.byteString "\n"
        Just (cs, c) ->
          fullBodyLinesBuilder cs
            <> finalBodyLineBuilder c

stanzaParser :: Parser Stanza
stanzaParser = do
  void $ string stanzaBegin
  (tag, args) <- argLineParser
  void $ char8 '\n'
  body <- bodyParser
  pure Stanza
    { sTag = tag
    , sArgs = args
    , sBody = body
    }
  where
    argLineParser :: Parser (ByteString, [ByteString])
    argLineParser = do
      args <- vcharStringParser `sepBy1'` char8 ' '
      case L.uncons args of
        Nothing -> error "stanzaParser: impossible: no elements in a list parsed using sepBy1"
        Just (tag, rest) -> pure (tag, rest)

    fullBodyLineParser :: Parser ByteString
    fullBodyLineParser = takeNBase64Chars 64 <* char8 '\n'

    finalBodyLineParser :: Parser ByteString
    finalBodyLineParser = takeMNBase64Chars 0 63 <* char8 '\n'

    -- Parse the line-wrapped base64-encoded stanza body.
    --
    -- This parser returns each base64-encoded line of the stanza body.
    wrappedBodyParser :: Parser [ByteString]
    wrappedBodyParser = do
      fullLines <- many' fullBodyLineParser
      finalLine <- finalBodyLineParser
      pure $ fullLines ++ [finalLine]

    -- Parse and decode the stanza body.
    --
    -- This parses each base64-encoded line of the stanza body, concatenates
    -- them, and then base64 decodes the result.
    bodyParser :: Parser ByteString
    bodyParser = do
      bodyLines <- wrappedBodyParser
      case decodeBase64StdUnpadded (BS.concat bodyLines) of
        Left err -> fail (T.unpack err)
        Right bs -> pure bs

-- | Header MAC.
--
-- Note that this type's 'Eq' instance performs a constant-time equality
-- check.
newtype HeaderMac = HeaderMac
  { unHeaderMac :: Crypto.HMAC Crypto.SHA256 }

instance Show HeaderMac where
  show = show . Crypto.hmacGetDigest . unHeaderMac

instance Eq HeaderMac where
  HeaderMac x == HeaderMac y = x `constEq` y

-- | Compute the 'HeaderMac' for a 'Header' which would contain the provided 'Stanza's.
computeHeaderMac :: FileKey -> NonEmpty Stanza -> HeaderMac
computeHeaderMac fk stanzas = do
  let info :: ByteString
      info = "header"

      prk :: HKDF.PRK Crypto.SHA256
      prk = HKDF.extract BS.empty (fileKeyToBytes fk)

      hmacKey :: ScrubbedBytes
      hmacKey = HKDF.expand prk info 32

      partialHeaderBs :: ByteString
      partialHeaderBs =
        BS.toStrict . Builder.toLazyByteString $
          headerBuilder' (PartialHeader stanzas) Nothing

  HeaderMac (Crypto.hmac hmacKey partialHeaderBs)

headerMacMark :: ByteString
headerMacMark = "---"

headerMacBegin :: ByteString
headerMacBegin = headerMacMark <> " "

headerMacBuilder :: HeaderMac -> Builder
headerMacBuilder (HeaderMac h) =
  Builder.byteString headerMacBegin
    <> Builder.byteString (encodeBase64StdUnpadded . BA.convert $ Crypto.hmacGetDigest h)

headerMacParser :: Parser HeaderMac
headerMacParser = do
  void $ string headerMacBegin
  macBs <- base64UnpaddedParser 43
  case Crypto.digestFromByteString macBs of
    Nothing -> fail "invalid header HMAC"
    Just d -> pure $ HeaderMac (Crypto.HMAC d)

-- | Partial header.
--
-- This is an internal data type used for computing the header MAC.
newtype PartialHeader = PartialHeader (NonEmpty Stanza)
  deriving stock (Show, Eq)

-- | Convert a 'Header' to a 'PartialHeader'.
toPartialHeader :: Header -> PartialHeader
toPartialHeader Header{hStanzas} = PartialHeader hStanzas

-- | Header.
data Header = Header
  { -- | Stanzas.
    hStanzas :: !(NonEmpty Stanza)
  , -- | Header MAC.
    --
    -- Note that the value of this field is /not/ guaranteed to be
    -- cryptographically verified. For example, if this value was constructed
    -- as the result of parsing an age file, that only means that the value was
    -- deemed to be /syntactically/ valid. It does not mean that it was
    -- verified to be /cryptographically/ valid.
    hMac :: !HeaderMac
  } deriving stock (Show, Eq)

headerVersionLine :: ByteString
headerVersionLine = "age-encryption.org/v1\n"

-- | Encoder which can either encode a partial header (i.e. one with no MAC;
-- which is used for computing the MAC) or a full header (i.e. one with a MAC).
--
-- Note that this function is not intended to be exported.
headerBuilder' :: PartialHeader -> Maybe HeaderMac -> Builder
headerBuilder' (PartialHeader stanzas) mbHeaderMac =
  Builder.byteString headerVersionLine
    <> foldMap' stanzaBuilder stanzas
    <> case mbHeaderMac of
      Just m -> headerMacBuilder m <> Builder.byteString "\n"
      Nothing -> Builder.byteString headerMacMark

-- | 'Header' encoder.
headerBuilder :: Header -> Builder
headerBuilder h@Header{hMac} =
  headerBuilder' (toPartialHeader h) (Just hMac)

-- | 'Header' parser.
headerParser :: Parser Header
headerParser = do
  void $ string headerVersionLine
  stanzas <- many1' stanzaParser
  nonEmptyStanzas <-
    case NE.nonEmpty stanzas of
      Nothing -> fail "expecting one or more recipient stanzas"
      Just x -> pure x
  mac <- headerMacParser
  void $ char8 '\n'
  pure Header
    { hStanzas = nonEmptyStanzas
    , hMac = mac
    }