feat(kez-chat): v0.1 chat — encrypted 1:1 messages (server + web client)

Time to actually chat. Server is a dumb relay storing opaque envelopes;
recipients decrypt client-side. Everything below is end-to-end encrypted,
the server can't read anything it stores.

Server (kez-chat-server):
  • New messages table (seq autoinc, recipient_handle, envelope blob,
    created_at). Indexed by (recipient, seq) for cursor paging.
  • POST /v1/messages
      body: { to: handle, envelope: <opaque JSON> }
      validates recipient exists; rejects > 256 KB envelopes.
  • GET /v1/inbox/:handle?since=<seq>&limit=<n>
      auth: X-KEZ-Auth: <unix_ts>:<sig_hex>
      sig = ed25519(handle's primary,
                    "GET\n/v1/inbox/<handle>\nsince=<n>\n<ts>")
      60s clock-skew tolerance; signed message includes cursor so a
      captured header can't page through history.
  • New ApiError::Unauthorized → 401.
  • kez-core: verify_ed25519_hex is now pub so the auth handler can
    use it for arbitrary-message verification (outside JCS envelopes).

Crypto (browser):
  • ed25519 seed → x25519 priv via Montgomery conversion
    (ed25519.utils.toMontgomerySecret).
  • ed25519 pubkey → x25519 pubkey for the recipient (toMontgomery).
  • ECDH → 32-byte shared secret → HKDF-SHA256(salt=nonce, info=
    "kez-chat-msg-v1") → AES-256-GCM key.
  • Per-message random 12-byte nonce; each message gets a unique AES key.
  • Sender signs envelope-minus-sig with their ed25519 primary so the
    recipient can confirm the sender authored the ciphertext + binding.

SPA UI:
  • /messages route, two-pane layout (sidebar conversations, thread view,
    compose box).
  • 5-second poller against /v1/inbox using the global cursor; new
    messages get decrypted + appended to the right thread.
  • Local IDB cache (lib/conversations-store.ts) so decrypted history
    survives reloads. Dedupes by seq+direction.
  • Page-specific max-w-6xl so the two-pane layout has room.

Tests:
  • 6 new unit tests in messages.rs covering auth header verification
    (stale ts, wrong handle, wrong cursor, malformed).
  • 4 new integration tests in tests/http.rs: full send + inbox round-
    trip, wrong-signer rejected, missing header rejected, unknown
    recipient → 404.
  • All 17 chat-server tests pass.

Followups (deferred):
  • NATS WebSocket push (live messages without 5s poll lag).
  • Group chats with proper member-key rotation.
  • Reverse handle resolution (/v1/by-primary) so the UI can show
    "@alice" instead of the truncated ed25519 hex.
  • At-rest encryption for the IDB conversations cache.
  • Sender spam mitigation on POST /v1/messages.

Live at https://kez.lat — try /messages with two browsers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-25 16:10:43 -06:00
parent 109852ed75
commit 5cb46e2aa1
12 changed files with 1220 additions and 4 deletions

View File

@ -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));

View File

@ -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",
}
}

View File

@ -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;

274
kez-chat/src/messages.rs Normal file
View File

@ -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=<seq>&limit=<n>
//! requires X-KEZ-Auth: <unix_ts>:<sig_hex>
//! where sig = ed25519(handle's primary,
//! "GET\n/v1/inbox/<handle>\nsince=<seq>\n<unix_ts>")
//!
//! 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<AppState>,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<SendMessageResponse>, 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<i64>,
}
#[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<InboxMessage>,
/// 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<AppState>,
Path(handle): Path<String>,
Query(q): Query<InboxQuery>,
headers: HeaderMap,
) -> Result<Json<InboxResponse>, 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::<Result<Vec<_>, _>>()?;
Ok(Json(InboxResponse { messages, cursor }))
}
/// Parse + verify the `X-KEZ-Auth: <unix_ts>:<sig_hex>` 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 <ts>:<sig>".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(_)));
}
}

View File

@ -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<Utc>,
}
impl Store {
/// Append an envelope to `recipient`'s mailbox. Returns the assigned seq.
pub async fn store_message(
&self,
recipient: &str,
envelope: &str,
) -> Result<i64, ApiError> {
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<Vec<StoredMessage>, 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::<Result<Vec<_>, _>>()?;
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)
}
}

View File

@ -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.

View File

@ -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}
<nav class="flex items-center gap-4 text-sm">
<a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a>
<a href="#/messages" class="text-gray-700 hover:text-gray-900">Messages</a>
<a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a>
<span class="text-gray-400">|</span>
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>
@ -58,7 +61,7 @@
</div>
</header>
<main class="max-w-3xl mx-auto px-6 py-8">
<main class={`mx-auto px-6 py-8 ${$location === "/messages" ? "max-w-6xl" : "max-w-3xl"}`}>
<Router {routes} />
</main>

View File

@ -0,0 +1,115 @@
// Local cache of decrypted messages, keyed by the other party's handle.
// IndexedDB so it survives reloads. Encrypted-at-rest is *not* attempted
// here — anyone with access to this browser profile already has the
// user's KEZ seed (which is the real secret). If we ever want at-rest
// encryption, encrypt the whole IDB store under a session-derived key.
import { get, set } from "idb-keyval";
import type { Identity } from "./kez.js";
const KEY = "kez-chat:conversations";
export interface ConversationMessage {
/** Server seq when received (own sends use Date.now() to keep ordering). */
seq: number;
direction: "in" | "out";
/** Display name = the other party (always the SAME for all messages in a thread). */
peer: string; // bare handle
/** Sender's primary, for verifying who signed. */
from: Identity;
body: string;
/** ISO timestamp (sender's clock for `out`, recipient receive-time for `in`). */
ts: string;
}
export interface Conversation {
peer: string;
messages: ConversationMessage[];
/** Highest server seq we've processed from the inbox stream. */
last_seq: number;
}
interface Store {
by_peer: Record<string, Conversation>;
/** Global highest seq across all peers — passed to /v1/inbox?since=. */
global_cursor: number;
}
async function read(): Promise<Store> {
return (await get<Store>(KEY)) ?? { by_peer: {}, global_cursor: 0 };
}
async function write(s: Store): Promise<void> {
await set(KEY, s);
}
export async function listConversations(): Promise<Conversation[]> {
const s = await read();
return Object.values(s.by_peer).sort((a, b) => {
const at = a.messages[a.messages.length - 1]?.ts ?? "";
const bt = b.messages[b.messages.length - 1]?.ts ?? "";
return bt.localeCompare(at); // most-recent-first
});
}
export async function getConversation(peer: string): Promise<Conversation> {
const s = await read();
return s.by_peer[peer] ?? { peer, messages: [], last_seq: 0 };
}
export async function getGlobalCursor(): Promise<number> {
return (await read()).global_cursor;
}
export async function appendInbound(opts: {
seq: number;
peer: string;
from: Identity;
body: string;
ts: string;
}): Promise<void> {
const s = await read();
const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 };
// Dedupe by seq in case a poll overlaps.
if (!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq)) {
conv.messages.push({
seq: opts.seq,
direction: "in",
peer: opts.peer,
from: opts.from,
body: opts.body,
ts: opts.ts,
});
}
conv.last_seq = Math.max(conv.last_seq, opts.seq);
s.by_peer[opts.peer] = conv;
s.global_cursor = Math.max(s.global_cursor, opts.seq);
await write(s);
}
export async function appendOutbound(opts: {
peer: string;
from: Identity;
body: string;
}): Promise<void> {
const s = await read();
const conv = s.by_peer[opts.peer] ?? { peer: opts.peer, messages: [], last_seq: 0 };
conv.messages.push({
seq: Date.now(),
direction: "out",
peer: opts.peer,
from: opts.from,
body: opts.body,
ts: new Date().toISOString(),
});
s.by_peer[opts.peer] = conv;
await write(s);
}
export async function ensureConversation(peer: string): Promise<void> {
const s = await read();
if (!s.by_peer[peer]) {
s.by_peer[peer] = { peer, messages: [], last_seq: 0 };
await write(s);
}
}

View File

@ -0,0 +1,205 @@
// End-to-end encryption for kez-chat 1:1 messages.
//
// Each user's KEZ primary is an ed25519 key. We deterministically
// derive an x25519 keypair from the same seed (via Montgomery-form
// conversion) and use it for ECDH. The conversion is standard
// (libsodium does it too), so any future client can independently
// derive the same x25519 pubkey from the same primary.
//
// Per-message flow:
// 1. ECDH(my x25519 priv, their x25519 pub) → 32-byte shared secret
// 2. HKDF-SHA256(shared, salt=nonce, info="kez-chat-msg-v1") → AES-256 key
// 3. Plaintext JSON {from, body, sent_at} → AES-256-GCM (12-byte nonce)
// 4. Wrap in envelope: {v:1, from, to, nonce, ciphertext, sender_sig}
// 5. sender_sig = ed25519(sender's ed25519 priv, canonical envelope-without-sig)
//
// Why sign the ciphertext? Forward-deniability concerns aside, this
// closes the simplest attack: server (or anyone in the middle) can
// drop a ciphertext into a mailbox addressed to bob — without sig the
// recipient would just see "decryption failed" (no info). With sig,
// recipients can confirm the sender's primary controls the ciphertext.
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha2";
import { hkdf } from "@noble/hashes/hkdf";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { canonicalBytes, type Identity } from "./kez.js";
const ENVELOPE_VERSION = 1;
const HKDF_INFO = new TextEncoder().encode("kez-chat-msg-v1");
/** What the sender stores in the encrypted blob. */
export interface MessagePlaintext {
/** Sender's KEZ primary, e.g. "ed25519:<hex>". Lets recipient cross-check sig. */
from: Identity;
/** UTF-8 message body. v0.1 = plain text; v0.2 = markdown / rich. */
body: string;
/** RFC3339 timestamp when the sender clicked send. */
sent_at: string;
}
/** What goes on the wire to POST /v1/messages. */
export interface SealedEnvelope {
v: 1;
/** Sender's primary — recipient uses this to derive x25519 pub for ECDH. */
from: Identity;
/** Recipient handle, e.g. "alice". */
to: string;
/** 12-byte AES-GCM nonce, hex. Also seeds HKDF salt → key. */
nonce: string;
/** AES-256-GCM(plaintext_json), hex. */
ciphertext: string;
/** ed25519 sig over canonical(envelope minus sender_sig), hex. */
sender_sig: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Key derivation
// ─────────────────────────────────────────────────────────────────────────────
/**
* ed25519 32-byte seed 32-byte x25519 private key. Standard Montgomery
* conversion (libsodium crypto_sign_ed25519_sk_to_curve25519 = same fn).
*/
export function x25519PrivFromEd25519Seed(seed: Uint8Array): Uint8Array {
return ed25519.utils.toMontgomerySecret(seed);
}
/** ed25519 32-byte pubkey → 32-byte x25519 public key. */
export function x25519PubFromEd25519Pub(pub: Uint8Array): Uint8Array {
return ed25519.utils.toMontgomery(pub);
}
/**
* Resolve `ed25519:<hex>` 32-byte x25519 pub. Throws on non-ed25519
* primaries (we'll add other curve mappings if KEZ ever supports them).
*/
export function x25519PubFromPrimary(primary: Identity): Uint8Array {
if (!primary.startsWith("ed25519:")) {
throw new Error(`only ed25519 primaries supported for ECDH; got ${primary}`);
}
return x25519PubFromEd25519Pub(hexToBytes(primary.slice("ed25519:".length)));
}
// ─────────────────────────────────────────────────────────────────────────────
// Sealing + opening (raw — UI calls these via sealMessage / openMessage)
// ─────────────────────────────────────────────────────────────────────────────
async function deriveAesKey(
myPriv: Uint8Array,
theirPub: Uint8Array,
nonce: Uint8Array,
): Promise<CryptoKey> {
const shared = x25519.getSharedSecret(myPriv, theirPub);
// HKDF-SHA256 with the nonce as salt — different nonce per message →
// different AES key, even if shared secret stays the same.
const keyBytes = hkdf(sha256, shared, nonce, HKDF_INFO, 32);
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
"encrypt",
"decrypt",
]);
}
/** Same TS 5.6+ workaround as identity-store.ts — copy into ArrayBuffer. */
function asBuffer(u: Uint8Array): ArrayBuffer {
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer;
}
/**
* Encrypt + sign a message for `recipientPrimary`. Returns the wire
* envelope ready for POST /v1/messages.
*
* `senderSeed` is the sender's 32-byte ed25519 seed (used to derive
* both their signing key and their x25519 key for ECDH).
*/
export async function sealMessage(opts: {
senderSeed: Uint8Array;
senderPrimary: Identity;
recipientHandle: string;
recipientPrimary: Identity;
body: string;
}): Promise<SealedEnvelope> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const senderX25519Priv = x25519PrivFromEd25519Seed(opts.senderSeed);
const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary);
const aesKey = await deriveAesKey(senderX25519Priv, recipientX25519Pub, nonce);
const plaintext: MessagePlaintext = {
from: opts.senderPrimary,
body: opts.body,
sent_at: new Date().toISOString(),
};
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
const ctBytes = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
aesKey,
asBuffer(ptBytes),
),
);
// Sign the envelope-minus-sig so the recipient can confirm the
// sender's primary key authored this ciphertext (and no one swapped
// the nonce or recipient post-hoc).
const partial = {
v: ENVELOPE_VERSION,
from: opts.senderPrimary,
to: opts.recipientHandle,
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(ctBytes),
};
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
return { ...partial, v: 1, sender_sig: bytesToHex(sig) };
}
/**
* Verify + decrypt an envelope addressed to me. Returns the plaintext
* fields or throws on any failure (bad sig, primary mismatch, AES tag
* mismatch all indistinguishable to an attacker).
*/
export async function openMessage(opts: {
envelope: SealedEnvelope;
myHandle: string;
mySeed: Uint8Array;
}): Promise<MessagePlaintext> {
const env = opts.envelope;
if (env.v !== 1) throw new Error(`unsupported envelope version: ${env.v}`);
if (env.to !== opts.myHandle) {
throw new Error(`envelope addressed to ${env.to}, not ${opts.myHandle}`);
}
// 1. Verify the sender's signature over the unsigned envelope.
const partial = {
v: env.v,
from: env.from,
to: env.to,
nonce: env.nonce,
ciphertext: env.ciphertext,
};
if (!env.from.startsWith("ed25519:")) {
throw new Error(`unsupported sender primary scheme: ${env.from}`);
}
const senderPubKey = hexToBytes(env.from.slice("ed25519:".length));
const sigOk = ed25519.verify(
hexToBytes(env.sender_sig),
canonicalBytes(partial),
senderPubKey,
);
if (!sigOk) throw new Error("envelope signature did not verify");
// 2. ECDH → key → AES-GCM decrypt.
const nonce = hexToBytes(env.nonce);
const myX25519Priv = x25519PrivFromEd25519Seed(opts.mySeed);
const senderX25519Pub = x25519PubFromEd25519Pub(senderPubKey);
const aesKey = await deriveAesKey(myX25519Priv, senderX25519Pub, nonce);
const ptBytes = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
aesKey,
asBuffer(hexToBytes(env.ciphertext)),
),
);
return JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
}

View File

@ -0,0 +1,126 @@
// API client for /v1/messages + /v1/inbox.
//
// Pairs with the rust handlers in kez-chat/src/messages.rs — the auth
// header format and canonical-signed message must match byte-for-byte.
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import { lookup } from "./api.js";
import { sealMessage, openMessage, type SealedEnvelope, type MessagePlaintext } from "./crypto.js";
import type { Identity } from "./kez.js";
/** Server base URL (always same-origin for the SPA). */
function base(): string {
return "";
}
export interface InboxMessage {
seq: number;
envelope: SealedEnvelope;
created_at: string;
}
/** Canonical bytes the inbox poller signs. Mirrors the rust constant. */
function canonicalInboxMessage(handle: string, since: number, ts: number): string {
return `GET\n/v1/inbox/${handle}\nsince=${since}\n${ts}`;
}
/**
* Build the X-KEZ-Auth header value. The server's 60s clock-skew window
* means we can sign each request just-in-time; no token cache.
*/
function authHeader(opts: {
handle: string;
since: number;
seed: Uint8Array;
}): string {
const ts = Math.floor(Date.now() / 1000);
const msg = canonicalInboxMessage(opts.handle, opts.since, ts);
const sig = ed25519.sign(new TextEncoder().encode(msg), opts.seed);
return `${ts}:${bytesToHex(sig)}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Send
// ─────────────────────────────────────────────────────────────────────────────
/**
* Resolve the recipient's primary key, encrypt the body, POST the envelope.
* `recipient` is a bare local handle on the same server (v0.1) or
* "handle@server" (v0.2). Today we treat both as local lookups.
*/
export async function sendMessage(opts: {
senderHandle: string;
senderSeed: Uint8Array;
senderPrimary: Identity;
recipient: string;
body: string;
}): Promise<{ seq: number }> {
const recipientHandle = opts.recipient.split("@")[0];
const record = await lookup(recipientHandle); // throws on 404
const envelope = await sealMessage({
senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary,
recipientHandle,
recipientPrimary: record.primary as Identity,
body: opts.body,
});
const resp = await fetch(`${base()}/v1/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: recipientHandle, envelope }),
});
if (!resp.ok) {
throw new Error(`POST /v1/messages → ${resp.status}: ${await resp.text()}`);
}
return (await resp.json()) as { seq: number };
}
// ─────────────────────────────────────────────────────────────────────────────
// Inbox poll
// ─────────────────────────────────────────────────────────────────────────────
/**
* Pull envelopes newer than `since`. Returns the raw envelopes (no
* decryption yet that happens per-message via `decrypt()`) and a
* new cursor to pass to the next call.
*/
export async function pollInbox(opts: {
handle: string;
seed: Uint8Array;
since: number;
limit?: number;
}): Promise<{ messages: InboxMessage[]; cursor: number }> {
const params = new URLSearchParams();
params.set("since", String(opts.since));
if (opts.limit) params.set("limit", String(opts.limit));
const url = `${base()}/v1/inbox/${opts.handle}?${params}`;
const resp = await fetch(url, {
headers: {
"X-KEZ-Auth": authHeader({
handle: opts.handle,
since: opts.since,
seed: opts.seed,
}),
},
});
if (resp.status === 401) {
throw new Error("inbox auth failed — clock drift or signing error");
}
if (!resp.ok) {
throw new Error(`GET /v1/inbox → ${resp.status}: ${await resp.text()}`);
}
return (await resp.json()) as { messages: InboxMessage[]; cursor: number };
}
/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw. */
export async function decrypt(
envelope: SealedEnvelope,
myHandle: string,
mySeed: Uint8Array,
): Promise<MessagePlaintext> {
return openMessage({ envelope, myHandle, mySeed });
}
export type { SealedEnvelope, MessagePlaintext };

View File

@ -0,0 +1,278 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { decrypt, pollInbox, sendMessage } from "../lib/messages.js";
import {
appendInbound,
appendOutbound,
ensureConversation,
getConversation,
getGlobalCursor,
listConversations,
type Conversation,
} from "../lib/conversations-store.js";
let conversations = $state<Conversation[]>([]);
let active = $state<Conversation | null>(null);
let composeText = $state("");
let composing = $state(false);
let newPeerInput = $state("");
let pollError = $state<string | null>(null);
let lastPolledAt = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const POLL_INTERVAL_MS = 5_000;
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
await refreshConversations();
// Eager first poll so the UI doesn't sit empty waiting for the 5s tick.
await pollOnce();
pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
async function refreshConversations() {
conversations = await listConversations();
if (active) active = await getConversation(active.peer);
}
async function pollOnce() {
if (!session.unlocked) return;
try {
const since = await getGlobalCursor();
const { messages } = await pollInbox({
handle: session.unlocked.handle,
seed: session.unlocked.seed,
since,
});
for (const m of messages) {
try {
const pt = await decrypt(
m.envelope,
session.unlocked.handle,
session.unlocked.seed,
);
// Peer = sender's handle. We only have the sender's primary
// here; resolve to a handle by looking at the envelope.from.
// For v0.1 the SPA can't reverse primary→handle cheaply, so
// we display the local-part of the primary if we can't figure
// it out — TODO add /v1/by-primary endpoint and cache.
const peer = pt.from; // primary as the "peer key" for now
await appendInbound({
seq: m.seq,
peer,
from: pt.from,
body: pt.body,
ts: pt.sent_at,
});
} catch (e) {
console.error(`seq ${m.seq}: decrypt failed`, e);
}
}
if (messages.length > 0) await refreshConversations();
pollError = null;
lastPolledAt = new Date().toISOString();
} catch (e) {
pollError = (e as Error).message;
}
}
async function openConversation(peer: string) {
active = await getConversation(peer);
}
async function startNewConversation() {
const peer = newPeerInput.trim();
if (!peer) return;
await ensureConversation(peer);
newPeerInput = "";
await refreshConversations();
await openConversation(peer);
}
async function send() {
if (!session.unlocked || !active || !composeText.trim()) return;
composing = true;
try {
const body = composeText;
composeText = "";
await sendMessage({
senderHandle: session.unlocked.handle,
senderSeed: session.unlocked.seed,
senderPrimary: session.unlocked.primary,
recipient: active.peer,
body,
});
await appendOutbound({
peer: active.peer,
from: session.unlocked.primary,
body,
});
await refreshConversations();
} catch (e) {
alert(`Send failed: ${(e as Error).message}`);
composeText += "";
} finally {
composing = false;
}
}
/** Show short identifier — drop "ed25519:" prefix, keep first 12 chars. */
function shortPeer(peer: string): string {
if (peer.startsWith("ed25519:")) {
const hex = peer.slice("ed25519:".length);
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
}
return peer;
}
function formatTime(iso: string): string {
const d = new Date(iso);
const today = new Date();
if (
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
}
</script>
<div class="flex h-[calc(100vh-8rem)] gap-4">
<!-- Sidebar: conversation list + new-conv form -->
<aside class="w-72 shrink-0 border border-gray-200 rounded-lg bg-white flex flex-col">
<div class="p-3 border-b border-gray-200">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
New conversation
</p>
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
startNewConversation();
}}
>
<input
type="text"
bind:value={newPeerInput}
placeholder="handle"
class="flex-1 min-w-0 px-2 py-1 text-sm border border-gray-300 rounded font-mono"
autocomplete="off"
/>
<button
type="submit"
class="px-3 py-1 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
disabled={!newPeerInput.trim()}
>
+
</button>
</form>
</div>
<div class="flex-1 overflow-y-auto">
{#if conversations.length === 0}
<p class="p-4 text-sm text-gray-500 italic">
No conversations yet. Type a handle above to start one.
</p>
{:else}
<ul>
{#each conversations as c (c.peer)}
{@const last = c.messages[c.messages.length - 1]}
<li>
<button
class={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-50 ${active?.peer === c.peer ? "bg-gray-100" : ""}`}
onclick={() => openConversation(c.peer)}
>
<p class="font-mono text-sm font-semibold text-gray-900 truncate">
{shortPeer(c.peer)}
</p>
{#if last}
<p class="text-xs text-gray-500 truncate">
{last.direction === "out" ? "→ " : "← "}{last.body}
</p>
<p class="text-xs text-gray-400 mt-0.5">{formatTime(last.ts)}</p>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="p-2 border-t border-gray-200 text-xs text-gray-500">
{#if pollError}
<p class="text-red-700">{pollError}</p>
{:else if lastPolledAt}
Polled {formatTime(lastPolledAt)}
{/if}
</div>
</aside>
<!-- Main: thread view + compose -->
<main class="flex-1 min-w-0 border border-gray-200 rounded-lg bg-white flex flex-col">
{#if !active}
<div class="flex-1 flex items-center justify-center text-gray-400 text-sm">
Select or start a conversation
</div>
{:else}
<div class="p-3 border-b border-gray-200">
<p class="font-mono text-sm font-semibold text-gray-900">{shortPeer(active.peer)}</p>
<p class="text-xs text-gray-500 break-all">{active.peer}</p>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#each active.messages as m (m.seq + ":" + m.direction)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
>
{m.body}
</div>
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
{formatTime(m.ts)}
</p>
</div>
{/each}
{#if active.messages.length === 0}
<p class="text-gray-400 text-sm italic text-center mt-8">
No messages yet. Say hi.
</p>
{/if}
</div>
<form
class="p-3 border-t border-gray-200 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
<input
type="text"
bind:value={composeText}
placeholder="Type a message…"
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
autocomplete="off"
disabled={composing}
/>
<button
type="submit"
class="px-4 py-2 text-sm bg-gray-900 text-white rounded hover:bg-gray-700 disabled:opacity-50"
disabled={composing || !composeText.trim()}
>
{composing ? "Sending…" : "Send"}
</button>
</form>
{/if}
</main>
</div>

View File

@ -884,7 +884,13 @@ fn verify_jcs_schnorr_hex<T: Serialize>(payload: &T, npub: &str, sig: &str) -> R
Ok(())
}
fn verify_ed25519_hex(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> {
/// Verify a raw ed25519 signature (no JCS / no envelope wrapping).
///
/// `pubkey_hex` and `sig_hex` are lowercase-hex 32-byte and 64-byte values
/// respectively. `message` is the exact bytes the signer signed over.
/// Used by kez-chat-server for the per-request auth header — anywhere
/// outside the kez.claim / kez.handle_registration envelopes.
pub fn verify_ed25519_hex(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> {
let pubkey_bytes: [u8; 32] = hex::decode(pubkey_hex)?
.try_into()
.map_err(|_| KezError::InvalidIdentity("ed25519 pubkey must be 32 bytes".to_owned()))?;