poly1305

The Poly1305 message authentication code (docs.ppad.tech/poly1305).
git clone git://git.ppad.tech/poly1305.git
Log | Files | Refs | README | LICENSE

commit bd98c53ac3d468dcd01acf4f3f42a3a3daef1342
parent dc3b3ee876ed880155d3e88ea47b90acf18dcd3b
Author: Jared Tobin <jared@jtobin.io>
Date:   Mon,  6 Jul 2026 09:03:46 -0230

lib: unroll the constant-time MAC comparison

Replace the byte-serial fused fold with a fully-unrolled OR-tree
over two 64-bit lanes for standard 16-byte tags, retaining the
byte-serial fold as a fallback for constructor-built MACs of
nonstandard length.

The byte-serial fold carries an accumulator whose nonzero span
tracks the position of the first differing byte: comparing
equal-length unequal tags, the accumulator is nonzero for
(length - position) iterations, a data-value trajectory that is
observable as a small wall-clock bias on some microarchitectures
(e.g. Apple M-series) even though instruction count, branching,
and allocation are all position-independent. The unrolled OR-tree
folds through no loop-carried accumulator, coarsening that surface
from byte to 64-bit-lane granularity. It is also faster.

Diffstat:
Mlib/Crypto/MAC/Poly1305.hs | 45++++++++++++++++++++++++++++++++++++++-------
1 file changed, 38 insertions(+), 7 deletions(-)

diff --git a/lib/Crypto/MAC/Poly1305.hs b/lib/Crypto/MAC/Poly1305.hs @@ -31,7 +31,7 @@ import qualified Data.Bits as B import qualified Data.ByteString as BS import qualified Data.ByteString.Internal as BI import qualified Data.ByteString.Unsafe as BU -import Data.Word (Word8) +import Data.Word (Word8, Word64) import Data.Word.Limb (Limb(..)) import qualified Data.Word.Limb as L import Data.Word.Wider (Wider(..)) @@ -134,6 +134,24 @@ clamp :: Wider -> Wider clamp r = r `W.and` 0x0ffffffc0ffffffc0ffffffc0fffffff {-# INLINE clamp #-} +-- | Assemble the 64-bit word at the given byte offset, little-endian. +-- +-- Byte order is immaterial to an equality test as long as both +-- operands are assembled the same way; this is used only by the +-- constant-time 'MAC' comparison. The length is not checked. +word64le :: BS.ByteString -> Int -> Word64 +word64le bs m = + let ix k = fromIntegral (BU.unsafeIndex bs (m + k)) :: Word64 + in ix 0 + B..|. (ix 1 `B.unsafeShiftL` 0x08) + B..|. (ix 2 `B.unsafeShiftL` 0x10) + B..|. (ix 3 `B.unsafeShiftL` 0x18) + B..|. (ix 4 `B.unsafeShiftL` 0x20) + B..|. (ix 5 `B.unsafeShiftL` 0x28) + B..|. (ix 6 `B.unsafeShiftL` 0x30) + B..|. (ix 7 `B.unsafeShiftL` 0x38) +{-# INLINE word64le #-} + -- | A Poly1305 message authentication code. -- -- Note that you should compare MACs for equality using the 'Eq' @@ -154,13 +172,26 @@ instance Eq MAC where -- -- Runs in variable-time only for invalid inputs. (MAC a@(BI.PS _ _ la)) == (MAC b@(BI.PS _ _ lb)) - | la /= lb = False - | otherwise = go 0 0 + | la /= lb = False + | la == 16 = + -- fully-unrolled, fixed OR-tree over two 64-bit lanes. A + -- standard 16-byte tag folds through no loop-carried + -- accumulator, so there is no per-byte accumulator whose + -- nonzero span tracks the position of a differing byte: + -- the comparison time is independent of where (or whether) + -- the tags differ. + let !d0 = B.xor (word64le a 0) (word64le b 0) + !d1 = B.xor (word64le a 8) (word64le b 8) + in (d0 B..|. d1) == 0 + | otherwise = go 0 0 where - -- fused fold: OR the bytewise XORs into an accumulator - -- directly, rather than via packZipWith, so no intermediate - -- ByteString holding the (secret-derived) difference bytes - -- is ever materialised on the heap. + -- byte-serial fallback for nonstandard tag lengths. 'mac' + -- always yields 16 bytes, so this path is unreachable through + -- it; it exists only to keep the instance total for MACs built + -- directly via the exported constructor. The fused fold ORs the + -- bytewise XORs into an accumulator directly, rather than via + -- packZipWith, so no intermediate ByteString holding the + -- (secret-derived) difference bytes is ever materialised. go :: Word8 -> Int -> Bool go !acc !i | i == la = acc == 0