Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(security): constant-time plaintext password comparison

The Password::Plain verify branch used a short-circuiting byte-slice
compare (candidate.as_bytes() == p.as_bytes()) that leaked password
length and the matching-prefix length via response timing, letting an
attacker recover a plaintext OPER/link/allow/WEBIRC secret byte-by-byte.
Compare via fixed-width SHA-256 digests folded with subtle's
ConstantTimeEq so timing is independent of both prefix and length.
Promote subtle to a direct dependency (already resolved transitively).

+34 -2
+1
Cargo.lock
··· 1225 1225 "serde", 1226 1226 "serde_json", 1227 1227 "sha2 0.10.9", 1228 + "subtle", 1228 1229 "thiserror 2.0.18", 1229 1230 "tokio", 1230 1231 "tokio-rustls",
+3
leveva/Cargo.toml
··· 81 81 # text is fully supported. The canonical unicode-rs crate; no transitive deps. 82 82 unicode-segmentation = "1" 83 83 pwhash = "1.0.0" 84 + # Constant-time equality (`ConstantTimeEq`) for verifying a `(plain)`-configured 85 + # secret without leaking the matching prefix (or length) via response latency. 86 + subtle = "2" 84 87 rust-embed = "8" 85 88 # Chat-history persistence (IRCv3 draft/chathistory). SQLite via rusqlite with the 86 89 # `bundled` feature so no system libsqlite3 is required — the SQLite amalgamation is
+30 -2
leveva/src/config/password.rs
··· 28 28 //! a value that looks like *some other* crypt format is an error (use 29 29 //! `(plain)` if it really is a literal password); otherwise [`Plain`]. 30 30 31 + use sha2::{Digest, Sha256}; 32 + use subtle::ConstantTimeEq; 33 + 31 34 /// A password as configured: a SHA-512 `crypt(3)` hash, or literal plaintext. 32 35 #[derive(Debug, Clone, PartialEq, Eq)] 33 36 pub enum Password { ··· 77 80 78 81 /// Verify a candidate plaintext against this stored credential. 79 82 /// 80 - /// - [`Plain`](Password::Plain) → a direct byte compare. 83 + /// - [`Plain`](Password::Plain) → a constant-time compare via [`plain_eq`]; the 84 + /// running time is independent of the matching prefix and of the length 85 + /// difference, so latency cannot be used to recover the secret byte-by-byte. 81 86 /// - [`Hashed`](Password::Hashed) → re-hash `candidate` with the stored hash as its own 82 87 /// salt and constant-time compare, via [`pwhash::sha512_crypt::verify`]. A 83 88 /// structurally-broken hash is a non-match, never a panic. 84 89 pub fn verify(&self, candidate: &str) -> bool { 85 90 match self { 86 - Password::Plain(p) => candidate.as_bytes() == p.as_bytes(), 91 + Password::Plain(p) => plain_eq(candidate.as_bytes(), p.as_bytes()), 87 92 Password::Hashed(stored) => pwhash::sha512_crypt::verify(candidate, stored), 88 93 } 89 94 } 95 + } 96 + 97 + /// Constant-time equality for a plaintext password compare. 98 + /// 99 + /// A naive `a == b` on the raw bytes short-circuits on the first differing byte 100 + /// (and on a length mismatch), leaking both the length and the matching prefix 101 + /// through timing. `subtle`'s slice `ct_eq` still early-returns on unequal 102 + /// lengths, so instead both sides are reduced to fixed-width SHA-256 digests and 103 + /// those are compared with [`ConstantTimeEq`]: the comparison always folds over 104 + /// exactly 32 bytes, its running time is independent of where (or whether) the 105 + /// inputs first differ, and a collision would require breaking SHA-256. 106 + fn plain_eq(a: &[u8], b: &[u8]) -> bool { 107 + let da = Sha256::digest(a); 108 + let db = Sha256::digest(b); 109 + da.ct_eq(&db).into() 90 110 } 91 111 92 112 /// Whether `s` is a well-formed SHA-512 `crypt(3)` hash: `$6$[rounds=N$]salt$hash` ··· 214 234 assert!(!p.verify("")); 215 235 // A NUL in the candidate can never match a NUL-free stored value. 216 236 assert!(!p.verify("hunter2\0extra")); 237 + // A candidate that shares a prefix but differs in length is rejected — 238 + // the constant-time compare never accepts on a prefix match. 239 + assert!(!p.verify("hunter")); 240 + assert!(!p.verify("hunter22")); 241 + // An empty stored password only matches an empty candidate. 242 + let empty = Password::Plain(String::new()); 243 + assert!(empty.verify("")); 244 + assert!(!empty.verify("x")); 217 245 } 218 246 219 247 #[test]