A green check next to any KEZ that controls a proven account. Unlike
Twitter's "we say so," the badge means YOUR browser independently
verified ≥1 of the peer's published proofs against the channel.
Server:
• handles.proofs column (JSON array of claim subjects) + ALTER for
existing DBs. Returned in /v1/u/:handle and /v1/by-primary as
`proofs` — pure discovery; peers verify each themselves.
• PUT /v1/profile/:handle/proofs (authed X-KEZ-Auth, signed over
"PUT\n/v1/profile/<h>/proofs\n<ts>", distinct line from inbox/stream
so sigs can't cross-replay; 60s skew; max 64 subjects).
• All 20 existing http tests still pass.
Client:
• api.ts: HandleResponse.proofs + setProofs() (signs + PUTs).
• verify.ts: verifySubject(subject, primary) — runs the real channel
verifier given just subject+primary (no local envelope needed).
• conversations-store: cache verified + verified_checked_at per peer.
• Messages: on conversation open, fetch the peer's proof subjects and
verify them in the background (24h cache → snappy, rate-limit
friendly). VerifiedBadge in the conversation row + thread header.
• Identity: reverify now publishes your verified subjects to your
profile (so peers can discover them) + shows the badge on your own
card.
• VerifiedBadge.svelte: scalloped-seal check in verified-green
(distinct from the cyan brand accent).
Flow: you reverify your proofs on Identity → they publish to your
profile → when someone opens a chat with you, their client fetches +
verifies them → you get the check on their screen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
241 lines
8.5 KiB
Rust
241 lines
8.5 KiB
Rust
//! SQLite-backed handle registry.
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use kez_core::Identity;
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::error::ApiError;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HandleRecord {
|
|
pub handle: String,
|
|
pub primary: Identity,
|
|
pub registered_at: DateTime<Utc>,
|
|
/// JSON array of claim subjects the user has published to their profile
|
|
/// (e.g. ["github:alice","dns:alice.com"]). Discovery only — peers
|
|
/// independently verify each against the channel. None until set.
|
|
pub proofs: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Store {
|
|
inner: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
impl Store {
|
|
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
|
let conn = Connection::open(path)?;
|
|
init_schema(&conn)?;
|
|
Ok(Self {
|
|
inner: Arc::new(Mutex::new(conn)),
|
|
})
|
|
}
|
|
|
|
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
|
let conn = Connection::open_in_memory()?;
|
|
init_schema(&conn)?;
|
|
Ok(Self {
|
|
inner: Arc::new(Mutex::new(conn)),
|
|
})
|
|
}
|
|
|
|
/// Reserve a handle for a primary key. Fails with Conflict if the
|
|
/// handle is already taken, or if this primary key has already
|
|
/// registered a (different) handle.
|
|
pub async fn register(&self, record: &HandleRecord) -> Result<(), ApiError> {
|
|
let conn = self.inner.lock().await;
|
|
conn.execute(
|
|
"INSERT INTO handles (handle, primary_id, registered_at)
|
|
VALUES (?1, ?2, ?3)",
|
|
params![
|
|
record.handle,
|
|
record.primary.to_string(),
|
|
record.registered_at.to_rfc3339(),
|
|
],
|
|
)
|
|
.map_err(|e| match e {
|
|
rusqlite::Error::SqliteFailure(err, _)
|
|
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
|
{
|
|
ApiError::Conflict("handle is already taken".into())
|
|
}
|
|
other => ApiError::Internal(format!("db: {other}")),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Look up the record for `handle`. Returns None if not registered.
|
|
pub async fn lookup(&self, handle: &str) -> Result<Option<HandleRecord>, ApiError> {
|
|
let conn = self.inner.lock().await;
|
|
let row = conn
|
|
.query_row(
|
|
"SELECT handle, primary_id, registered_at, proofs
|
|
FROM handles WHERE handle = ?1",
|
|
params![handle],
|
|
row_to_record_parts,
|
|
)
|
|
.optional()?;
|
|
row.map(build_record).transpose()
|
|
}
|
|
|
|
/// Replace the published proof-subject list for `handle`.
|
|
pub async fn set_proofs(&self, handle: &str, proofs_json: &str) -> Result<(), ApiError> {
|
|
let conn = self.inner.lock().await;
|
|
let n = conn.execute(
|
|
"UPDATE handles SET proofs = ?1 WHERE handle = ?2",
|
|
params![proofs_json, handle],
|
|
)?;
|
|
if n == 0 {
|
|
return Err(ApiError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Look up the record for a primary key — used by the NATS auth
|
|
/// callout: NATS sends us a connecting client's nkey, we figure out
|
|
/// which handle (if any) owns it.
|
|
pub async fn lookup_by_primary(
|
|
&self,
|
|
primary: &Identity,
|
|
) -> Result<Option<HandleRecord>, ApiError> {
|
|
let conn = self.inner.lock().await;
|
|
let row = conn
|
|
.query_row(
|
|
"SELECT handle, primary_id, registered_at, proofs
|
|
FROM handles WHERE primary_id = ?1",
|
|
params![primary.to_string()],
|
|
row_to_record_parts,
|
|
)
|
|
.optional()?;
|
|
row.map(build_record).transpose()
|
|
}
|
|
}
|
|
|
|
type RecordParts = (String, String, String, Option<String>);
|
|
|
|
fn row_to_record_parts(row: &rusqlite::Row) -> rusqlite::Result<RecordParts> {
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
}
|
|
|
|
fn build_record(parts: RecordParts) -> Result<HandleRecord, ApiError> {
|
|
let (handle, primary_id, registered_at, proofs) = parts;
|
|
let primary = Identity::parse(primary_id)
|
|
.map_err(|e| ApiError::Internal(format!("stored primary not parseable: {e}")))?;
|
|
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
|
.map_err(|e| ApiError::Internal(format!("stored timestamp not parseable: {e}")))?
|
|
.with_timezone(&Utc);
|
|
Ok(HandleRecord {
|
|
handle,
|
|
primary,
|
|
registered_at,
|
|
proofs,
|
|
})
|
|
}
|
|
|
|
fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|
// New column for existing databases — ignore "duplicate column" so
|
|
// re-running on an already-migrated DB is a no-op.
|
|
let _ = conn.execute("ALTER TABLE handles ADD COLUMN proofs TEXT", []);
|
|
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS handles (
|
|
handle TEXT NOT NULL PRIMARY KEY,
|
|
primary_id TEXT NOT NULL UNIQUE,
|
|
registered_at TEXT NOT NULL,
|
|
proofs TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_handles_primary
|
|
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)
|
|
}
|
|
}
|