Skip to main content

14 - Side channels and constant-time

1. Why this chapter exists

Every cryptographic implementation in this workspace must be constant-time with respect to secret data. A single non-constant- time branch in a hot path can leak secrets to a co-resident adversary, a network observer, or (for hardware wallets) a power probe. The historical bellman timing leak (chapter 12) and the remote-side-channel paper of Tramer et al. (USENIX 2020) show the risk is operational, not theoretical. A contributor touching zcash_primitives, the underlying jubjub, bls12_381, pasta_curves, subtle, or zeroize crates, or any wallet code that handles secrets must understand the threat model, the subtle::CtOption and subtle::Choice idioms, the vartime APIs that exist as foot-guns, and the constant-time scalar-multiplication primitives those crates expose.

2. Definitions

Definition 2.1 (constant-time with respect to a secret). Let f ⁣:S×XYf\colon \mathcal{S} \times \mathcal{X} \to \mathcal{Y} be a function whose first argument sSs \in \mathcal{S} is a secret and whose second argument xXx \in \mathcal{X} is public. Let Of(s,x)\mathcal{O}_f(s, x) denote the observable trace of evaluating ff on inputs (s,x)(s, x) on the target machine, where the observable channel includes wall-clock time, cache-line access pattern, branch-prediction history, and allocation pattern. The implementation of ff is constant-time with respect to ss if Of(s1,x)=Of(s2,x)\mathcal{O}_f(s_1, x) = \mathcal{O}_f(s_2, x) for all s1,s2Ss_1, s_2 \in \mathcal{S} and all xXx \in \mathcal{X}. In a wallet, ivkFr\mathsf{ivk} \in \mathbb{F}_r is secret but the number of outputs to scan is public; scan runtime must depend only on the latter.

Definition 2.2 (vartime API). A function whose name ends in _vartime or _vt (e.g. pow_vartime, mul_vartime, invert_vartime) is documented as variable-time: its trace O\mathcal{O} on inputs (s,x)(s, x) may depend on ss. The contract is that such functions are permitted to leak their inputs via the observable channel and must never be invoked on a secret. The suffix exists because the constant-time counterpart is slower and the variable-time function is the default for public inputs.

Definition 2.3 (Montgomery ladder). Let G\mathbb{G} be a cyclic group of prime order \ell with generator GG. Fix a scalar k=i=0n1ki2iFk = \sum_{i=0}^{n-1} k_i 2^i \in \mathbb{F}_\ell with n=log2n = \lceil \log_2 \ell \rceil. The Montgomery ladder computes [k]G[k] G by initialising (R0,R1)=(O,G)(R_0, R_1) = (\mathcal{O}, G) and, for i=n1,n2,,0i = n-1, n-2, \ldots, 0, applying

(R0,R1)    {(2R0,R0+R1)if ki=0,(R0+R1,2R1)if ki=1.(R_0, R_1) \;\leftarrow\; \begin{cases} (2 R_0, R_0 + R_1) & \text{if } k_i = 0,\\ (R_0 + R_1, 2 R_1) & \text{if } k_i = 1. \end{cases}

The number of group operations is exactly 2n2n, independent of kk. Both jubjub and pasta_curves implement scalar multiplication on secret-keyed paths via a windowed constant-time variant of this skeleton.

Definition 2.4 (threat-model boundary). The side-channel threat model considered in this chapter consists of: wall-clock timing, cache-line access pattern, branch-prediction state, and memory-allocation pattern observable to a co-resident process or network observer. Out of scope: power-trace analysis, electromagnetic emanation, and laser fault injection, which are hardware-wallet concerns addressed by the firmware running on the device, not by librustzcash.

3. The code

3.1 What can leak (and what cannot)

Constructs that leak in straightforward Rust code:

  • if secret_bit { ... } else { ... }: branch leaks via timing, branch predictor, and often cache.
  • array[secret_index]: cache-line access pattern leaks.
  • if x == 0 { return Err(...) } for a secret xx: early exit leaks zero-ness.
  • match secret_value { ... }: same.
  • format!("{}", secret): inner allocation paths leak.
  • Naive scalar-mul for bit in scalar.bits() { ... } with a per-bit branch.

Under the assumption that the underlying primitive is implemented constant-time, the following do not leak:

  • Arithmetic on field/group elements via the curve crates (+, *, scalar mul).
  • subtle::Choice and subtle::CtOption.
  • BLAKE2 hashing.
  • ChaCha20-Poly1305 AEAD decryption from the chacha20poly1305 crate.

3.2 The subtle crate

subtle provides constant-time primitives that are the backbone of this stack:

pub struct Choice(u8); // 0 or 1, no early-out
pub struct CtOption<T> { ... } // like Option<T> without branching

impl Choice {
fn unwrap_u8(&self) -> u8;
fn from(u: u8) -> Choice; // 0 -> false, 1 -> true
}

impl<T: ConditionallySelectable> {
fn conditional_select(a: &T, b: &T, c: Choice) -> T;
}

Use CtOption instead of Option whenever the option carries information about a secret. Option::unwrap is fine when the failure is structurally impossible; CtOption::into_option is the appropriate consumer when failure depends on a secret.

subtle::ConstantTimeEq is the constant-time ==:

let are_equal: Choice = secret_point_a.ct_eq(&secret_point_b);

When in doubt, grep ct_eq and conditional_select in the codebase to see the idiom in context.

3.3 Constant-time deserialisation in the Sapling reader

The Sapling-component reader is the canonical example of a constant-time wire path:

zcash_primitives/src/transaction/components/sapling.rs
loading...

ValueCommitment::from_bytes_not_small_order returns a CtOption. The branch that follows (if cv.is_none().into()) is on a public boolean: the parsing path was constant-time and the result (valid or invalid) is itself non-secret. Convert via into_option and propagate via ?; never unwrap() on attacker-controlled input.

3.4 Constant-time scalar multiplication

Scalar mul [k]P[k] P is the dominant cost. A naive double-and-add leaks popcount(k)\mathsf{popcount}(k) and the positions of set bits. Mitigations used in the workspace:

  • Montgomery ladder: for each bit of kk from high to low, maintain (R0,R1)(R_0, R_1) and unconditionally update both based on the bit.
  • Fixed-base comb / windowed methods: for fixed bases (GakG^{\mathsf{ak}}, GnkG^{\mathsf{nk}}, ...) the implementation precomputes a table of multiples and selects them via conditional_select over the whole table.
  • What you do not need to write: jubjub::SubgroupPoint::mul and pallas::Point::mul are constant-time. Use them directly.

Never roll your own scalar multiplication for secret scalars. If you find yourself doing that, stop and use the crate API.

3.5 Constant-time field arithmetic and vartime foot-guns

bls12_381::Scalar, jubjub::Fr, pallas::Base, etc. use Montgomery form and constant-time Barrett reduction. Field inversion uses Bernstein-Yang's safegcd or equivalent, which is constant-time.

Vartime APIs to grep for and never use on secrets:

  • pow_vartime: leaks the exponent.
  • mul_vartime: leaks the multiplicand.
  • invert_vartime: leaks the input.

If they appear in a path that touches secrets, that is a bug.

3.6 zeroize

zeroize::Zeroize overwrites secret material before deallocation:

use zeroize::Zeroize;

let mut sk: [u8; 32] = derive_sk();
// ... use sk ...
sk.zeroize();

Or ergonomically:

let sk = Zeroizing::new(derive_sk());
// sk's bytes are zeroed on drop

Zcash key types implement Zeroize and Drop to zero the underlying material. See sapling-crypto::keys::ExpandedSpendingKey or orchard::keychain::SpendingKey for examples.

Caveats:

  • Compiler optimisations can eliminate "dead writes" of zeros. zeroize uses volatile writes and a compiler fence to prevent this.
  • Stack copies of secrets are hard to zero. The library minimises this by working with Box<Secret> for large secrets.

3.7 Allocation patterns

Heap allocations are not constant-time by default: malloc paths vary with size, and large allocations may cause OS-level page faults observable through timing. For known-size key material, allocate on the stack or use [u8; 32] directly. Most Zcash key types are stack-allocated.

For variable-size intermediate data (transcript hashing buffers etc.), the allocation pattern is a function of public input size, not secrets, so it does not leak secrets.

3.8 Pattern: "decrypt and discard non-matching"

In zcash_client_backend::scanning, the trial-decryption loop looks like:

for output in block.outputs {
for ivk in tracked_ivks {
if let Some(note) = try_decrypt(output, ivk) {
// ...
}
}
}

try_decrypt does:

  1. Compute ECDH shared secret (constant-time scalar mul).
  2. Derive KDF key (constant-time hash).
  3. Attempt AEAD decryption (constant-time on tag mismatch).
  4. Re-derive commitment and compare (constant-time ct_eq).
  5. Return None if tag fails; else Some(note).

The overall loop is constant-time per (output, ivk) pair. Success leaks (the wallet records the note, leaving a side-channel trace), but failure is fully constant-time.

3.9 Pattern: secret-dependent loop bounds

A loop whose iteration count depends on a secret is the canonical non-constant-time mistake. The closest analogue in Zcash is diversifier enumeration:

let mut i = 0u64;
loop {
let d = diversifier_index_to_bytes(dk, i);
if let Some(g_d) = diversify_hash(d) {
return (i, g_d);
}
i += 1;
}

Here dk\mathsf{dk} is secret. An attacker who observes the runtime of address derivation learns roughly how many indices were tried, leaking a small amount of information about dk\mathsf{dk} via the FF1 output distribution. The mitigation in librustzcash is to not enumerate over a secret-dependent space: ZIP 32 designates specific indices as "default" and the wallet does not expose the side channel beyond user-initiated actions. Where enumeration is necessary (importing a wallet with unknown used indices), the operation is rare and not in a hot path.

3.10 Pattern: aggregating proofs without secret-dependent paths

When verifying a Sapling bundle, the verifier computes

bvk  =  icviinjcvjout[vbal]V\mathsf{bvk} \;=\; \sum_i \mathsf{cv}_i^{\text{in}} - \sum_j \mathsf{cv}_j^{\text{out}} - [v_{\text{bal}}] V

unconditionally, then checks the binding signature. The verification path does not branch on the result of any intermediate arithmetic. This is constant-time over public inputs.

3.11 Heuristics to grep for during review

# Vartime APIs
grep -r "vartime\|_vt(" --include='*.rs'

# Naive equality on potentially-secret data
grep -rn "\.eq(\|== &" --include='*.rs' \
| grep -i "key\|secret\|sk\|ivk\|nsk"

# Match arms with secret-derived expressions
grep -rB2 "match.*secret\|match.*sk\b\|match.*key" --include='*.rs'

# Early-exit iterators over secret data
grep -rB1 "\.any(\|\.all(" --include='*.rs'

These are heuristics; results need human review. Many hits will be benign (the grep is wide).

3.12 Compiler-level concerns

LLVM, the Rust compiler's backend, can in principle introduce secret-dependent branches even into seemingly constant-time source. The Rust crypto ecosystem mitigates this by:

  • Working in Montgomery form so reductions are uniform.
  • Using core::hint::black_box to hide values from the optimiser in critical sections.
  • Conservative use of inline-assembly stubs (sparingly).
  • Periodic audits with ctgrind, dudect, Lima.

The Zcash core crypto crates (bls12_381, jubjub, pasta_curves, pairing) have been audited and ship constant-time test suites.

3.13 Spectre and microarchitectural attacks

Modern attacks (Spectre v1-v4, BHB, Retbleed, Downfall) can leak data from constant-time code if adjacent code has secret-dependent branches. librustzcash does not implement Spectre mitigations directly; that is a kernel and microarchitecture concern. Operators of hosted prover services must enable CPU microcode and kernel patches.

3.14 The Sapling proving-key argument

A Sapling Spend proof takes roughly 2 seconds on a modern CPU. The proving process touches ask\mathsf{ask}, nsk\mathsf{nsk}, α\alpha, rcm\mathsf{rcm}, rcv\mathsf{rcv}, the note value vv, the Merkle path, all flowing through bellman's circuit synthesiser and multi-exponentiation.

For the proving stack to be safe:

  • bellman must use constant-time field/curve arithmetic for secret data (it does).
  • The proving key must be loaded once and kept in memory in a predictable allocation so cache layout is stable.
  • The proving thread should not be co-resident with attacker code (OS-level concern).

For a hardware wallet the constraints are tighter; the PCZT flow lets the hardware wallet sign without proving, side-stepping the proving-side-channel issue.

3.15 Memo and length leakage

Sapling encrypted memos are up to 512 bytes. If a wallet truncates the memo for display based on a secret predicate ("show only if it looks like ASCII"), the truncation could be observable. Best practice: treat memos as opaque bytes for storage; make display decisions on user-visible policy, not on content.

3.16 Anonymity-network considerations

The wallet may use Tor (via the tor feature in zcash_client_backend) to talk to lightwalletd. Tor protects network metadata but not local side channels. Both are needed for privacy.

4. Failure modes

  • pow_vartime on a secret exponent. Leaks the exponent. Use the non-vartime pow / exp API in pasta_curves and bls12_381.

    No automated test in this workspace. The vartime/constant-time split is enforced by naming convention in the external bls12_381, jubjub, and pasta_curves crates; this workspace contains no _vartime calls on secret data. Caught by audit only.

  • if secret_byte == 0 { ... } early exit. Leaks zero-ness via timing. Use ct_eq and conditional_select.

    No automated test in this workspace. The subtle::ConstantTimeEq discipline is enforced by review on every code path that consumes secret bytes. Caught by audit only.

  • Naive for bit in scalar.bits() { ... } scalar mul. Leaks popcount\mathsf{popcount} and bit positions. Use the curve crates' built-in scalar mul.

    No automated test in this workspace. Scalar multiplication on secret scalars is delegated to jubjub::SubgroupPoint::mul and pallas::Point::mul, both implemented constant-time in their respective crates. Caught by audit only.

  • Option::unwrap() on a CtOption from attacker bytes. Re-introduces a branch on a secret-derived boolean and can panic on adversarial input. Use into_option().ok_or(...).

    Caught by: zcash_primitives::transaction::tests::tx_read_write in zcash_primitives/src/transaction/tests.rs (parses an adversarially crafted-but-valid v4 transaction; if a reader replaces into_option().ok_or(...) with unwrap(), the behaviour on the existing test corpus is unchanged but a companion fuzzed input would panic; no fuzz harness exists in this workspace today, so this regression is only partially covered by the round-trip test).

  • Allocating a Vec<u8> whose length depends on a secret. The allocation path leaks the size. Use a fixed-size buffer.

    No automated test in this workspace. Allocation-pattern analysis requires tools such as ctgrind or dudect, which are not integrated into the workspace test suite. Caught by audit only.

  • Forgetting zeroize::Zeroize on a long-lived secret struct. The bytes persist in memory until overwritten by later allocations; a coredump or swap file exposes them.

    Caught by: the Zeroize and ZeroizeOnDrop impl blocks on zcash_keys::keys::transparent::Key in zcash_keys/src/keys/transparent.rs are compile-time requirements for callers that wrap the type in SecretVec. No runtime test asserts post-drop zeroing on this workspace. Caught by audit only.

  • Calling format! / Debug::fmt on a secret. Formatting invokes allocator paths; the resulting string is also a liability. Implement Debug to redact for secret types.

    Caught by: zcash_keys::keys::transparent::tests::key_debug_redaction in zcash_keys/src/keys/transparent.rs (constructs a transparent Key, formats it via Debug, and asserts the rendered string contains secret: "..." rather than the raw bytes).

  • Using match directly on a secret enum. Convert to a bit-mask + conditional_select for each arm.

    No automated test in this workspace. The pattern is enforced by review; there are no secret-enum match patterns in the workspace today. Caught by audit only.

5. Spec pointers

6. Exercises

  1. Map the vartime surface. Run grep -rn "vartime\|_vt(" --include='*.rs' in the workspace and in pasta_curves / bls12_381 / jubjub. For each hit, classify it as (a) called only on public data, (b) called on secrets (a bug), or (c) ambiguous. Report any (b) hits.
  2. Trace a constant-time decode. Read zcash_primitives/src/transaction/components/sapling.rs#L87-L150 and explain in two sentences why the is_none().into() branch does not leak. What changes if the caller unwrap()s instead of going via into_option?
  3. Add a Zeroize test. In a checkout, pick a struct in zcash_keys that holds secret bytes (e.g. a spending key wrapper). Write a unit test that creates the struct, captures its raw bytes via a pointer, drops it, and verifies the bytes are zero. The test should pass; remove the Zeroize impl and it must fail.

Answers in the code

  • Constant-time deserialiser pattern: zcash_primitives/src/transaction/components/sapling.rs#L87-L150.
  • The subtle::Choice and CtOption types live in the external subtle crate; see its docs.
  • Zeroize and Zeroizing live in the external zeroize crate; the Zcash key types implement the trait in the sapling-crypto and orchard repositories.

7. Further reading

  • Chapter 13: the cofactor-clearing pattern these constant-time primitives protect.
  • Chapter 15: the proving-key flow that the constant-time bellman pipeline operates on.
  • Chapter 18: the network-level metadata model that complements local side-channel hardening.
  • Almeida, Barbosa et al. (USENIX 2016) as above for the formal-verification side of constant-time validation.