diff --git a/CHANGELOG b/CHANGELOG
new file mode 100644
--- /dev/null
+++ b/CHANGELOG
@@ -0,0 +1,5 @@
+# Changelog
+
+- 0.1.0 (2025-03-09)
+  * Initial release, supporting the Poly1305 MAC.
+
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,30 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Criterion.Main
+import qualified Crypto.MAC.Poly1305 as Poly1305
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as B16
+import Data.Maybe (fromJust)
+
+main :: IO ()
+main = defaultMain [
+    suite
+  ]
+
+msg :: BS.ByteString
+msg = fromJust . B16.decode $
+  "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e"
+
+key :: BS.ByteString
+key = fromJust . B16.decode $
+  "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+
+suite :: Benchmark
+suite =
+  bgroup "ppad-poly1305" [
+    bench "mac" $ nf (Poly1305.mac key) msg
+  ]
+
diff --git a/lib/Crypto/MAC/Poly1305.hs b/lib/Crypto/MAC/Poly1305.hs
new file mode 100644
--- /dev/null
+++ b/lib/Crypto/MAC/Poly1305.hs
@@ -0,0 +1,80 @@
+{-# OPTIONS_HADDOCK prune #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE ViewPatterns #-}
+
+-- |
+-- Module: Crypto.MAC.Poly1305
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- A pure Poly1305 MAC implementation, as specified by
+-- [RFC 8439](https://datatracker.ietf.org/doc/html/rfc8439).
+
+module Crypto.MAC.Poly1305 (
+    -- * Poly1305 message authentication code
+    mac
+  ) where
+
+import Data.Bits ((.&.), (.|.), (.<<.), (.>>.))
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Internal as BI
+
+fi :: (Integral a, Num b) => a -> b
+fi = fromIntegral
+{-# INLINE fi #-}
+
+-- arbitrary-size little-endian bytestring decoding
+roll :: BS.ByteString -> Integer
+roll = BS.foldr alg 0 where
+  alg (fi -> !b) !a = (a .<<. 8) .|. b
+{-# INLINE roll #-}
+
+-- little-endian bytestring encoding
+unroll :: Integer -> BS.ByteString
+unroll i = case i of
+    0 -> BS.singleton 0
+    _ -> BS.unfoldr coalg i
+  where
+    coalg = \case
+      0 -> Nothing
+      m -> Just $! (fi m, m .>>. 8)
+{-# INLINE unroll #-}
+
+clamp :: Integer -> Integer
+clamp r = r .&. 0x0ffffffc0ffffffc0ffffffc0fffffff
+{-# INLINE clamp #-}
+
+-- | Produce a Poly1305 MAC for the provided message, given the provided
+--   key.
+--
+--   Per RFC8439: the key, which is essentially a /one-time/ key, should
+--   be unique, and MUST be unpredictable for each invocation.
+--
+--   The key must be exactly 256 bits in length. Providing an invalid
+--   key will cause the function to throw an ErrorCall exception.
+--
+--   >>> mac "i'll never use this key again!!!" "a message needing authentication"
+--   "O'\231Z\224\149\148\246\203[}\210\203\b\200\207"
+mac
+  :: BS.ByteString -- ^ 256-bit one-time key
+  -> BS.ByteString -- ^ arbitrary-length message
+  -> BS.ByteString -- ^ 128-bit message authentication code
+mac key@(BI.PS _ _ kl) msg
+    | kl /= 32  = error "ppad-poly1305 (mac): invalid key"
+    | otherwise =
+        let (clamp . roll -> r, roll -> s) = BS.splitAt 16 key
+
+            loop !acc !bs = case BS.splitAt 16 bs of
+              (chunk@(BI.PS _ _ l), etc)
+                | l == 0 -> BS.take 16 (unroll (acc + s))
+                | otherwise ->
+                    let !n = roll chunk .|. (0x01 .<<. (8 * l))
+                        !nacc = r * (acc + n) `rem` p
+                    in  loop nacc etc
+
+        in  loop 0 msg
+  where
+    p = 1361129467683753853853498429727072845819 -- (1 << 130) - 5
+
diff --git a/ppad-poly1305.cabal b/ppad-poly1305.cabal
new file mode 100644
--- /dev/null
+++ b/ppad-poly1305.cabal
@@ -0,0 +1,65 @@
+cabal-version:      3.0
+name:               ppad-poly1305
+version:            0.1.0
+synopsis:           A pure Poly1305 MAC
+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 Poly1305 message authentication code, per
+  [RFC8439](https://datatracker.ietf.org/doc/html/rfc8439).
+
+source-repository head
+  type:     git
+  location: git.ppad.tech/poly1305.git
+
+library
+  default-language: Haskell2010
+  hs-source-dirs:   lib
+  ghc-options:
+      -Wall
+  exposed-modules:
+      Crypto.MAC.Poly1305
+  build-depends:
+      base >= 4.9 && < 5
+    , bytestring >= 0.9 && < 0.13
+
+test-suite poly1305-tests
+  type:                exitcode-stdio-1.0
+  default-language:    Haskell2010
+  hs-source-dirs:      test
+  main-is:             Main.hs
+
+  ghc-options:
+    -rtsopts -Wall -O2
+
+  build-depends:
+      base
+    , bytestring
+    , ppad-base16
+    , ppad-poly1305
+    , primitive
+    , tasty
+    , tasty-hunit
+
+benchmark poly1305-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-base16
+    , ppad-poly1305
+
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,27 @@
+{-# OPTIONS_GHC -fno-warn-incomplete-uni-patterns #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import qualified Crypto.MAC.Poly1305 as Poly1305
+import qualified Data.ByteString.Base16 as B16
+import Test.Tasty
+import qualified Test.Tasty.HUnit as H
+
+main :: IO ()
+main = defaultMain $ testGroup "ppad-poly1305" [
+    mac
+  ]
+
+mac :: TestTree
+mac = H.testCase "mac" $ do
+  let Just key = B16.decode
+        "85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b"
+      msg = "Cryptographic Forum Research Group"
+
+      Just e = B16.decode "a8061dc1305136c6c22b8baf0c0127a9"
+
+      o = Poly1305.mac key msg
+  H.assertEqual mempty e o
+
