use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::Mutex;
pub const MAX_BODY: usize = 40 * 1024 * 1024;
const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const MAX_INFLIGHT: usize = 64;
const MAX_HEAD_BYTES: u64 = 64 * 1024;
const MAX_HEADERS: usize = 200;
const DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
static BODY_BYTES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
const MAX_BODY_BYTES_INFLIGHT: usize = 96 * 1024 * 1024;
struct BodyReservation(usize);
impl BodyReservation {
fn take(n: usize) -> Option<Self> {
use std::sync::atomic::Ordering;
let mut cur = BODY_BYTES.load(Ordering::SeqCst);
loop {
if cur + n > MAX_BODY_BYTES_INFLIGHT {
return None;
}
match BODY_BYTES.compare_exchange(cur, cur + n, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => return Some(BodyReservation(n)),
Err(actual) => cur = actual,
}
}
}
}
impl Drop for BodyReservation {
fn drop(&mut self) {
BODY_BYTES.fetch_sub(self.0, std::sync::atomic::Ordering::SeqCst);
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Registration {
pub sign_key: String,
pub box_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proof: Option<String>,
}
struct State {
root: PathBuf,
owners: HashMap<String, String>,
challenges: HashMap<String, String>,
pending: HashMap<(String, String), (String, std::time::Instant)>,
challenge_age: HashMap<String, std::time::Instant>,
limits: Limits,
rate: HashMap<String, (u32, std::time::Instant)>,
}
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub mailbox_bytes: u64,
pub mailbox_msgs: usize,
pub store_bytes: u64,
pub per_min: u32,
pub mailboxes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
mailbox_bytes: 64 * 1024 * 1024,
mailbox_msgs: 500,
store_bytes: 4 * 1024 * 1024 * 1024,
per_min: 120,
mailboxes: 2_000,
}
}
}
fn dir_bytes(p: &std::path::Path) -> u64 {
let Ok(rd) = std::fs::read_dir(p) else {
return 0;
};
rd.flatten()
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum()
}
fn dir_count(p: &std::path::Path) -> usize {
std::fs::read_dir(p)
.map(|rd| rd.flatten().count())
.unwrap_or(0)
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
fn b64_ok(s: &str) -> bool {
use base64::{Engine, engine::general_purpose::STANDARD as B64};
matches!(B64.decode(s), Ok(v) if v.len() == 32)
}
fn key_to_dirname(k: &str) -> Option<String> {
if !b64_ok(k) {
return None;
}
Some(k.replace('/', "_").replace('+', "-").replace('=', ""))
}
#[allow(dead_code)]
pub fn serve(bind: &str, root: PathBuf) -> std::io::Result<()> {
serve_with(bind, root, Limits::default())
}
pub fn serve_with(bind: &str, root: PathBuf, limits: Limits) -> std::io::Result<()> {
std::fs::create_dir_all(root.join("mail"))?;
let owners = std::fs::read_to_string(root.join("owners.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
*STATE.lock().unwrap() = Some(State {
root: root.clone(),
owners,
challenges: HashMap::new(),
challenge_age: HashMap::new(),
pending: HashMap::new(),
limits,
rate: HashMap::new(),
});
let l = TcpListener::bind(bind)?;
println!("gage relay on {bind}, store at {}", root.display());
println!(" it cannot read what it stores; envelopes are sealed to recipients");
println!(
" limits: {} MB and {} envelopes per mailbox, {} MB total, {} requests/min per address",
limits.mailbox_bytes / (1024 * 1024),
limits.mailbox_msgs,
limits.store_bytes / (1024 * 1024),
limits.per_min
);
println!(" at most {} mailboxes", limits.mailboxes);
let live = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
for s in l.incoming() {
let s = match s {
Ok(s) => s,
Err(e) => {
eprintln!("relay: accept: {e}");
continue;
}
};
let _ = s.set_read_timeout(Some(IO_TIMEOUT));
let _ = s.set_write_timeout(Some(IO_TIMEOUT));
let n = live.load(std::sync::atomic::Ordering::SeqCst);
if n >= MAX_INFLIGHT {
std::thread::spawn(move || {
let _ = reply(
s,
"503 Service Unavailable",
r#"{"error":"relay busy, retry"}"#,
);
});
continue;
}
live.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let live2 = live.clone();
std::thread::spawn(move || {
if let Err(e) = handle(s) {
eprintln!("relay: {e}");
}
live2.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
});
}
Ok(())
}
const ROOT_PAGE: &str = r#"<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>a gage relay</title>
<style>
body{max-width:38rem;margin:12vh auto;padding:0 1.25rem;
font:16px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
color:#191815;background:#fbfaf8}
h1{font-size:1.35rem;letter-spacing:-.02em;margin:0 0 1rem}
p{margin:0 0 1rem}
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9rem;
background:#f3f1ec;border:1px solid #e7e3dc;border-radius:6px;padding:.1rem .35rem}
pre{background:#f3f1ec;border:1px solid #e7e3dc;border-radius:10px;padding:.8rem 1rem;
overflow-x:auto;font-size:.9rem}
.q{color:#5b5751;font-size:.95rem;border-left:2px solid #d6d1c7;padding-left:1rem;
margin:1.5rem 0}
@media(prefers-color-scheme:dark){
body{color:#edebe7;background:#121316}
code,pre{background:#232529;border-color:#2b2e34}
.q{color:#a8a49d;border-color:#3a3e45}
}
</style>
<h1>This is a gage relay.</h1>
<p>It holds sealed mail for agents whose humans have confirmed each other, and
hands each one over only to whoever proves they hold the matching key.</p>
<p><b>It cannot read anything it stores.</b> Every envelope is sealed to its
recipient before it arrives here. Whoever runs this machine can see that mail
exists, how big it is and when it moved — and not a word of what it says.</p>
<p>If someone gave you this address, they will have given you a command too:</p>
<pre>gage connect https://<this address></pre>
<p>That makes you an identity, registers a mailbox here, and prints one line
for you to send them. Then you each add the other and read four word-pairs
aloud, on a phone call or in person — not in the same place you sent the
line. That comparison is the whole point, and it is the one step nothing can
do for you.</p>
<div class="q">There is nothing to log into here, and nothing to see. A relay
is a letterbox: the decisions all happen on the two machines at either end.</div>
<p><code>/health</code> is the only other thing a browser can usefully ask.</p>
"#;
fn reply_html(mut s: TcpStream, status: &str, body: &str) -> std::io::Result<()> {
let h = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nX-Content-Type-Options: nosniff\r\n\
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'\r\n\
Connection: close\r\n\r\n",
body.len()
);
s.write_all(h.as_bytes())?;
s.write_all(body.as_bytes())?;
s.flush()?;
let _ = s.shutdown(std::net::Shutdown::Both);
Ok(())
}
fn reply(mut s: TcpStream, status: &str, body: &str) -> std::io::Result<()> {
let h = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
s.write_all(h.as_bytes())?;
s.write_all(body.as_bytes())?;
s.flush()?;
let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(50)));
let mut sink = [0u8; 4096];
for _ in 0..16 {
match s.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
let _ = s.shutdown(std::net::Shutdown::Both);
Ok(())
}
fn over_rate(peer: &str) -> bool {
let ip = match peer.parse::<std::net::SocketAddr>() {
Ok(std::net::SocketAddr::V4(a)) => a.ip().to_string(),
Ok(std::net::SocketAddr::V6(a)) => {
let o = a.ip().octets();
format!(
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}::/64",
o[0], o[1], o[2], o[3], o[4], o[5], o[6], o[7]
)
}
Err(_) => peer.to_string(),
};
let mut g = STATE.lock().unwrap();
let st = g.as_mut().unwrap();
let per_min = st.limits.per_min;
let now = std::time::Instant::now();
let window = std::time::Duration::from_secs(60);
st.rate
.retain(|_, (_, at)| now.duration_since(*at) < window);
let e = st.rate.entry(ip).or_insert((0, now));
if now.duration_since(e.1) >= window {
*e = (0, now);
}
e.0 += 1;
e.0 > per_min
}
fn handle(stream: TcpStream) -> std::io::Result<()> {
let peer = stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| "unknown".to_string());
if over_rate(&peer) {
return reply(stream, "429 Too Many Requests", r#"{"error":"slow down"}"#);
}
let started = std::time::Instant::now();
let mut r = BufReader::new(stream.try_clone()?);
let mut line = String::new();
{
let mut head = (&mut r).take(MAX_HEAD_BYTES);
head.read_line(&mut line)?;
}
if line.is_empty() {
return reply(stream, "400 Bad Request", r#"{"error":"no request line"}"#);
}
let mut it = line.split_whitespace();
let method = it.next().unwrap_or("").to_string();
let target = it.next().unwrap_or("/").to_string();
let mut len = 0usize;
let mut headers = 0usize;
let mut head_budget = MAX_HEAD_BYTES.saturating_sub(line.len() as u64);
loop {
let mut h = String::new();
let n = {
let mut head = (&mut r).take(head_budget);
head.read_line(&mut h)?
};
head_budget = head_budget.saturating_sub(n as u64);
if head_budget == 0 {
return reply(
stream,
"431 Request Header Fields Too Large",
r#"{"error":"head too large"}"#,
);
}
if n == 0 || h.trim().is_empty() {
break;
}
headers += 1;
if headers > MAX_HEADERS {
return reply(
stream,
"431 Request Header Fields Too Large",
r#"{"error":"too many headers"}"#,
);
}
if started.elapsed() > DEADLINE {
return reply(
stream,
"408 Request Timeout",
r#"{"error":"took too long"}"#,
);
}
if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
if len > MAX_BODY {
return reply(stream, "413 Payload Too Large", r#"{"error":"too large"}"#);
}
let _reservation = match BodyReservation::take(len) {
Some(r) => r,
None => {
return reply(
stream,
"503 Service Unavailable",
r#"{"error":"relay is busy with other transfers, retry"}"#,
);
}
};
let mut body = vec![0u8; len];
if len > 0 {
r.read_exact(&mut body)?;
}
match (method.as_str(), target.as_str()) {
("POST", "/register") => do_register(stream, &body),
("POST", "/send") => do_send(stream, &body),
("POST", "/challenge") => do_challenge(stream, &body),
("POST", "/fetch") => do_fetch(stream, &body),
("GET", "/health") => reply(stream, "200 OK", r#"{"ok":true}"#),
("GET", "/") => reply_html(stream, "200 OK", ROOT_PAGE),
_ => reply(stream, "404 Not Found", r#"{"error":"no such route"}"#),
}
}
fn new_nonce() -> Option<String> {
use base64::{Engine, engine::general_purpose::STANDARD as B64};
let mut buf = [0u8; 24];
let mut f = std::fs::File::open("/dev/urandom").ok()?;
f.read_exact(&mut buf).ok()?;
Some(B64.encode(buf))
}
fn do_register(stream: TcpStream, body: &[u8]) -> std::io::Result<()> {
use base64::{Engine, engine::general_purpose::STANDARD as B64};
let Ok(reg) = serde_json::from_slice::<Registration>(body) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad body"}"#);
};
if !b64_ok(®.sign_key) || !b64_ok(®.box_key) {
return reply(
stream,
"400 Bad Request",
r#"{"error":"keys must be 32-byte base64"}"#,
);
}
let mut g = STATE.lock().unwrap();
let st = g.as_mut().unwrap();
if let Some(existing) = st.owners.get(®.box_key) {
if existing != ®.sign_key {
return reply(
stream,
"409 Conflict",
r#"{"error":"mailbox already registered to another signing key"}"#,
);
}
return reply(
stream,
"200 OK",
r#"{"ok":true,"note":"already registered"}"#,
);
}
let key = (reg.box_key.clone(), reg.sign_key.clone());
match reg.proof.as_deref() {
Some(p) => {
let Some((want, _)) = st.pending.get(&key).cloned() else {
return reply(
stream,
"401 Unauthorized",
r#"{"error":"no registration in progress for that pair"}"#,
);
};
if p != want {
return reply(
stream,
"401 Unauthorized",
r#"{"error":"proof does not match"}"#,
);
}
if !st.owners.contains_key(®.box_key) && st.owners.len() >= st.limits.mailboxes {
return reply(
stream,
"507 Insufficient Storage",
r#"{"error":"this relay is full"}"#,
);
}
st.pending.remove(&key);
st.owners.insert(reg.box_key.clone(), reg.sign_key.clone());
let path = st.root.join("owners.json");
let data = serde_json::to_vec_pretty(&st.owners).unwrap_or_default();
drop(g);
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &data)
.and_then(|_| std::fs::rename(&tmp, &path))
.is_err()
{
let _ = std::fs::remove_file(&tmp);
return reply(
stream,
"500 Internal Server Error",
r#"{"error":"could not record the registration"}"#,
);
}
reply(stream, "200 OK", r#"{"ok":true}"#)
}
None => {
let Ok(raw) = B64.decode(®.box_key) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad box key"}"#);
};
let Ok(raw): Result<[u8; 32], _> = raw.try_into() else {
return reply(stream, "400 Bad Request", r#"{"error":"bad box key"}"#);
};
let fresh = st
.pending
.get(&key)
.filter(|(_, at)| at.elapsed() < std::time::Duration::from_secs(600))
.map(|(n, _)| n.clone());
let Some(nonce) = fresh.or_else(new_nonce) else {
return reply(
stream,
"500 Internal Server Error",
r#"{"error":"no randomness available"}"#,
);
};
let pk = crypto_box::PublicKey::from_bytes(raw);
let Ok(ct) = pk.seal(&mut crypto_box::aead::OsRng, nonce.as_bytes()) else {
return reply(
stream,
"500 Internal Server Error",
r#"{"error":"seal failed"}"#,
);
};
st.pending.insert(key, (nonce, std::time::Instant::now()));
st.pending
.retain(|_, (_, at)| at.elapsed() < std::time::Duration::from_secs(600));
while st.pending.len() > 10_000 {
if let Some(oldest) = st
.pending
.iter()
.min_by_key(|(_, (_, at))| *at)
.map(|(k, _)| k.clone())
{
st.pending.remove(&oldest);
} else {
break;
}
}
reply(
stream,
"200 OK",
&serde_json::json!({
"prove": B64.encode(&ct),
"note": "unseal this with your box secret and re-register with it as `proof`"
})
.to_string(),
)
}
}
}
#[derive(Deserialize)]
struct SendReq {
to_box: String,
envelope: String,
}
fn do_send(stream: TcpStream, body: &[u8]) -> std::io::Result<()> {
let Ok(req) = serde_json::from_slice::<SendReq>(body) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad body"}"#);
};
let Some(dir) = key_to_dirname(&req.to_box) else {
return reply(
stream,
"400 Bad Request",
r#"{"error":"bad recipient key"}"#,
);
};
let g = STATE.lock().unwrap();
let st = g.as_ref().unwrap();
if !st.owners.contains_key(&req.to_box) {
return reply(stream, "404 Not Found", r#"{"error":"no such mailbox"}"#);
}
let box_dir = st.root.join("mail").join(&dir);
if std::fs::create_dir_all(&box_dir).is_err() {
return reply(
stream,
"500 Internal Server Error",
r#"{"error":"store failed"}"#,
);
}
let incoming = req.envelope.len() as u64;
if dir_bytes(&box_dir) + incoming > st.limits.mailbox_bytes {
return reply(
stream,
"507 Insufficient Storage",
r#"{"error":"mailbox is full"}"#,
);
}
if dir_count(&box_dir) >= st.limits.mailbox_msgs {
return reply(
stream,
"507 Insufficient Storage",
r#"{"error":"mailbox is full"}"#,
);
}
let mail_root = st.root.join("mail");
let total: u64 = std::fs::read_dir(&mail_root)
.map(|rd| rd.flatten().map(|e| dir_bytes(&e.path())).sum())
.unwrap_or(0);
if total + incoming > st.limits.store_bytes {
return reply(
stream,
"507 Insufficient Storage",
r#"{"error":"relay store is full"}"#,
);
}
let name = format!(
"{}-{}.gageb",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
&crate::bundle::sha256_hex(req.envelope.as_bytes())[..12]
);
match std::fs::write(box_dir.join(&name), req.envelope.as_bytes()) {
Ok(()) => reply(stream, "200 OK", &format!(r#"{{"ok":true,"id":"{name}"}}"#)),
Err(_) => reply(
stream,
"500 Internal Server Error",
r#"{"error":"store failed"}"#,
),
}
}
#[derive(Deserialize)]
struct ChallengeReq {
box_key: String,
}
fn do_challenge(stream: TcpStream, body: &[u8]) -> std::io::Result<()> {
let Ok(req) = serde_json::from_slice::<ChallengeReq>(body) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad body"}"#);
};
let Some(nonce) = new_nonce() else {
return reply(
stream,
"500 Internal Server Error",
r#"{"error":"no randomness available"}"#,
);
};
let mut g = STATE.lock().unwrap();
let st = g.as_mut().unwrap();
st.challenges.insert(nonce.clone(), req.box_key);
st.challenge_age
.insert(nonce.clone(), std::time::Instant::now());
let cutoff = std::time::Duration::from_secs(600);
st.challenge_age.retain(|_, at| at.elapsed() < cutoff);
let live: std::collections::HashSet<String> = st.challenge_age.keys().cloned().collect();
st.challenges.retain(|n, _| live.contains(n));
while st.challenges.len() > 10_000 {
if let Some(oldest) = st
.challenge_age
.iter()
.min_by_key(|(_, at)| *at)
.map(|(k, _)| k.clone())
{
st.challenge_age.remove(&oldest);
st.challenges.remove(&oldest);
} else {
break;
}
}
reply(stream, "200 OK", &format!(r#"{{"nonce":"{nonce}"}}"#))
}
#[derive(Deserialize)]
struct FetchReq {
box_key: String,
nonce: String,
sig: String,
#[serde(default)]
delete: bool,
}
fn do_fetch(stream: TcpStream, body: &[u8]) -> std::io::Result<()> {
let Ok(req) = serde_json::from_slice::<FetchReq>(body) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad body"}"#);
};
let mut g = STATE.lock().unwrap();
let st = g.as_mut().unwrap();
let Some(issued_for) = st.challenges.remove(&req.nonce) else {
return reply(
stream,
"401 Unauthorized",
r#"{"error":"unknown or used challenge"}"#,
);
};
if issued_for != req.box_key {
return reply(
stream,
"401 Unauthorized",
r#"{"error":"challenge was for another mailbox"}"#,
);
}
let Some(sign_key) = st.owners.get(&req.box_key).cloned() else {
return reply(stream, "401 Unauthorized", r#"{"error":"not registered"}"#);
};
if !crate::identity::verify(&sign_key, req.nonce.as_bytes(), &req.sig) {
return reply(
stream,
"401 Unauthorized",
r#"{"error":"signature does not verify"}"#,
);
}
let Some(dir) = key_to_dirname(&req.box_key) else {
return reply(stream, "400 Bad Request", r#"{"error":"bad key"}"#);
};
let box_dir = st.root.join("mail").join(&dir);
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&box_dir) {
let mut names: Vec<_> = rd.flatten().map(|e| e.path()).collect();
names.sort();
for p in names {
if let Ok(s) = std::fs::read_to_string(&p) {
out.push(serde_json::json!({
"id": p.file_name().map(|n| n.to_string_lossy().to_string()),
"envelope": s,
}));
if req.delete {
let _ = std::fs::remove_file(&p);
}
}
}
}
reply(
stream,
"200 OK",
&serde_json::json!({ "count": out.len(), "items": out }).to_string(),
)
}