packages feed

hOpenPGP-3.0.0: tests/Tests/Properties.hs

-- Properties.hs: hOpenPGP test suite
-- Copyright © 2012-2026  Clint Adams
-- This software is released under the terms of the Expat license.
-- (See the LICENSE file).

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}

module Tests.Properties (propertiesTests) where

import Codec.Encryption.OpenPGP.Encrypt (canonicalizePKESKRecipientId)
import Codec.Encryption.OpenPGP.Policy (OpenPGPPolicy(..), OpenPGPRFC(RFC4880), defaultPolicy, policyForRFC)
import Codec.Encryption.OpenPGP.KeyringParser (parseTKsWithWireRep)
import Codec.Encryption.OpenPGP.SecretKey (decryptPrivateKey, encryptPrivateKey)
import Codec.Encryption.OpenPGP.Serialize (parsePktsEither, parsePktsWithWireRep)
import Codec.Encryption.OpenPGP.Types
import Control.Exception (SomeException, try)
import Data.Binary (get, put)
import Data.Binary.Put (runPut)
import qualified Data.ByteString.Lazy as BL
import qualified Data.Conduit as DC
import qualified Data.Conduit.List as CL
import Data.Word (Word8)
import Test.Tasty (TestTree, localOption, testGroup)
import qualified Test.Tasty.QuickCheck as QC
import Tests.Common
  ( collectSecretKeyInfos
  , conduitDecryptWithPKESKContext
  , loadSEIPDv2FixtureWithV4Secret
  , loadV4EncryptedSecretKeyFixtureForProperty
  , loadV6UnencryptedSecretKeyFixtureForProperty
  , prependUnusableLatestPKESK
  , readFixtureLazy
  , reorderPrecedingPKESKs
  , reverseIf
  , runGet
  , selectRecipientKeyInfo
  )

propertiesTests :: TestTree
propertiesTests = testGroup "Properties" [qcProps]

qcProps :: TestTree
qcProps =
  testGroup
    "(checked by QuickCheck)"
    [ QC.testProperty "PKESKv3 packet serialization-deserialization" $ \pkesk ->
        Right (pkesk :: PKESK 'PKESKV3) == runGet get (runPut (put pkesk))
    , QC.testProperty "PKESKv6 packet serialization-deserialization" $ \pkesk ->
        Right (pkesk :: PKESK 'PKESKV6) == runGet get (runPut (put pkesk))
    , QC.testProperty "Signature packet serialization-deserialization" $ \sig ->
        (case _signaturePayload (sig :: Signature) of
           SigVOther _ _ -> False
           _ -> True) QC.==>
        Right (sig :: Signature) == runGet get (runPut (put sig))
    , QC.testProperty "UserId packet serialization-deserialization" $ \uid ->
        Right (uid :: UserId) == runGet get (runPut (put uid))
    , QC.testProperty "decryptPrivateKey (encryptPrivateKey sk pw) pw equivalence" $ \passphraseNE ->
        QC.ioProperty $ do
          fixture <- loadV6UnencryptedSecretKeyFixtureForProperty
          case fixture of
            Left err -> pure (QC.counterexample err False)
            Right (pkp, ska, expectedSKey) -> do
              let passphraseChars = (QC.getNonEmpty passphraseNE :: String)
                  passphrase = BL.pack (map (fromIntegral . fromEnum) passphraseChars)
              encryptedResult <- encryptPrivateKey defaultPolicy pkp ska passphrase
              pure $
                case encryptedResult of
                  Left err ->
                    QC.counterexample ("encryptPrivateKey failed: " ++ err) False
                  Right encryptedSKA ->
                    case decryptPrivateKey (pkp, encryptedSKA) passphrase of
                      Left err ->
                        QC.counterexample ("decryptPrivateKey failed: " ++ err) False
                      Right (SUUnencrypted skey _) ->
                        QC.counterexample
                          "secret key material changed across encrypt/decrypt roundtrip"
                          (skey == expectedSKey)
                      Right other ->
                        QC.counterexample
                          ("expected SUUnencrypted after decrypting encrypted key, got: " ++
                           show other)
                          False
    , localOption
        (QC.QuickCheckTests 5)
        (QC.testProperty "decryptPrivateKey (encryptPrivateKey v4sk pw) pw equivalence" $
           QC.ioProperty $ do
             fixture <- loadV4EncryptedSecretKeyFixtureForProperty
             case fixture of
               Left err -> pure (QC.counterexample err False)
               Right (pkp, ska, expectedSKey, passphrase) -> do
                 let legacyOverridePolicy :: OpenPGPPolicy
                     legacyOverridePolicy =
                       (policyForRFC RFC4880)
                         { policySecretKeyProtection = policySecretKeyProtection defaultPolicy
                         }
                 encryptedResult <- encryptPrivateKey legacyOverridePolicy pkp ska passphrase
                 pure $
                   case encryptedResult of
                     Left err ->
                       QC.counterexample ("encryptPrivateKey failed under legacy override policy: " ++ err) False
                     Right encryptedSKA ->
                       case decryptPrivateKey (pkp, encryptedSKA) passphrase of
                         Left err ->
                           QC.counterexample ("decryptPrivateKey failed: " ++ err) False
                         Right (SUUnencrypted skey _) ->
                           QC.counterexample
                             "v4 secret key material changed across encrypt/decrypt roundtrip"
                             (skey == expectedSKey)
                         Right other ->
                           QC.counterexample
                             ("expected SUUnencrypted after decrypting v4 encrypted key, got: " ++
                              show other)
                             False)
    , QC.testProperty
        "canonicalizePKESKRecipientId idempotence on valid v4/v6 recipient ids"
        propertyCanonicalizePKESKRecipientIdIdempotent
    , localOption
        (QC.QuickCheckTests 10)
        (QC.testProperty
           "canonicalizeTKStructuredWithWireRep is stable across packet/sig reordering"
           propertyCanonicalizeTKStructuredStableAcrossReordering)
    , localOption
        (QC.QuickCheckTests 1)
        (QC.testProperty
           "recipient selection remains decryptable with extra unusable PKESK candidates"
           propertyRecipientSelectionMonotonicWithUnusableCandidates)
    , QC.testProperty
        "parsePktsEither does not accept truncated packet streams as intact packets"
        propertyParsePktsEitherRejectsTruncatedPacketStream
    ]

propertyCanonicalizePKESKRecipientIdIdempotent :: Bool -> Bool -> [Word8] -> QC.Property
propertyCanonicalizePKESKRecipientIdIdempotent useV6 prefixed seedBytes =
  case canonicalizePKESKRecipientId payload of
    Left err ->
      QC.counterexample ("canonicalizePKESKRecipientId unexpectedly failed: " ++ show err) False
    Right canonical ->
      QC.counterexample
        "canonicalizePKESKRecipientId should be idempotent"
        (canonicalizePKESKRecipientId canonical == Right canonical)
  where
    targetLen = if useV6 then 32 else 20
    versionOctet = if useV6 then 0x06 else 0x04
    ridBody = BL.pack (take targetLen (seedBytes ++ repeat 0x00))
    rid
      | prefixed = BL.cons versionOctet ridBody
      | otherwise = ridBody
    payload = PKESKPayloadV6Packet (PKESKPayloadV6 rid RSA "esk")

propertyCanonicalizeTKStructuredStableAcrossReordering ::
     QC.NonNegative Int
  -> Bool
  -> Bool
  -> Bool
  -> Bool
  -> Bool
  -> Bool
  -> Bool
  -> QC.Property
propertyCanonicalizeTKStructuredStableAcrossReordering
  (QC.NonNegative indexSeed)
  reverseDirect
  reverseUIDs
  reverseUIDSigs
  reverseUATs
  reverseUATSigs
  reverseSubs
  reverseSubSigs =
  QC.ioProperty $ do
    lbs <- readFixtureLazy "pubring.gpg"
    let src = wireRepRef lbs
        parsed = parseTKsWithWireRep True (parsePktsWithWireRep src lbs)
    if null parsed
      then pure (QC.counterexample "pubring.gpg parsed to no TKWithWireRep values" False)
      else do
        let tk = parsed !! (indexSeed `mod` length parsed)
        pure $
          case toStructuredTKWithWireRep tk of
            Left err ->
              QC.counterexample ("toStructuredTKWithWireRep failed: " ++ err) False
            Right structured ->
              let shuffled =
                    structured
                      { _tkStructuredDirectSignatures =
                          reverseIf reverseDirect (_tkStructuredDirectSignatures structured)
                      , _tkStructuredUIDs =
                          reverseIf reverseUIDs
                            (map
                               (\uid ->
                                  uid
                                    { _uidWithWireRefsSignatures =
                                        reverseIf reverseUIDSigs (_uidWithWireRefsSignatures uid)
                                    })
                               (_tkStructuredUIDs structured))
                      , _tkStructuredUAts =
                          reverseIf reverseUATs
                            (map
                               (\uat ->
                                  uat
                                    { _uatWithWireRefsSignatures =
                                        reverseIf reverseUATSigs (_uatWithWireRefsSignatures uat)
                                    })
                               (_tkStructuredUAts structured))
                      , _tkStructuredSubkeys =
                          reverseIf reverseSubs
                            (map
                               (\sub ->
                                  sub
                                    { _subkeyWithWireRefsSignatures =
                                        reverseIf reverseSubSigs (_subkeyWithWireRefsSignatures sub)
                                    })
                               (_tkStructuredSubkeys structured))
                      }
               in case (canonicalizeTKStructuredWithWireRep structured, canonicalizeTKStructuredWithWireRep shuffled) of
                    (Right canonicalBase, Right canonicalShuffled) ->
                      QC.counterexample
                        "canonicalization should be stable under packet/sig reordering"
                        (canonicalBase == canonicalShuffled)
                    (Left err, _) ->
                      QC.counterexample ("canonicalizeTKStructuredWithWireRep failed on base: " ++ show err) False
                    (_, Left err) ->
                      QC.counterexample ("canonicalizeTKStructuredWithWireRep failed on shuffled: " ++ show err) False

propertyRecipientSelectionMonotonicWithUnusableCandidates ::
     QC.NonNegative Int -> Bool -> QC.Property
propertyRecipientSelectionMonotonicWithUnusableCandidates (QC.NonNegative extraBogus) reorderPKESKs =
  QC.ioProperty $ do
    let applyBogus = foldr (.) id (replicate (extraBogus `mod` 5) prependUnusableLatestPKESK)
        transformPackets
          | reorderPKESKs = applyBogus . reorderPrecedingPKESKs
          | otherwise = applyBogus
    result <-
      (try $ do
         (messagePacketsRaw, encryptedSecretPackets, passphrase) <-
           loadSEIPDv2FixtureWithV4Secret "seipdv2-three-recipients.pgp.aa"
         keyInfos <- collectSecretKeyInfos encryptedSecretPackets passphrase
         let messagePackets = transformPackets messagePacketsRaw
             passphraseCallback _ = pure BL.empty
             keyContextCallback pkt = pure (selectRecipientKeyInfo pkt keyInfos)
         decrypted <-
           DC.runConduitRes $
           CL.sourceList messagePackets DC..|
           conduitDecryptWithPKESKContext keyContextCallback passphraseCallback DC..|
           CL.consume
         pure (any (not . BL.null) [payload | LiteralDataPkt _ _ _ payload <- decrypted]))
      :: IO (Either SomeException Bool)
    case result of
      Left e ->
        pure $
          QC.counterexample
            ("recipient selection should remain decryptable despite added unusable candidates: " ++ show e)
            False
      Right didDecrypt ->
        pure $
          QC.counterexample
            "recipient selection should still yield a non-empty decrypted literal payload"
            didDecrypt

propertyParsePktsEitherRejectsTruncatedPacketStream ::
     PKESK 'PKESKV6 -> QC.Positive Int -> QC.Property
propertyParsePktsEitherRejectsTruncatedPacketStream pkesk (QC.Positive cutSeed) =
  case parsePktsEither truncated of
    Left _ -> QC.property True
    Right parsed ->
      QC.counterexample
        ("parsePktsEither unexpectedly treated truncated packet stream as original packet: " ++
         show parsed)
        (parsed /= [toPkt pkesk])
  where
    encoded = runPut (put (toPkt pkesk))
    cut = fromIntegral (1 + (cutSeed `mod` fromIntegral (BL.length encoded)))
    truncated = BL.take (BL.length encoded - cut) encoded