diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,5 @@
+# Changelog
+
+- 0.1.0 (2025-02-24)
+  * Initial release, supporting a generic RFC2898 PBKDF2 function.
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2025 Jared Tobin
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/bench/Main.hs b/bench/Main.hs
new file mode 100644
--- /dev/null
+++ b/bench/Main.hs
@@ -0,0 +1,27 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Criterion.Main
+import qualified Crypto.KDF.PBKDF as KDF
+import qualified Crypto.Hash.SHA256 as SHA256
+import qualified Crypto.Hash.SHA512 as SHA512
+
+main :: IO ()
+main = defaultMain [
+    suite
+  ]
+
+suite :: Benchmark
+suite =
+  bgroup "ppad-pbkdf" [
+    bgroup "PBKDF-SHA256" [
+      bench "derive (outlen 32)" $
+        nf (KDF.derive SHA256.hmac "muh password" "muh salt" 32) 64
+    ]
+  , bgroup "PBKDF-SHA512" [
+      bench "derive (outlen 32)" $
+        nf (KDF.derive SHA512.hmac "muh password" "muh salt" 32) 64
+    ]
+  ]
+
diff --git a/lib/Crypto/KDF/PBKDF.hs b/lib/Crypto/KDF/PBKDF.hs
new file mode 100644
--- /dev/null
+++ b/lib/Crypto/KDF/PBKDF.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE BinaryLiterals #-}
+{-# LANGUAGE NumericUnderscores #-}
+
+-- |
+-- Module: Crypto.KDF.PBKDF
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- A pure PBKDF2 (password-based key derivation
+-- function) implementation, as specified by
+-- [RFC2898](https://datatracker.ietf.org/doc/html/rfc2898).
+
+module Crypto.KDF.PBKDF where
+
+import Data.Bits ((.>>.), (.&.))
+import qualified Data.Bits as B
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Builder as BSB
+import Data.Word (Word32, Word64)
+
+-- NB following synonym really only exists to make haddocks more
+--    readable
+
+-- | A HMAC function, taking a key as the first argument and the input
+--   value as the second, producing a MAC digest.
+--
+--   (RFC2898 specifically requires a "pseudorandom function" of two
+--   arguments, but in practice this will usually be a HMAC function.)
+--
+--   >>> import qualified Crypto.Hash.SHA256 as SHA256
+--   >>> :t SHA256.hmac
+--   SHA256.hmac :: BS.ByteString -> BS.ByteString -> BS.ByteString
+--   >>> SHA256.hmac "my HMAC key" "my HMAC input"
+--   <256-bit MAC>
+type HMAC = BS.ByteString -> BS.ByteString -> BS.ByteString
+
+fi :: (Integral a, Num b) => a -> b
+fi = fromIntegral
+{-# INLINE fi #-}
+
+-- serialize a 32-bit word, MSB first
+ser32 :: Word32 -> BS.ByteString
+ser32 w =
+  let !mask = 0b00000000_00000000_00000000_11111111
+      !w0 = fi (w .>>. 24) .&. mask
+      !w1 = fi (w .>>. 16) .&. mask
+      !w2 = fi (w .>>. 08) .&. mask
+      !w3 = fi w .&. mask
+  in  BS.cons w0 (BS.cons w1 (BS.cons w2 (BS.singleton w3)))
+{-# INLINE ser32 #-}
+
+-- bytewise xor on bytestrings
+xor :: BS.ByteString -> BS.ByteString -> BS.ByteString
+xor = BS.packZipWith B.xor
+{-# INLINE xor #-}
+
+-- | Derive a key from a secret via the PBKDF2 key derivation function.
+--
+--   >>> :set -XOverloadedStrings
+--   >>> import qualified Crypto.Hash.SHA256 as SHA256
+--   >>> import qualified Data.ByteString as BS
+--   >>> import qualified Data.ByteString.Base16 as B16
+--   >>> BS.take 16 (B16.encode (derive SHA256.hmac "passwd" "salt" 1 64))
+--   "55ac046e56e3089f"
+derive
+  :: HMAC          -- ^ pseudo-random function (HMAC)
+  -> BS.ByteString -- ^ password
+  -> BS.ByteString -- ^ salt
+  -> Word64        -- ^ iteration count
+  -> Word32        -- ^ bytelength of derived key (max 0xffff_ffff * hlen)
+  -> BS.ByteString -- ^ derived key
+derive prf p s c dklen
+    | dklen > 0xffff_ffff * fi hlen =      -- 2 ^ 32 - 1
+        error "ppad-pbkdf (derive): derived key too long"
+    | otherwise =
+        loop mempty 1
+  where
+    !hlen = BS.length (prf mempty mempty)
+    !l = ceiling (fi dklen / fi hlen :: Double) :: Word32
+    !r = fi (dklen - (l - 1) * fi hlen)
+
+    f !i =
+      let go j !acc !las
+            | j == c = acc
+            | otherwise =
+                let u = prf p las
+                    nacc = acc `xor` u
+                in  go (j + 1) nacc u
+
+          org = prf p (s <> ser32 i)
+
+      in  go 1 org org
+    {-# INLINE f #-}
+
+    loop !acc !i
+      | i == l =
+          let t = f i
+              fin = BS.take r t
+          in  BS.toStrict . BSB.toLazyByteString $
+                acc <> BSB.byteString fin
+      | otherwise =
+          let t = f i
+              nacc = acc <> BSB.byteString t
+          in  loop nacc (i + 1)
+
diff --git a/ppad-pbkdf.cabal b/ppad-pbkdf.cabal
new file mode 100644
--- /dev/null
+++ b/ppad-pbkdf.cabal
@@ -0,0 +1,71 @@
+cabal-version:      3.0
+name:               ppad-pbkdf
+version:            0.1.0
+synopsis:           A password-based key derivation function
+license:            MIT
+license-file:       LICENSE
+author:             Jared Tobin
+maintainer:         jared@ppad.tech
+category:           Cryptography
+build-type:         Simple
+tested-with:        GHC == { 9.8.1 }
+extra-doc-files:    CHANGELOG
+description:
+  A pure implementation of the password-based key derivation function PBKDF2,
+  per RFC 2898.
+
+source-repository head
+  type:     git
+  location: git.ppad.tech/pbkdf.git
+
+library
+  default-language: Haskell2010
+  hs-source-dirs:   lib
+  ghc-options:
+      -Wall
+  exposed-modules:
+      Crypto.KDF.PBKDF
+  build-depends:
+      base >= 4.9 && < 5
+    , bytestring >= 0.9 && < 0.13
+
+test-suite pbkdf-tests
+  type:                exitcode-stdio-1.0
+  default-language:    Haskell2010
+  hs-source-dirs:      test
+  main-is:             Main.hs
+  other-modules:
+    Wycheproof
+
+  ghc-options:
+    -rtsopts -Wall -O2
+
+  build-depends:
+      aeson
+    , base
+    , bytestring
+    , ppad-base16
+    , ppad-pbkdf
+    , ppad-sha256
+    , ppad-sha512
+    , tasty
+    , tasty-hunit
+    , text
+
+benchmark pbkdf-bench
+  type:                exitcode-stdio-1.0
+  default-language:    Haskell2010
+  hs-source-dirs:      bench
+  main-is:             Main.hs
+
+  ghc-options:
+    -rtsopts -O2 -Wall
+
+  build-depends:
+      base
+    , bytestring
+    , criterion
+    , ppad-pbkdf
+    , ppad-sha256
+    , ppad-sha512
+
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,66 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Main where
+
+import Control.Exception
+import qualified Crypto.Hash.SHA256 as SHA256
+import qualified Crypto.Hash.SHA512 as SHA512
+import qualified Crypto.KDF.PBKDF as KDF
+import qualified Data.ByteString as BS
+import qualified Data.Aeson as A
+import qualified Data.Text.IO as TIO
+import Test.Tasty
+import Test.Tasty.HUnit
+import qualified Wycheproof as W
+
+main :: IO ()
+main = do
+  wycheproof_hmacsha256 <- TIO.readFile "etc/pbkdf2_hmacsha256_test.json"
+  wycheproof_hmacsha512 <- TIO.readFile "etc/pbkdf2_hmacsha512_test.json"
+  let wycheproofs = do
+        a <- A.decodeStrictText wycheproof_hmacsha256 :: Maybe W.Wycheproof
+        b <- A.decodeStrictText wycheproof_hmacsha512 :: Maybe W.Wycheproof
+        pure (a, b)
+  case wycheproofs of
+    Nothing -> error "couldn't parse wycheproof vectors"
+    Just (w256, w512) -> defaultMain $ testGroup "ppad-pbkdf" [
+        wycheproof_tests SHA256 w256
+      , wycheproof_tests SHA512 w512
+      ]
+
+data Hash = SHA256 | SHA512
+  deriving Show
+
+wycheproof_tests :: Hash -> W.Wycheproof -> TestTree
+wycheproof_tests h W.Wycheproof {..} =
+  testGroup ("wycheproof vectors (pbkdf, " <> show h <> ")") $
+    fmap (execute_group h) wp_testGroups
+
+execute_group :: Hash -> W.PbkdfTestGroup -> TestTree
+execute_group h W.PbkdfTestGroup {..} =
+  testGroup mempty (fmap (execute h) ptg_tests)
+
+execute :: Hash -> W.PbkdfTest -> TestTree
+execute h W.PbkdfTest {..} = testCase t_msg $ do
+    let pas = pt_password
+        sal = pt_salt
+        cow = pt_iterationCount
+        siz = pt_dkLen
+        pec = pt_dk
+    if   pt_result == "invalid"
+    then do
+      out <- try (pure $! KDF.derive hmac pas sal cow siz)
+               :: IO (Either ErrorCall BS.ByteString)
+      case out of
+        Left _  -> assertBool "invalid" True
+        Right o -> assertBool "invalid" (pec /= o)
+    else do
+      let out = KDF.derive hmac pas sal cow siz
+      assertEqual mempty pec out
+  where
+    hmac = case h of
+      SHA256 -> SHA256.hmac
+      SHA512 -> SHA512.hmac
+    t_msg = "test " <> show pt_tcId -- XX embellish
+
diff --git a/test/Wycheproof.hs b/test/Wycheproof.hs
new file mode 100644
--- /dev/null
+++ b/test/Wycheproof.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Wycheproof (
+    Wycheproof(..)
+  , PbkdfTestGroup(..)
+  , PbkdfTest(..)
+  ) where
+
+import Data.Aeson ((.:))
+import qualified Data.Aeson as A
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as B16
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import Data.Word (Word32, Word64)
+
+data Wycheproof = Wycheproof {
+    wp_numberOfTests :: !Int
+  , wp_testGroups    :: ![PbkdfTestGroup]
+  } deriving Show
+
+instance A.FromJSON Wycheproof where
+  parseJSON = A.withObject "Wycheproof" $ \m -> Wycheproof
+    <$> m .: "numberOfTests"
+    <*> m .: "testGroups"
+
+data PbkdfTestGroup = PbkdfTestGroup {
+    ptg_type    :: !T.Text
+  , ptg_tests   :: ![PbkdfTest]
+  } deriving Show
+
+instance A.FromJSON PbkdfTestGroup where
+  parseJSON = A.withObject "PbkdfTestGroup" $ \m -> PbkdfTestGroup
+    <$> m .: "type"
+    <*> m .: "tests"
+
+data PbkdfTest = PbkdfTest {
+    pt_tcId           :: !Int
+  , pt_comment        :: !T.Text
+  , pt_password       :: !BS.ByteString
+  , pt_salt           :: !BS.ByteString
+  , pt_iterationCount :: !Word64
+  , pt_dkLen          :: !Word32
+  , pt_dk             :: !BS.ByteString
+  , pt_result         :: !T.Text
+  } deriving Show
+
+decodehex :: T.Text -> BS.ByteString
+decodehex t = case B16.decode (TE.encodeUtf8 t) of
+  Nothing -> error "bang"
+  Just bs -> bs
+
+instance A.FromJSON PbkdfTest where
+  parseJSON = A.withObject "PbkdfTest" $ \m -> PbkdfTest
+    <$> m .: "tcId"
+    <*> m .: "comment"
+    <*> fmap decodehex (m .: "password")
+    <*> fmap decodehex (m .: "salt")
+    <*> m .: "iterationCount"
+    <*> m .: "dkLen"
+    <*> fmap decodehex (m .: "dk")
+    <*> m .: "result"
+
+
