packages feed

openpgp-asciiarmor-1.0: Codec/Encryption/OpenPGP/ASCIIArmor/Decode.hs

{-# LANGUAGE OverloadedStrings #-}
-- ASCIIArmor/Decode.hs: OpenPGP (RFC9580) ASCII armor implementation
-- Copyright © 2012-2026  Clint Adams
-- This software is released under the terms of the Expat license.
-- (See the LICENSE file).

module Codec.Encryption.OpenPGP.ASCIIArmor.Decode (
   parseArmor
 , decode
 , decodeLazy
 , decodeWith
 , decodeLazyWith
) where

import Codec.Encryption.OpenPGP.ASCIIArmor.Types
import Codec.Encryption.OpenPGP.ASCIIArmor.Utils
import Control.Applicative (many, (<|>), optional)
import qualified Data.Attoparsec.Combinator as AC
import Data.Attoparsec.ByteString (Parser, many1, string, inClass, notInClass, satisfy, word8, (<?>))
import qualified Data.Attoparsec.ByteString as AS
import qualified Data.Attoparsec.ByteString.Lazy as AL
import Data.Attoparsec.ByteString.Char8 (isDigit_w8, anyChar)
import Data.Bits (shiftL)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Char8 as BC8
import qualified Data.ByteString.Base64 as Base64
import Data.Char (isAlphaNum, isSpace)
import Data.Digest.CRC24 (crc24)
import Data.Binary.Get (Get, runGetOrFail, getWord8)
import Data.Functor (($>))
import Data.List (dropWhileEnd)
import Data.String (IsString, fromString)
import Data.Word (Word32)

decode :: IsString e => B.ByteString -> Either e [Armor]
decode = decodeWith defaultDecodeOptions

decodeWith :: IsString e => DecodeOptions -> B.ByteString -> Either e [Armor]
decodeWith options bs = go (AS.parse (parseArmors options) bs)
    where
        go (AS.Fail _ _ e) = Left (fromString e)
        go (AS.Partial cont) = go (cont B.empty)
        go (AS.Done _ r) = Right r

decodeLazy :: IsString e => BL.ByteString -> Either e [Armor]
decodeLazy = decodeLazyWith defaultDecodeOptions

decodeLazyWith :: IsString e => DecodeOptions -> BL.ByteString -> Either e [Armor]
decodeLazyWith options bs = go (AL.parse (parseArmors options) bs)
    where
        go (AL.Fail _ _ e) = Left (fromString e)
        go (AL.Done _ r) = Right r

parseArmors :: DecodeOptions -> Parser [Armor]
parseArmors options = many (parseArmorWith options)

parseArmor :: Parser Armor
parseArmor = parseArmorWith defaultDecodeOptions

parseArmorWith :: DecodeOptions -> Parser Armor
parseArmorWith options = prefixed (clearsigned options <|> armor options) <?> "armor"

clearsigned :: DecodeOptions -> Parser Armor
clearsigned options = do
    _ <- string "-----BEGIN PGP SIGNED MESSAGE-----" <?> "clearsign header"
    _ <- lineEnding <?> "line ending"
    headers <- cleartextHeaders options <?> "clearsign headers"
    _ <- blankishLine <?> "blank line"
    cleartext <- dashEscapedCleartext
    sig <- armor options
    return $ ClearSigned headers cleartext sig

armor :: DecodeOptions -> Parser Armor
armor options = do
    atype <- beginLine <?> "begin line"
    headers <- armorHeaders <?> "headers"
    _ <- blankishLine <?> "blank line"
    payload <- base64Data options <?> "base64 data"
    _ <- endLine atype <?> "end line"
    return $ Armor atype headers payload

beginLine :: Parser ArmorType
beginLine = do
    _ <- string "-----BEGIN PGP " <?> "leading minus-hyphens"
    atype <- pubkey <|> privkey <|> parts <|> message <|> signature
    _ <- string "-----" <?> "trailing minus-hyphens"
    _ <- many (satisfy (inClass " \t")) <?> "whitespace"
    _ <- lineEnding <?> "line ending"
    return atype
    where
        message = string "MESSAGE" $> ArmorMessage
        pubkey = string "PUBLIC KEY BLOCK" $> ArmorPublicKeyBlock
        privkey = string "PRIVATE KEY BLOCK" $> ArmorPrivateKeyBlock
        signature = string "SIGNATURE" $> ArmorSignature
        parts = string "MESSAGE, PART " *> (partsdef <|> partsindef)
        partsdef = do
            firstnum <- num
            _ <- word8 (fromIntegral . fromEnum $ '/')
            secondnum <- num
            return $ ArmorSplitMessage (BL.pack firstnum) (BL.pack secondnum)
        partsindef = ArmorSplitMessageIndefinite . BL.pack <$> num
        num = many1 (satisfy isDigit_w8) <?> "number"

lineEnding :: Parser B.ByteString
lineEnding = string "\n" <|> string "\r\n"

cleartextHeaders :: DecodeOptions -> Parser [(String, String)]
cleartextHeaders options
    | shouldRequireHashOnlyHeaders options = many hashHeader
    | otherwise = armorHeaders
    where
        hashHeader = do
            hdr@(k, v) <- armorHeader
            if k == "Hash" && isConformantHashHeader v
                then return hdr
                else fail "Only well-formed Hash headers are allowed before cleartext payload"

isConformantHashHeader :: String -> Bool
isConformantHashHeader v = not (null tokens) && all isValidToken tokens
    where
        tokens = map trim (splitOnComma v)
        splitOnComma [] = [""]
        splitOnComma xs =
            let (a, rest) = break (== ',') xs
             in a : case rest of
                 [] -> []
                 (_:ys) -> splitOnComma ys
        trim = dropWhileEnd isSpace . dropWhile isSpace
        isValidToken [] = False
        isValidToken t = all (\c -> isAlphaNum c || c == '-') t

armorHeaders :: Parser [(String, String)]
armorHeaders = many armorHeader

armorHeader :: Parser (String, String)
armorHeader = do
    key <- many1 (satisfy (inClass "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"))
    _ <- string ": "
    val <- many1 (satisfy (notInClass "\n\r"))
    _ <- lineEnding
    return (w8sToString key, w8sToString val)
    where
        w8sToString = BC8.unpack . B.pack

blankishLine ::  Parser B.ByteString
blankishLine = many (satisfy (inClass " \t")) *> lineEnding

endLine :: ArmorType -> Parser B.ByteString
endLine atype = do
    _ <- string $ "-----END PGP " `B.append` aType atype `B.append` "-----"
    lineEnding

aType :: ArmorType -> B.ByteString
aType ArmorMessage = BC8.pack "MESSAGE"
aType ArmorPublicKeyBlock = BC8.pack "PUBLIC KEY BLOCK"
aType ArmorPrivateKeyBlock = BC8.pack "PRIVATE KEY BLOCK"
aType (ArmorSplitMessage x y) = BC8.pack "MESSAGE, PART " `B.append` l2s x `B.append` BC8.singleton '/' `B.append` l2s y
aType (ArmorSplitMessageIndefinite x) = BC8.pack "MESSAGE, PART " `B.append` l2s x
aType ArmorSignature = BC8.pack "SIGNATURE"

l2s :: BL.ByteString -> B.ByteString
l2s = B.concat . BL.toChunks

base64Data :: DecodeOptions -> Parser ByteString
base64Data options = do
    ls <- many1 base64Line
    let payload = B.concat ls
    case crc24Validation options of
        DecodeCRC24ValidationStrict -> do
            decodedChecksum <- checksumLineStrict
            validateChecksum payload (Just decodedChecksum) True
            return (BL.fromStrict payload)
        DecodeCRC24ValidationPermissive -> do
            decodedChecksum <- optional checksumLinePermissive
            validateChecksum payload decodedChecksum False
            return (BL.fromStrict payload)
        DecodeCRC24ValidationAuto -> fail "Internal error: unresolved CRC24 validation mode"
  where
    base64Line :: Parser B.ByteString
    base64Line = do
        startsChecksum <- isChecksumLineStart
        if startsChecksum
            then fail "checksum line"
            else do
                b64line <- B.pack <$> many1 (satisfy (inClass "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= \t"))
                _ <- lineEnding
                let cleaned = stripInlineWhitespace b64line
                case Base64.decode cleaned of
                    Left err -> fail err
                    Right bs -> return bs

    isChecksumLineStart :: Parser Bool
    isChecksumLineStart = AC.lookAhead $ do
        _ <- many (satisfy (inClass " \t"))
        c <- satisfy (notInClass "\n\r")
        return (c == fromIntegral (fromEnum '='))

    checksumLineStrict :: Parser B.ByteString
    checksumLineStrict = do
        _ <- word8 (fromIntegral . fromEnum $ '=')
        b64 <- B.pack <$> many1 (satisfy (inClass "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ \t"))
        _ <- lineEnding
        case Base64.decode (stripInlineWhitespace b64) of
            Left err -> fail err
            Right bs -> return bs

    checksumLinePermissive :: Parser B.ByteString
    checksumLinePermissive = do
        _ <- word8 (fromIntegral . fromEnum $ '=')
        raw <- B.pack <$> many (satisfy (notInClass "\n\r"))
        _ <- lineEnding
        case Base64.decode (stripInlineWhitespace raw) of
            Left _ -> return B.empty
            Right bs -> return bs

validateChecksum :: B.ByteString -> Maybe B.ByteString -> Bool -> Parser ()
validateChecksum payload mDecodedChecksum requireValidCrc =
    case mDecodedChecksum of
        Nothing ->
            if requireValidCrc
                then fail "Missing CRC24 checksum line"
                else return ()
        Just decodedChecksum ->
            if B.length decodedChecksum /= 3
                then if requireValidCrc then fail "Malformed CRC24 checksum line" else return ()
                else
                    case runGetOrFail d24 (BL.fromStrict decodedChecksum) of
                        Left (_,_,err) ->
                            if requireValidCrc then fail err else return ()
                        Right (_,_,theircksum) ->
                            let ourcksum = crc24 payload
                             in if theircksum == ourcksum || not requireValidCrc
                                   then return ()
                                   else fail ("CRC24 mismatch: " ++ show (B.unpack decodedChecksum) ++ "/" ++ show theircksum ++ " vs. " ++ show ourcksum)

stripInlineWhitespace :: B.ByteString -> B.ByteString
stripInlineWhitespace = B.filter (\w -> w /= 0x20 && w /= 0x09)

crc24Validation :: DecodeOptions -> DecodeCRC24Validation
crc24Validation options =
    case decodeCRC24Validation options of
        DecodeCRC24ValidationAuto ->
            case decodeConformanceProfile options of
                Strict_4880_AA -> DecodeCRC24ValidationStrict
                Strict_9580_AA -> DecodeCRC24ValidationPermissive
                Postel_AA -> DecodeCRC24ValidationPermissive
        explicitMode -> explicitMode

shouldRequireHashOnlyHeaders :: DecodeOptions -> Bool
shouldRequireHashOnlyHeaders options =
    case decodeCleartextHeaderPolicy options of
        DecodeCleartextHeaderPolicyAuto ->
            decodeConformanceProfile options == Strict_9580_AA
        DecodeCleartextHeaderPolicyLegacy -> False
        DecodeCleartextHeaderPolicyHashOnly -> True

d24 :: Get Word32
d24 = do
    a <- getWord8
    b <- getWord8
    c <- getWord8
    return $ shiftL (fromIntegral a :: Word32) 16 + shiftL (fromIntegral b :: Word32) 8 + (fromIntegral c :: Word32)

prefixed :: Parser a -> Parser a
prefixed end = end <|> anyChar *> prefixed end

dashEscapedCleartext :: Parser ByteString
dashEscapedCleartext = BL.fromStrict . crlfUnlines <$> go []
    where
        go acc = do
            isSignatureStart <- AC.lookAhead ((string "-----BEGIN PGP " >> return True) <|> return False)
            if isSignatureStart
                then return (reverse acc)
                else do
                    rawLine <- B.pack <$> many (satisfy (notInClass "\n\r"))
                    _ <- lineEnding
                    go (undash rawLine : acc)

        undash :: B.ByteString -> B.ByteString
        undash line
            | BC8.pack "- " `B.isPrefixOf` line = B.drop 2 line
            | otherwise = line