feat(kez-chat): verified-user badge in chat (X/Twitter-style, but real)
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>
This commit is contained in:
parent
fc75b27ac6
commit
a2538b2886
@ -41,6 +41,7 @@ pub fn router(state: AppState) -> axum::Router {
|
|||||||
.route("/v1/u/:handle", get(lookup))
|
.route("/v1/u/:handle", get(lookup))
|
||||||
.route("/v1/by-primary/:primary", get(lookup_by_primary))
|
.route("/v1/by-primary/:primary", get(lookup_by_primary))
|
||||||
.route("/v1/register", post(register))
|
.route("/v1/register", post(register))
|
||||||
|
.route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs))
|
||||||
.route("/v1/messages", post(crate::messages::send_message))
|
.route("/v1/messages", post(crate::messages::send_message))
|
||||||
.route("/v1/inbox/:handle", get(crate::messages::inbox))
|
.route("/v1/inbox/:handle", get(crate::messages::inbox))
|
||||||
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
|
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
|
||||||
@ -81,6 +82,9 @@ pub struct HandleResponse {
|
|||||||
pub primary: String, // e.g. "ed25519:abc..."
|
pub primary: String, // e.g. "ed25519:abc..."
|
||||||
pub sigchain_url: String, // where the sigchain lives
|
pub sigchain_url: String, // where the sigchain lives
|
||||||
pub registered_at: String,
|
pub registered_at: String,
|
||||||
|
/// Claim subjects the user published for discovery (e.g.
|
||||||
|
/// ["github:alice"]). Peers verify each independently. Empty if none.
|
||||||
|
pub proofs: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn lookup(
|
async fn lookup(
|
||||||
@ -126,6 +130,11 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
|||||||
id
|
id
|
||||||
),
|
),
|
||||||
registered_at: record.registered_at.to_rfc3339(),
|
registered_at: record.registered_at.to_rfc3339(),
|
||||||
|
proofs: record
|
||||||
|
.proofs
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,6 +163,7 @@ async fn register(
|
|||||||
handle: req.payload.handle.clone(),
|
handle: req.payload.handle.clone(),
|
||||||
primary: req.payload.primary.clone(),
|
primary: req.payload.primary.clone(),
|
||||||
registered_at: Utc::now(),
|
registered_at: Utc::now(),
|
||||||
|
proofs: None,
|
||||||
};
|
};
|
||||||
state.store.register(&record).await?;
|
state.store.register(&record).await?;
|
||||||
|
|
||||||
@ -163,6 +173,72 @@ async fn register(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PUT /v1/profile/:handle/proofs — publish your claim subjects (authed)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetProofsRequest {
|
||||||
|
/// Claim subjects, e.g. ["github:alice","dns:alice.com"].
|
||||||
|
pub proofs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_proofs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(handle): Path<String>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(req): Json<SetProofsRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
validate_handle(&handle)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
||||||
|
|
||||||
|
let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
// Auth: X-KEZ-Auth: <unix_ts>:<sig>, signed by the handle's primary
|
||||||
|
// over the canonical request line. Same 60s skew window as inbox.
|
||||||
|
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()))?;
|
||||||
|
verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?;
|
||||||
|
|
||||||
|
// Cap to keep profiles small; reject absurd payloads.
|
||||||
|
if req.proofs.len() > 64 {
|
||||||
|
return Err(ApiError::BadRequest("too many proofs (max 64)".into()));
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string(&req.proofs)?;
|
||||||
|
state.store.set_proofs(&handle, &json).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical message the proofs-setter signs. Distinct first line from
|
||||||
|
/// the inbox/stream auth so signatures can't be cross-replayed.
|
||||||
|
pub fn canonical_profile_message(handle: &str, ts: i64) -> String {
|
||||||
|
format!("PUT\n/v1/profile/{handle}/proofs\n{ts}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_profile_auth(
|
||||||
|
auth: &str,
|
||||||
|
handle: &str,
|
||||||
|
pubkey_hex: &str,
|
||||||
|
now_ts: i64,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let (ts_str, sig_hex) = auth
|
||||||
|
.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("auth ts must be a unix timestamp".into()))?;
|
||||||
|
if (now_ts - ts).abs() > 60 {
|
||||||
|
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
||||||
|
}
|
||||||
|
let message = canonical_profile_message(handle, ts);
|
||||||
|
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
|
||||||
|
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// GET /.well-known/webfinger — fediverse-style discovery
|
// GET /.well-known/webfinger — fediverse-style discovery
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -15,6 +15,10 @@ pub struct HandleRecord {
|
|||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub primary: Identity,
|
pub primary: Identity,
|
||||||
pub registered_at: DateTime<Utc>,
|
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)]
|
#[derive(Clone)]
|
||||||
@ -69,36 +73,26 @@ impl Store {
|
|||||||
let conn = self.inner.lock().await;
|
let conn = self.inner.lock().await;
|
||||||
let row = conn
|
let row = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT handle, primary_id, registered_at
|
"SELECT handle, primary_id, registered_at, proofs
|
||||||
FROM handles WHERE handle = ?1",
|
FROM handles WHERE handle = ?1",
|
||||||
params![handle],
|
params![handle],
|
||||||
|row| {
|
row_to_record_parts,
|
||||||
let handle: String = row.get(0)?;
|
|
||||||
let primary_id: String = row.get(1)?;
|
|
||||||
let registered_at: String = row.get(2)?;
|
|
||||||
Ok((handle, primary_id, registered_at))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
row.map(build_record).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
match row {
|
/// Replace the published proof-subject list for `handle`.
|
||||||
None => Ok(None),
|
pub async fn set_proofs(&self, handle: &str, proofs_json: &str) -> Result<(), ApiError> {
|
||||||
Some((handle, primary_id, registered_at)) => {
|
let conn = self.inner.lock().await;
|
||||||
let primary = Identity::parse(primary_id).map_err(|e| {
|
let n = conn.execute(
|
||||||
ApiError::Internal(format!("stored primary not parseable: {e}"))
|
"UPDATE handles SET proofs = ?1 WHERE handle = ?2",
|
||||||
})?;
|
params![proofs_json, handle],
|
||||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
)?;
|
||||||
.map_err(|e| {
|
if n == 0 {
|
||||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
return Err(ApiError::NotFound);
|
||||||
})?
|
|
||||||
.with_timezone(&Utc);
|
|
||||||
Ok(Some(HandleRecord {
|
|
||||||
handle,
|
|
||||||
primary,
|
|
||||||
registered_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up the record for a primary key — used by the NATS auth
|
/// Look up the record for a primary key — used by the NATS auth
|
||||||
@ -111,45 +105,48 @@ impl Store {
|
|||||||
let conn = self.inner.lock().await;
|
let conn = self.inner.lock().await;
|
||||||
let row = conn
|
let row = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT handle, primary_id, registered_at
|
"SELECT handle, primary_id, registered_at, proofs
|
||||||
FROM handles WHERE primary_id = ?1",
|
FROM handles WHERE primary_id = ?1",
|
||||||
params![primary.to_string()],
|
params![primary.to_string()],
|
||||||
|row| {
|
row_to_record_parts,
|
||||||
let handle: String = row.get(0)?;
|
|
||||||
let primary_id: String = row.get(1)?;
|
|
||||||
let registered_at: String = row.get(2)?;
|
|
||||||
Ok((handle, primary_id, registered_at))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
row.map(build_record).transpose()
|
||||||
match row {
|
|
||||||
None => Ok(None),
|
|
||||||
Some((handle, primary_id, registered_at)) => {
|
|
||||||
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(Some(HandleRecord {
|
|
||||||
handle,
|
|
||||||
primary,
|
|
||||||
registered_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
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(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS handles (
|
"CREATE TABLE IF NOT EXISTS handles (
|
||||||
handle TEXT NOT NULL PRIMARY KEY,
|
handle TEXT NOT NULL PRIMARY KEY,
|
||||||
primary_id TEXT NOT NULL UNIQUE,
|
primary_id TEXT NOT NULL UNIQUE,
|
||||||
registered_at TEXT NOT NULL
|
registered_at TEXT NOT NULL,
|
||||||
|
proofs TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_handles_primary
|
CREATE INDEX IF NOT EXISTS idx_handles_primary
|
||||||
ON handles (primary_id);
|
ON handles (primary_id);
|
||||||
|
|||||||
27
kez-chat/web/src/lib/VerifiedBadge.svelte
Normal file
27
kez-chat/web/src/lib/VerifiedBadge.svelte
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Verified check — shown next to a KEZ that has ≥1 cryptographically
|
||||||
|
// verified proof (the client checked it, X/Twitter-style). Green so it
|
||||||
|
// reads as "verified", distinct from the cyan brand accent.
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
/** Tooltip text. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
let { size = 16, title = "Verified — controls a proven account" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="inline-block shrink-0 align-text-bottom"
|
||||||
|
style="color: var(--color-verified)"
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
aria-label="verified"
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
<!-- Scalloped seal + check, the familiar verified glyph. -->
|
||||||
|
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
|
||||||
|
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
|
||||||
|
</svg>
|
||||||
@ -1,6 +1,8 @@
|
|||||||
// Thin HTTP client for kez-chat-server. Same calls a native CLI would
|
// Thin HTTP client for kez-chat-server. Same calls a native CLI would
|
||||||
// make — the SPA dogfoods the API surface.
|
// make — the SPA dogfoods the API surface.
|
||||||
|
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import type { SignedRegistration } from "./kez.js";
|
import type { SignedRegistration } from "./kez.js";
|
||||||
|
|
||||||
export interface HandleResponse {
|
export interface HandleResponse {
|
||||||
@ -9,6 +11,8 @@ export interface HandleResponse {
|
|||||||
primary: string;
|
primary: string;
|
||||||
sigchain_url: string;
|
sigchain_url: string;
|
||||||
registered_at: string;
|
registered_at: string;
|
||||||
|
/** Claim subjects the user published for discovery, e.g. ["github:alice"]. */
|
||||||
|
proofs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
@ -78,6 +82,32 @@ export async function lookupByPrimary(primary: string): Promise<HandleResponse>
|
|||||||
return unwrap(resp);
|
return unwrap(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish your verified claim subjects to your server profile so peers
|
||||||
|
* can discover (and independently verify) them — drives the verified
|
||||||
|
* badge in chat. Authed with X-KEZ-Auth signed by your primary.
|
||||||
|
*/
|
||||||
|
export async function setProofs(
|
||||||
|
handle: string,
|
||||||
|
seed: Uint8Array,
|
||||||
|
subjects: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const ts = Math.floor(Date.now() / 1000);
|
||||||
|
const msg = `PUT\n/v1/profile/${handle}/proofs\n${ts}`;
|
||||||
|
const sig = ed25519.sign(new TextEncoder().encode(msg), seed);
|
||||||
|
const resp = await fetch(url(`/v1/profile/${encodeURIComponent(handle)}/proofs`), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"X-KEZ-Auth": `${ts}:${bytesToHex(sig)}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ proofs: subjects }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new ApiError(resp.status, `setProofs → ${resp.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function register(
|
export async function register(
|
||||||
signed: SignedRegistration,
|
signed: SignedRegistration,
|
||||||
): Promise<HandleResponse> {
|
): Promise<HandleResponse> {
|
||||||
|
|||||||
@ -34,6 +34,10 @@ export interface Conversation {
|
|||||||
messages: ConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
/** Max server-seq we've processed for any message from this peer. */
|
/** Max server-seq we've processed for any message from this peer. */
|
||||||
last_seq: number;
|
last_seq: number;
|
||||||
|
/** Whether ≥1 of the peer's published proofs verified (the badge). */
|
||||||
|
verified?: boolean;
|
||||||
|
/** ISO timestamp of the last verification check (24h cache window). */
|
||||||
|
verified_checked_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
@ -99,6 +103,19 @@ export async function ensureConversation(
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Record a peer's verification result (drives the verified badge). */
|
||||||
|
export async function setVerified(
|
||||||
|
peer_primary: Identity,
|
||||||
|
verified: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const s = await read();
|
||||||
|
const conv = s.by_peer[peer_primary];
|
||||||
|
if (!conv) return;
|
||||||
|
conv.verified = verified;
|
||||||
|
conv.verified_checked_at = new Date().toISOString();
|
||||||
|
await write(s);
|
||||||
|
}
|
||||||
|
|
||||||
export async function appendInbound(opts: {
|
export async function appendInbound(opts: {
|
||||||
peer_primary: Identity;
|
peer_primary: Identity;
|
||||||
peer_handle: string;
|
peer_handle: string;
|
||||||
|
|||||||
@ -48,4 +48,34 @@ export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that `primary` genuinely controls `subject` by fetching the
|
||||||
|
* proof from the subject's channel and checking the signature — same
|
||||||
|
* real check as verifyClaim, but driven by just (subject, primary)
|
||||||
|
* since we don't hold a peer's envelope locally. Used for the verified
|
||||||
|
* badge on other people in chat.
|
||||||
|
*
|
||||||
|
* The channel is the subject prefix (github:, dns:, …). Verifiers read
|
||||||
|
* ctx.subject + ctx.primary; `expected` is unused by them, so a stub is
|
||||||
|
* fine here.
|
||||||
|
*/
|
||||||
|
export async function verifySubject(
|
||||||
|
subject: string,
|
||||||
|
primary: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const channel = subject.split(":")[0];
|
||||||
|
const handler = REGISTRY[channel];
|
||||||
|
if (!handler) return false;
|
||||||
|
try {
|
||||||
|
const res = await handler({
|
||||||
|
subject,
|
||||||
|
primary,
|
||||||
|
expected: undefined as never,
|
||||||
|
});
|
||||||
|
return res.status === "ok";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type { VerifyResult } from "./verifiers/types.js";
|
export type { VerifyResult } from "./verifiers/types.js";
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { lookup, ApiError } from "../lib/api.js";
|
import { lookup, setProofs, ApiError } from "../lib/api.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
|
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||||
import {
|
import {
|
||||||
listClaims,
|
listClaims,
|
||||||
setVerifyResult,
|
setVerifyResult,
|
||||||
@ -43,11 +44,30 @@
|
|||||||
await setVerifyResult(c.id, result);
|
await setVerifyResult(c.id, result);
|
||||||
}
|
}
|
||||||
claims = await listClaims();
|
claims = await listClaims();
|
||||||
|
await publishVerifiedSubjects();
|
||||||
} finally {
|
} finally {
|
||||||
verifyingAll = false;
|
verifyingAll = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the subjects of our currently-verified claims to the server
|
||||||
|
* profile so peers can discover + independently verify them (drives
|
||||||
|
* their verified badge for us). Best-effort — a failure here doesn't
|
||||||
|
* affect local verification.
|
||||||
|
*/
|
||||||
|
async function publishVerifiedSubjects() {
|
||||||
|
if (!session.unlocked) return;
|
||||||
|
const subjects = claims
|
||||||
|
.filter((c) => c.last_verify?.status === "ok")
|
||||||
|
.map((c) => c.envelope.payload.subject);
|
||||||
|
try {
|
||||||
|
await setProofs(session.unlocked.handle, session.unlocked.seed, subjects);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("publishVerifiedSubjects failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function channelLabel(ch: string): string {
|
function channelLabel(ch: string): string {
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
@ -87,8 +107,9 @@
|
|||||||
<Avatar seed={session.unlocked.primary} size={64} ring />
|
<Avatar seed={session.unlocked.primary} size={64} ring />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="font-mono text-lg font-semibold text-text truncate">
|
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
|
||||||
{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span>
|
<span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span>
|
||||||
|
{#if verified.length > 0}<VerifiedBadge size={16} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
|
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
|
||||||
|
|||||||
@ -3,14 +3,17 @@
|
|||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
import { sendMessage } from "../lib/messages.js";
|
import { sendMessage } from "../lib/messages.js";
|
||||||
import { lookup, ApiError } from "../lib/api.js";
|
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||||
|
import { verifySubject } from "../lib/verify.js";
|
||||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
import Avatar from "../lib/Avatar.svelte";
|
||||||
|
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||||
import {
|
import {
|
||||||
appendOutbound,
|
appendOutbound,
|
||||||
ensureConversation,
|
ensureConversation,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
setVerified,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
} from "../lib/conversations-store.js";
|
} from "../lib/conversations-store.js";
|
||||||
import type { Identity } from "../lib/kez.js";
|
import type { Identity } from "../lib/kez.js";
|
||||||
@ -153,6 +156,43 @@
|
|||||||
conversations = await listConversations();
|
conversations = await listConversations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the peer whenever a conversation is opened. The 24h cache in
|
||||||
|
// verifyPeer makes this a no-op on repeat opens (and after setVerified
|
||||||
|
// refreshes the list, so no loop).
|
||||||
|
$effect(() => {
|
||||||
|
const conv = activeConv;
|
||||||
|
if (conv) void verifyPeer(conv);
|
||||||
|
});
|
||||||
|
|
||||||
|
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a peer's published proofs (24h cache). Fetches their claimed
|
||||||
|
* subjects from the server, runs the real channel verifiers against
|
||||||
|
* each, and caches whether ≥1 passed → drives the verified badge.
|
||||||
|
* Runs in the background on conversation open; never blocks the UI.
|
||||||
|
*/
|
||||||
|
async function verifyPeer(conv: Conversation) {
|
||||||
|
const fresh =
|
||||||
|
conv.verified_checked_at &&
|
||||||
|
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
|
||||||
|
if (fresh) return;
|
||||||
|
try {
|
||||||
|
const record = await lookupByPrimary(conv.peer_primary);
|
||||||
|
let ok = false;
|
||||||
|
for (const subject of record.proofs ?? []) {
|
||||||
|
if (await verifySubject(subject, conv.peer_primary)) {
|
||||||
|
ok = true;
|
||||||
|
break; // one verified proof is enough for the badge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await setVerified(conv.peer_primary, ok);
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
// Peer not resolvable / offline channels — leave badge as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "Start chat with handle" — resolve, ensure conversation, open it. */
|
/** "Start chat with handle" — resolve, ensure conversation, open it. */
|
||||||
async function startConversation() {
|
async function startConversation() {
|
||||||
if (!session.unlocked || !newPeerInput.trim()) return;
|
if (!session.unlocked || !newPeerInput.trim()) return;
|
||||||
@ -322,7 +362,10 @@
|
|||||||
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
||||||
<Avatar seed={c.peer_primary} size={40} />
|
<Avatar seed={c.peer_primary} size={40} />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(c)}</p>
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
|
<span class="truncate">{displayName(c)}</span>
|
||||||
|
{#if c.verified}<VerifiedBadge size={14} />{/if}
|
||||||
|
</p>
|
||||||
{#if last}
|
{#if last}
|
||||||
<p class="text-xs text-text-secondary truncate">
|
<p class="text-xs text-text-secondary truncate">
|
||||||
{last.direction === "out" ? "→ " : ""}{last.body}
|
{last.direction === "out" ? "→ " : ""}{last.body}
|
||||||
@ -365,7 +408,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(activeConv)}</p>
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
|
<span class="truncate">{displayName(activeConv)}</span>
|
||||||
|
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
|
||||||
|
</p>
|
||||||
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user