Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
status: open
kind: defect
opened: 2026-11-01
---

# Intel's store-forwarding predictor times constant-time code by its data

On the T14 (Tiger Lake, signature 0x806c1, microcode 0xbe), toyos-crypto's
P-256 and P-384 public key and agreement at 9217793d2, whose branches and
addresses do not depend on the scalar, leaked through timing: max |t| 69 to
81 over 1,000,000 fixed-versus-random measurements, the fixed scalar 1 about
90 ticks slower in 316,000 for P-256. Turning on PSFD alone (`IA32_SPEC_CTRL`
bit 7), which disables only the Fast Store Forwarding Predictor, removed it,
and so did DOITM and SSBD. The T14 enumerates no `DDP_CTRL`, so it has no
data-dependent prefetcher.

- Evidence: the off/on pair,
https://github.com/ToyOSOrg/evidence/tree/7fb806b73ce0397b9cbec9e1da1df910ba8f4785/pr-1038/r1/probe,
and the attribution boot by mode,
https://github.com/ToyOSOrg/evidence/tree/56a9678bcec3939074783b0f0a363eb14c1a25da/combined/fsfp-attribution.
- Intel lists FSFP on Ice Lake U/Y and SP, Lakefield, Tiger Lake U/H, Rocket
Lake and Alder Lake P/M/S, and says later parts declare it by enumerating
PSFD (CPUID.(7,2).EDX[0]), as the T14 does too (EDX 0x1). It does not
document what makes the predictor forward wrongly, so no coding rule
avoids it.
- Current code passes on the T14 with every such control off. That rests on
avoiding the value pattern that triggered it (#1013 keeps the running point
off the identity), which a test of chosen secrets cannot extend to every
secret.
- By the owner's ruling (2026-10-26) ToyOS switches no DOITM on x86-64:
constant time there rests on the code, as it must on AMD, which has no
such mode. `toyos::timing` reports x86-64 unprotected.

**Owner**: the crypto track. **Exit**: a program holding secrets on a CPU
with FSFP runs with it disabled, by PSFD or SSBD through the per-program flag
of `issues/no-program-runs-with-speculative-store-bypass-disabled.md`, or
Intel documents which data the predictor depends on and toyos-crypto is shown
to keep secrets out of it. **Oracle**: `crypto_secret_timing_curves` on the
T14 at a revision that leaked, with the control on and off.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
status: open
kind: defect
opened: 2026-11-01
---

# The AVX2 Poly1305 runs under Intel's MXCSR-dependent timing

Intel's "MXCSR Configuration Dependent Timing" guidance says that on a CPU
that does not enumerate `MCDT_NO` (CPUID.(07H,2):EDX[5]), some vector
instructions can retire up to one cycle later on particular data under an
MXCSR other than 0x1FBF. `VPMULUDQ` is on its list, and toyos-crypto's AVX2
Poly1305 kernel (`lib/crypto/src/arch/x86_64/poly1305.rs`) runs it on the
secret key's limbs under the default MXCSR (0x1F80). The T14 does not
enumerate `MCDT_NO`: its CPUID.(07H,2):EDX reads 0x1, in the boot at
https://github.com/ToyOSOrg/evidence/tree/56a9678bcec3939074783b0f0a363eb14c1a25da/combined/fsfp-attribution.
DOITM does not cover it, and by the owner's ruling (2026-10-26) ToyOS
switches no DOITM on x86-64 anyway.

Whether the delay is measurable on our benches is unmeasured.

**Owner**: the crypto track. **Exit**: where `MCDT_NO` is clear, the AVX2
Poly1305 kernel runs under Intel's sequence (`STMXCSR`; `LDMXCSR` 0x1FBF;
`LFENCE` before the kernel; `LFENCE`; `LDMXCSR` of the saved value after),
its cost measured on the T14. **Oracle**: Intel's instruction list and the
CPU's `MCDT_NO` bit.
38 changes: 38 additions & 0 deletions issues/toyos-crypto-is-in-the-tree-on-par-with-ring-and-secure.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ review has been done, and passing vectors does not show it is safer than ring.
2.6 GB/s against the portable rounds' 0.52, and SHA-512 at 1.36 against
0.83. ToyOS's own AArch64 targets do not promise them and nothing detects
them at run time, so they hash portably; see the weaknesses.
- **On ToyOS every operation that takes a secret runs under the thread's
timing protection** (`src/timing.rs`, `toyos::timing`): `PSTATE.DIT` on
AArch64. That covers ciphers, MACs, KDFs, keyed SipHash, `ct::equal` and
the key operations. A plain hash takes none, so a caller hashing a secret
takes `toyos_crypto::timing::protect` itself, as `toyos-ssh`'s exchange
hash and key derivation do. x86-64 has no such mode a thread sets, and
ToyOS turns Intel's DOITM on for none, so constant time there rests on the
code, as on AMD.

## Consumers and what each needs

Expand Down Expand Up @@ -499,6 +507,28 @@ Not consumers:
2.65; null 1.82 and 1.04, leaky 210.6 and 194.7. Its Ed25519 run is
pending. Evidence:
https://github.com/ToyOSOrg/evidence/tree/6488b4fbb6785e65332d03fb504fe3849d89810a/pr-1046/r4
- **#1063's P-256 register kernels at 9a5d4fad5** (with the AVX2
table read), 1,000,000 measurements a routine, no CPU timing mode:
- The T14 (0x806c1, microcode 0xbe): P-256 public key max |t|
3.20 and agreement 1.51; P-384 public key 1.19 and agreement
1.42; Ed25519 2.73. Null 1.22 and 1.70, leaky 855.8 and 890.6.
- The AMD bench (0x860f01, 0x860010d): P-256 2.99 and 1.49; P-384
2.47 and 2.43; Ed25519 1.66. Null 1.14 and 2.12, leaky 140.1
and 169.3.
- Evidence:
https://github.com/ToyOSOrg/evidence/tree/aa3369406af00b46a4b76476ef4cea7df040e30f/combined/r3
- Its speed A/B against main, three base/head pairs a bench: P-256
ECDH 0.75–0.82 of main and verify 0.76–0.85, but for one T14
boot in which ring ran 1.10x slower too.
- **A measured layout effect:** AMD asserted `chacha20-poly1305/key`
rose from 5.3 ns to 6.0, 6.9 and 6.0 ns in the three pairs, with ring
unchanged. Its instructions are the same in both builds; only
`SealingKey::chacha20_poly1305` moved, from a 64-byte boundary to
32 bytes past one. The T14 and the unasserted build show
1.00–1.04. Evidence:
https://github.com/ToyOSOrg/evidence/tree/58dcc2314053075b410ed6ccdd48f0e2b68c9c1f/combined/r4
and
https://github.com/ToyOSOrg/evidence/tree/d6e8e35334d4f83b5140e7dedd8b4d9f1f49b338/combined/r5
- Their judge asks only that each job ran and its controls held; the
verdicts are this record's. The first boots, of the fast program at
ec5a0cd18, read LEAKS everywhere: that harness made each random
Expand Down Expand Up @@ -652,6 +682,14 @@ Not consumers:

## Weaknesses until the exits hold

- On x86-64 nothing but the code keeps secret work's timing independent of
its data. The T14's store-forwarding predictor once timed P-256 by its
data, which DOITM, PSFD or SSBD removed
(`issues/intels-store-forwarding-predictor-times-constant-time-code-by-its-data.md`).
Intel's MXCSR-dependent timing can delay the AVX2 Poly1305's `VPMULUDQ`
on its data
(`issues/the-avx2-poly1305-runs-under-intels-mxcsr-dependent-timing.md`).
**Owner**: this track. **Exit**: both issues'.
- Erasure is what `lib/crypto/src/secret.rs`'s contract states: secret
owners, each key agreement's shared value and named workspaces are
erased. Since the parity work's 5x51 field (#992), 2^255 - 19 field
Expand Down
30 changes: 29 additions & 1 deletion kernel/src/arch/aarch64/control_regs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! and the GIC architecture specification (IHI 0069H), chapter 12, for the
//! `ICC_` registers.

use core::sync::atomic::AtomicU64;
use core::sync::atomic::{AtomicU64, AtomicU8, Ordering};

use crate::log;

Expand Down Expand Up @@ -94,6 +94,33 @@ macro_rules! read {
}};
}

/// FEAT_DIT, `ID_AA64PFR0_EL1.DIT` (bits 51:48) nonzero, as the boot CPU
/// answered it: every other CPU must answer the same, since a thread that sets
/// `PSTATE.DIT` on one runs on any. Zero until the boot CPU's [`check`].
static DIT: AtomicU8 = AtomicU8::new(0);
const DIT_ABSENT: u8 = 1;
const DIT_PRESENT: u8 = 2;

/// Whether a user thread can set `PSTATE.DIT` for itself: every CPU has
/// FEAT_DIT. EL0 writes it, every exception from EL0 saves it in `SPSR_EL1`
/// in the thread's `trap::Frame` and the return puts it back, and a thread
/// starts with `SPSR_EL1` zero, so it is each thread's own with no switch work.
pub fn user_timing_control() -> bool {
DIT.load(Ordering::Acquire) == DIT_PRESENT
}

/// This CPU's FEAT_DIT, held to the boot CPU's.
fn hold_dit() {
let mine = read!("id_aa64pfr0_el1") >> 48 & 0xF != 0;
let code = if mine { DIT_PRESENT } else { DIT_ABSENT };
match DIT.compare_exchange(0, code, Ordering::Release, Ordering::Acquire) {
Ok(_) => log!("control registers: the boot CPU {} FEAT_DIT, as every other must", if mine { "has" } else { "has no" }),
Err(machine) => {
assert!(machine == code, "control registers: this CPU's FEAT_DIT is {mine} and the boot CPU's is {}", !mine)
}
}
}

/// `TCR_EL1` whole, as this CPU's physical address range makes it.
pub fn tcr() -> u64 {
TCR | (read!("id_aa64mmfr0_el1") & 0xF) << TCR_IPS_SHIFT
Expand All @@ -116,6 +143,7 @@ pub fn check(el: u64) {
for (name, live, value) in declared {
assert_eq!(live, value, "control registers: {name} holds {live:#x}, and the declaration says {value:#x}");
}
hold_dit();
// What the drop from EL2 left, or the EL1 entry kept: EL1, on `SP_EL1`.
let (now, spsel) = (read!("CurrentEL") >> 2 & 3, read!("SPSel") & 1);
assert_eq!((now, spsel), (1, 1), "control registers: running at EL{now} on SP_EL{spsel}, not EL1 on SP_EL1");
Expand Down
9 changes: 9 additions & 0 deletions kernel/src/arch/x86_64/control_regs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,15 @@ pub fn pcid_active() -> bool {
DECLARED_CR4.load(Ordering::Acquire) & cr4::PCIDE != 0
}

/// Whether a user thread can set data-independent timing for itself: never
/// on x86-64. Intel's DOITM (`IA32_UARCH_MISC_CTL[0]`) is Ring 0's, and this
/// kernel turns it on for no thread
/// (`issues/intels-store-forwarding-predictor-times-constant-time-code-by-its-data.md`);
/// AMD has no such mode.
pub fn user_timing_control() -> bool {
false
}

/// Drops every translation this CPU holds, global ones included (SDM Vol. 3A
/// §4.10.4.1, APM Vol. 2 §5.5): `PGE` cleared and put back where it is set;
/// where it is not, nothing is global and a `CR3` reload drops the rest. For a
Expand Down
1 change: 1 addition & 0 deletions kernel/src/syscall/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) ->
}
}
SYS_CPU_COUNT => crate::smp::cpu_count() as u64,
SYS_TIMING_CONTROL => u64::from(crate::arch::control_regs::user_timing_control()),
SYS_FUTEX_WAIT => match UserAddr::checked(a1) {
Some(addr) => process::futex_wait(addr, a2 as u32, a3),
None => bad_addr,
Expand Down
5 changes: 5 additions & 0 deletions lib/crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ rustls-provider = ["dep:rustls"]
[dependencies]
rustls = { version = "0.23.45", optional = true, default-features = false, features = ["custom-provider", "tls12"] }

# The thread's timing protection around secret work (`src/timing.rs`).
[target.'cfg(target_os = "toyos")'.dependencies]
toyos = { path = "../../sdk/toyos" }

# The oracles every primitive is held against, on the host alone.
[dev-dependencies]
ring = "0.17.14"
Expand All @@ -44,3 +48,4 @@ num-bigint = "0.4"

[lints.rust]
unsafe_op_in_unsafe_fn = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("toyos"))'] }
8 changes: 8 additions & 0 deletions lib/crypto/src/aead.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ impl SealingKey {
}
#[cfg(feature = "rustls-provider")]
pub(crate) fn initialize(&mut self, key: &[u8]) {
let _protection = crate::timing::protect();
self.key.initialize(key);
}
pub fn aes128_gcm(key: [u8; 16], iv: [u8; 12]) -> Self {
let _protection = crate::timing::protect();
let key = Secret(key);
Self {
key: Key::Gcm(crate::gcm::Gcm::aes128(&key.0)),
Expand All @@ -68,6 +70,7 @@ impl SealingKey {
}
}
pub fn aes256_gcm(key: [u8; 32], iv: [u8; 12]) -> Self {
let _protection = crate::timing::protect();
let key = Secret(key);
Self {
key: Key::Gcm(crate::gcm::Gcm::aes256(&key.0)),
Expand All @@ -90,6 +93,7 @@ impl SealingKey {
aad: &[u8],
plaintext: &mut [u8],
) -> Result<([u8; 12], [u8; 16]), Error> {
let _protection = crate::timing::protect();
if self.exhausted {
return Err(Error::CounterExhausted);
}
Expand Down Expand Up @@ -122,15 +126,18 @@ impl OpeningKey {
}
#[cfg(feature = "rustls-provider")]
pub(crate) fn initialize(&mut self, key: &[u8]) {
let _protection = crate::timing::protect();
self.key.initialize(key);
}
pub fn aes128_gcm(key: [u8; 16]) -> Self {
let _protection = crate::timing::protect();
let key = Secret(key);
Self {
key: Key::Gcm(crate::gcm::Gcm::aes128(&key.0)),
}
}
pub fn aes256_gcm(key: [u8; 32]) -> Self {
let _protection = crate::timing::protect();
let key = Secret(key);
Self {
key: Key::Gcm(crate::gcm::Gcm::aes256(&key.0)),
Expand All @@ -148,6 +155,7 @@ impl OpeningKey {
ciphertext: &mut [u8],
tag: &[u8],
) -> Result<(), Error> {
let _protection = crate::timing::protect();
if tag.len() != 16 {
return Err(Error::Length);
}
Expand Down
23 changes: 6 additions & 17 deletions lib/crypto/src/arch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,12 @@ pub(crate) fn rsa_mul(a: &[u64], b: &[u64], m: &[u64], n0: u64, n: usize, out: &
})
}

/// a b / 2^256 mod P-256's p, for a, b < p, by an instruction path; `None`
/// where the CPU has no such path. The time does not depend on a or b.
#[inline]
pub(crate) fn p256_mul(a: &[u64; 4], b: &[u64; 4]) -> Option<[u64; 4]> {
hosted!(x86_64::p256_mul(a, b), {
let _ = (a, b);
None
})
}

/// a^2 / 2^256 mod P-256's p, for a < p, as [`p256_mul`].
#[inline]
pub(crate) fn p256_square(a: &[u64; 4]) -> Option<[u64; 4]> {
hosted!(x86_64::p256_square(a), {
let _ = a;
None
})
/// `computation` over P-256's field arithmetic on the CPU's kernels where it
/// has them, otherwise `P256Field`'s own. The dispatch is per computation:
/// no field operation inside it checks the CPU.
#[inline(always)]
pub(crate) fn p256<C: crate::montgomery::Computation<4>>(computation: C) -> C::Output {
hosted!(x86_64::p256(computation), computation.run::<crate::nist::P256Field>())
}

/// a b mod 2^255 - 19 in `field25519`'s four-word form, by an instruction
Expand Down
Loading