pakery_core/ct.rs
1//! Constant-time verification helpers (ctgrind pattern).
2//!
3//! These helpers wire secret material into Valgrind memcheck's definedness
4//! tracking, following the classic ctgrind idea (as used by graviola, aws-lc,
5//! and NSS): memory holding secrets is marked *undefined*, so any
6//! secret-dependent branch or memory index in the compiled artifact surfaces
7//! as a memcheck error when the test harness runs under
8//! `valgrind --track-origins=yes --error-exitcode=99`.
9//!
10//! Without the private `__ctgrind` cargo feature every function here compiles
11//! to a no-op; with it, they emit Valgrind client requests via [`crabgrind`]
12//! (a safe API — compatible with `#![forbid(unsafe_code)]`). The feature is
13//! internal to pakery's CI (`ct.yml`) and must never be enabled in production
14//! builds — the double-underscore prefix marks it exempt from semver.
15//!
16//! # Marking policy
17//!
18//! Marked secret at their byte boundaries: passwords, scalar/private-key
19//! bytes, DH outputs, OPRF outputs, PRKs and derived keys, envelope contents.
20//!
21//! Declassified (each call site carries a justification comment):
22//! - public-key and key-share encodings before wire serialization — public
23//! by protocol design;
24//! - MAC tags before wire output — public once sent;
25//! - ciphertext (e.g. OPAQUE `masked_response`) before wire output;
26//! - boolean accept/reject decisions after a `subtle::ct_eq` comparison
27//! ([`declassify_choice`]) — the abort/continue outcome is public;
28//! - copies of secret-scalar encodings fed to `curve25519-dalek` /
29//! `p256` deserializers ([`declassify`] on a temporary copy): their
30//! canonicity checks branch on a `subtle::CtOption` discriminant, which
31//! memcheck would flag inside the dependency. Canonicity of an honestly
32//! generated key is public information, and constant-time verification of
33//! dependency-internal primitives is out of scope here (tracked upstream;
34//! see SECURITY_TESTING.md, "Constant-time verification"). Where a parse is laundered
35//! this way, the *result* of the following group operation is re-marked
36//! secret so taint stays end-to-end.
37
38/// Whether the helpers emit real Valgrind client requests.
39///
40/// True only when the `__ctgrind` feature is on **and** crabgrind was built
41/// against real Valgrind headers. Without headers (e.g. a `--all-features`
42/// build on macOS or a runner without Valgrind installed) crabgrind falls
43/// back to stub headers whose version is the sentinel `0xBEDABEDA`, and
44/// every client request would panic at runtime — so the helpers downgrade
45/// to no-ops instead. `ct.yml` asserts this returns true (via the harness's
46/// `ct_harness_is_armed` test) to rule out a silently disarmed run.
47#[inline(always)]
48#[must_use]
49pub fn is_active() -> bool {
50 #[cfg(feature = "__ctgrind")]
51 {
52 crabgrind::VALGRIND_VERSION.0 != 0xBEDA_BEDA
53 }
54 #[cfg(not(feature = "__ctgrind"))]
55 false
56}
57
58/// Mark `bytes` as secret: Valgrind memcheck treats them as undefined, so any
59/// branch or memory index computed from them is reported as an error.
60///
61/// No-op unless the `__ctgrind` feature is enabled (and, at runtime, the
62/// process runs under Valgrind).
63#[inline(always)]
64pub fn mark_secret(bytes: &[u8]) {
65 #[cfg(feature = "__ctgrind")]
66 if is_active() {
67 let _ = crabgrind::memcheck::mark_memory(
68 bytes.as_ptr().cast(),
69 bytes.len(),
70 crabgrind::memcheck::MemState::Undefined,
71 );
72 }
73 #[cfg(not(feature = "__ctgrind"))]
74 let _ = bytes;
75}
76
77/// Declassify `bytes`: mark them as defined (public) for Valgrind memcheck.
78///
79/// Use only at boundaries where the data is genuinely public (wire output,
80/// public-key encodings, MAC tags being sent) — every call site must carry a
81/// justification comment. No-op unless the `__ctgrind` feature is enabled.
82#[inline(always)]
83pub fn declassify(bytes: &[u8]) {
84 #[cfg(feature = "__ctgrind")]
85 if is_active() {
86 let _ = crabgrind::memcheck::mark_memory(
87 bytes.as_ptr().cast(),
88 bytes.len(),
89 crabgrind::memcheck::MemState::Defined,
90 );
91 }
92 #[cfg(not(feature = "__ctgrind"))]
93 let _ = bytes;
94}
95
96/// Declassify the boolean outcome of a constant-time comparison.
97///
98/// The accept/reject decision after a `ct_eq` (MAC verification,
99/// identity-point rejection, shared-secret equality) is public: the protocol
100/// visibly aborts or continues. Converting `Choice → bool` directly would
101/// branch on secret-derived data (memcheck flags both the caller's `if` and
102/// `subtle`'s internal `debug_assert!`), so the decision byte is copied to
103/// the stack, declassified, and only then read.
104#[inline(always)]
105pub fn declassify_choice(choice: subtle::Choice) -> bool {
106 let mut byte = [choice.unwrap_u8()];
107 declassify(&byte);
108 // In release builds LLVM may keep the pre-declassify value in a register
109 // (`declassify` only takes a shared reference, so the memory is assumed
110 // unmodified across the call) and branch on that still-tainted copy.
111 // Routing the array through an opaque identity forces the branch below
112 // to re-read the now-defined memory.
113 let byte = core::hint::black_box(&mut byte);
114 byte[0] != 0
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 // Outside Valgrind these are behavioral no-ops; the tests pin that the
122 // helpers never alter values or crash, with and without `__ctgrind`.
123 #[test]
124 fn helpers_preserve_values() {
125 let data = [1u8, 2, 3];
126 mark_secret(&data);
127 declassify(&data);
128 assert_eq!(data, [1, 2, 3]);
129
130 assert!(declassify_choice(subtle::Choice::from(1)));
131 assert!(!declassify_choice(subtle::Choice::from(0)));
132 }
133
134 #[test]
135 fn helpers_accept_empty_slices() {
136 mark_secret(&[]);
137 declassify(&[]);
138 }
139}