commit 29c12eb38dc6b44be45fd20663e314a187d99793
parent 9316ad643808e894163ec8cce926c6c2547872ed
Author: Jared Tobin <jared@jtobin.io>
Date: Fri, 17 Jul 2026 15:26:58 -0230
lib: barrier ct_eq accumulator against LLVM short-circuit
The LLVM backend recognises ct_eq's OR-accumulate-then-compare-zero
as an array-equality test and lowers it to a short-circuiting
byte-by-byte compare that exits on the first differing tag byte,
re-introducing the mismatch-position timing leak the constant-time
comparison was written to avoid (research/timing ATTACK163). NCG is
unaffected.
Route the accumulator through Data.Barrier.barrier -- a NOINLINE
identity in a separate module, so it compiles to an opaque external
call the LLVM optimiser cannot see through -- before the zero-test.
This forces the full OR-fold over every byte; verified with substratum
(the early-exit branches are gone, the OR-accumulate is back) on both
aarch64 and x86_64 LLVM builds. All tests pass.
Diffstat:
4 files changed, 42 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,3 @@
dist-newstyle/
result-doc
+*.s
diff --git a/lib/Crypto/AEAD/ChaCha20Poly1305.hs b/lib/Crypto/AEAD/ChaCha20Poly1305.hs
@@ -25,6 +25,7 @@ module Crypto.AEAD.ChaCha20Poly1305 (
, _poly1305_key_gen
) where
+import Data.Barrier (barrier)
import qualified Crypto.Cipher.ChaCha20 as ChaCha20
import qualified Crypto.MAC.Poly1305 as Poly1305
import Data.Bits ((.>>.))
@@ -42,7 +43,9 @@ fi = fromIntegral
-- 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.
+-- heap. The accumulator is routed through 'barrier' before the
+-- zero-test so the LLVM backend cannot recover the array-equality
+-- idiom and short-circuit on the first mismatch (see "Data.Barrier").
ct_eq :: BS.ByteString -> BS.ByteString -> Bool
ct_eq a@(BI.PS _ _ la) b@(BI.PS _ _ lb)
| la /= lb = False
@@ -50,7 +53,7 @@ ct_eq a@(BI.PS _ _ la) b@(BI.PS _ _ lb)
where
go :: Word8 -> Int -> Bool
go !acc !i
- | i == la = acc == 0
+ | i == la = barrier acc == 0
| otherwise =
let !x = BU.unsafeIndex a i
!y = BU.unsafeIndex b i
diff --git a/lib/Data/Barrier.hs b/lib/Data/Barrier.hs
@@ -0,0 +1,34 @@
+-- |
+-- Module: Data.Barrier
+-- Copyright: (c) 2025 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- An optimisation barrier for constant-time code.
+
+module Data.Barrier (
+ barrier
+ ) where
+
+import Data.Word (Word8)
+
+-- | Identity on 'Word8', but opaque to the optimiser. A constant-time
+-- accumulate-then-compare (e.g. an OR-fold of bytewise XORs, tested
+-- against zero) routes its accumulator through this before the
+-- zero-test, so the compiler cannot recognise it as an array-equality
+-- test and lower it to a short-circuiting byte comparison (which would
+-- leak the mismatch position).
+--
+-- Both properties are required and must not be \"tidied\" away:
+--
+-- * @NOINLINE@ -- if GHC inlines it, the LLVM backend regains the
+-- accumulator's definition and short-circuits again.
+-- * a /separate/ module -- a caller then compiles @barrier@ to an
+-- external call it cannot see through. Inline it into the caller
+-- and the barrier is gone.
+--
+-- Guarded by the substratum + censor constant-time checks; weakening
+-- either property re-introduces the timing leak.
+barrier :: Word8 -> Word8
+barrier x = x
+{-# NOINLINE barrier #-}
diff --git a/ppad-aead.cabal b/ppad-aead.cabal
@@ -33,6 +33,8 @@ library
ghc-options: -fllvm -O2
exposed-modules:
Crypto.AEAD.ChaCha20Poly1305
+ other-modules:
+ Data.Barrier
build-depends:
base >= 4.9 && < 5
, bytestring >= 0.9 && < 0.13