diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,15 @@
+# ChangeLog for domain-auth
+
+## 0.2.5
+
+* Supporting crypton >= 1.1 and crypton-x509 >= 1.8.
+* DKIM/DomainKeys: malformed signatures and public keys (unknown
+  algorithms or canonicalizations, duplicated tags, bad base64) no
+  longer throw exceptions; they are treated as permerror or fail.
+* DKIM: signed header fields are selected bottom-up from the entire
+  header (RFC 6376 Section 5.4.2). `fieldsWith` now selects the last
+  instance first. This fixes an oversigning bypass by adding a field
+  above DKIM-Signature.
+* SPF: "a", "mx", "include" and "redirect" are counted against `limit`.
+  More than 10 MX records or more than 2 void lookups are permerror
+  (RFC 7208 Section 4.6.4). Out-of-range CIDR lengths are rejected.
diff --git a/Network/DomainAuth/DK/Parser.hs b/Network/DomainAuth/DK/Parser.hs
--- a/Network/DomainAuth/DK/Parser.hs
+++ b/Network/DomainAuth/DK/Parser.hs
@@ -9,6 +9,7 @@
 import Data.List
 import qualified Data.Map as M
 import Data.Maybe
+import qualified Data.Set as Set
 import Network.DomainAuth.DK.Types
 import Network.DomainAuth.Mail
 
@@ -38,11 +39,13 @@
 --         }
 --     )
 parseDK :: RawFieldValue -> Maybe DK
-parseDK val = toDK domkey
+parseDK val
+    | Set.size (Set.fromList ts) /= length ts = Nothing -- duplicated tags
+    | otherwise = toDK domkey
   where
     (ts, vs) = unzip $ parseTaggedValue val
     fs = map tagToSetter ts
-    tagToSetter tag = fromMaybe (\_ mdk -> mdk) $ lookup (BS.head tag) dkTagDB
+    tagToSetter tag = fromMaybe (\_ mdk -> mdk) $ lookup (BS.unpack tag) dkTagDB
     pfs = zipWith ($) fs vs
     domkey = foldr ($) initialMDK pfs
     toDK mdk = do
@@ -84,20 +87,20 @@
 
 type DKSetter = ByteString -> MDK -> MDK
 
-dkTagDB :: [(Char, DKSetter)]
+dkTagDB :: [(String, DKSetter)]
 dkTagDB =
-    [ ('a', setDkAlgorithm)
-    , ('b', setDkSignature)
-    , ('c', setDkCanonAlgo)
-    , ('d', setDkDomain)
-    , ('h', setDkFields)
-    , --  , ('q',setDkQuery)
-      ('s', setDkSelector)
+    [ ("a", setDkAlgorithm)
+    , ("b", setDkSignature)
+    , ("c", setDkCanonAlgo)
+    , ("d", setDkDomain)
+    , ("h", setDkFields)
+    , --  , ("q",setDkQuery)
+      ("s", setDkSelector)
     ]
 
 setDkAlgorithm :: DKSetter
 setDkAlgorithm "rsa-sha1" dk = dk{mdkAlgorithm = Just DK_RSA_SHA1}
-setDkAlgorithm _ _ = error "setDkAlgorithm"
+setDkAlgorithm _ dk = dk{mdkAlgorithm = Nothing}
 
 setDkSignature :: DKSetter
 setDkSignature sig dk = dk{mdkSignature = Just sig}
@@ -105,7 +108,7 @@
 setDkCanonAlgo :: DKSetter
 setDkCanonAlgo "simple" dk = dk{mdkCanonAlgo = Just DK_SIMPLE}
 setDkCanonAlgo "nofws" dk = dk{mdkCanonAlgo = Just DK_NOFWS}
-setDkCanonAlgo _ _ = error "setDkCanonAlgo"
+setDkCanonAlgo _ dk = dk{mdkCanonAlgo = Nothing}
 
 setDkDomain :: DKSetter
 setDkDomain dom dk = dk{mdkDomain = Just dom}
diff --git a/Network/DomainAuth/DK/Verify.hs b/Network/DomainAuth/DK/Verify.hs
--- a/Network/DomainAuth/DK/Verify.hs
+++ b/Network/DomainAuth/DK/Verify.hs
@@ -60,7 +60,8 @@
 ----------------------------------------------------------------
 
 verifyDK :: Mail -> DK -> PublicKey -> Bool
-verifyDK mail dk pub = verify (Just SHA1) pub cmail sig
+verifyDK mail dk pub = case B.decode (dkSignature dk) of
+    Nothing -> False
+    Just sig -> verify (Just SHA1) pub cmail sig
   where
-    sig = B.decode . dkSignature $ dk
     cmail = BL.toStrict $ BB.toLazyByteString $ prepareDK dk mail
diff --git a/Network/DomainAuth/DKIM/Parser.hs b/Network/DomainAuth/DKIM/Parser.hs
--- a/Network/DomainAuth/DKIM/Parser.hs
+++ b/Network/DomainAuth/DKIM/Parser.hs
@@ -7,6 +7,7 @@
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as BS
 import Data.Maybe
+import qualified Data.Set as Set
 import Network.DomainAuth.DKIM.Types
 import Network.DomainAuth.Mail
 
@@ -86,7 +87,9 @@
 --         }
 --     )
 parseDKIM :: RawFieldValue -> Maybe DKIM
-parseDKIM val = toDKIM domkey
+parseDKIM val
+    | Set.size (Set.fromList ts) /= length ts = Nothing -- duplicated tags
+    | otherwise = toDKIM domkey
   where
     (ts, vs) = unzip $ parseTaggedValue val
     fs = map tagToSetter ts
@@ -167,7 +170,7 @@
 setDkimSigAlgo :: DKIMSetter
 setDkimSigAlgo "rsa-sha1" dkim = dkim{mdkimSigAlgo = Just RSA_SHA1}
 setDkimSigAlgo "rsa-sha256" dkim = dkim{mdkimSigAlgo = Just RSA_SHA256}
-setDkimSigAlgo _ _ = error "setDkimSigAlgo"
+setDkimSigAlgo _ dkim = dkim{mdkimSigAlgo = Nothing}
 
 setDkimSignature :: DKIMSetter
 setDkimSignature sig dkim = dkim{mdkimSignature = Just sig}
@@ -176,6 +179,11 @@
 setDkimBodyHash bh dkim = dkim{mdkimBodyHash = Just bh}
 
 setDkimCanonAlgo :: DKIMSetter
+setDkimCanonAlgo "simple" dkim =
+    dkim
+        { mdkimHeaderCanon = Just DKIM_SIMPLE
+        , mdkimBodyCanon = Just DKIM_SIMPLE
+        }
 setDkimCanonAlgo "relaxed" dkim =
     dkim
         { mdkimHeaderCanon = Just DKIM_RELAXED
@@ -201,7 +209,11 @@
         { mdkimHeaderCanon = Just DKIM_SIMPLE
         , mdkimBodyCanon = Just DKIM_SIMPLE
         }
-setDkimCanonAlgo _ _ = error "setDkimCanonAlgo"
+setDkimCanonAlgo _ dkim =
+    dkim
+        { mdkimHeaderCanon = Nothing
+        , mdkimBodyCanon = Nothing
+        }
 
 setDkimDomain :: DKIMSetter
 setDkimDomain dom dkim = dkim{mdkimDomain = Just dom}
diff --git a/Network/DomainAuth/DKIM/Verify.hs b/Network/DomainAuth/DKIM/Verify.hs
--- a/Network/DomainAuth/DKIM/Verify.hs
+++ b/Network/DomainAuth/DKIM/Verify.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE OverloadedStrings #-}
-{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
 
 module Network.DomainAuth.DKIM.Verify (
     verifyDKIM,
@@ -9,13 +8,13 @@
 import Crypto.Hash
 import Crypto.PubKey.RSA
 import Crypto.PubKey.RSA.PKCS15
-import Data.ByteArray
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
 import Data.ByteString.Builder (Builder)
 import qualified Data.ByteString.Builder as BB
 import qualified Data.ByteString.Lazy as BL
 import Network.DomainAuth.DKIM.Btag
+import Network.DomainAuth.DKIM.Parser
 import Network.DomainAuth.DKIM.Types
 import Network.DomainAuth.Mail
 import qualified Network.DomainAuth.Pubkey.Base64 as B
@@ -23,14 +22,20 @@
 
 ----------------------------------------------------------------
 
-prepareDKIM :: DKIM -> Mail -> Builder
-prepareDKIM dkim mail = header
+-- | Fields are selected from the entire header except the signature
+--   being verified, bottom-up (RFC 6376 Section 5.4.2).
+prepareDKIM :: DKIM -> Mail -> Maybe Builder
+prepareDKIM dkim mail = case break isTheSig (mailHeader mail) of
+    (_, []) -> Nothing
+    (pre, dkimField : post) ->
+        let targets = fieldsWith (dkimFields dkim) (pre ++ post)
+         in Just $ concatCRLFWith hCanon targets +++ canon dkimField
   where
-    dkimField : fields = fieldsFrom dkimFieldKey (mailHeader mail)
+    isTheSig fld =
+        fieldSearchKey fld == dkimFieldKey
+            && parseDKIM (fieldValueUnfolded fld) == Just dkim
     hCanon = canonDkimField (dkimHeaderCanon dkim)
     canon = BB.byteString . removeBtagValue . hCanon
-    targets = fieldsWith (dkimFields dkim) fields
-    header = concatCRLFWith hCanon targets +++ canon dkimField
 
 ----------------------------------------------------------------
 
@@ -51,24 +56,27 @@
 ----------------------------------------------------------------
 
 verifyDKIM :: Mail -> DKIM -> PublicKey -> Bool
-verifyDKIM mail dkim pub =
-    bodyHash1 mail == bodyHash2 dkim
-        && verify' (dkimSigAlgo dkim) pub cmail sig
+verifyDKIM mail dkim pub = case (mbh, msig, mhdr) of
+    (Just bh, Just sig, Just hdr) ->
+        matchHash alg bh body && verify' alg pub (toBS hdr) sig
+    _ -> False
   where
-    sig = B.decode . dkimSignature $ dkim
-    cmail = BL.toStrict $ BB.toLazyByteString $ prepareDKIM dkim mail
-    bodyHash1 =
-        hashAlgo2 (dkimSigAlgo dkim)
-            . BL.toStrict
+    alg = dkimSigAlgo dkim
+    mbh = B.decode $ dkimBodyHash dkim
+    msig = B.decode $ dkimSignature dkim
+    mhdr = prepareDKIM dkim mail
+    toBS = BL.toStrict . BB.toLazyByteString
+    body =
+        BL.toStrict
             . BB.toLazyByteString
             . canonDkimBody (dkimBodyCanon dkim)
-            . mailBody
-    bodyHash2 = B.decode . dkimBodyHash
+            $ mailBody mail
 
 verify' :: DkimSigAlgo -> PublicKey -> ByteString -> ByteString -> Bool
 verify' RSA_SHA1 = verify (Just SHA1)
 verify' RSA_SHA256 = verify (Just SHA256)
 
-hashAlgo2 :: ByteArray c => DkimSigAlgo -> ByteString -> c
-hashAlgo2 RSA_SHA1 = convert . (hash :: ByteString -> Digest SHA1)
-hashAlgo2 RSA_SHA256 = convert . (hash :: ByteString -> Digest SHA256)
+-- | Checking if the body hash matches the decoded "bh" value.
+matchHash :: DkimSigAlgo -> ByteString -> ByteString -> Bool
+matchHash RSA_SHA1 bh txt = digestFromByteString bh == Just (hashWith SHA1 txt)
+matchHash RSA_SHA256 bh txt = digestFromByteString bh == Just (hashWith SHA256 txt)
diff --git a/Network/DomainAuth/Mail/Mail.hs b/Network/DomainAuth/Mail/Mail.hs
--- a/Network/DomainAuth/Mail/Mail.hs
+++ b/Network/DomainAuth/Mail/Mail.hs
@@ -50,14 +50,16 @@
     safeTail [] = []
     safeTail xs = tail xs
 
--- RFC 4871 is ambiguous, so implement only normal case.
-
--- | Obtaining all fields with DKIM algorithm.
+-- | Obtaining all fields with DKIM algorithm (RFC 6376 Section 5.4.2).
+--   Each key selects the last unused instance (bottom-up).
+--   Keys of nonexistent instances are ignored.
 --
+-- >>> fieldsWith ["from","from","subject"] [Field "from" "From" ["1"],Field "subject" "Subject" ["2"],Field "from" "From" ["3"]]
+-- [Field {fieldSearchKey = "from", fieldKey = "From", fieldValue = ["3"]},Field {fieldSearchKey = "from", fieldKey = "From", fieldValue = ["1"]},Field {fieldSearchKey = "subject", fieldKey = "Subject", fieldValue = ["2"]}]
 -- >>> fieldsWith ["from","to","subject","date","message-id"] [Field "from" "From" ["foo"],Field "to" "To" ["bar"],Field "subject" "Subject" ["baz"],Field "date" "Date" ["qux"],Field "message-id" "Message-Id" ["quux"], Field "received" "Received" ["fiz"], Field "filtered-out" "Filtered-Out" ["buzz"], Field "not-needed" "Not-Needed" ["fizz"]]
 -- [Field {fieldSearchKey = "from", fieldKey = "From", fieldValue = ["foo"]},Field {fieldSearchKey = "to", fieldKey = "To", fieldValue = ["bar"]},Field {fieldSearchKey = "subject", fieldKey = "Subject", fieldValue = ["baz"]},Field {fieldSearchKey = "date", fieldKey = "Date", fieldValue = ["qux"]},Field {fieldSearchKey = "message-id", fieldKey = "Message-Id", fieldValue = ["quux"]}]
 fieldsWith :: [CanonFieldKey] -> Header -> Header
-fieldsWith kx hx = catMaybes $ enm kx hx (\k h -> k == fieldSearchKey h)
+fieldsWith kx hx = catMaybes $ enm kx (reverse hx) (\k h -> k == fieldSearchKey h)
 
 -- | RFC 6376 says:
 --    Signers MAY claim to have signed header fields that do not exist
diff --git a/Network/DomainAuth/Pubkey/Base64.hs b/Network/DomainAuth/Pubkey/Base64.hs
--- a/Network/DomainAuth/Pubkey/Base64.hs
+++ b/Network/DomainAuth/Pubkey/Base64.hs
@@ -2,7 +2,6 @@
 
 module Network.DomainAuth.Pubkey.Base64 (
     decode,
-    decode',
 ) where
 
 import Data.Bits (shiftL, shiftR, (.&.), (.|.))
@@ -14,30 +13,33 @@
 import Data.Word
 import Network.DomainAuth.Utils
 
-isBase64 :: Word8 -> Bool
-isBase64 c = isAlphaNum c || (c `elem` [cPlus, cSlash, cEqual])
-
-decode :: ByteString -> ByteString
-decode = BL.toStrict . decode'
-
-decode' :: ByteString -> BL.ByteString
-decode' = BB.toLazyByteString . dec . BS.filter isBase64
+-- | Decoding base64 with folding white spaces.
+--   'Nothing' is returned for malformed input.
+--
+-- >>> decode "aGVs bG8="
+-- Just "hello"
+-- >>> decode "aGVsbG8"
+-- Nothing
+-- >>> decode "aGV*bG8="
+-- Nothing
+-- >>> decode "=AAA"
+-- Nothing
+decode :: ByteString -> Maybe ByteString
+decode bs = BL.toStrict . BB.toLazyByteString <$> dec (BS.filter (not . isSpace) bs)
 
-dec :: ByteString -> Builder
+dec :: ByteString -> Maybe Builder
 dec bs
-    | BS.null bs = empty
-    | len == 4 && c3 == cEqual = dec1' x1 x2
-    | len == 4 && c4 == cEqual = dec2' x1 x2 x3
-    | len >= 4 = dec' x1 x2 x3 x4 +++ dec bs'
-    | otherwise = error "dec"
+    | BS.null bs = Just empty
+    | len < 4 = Nothing
+    | len == 4 && c3 == cEqual && c4 == cEqual = dec1' <$> x1 <*> x2
+    | len == 4 && c4 == cEqual = dec2' <$> x1 <*> x2 <*> x3
+    | otherwise = (+++) <$> (dec' <$> x1 <*> x2 <*> x3 <*> x4) <*> dec bs'
   where
     len = BS.length bs
-    c1 = bs !!! 0
-    c2 = bs !!! 1
     c3 = bs !!! 2
     c4 = bs !!! 3
-    x1 = fromChar c1
-    x2 = fromChar c2
+    x1 = fromChar (bs !!! 0)
+    x2 = fromChar (bs !!! 1)
     x3 = fromChar c3
     x4 = fromChar c4
     bs' = BS.drop 4 bs
@@ -60,18 +62,11 @@
     d1 = (x1 `shiftL` 2) .|. (x2 `shiftR` 4)
     d2 = ((x2 `shiftL` 4) .&. 0xF0) .|. (x3 `shiftR` 2)
 
-fromChar :: Word8 -> Word8
+fromChar :: Word8 -> Maybe Word8
 fromChar c
-    | isUpper c = c - cA
-    | isLower c = c - cSmallA + 26
-    | isDigit c = c - cZero + 52
-    | c == cPlus = 62
-    | c == cSlash = 63
-    | otherwise = error ("fromChar: Can't happen: Bad input: " ++ show c)
-
-{-
-splits :: Int -> [a] -> [[a]]
-splits _ [] = []
-splits n xs = case splitAt n xs of
-                  (ys, zs) -> ys:splits n zs
--}
+    | isUpper c = Just (c - cA)
+    | isLower c = Just (c - cSmallA + 26)
+    | isDigit c = Just (c - cZero + 52)
+    | c == cPlus = Just 62
+    | c == cSlash = Just 63
+    | otherwise = Nothing
diff --git a/Network/DomainAuth/Pubkey/RSAPub.hs b/Network/DomainAuth/Pubkey/RSAPub.hs
--- a/Network/DomainAuth/Pubkey/RSAPub.hs
+++ b/Network/DomainAuth/Pubkey/RSAPub.hs
@@ -2,10 +2,11 @@
 
 module Network.DomainAuth.Pubkey.RSAPub (
     lookupPublicKey,
+    decodeRSAPublicyKey,
 ) where
 
 import Crypto.PubKey.RSA (PublicKey)
-import Data.ASN1.BinaryEncoding (DER)
+import Data.ASN1.BinaryEncoding (DER (..))
 import Data.ASN1.Encoding (decodeASN1')
 import Data.ASN1.Types (fromASN1)
 import Data.ByteString (ByteString)
@@ -46,10 +47,10 @@
     (x : _) -> lookup "p" (parseTaggedValue x)
 
 decodeRSAPublicyKey :: ByteString -> Maybe PublicKey
-decodeRSAPublicyKey b64 = case decodeASN1' (undefined :: DER) der of
-    Left _ -> Nothing
-    Right ans1 -> case fromASN1 ans1 of
-        Right (PubKeyRSA p, []) -> Just p
-        _ -> Nothing
-  where
-    der = B.decode b64
+decodeRSAPublicyKey b64 = do
+    der <- B.decode b64
+    case decodeASN1' DER der of
+        Left _ -> Nothing
+        Right ans1 -> case fromASN1 ans1 of
+            Right (PubKeyRSA p, []) -> Just p
+            _ -> Nothing
diff --git a/Network/DomainAuth/SPF.hs b/Network/DomainAuth/SPF.hs
--- a/Network/DomainAuth/SPF.hs
+++ b/Network/DomainAuth/SPF.hs
@@ -6,13 +6,11 @@
     defaultLimit,
 ) where
 
-import Control.Exception as E
 import Data.IP
 import Network.DNS (Domain, Resolver)
 import Network.DomainAuth.SPF.Eval
 import Network.DomainAuth.SPF.Resolver
 import Network.DomainAuth.Types
-import System.IO.Error
 
 -- $setup
 -- >>> import Network.DNS
@@ -21,7 +19,7 @@
 --   If 'Domain' is specified from SMTP MAIL FROM, authentication is
 --   based on SPF. If 'Domain' is specified from the From field of mail
 --   header, authentication is based on SenderID. If condition reaches
---   'Limit', 'SpfPermError' is returned.
+--   'Limit', 'DAPermError' is returned.
 --
 -- >>> rs <- makeResolvSeed defaultResolvConf
 --
@@ -50,11 +48,4 @@
 -- >>> withResolver rs $ \rslv -> runSPF limit1 rslv "gmail.com" "72.14.192.1"
 -- softfail
 runSPF :: Limit -> Resolver -> Domain -> IP -> IO DAResult
-runSPF lim resolver dom ip =
-    (resolveSPF resolver dom ip >>= evalSPF lim ip) `E.catch` spfErrorHandle
-
-spfErrorHandle :: IOError -> IO DAResult
-spfErrorHandle e = case ioeGetErrorString e of
-    "TempError" -> return DATempError
-    "PermError" -> return DAPermError
-    _ -> return DANone
+runSPF lim resolver = runSPFWith lim (dnsLookup resolver)
diff --git a/Network/DomainAuth/SPF/Eval.hs b/Network/DomainAuth/SPF/Eval.hs
--- a/Network/DomainAuth/SPF/Eval.hs
+++ b/Network/DomainAuth/SPF/Eval.hs
@@ -1,20 +1,24 @@
 module Network.DomainAuth.SPF.Eval (
+    runSPFWith,
     evalSPF,
     Limit (..),
     defaultLimit,
 ) where
 
-import Data.IORef
+import Control.Exception as E
 import Data.IP
 import Data.Maybe
+import Network.DNS (Domain)
+import Network.DomainAuth.SPF.Resolver
 import Network.DomainAuth.SPF.Types
 import Network.DomainAuth.Types
+import System.IO.Error
 
 -- |  Limit for SPF authentication.
 data Limit = Limit
     { limit :: Int
-    -- ^ How many \"redirect\"/\"include\" should be followed.
-    --   'DAPermError' is returned if reached to this limit.
+    -- ^ How many DNS-querying terms (\"a\", \"mx\", \"include\" and
+    --   \"redirect\") are allowed. 'DAPermError' is returned if exceeded.
     , ipv4_masklen :: Int
     -- ^ Ignoring IPv4 range whose mask length is shorter than this.
     , ipv6_masklen :: Int
@@ -39,32 +43,38 @@
 
 ----------------------------------------------------------------
 
+-- | Running SPF with the given lookup functions.
+runSPFWith :: Limit -> SpfLookup -> Domain -> IP -> IO DAResult
+runSPFWith lim lk dom ip = do
+    ctx <- newSpfCtx lk (limit lim)
+    (resolveSPF ctx False dom ip >>= evalSPF lim ip) `E.catch` spfErrorHandle
+
+spfErrorHandle :: IOError -> IO DAResult
+spfErrorHandle e = case ioeGetErrorString e of
+    "TempError" -> return DATempError
+    "PermError" -> return DAPermError
+    _ -> return DANone
+
+----------------------------------------------------------------
+
+-- | DNS lookup limits are enforced in "Network.DomainAuth.SPF.Resolver".
 evalSPF :: Limit -> IP -> [IO SpfSeq] -> IO DAResult
-evalSPF lim ip ss = do
-    ref <- newIORef (0 :: Int)
-    fromJust <$> evalspf ref lim ip ss
+evalSPF lim ip ss = fromJust <$> evalspf lim ip ss
 
 ----------------------------------------------------------------
 
-evalspf :: IORef Int -> Limit -> IP -> [IO SpfSeq] -> IO (Maybe DAResult)
-evalspf _ _ _ [] = return (Just DANeutral) -- default result
-evalspf ref lim ip (s : ss) = do
-    cnt <- readIORef ref
-    if cnt > limit lim
-        then return (Just DAPermError) -- reached the limit
-        else do
-            mres <- eval ref lim ip s
-            case mres of
-                Nothing -> evalspf ref lim ip ss
-                res -> return res
+evalspf :: Limit -> IP -> [IO SpfSeq] -> IO (Maybe DAResult)
+evalspf _ _ [] = return (Just DANeutral) -- default result
+evalspf lim ip (s : ss) = do
+    mres <- eval lim ip s
+    case mres of
+        Nothing -> evalspf lim ip ss
+        res -> return res
 
 ----------------------------------------------------------------
--- Follow N of redirect/include. But the last one is not
--- evaluated.
 
-eval :: IORef Int -> Limit -> IP -> IO SpfSeq -> IO (Maybe DAResult)
-eval ref lim ip is = do
-    cnt <- readIORef ref
+eval :: Limit -> IP -> IO SpfSeq -> IO (Maybe DAResult)
+eval lim ip is = do
     s <- is
     case s of
         SS_All q ->
@@ -88,14 +98,11 @@
             | any (ipv6 ip `isMatchedTo`) iprs -> ret q
             | otherwise -> continue
         SS_IF_Pass q ss -> do
-            writeIORef ref (cnt + 1)
-            r <- evalspf ref lim ip ss
+            r <- evalspf lim ip ss
             if r == Just DAPass
                 then ret q
                 else continue
-        SS_SpfSeq ss -> do
-            writeIORef ref (cnt + 1)
-            evalspf ref lim ip ss
+        SS_SpfSeq ss -> evalspf lim ip ss
   where
     ret = return . Just . toEnum . fromEnum
     result = return . Just
diff --git a/Network/DomainAuth/SPF/Parser.hs b/Network/DomainAuth/SPF/Parser.hs
--- a/Network/DomainAuth/SPF/Parser.hs
+++ b/Network/DomainAuth/SPF/Parser.hs
@@ -108,8 +108,12 @@
 optionalDomain :: Parser (Maybe Domain)
 optionalDomain = P.option Nothing (Just <$> (P.word8 _colon *> domain))
 
-mask :: Parser Int
-mask = read . BS8.unpack <$> P.takeWhile1 (P.inClass "0-9")
+mask :: Int -> Parser Int
+mask maxlen = do
+    ds <- P.takeWhile1 (P.inClass "0-9")
+    case readMaybe (BS8.unpack ds) of
+        Just n | BS.length ds <= 3 && n <= maxlen -> return n
+        _ -> fail "mask"
 
 optionalMask :: Parser (Int, Int)
 optionalMask = P.try both <|> P.try v4 <|> P.try v6 <|> none
@@ -120,7 +124,7 @@
     none = return (32, 128)
 
 ipv4Mask :: Parser Int
-ipv4Mask = P.word8 _slash *> mask
+ipv4Mask = P.word8 _slash *> mask 32
 
 ipv6Mask :: Parser Int
-ipv6Mask = P.string "//" *> mask
+ipv6Mask = P.string "//" *> mask 128
diff --git a/Network/DomainAuth/SPF/Resolver.hs b/Network/DomainAuth/SPF/Resolver.hs
--- a/Network/DomainAuth/SPF/Resolver.hs
+++ b/Network/DomainAuth/SPF/Resolver.hs
@@ -2,10 +2,16 @@
 
 module Network.DomainAuth.SPF.Resolver (
     resolveSPF,
+    SpfLookup (..),
+    dnsLookup,
+    SpfCtx,
+    newSpfCtx,
 ) where
 
 import Control.Monad
+import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as BS
+import Data.IORef
 import Data.IP
 import Data.Maybe
 import Network.DNS
@@ -14,23 +20,77 @@
 
 ----------------------------------------------------------------
 
-resolveSPF :: Resolver -> Domain -> IP -> IO [IO SpfSeq]
-resolveSPF resolver dom ip = do
-    jrc <- lookupTXT resolver dom
-    checkDNS jrc "TempError"
-    let rr = getSPFRR jrc
-    checkExistence rr "None"
+-- | DNS lookup functions used by SPF.
+data SpfLookup = SpfLookup
+    { spfLookupTXT :: Domain -> IO (Either DNSError [ByteString])
+    , spfLookupA :: Domain -> IO (Either DNSError [IPv4])
+    , spfLookupAAAA :: Domain -> IO (Either DNSError [IPv6])
+    , spfLookupMX :: Domain -> IO (Either DNSError [(Domain, Int)])
+    }
+
+dnsLookup :: Resolver -> SpfLookup
+dnsLookup r =
+    SpfLookup
+        { spfLookupTXT = lookupTXT r
+        , spfLookupA = lookupA r
+        , spfLookupAAAA = lookupAAAA r
+        , spfLookupMX = lookupMX r
+        }
+
+-- | Shared state for one SPF evaluation.
+data SpfCtx = SpfCtx
+    { ctxLookup :: SpfLookup
+    , ctxLimit :: Int
+    , ctxCount :: IORef Int
+    , ctxVoid :: IORef Int
+    }
+
+newSpfCtx :: SpfLookup -> Int -> IO SpfCtx
+newSpfCtx lk lim = SpfCtx lk lim <$> newIORef 0 <*> newIORef 0
+
+-- RFC 7208 Section 4.6.4
+voidLookupLimit, mxLimit :: Int
+voidLookupLimit = 2
+mxLimit = 10
+
+-- | Charging one DNS-querying term against the limit.
+charge :: SpfCtx -> IO ()
+charge ctx = do
+    n <- atomicModifyIORef' (ctxCount ctx) $ \x -> (x + 1, x + 1)
+    when (n > ctxLimit ctx) $ fail "PermError"
+
+-- | NXDOMAIN is treated as empty. Empty answers are void lookups.
+checkVoid :: SpfCtx -> Either DNSError [a] -> IO [a]
+checkVoid ctx r = case r of
+    Left NameError -> void' >> return []
+    Left _ -> fail "TempError"
+    Right [] -> void' >> return []
+    Right xs -> return xs
+  where
+    void' = do
+        n <- atomicModifyIORef' (ctxVoid ctx) $ \x -> (x + 1, x + 1)
+        when (n > voidLookupLimit) $ fail "PermError"
+
+----------------------------------------------------------------
+
+-- | 'True' for include/redirect, where no SPF record is PermError.
+resolveSPF :: SpfCtx -> Bool -> Domain -> IP -> IO [IO SpfSeq]
+resolveSPF ctx nested dom ip = do
+    jrc <- spfLookupTXT (ctxLookup ctx) dom
+    rc <- case jrc of
+        Left NameError -> return []
+        Left _ -> fail "TempError"
+        Right rc -> return rc
+    let rr = getSPFRR rc
+    checkExistence rr (if nested then "PermError" else "None")
     let jrs = parseSPF rr
     checkSyntax jrs "PermError"
     let is = filterSPFWithIP ip (fromJust jrs)
-    return $ map (toSpfSeq resolver dom ip) is
+    return $ map (toSpfSeq ctx dom ip) is
   where
-    getSPFRR (Left _) = error "getSPRRR"
-    getSPFRR (Right rc)
-        | null ts = ""
-        | otherwise = head ts
-      where
-        ts = filter ("v=spf1" `BS.isPrefixOf`) rc
+    getSPFRR rc = case filter ("v=spf1" `BS.isPrefixOf`) rc of
+        [] -> ""
+        t : _ -> t
     checkSyntax rs estr = when (isNothing rs) (fail estr)
     checkExistence rr estr = when (BS.null rr) (fail estr)
 
@@ -50,43 +110,47 @@
 
 ----------------------------------------------------------------
 
-toSpfSeq :: Resolver -> Domain -> IP -> SPF -> IO SpfSeq
+toSpfSeq :: SpfCtx -> Domain -> IP -> SPF -> IO SpfSeq
 toSpfSeq _ _ _ (SPF_IPv4Range q ipr) = return $ SS_IPv4Range q ipr
 toSpfSeq _ _ _ (SPF_IPv6Range q ipr) = return $ SS_IPv6Range q ipr
 toSpfSeq _ _ _ (SPF_All q) = return $ SS_All q
-toSpfSeq r _ ip (SPF_Include q dom) = SS_IF_Pass q <$> resolveSPF r dom ip
-toSpfSeq r _ ip (SPF_Redirect dom) = SS_SpfSeq <$> resolveSPF r dom ip
-toSpfSeq r dom (IPv4 _) (SPF_MX q Nothing (l4, _)) =
-    lookupAviaMX r dom >>= doit4 q l4
-toSpfSeq r dom (IPv6 _) (SPF_MX q Nothing (_, l6)) =
-    lookupAAAAviaMX r dom >>= doit6 q l6
-toSpfSeq r _ (IPv4 _) (SPF_MX q (Just dom) (l4, _)) =
-    lookupAviaMX r dom >>= doit4 q l4
-toSpfSeq r _ (IPv6 _) (SPF_MX q (Just dom) (_, l6)) =
-    lookupAAAAviaMX r dom >>= doit6 q l6
-toSpfSeq r dom (IPv4 _) (SPF_Address q Nothing (l4, _)) =
-    lookupA r dom >>= doit4 q l4
-toSpfSeq r dom (IPv6 _) (SPF_Address q Nothing (_, l6)) =
-    lookupAAAA r dom >>= doit6 q l6
-toSpfSeq r _ (IPv4 _) (SPF_Address q (Just dom) (l4, _)) =
-    lookupA r dom >>= doit4 q l4
-toSpfSeq r _ (IPv6 _) (SPF_Address q (Just dom) (_, l6)) =
-    lookupAAAA r dom >>= doit6 q l6
-
-doit4 :: Qualifier -> Int -> Either DNSError [IPv4] -> IO SpfSeq
-doit4 _ _ (Left _) = fail "TempError"
-doit4 q l4 (Right is) = return $ SS_IPv4Ranges q $ map (mkr l4) is
+toSpfSeq ctx _ ip (SPF_Include q dom) = do
+    charge ctx
+    SS_IF_Pass q <$> resolveSPF ctx True dom ip
+toSpfSeq ctx _ ip (SPF_Redirect dom) = do
+    charge ctx
+    SS_SpfSeq <$> resolveSPF ctx True dom ip
+toSpfSeq ctx dom0 ip (SPF_MX q mdom (l4, l6)) = do
+    charge ctx
+    mxs <- spfLookupMX lk dom >>= checkVoid ctx
+    when (length mxs > mxLimit) $ fail "PermError"
+    let hosts = map fst mxs
+    case ip of
+        IPv4 _ ->
+            SS_IPv4Ranges q . ranges l4 . concat <$> mapM (lookupHost (spfLookupA lk)) hosts
+        IPv6 _ ->
+            SS_IPv6Ranges q . ranges l6 . concat
+                <$> mapM (lookupHost (spfLookupAAAA lk)) hosts
   where
-    mkr = flip makeAddrRange
-
-doit6 :: Qualifier -> Int -> Either DNSError [IPv6] -> IO SpfSeq
-doit6 _ _ (Left _) = fail "TempError"
-doit6 q l6 (Right is) = return $ SS_IPv6Ranges q $ map (mkr l6) is
+    lk = ctxLookup ctx
+    dom = fromMaybe dom0 mdom
+toSpfSeq ctx dom0 ip (SPF_Address q mdom (l4, l6)) = do
+    charge ctx
+    case ip of
+        IPv4 _ -> SS_IPv4Ranges q . ranges l4 <$> (spfLookupA lk dom >>= checkVoid ctx)
+        IPv6 _ -> SS_IPv6Ranges q . ranges l6 <$> (spfLookupAAAA lk dom >>= checkVoid ctx)
   where
-    mkr = flip makeAddrRange
+    lk = ctxLookup ctx
+    dom = fromMaybe dom0 mdom
 
-----------------------------------------------------------------
+ranges :: Addr a => Int -> [a] -> [AddrRange a]
+ranges len = map (`makeAddrRange` len)
 
-checkDNS :: Either DNSError a -> String -> IO ()
-checkDNS (Right _) _ = return ()
-checkDNS (Left _) estr = fail estr
+-- | Lookups for MX hosts are not counted as void lookups.
+lookupHost :: (Domain -> IO (Either DNSError [a])) -> Domain -> IO [a]
+lookupHost f d = do
+    r <- f d
+    case r of
+        Left NameError -> return []
+        Left _ -> fail "TempError"
+        Right xs -> return xs
diff --git a/domain-auth.cabal b/domain-auth.cabal
--- a/domain-auth.cabal
+++ b/domain-auth.cabal
@@ -1,6 +1,6 @@
 cabal-version: >=1.10
 name:          domain-auth
-version:       0.2.4
+version:       0.2.5
 license:       BSD3
 license-file:  LICENSE
 maintainer:    Kazu Yamamoto <kazu@iij.ad.jp>
@@ -12,6 +12,7 @@
 
 category:      Network
 build-type:    Simple
+extra-source-files: ChangeLog.md
 
 source-repository head
     type:     git
@@ -55,15 +56,56 @@
     build-depends:
         base >=4 && <5,
         attoparsec,
-        asn1-encoding,
-        asn1-types,
         bytestring,
         containers,
         crypton,
-        crypton-x509,
+        crypton-asn1-encoding >=0.10 && <0.11,
+        crypton-asn1-types >=0.4.1 && <0.5,
+        crypton-x509 >=1.8,
         dns >=1.0,
         iproute,
-        memory,
         network,
         pretty-simple,
+        word8
+
+test-suite spec
+    type:             exitcode-stdio-1.0
+    main-is:          Spec.hs
+    hs-source-dirs:   test .
+    other-modules:
+        Network.DomainAuth.Utils
+        Network.DomainAuth.Mail
+        Network.DomainAuth.Mail.Mail
+        Network.DomainAuth.Mail.Parser
+        Network.DomainAuth.Mail.Types
+        Network.DomainAuth.Mail.XMail
+        Network.DomainAuth.DK.Parser
+        Network.DomainAuth.DK.Types
+        Network.DomainAuth.DK.Verify
+        Network.DomainAuth.DKIM.Btag
+        Network.DomainAuth.DKIM.Parser
+        Network.DomainAuth.DKIM.Types
+        Network.DomainAuth.DKIM.Verify
+        Network.DomainAuth.Pubkey.Base64
+        Network.DomainAuth.Pubkey.RSAPub
+        Network.DomainAuth.SPF.Eval
+        Network.DomainAuth.SPF.Parser
+        Network.DomainAuth.SPF.Resolver
+        Network.DomainAuth.SPF.Types
+        Network.DomainAuth.Types
+
+    default-language: Haskell2010
+    ghc-options:      -Wall
+    build-depends:
+        base >=4 && <5,
+        attoparsec,
+        bytestring,
+        containers,
+        crypton,
+        crypton-asn1-encoding,
+        crypton-asn1-types,
+        crypton-x509,
+        dns,
+        hspec,
+        iproute,
         word8
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,217 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
+
+module Main where
+
+import qualified Crypto.Hash as H
+import qualified Crypto.PubKey.RSA as RSA
+import qualified Crypto.PubKey.RSA.PKCS15 as PK
+import Data.Bits (shiftL, shiftR, (.&.), (.|.))
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Builder as BB
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as BL
+import Data.Char (digitToInt)
+import Data.IORef
+import Data.IP
+import Data.Maybe (isJust, isNothing)
+import Data.Word (Word32, Word8)
+import Network.DNS (DNSError (..), Domain)
+import Test.Hspec
+
+import Network.DomainAuth.DK.Parser
+import Network.DomainAuth.DKIM.Parser
+import Network.DomainAuth.DKIM.Types
+import Network.DomainAuth.DKIM.Verify
+import Network.DomainAuth.Mail
+import qualified Network.DomainAuth.Pubkey.Base64 as B64
+import Network.DomainAuth.Pubkey.RSAPub
+import Network.DomainAuth.SPF.Eval
+import Network.DomainAuth.SPF.Parser
+import Network.DomainAuth.SPF.Resolver
+import Network.DomainAuth.Types
+
+main :: IO ()
+main = do
+    (pub, priv) <- RSA.generate 128 65537
+    hspec $ do
+        parserSpec
+        dkimSpec pub priv
+        spfSpec
+
+----------------------------------------------------------------
+
+parserSpec :: Spec
+parserSpec = do
+    describe "parseDKIM" $ do
+        it "rejects unknown algorithms" $
+            parseDKIM (sig "a=ed25519-sha256; c=relaxed") `shouldBe` Nothing
+        it "rejects unknown canonicalization" $
+            parseDKIM (sig "a=rsa-sha256; c=foo") `shouldBe` Nothing
+        it "accepts c=simple" $
+            dkimBodyCanon <$> parseDKIM (sig "a=rsa-sha256; c=simple")
+                `shouldBe` Just DKIM_SIMPLE
+        it "rejects duplicated tags" $
+            parseDKIM (sig "a=rsa-sha256; c=relaxed; d=evil.example")
+                `shouldBe` Nothing
+        it "does not crash on empty tags" $
+            parseDKIM ("=x;" <> sig "a=rsa-sha256") `shouldSatisfy` isJust
+    describe "parseDK" $ do
+        it "does not crash on empty tags" $
+            parseDK "=x; a=rsa-sha1; c=simple; d=x; s=y; b=AAAA"
+                `shouldSatisfy` isJust
+        it "rejects unknown algorithms" $
+            parseDK "a=rsa-sha256; c=simple; d=x; s=y; b=AAAA" `shouldBe` Nothing
+        it "rejects unknown canonicalization" $
+            parseDK "a=rsa-sha1; c=foo; d=x; s=y; b=AAAA" `shouldBe` Nothing
+    describe "Base64.decode" $ do
+        it "decodes with white spaces" $
+            B64.decode "aGVs bG8=" `shouldBe` Just "hello"
+        it "rejects bad length" $ B64.decode "abc" `shouldBe` Nothing
+        it "rejects bad characters" $ B64.decode "aGV*bG8=" `shouldBe` Nothing
+        it "rejects misplaced padding" $ B64.decode "=AAA" `shouldBe` Nothing
+    describe "decodeRSAPublicyKey" $
+        it "rejects malformed keys" $ do
+            decodeRSAPublicyKey "abc" `shouldSatisfy` isNothing
+            decodeRSAPublicyKey "!!!!" `shouldSatisfy` isNothing
+            decodeRSAPublicyKey "AAAA" `shouldSatisfy` isNothing
+    describe "parseSPF" $
+        it "does not accept out-of-range masks" $ do
+            show (parseSPF "v=spf1 a/99 -all") `shouldNotContain` "99"
+            show (parseSPF "v=spf1 mx//999 -all") `shouldNotContain` "999"
+  where
+    sig x = "v=1; " <> x <> "; d=example.com; s=sel; h=from; bh=AAAA; b=AAAA"
+
+----------------------------------------------------------------
+
+dkimSpec :: RSA.PublicKey -> RSA.PrivateKey -> Spec
+dkimSpec pub priv = describe "verifyDKIM" $ do
+    it "verifies a signed mail" $
+        verifyDKIM (mkMail $ hdrs sigv) dkim pub `shouldBe` True
+    it "rejects a From added below the signature" $
+        verifyDKIM (mkMail $ hdrs sigv ++ [evil]) dkim pub `shouldBe` False
+    it "rejects a From added above the signature" $
+        verifyDKIM (mkMail $ evil : hdrs sigv) dkim pub `shouldBe` False
+    it "returns False if the signature field is missing" $
+        verifyDKIM (mkMail $ drop 1 $ hdrs sigv) dkim pub `shouldBe` False
+    it "selects fields bottom-up from the entire header" $ do
+        let v = sigTmpl bhash "AAAA"
+            Just d = parseDKIM v
+            m = mkMail ["From: a1", "DKIM-Signature: " <> v, "From: a2", "Subject:  s "]
+        toStrict' <$> prepareDKIM d m
+            `shouldBe` Just ("from:a2\r\nfrom:a1\r\nsubject:s\r\ndkim-signature:" <> sigTmpl bhash "")
+    it "returns False for malformed b=" $ do
+        let v = sigTmpl bhash "abc"
+            Just d = parseDKIM v
+        verifyDKIM (mkMail $ hdrs v) d pub `shouldBe` False
+    it "returns False for malformed bh=" $ do
+        let v = sigTmpl "=AAA" b64
+            Just d = parseDKIM v
+        verifyDKIM (mkMail $ hdrs v) d pub `shouldBe` False
+  where
+    body = "hello\r\n"
+    bhash = encode64 $ fromHex $ show (H.hashWith H.SHA256 (body :: BS8.ByteString))
+    evil = "From: mallory@evil.example"
+    hdrs v = ["DKIM-Signature: " <> v, "From: alice@example.com", "Subject: hi"]
+    mkMail hs = getMail (BS8.intercalate "\r\n" hs <> "\r\n\r\n" <> body)
+    Just dkim0 = parseDKIM (sigTmpl bhash "AAAA")
+    Just toSign' = prepareDKIM dkim0 (mkMail $ hdrs $ sigTmpl bhash "AAAA")
+    toSign = toStrict' toSign'
+    Right s = PK.sign Nothing (Just H.SHA256) priv toSign
+    b64 = encode64 s
+    sigv = sigTmpl bhash b64
+    Just dkim = parseDKIM sigv
+
+sigTmpl :: BS8.ByteString -> BS8.ByteString -> BS8.ByteString
+sigTmpl bh b =
+    "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=sel; h=from:from:subject; bh="
+        <> bh
+        <> "; b="
+        <> b
+
+toStrict' :: BB.Builder -> BS8.ByteString
+toStrict' = BL.toStrict . BB.toLazyByteString
+
+fromHex :: String -> BS8.ByteString
+fromHex = BS.pack . go
+  where
+    go (a : b : xs) = fromIntegral (digitToInt a * 16 + digitToInt b) : go xs
+    go _ = []
+
+encode64 :: BS8.ByteString -> BS8.ByteString
+encode64 = BS8.pack . go . BS.unpack
+  where
+    tbl = ['A' .. 'Z'] ++ ['a' .. 'z'] ++ ['0' .. '9'] ++ "+/"
+    ch n = tbl !! fromIntegral (n .&. 63)
+    go (a : b : c : xs) =
+        let n = w a `shiftL` 16 .|. w b `shiftL` 8 .|. w c
+         in map (ch . (n `shiftR`)) [18, 12, 6, 0] ++ go xs
+    go [a, b] =
+        let n = w a `shiftL` 16 .|. w b `shiftL` 8
+         in map (ch . (n `shiftR`)) [18, 12, 6] ++ "="
+    go [a] = map (ch . ((w a `shiftL` 16) `shiftR`)) [18, 12] ++ "=="
+    go [] = []
+    w :: Word8 -> Word32
+    w = fromIntegral
+
+----------------------------------------------------------------
+
+-- | A fake DNS: "aN" has an A record, "mxN" has N MX records,
+--   others are NXDOMAIN. Returns the number of queries.
+fakeSPF :: [(Domain, BS8.ByteString)] -> Domain -> IO (DAResult, Int)
+fakeSPF txts dom = do
+    cnt <- newIORef (0 :: Int)
+    let tick = modifyIORef' cnt (+ 1)
+        lk =
+            SpfLookup
+                { spfLookupTXT = \d -> tick >> return (maybe (Left NameError) (Right . (: [])) $ lookup d txts)
+                , spfLookupA = \d ->
+                    tick >> return (if "a" `BS8.isPrefixOf` d then Right [ip1] else Left NameError)
+                , spfLookupAAAA = \_ -> tick >> return (Left NameError)
+                , spfLookupMX = \d -> tick >> return (mxs d)
+                }
+    r <- runSPFWith defaultLimit lk dom (IPv4 "192.0.2.1")
+    n <- readIORef cnt
+    return (r, n)
+  where
+    ip1 = "198.51.100.1"
+    mxs d = case BS8.stripPrefix "mx" d >>= BS8.readInt of
+        Just (n, _) -> Right [(BS8.pack ("a" ++ show i), 10) | i <- [1 .. n]]
+        Nothing -> Left NameError
+
+spfSpec :: Spec
+spfSpec = describe "runSPF" $ do
+    it "allows 10 DNS-querying terms" $
+        fst <$> fakeSPF [("example.com", terms 10 "a:a")] "example.com"
+            `shouldReturn` DAHardFail
+    it "rejects more than 10 a terms" $ do
+        (r, n) <- fakeSPF [("example.com", terms 1000 "a:a")] "example.com"
+        r `shouldBe` DAPermError
+        n `shouldSatisfy` (<= 11)
+    it "counts mx, include and redirect" $ do
+        let txts =
+                [ ("example.com", "v=spf1 mx:mx1 include:inc.example redirect=red.example")
+                , ("inc.example", terms 4 "a:a")
+                , ("red.example", terms 4 "mx:mx")
+                ]
+        fst <$> fakeSPF txts "example.com" `shouldReturn` DAPermError
+    it "rejects more than 10 MX records" $ do
+        fst <$> fakeSPF [("example.com", "v=spf1 mx:mx10 -all")] "example.com"
+            `shouldReturn` DAHardFail
+        fst <$> fakeSPF [("example.com", "v=spf1 mx:mx11 -all")] "example.com"
+            `shouldReturn` DAPermError
+    it "rejects more than 2 void lookups" $ do
+        fst <$> fakeSPF [("example.com", terms 2 "a:x")] "example.com"
+            `shouldReturn` DAHardFail
+        fst <$> fakeSPF [("example.com", terms 3 "a:x")] "example.com"
+            `shouldReturn` DAPermError
+    it "returns permerror for include without SPF record" $
+        fst
+            <$> fakeSPF [("example.com", "v=spf1 include:none.example -all")] "example.com"
+            `shouldReturn` DAPermError
+  where
+    terms :: Int -> BS8.ByteString -> BS8.ByteString
+    terms n t =
+        "v=spf1 "
+            <> BS8.unwords [t <> BS8.pack (show i) | i <- [1 .. n]]
+            <> " -all"
