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:
parent
109852ed75
commit
5cb46e2aa1
@ -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));
|
||||
|
||||
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
274
kez-chat/src/messages.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
115
kez-chat/web/src/lib/conversations-store.ts
Normal file
115
kez-chat/web/src/lib/conversations-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
205
kez-chat/web/src/lib/crypto.ts
Normal file
205
kez-chat/web/src/lib/crypto.ts
Normal 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;
|
||||
}
|
||||
126
kez-chat/web/src/lib/messages.ts
Normal file
126
kez-chat/web/src/lib/messages.ts
Normal 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 };
|
||||
278
kez-chat/web/src/routes/Messages.svelte
Normal file
278
kez-chat/web/src/routes/Messages.svelte
Normal 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>
|
||||
@ -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()))?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user