The Two-Class Pattern
tacet uses two-class testing to detect timing side channels. You provide two types of inputs (baseline and sample) and the library measures whether they take different amounts of time. If your code is constant-time, both classes complete in the same duration. If there’s a timing leak, one class is consistently faster or slower than the other.
This page explains how to choose input classes that will actually reveal timing leaks in your code.
Starting with a simple case
Section titled “Starting with a simple case”Let’s start with an example where the input class choice is straightforward: testing whether a hash function has data-dependent timing.
use tacet::{TimingOracle, AttackerModel, helpers::InputPair};use sha3::{Sha3_256, Digest};
let inputs = InputPair::new( || [0u8; 64], // Baseline: all zeros || rand::random(), // Sample: random bytes);
let outcome = TimingOracle::for_attacker(AttackerModel::AdjacentNetwork) .test(inputs, |data| { let mut hasher = Sha3_256::new(); hasher.update(&data); std::hint::black_box(hasher.finalize()); });import { TimingOracle, AttackerModel } from '@tacet/wasm';
const inputs = { baseline: () => new Uint8Array(64), // All zeros sample: () => crypto.getRandomValues(new Uint8Array(64))};
const outcome = await TimingOracle.forAttacker(AttackerModel.AdjacentNetwork()) .test(inputs, (data) => { const hasher = new Sha3_256(); hasher.update(data); hasher.finalize(); });#include <tacet/tacet.h>#include <sha3.h>
void generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 64); // All zeros random_bytes(sample, 64); // Random bytes}
void measure_hash(const void *input, void *ctx) { sha3_ctx_t hasher; sha3_init(&hasher, 64); sha3_update(&hasher, input, 64); uint8_t hash[32]; sha3_final(hash, &hasher);}
tacet_attacker_model_t model = tacet_attacker_model_adjacent_network();tacet_oracle_t *oracle = tacet_oracle_for_attacker(model);
tacet_outcome_t outcome;tacet_test(oracle, generate_inputs, NULL, measure_hash, NULL, &outcome);#include <tacet/tacet.hpp>#include <sha3.hpp>#include <span>
auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); // All zeros std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::AdjacentNetwork()).test(inputs, [](std::span<const uint8_t> data) { sha3::Sha3_256 hasher; hasher.update(data); hasher.finalize();});package main
import ( "crypto/rand" "github.com/tacet-oracle/tacet-go" "golang.org/x/crypto/sha3")
inputs := func() ([]byte, []byte) { baseline := make([]byte, 64) // All zeros sample := make([]byte, 64) rand.Read(sample) // Random bytes return baseline, sample}
outcome := tacet.ForAttacker(tacet.AdjacentNetwork). Test(inputs, func(data []byte) { hasher := sha3.New256() hasher.Write(data) hasher.Sum(nil) })This works because SHA-3 processes all input bytes the same way regardless of their values. The zeros and random bytes go through identical code paths, so any timing difference would indicate a leak.
Why this pattern works here:
- Both inputs are the same size (64 bytes)
- Both inputs go through the same hash computation
- The only difference is the byte values themselves
- A constant-time hash function treats all byte values identically
This zeros vs random pattern is your default for most cryptographic primitives: block ciphers, hash functions, MACs, and many asymmetric operations.
Why zeros vs random works
Section titled “Why zeros vs random works”Zeros are a useful baseline because they’re a degenerate case: no bits are set, all bytes are identical, and any computation over them has uniform behavior. Random data, by contrast, has varied bit patterns that exercise different internal states.
If an implementation has timing that depends on input values (like branching on individual bits or accessing different cache lines based on byte values), zeros and random inputs will trigger measurably different execution times.
Baseline (zeros): [0x00, 0x00, 0x00, ...] → uniform computationSample (random): [0x7A, 0xF2, 0x1B, ...] → varied computation
Constant-time code: both take the same time ✓Leaky code: one is faster than the other ✗When zeros vs random isn’t enough
Section titled “When zeros vs random isn’t enough”Some functions have timing that depends on the relationship between the input and some internal state, not just the input values themselves. For these functions, zeros vs random won’t create the timing asymmetry you need.
The comparison function trap
Section titled “The comparison function trap”Consider testing an early-exit comparison function:
// A leaky comparison (exits on first mismatch)fn leaky_compare(a: &[u8], b: &[u8]) -> bool { for i in 0..a.len() { if a[i] != b[i] { return false; // Early exit! } } true}// A leaky comparison (exits on first mismatch)function leakyCompare(a, b) { for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; // Early exit! } } return true;}// A leaky comparison (exits on first mismatch)bool leaky_compare(const uint8_t *a, const uint8_t *b, size_t len) { for (size_t i = 0; i < len; i++) { if (a[i] != b[i]) { return false; // Early exit! } } return true;}// A leaky comparison (exits on first mismatch)bool leakyCompare(std::span<const uint8_t> a, std::span<const uint8_t> b) { for (size_t i = 0; i < a.size(); i++) { if (a[i] != b[i]) { return false; // Early exit! } } return true;}// A leaky comparison (exits on first mismatch)func leakyCompare(a, b []byte) bool { for i := range a { if a[i] != b[i] { return false // Early exit! } } return true}If you test this naively:
let secret = [0x42u8; 32]; // Secret value being compared
let inputs = InputPair::new( || [0u8; 32], // Baseline: zeros || rand::random(), // Sample: random);
let outcome = TimingOracle::for_attacker(AttackerModel::AdjacentNetwork) .test(inputs, |data| { leaky_compare(&secret, &data); });const secret = new Uint8Array(32).fill(0x42); // Secret value
const inputs = { baseline: () => new Uint8Array(32), // Zeros sample: () => crypto.getRandomValues(new Uint8Array(32))};
const outcome = await TimingOracle.forAttacker(AttackerModel.AdjacentNetwork()) .test(inputs, (data) => { leakyCompare(secret, data); });uint8_t secret[32];memset(secret, 0x42, 32); // Secret value
void generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); // Zeros random_bytes(sample, 32); // Random}
void measure_compare(const void *input, void *ctx) { leaky_compare(secret, input, 32);}
tacet_attacker_model_t model = tacet_attacker_model_adjacent_network();tacet_oracle_t *oracle = tacet_oracle_for_attacker(model);tacet_outcome_t outcome;tacet_test(oracle, generate_inputs, NULL, measure_compare, NULL, &outcome);std::array<uint8_t, 32> secret;std::fill(secret.begin(), secret.end(), 0x42); // Secret value
auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); // Zeros std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::AdjacentNetwork()).test(inputs, [&secret](std::span<const uint8_t> data) { leakyCompare(secret, data);});secret := make([]byte, 32)for i := range secret { secret[i] = 0x42 // Secret value}
inputs := func() ([]byte, []byte) { baseline := make([]byte, 32) // Zeros sample := make([]byte, 32) rand.Read(sample) // Random return baseline, sample}
outcome := tacet.ForAttacker(tacet.AdjacentNetwork). Test(inputs, func(data []byte) { leakyCompare(secret, data) })This test will incorrectly pass. Here’s why:
- Baseline
[0x00, 0x00, ...]compared to secret[0x42, 0x42, ...]: mismatch on byte 0, exits immediately - Sample
[0x7A, 0xF2, ...]compared to secret[0x42, 0x42, ...]: mismatch on byte 0, exits immediately
Both inputs exit on the very first byte! There’s no timing difference to detect, even though the function is clearly not constant-time.
The fix: baseline must match the secret
Section titled “The fix: baseline must match the secret”For comparison functions, the baseline must match what’s being compared against:
let secret = [0u8; 32]; // Use a secret that baseline will match
let inputs = InputPair::new( || [0u8; 32], // Baseline: matches secret → checks ALL bytes || rand::random(), // Sample: random → exits on first mismatch);
let outcome = TimingOracle::for_attacker(AttackerModel::AdjacentNetwork) .test(inputs, |data| { leaky_compare(&secret, &data); });const secret = new Uint8Array(32); // All zeros - baseline will match
const inputs = { baseline: () => new Uint8Array(32), // Matches secret → checks ALL bytes sample: () => crypto.getRandomValues(new Uint8Array(32))};
const outcome = await TimingOracle.forAttacker(AttackerModel.AdjacentNetwork()) .test(inputs, (data) => { leakyCompare(secret, data); });uint8_t secret[32] = {0}; // All zeros - baseline will match
void generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); // Matches secret → checks ALL bytes random_bytes(sample, 32); // Random → exits on first mismatch}
void measure_compare(const void *input, void *ctx) { leaky_compare(secret, input, 32);}
tacet_attacker_model_t model = tacet_attacker_model_adjacent_network();tacet_oracle_t *oracle = tacet_oracle_for_attacker(model);tacet_outcome_t outcome;tacet_test(oracle, generate_inputs, NULL, measure_compare, NULL, &outcome);std::array<uint8_t, 32> secret{}; // All zeros - baseline will match
auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); // Matches secret std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::AdjacentNetwork()).test(inputs, [&secret](std::span<const uint8_t> data) { leakyCompare(secret, data);});secret := make([]byte, 32) // All zeros - baseline will match
inputs := func() ([]byte, []byte) { baseline := make([]byte, 32) // Matches secret → checks ALL bytes sample := make([]byte, 32) rand.Read(sample) // Random → exits on first mismatch return baseline, sample}
outcome := tacet.ForAttacker(tacet.AdjacentNetwork). Test(inputs, func(data []byte) { leakyCompare(secret, data) })Now:
- Baseline
[0x00, 0x00, ...]matches secret → compares all 32 bytes (slow) - Sample
[0x7A, 0xF2, ...]mismatches → exits on first differing byte (fast)
The early-exit leak creates a measurable timing difference, and the test correctly fails.
The general principle
Section titled “The general principle”To detect a timing leak, your two input classes must trigger different timing behaviors:
| Class | Purpose |
|---|---|
| Baseline | Triggers one timing behavior (often the slow path) |
| Sample | Triggers a different timing behavior (often the fast path) |
If your code is constant-time, both classes take the same time regardless of which path you intended them to trigger. If there’s a leak, one class is consistently faster.
The key question to ask: What makes this operation take different amounts of time?
| What causes timing variation | Baseline | Sample |
|---|---|---|
| Branches on bit values | All zeros (no bits set) | Random (many bits set) |
| Early-exit on mismatch | Matches the secret | Random (mismatches early) |
| Cache access by index | Same indices repeatedly | Random indices |
| Conditional operations on high bits | All zeros (high bits clear) | Random (high bits vary) |
Patterns by operation type
Section titled “Patterns by operation type”Block ciphers, hash functions, MACs
Section titled “Block ciphers, hash functions, MACs”Use the standard zeros vs random pattern:
let inputs = InputPair::new( || [0u8; 16], // Baseline: zeros || rand::random(), // Sample: random);const inputs = { baseline: () => new Uint8Array(16), sample: () => crypto.getRandomValues(new Uint8Array(16))};void generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 16); // Baseline: zeros random_bytes(sample, 16); // Sample: random}auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};inputs := func() ([]byte, []byte) { baseline := make([]byte, 16) // Zeros sample := make([]byte, 16) rand.Read(sample) // Random return baseline, sample}These primitives should process all input bytes identically.
AEAD ciphers (AES-GCM, ChaCha20-Poly1305)
Section titled “AEAD ciphers (AES-GCM, ChaCha20-Poly1305)”Use zeros vs random for the plaintext, but ensure unique nonces:
use std::sync::atomic::{AtomicU64, Ordering};
let nonce_counter = AtomicU64::new(0);
let inputs = InputPair::new( || [0u8; 64], || rand::random(),);
let outcome = TimingOracle::for_attacker(AttackerModel::AdjacentNetwork) .test(inputs, |plaintext| { let n = nonce_counter.fetch_add(1, Ordering::Relaxed); let mut nonce = [0u8; 12]; nonce[..8].copy_from_slice(&n.to_le_bytes());
cipher.encrypt(&nonce, &plaintext); });let nonceCounter = 0n;
const inputs = { baseline: () => new Uint8Array(64), sample: () => crypto.getRandomValues(new Uint8Array(64))};
const outcome = await TimingOracle.forAttacker(AttackerModel.AdjacentNetwork()) .test(inputs, (plaintext) => { const nonce = new Uint8Array(12); const n = nonceCounter++; new DataView(nonce.buffer).setBigUint64(0, n, true);
cipher.encrypt(nonce, plaintext); });#include <stdatomic.h>
atomic_uint_fast64_t nonce_counter = ATOMIC_VAR_INIT(0);
void measure_aead(const void *input, void *ctx) { uint64_t n = atomic_fetch_add(&nonce_counter, 1); uint8_t nonce[12] = {0}; memcpy(nonce, &n, 8);
aead_encrypt(nonce, input, 64, /* ... */);}#include <atomic>
std::atomic<uint64_t> nonce_counter{0};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::AdjacentNetwork()).test(inputs, [&](std::span<const uint8_t> plaintext) { uint64_t n = nonce_counter.fetch_add(1); std::array<uint8_t, 12> nonce{}; std::memcpy(nonce.data(), &n, 8);
cipher.encrypt(nonce, plaintext);});import "sync/atomic"
var nonceCounter uint64
inputs := func() ([]byte, []byte) { baseline := make([]byte, 64) sample := make([]byte, 64) rand.Read(sample) return baseline, sample}
outcome := tacet.ForAttacker(tacet.AdjacentNetwork). Test(inputs, func(plaintext []byte) { n := atomic.AddUint64(&nonceCounter, 1) nonce := make([]byte, 12) binary.LittleEndian.PutUint64(nonce, n)
cipher.Encrypt(nonce, plaintext) })Comparison and equality functions
Section titled “Comparison and equality functions”Baseline must match the value being compared:
let secret = [0u8; 32];
let inputs = InputPair::new( || [0u8; 32], // Matches secret || rand::random(), // Mismatches);const secret = new Uint8Array(32);
const inputs = { baseline: () => new Uint8Array(32), // Matches secret sample: () => crypto.getRandomValues(new Uint8Array(32))};uint8_t secret[32] = {0};
void generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); // Matches secret random_bytes(sample, 32); // Mismatches}std::array<uint8_t, 32> secret{};
auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); // Matches std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};secret := make([]byte, 32)
inputs := func() ([]byte, []byte) { baseline := make([]byte, 32) // Matches secret sample := make([]byte, 32) rand.Read(sample) // Mismatches return baseline, sample}Scalar multiplication (X25519, ECDH)
Section titled “Scalar multiplication (X25519, ECDH)”Use zeros vs random for the scalar:
let inputs = InputPair::new( || [0u8; 32], // Baseline: zero scalar || rand::random(), // Sample: random scalar);Constant-time implementations should handle all scalar values identically. A naive square-and-multiply implementation would branch on scalar bits.
RSA operations
Section titled “RSA operations”Use zeros vs random for the message/plaintext:
let inputs = InputPair::new( || vec![0u8; 256], // Baseline: zero-padded || random_padded_msg(), // Sample: random content);Verifying your test harness
Section titled “Verifying your test harness”Before trusting results from a timing test, verify that your harness itself is working correctly.
Sanity check: identical inputs should pass
Section titled “Sanity check: identical inputs should pass”If you test with both classes generating the same input, the test should always pass:
let inputs = InputPair::new( || [0u8; 32], || [0u8; 32], // Same as baseline);
let outcome = TimingOracle::for_attacker(AttackerModel::Research) .test(inputs, |data| my_function(&data));
assert!(outcome.passed(), "Sanity check failed: identical inputs should pass");const inputs = { baseline: () => new Uint8Array(32), sample: () => new Uint8Array(32) // Same as baseline};
const outcome = await TimingOracle.forAttacker(AttackerModel.Research()) .test(inputs, (data) => myFunction(data));
console.assert(outcome.passed(), "Sanity check failed");void generate_identical(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); memset(sample, 0, 32); // Same as baseline}
tacet_attacker_model_t model = tacet_attacker_model_research();tacet_oracle_t *oracle = tacet_oracle_for_attacker(model);tacet_outcome_t outcome;tacet_test(oracle, generate_identical, NULL, measure_fn, NULL, &outcome);
assert(tacet_outcome_passed(&outcome));auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); std::fill(sample.begin(), sample.end(), 0); // Same};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::Research()).test(inputs, [](std::span<const uint8_t> data) { myFunction(data);});
assert(outcome.passed());inputs := func() ([]byte, []byte) { baseline := make([]byte, 32) sample := make([]byte, 32) // Same as baseline return baseline, sample}
outcome := tacet.ForAttacker(tacet.Research). Test(inputs, func(data []byte) { myFunction(data) })
if !outcome.Passed() { panic("Sanity check failed")}If this fails, there’s likely an environmental issue (high system noise, measurement problems) rather than a timing leak in your code.
Known-leaky test: verify detection works
Section titled “Known-leaky test: verify detection works”Test against a function you know is not constant-time:
fn definitely_leaky(data: &[u8]) -> bool { data.iter().all(|&b| b == 0) // Early exit on non-zero}
let inputs = InputPair::new( || [0u8; 64], // All zeros → checks all bytes || [0xFFu8; 64], // All 0xFF → exits on first byte);
let outcome = TimingOracle::for_attacker(AttackerModel::AdjacentNetwork) .test(inputs, |data| { definitely_leaky(&data); });
assert!(outcome.failed(), "Should detect the obvious leak");function definitelyLeaky(data) { return data.every(b => b === 0); // Early exit on non-zero}
const inputs = { baseline: () => new Uint8Array(64), // All zeros sample: () => new Uint8Array(64).fill(0xFF) // All 0xFF};
const outcome = await TimingOracle.forAttacker(AttackerModel.AdjacentNetwork()) .test(inputs, (data) => { definitelyLeaky(data); });
console.assert(outcome.failed(), "Should detect leak");bool definitely_leaky(const uint8_t *data, size_t len) { for (size_t i = 0; i < len; i++) { if (data[i] != 0) return false; // Early exit } return true;}
void generate_leaky_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 64); // All zeros memset(sample, 0xFF, 64); // All 0xFF}
void measure_leaky(const void *input, void *ctx) { definitely_leaky(input, 64);}
tacet_attacker_model_t model = tacet_attacker_model_adjacent_network();tacet_oracle_t *oracle = tacet_oracle_for_attacker(model);tacet_outcome_t outcome;tacet_test(oracle, generate_leaky_inputs, NULL, measure_leaky, NULL, &outcome);
assert(tacet_outcome_failed(&outcome));bool definitelyLeaky(std::span<const uint8_t> data) { for (auto b : data) { if (b != 0) return false; // Early exit } return true;}
auto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); std::fill(sample.begin(), sample.end(), 0xFF);};
auto outcome = tacet::TimingOracle::forAttacker( tacet::AttackerModel::AdjacentNetwork()).test(inputs, [](std::span<const uint8_t> data) { definitelyLeaky(data);});
assert(outcome.failed());func definitelyLeaky(data []byte) bool { for _, b := range data { if b != 0 { return false // Early exit } } return true}
inputs := func() ([]byte, []byte) { baseline := make([]byte, 64) // All zeros sample := make([]byte, 64) for i := range sample { sample[i] = 0xFF // All 0xFF } return baseline, sample}
outcome := tacet.ForAttacker(tacet.AdjacentNetwork). Test(inputs, func(data []byte) { definitelyLeaky(data) })
if !outcome.Failed() { panic("Should detect the leak")}If your harness can’t detect this obvious leak, something is wrong with your measurement setup.
Input generation: closures, not values
Section titled “Input generation: closures, not values”The InputPair::new() function takes closures that generate fresh inputs for each measurement:
// ✓ Correct: fresh random value each timelet inputs = InputPair::new( || [0u8; 32], || rand::random::<[u8; 32]>(), // Called on each measurement);
// ✗ Wrong: same captured value for all measurementslet random_value = rand::random::<[u8; 32]>();let inputs = InputPair::new( || [0u8; 32], || random_value, // Always returns the same value!);// ✓ Correct: fresh random value each timeconst inputs = { baseline: () => new Uint8Array(32), sample: () => crypto.getRandomValues(new Uint8Array(32))};
// ✗ Wrong: same captured value for all measurementsconst randomValue = crypto.getRandomValues(new Uint8Array(32));const badInputs = { baseline: () => new Uint8Array(32), sample: () => randomValue // Always returns the same value!};// ✓ Correct: generate fresh values in the callbackvoid generate_inputs(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); random_bytes(sample, 32); // Fresh random each call}
// ✗ Wrong: reusing the same bufferuint8_t random_value[32];random_bytes(random_value, 32); // Only called once!
void generate_inputs_wrong(uint64_t *baseline, uint64_t *sample, size_t n, void *ctx) { memset(baseline, 0, 32); memcpy(sample, random_value, 32); // Same value every time!}// ✓ Correct: fresh random value each timeauto inputs = [](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); std::generate(sample.begin(), sample.end(), []() { return random_byte(); });};
// ✗ Wrong: same captured value for all measurementsstd::array<uint8_t, 32> randomValue = generateRandom();auto badInputs = [randomValue](std::span<uint64_t> baseline, std::span<uint64_t> sample) { std::fill(baseline.begin(), baseline.end(), 0); std::copy(randomValue.begin(), randomValue.end(), reinterpret_cast<uint8_t*>(sample.data()));};// ✓ Correct: fresh random value each timeinputs := func() ([]byte, []byte) { baseline := make([]byte, 32) sample := make([]byte, 32) rand.Read(sample) // Fresh random each call return baseline, sample}
// ✗ Wrong: same captured value for all callsrandomValue := make([]byte, 32)rand.Read(randomValue) // Only called once!
badInputs := func() ([]byte, []byte) { baseline := make([]byte, 32) return baseline, randomValue // Same value every time!}Generating fresh random values ensures that sample-class measurements exercise varied code paths across the test run.
Summary
Section titled “Summary”- Default pattern: zeros vs random works for most crypto primitives
- Comparison functions: baseline must match the secret
- The key question: what makes this operation take different amounts of time?
- Verify your harness: test with identical inputs (should pass) and known-leaky code (should fail)
- Use closures: generate fresh inputs for each measurement
For copy-paste patterns for specific cryptographic operations, see Testing Cryptographic Code.