Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@
- **Decision:** qsl-server gains the three community-health files `SECURITY.md`, `CODE_OF_CONDUCT.md`, and `CONTRIBUTING.md`, adapted from the qsl-protocol spine's files as the source of truth (qsl-protocol NA-0655 / QSL-DIR-2026-07-18-591 (D591) / D-1278). `SECURITY.md` carries the spine's reporting section verbatim plus a repo-scope section naming this repository (a research-stage, transport-only relay server) and routing protocol-level reports to qsl-protocol; it describes REPORTING only and adds no assurance claims. `CODE_OF_CONDUCT.md` is byte-identical to the spine's (Contributor Covenant 2.1). `CONTRIBUTING.md` states this repository's real gate (the single required `rust` check on every PR; merge commits only) and the spine-governed lane reality. README, LICENSE, NOTICE, code, tests, dependencies, workflows, and repository settings are untouched by this change.
- **Rationale:** The org-level `QuantumShieldLabs/.github` defaults already provide generic community-health files and explicitly defer to per-repo files; the per-repo files add the repo-specific scope line and the honest contribution reality the org defaults cannot express. Reviewer outreach (qsl-protocol DOC-PROG-004 parallel track) makes this surface timely. Governance authority lives in qsl-protocol.
- **References:** qsl-protocol NA-0655 / QSL-DIR-2026-07-18-591 (D591) / D-1278; `SECURITY.md`; `CODE_OF_CONDUCT.md`; `CONTRIBUTING.md`

- **ID:** D-0014
- **Status:** Accepted
- **Date:** 2026-07-23
- **Goals:** G4, G5
- **Decision:** qsl-server's bearer-token check in `auth_ok` no longer uses `provided == token` (`str::eq`, which short-circuits on the first differing byte). Both sides are now reduced to a fixed 32-byte SHA-256 digest and folded with an XOR-accumulate loop (`diff |= da[i] ^ db[i]` over all 32 bytes, `diff == 0`) in a new private `ct_eq_secret` helper — the same shape as the qsc client handshake's `hs_ct_eq_32` (qsl-protocol ENG-0003) and consistent with this file's existing `route_key_for`, which already hashes the OTHER secret (the route token). Hashing first normalizes both inputs to 32 bytes, so the fold does identical work for every input, closing the length leak as well. The two non-secret `return false` guards (missing / malformed `Authorization` header) are retained — they branch on attacker-known request shape, not on the secret. No new dependency: `sha2` is already a direct dependency of this crate (`subtle` is present in `Cargo.lock` only via rustls under the reqwest DEV-dependency and is deliberately NOT added to the production graph). No wire, API, protocol, schema, or env change; the four gated handlers (`server-info`, `push`, `pull`, `pull/ack`) still reject-before-mutation because `auth_ok` remains their first statement. A same-length wrong-token reject test (`auth_enabled_wrong_token_same_length_401_no_mutation`, `"topsecreX"` vs `"topsecret"`, both 9 bytes) is added because the pre-existing wrong-token test uses different-length tokens, so `==` rejected on length before comparing a byte and passed against the buggy code.
- **Rationale:** This is the last unfixed HIGH (C-2) from the 2026-07-22 independent audit and the only one outside the governance spine, on the single component deliberately exposed to the network. On a shared bearer token, `str::eq`'s short-circuit is a remote timing oracle; the LAN/tailnet deployment posture is exactly the low-jitter regime in which byte-at-a-time statistical amplification is practical. Fixing it in the file's existing idiom (SHA-256 then fold) makes the relay treat both of its secrets the same way, which is the strongest form of the fix — the property earned is structural (fixed work over the full digest, no data-dependent early return), read-verified, not a measured timing claim. `subtle::ConstantTimeEq` was deliberately not used: it would newly enter the production dependency graph, and — being defined for equal-length slices — would not close the length leak without a length-visible branch, whereas hashing-first closes it for free. HMAC with a random per-process key was not used: its random key defends against precomputation, but an attacker who already holds a candidate token can simply send it, so plain SHA-256 delivers the needed property (comparison time independent of matching-prefix length) with no added machinery. Governance authority lives in qsl-protocol (NA-0670, D606, D-1297).
- **References:** qsl-protocol NA-0670 / QSL-DIR-2026-07-23-606 (D606) / D-1297; 2026-07-22 independent audit finding C-2; qsl-protocol ENG-0003 (client `hs_ct_eq_32`); `src/lib.rs` (`auth_ok`, `ct_eq_secret`); `TRACEABILITY.md`
1 change: 1 addition & 0 deletions TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
- NA-0011 DONE — PR #42 (https://github.com/QuantumShieldLabs/qsl-server/pull/42) merged (merge SHA d66ea363bf6a45f550114c52b98b3f750b166c6c); the live relay at `qsl.ddnsfree.com` now serves the canonical header-based API expected by current qsc, legacy compatibility remains present truthfully, and the project now fails fast on legacy-only deployments before real-world validation starts.
- NA-0012 implementation — `src/lib.rs`; `tests/relay_smoke.rs`; `README.md`; `docs/server/DOC-SRV-003_Relay_Inbox_Contract_v1.0.0_DRAFT.md`; `packaging/runbook_ubuntu.md`; `scripts/check_relay_compatibility.sh`; `scripts/ci/test_relay_deploy_compatibility_guard.sh`; `scripts/verify_remote.sh`; `DECISIONS.md` — route-token URI compatibility is retired, canonical header carriage remains authoritative, and deployment guards now fail when the legacy path-token surface is still enabled.
- NA-0012 DONE — PR #45 (https://github.com/QuantumShieldLabs/qsl-server/pull/45) merged (merge SHA 550bd3b0ea9916fb892e8468589727fa35e33720); legacy `/v1/push/:channel` and `/v1/pull/:channel?max=N` ingress is now retired outright, canonical header-carried routing remains authoritative, relay auth and transport-only semantics stay unchanged, and the repo-local queue returns truthfully to `READY=0`.
- NA-0670 implementation — `src/lib.rs` (`auth_ok` now folds via the new constant-time `ct_eq_secret`; same-length wrong-token reject test added); `DECISIONS.md` (Decision D-0014); `TRACEABILITY.md` — constant-time bearer-token comparison (2026-07-22 independent audit finding C-2), governed by qsl-protocol NA-0670 / QSL-DIR-2026-07-23-606 (D606) / D-1297. PR #64 (https://github.com/QuantumShieldLabs/qsl-server/pull/64).
66 changes: 65 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,32 @@ fn auth_ok(headers: &HeaderMap, relay_token: Option<&str>) -> bool {
let Some(provided) = raw.strip_prefix("Bearer ") else {
return false;
};
provided == token
// Constant-time credential check. `str::eq` short-circuits on the first
// differing byte -> a remote timing oracle on a network-exposed bearer
// token (cf. the client's `hs_ct_eq_32`, ENG-0003, and this file's own
// `route_key_for`). Reducing both sides to a fixed 32-byte digest first
// also removes length dependence: the fold does identical work for every
// input. NB: `subtle` is present in Cargo.lock only via rustls under the
// reqwest DEV-dependency, so it is NOT in the production graph -- do not
// reach for it here; `sha2` is already a direct dependency of this crate.
ct_eq_secret(provided, token)
}
}
}

/// Constant-time equality of two secrets, folded over a fixed 32-byte SHA-256
/// digest so the comparison leaks neither content nor length. Same XOR-accumulate
/// shape as the client handshake's `hs_ct_eq_32` (ENG-0003).
fn ct_eq_secret(a: &str, b: &str) -> bool {
let da = Sha256::digest(a.as_bytes());
let db = Sha256::digest(b.as_bytes());
let mut diff = 0u8;
for i in 0..32 {
diff |= da[i] ^ db[i];
}
diff == 0
}

fn resolve_route_token(headers: &HeaderMap) -> Result<String, &'static str> {
match headers.get(ROUTE_TOKEN_HEADER) {
None => Err("ERR_MISSING_ROUTE_TOKEN"),
Expand Down Expand Up @@ -1177,6 +1198,49 @@ mod tests {
handle.abort();
}

// Same-length wrong token: "topsecreX" vs the configured "topsecret", both 9
// bytes. The wrong_token test above uses "wrong" (5 bytes), so the old `==`
// rejected on length before comparing a byte and passed against the buggy code.
// This case is the only behavioural test that exercises the constant-time fold
// -- it proves the fold returns the right ANSWER (reject + no mutation), not
// that it runs in constant TIME (a structural, read-verified claim; see §3).
#[tokio::test]
async fn auth_enabled_wrong_token_same_length_401_no_mutation() {
let (base, handle) = spawn_server_with_token(
Limits {
max_body_bytes: 1024 * 1024,
max_queue_depth: 8,
},
Some("topsecret".to_string()),
)
.await;
let client = reqwest::Client::new();
let push = client
.post(format!("{}/v1/push", base))
.header(ROUTE_TOKEN_HEADER, "auth")
.header("Authorization", "Bearer topsecreX")
.body(b"x".to_vec())
.send()
.await
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(push.status(), ReqStatus::UNAUTHORIZED);
assert_eq!(
push.text().await.unwrap_or_else(|e| panic!("{e}")),
"ERR_UNAUTHORIZED"
);

// Queue unmutated: the correct token drains nothing.
let pull_ok = client
.get(format!("{}/v1/pull?max=1", base))
.header(ROUTE_TOKEN_HEADER, "auth")
.header("Authorization", "Bearer topsecret")
.send()
.await
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(pull_ok.status(), ReqStatus::NO_CONTENT);
handle.abort();
}

#[tokio::test]
async fn auth_enabled_correct_token_allows_roundtrip() {
let (base, handle) = spawn_server_with_token(
Expand Down