diff --git a/kez-chat/src/api.rs b/kez-chat/src/api.rs index efa2e37..90b9bac 100644 --- a/kez-chat/src/api.rs +++ b/kez-chat/src/api.rs @@ -39,6 +39,8 @@ pub fn router(state: AppState) -> axum::Router { .route("/v1/healthz", get(healthz)) .route("/v1/u/:handle", get(lookup)) .route("/v1/register", post(register)) + .route("/v1/messages", post(crate::messages::send_message)) + .route("/v1/inbox/:handle", get(crate::messages::inbox)) .route("/.well-known/webfinger", get(webfinger)) .route("/internal/nats/auth", post(nats_auth_callout)); diff --git a/kez-chat/src/error.rs b/kez-chat/src/error.rs index 28a8d1f..27a3cf3 100644 --- a/kez-chat/src/error.rs +++ b/kez-chat/src/error.rs @@ -17,6 +17,8 @@ pub enum ApiError { Conflict(String), #[error("forbidden: {0}")] Forbidden(String), + #[error("unauthorized: {0}")] + Unauthorized(String), #[error("internal: {0}")] Internal(String), } @@ -28,6 +30,7 @@ impl ApiError { ApiError::BadRequest(_) => StatusCode::BAD_REQUEST, ApiError::Conflict(_) => StatusCode::CONFLICT, ApiError::Forbidden(_) => StatusCode::FORBIDDEN, + ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED, ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -38,6 +41,7 @@ impl ApiError { ApiError::BadRequest(_) => "bad_request", ApiError::Conflict(_) => "conflict", ApiError::Forbidden(_) => "forbidden", + ApiError::Unauthorized(_) => "unauthorized", ApiError::Internal(_) => "internal", } } diff --git a/kez-chat/src/lib.rs b/kez-chat/src/lib.rs index 51c7099..dff878a 100644 --- a/kez-chat/src/lib.rs +++ b/kez-chat/src/lib.rs @@ -5,6 +5,7 @@ pub mod api; pub mod config; pub mod error; pub mod handles; +pub mod messages; pub mod registration; pub mod store; diff --git a/kez-chat/src/messages.rs b/kez-chat/src/messages.rs new file mode 100644 index 0000000..ce24d3d --- /dev/null +++ b/kez-chat/src/messages.rs @@ -0,0 +1,274 @@ +//! Encrypted-mailbox endpoints. The server is a dumb relay: it stores +//! opaque envelopes addressed to a handle and hands them back to the +//! handle's owner. Decryption + payload sig verification happen in the +//! client; the server only checks that the *poller* controls the +//! handle's primary key. +//! +//! POST /v1/messages { to: handle, envelope: {...} } +//! GET /v1/inbox/:handle?since=&limit= +//! requires X-KEZ-Auth: : +//! where sig = ed25519(handle's primary, +//! "GET\n/v1/inbox/\nsince=\n") +//! +//! Rationale for "sign the request line": +//! • No bearer tokens / no session storage. +//! • Replay-resistant within a 60s window. +//! • Trivially portable to a CLI client (Rust, Go, anything). +//! +//! Spam: v0.1 doesn't gate POST — anyone can drop an envelope into +//! anyone's mailbox. Mitigations land in v0.2 (rate-limit by source IP + +//! optional sender-handle proof). + +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::HeaderMap; +use chrono::Utc; +use kez_core::verify_ed25519_hex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::AppState; +use crate::error::ApiError; +use crate::handles::validate_handle; + +/// Hard cap on how many envelopes a single inbox poll can return. +const INBOX_MAX_LIMIT: i64 = 200; +/// Default if the client doesn't pass `?limit=`. +const INBOX_DEFAULT_LIMIT: i64 = 50; +/// Max envelope size the relay will store (raw JSON bytes). Generous — +/// a few small messages or one fat attachment-ish chunk; not a file host. +const MAX_ENVELOPE_BYTES: usize = 256 * 1024; +/// Clock-skew tolerance on the auth header timestamp. +const AUTH_MAX_AGE_SECS: i64 = 60; + +// ───────────────────────────────────────────────────────────────────────────── +// POST /v1/messages +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct SendMessageRequest { + pub to: String, + /// Opaque to the server — kept as serde_json::Value so we round-trip + /// it byte-for-byte through the database. + pub envelope: Value, +} + +#[derive(Debug, Serialize)] +pub struct SendMessageResponse { + pub seq: i64, +} + +pub async fn send_message( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + validate_handle(&req.to) + .map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?; + + let envelope_str = serde_json::to_string(&req.envelope)?; + if envelope_str.len() > MAX_ENVELOPE_BYTES { + return Err(ApiError::BadRequest(format!( + "envelope too large: {} bytes (max {})", + envelope_str.len(), + MAX_ENVELOPE_BYTES + ))); + } + + // Recipient must exist — otherwise their primary key wouldn't be + // resolvable for the sender to encrypt to in the first place. We + // could be lenient here, but a fast NotFound is friendlier than + // silently dropping into a mailbox no one will ever read. + let recipient = state + .store + .lookup(&req.to) + .await? + .ok_or(ApiError::NotFound)?; + + let seq = state + .store + .store_message(&recipient.handle, &envelope_str) + .await?; + Ok(Json(SendMessageResponse { seq })) +} + +// ───────────────────────────────────────────────────────────────────────────── +// GET /v1/inbox/:handle +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct InboxQuery { + #[serde(default)] + pub since: i64, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct InboxMessage { + pub seq: i64, + pub envelope: Value, + pub created_at: String, +} + +#[derive(Debug, Serialize)] +pub struct InboxResponse { + pub messages: Vec, + /// max(seq) in this response, or the input `since` if empty — + /// pass back as `?since=` next poll. + pub cursor: i64, +} + +pub async fn inbox( + State(state): State, + Path(handle): Path, + Query(q): Query, + headers: HeaderMap, +) -> Result, ApiError> { + validate_handle(&handle) + .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; + + // Auth: header must be a fresh signature, signed by the handle's primary, + // over the canonical request line including the since cursor. + let auth = headers + .get("X-KEZ-Auth") + .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? + .to_str() + .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; + + let record = state + .store + .lookup(&handle) + .await? + .ok_or(ApiError::NotFound)?; + + verify_inbox_auth(auth, &handle, q.since, record.primary.value(), Utc::now().timestamp())?; + + let limit = q + .limit + .unwrap_or(INBOX_DEFAULT_LIMIT) + .clamp(1, INBOX_MAX_LIMIT); + + let stored = state.store.inbox(&handle, q.since, limit).await?; + let cursor = stored.last().map(|m| m.seq).unwrap_or(q.since); + let messages = stored + .into_iter() + .map(|m| { + let envelope: Value = serde_json::from_str(&m.envelope)?; + Ok::<_, ApiError>(InboxMessage { + seq: m.seq, + envelope, + created_at: m.created_at.to_rfc3339(), + }) + }) + .collect::, _>>()?; + Ok(Json(InboxResponse { messages, cursor })) +} + +/// Parse + verify the `X-KEZ-Auth: :` header. +/// +/// `pubkey_hex` is the recipient handle's ed25519 primary (hex). The +/// signed message is the canonical request line so a stolen signature +/// can't be replayed against a different handle, cursor, or window. +pub fn verify_inbox_auth( + header: &str, + handle: &str, + since: i64, + pubkey_hex: &str, + now_ts: i64, +) -> Result<(), ApiError> { + let (ts_str, sig_hex) = header + .split_once(':') + .ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be :".into()))?; + let ts: i64 = ts_str + .parse() + .map_err(|_| ApiError::Unauthorized("X-KEZ-Auth ts must be a unix timestamp".into()))?; + if (now_ts - ts).abs() > AUTH_MAX_AGE_SECS { + return Err(ApiError::Unauthorized(format!( + "auth header is {}s stale (max {}s)", + (now_ts - ts).abs(), + AUTH_MAX_AGE_SECS + ))); + } + let message = canonical_inbox_message(handle, since, ts); + verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex) + .map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?; + Ok(()) +} + +/// The exact bytes a poller must sign. Keep this in sync with the SPA +/// client (kez-chat/web/src/lib/messages.ts). +pub fn canonical_inbox_message(handle: &str, since: i64, ts: i64) -> String { + format!("GET\n/v1/inbox/{handle}\nsince={since}\n{ts}") +} + +#[cfg(test)] +mod tests { + use super::*; + use kez_core::Ed25519Secret; + + #[test] + fn canonical_inbox_message_is_stable() { + assert_eq!( + canonical_inbox_message("tudisco", 0, 1_700_000_000), + "GET\n/v1/inbox/tudisco\nsince=0\n1700000000", + ); + } + + fn signer_for_test() -> (Ed25519Secret, String) { + // Deterministic seed for test reproducibility. + let sk = Ed25519Secret::from_seed_hex(&"07".repeat(32)).unwrap(); + let pubkey_hex = sk.pubkey_hex(); + (sk, pubkey_hex) + } + + fn header_for(sk: &Ed25519Secret, handle: &str, since: i64, ts: i64) -> String { + let msg = canonical_inbox_message(handle, since, ts); + let sig = sk.sign(msg.as_bytes()); + format!("{ts}:{}", hex::encode(sig)) + } + + #[test] + fn verify_inbox_auth_accepts_fresh_valid_signature() { + let (sk, pk) = signer_for_test(); + let now = 1_700_000_000; + let header = header_for(&sk, "tudisco", 0, now); + verify_inbox_auth(&header, "tudisco", 0, &pk, now).unwrap(); + } + + #[test] + fn verify_inbox_auth_rejects_stale_timestamp() { + let (sk, pk) = signer_for_test(); + let signed_at = 1_700_000_000; + let now = signed_at + 120; // > 60s tolerance + let header = header_for(&sk, "tudisco", 0, signed_at); + let err = verify_inbox_auth(&header, "tudisco", 0, &pk, now).unwrap_err(); + assert!(matches!(err, ApiError::Unauthorized(_))); + } + + #[test] + fn verify_inbox_auth_rejects_wrong_handle() { + let (sk, pk) = signer_for_test(); + let now = 1_700_000_000; + // Sign for handle "alice" but try to use it for "bob". + let header = header_for(&sk, "alice", 0, now); + let err = verify_inbox_auth(&header, "bob", 0, &pk, now).unwrap_err(); + assert!(matches!(err, ApiError::Unauthorized(_))); + } + + #[test] + fn verify_inbox_auth_rejects_wrong_cursor() { + let (sk, pk) = signer_for_test(); + let now = 1_700_000_000; + // Sign with since=0 but request since=10 — attacker can't + // re-use one signed header to page through history. + let header = header_for(&sk, "tudisco", 0, now); + let err = verify_inbox_auth(&header, "tudisco", 10, &pk, now).unwrap_err(); + assert!(matches!(err, ApiError::Unauthorized(_))); + } + + #[test] + fn verify_inbox_auth_rejects_malformed_header() { + let (_sk, pk) = signer_for_test(); + let err = verify_inbox_auth("not-a-header", "tudisco", 0, &pk, 1).unwrap_err(); + assert!(matches!(err, ApiError::Unauthorized(_))); + } +} diff --git a/kez-chat/src/store.rs b/kez-chat/src/store.rs index 8ff715c..e6d5ce4 100644 --- a/kez-chat/src/store.rs +++ b/kez-chat/src/store.rs @@ -152,6 +152,92 @@ fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> { registered_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_handles_primary - ON handles (primary_id);", + ON handles (primary_id); + + -- Opaque encrypted-envelope mailbox. The server can't read these; + -- it's a dumb relay that stores blobs addressed to a handle and + -- hands them back when the handle's owner authenticates a poll. + CREATE TABLE IF NOT EXISTS messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_handle TEXT NOT NULL, + envelope TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_messages_recipient + ON messages (recipient_handle, seq);", ) } + +// ───────────────────────────────────────────────────────────────────────────── +// Messages (opaque encrypted envelopes, server-side dumb relay) +// ───────────────────────────────────────────────────────────────────────────── + +/// One stored envelope. The server doesn't introspect the envelope JSON — +/// recipients decrypt it client-side. We surface only what's needed for +/// addressing + ordering: `seq`, when we received it, and the raw blob. +#[derive(Debug, Clone)] +pub struct StoredMessage { + pub seq: i64, + pub envelope: String, + pub created_at: DateTime, +} + +impl Store { + /// Append an envelope to `recipient`'s mailbox. Returns the assigned seq. + pub async fn store_message( + &self, + recipient: &str, + envelope: &str, + ) -> Result { + let now = Utc::now().to_rfc3339(); + let conn = self.inner.lock().await; + conn.execute( + "INSERT INTO messages (recipient_handle, envelope, created_at) + VALUES (?1, ?2, ?3)", + params![recipient, envelope, now], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Pull envelopes for `recipient` with `seq > since`, oldest first. + /// `limit` caps the response so a long-offline user can't blow up a + /// single poll — the client paginates by re-calling with the new max seq. + pub async fn inbox( + &self, + recipient: &str, + since: i64, + limit: i64, + ) -> Result, ApiError> { + let conn = self.inner.lock().await; + let mut stmt = conn.prepare( + "SELECT seq, envelope, created_at + FROM messages + WHERE recipient_handle = ?1 AND seq > ?2 + ORDER BY seq ASC + LIMIT ?3", + )?; + let rows = stmt + .query_map(params![recipient, since, limit], |row| { + let seq: i64 = row.get(0)?; + let envelope: String = row.get(1)?; + let created_at: String = row.get(2)?; + Ok((seq, envelope, created_at)) + })? + .collect::, _>>()?; + + let mut out = Vec::with_capacity(rows.len()); + for (seq, envelope, created_at) in rows { + let created_at = DateTime::parse_from_rfc3339(&created_at) + .map_err(|e| { + ApiError::Internal(format!("stored message ts not parseable: {e}")) + })? + .with_timezone(&Utc); + out.push(StoredMessage { + seq, + envelope, + created_at, + }); + } + Ok(out) + } +} diff --git a/kez-chat/tests/http.rs b/kez-chat/tests/http.rs index 7187971..88e3722 100644 --- a/kez-chat/tests/http.rs +++ b/kez-chat/tests/http.rs @@ -285,6 +285,122 @@ async fn nats_auth_callout_stub_returns_not_implemented() { assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); } +// ─── messages: send + inbox round-trip ─────────────────────────────────────── + +/// Helper: register a handle, return (handle, secret) for use as recipient. +async fn register_user(base: &str, handle: &str) -> Ed25519Secret { + let secret = Ed25519Secret::generate(); + let req = sign_registration(&secret, handle, "kez.test", Utc::now()); + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base}/v1/register")) + .json(&req) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED, "registration failed"); + secret +} + +/// Helper: build the X-KEZ-Auth header value for a given recipient + cursor. +fn auth_header(secret: &Ed25519Secret, handle: &str, since: i64, ts: i64) -> String { + let msg = kez_chat_server::messages::canonical_inbox_message(handle, since, ts); + let sig = secret.sign(msg.as_bytes()); + format!("{ts}:{}", hex::encode(sig)) +} + +#[tokio::test] +async fn send_and_inbox_round_trip() { + let server = spawn_server().await; + let alice = register_user(&server.base, "alice").await; + let _bob = register_user(&server.base, "bob").await; + let client = reqwest::Client::new(); + + // bob sends two opaque envelopes to alice (server doesn't introspect). + for body in ["hello alice", "this is the second one"] { + let req = serde_json::json!({ + "to": "alice", + "envelope": { "v": 1, "ciphertext": body, "from": "bob" }, + }); + let resp = client + .post(format!("{}/v1/messages", server.base)) + .json(&req) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + // alice polls her inbox with a fresh auth header. + let now = Utc::now().timestamp(); + let resp = client + .get(format!("{}/v1/inbox/alice", server.base)) + .header("X-KEZ-Auth", auth_header(&alice, "alice", 0, now)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["messages"].as_array().unwrap().len(), 2); + assert_eq!(body["messages"][0]["envelope"]["ciphertext"], "hello alice"); + assert_eq!(body["cursor"], 2); + + // Polling again with cursor=2 yields nothing new. + let resp = client + .get(format!("{}/v1/inbox/alice?since=2", server.base)) + .header("X-KEZ-Auth", auth_header(&alice, "alice", 2, now)) + .send() + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["messages"].as_array().unwrap().len(), 0); + assert_eq!(body["cursor"], 2); +} + +#[tokio::test] +async fn inbox_rejects_wrong_signer() { + let server = spawn_server().await; + let _alice = register_user(&server.base, "alice").await; + let mallory = Ed25519Secret::generate(); // not alice + let client = reqwest::Client::new(); + + let now = Utc::now().timestamp(); + let resp = client + .get(format!("{}/v1/inbox/alice", server.base)) + .header("X-KEZ-Auth", auth_header(&mallory, "alice", 0, now)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn inbox_rejects_missing_auth_header() { + let server = spawn_server().await; + let _alice = register_user(&server.base, "alice").await; + let resp = reqwest::get(format!("{}/v1/inbox/alice", server.base)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn send_to_unknown_handle_404s() { + let server = spawn_server().await; + let client = reqwest::Client::new(); + let req = serde_json::json!({ + "to": "ghost", + "envelope": { "v": 1 }, + }); + let resp = client + .post(format!("{}/v1/messages", server.base)) + .json(&req) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + // Sanity: signing the same payload twice with the same Ed25519 key // gives the same signature. Catches any accidental non-determinism in // the JCS pipeline. diff --git a/kez-chat/web/src/App.svelte b/kez-chat/web/src/App.svelte index 7371e19..f5358b0 100644 --- a/kez-chat/web/src/App.svelte +++ b/kez-chat/web/src/App.svelte @@ -11,6 +11,7 @@ import Dashboard from "./routes/Dashboard.svelte"; import Claims from "./routes/Claims.svelte"; import AddClaim from "./routes/AddClaim.svelte"; + import Messages from "./routes/Messages.svelte"; const routes = { "/": Landing, @@ -20,6 +21,7 @@ "/dashboard": Dashboard, "/claims": Claims, "/claims/add": AddClaim, + "/messages": Messages, }; // First-load: if there's a stored identity but session is locked, @@ -27,7 +29,7 @@ // bounce to /. onMount(async () => { const stored = await hasStoredIdentity(); - const protectedRoutes = ["/dashboard", "/claims", "/claims/add"]; + const protectedRoutes = ["/dashboard", "/claims", "/claims/add", "/messages"]; if (!stored && protectedRoutes.includes($location)) { push("/"); } else if (stored && !session.unlocked && protectedRoutes.includes($location)) { @@ -44,6 +46,7 @@ {#if session.unlocked}