Initial implementation: core vertical slice
Implements the core of SPEC.md — the manifest exchange, less audio-tier matching (§3) and federation (§9a), both of which the spec sequences as later work. - §2 Jmanifest format and series bundles - §3 cut matching: exact / runtime / loose tiers - §4 API, less POST /manifests/search - §5 rate limiting; §5a trust model, anonymous bearer tokens - §6 upload validation, all four stages - §7 relational storage, no JSON blob on the write path - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §9a content addressing, computed on upload Reconciled against the system spec: - anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows track extent, so a track survives its own gaps and there is nothing to anneal. Its successor extinction_sec and the new gallery_scope are accepted and stored; scope enters the §7 ranking. A manifest still carrying anneal_sec is a hard 400, not silently ignored — it came from a pipeline whose window semantics differ from what this server assumes. - Audio signature: media under 120 s now emits no signature at all, matching scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened window under 150 s, which was the weaker rule — a caller-varying length is the property SR-004 forbids. - UR IDs regularised to UR-nnn; docs/requirements.md registers 32 requirements, each tracing to an SR-nnn or PR-nnn. 189 tests: unit, end-to-end through the real router, and an injection suite covering SQL, JSON, header and Unicode payloads. Writing that suite found two real gaps, both fixed here: compatibility homoglyphs passed the §5a character class, and a one-frame audio signature was accepted on a feature-length item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+186
@@ -0,0 +1,186 @@
|
||||
//! §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.
|
||||
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.
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user