diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,19 @@
 # ChangeLog for hpke
 
+## 0.4.0
+
+* **Breaking**: `nEnc` and `nTag` return `Maybe Int`, with `Nothing` for an
+  identifier they do not know, rather than `0`.
+* `seal` is safe to call from several threads: each call takes its own
+  sequence number, so no two share a nonce.  `open` is serialized.
+* `seal` and `open` throw `MessageLimitReachedError` before the sequence
+  number would wrap, as RFC 9180 section 5.2 requires.
+* AEAD tags are compared in constant time.
+* A ChaCha20-Poly1305 decryption failure is an `OpenError`, not a
+  `SealError`.
+* `Show` of an unknown `KDF_ID` or `KEM_ID` uses the type's name.
+* The lower bound on base moves to 4.9.
+
 ## 0.3.0
 
 * A receiver authenticates a sender by its **public** key.  `setupBaseR`,
diff --git a/Crypto/HPKE.hs b/Crypto/HPKE.hs
--- a/Crypto/HPKE.hs
+++ b/Crypto/HPKE.hs
@@ -51,21 +51,23 @@
 import Crypto.HPKE.Types
 
 -- | Length of "enc", aka sender's public key.
+--   'Nothing' for a KEM not registered in RFC 9180.
 {- FOURMOLU_DISABLE -}
-nEnc :: KEM_ID -> Int
-nEnc DHKEM_P256_HKDF_SHA256   =  65
-nEnc DHKEM_P384_HKDF_SHA384   =  97
-nEnc DHKEM_P521_HKDF_SHA512   = 133
-nEnc DHKEM_X25519_HKDF_SHA256 =  32
-nEnc DHKEM_X448_HKDF_SHA512   =  56
-nEnc _                        =  0
+nEnc :: KEM_ID -> Maybe Int
+nEnc DHKEM_P256_HKDF_SHA256   = Just  65
+nEnc DHKEM_P384_HKDF_SHA384   = Just  97
+nEnc DHKEM_P521_HKDF_SHA512   = Just 133
+nEnc DHKEM_X25519_HKDF_SHA256 = Just  32
+nEnc DHKEM_X448_HKDF_SHA512   = Just  56
+nEnc _                        = Nothing
 {- FOURMOLU_ENABLE -}
 
 -- | Length of AEAD tag.
+--   'Nothing' for an AEAD not registered in RFC 9180.
 {- FOURMOLU_DISABLE -}
-nTag :: AEAD_ID -> Int
-nTag AES_128_GCM      = 16
-nTag AES_256_GCM      = 16
-nTag ChaCha20Poly1305 = 16
-nTag _                =  0
+nTag :: AEAD_ID -> Maybe Int
+nTag AES_128_GCM      = Just 16
+nTag AES_256_GCM      = Just 16
+nTag ChaCha20Poly1305 = Just 16
+nTag _                = Nothing
 {- FOURMOLU_ENABLE -}
diff --git a/Crypto/HPKE/AEAD.hs b/Crypto/HPKE/AEAD.hs
--- a/Crypto/HPKE/AEAD.hs
+++ b/Crypto/HPKE/AEAD.hs
@@ -35,8 +35,9 @@
 
 mkOpenA :: AeadDecrypt -> Int -> p -> Key -> Open
 mkOpenA dec len _ key nonce aad cipher = do
-    (plain, AuthTag tag) <- dec key nonce aad cipher'
-    if tag == convert tag'
+    (plain, tag) <- dec key nonce aad cipher'
+    -- AuthTag's Eq is constant-time
+    if tag == AuthTag (convert tag')
         then Right plain
         else Left $ OpenError "tag mismatch"
   where
@@ -105,7 +106,7 @@
 
 decryptAes128gcm :: AeadDecrypt
 decryptAes128gcm key nonce aad cipher = case initAES key nonce of
-    Nothing -> Left $ OpenError "decrypttAes128gcm"
+    Nothing -> Left $ OpenError "decryptAes128gcm"
     Just st -> Right $ simpleDecrypt (st :: AEAD AES128) aad cipher aes128tagLength
 
 aes128tagLength :: Int
@@ -170,7 +171,7 @@
 decryptChacha20poly1305 key nonce aad cipher =
     case CCP.aeadChacha20poly1305Init key nonce of
         CryptoPassed st -> Right $ simpleDecrypt st aad cipher chacha20poly1305tagLength
-        CryptoFailed _ -> Left $ SealError "decryptChacha20poly1305"
+        CryptoFailed _ -> Left $ OpenError "decryptChacha20poly1305"
 
 chacha20poly1305tagLength :: Int
 chacha20poly1305tagLength = 16
diff --git a/Crypto/HPKE/Context.hs b/Crypto/HPKE/Context.hs
--- a/Crypto/HPKE/Context.hs
+++ b/Crypto/HPKE/Context.hs
@@ -14,10 +14,11 @@
     exportR,
 ) where
 
+import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)
 import qualified Control.Exception as E
 import Data.ByteArray (xor)
 import qualified Data.ByteString as BS
-import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Data.IORef (IORef, atomicModifyIORef', newIORef)
 
 import Crypto.HPKE.Types
 
@@ -33,7 +34,7 @@
 
 -- | Context for receivers.
 data ContextR = ContextR
-    { seqRefR :: IORef Integer
+    { seqVarR :: MVar Integer
     , openR :: Open
     , nonceBaseR :: Nonce
     , expandR :: Info -> Int -> Either HPKEError Key
@@ -43,36 +44,36 @@
 
 -- | Encryption.
 --   This throws 'HPKEError'.
+--   Safe to call from multiple threads: each call gets its own nonce.
 seal :: ContextS -> AAD -> PlainText -> IO CipherText
 seal ContextS{..} aad pt = do
-    seqI <- readIORef seqRefS
-    let len = BS.length nonceBaseS
-        seqBS = i2ospOf_ len seqI :: ByteString
-        nonce = seqBS `xor` nonceBaseS
-        ect = sealS nonce aad pt
-        seqI' = seqI + 1
-    writeIORef seqRefS seqI'
-    case ect of
-        Right ct -> return ct
-        Left err -> E.throwIO err
+    seqI <- atomicModifyIORef' seqRefS $ \i -> (i + 1, i)
+    checkLimit nonceBaseS seqI
+    either E.throwIO return $ sealS (computeNonce nonceBaseS seqI) aad pt
 
 -- | Decryption.
 --   This throws 'HPKEError'.
+--   The sequence number advances only when decryption succeeds.
 open
     :: ContextR -> AAD -> CipherText -> IO PlainText
-open ContextR{..} aad ct = do
-    seqI <- readIORef seqRefR
-    let len = BS.length nonceBaseR
-        seqBS = i2ospOf_ len seqI :: ByteString
-        nonce = seqBS `xor` nonceBaseR
-        ept = openR nonce aad ct
-    case ept of
+open ContextR{..} aad ct = modifyMVar seqVarR $ \seqI -> do
+    checkLimit nonceBaseR seqI
+    case openR (computeNonce nonceBaseR seqI) aad ct of
         Left err -> E.throwIO err
-        Right pt -> do
-            let seqI' = seqI + 1
-            writeIORef seqRefR seqI'
-            return pt
+        Right pt -> return (seqI + 1, pt)
 
+computeNonce :: Nonce -> Integer -> Nonce
+computeNonce nonce_base seqI = seqBS `xor` nonce_base
+  where
+    seqBS = i2ospOf_ (BS.length nonce_base) seqI :: ByteString
+
+-- RFC 9180 section 5.2: the sequence number must not wrap around.
+checkLimit :: Nonce -> Integer -> IO ()
+checkLimit nonce_base seqI
+    | seqI >= 2 ^ (8 * BS.length nonce_base) - 1 =
+        E.throwIO $ MessageLimitReachedError "sequence number overflow"
+    | otherwise = return ()
+
 ----------------------------------------------------------------
 
 -- | Exporting secret.
@@ -118,10 +119,10 @@
     -> (Info -> Int -> Either HPKEError Key)
     -> IO ContextR
 newContextR key nonce_base open' expand = do
-    seqref <- newIORef 0
+    seqvar <- newMVar 0
     return $
         ContextR
-            { seqRefR = seqref
+            { seqVarR = seqvar
             , openR = open' key
             , nonceBaseR = nonce_base
             , expandR = expand
diff --git a/Crypto/HPKE/ID.hs b/Crypto/HPKE/ID.hs
--- a/Crypto/HPKE/ID.hs
+++ b/Crypto/HPKE/ID.hs
@@ -86,7 +86,7 @@
     show HKDF_SHA256 = "HKDF_SHA256"
     show HKDF_SHA384 = "HKDF_SHA384"
     show HKDF_SHA512 = "HKDF_SHA512"
-    show (KDF_ID n)  = "HKDF_ID 0x" ++ printf "%04x" n
+    show (KDF_ID n)  = "KDF_ID 0x" ++ printf "%04x" n
 {- FOURMOLU_ENABLE -}
 
 data KDFHash = forall h. (HashAlgorithm h, KDF h) => KDFHash h
@@ -122,7 +122,7 @@
     show DHKEM_P521_HKDF_SHA512   = "DHKEM(P-521, HKDF-SHA512)"
     show DHKEM_X25519_HKDF_SHA256 = "DHKEM(X25519, HKDF-SHA256)"
     show DHKEM_X448_HKDF_SHA512   = "DHKEM(X448, HKDF-SHA512)"
-    show (KEM_ID n)               = "DHKEM_ID 0x" ++ printf "%04x" n
+    show (KEM_ID n)               = "KEM_ID 0x" ++ printf "%04x" n
 {- FOURMOLU_ENABLE -}
 
 ----------------------------------------------------------------
diff --git a/Crypto/HPKE/KDF.hs b/Crypto/HPKE/KDF.hs
--- a/Crypto/HPKE/KDF.hs
+++ b/Crypto/HPKE/KDF.hs
@@ -86,5 +86,5 @@
     siz = hashDigestSize h
     -- the hash's own digest size, so the length is in range by construction
     shared_secret =
-        either (const "") id $
+        either (error . show) id $
             labeledExpand suite eae_prk "shared_secret" kem_context siz
diff --git a/Crypto/HPKE/KeyPair.hs b/Crypto/HPKE/KeyPair.hs
--- a/Crypto/HPKE/KeyPair.hs
+++ b/Crypto/HPKE/KeyPair.hs
@@ -1,6 +1,9 @@
 {-# LANGUAGE RecordWildCards #-}
 
-module Crypto.HPKE.KeyPair where
+module Crypto.HPKE.KeyPair (
+    genKeyPair,
+    toPublicKey,
+) where
 
 import qualified Control.Exception as E
 import Crypto.KEM (generateKeyPair)
diff --git a/Crypto/HPKE/KeySchedule.hs b/Crypto/HPKE/KeySchedule.hs
--- a/Crypto/HPKE/KeySchedule.hs
+++ b/Crypto/HPKE/KeySchedule.hs
@@ -46,7 +46,7 @@
     -> PSK
     -> PSK_ID
     -> SharedSecret
-    -> Either HPKEError (Key, Nonce, Int, PRK h)
+    -> Either HPKEError (Key, Nonce, PRK h)
 keySchedule h suite nk nn mode info psk psk_id shared_secret = do
     key <- labeledExpand suite secret "key" key_schedule_context nk
     base_nonce <- labeledExpand suite secret "base_nonce" key_schedule_context nn
@@ -54,7 +54,7 @@
         labeledExpand suite secret "exp" key_schedule_context $ hashDigestSize h
     case toPRK exporter_secret of
         Nothing -> Left $ KeyScheduleError "cannot convert to PRK"
-        Just prk -> Right (key, base_nonce, 0, prk)
+        Just prk -> Right (key, base_nonce, prk)
   where
     psk_id_hash = labeledExtract suite "" "psk_id_hash" psk_id :: PRK h
     info_hash = labeledExtract suite "" "info_hash" info :: PRK h
@@ -63,5 +63,3 @@
             :: ByteString
 
     secret = labeledExtract suite (convert shared_secret) "secret" psk :: PRK h
-
-
diff --git a/Crypto/HPKE/Map.hs b/Crypto/HPKE/Map.hs
--- a/Crypto/HPKE/Map.hs
+++ b/Crypto/HPKE/Map.hs
@@ -1,4 +1,3 @@
-
 -- | Which algorithms this package will run, and under which identifiers.
 module Crypto.HPKE.Map (
     HPKEMap (..),
diff --git a/Crypto/HPKE/Setup.hs b/Crypto/HPKE/Setup.hs
--- a/Crypto/HPKE/Setup.hs
+++ b/Crypto/HPKE/Setup.hs
@@ -34,7 +34,7 @@
     -- ^ My ephemeral secret key. Automatically generated if 'Nothing'
     -> Maybe EncodedSecretKey
     -- ^ My secret key for authentication.
-    --   'mode_base' is used if 'Nothing'. 'base_auth' is used, otherwise.
+    --   @mode_base@ is used if 'Nothing'. @mode_auth@ is used, otherwise.
     -> EncodedPublicKey
     -- ^ Peer's public key.
     -> Info
@@ -56,7 +56,7 @@
     -- ^ My secret key
     -> Maybe EncodedPublicKey
     -- ^ The sender's public key, for authentication.
-    --   'mode_base' is used if 'Nothing'. 'mode_auth' is used, otherwise.
+    --   @mode_base@ is used if 'Nothing'. @mode_auth@ is used, otherwise.
     -> EncodedPublicKey
     -- ^ The encapsulated key, @enc@.
     -> Info
@@ -80,15 +80,15 @@
     -- ^ My ephemeral secret key. Automatically generated if 'Nothing'
     -> Maybe EncodedSecretKey
     -- ^ My secret key for authentication.
-    --   'mode_base' is used if 'Nothing'. 'base_auth' is used, otherwise.
+    --   @mode_psk@ is used if 'Nothing'. @mode_auth_psk@ is used, otherwise.
     -> EncodedPublicKey
     -- ^ Peer's public key.
     -> Info
     -> PSK
     -> PSK_ID
     -> IO (EncodedPublicKey, ContextS)
-setupPSKS kem_id kdf_id aead_id skRm mskSm =
-    setupS defaultHPKEMap mode kem_id kdf_id aead_id skRm mskSm
+setupPSKS kem_id kdf_id aead_id mskEm mskSm =
+    setupS defaultHPKEMap mode kem_id kdf_id aead_id mskEm mskSm
   where
     mode = case mskSm of
         Nothing -> ModePsk
@@ -104,7 +104,7 @@
     -- ^ My secret key
     -> Maybe EncodedPublicKey
     -- ^ The sender's public key, for authentication.
-    --   'mode_psk' is used if 'Nothing'. 'mode_auth_psk' is used, otherwise.
+    --   @mode_psk@ is used if 'Nothing'. @mode_auth_psk@ is used, otherwise.
     -> EncodedPublicKey
     -- ^ The encapsulated key, @enc@.
     -> Info
@@ -129,8 +129,7 @@
     -> Maybe EncodedSecretKey
     -- ^ My ephemeral secret key. Automatically generated if 'Nothing'
     -> Maybe EncodedSecretKey
-    -- ^ My secret key for authentication.
-    --   'mode_base' is used if 'Nothing'. 'base_auth' is used, otherwise.
+    -- ^ My secret key, for the authenticated modes.
     -> EncodedPublicKey
     -- ^ Peer's public key.
     -> Info
@@ -146,7 +145,7 @@
             let (nk, nn, seal', _) = aeadParams c
                 suite' = suiteHPKE kem_id kdf_id aead_id
                 keys = keySchedule h' suite' nk nn mode info psk psk_id shared_secret
-            throwOnError keys $ \(key, nonce, _, prk) -> do
+            throwOnError keys $ \(key, nonce, prk) -> do
                 let expand' = labeledExpand suite' prk "sec"
                 ctx <- newContextS key nonce seal' expand'
                 return (enc, ctx)
@@ -175,7 +174,7 @@
             let (nk, nn, _, open') = aeadParams c
                 suite' = suiteHPKE kem_id kdf_id aead_id
                 keys = keySchedule h' suite' nk nn mode info psk psk_id shared_secret
-            throwOnError keys $ \(key, nonce, _, prk) -> do
+            throwOnError keys $ \(key, nonce, prk) -> do
                 let expand' = labeledExpand suite' prk "sec"
                 newContextR key nonce open' expand'
 
diff --git a/Crypto/HPKE/Types.hs b/Crypto/HPKE/Types.hs
--- a/Crypto/HPKE/Types.hs
+++ b/Crypto/HPKE/Types.hs
@@ -22,7 +22,6 @@
     PSK_ID,
     Encap,
     Decap,
-    ExporterSecret,
     -- rexport
     CryptoFailable (..),
     SharedSecret (..),
@@ -154,7 +153,3 @@
 lookupE k table = case lookup k table of
     Nothing -> Left $ Unsupported $ show k
     Just v -> Right v
-
-----------------------------------------------------------------
-
-type ExporterSecret = ByteString
diff --git a/hpke.cabal b/hpke.cabal
--- a/hpke.cabal
+++ b/hpke.cabal
@@ -1,6 +1,6 @@
 cabal-version:      >=1.10
 name:               hpke
-version:            0.3.0
+version:            0.4.0
 license:            BSD3
 license-file:       LICENSE
 maintainer:         kazu@iij.ad.jp
@@ -31,11 +31,11 @@
     default-language: Haskell2010
     ghc-options:      -Wall
     build-depends:
-        base >=4.7 && <5,
-        base16-bytestring,
+        base >=4.9 && <5,
+        base16-bytestring >=1.0 && <1.1,
         bytestring,
         crypton >=2.1.8 && <2.3,
-        ram
+        ram >=0.19 && <0.23
 
     default-extensions: Strict StrictData
 
@@ -59,10 +59,9 @@
     ghc-options:        -Wall -threaded -rtsopts
     build-depends:
         base >=4.9 && <5,
-        QuickCheck,
         bytestring,
-        base16-bytestring,
-        crypton,
+        base16-bytestring >=1.0 && <1.1,
+        crypton >=2.1.8 && <2.3,
         hpke,
         hspec,
-        ram
+        ram >=0.19 && <0.23
diff --git a/test/Test.hs b/test/Test.hs
--- a/test/Test.hs
+++ b/test/Test.hs
@@ -2,7 +2,9 @@
 
 module Test (runTest) where
 
+import Data.Bits (xor)
 import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
 import qualified Data.ByteString.Base16 as B16
 import Test.Hspec
 
@@ -62,6 +64,9 @@
     pt0 `shouldBe` pt
     ct1' <- seal ctxS aad1 pt
     ct1' `shouldBe` ct1
+    -- a forged tag is refused, and the sequence number does not advance
+    open ctxR aad1 (BS.init ct1 <> BS.singleton (BS.last ct1 `xor` 1))
+        `shouldThrow` isOpenError
     pt1 <- open ctxR aad1 ct1
     pt1 `shouldBe` pt
 
@@ -98,6 +103,10 @@
     sec0 = B16.decodeLenient _sec0
     sec1 = B16.decodeLenient _sec1
     sec2 = B16.decodeLenient _sec2
+
+isOpenError :: HPKEError -> Bool
+isOpenError (OpenError _) = True
+isOpenError _ = False
 
 aad0 :: AAD
 aad0 = "\x43\x6f\x75\x6e\x74\x2d\x30"
