Use constant-time comparison for token and credential verification#506
Use constant-time comparison for token and credential verification#506
Conversation
Replace standard == comparisons with subtle::ConstantTimeEq in the three places that verify secrets: tstoken signature in proxy.rs, clear-URL signature in http_util.rs, and Basic Auth credentials in auth.rs. The auth fix also removes the && short-circuit that created a username- existence oracle — both username and password are now always evaluated using bitwise & on subtle::Choice values. Adds log::warn on auth failure (path only, no credentials) and five targeted tests covering tampered tokens, wrong-username-right-password, and empty tokens. Closes #410
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Verdict: Clean, well-focused security hardening PR. Ready to merge with minor improvements.
Excellent anti-oracle design in auth.rs — bitwise & on subtle::Choice instead of && prevents username-existence oracle. Minimal, focused changes — only touches the 3 comparison sites + targeted tests. Good test coverage (5 new tests). subtle v2 is the right dependency choice.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Approved — ready to merge
This PR correctly hardens the three token/credential comparison sites with constant-time operations from the subtle crate. The changes are minimal, focused, and well-documented.
Highlights
- Excellent anti-oracle design in
auth.rs: using bitwise&onsubtle::Choiceinstead of&&prevents username-existence oracles. The inline comment explaining why is exactly what future maintainers need. - Well-chosen tests covering tampered tokens (same length, wrong bytes), wrong-username/right-password, correct-username/wrong-password, and empty tokens.
- Good doc comments explaining security invariants on
enforce_basic_auth,verify_clear_url_signature, and inline inproxy.rs. log::warn!on auth failure logs path only (no credentials) — correct observability without leaking secrets.- Minimal, focused changes — only the 3 comparison sites plus targeted tests. No unnecessary refactoring.
- All CI checks pass (fmt, test, clippy, vitest, CodeQL).
Minor note (not actionable for this PR)
extract_credentialsinauth.rs(lines 49-73) has pre-existing early returns that leak timing about credential format (missing header, wrong scheme, malformed base64). This is not introduced by this PR and is fine — the CT comparison correctly protects credential values, not header format parsing.
aram356
left a comment
There was a problem hiding this comment.
Summary
This PR replaces == comparisons with subtle::ConstantTimeEq in all three secret-verification sites (tstoken, clear-URL signature, Basic Auth), fixing timing side-channel vulnerabilities. The bitwise & in auth to eliminate the username-existence oracle is textbook. One structural issue to address before merge.
Blocking
🔧 wrench
Redacted::PartialEqre-introduces the timing oracle this PR fixes:PartialEq for Redacted<T>(inredacted.rs:69-72, not in this PR's diff but directly relevant) delegates toT::PartialEq, which forStringis a short-circuiting byte comparison. This is the type wrappingproxy_secret,username, andpassword. While the call sites in this PR correctly useexpose()+ct_eq(), thePartialEqimpl creates an attractive nuisance — a future developer comparingRedactedvalues directly would unknowingly bypass constant-time. Consider removing thePartialEqimpls or implementing viact_eqin a follow-up.
❓ question
reconstruct_and_validate_signed_targetreturns 502 for invalid tstoken (proxy.rs:1084): A tampered or forged tstoken is a client error, not a gateway error. Should this be 400 Bad Request or 403 Forbidden instead of 502 Bad Gateway?
Non-blocking
♻️ refactor
- Duplicated constant-time comparison pattern: The
len == len && bool::from(ct_eq)pattern appears inauth.rs:39,http_util.rs:331, andproxy.rs:1082. Extract act_str_eq(a, b) -> boolhelper to prevent future copies from forgetting the length check orbool::fromconversion. (auth.rs:39)
🌱 seedling
sign_clear_urlis SHA-256(prefix || secret || message), not HMAC (http_util.rs:320): The doc comment acknowledges this. The construction is safe here because URLs are validated against the signature (not extended), butSHA-256(prefix || secret || msg)is vulnerable to length-extension if reused elsewhere. Consider migrating tohmac::Hmac<Sha256>in a future PR —sha2is already a dependency.
⛏ nitpick
- PR body checklist says "Uses
tracingmacros (notprintln!)" but the codebase useslog, nottracing— minor template inconsistency.
CI Status
- cargo fmt: PASS
- cargo clippy: PASS
- cargo test: PASS
- vitest: PASS
- integration tests: PASS
- browser integration tests: PASS
- CodeQL: PASS
Resolve conflicts introduced by main's upgrade to SHA-256-based credential comparison in auth.rs: - Take main's Sha256::digest + ct_eq approach over the branch's ct_str_eq, as hashing to a fixed-length digest eliminates the length oracle that raw ct_eq exposes on unequal-length inputs - Remove now-unused import - Replace settings_with_handlers() test helper (not present in merged state) with create_test_settings(), which is functionally equivalent - Remove duplicate challenge_when_username_* tests brought in from main - Accept main's subtle = 2.6 pin in Cargo.toml
aram356
left a comment
There was a problem hiding this comment.
Summary
Solid hardening PR that replaces == comparisons with subtle::ConstantTimeEq across all three secret-verification sites (tstoken, clear-URL signature, Basic Auth), closing timing side-channel attack vectors. The Redacted<T> PartialEq removal is a nice touch to prevent future regressions.
Blocking
🔧 wrench
Unauthorizedvariant name contradicts its 403 status code: The new error variant is namedUnauthorizedbut maps toStatusCode::FORBIDDEN(403). In HTTP, 401 is "Unauthorized" and 403 is "Forbidden". Suggest renaming toForbiddento match the status code semantics. (error.rs:69)
❓ question
- Status code change from 502 → 403 for invalid tstoken: Previously invalid tstoken returned
Proxy→ 502 Bad Gateway. Now returnsUnauthorized→ 403 Forbidden. 403 is semantically correct, but this is a client-visible behavior change — any clients or monitoring keying off 502 for this? (proxy.rs:1080)
Non-blocking
🤔 thinking
ct_str_eqlength check leaks length equality (by design): The&&short-circuits on length mismatch. Documented as safe for current call-sites (fixed-length base64url SHA-256 digests), but the function ispub— a future caller with variable-length secrets could misuse it. Considerpub(crate)or a# Securitydoc section. (http_util.rs:339)
🌱 seedling
- Consider HMAC-SHA-256 for URL signing:
sign_clear_urlusesSHA-256(prefix || secret || url)rather than HMAC-SHA-256. Not practically exploitable here, but HMAC is the standard construct for keyed message authentication. Out of scope — worth a follow-up issue.
CI Status
- cargo fmt: PASS
- cargo clippy: PASS
- cargo test: PASS
- vitest: PASS
- integration tests: PASS
- browser integration tests: PASS
- CodeQL: PASS
| #[display("Proxy error: {message}")] | ||
| Proxy { message: String }, | ||
|
|
||
| /// Authorization failure — request understood but not permitted. |
There was a problem hiding this comment.
🔧 wrench — Unauthorized variant name contradicts its 403 status code
In HTTP, 401 is "Unauthorized" and 403 is "Forbidden". This variant maps to StatusCode::FORBIDDEN (403) but is named Unauthorized, which will mislead maintainers into expecting a 401.
The doc comment ("Authorization failure — request understood but not permitted") describes 403 perfectly — the name just doesn't match.
Fix: Rename to Forbidden:
/// Authorization failure — request understood but not permitted.
#[display("Forbidden: {message}")]
Forbidden { message: String },And update the usage in proxy.rs accordingly.
| // Constant-time comparison to prevent timing side-channel attacks on the token. | ||
| // Length is not secret (always 43 bytes for base64url-encoded SHA-256), | ||
| // but we check explicitly to document the invariant. | ||
| if !ct_str_eq(&expected, &sig) { |
There was a problem hiding this comment.
❓ question — Status code change from 502 → 403 for invalid tstoken: intentional?
Previously, an invalid tstoken returned TrustedServerError::Proxy → 502 Bad Gateway. Now it returns Unauthorized → 403 Forbidden. This is a client-visible behavior change.
403 is semantically much better than 502 for a signature validation failure — 502 implied an upstream failure, not a client error. But are there any clients or monitoring that key off the 502 for invalid tokens?
| /// ``` | ||
| #[must_use] | ||
| pub fn ct_str_eq(a: &str, b: &str) -> bool { | ||
| a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes())) |
There was a problem hiding this comment.
🤔 thinking — ct_str_eq length check leaks length equality (by design)
The && short-circuits on length mismatch, revealing whether lengths match via timing. The doc comment correctly documents this as safe because token lengths are public (always 43 bytes for base64url-encoded SHA-256).
However, the function is pub — a future caller with variable-length secrets could misuse it. Consider either:
pub(crate)to limit exposure, or- Adding a
# Securitydoc section noting the length-leak invariant
Not blocking since all current call-sites use fixed-length inputs, and auth.rs correctly uses SHA-256 hashing to normalize lengths before calling ct_eq directly.
Summary
==comparisons withsubtle::ConstantTimeEqin all three secret-verification sites (tstoken, clear-URL signature, Basic Auth), eliminating timing side-channel attacks&&allowed an attacker to distinguish wrong-username from wrong-password via timing — replaced with bitwise&onsubtle::Choiceso both fields are always evaluatedlog::warn!on auth failure (path only, no credentials) and five new targeted tests covering each fixChanges
Cargo.tomlsubtle = "2"to workspace dependenciescrates/common/Cargo.tomlsubtleas direct dependencycrates/common/src/auth.rs&instead of&&;log::warn!on failure; 2 new testscrates/common/src/http_util.rsverify_clear_url_signature; 2 new testscrates/common/src/proxy.rsreconstruct_and_validate_signed_target; 1 new testCloses
Closes #410
Test plan
cargo test --workspacecargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest runcd crates/js/lib && npm run formatcd docs && npm run formatcargo build --bin trusted-server-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)