agentmux_common/secret_eq.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Constant-time comparison for secrets presented by a caller.
5//!
6//! `==` on strings returns at the first differing byte, so how long a
7//! rejection takes tells a caller how many leading bytes of its guess were
8//! right. Every check of a caller-supplied secret against the real one (srv's
9//! auth key and LAN key, the CEF host's IPC token, a re-registering host's
10//! token, a webhook verify token) goes through [`secret_eq`] instead.
11//!
12//! The length is not hidden: a length mismatch returns early. Every secret
13//! compared here has a fixed, public length (UUIDs, fixed-size tokens), so
14//! the length reveals nothing.
15
16/// True when `supplied` equals `expected`, in time that depends only on the
17/// length, never on where the first difference is.
18pub fn secret_eq(supplied: &[u8], expected: &[u8]) -> bool {
19 if supplied.len() != expected.len() {
20 return false;
21 }
22 let mut diff: u8 = 0;
23 for (x, y) in supplied.iter().zip(expected.iter()) {
24 diff |= x ^ y;
25 }
26 // Keep the optimizer from turning the loop back into an early exit.
27 std::hint::black_box(diff) == 0
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33
34 #[test]
35 fn equal_secrets_match() {
36 assert!(secret_eq(b"3f2b9c1e-auth-key", b"3f2b9c1e-auth-key"));
37 }
38
39 #[test]
40 fn same_length_different_secrets_do_not_match() {
41 assert!(!secret_eq(b"3f2b9c1e-auth-key", b"3f2b9c1e-auth-kez"));
42 assert!(!secret_eq(b"Xf2b9c1e-auth-key", b"3f2b9c1e-auth-key"));
43 }
44
45 #[test]
46 fn different_lengths_do_not_match() {
47 assert!(!secret_eq(b"short", b"a-much-longer-secret"));
48 assert!(!secret_eq(b"3f2b9c1e-auth-key", b"3f2b9c1e-auth-key-"));
49 }
50
51 #[test]
52 fn empty_matches_only_empty() {
53 assert!(secret_eq(b"", b""));
54 assert!(!secret_eq(b"", b"x"));
55 }
56}