Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo runs the same extractor as every other component rather than its own copy, and gains the system spec that defines the PR/SR requirements its register traces up to. scripts/traceability-gate.sh is a thin wrapper holding only what is specific to this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate cannot infer once vendored, since its default resolves to the submodule itself. Each override fails silently in a way that looks like "no work done" rather than "misconfigured", so the wrapper documents why each is needed. Annotates 35 units with TRACES tags, on the code that decides rather than every helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is federation, and UR-015..018 are the pending SR-003 schema bump. The gate caught a real error in the first pass: several tags separated IDs of different types with commas. A comma joins IDs within one type; a pipe separates types. Fixed, and the diagnostics are now clean. MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100% ratio, a register parsing to nothing, or an empty source scan — raise the threshold as a ratchet once the remaining work lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
6.8 KiB
Rust
189 lines
6.8 KiB
Rust
//! §5a tokens and client-IP attribution.
|
|
//!
|
|
//! A token is **not an account** — it is an anonymous bearer capability. No
|
|
//! email, no verification, no personal data. It is stored only as a hash, so the
|
|
//! server cannot enumerate who holds tokens, and its sole purposes are
|
|
//! rate-limiting attribution (§5) and revocation.
|
|
//!
|
|
//! Discarding a token and requesting another is trivially easy, and that is
|
|
//! fine: the token is not the defence, the content checks are. Sybil resistance
|
|
//! is not required because identity is not load-bearing.
|
|
|
|
use std::net::IpAddr;
|
|
|
|
use axum::http::HeaderMap;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
/// Hashes a bearer token for storage and lookup.
|
|
///
|
|
/// Plain SHA-256 rather than a password KDF is deliberate and sufficient here:
|
|
/// tokens are 256 bits of server-generated randomness, not user-chosen secrets,
|
|
/// so there is no dictionary to attack.
|
|
/// TRACES: UR-005 | SR-004
|
|
pub fn hash_token(token: &str) -> String {
|
|
let mut h = Sha256::new();
|
|
h.update(token.as_bytes());
|
|
hex(&h.finalize())
|
|
}
|
|
|
|
/// Hashes a client IP for report attribution (§7 `reports.source_ip_hash`).
|
|
///
|
|
/// Salted with the server id so hashes are not comparable across instances.
|
|
pub fn hash_ip(ip: &str, server_id: &str) -> String {
|
|
let mut h = Sha256::new();
|
|
h.update(server_id.as_bytes());
|
|
h.update(b"\0");
|
|
h.update(ip.as_bytes());
|
|
hex(&h.finalize())
|
|
}
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
s.push_str(&format!("{b:02x}"));
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Generates a new token. Returned once to the caller; only its hash is stored.
|
|
pub fn generate_token() -> String {
|
|
use rand::RngCore;
|
|
let mut bytes = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut bytes);
|
|
format!("jray_{}", hex(&bytes))
|
|
}
|
|
|
|
/// Extracts a bearer token from an `Authorization` header.
|
|
pub fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
|
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
|
|
let (scheme, value) = raw.split_once(' ')?;
|
|
if !scheme.eq_ignore_ascii_case("bearer") {
|
|
return None;
|
|
}
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return None;
|
|
}
|
|
Some(value.to_string())
|
|
}
|
|
|
|
/// Resolves the client IP for rate-limiting and report attribution.
|
|
///
|
|
/// §8: the app must trust `X-Forwarded-For` **only** from the operator's proxy.
|
|
/// Rate limiting and report attribution key on client IP, so a spoofable header
|
|
/// defeats both — hence `trusted_proxies` is explicit configuration and an
|
|
/// untrusted peer's header is ignored outright.
|
|
/// TRACES: UR-004 | DR-008 | SR-004
|
|
pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -> String {
|
|
let peer_is_trusted = peer.is_some_and(|p| trusted_proxies.contains(&p));
|
|
|
|
if peer_is_trusted {
|
|
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
|
// Right-most entry is the one our trusted proxy appended; entries to
|
|
// its left are client-supplied and forgeable. Walk from the right
|
|
// past any further trusted hops.
|
|
for candidate in xff.split(',').rev().map(str::trim).filter(|s| !s.is_empty()) {
|
|
match candidate.parse::<IpAddr>() {
|
|
Ok(ip) if trusted_proxies.contains(&ip) => continue,
|
|
Ok(ip) => return ip.to_string(),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
peer.map(|p| p.to_string()).unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::http::HeaderValue;
|
|
|
|
fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
|
|
let mut h = HeaderMap::new();
|
|
for (k, v) in pairs {
|
|
h.insert(*k, HeaderValue::from_str(v).unwrap());
|
|
}
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn token_hash_is_stable_and_distinguishing() {
|
|
assert_eq!(hash_token("abc"), hash_token("abc"));
|
|
assert_ne!(hash_token("abc"), hash_token("abd"));
|
|
assert_eq!(hash_token("abc").len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_tokens_are_unique_and_prefixed() {
|
|
let a = generate_token();
|
|
let b = generate_token();
|
|
assert_ne!(a, b);
|
|
assert!(a.starts_with("jray_"));
|
|
assert_eq!(a.len(), 5 + 64);
|
|
}
|
|
|
|
#[test]
|
|
fn ip_hash_is_salted_per_server() {
|
|
// Hashes must not be comparable across instances.
|
|
assert_ne!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "b.example"));
|
|
assert_eq!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "a.example"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_bearer_tokens_case_insensitively() {
|
|
assert_eq!(
|
|
bearer_token(&headers(&[("authorization", "Bearer xyz")])).as_deref(),
|
|
Some("xyz")
|
|
);
|
|
assert_eq!(
|
|
bearer_token(&headers(&[("authorization", "bearer xyz")])).as_deref(),
|
|
Some("xyz")
|
|
);
|
|
assert!(bearer_token(&headers(&[("authorization", "Basic xyz")])).is_none());
|
|
assert!(bearer_token(&headers(&[("authorization", "Bearer ")])).is_none());
|
|
assert!(bearer_token(&HeaderMap::new()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn forwarded_header_from_an_untrusted_peer_is_ignored() {
|
|
// The whole point of §8's explicit trusted-proxy configuration: an
|
|
// arbitrary client must not be able to choose its own rate-limit key.
|
|
let h = headers(&[("x-forwarded-for", "9.9.9.9")]);
|
|
let peer: IpAddr = "203.0.113.7".parse().unwrap();
|
|
assert_eq!(client_ip(&h, Some(peer), &[]), "203.0.113.7");
|
|
}
|
|
|
|
#[test]
|
|
fn forwarded_header_from_a_trusted_proxy_is_honoured() {
|
|
let h = headers(&[("x-forwarded-for", "9.9.9.9")]);
|
|
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
|
|
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "9.9.9.9");
|
|
}
|
|
|
|
#[test]
|
|
fn client_supplied_entries_left_of_the_proxy_cannot_spoof() {
|
|
// A client that sends its own XFF gets its value appended to, not
|
|
// replaced, so only the right-most entry is trustworthy.
|
|
let h = headers(&[("x-forwarded-for", "9.9.9.9, 203.0.113.7")]);
|
|
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
|
|
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "203.0.113.7");
|
|
}
|
|
|
|
#[test]
|
|
fn walks_past_additional_trusted_hops() {
|
|
let inner: IpAddr = "10.0.0.2".parse().unwrap();
|
|
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
|
|
let h = headers(&[("x-forwarded-for", "203.0.113.7, 10.0.0.2")]);
|
|
assert_eq!(client_ip(&h, Some(proxy), &[proxy, inner]), "203.0.113.7");
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_forwarded_value_falls_back_to_the_peer() {
|
|
let h = headers(&[("x-forwarded-for", "not-an-ip")]);
|
|
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
|
|
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "127.0.0.1");
|
|
}
|
|
}
|