| Copyright | (c) 2026 Jared Tobin |
|---|---|
| License | MIT |
| Maintainer | Jared Tobin <jared@ppad.tech> |
| Safe Haskell | None |
| Language | Haskell2010 |
Censor
Description
Sequential constant-time testing.
Declare a Hypothesis (an IO action plus two input samplers);
runCT measures paired class-A / class-B timings in random order,
streams them through an anytime-valid test, and either rejects (a
leak is detected) or exhausts its budget. Type-I error is
controlled at cfgAlpha whenever the run stops.
The null
The null is conditional exchangeability of the measured pair
(ta, tb): given the past, swapping the class labels leaves the
joint law unchanged. The randomised per-pair order supplies it,
provided the nuisance dynamics within a pair (cache and predictor
state, frequency) are blind to the class labels; randomisation
cannot undo a nuisance process that reacts to the class itself.
The test
Every component bets on an antisymmetric functional of the pair
(g(ta, tb) = -g(tb, ta)), which is conditionally symmetric about
zero under the null. The test is a uniform mixture of e-processes
(Numeric.Eproc.Mixture) over a family of them, with
d = ta - tb:
- sign:
sign d. Catches an asymmetry ofditself, including bulk shifts that heavy tails hide from the magnitude components. - magnitude:
dclipped to[-c, c],[-4c, 4c], and[-16c, 16c], for a warmup-estimatedc. The tight bound is the most powerful against small consistent shifts; the wide ones keep rare outliers. - CDF:
1[ta <= q] - 1[tb <= q]at warmup-fixed cut pointsq. Catches dispersion leaks, which leavedsymmetric.
The family is finite, so the test is not exhaustive: a departure
that moves none of these functionals off zero passes. Read a
Pass as "no evidence on these channels", not as proof of
constant time.
Synopsis
- data Hypothesis a = Hypothesis {}
- fixVsRandom :: (a -> IO ()) -> IO s -> (s -> IO s) -> (s -> IO a) -> IO (Hypothesis a)
- fixVsRandomCtx :: (a -> IO ()) -> IO s -> (s -> IO s) -> IO c -> (c -> s -> IO a) -> IO (Hypothesis a)
- data Config = Config {}
- defaultConfig :: Config
- data Result
- = Reject {
- resPairs :: !Int
- resPeakLogW :: !Double
- resPValue :: !Double
- resEffect :: !(Double, Double)
- resClipped :: !Int
- resClipped4 :: !Int
- resClipped16 :: !Int
- resBound :: !Double
- resPeakSign :: !Double
- resPeakMagn :: !Double
- resPeakMagn4 :: !Double
- resPeakMagn16 :: !Double
- resCdf :: ![(Double, Double)]
- resSeed :: !Word64
- resMargin :: !(Maybe Double)
- | Pass {
- resPairs :: !Int
- resPeakLogW :: !Double
- resPValue :: !Double
- resEffect :: !(Double, Double)
- resClipped :: !Int
- resClipped4 :: !Int
- resClipped16 :: !Int
- resBound :: !Double
- resPeakSign :: !Double
- resPeakMagn :: !Double
- resPeakMagn4 :: !Double
- resPeakMagn16 :: !Double
- resCdf :: ![(Double, Double)]
- resSeed :: !Word64
- resMargin :: !(Maybe Double)
- = Reject {
- resPeakCdf :: Result -> Double
- data Advisory
- data ClipRates = ClipRates {}
- data LeakShape
- diagnose :: Result -> [Advisory]
- leakShape :: Result -> LeakShape
- data CensorError
- runCT :: Meter -> Config -> Hypothesis a -> IO Result
- runCTWith :: Meter -> Config -> Hypothesis a -> (Frame -> IO ()) -> IO Result
- data Frame = Frame {}
- runCTAttributed :: Meter -> Config -> Hypothesis a -> IO (Result, Maybe Attribution)
- data Split a = Split {
- splitCycles :: !a
- splitPeriod :: !a
- splitWall :: !a
- runSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Result)
- runSplitWith :: SplitMeter -> Config -> Hypothesis a -> (Frame -> IO ()) -> IO (Split Result)
- attributeSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Attribution)
- calibrateBatch :: Meter -> Hypothesis a -> IO Int
- data CalibReport = CalibReport {}
- calibrateBatchReport :: Meter -> Hypothesis a -> IO CalibReport
- data Noise = Noise {}
- baselineReading :: Meter -> Config -> Hypothesis a -> IO Noise
- toAA :: Hypothesis a -> Hypothesis a
- runAA :: Meter -> Config -> Hypothesis a -> IO Result
- toBB :: Hypothesis a -> Hypothesis a
- runBB :: Meter -> Config -> Hypothesis a -> IO Result
- data Attribution = Attribution {
- attVerdict :: !ControlVerdict
- attAA :: !Result
- attBB :: !Result
- data ControlVerdict
- attribute :: Meter -> Config -> Hypothesis a -> IO Attribution
- newtype Meter = Meter {}
- wallClock :: Meter
- data Counter
- data MeterError
- = Unsupported
- | OpenFailed !Int
- withCounter :: Counter -> (Meter -> IO a) -> IO a
- newtype SplitMeter = SplitMeter {}
- withSplitMeter :: (SplitMeter -> IO a) -> IO a
- data Rng
- mkRng :: Word64 -> IO Rng
- nextWord :: Rng -> IO Word64
- reseed :: Rng -> Word64 -> IO ()
- randomBytes :: Rng -> Int -> IO ByteString
Hypothesis
data Hypothesis a Source #
A constant-time hypothesis.
target: theIOaction under test.prepare: a per-pair prologue, run before either sampler and outside the timed region;pure ()unless the classes share per-pair state (seefixVsRandomCtx).sampleA,sampleB: samplers for the two input classes, typically "fixed" and "random".
Only target is timed, so any asymmetry in how the samplers
prepare inputs shows up as a class difference. Return inputs in
normal form, build the fixed class by the same code path as the
random one (not as a top-level constant), and keep the samplers
symmetric in allocation and in where the secret is read from.
fixVsRandom does this for the usual case.
Arguments
| :: (a -> IO ()) | target |
| -> IO s | secret sampler |
| -> (s -> IO s) | re-materialise a secret into a fresh value |
| -> (s -> IO a) | complete an input around a secret |
| -> IO (Hypothesis a) |
A dudect-style fix-vs-random Hypothesis, from a target, a
secret sampler, a re-materialisation function, and a completion
building a full input around a secret.
The fixed secret is drawn once from the sampler, so it is an
ordinary heap value like the random class's. Each sample, class
A draws and discards a fresh secret and class B keeps its draw,
so the samplers allocate alike. Both classes then re-materialise
the pinned secret: class A completes around the copy, class B
discards it. With a real copy (for a ByteString,
\s -> evaluate (BS.copy s)), neither class is alone in reading
one long-lived, cache-hot value, a locality asymmetry that the
A/A and B/B controls cannot see. Pass pure only for types
with no cheap copy.
The completion runs identically in both classes, so per-pair public inputs stay fresh on both sides. The sampler, re-materialiser, and completion must return values in normal form.
hyp <- fixVsRandom (\x -> () <$ evaluate (inv x)) -- target (randomMont g) -- secret sampler pure -- no re-materialiser pure -- input is just the secret
Arguments
| :: (a -> IO ()) | target |
| -> IO s | secret sampler |
| -> (s -> IO s) | re-materialise a secret into a fresh value |
| -> IO c | public-context sampler (once per pair) |
| -> (c -> s -> IO a) | build an input from context and secret |
| -> IO (Hypothesis a) |
A fix-vs-random Hypothesis whose two classes share one
freshly drawn public context per pair.
fixVsRandom completes each class independently, so per-pair
public inputs (message, nonce, modulus) either differ across the
pair, adding nuisance variance to d, or are pinned for the
whole run, testing a single public input. Here prepare draws
one context per pair and both classes are built around it, so
the test is of secret dependence given identical fresh public
input. The null is unaffected: given the context, the two
readings are still exchangeable under H_0.
The re-materialisation function plays the same role as in
fixVsRandom.
hyp <- fixVsRandomCtx (\(k, m) -> () <$ evaluate (mac k m)) -- target (randomBytes g 32) -- secret sampler (\k -> evaluate (BS.copy k)) -- re-materialise (randomBytes g 256) -- public context (\msg key -> pure (key, msg)) -- build the input
Configuration
Driver configuration.
Constructors
| Config | |
Fields
| |
defaultConfig :: Config Source #
Defaults: alpha = 1e-6, budget 100000 pairs, batch 1 (no
inner repetition), warmup 200 pairs, order-bit seed drawn from
OS entropy per run, no margin (sharp null).
Result
Test outcome: Reject (a leak was detected) or Pass (none
within budget), each carrying the same run report.
Evidence is on the log e-value scale: a run starts at 0 and
rejects when the mixture's running supremum (resPeakLogW)
crosses log(1 / alpha). The per-component peaks say which
channel found the evidence. They are diagnostic only: each is a
supremum at its own time, and the verdict latches on the
mixture.
Constructors
| Reject | |
Fields
| |
| Pass | |
Fields
| |
resPeakCdf :: Result -> Double Source #
The largest peak log e-value across the CDF-indicator
components — 0 when warmup resolved no cut points. A maximum
over resCdf, which keeps the per-cut-point detail.
Advisories
Constructors
| HighClipRate !ClipRates | More than 5% of pairs were clipped at |
| LowPower !Double | On |
| RejectionDriver !LeakShape | On |
The share of pairs clipped at each of the hedge's three
magnitude bounds, carried by HighClipRate. Nested by
construction: clipWidest <= clipWide <= clipTight, since
|d| > 16c implies |d| > 4c implies |d| > c.
Constructors
| ClipRates | |
The qualitative shape of a leak, inferred from the per-component
peaks of the hedge. Reported by RejectionDriver and available
standalone via leakShape.
Constructors
| BulkShift |
|
| ShapeLeak |
|
| RareOutlier |
|
| CdfShift |
|
| MixedShape | No single component peak dominates by |
leakShape :: Result -> LeakShape Source #
Identify which channel of the hedge has the largest peak log
e-value in a Result, binning the two wider magnitude
components together as RareOutlier and the CDF-indicator
components together as CdfShift. Returns MixedShape when no
single peak exceeds the next-largest by a factor of 1.5.
Only meaningful on a Reject — on a Pass every peak sits near
the calibrated floor of 0 and the returned shape is not
informative.
Under a margin-mode run (resMargin present) only the gating
magnitude components are ranked: the sign and CDF peaks did not
drive the verdict there, and a sub-margin systematic routinely
inflates them.
Errors
data CensorError Source #
Errors thrown by runCT when calibration or configuration fails.
Constructors
| WarmupZeroDuration | warmup observed only zero-duration measurements: the meter
cannot resolve the target under the current |
| InvalidConfig !String | a |
| InvalidEprocConfig !ConfigError | the underlying e-process rejected the test configuration
derived from |
Instances
| Exception CensorError Source # | |
Defined in Censor Methods toException :: CensorError -> SomeException # fromException :: SomeException -> Maybe CensorError # displayException :: CensorError -> String # backtraceDesired :: CensorError -> Bool # | |
| Show CensorError Source # | |
Defined in Censor Methods showsPrec :: Int -> CensorError -> ShowS # show :: CensorError -> String # showList :: [CensorError] -> ShowS # | |
| Eq CensorError Source # | |
Defined in Censor | |
Running
runCT :: Meter -> Config -> Hypothesis a -> IO Result Source #
Run a constant-time test. See runCTWith for the phase-by-phase
description; this is that driver with the per-pair observer
discarded.
runCTWith :: Meter -> Config -> Hypothesis a -> (Frame -> IO ()) -> IO Result Source #
Run a constant-time test, handing a Frame to the observer after
every main-phase pair. The observer runs outside the timed
region and gets the frame lazily, so the no-op observer of
runCT costs nothing; a recording one reconstructs the
trajectory (see record in Censor.Runner).
- Warmup:
cfgWarmuppairs fix the clip boundcand the CDF cut points (order statistics of the pooled readings, so they carry no class information). - Main: each pair runs in random order, and every
component's functional of
(ta, tb)feeds its e-process. The run halts when the mixture crosses1 / alphaor the budget runs out.dclipped to[-16c, 16c]also feeds the confidence sequence behindresEffect.
With cfgMargin set, warmup also resolves the absolute margin,
and only the magnitude components gate the verdict.
Betting on d rather than on the raw readings lets the bet size
scale with the noise, p99(|d|), rather than with the target's
runtime: a large gain for slow targets with little jitter.
A per-pair snapshot of the driver's state, delivered to the
observer passed to runCTWith after every main-phase pair. All
quantities are on the same scale as the corresponding Result
fields; recording the stream of frames reconstructs the wealth
trajectory without reimplementing the driver.
Constructors
| Frame | |
Fields
| |
runCTAttributed :: Meter -> Config -> Hypothesis a -> IO (Result, Maybe Attribution) Source #
Run a constant-time test and, if it rejects, qualify the
rejection with the A/A and B/B negative controls
(attribute). A Pass returns Nothing (no controls needed);
a Reject returns reporting whether either
control convicted the harness. The controls re-run the test
twice, so this costs up to three runs on a rejection.Just att
Split runs
One value per channel of a split run (runSplitWith): a
Result each, or the controls' Attribution each.
Constructors
| Split | |
Fields
| |
runSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Result) Source #
runSplitWith with the per-pair observer discarded.
runSplitWith :: SplitMeter -> Config -> Hypothesis a -> (Frame -> IO ()) -> IO (Split Result) Source #
Run a constant-time test under a SplitMeter, handing the
cycles channel's Frame to the observer after every
main-phase pair.
Every pair is read once, as cycles and wall time, and tested on
three channels: cycles, the clock period (wall time per cycle),
and wall time. Each channel is a full test as runCTWith
describes, with its own clip bound and cut points fixed by the
same warmup pairs, and each holds its own type-I error at
cfgAlpha. The cycles channel decides when the run stops: on
its rejection, or at the budget. That stop is a stopping time
for the readings of all three, and the order bit is independent
of all three, so the other two channels' guarantees hold where
it falls.
The cycles channel answers the constant-time question. The period channel answers whether the clock moved with the class, as it does when the core's frequency follows data-dependent power; wall time answers whether elapsed time moved, by either route. A wall rejection beside a cycles pass, with the period rejecting too, is a frequency effect rather than a code path.
The period reads cleanly only beside a cycles pass: the counted region carries a fixed overhead (the clock reads), so wall time per cycle falls as cycles grow, and a cycles difference registers on the period as well.
attributeSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Attribution) Source #
The A/A and B/B controls under a SplitMeter: each control
is one split run, and each channel's verdict comes from that
channel's readings of the two. Only meaningful for a channel
that rejected, as for attribute.
Batch calibration
calibrateBatch :: Meter -> Hypothesis a -> IO Int Source #
Pick a cfgBatch that lifts the per-batch reading well above the
meter's resolution: probe a class-A sample at geometrically
growing batches until the reading reaches a target magnitude,
then scale to it. Only meaningful for targets that recompute on
every repetition (see cfgBatch).
b <- calibrateBatch meter hyp
r <- runCT meter cfg { cfgBatch = b } hypConsumes one prepare and one class-A draw; build a fresh
hypothesis for the run if its sampler stream must start
undisturbed. Returns 1 if the meter never resolves the target
(the run then throws WarmupZeroDuration). The result is
meter-specific; pin cfgBatch to compare meters.
data CalibReport Source #
The probe history behind calibrateBatchReport, for when the
chosen batch surprises.
Constructors
| CalibReport | |
Fields
| |
Instances
| Show CalibReport Source # | |
Defined in Censor Methods showsPrec :: Int -> CalibReport -> ShowS # show :: CalibReport -> String # showList :: [CalibReport] -> ShowS # | |
calibrateBatchReport :: Meter -> Hypothesis a -> IO CalibReport Source #
calibrateBatch with its CalibReport.
Dispersion of meter readings, in per-batch meter units (the scale
of resBound and resEffect). Deterministic meters read an IQR
of 0.
Constructors
| Noise | |
Baseline probe
baselineReading :: Meter -> Config -> Hypothesis a -> IO Noise Source #
Baseline cost of the target: median and IQR of single
measurements on fresh class-A draws (each after a prepare), at
cfgBatch, in the units of resEffect. Unlike crNoise, which
remeasures one applied action (no-ops after the first, for a pure
target), every measurement here is genuine work. Give it its own
hypothesis instance if the run's sampler stream must start
undisturbed.
A/A and B/B negative controls
toAA :: Hypothesis a -> Hypothesis a Source #
toBB :: Hypothesis a -> Hypothesis a Source #
data Attribution Source #
The control verdict with both control runs in full, so the evidence can be inspected rather than taken on trust.
Constructors
| Attribution | |
Fields
| |
Instances
| Show Attribution Source # | |
Defined in Censor Methods showsPrec :: Int -> Attribution -> ShowS # show :: Attribution -> String # showList :: [Attribution] -> ShowS # | |
data ControlVerdict Source #
The outcome of the A/A and B/B controls. They can convict the harness but not acquit it: each compares a sampler against itself, so an asymmetry only between the samplers (say, one returns a thunk that the target forces while timed) is invisible to both.
Constructors
| ControlsPass | Neither control rejected. A primary |
| SampleAAsymmetry | The A/A control rejected: |
| SampleBAsymmetry | The B/B control rejected: as above, for |
| PervasiveAsymmetry | Both controls rejected: the harness asymmetry is not specific to either sampler. |
Instances
| Show ControlVerdict Source # | |
Defined in Censor Methods showsPrec :: Int -> ControlVerdict -> ShowS # show :: ControlVerdict -> String # showList :: [ControlVerdict] -> ShowS # | |
| Eq ControlVerdict Source # | |
Defined in Censor Methods (==) :: ControlVerdict -> ControlVerdict -> Bool # (/=) :: ControlVerdict -> ControlVerdict -> Bool # | |
attribute :: Meter -> Config -> Hypothesis a -> IO Attribution Source #
Run the A/A and B/B controls. Only meaningful after a
Reject: on a symmetric harness both pass whether or not the
target leaks.
Measurement (re-exports)
A measurement device. runs measure m k actact k times
back to back and returns one reading for the batch: nanoseconds
for wallClock, counted events for a withCounter meter. Each
meter owns its inner loop, reading the clock or arming the
counter once per batch.
Wall-clock meter: elapsed nanoseconds over the batch.
On Darwin, backed by clock_gettime_nsec_np(CLOCK_UPTIME_RAW),
which resolves the hardware tick (~42 ns on Apple Silicon, so
sub-microsecond targets need batching; see cfgBatch).
Not GHC.Clock.getMonotonicTimeNSec, which macOS quantises to
microseconds, putting CDF cut points on quantisation boundaries
(see issues/handled/ISSUE2.md). Elsewhere, backed by
GHC.Clock.getMonotonicTimeNSec.
A hardware (or, for TaskClock, software) event to count.
With wallClock the counters form a ladder in which each
adjacent gap isolates one cause of a class difference:
instructions (code path) -> cycles (microarchitectural latency)
-> ref-cycles (frequency) -> task-clock (kernel and interrupt
time) -> wall (scheduling gaps).
Constructors
| InstructionsRetired |
|
| BranchesRetired |
|
| Cycles |
|
| RefCycles |
|
| TaskClock |
|
| BranchMisses |
|
| CacheMisses |
|
data MeterError Source #
Why a withCounter meter could not be opened.
Constructors
| Unsupported | not a Linux build; no hardware-counter support |
| OpenFailed !Int |
|
Instances
| Exception MeterError Source # | |
Defined in Censor.Meter Methods toException :: MeterError -> SomeException # fromException :: SomeException -> Maybe MeterError # displayException :: MeterError -> String # backtraceDesired :: MeterError -> Bool # | |
| Show MeterError Source # | |
Defined in Censor.Meter Methods showsPrec :: Int -> MeterError -> ShowS # show :: MeterError -> String # showList :: [MeterError] -> ShowS # | |
| Eq MeterError Source # | |
Defined in Censor.Meter | |
withCounter :: Counter -> (Meter -> IO a) -> IO a Source #
Acquire a performance-counter Meter for the duration of the
callback, then release it. Throws MeterError if the counter
cannot be opened.
Counters count user space only, which unprivileged processes may
open at kernel.perf_event_paranoid 2 or lower; TaskClock
deliberately includes kernel time and needs 1 or lower, failing
with otherwise rather than silently degrading
to a user-only clock. A counter is bound to the thread that
opened it, so the callback runs on one OS thread.OpenFailed 13
newtype SplitMeter Source #
A meter reading two quantities over one execution of the batch:
returns measureSplit m k act(cycles, nanoseconds). Wall
time is cycles times the clock period, and a secret can move it
through either factor: through cycles when the code's execution
depends on it, through the period when the core's frequency
does (data-dependent power under frequency scaling). Reading
both on the same execution tells the two apart.
Constructors
| SplitMeter | |
withSplitMeter :: (SplitMeter -> IO a) -> IO a Source #
Acquire a SplitMeter for the duration of the callback, then
release it. Throws MeterError if the cycle counter cannot be
opened.
The wall clock is read inside the counted region, so both
readings cover the batch and the clock reads; the overhead is
the same in both classes. As with withCounter, the callback
runs on one OS thread.
Sampler support (re-exports)
reseed :: Rng -> Word64 -> IO () Source #
Reset a generator to a seed in place: after reseed g s the
generator draws exactly the stream of a fresh .mkRng s
Lets one long-lived generator replay a pinned stream, or adopt a fresh one, without allocating per use — so the two classes of a fix-vs-random sampler can share a single generator object and differ only in the seed value written into it.
randomBytes :: Rng -> Int -> IO ByteString Source #
Draw n pseudorandom bytes as a strict ByteString.
The result is a fresh, fully-materialised heap value -- no thunks for the meter to pay inside the timed region -- so a sampler can return it directly.