packages feed

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

{-# LANGUAGE LambdaCase #-}

-- | age
-- [ASCII armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)
-- encoding and decoding.
module Crypto.Age.Armor
  ( -- * Encoding
    ArmorError (..)
  , conduitArmor
    -- * Decoding
  , UnarmorError (..)
  , conduitUnarmor
  ) where

import Control.Applicative ( Alternative (..), optional )
import Control.Monad ( void )
import Control.Monad.Except ( ExceptT, throwError, withExceptT )
import Control.Monad.Trans.Class ( MonadTrans (lift) )
import Data.Attoparsec.ByteString ( Parser, count, endOfInput, string )
import Data.Attoparsec.ByteString.Base64 ( takeNBase64Chars )
import Data.Attoparsec.ByteString.Char8 ( char8, endOfLine, skipSpace )
import Data.Attoparsec.ByteString.Extra ( countMN )
import Data.ByteString ( ByteString )
import qualified Data.ByteString as BS
import Data.Conduit ( ConduitT, await, leftover, transPipe, yield, (.|) )
import Data.Conduit.Attoparsec ( ParseError, sinkParserEither )
import Data.Conduit.Base64 ( conduitDecodeBase64, conduitEncodeBase64 )
import qualified Data.Conduit.Combinators as C
import Data.Text ( Text )
import Data.Word ( Word8 )
import Prelude

label :: ByteString
label = "AGE ENCRYPTED FILE"

lineBegin :: ByteString
lineBegin = "-----BEGIN " <> label <> "-----"

lineEnd :: ByteString
lineEnd = "-----END " <> label <> "-----"

-------------------------------------------------------------------------------
-- Encoding
-------------------------------------------------------------------------------

-- | Error
-- \"[armoring](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"
-- an age file.
data ArmorError
  = -- | No data was provided to be armored (i.e. end of input was reached
    -- without consuming any bytes).
    ArmorNoDataError
  deriving stock (Show, Eq)

-- | Stream and
-- \"[armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"
-- an age file.
conduitArmor :: Monad m => ConduitT ByteString ByteString (ExceptT ArmorError m) ()
conduitArmor = await >>= \case
  Nothing -> lift (throwError ArmorNoDataError)
  Just x
    | BS.null x ->
        -- An empty 'ByteString' was consumed from upstream, so try again.
        conduitArmor
    | otherwise -> do
        leftover x
        yield (lineBegin <> "\n")
        conduitEncodeBase64
          .| C.chunksOfE 64
          .| C.intersperse "\n"
        yield ("\n" <> lineEnd <> "\n")

-------------------------------------------------------------------------------
-- Decoding
-------------------------------------------------------------------------------

-- | @eol@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).
eolParser :: Parser ()
eolParser = endOfLine <|> void (char8 '\r')

lineBeginParser :: Parser ()
lineBeginParser = do
  void $ string lineBegin
  eolParser

lineEndParser :: Parser ()
lineEndParser = void $ string lineEnd

-- | @4base64char@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).
fourBase64CharParser :: Parser ByteString
fourBase64CharParser = takeNBase64Chars 4

-- | @base64pad@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).
base64PadParser :: Parser Word8
base64PadParser = char8 '='

-- | Parsed base64-encoded line.
data Base64Line
  = -- | Unpadded base64-encoded line of 64 characters.
    Base64FullUnpaddedLine !ByteString
  | -- | Padded base64-encoded line of 64 characters.
    Base64FullPaddedLine !ByteString
  | -- | Base64-encoded line (either padded or unpadded) of less than 64
    -- characters.
    Base64ShortLine !ByteString

-- | @base64fullline@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3).
base64FullUnpaddedLineParser :: Parser Base64Line
base64FullUnpaddedLineParser = Base64FullUnpaddedLine <$> (takeNBase64Chars 64 <* eolParser)

-- | Variant of the @strictbase64finl@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3) which only
-- parses a base64-encoded and padded line of 64 characters.
base64FullPaddedLineParser :: Parser Base64Line
base64FullPaddedLineParser =  do
  b64Chars <- count 15 fourBase64CharParser
  remainingB64Chars <- remainingB64CharsParser
  eolParser
  pure $ Base64FullPaddedLine (BS.concat b64Chars <> remainingB64Chars)
  where
    remainingB64CharsParser :: Parser ByteString
    remainingB64CharsParser =
      (mappend <$> takeNBase64Chars 3 <*> (BS.singleton <$> base64PadParser))
        <|> (mappend <$> takeNBase64Chars 2 <*> (BS.pack <$> count 2 base64PadParser))

-- | Variant of the @strictbase64finl@ rule from
-- [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468.html#section-3) which only
-- parses a base64-encoded line (either padded or not) that is less than 64
-- characters.
base64ShortLineParser :: Parser Base64Line
base64ShortLineParser = do
  b64Chars <- countMN 0 14 fourBase64CharParser
  remainingB64Chars <-
    if length b64Chars == 0
      then Just <$> remainingB64CharsParser
      else optional remainingB64CharsParser
  eolParser
  pure $ Base64ShortLine (BS.concat b64Chars <> maybe BS.empty id remainingB64Chars)
  where
    remainingB64CharsParser :: Parser ByteString
    remainingB64CharsParser =
      takeNBase64Chars 4
        <|> (mappend <$> takeNBase64Chars 3 <*> (BS.singleton <$> base64PadParser))
        <|> (mappend <$> takeNBase64Chars 2 <*> (BS.pack <$> count 2 base64PadParser))

sinkParseLineBegin :: Monad m => ConduitT ByteString o m (Either ParseError ())
sinkParseLineBegin = sinkParserEither lineBeginParser

-- | Internal helper type for 'conduitParseBase64UntilLineEnd'.
data ParsedLine
  = ParsedLineBase64 !Base64Line
  | ParsedLineEnd

conduitParseBase64UntilLineEnd :: Monad m => ConduitT ByteString ByteString (ExceptT UnarmorError m) ()
conduitParseBase64UntilLineEnd = await >>= \case
  Nothing ->
    -- End of input was reached, but the ending line was not parsed.
    lift (throwError UnarmorNoLineEndError)
  Just x -> do
    leftover x
    res <-
      sinkParserEither $
        (ParsedLineBase64 <$> base64FullUnpaddedLineParser)
          <|> (ParsedLineBase64 <$> base64FullPaddedLineParser)
          <|> (ParsedLineBase64 <$> base64ShortLineParser)
          <|> (lineEndIgnoreWhitespaceParser *> pure ParsedLineEnd)
    case res of
      Left err -> lift (throwError $ UnarmorParseError err)
      Right (ParsedLineBase64 parsedB64Line) ->
        case parsedB64Line of
          Base64FullUnpaddedLine b64Line -> do
            yield b64Line
            conduitParseBase64UntilLineEnd
          Base64FullPaddedLine b64Line -> do
            -- Parsed a padded line, so we should be at the end line now.
            yield b64Line
            lineEndRes <- sinkParserEither lineEndIgnoreWhitespaceParser
            case lineEndRes of
              Left err -> lift (throwError $ UnarmorParseError err)
              Right () -> pure ()
          Base64ShortLine b64Line -> do
            -- Parsed a short line, so we should be at the end line now.
            yield b64Line
            lineEndRes <- sinkParserEither lineEndIgnoreWhitespaceParser
            case lineEndRes of
              Left err -> lift (throwError $ UnarmorParseError err)
              Right () -> pure ()
      Right ParsedLineEnd -> pure ()
  where
    lineEndIgnoreWhitespaceParser :: Parser ()
    lineEndIgnoreWhitespaceParser = lineEndParser >> skipSpace >> endOfInput

-- | Error
-- \"un-[armoring](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"
-- an age file.
data UnarmorError
  = -- | End of input was reached immediately after parsing the beginning line.
    UnarmorNoDataAfterLineBeginError
  | -- | End of input was reached before the ending line was parsed.
    UnarmorNoLineEndError
  | -- | Error parsing the ASCII-armored age file.
    UnarmorParseError !ParseError
  | -- | Error base64 decoding the encapsulated text portion of the
    -- ASCII-armored age file.
    UnarmorDecodeBase64Error !Text
  deriving stock (Show)

-- | Stream and
-- \"un-[armor](https://github.com/C2SP/C2SP/blob/03ab74455beb3a6d6e0fb7dd1de5a932e2257cd0/age.md#ascii-armor)\"
-- an age file.
conduitUnarmor :: Monad m => ConduitT ByteString ByteString (ExceptT UnarmorError m) ()
conduitUnarmor = do
  -- Ignore leading whitespace
  void (sinkParserEither skipSpace)

  sinkParseLineBegin >>= \case
    Left err -> lift (throwError $ UnarmorParseError err)
    Right () -> pure ()

  -- Ensure that we try to parse at least one more line.
  await >>= \case
    Nothing -> lift (throwError UnarmorNoDataAfterLineBeginError)
    Just x -> do
      leftover x
      conduitParseBase64UntilLineEnd
        .| transPipe (withExceptT UnarmorDecodeBase64Error) conduitDecodeBase64