Compare commits
4 Commits
dac98486c5
...
d10dfb93f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10dfb93f2 | ||
|
|
7bbf8baf86 | ||
|
|
41f9442650 | ||
|
|
e1f2514fae |
9
kez-chat/web/.env
Normal file
9
kez-chat/web/.env
Normal file
@ -0,0 +1,9 @@
|
||||
# Nostr branch: chat transport runs over Nostr relays instead of the
|
||||
# kez-chat server inbox. The code default (see src/lib/transport.ts) is
|
||||
# still "server", so main/other branches are unaffected — this file is
|
||||
# what flips this branch to Nostr.
|
||||
VITE_TRANSPORT=nostr
|
||||
|
||||
# Relays to publish to / read from (comma-separated). Optional — the
|
||||
# transport falls back to these same defaults if unset.
|
||||
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
||||
@ -27,7 +27,7 @@ import {
|
||||
streamInbox,
|
||||
type InboxMessage,
|
||||
type StreamHandle,
|
||||
} from "./messages.js";
|
||||
} from "./transport.js";
|
||||
import { lookupByPrimary } from "./api.js";
|
||||
import {
|
||||
appendInbound,
|
||||
|
||||
61
kez-chat/web/src/lib/nostr-id.ts
Normal file
61
kez-chat/web/src/lib/nostr-id.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// Bridges the KEZ ed25519 identity onto Nostr's secp256k1 world.
|
||||
//
|
||||
// Nostr signs with secp256k1 (Schnorr); KEZ identities are ed25519.
|
||||
// The two curves can't be cross-derived, so we can't turn someone's
|
||||
// ed25519 primary into "their" Nostr pubkey. Instead:
|
||||
//
|
||||
// • The *signing* key is a secp256k1 key derived deterministically
|
||||
// from the user's own ed25519 seed (HKDF, domain-separated). It is
|
||||
// a pure transport credential — internal to this app, never the
|
||||
// user's real Nostr account, and its pubkey is never advertised.
|
||||
//
|
||||
// • *Addressing* is done by a tag derived from the recipient's
|
||||
// ed25519 PRIMARY (which is public, so any sender can compute it).
|
||||
// The recipient subscribes to relays filtering on the same tag.
|
||||
//
|
||||
// This keeps the whole thing "internal": nothing Nostr-specific leaks
|
||||
// into the UI or the KEZ identity model. Nostr is just the pipe.
|
||||
|
||||
import { hkdf } from "@noble/hashes/hkdf";
|
||||
import { sha256 } from "@noble/hashes/sha2";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import type { Identity } from "./kez.js";
|
||||
|
||||
/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */
|
||||
export const KEZ_DM_KIND = 4242;
|
||||
|
||||
/** Tag name carrying the recipient address. `#h` filter on the relay side. */
|
||||
export const ADDR_TAG = "h";
|
||||
|
||||
const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey");
|
||||
const SIGNKEY_INFO = new TextEncoder().encode("v1");
|
||||
const ADDR_SALT = new TextEncoder().encode("kez-chat:nostr-addr");
|
||||
const ADDR_INFO = new TextEncoder().encode("v1");
|
||||
|
||||
/**
|
||||
* Derive the secp256k1 secret key this identity signs Nostr events with.
|
||||
* Deterministic from the 32-byte ed25519 seed, so the same account always
|
||||
* produces the same Nostr signer — but it reveals nothing about the seed.
|
||||
*
|
||||
* HKDF output is a uniformly random 32-byte value; the probability it is
|
||||
* not a valid secp256k1 scalar (≥ curve order n) is ~2⁻¹²⁸, i.e. never.
|
||||
*/
|
||||
export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array {
|
||||
if (seed.length !== 32) throw new Error(`seed must be 32 bytes, got ${seed.length}`);
|
||||
return hkdf(sha256, seed, SIGNKEY_SALT, SIGNKEY_INFO, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic 32-byte (hex) address for a recipient, derived from their
|
||||
* public ed25519 primary. Both parties can compute it: the sender from the
|
||||
* directory lookup, the recipient from their own primary. Used as the value
|
||||
* of the `#h` tag so a relay subscription can fan messages to the right box.
|
||||
*
|
||||
* It is *not* the recipient's Nostr pubkey (can't be — wrong curve); it is an
|
||||
* opaque routing label. Using a hash also means the raw primary isn't sitting
|
||||
* in a relay-queryable tag.
|
||||
*/
|
||||
export function addrFromPrimary(primary: Identity): string {
|
||||
const bytes = new TextEncoder().encode(primary);
|
||||
return bytesToHex(hkdf(sha256, bytes, ADDR_SALT, ADDR_INFO, 32));
|
||||
}
|
||||
243
kez-chat/web/src/lib/nostr-transport.ts
Normal file
243
kez-chat/web/src/lib/nostr-transport.ts
Normal file
@ -0,0 +1,243 @@
|
||||
// Nostr-relay transport. Drop-in replacement for the server inbox in
|
||||
// messages.ts — same public surface (sendMessage / pollInbox /
|
||||
// streamInbox / decrypt) so inbox-service and the Messages UI don't know
|
||||
// or care which pipe is in use.
|
||||
//
|
||||
// What changes vs. the server transport: instead of POSTing the sealed
|
||||
// envelope to /v1/messages and polling /v1/inbox, we publish it as the
|
||||
// content of a Nostr event and subscribe to relays for events addressed
|
||||
// to us. The envelope itself is byte-identical — it's still produced by
|
||||
// crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves
|
||||
// the bytes; our key still does the encrypting.
|
||||
//
|
||||
// Addressing: events carry an `#h` tag = addrFromPrimary(recipient). We
|
||||
// subscribe with a `{ "#h": [myAddr] }` filter. Events are signed by a
|
||||
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
|
||||
// relays accept them — that key is never surfaced to the user.
|
||||
|
||||
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
||||
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
||||
import { lookup } from "./api.js";
|
||||
import { identityFromSeed, type Identity } from "./kez.js";
|
||||
import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js";
|
||||
// Decryption is transport-agnostic (just our crypto), so reuse it verbatim.
|
||||
import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js";
|
||||
|
||||
export { decrypt };
|
||||
export type { InboxMessage, StreamHandle };
|
||||
|
||||
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */
|
||||
const RELAYS: string[] = (
|
||||
import.meta.env.VITE_NOSTR_RELAYS ??
|
||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net"
|
||||
)
|
||||
.split(",")
|
||||
.map((r: string) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** One pool for the whole session — relay connections are reused. */
|
||||
let _pool: SimplePool | null = null;
|
||||
function pool(): SimplePool {
|
||||
if (!_pool) _pool = new SimplePool();
|
||||
return _pool;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-handle cursor + dedupe (localStorage, survives reloads)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
|
||||
const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`;
|
||||
const SEEN_CAP = 500;
|
||||
|
||||
/** Relay `since` filter (unix seconds). Start a little in the past so a
|
||||
* fresh device still catches very recent messages. */
|
||||
function readSince(handle: string): number {
|
||||
try {
|
||||
const v = localStorage.getItem(SINCE_KEY(handle));
|
||||
return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
function bumpSince(handle: string, createdAt: number) {
|
||||
try {
|
||||
if (createdAt > readSince(handle)) {
|
||||
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
|
||||
}
|
||||
} catch {
|
||||
/* private mode — fine */
|
||||
}
|
||||
}
|
||||
|
||||
function readSeen(handle: string): Set<string> {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY(handle)) ?? "[]"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
/** Returns true if this id is new (and records it); false if already seen. */
|
||||
function markSeen(handle: string, id: string): boolean {
|
||||
const seen = readSeen(handle);
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
// Bound the set so localStorage doesn't grow forever.
|
||||
const arr = [...seen].slice(-SEEN_CAP);
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY(handle), JSON.stringify(arr));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Nostr event → InboxMessage. `seq` must be monotonic across sessions
|
||||
* (the inbox-service notification watermark and conversations-store dedupe
|
||||
* both rely on it), so we base it on created_at and spread within a second
|
||||
* using the event id to avoid same-second collisions between distinct msgs.
|
||||
*/
|
||||
function toInboxMessage(ev: Event): InboxMessage | null {
|
||||
let envelope: SealedEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(ev.content) as SealedEnvelope;
|
||||
} catch {
|
||||
return null; // not one of ours / malformed
|
||||
}
|
||||
const subMilli = parseInt(ev.id.slice(0, 3), 16) % 1000;
|
||||
const seq = ev.created_at * 1000 + subMilli;
|
||||
return {
|
||||
seq,
|
||||
envelope,
|
||||
created_at: new Date(ev.created_at * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Send
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 recipientPrimary = record.primary as Identity;
|
||||
|
||||
// Our own encryption layer — identical to the server transport.
|
||||
const envelope = await sealMessage({
|
||||
senderSeed: opts.senderSeed,
|
||||
senderPrimary: opts.senderPrimary,
|
||||
recipientHandle,
|
||||
recipientPrimary,
|
||||
body: opts.body,
|
||||
});
|
||||
|
||||
const sk = nostrSecretFromSeed(opts.senderSeed);
|
||||
const tmpl: EventTemplate = {
|
||||
kind: KEZ_DM_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [[ADDR_TAG, addrFromPrimary(recipientPrimary)]],
|
||||
content: JSON.stringify(envelope),
|
||||
};
|
||||
const signed = finalizeEvent(tmpl, sk);
|
||||
|
||||
// Succeed if at least one relay accepts.
|
||||
const results = await Promise.allSettled(pool().publish(RELAYS, signed));
|
||||
if (!results.some((r) => r.status === "fulfilled")) {
|
||||
const why = results
|
||||
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`);
|
||||
}
|
||||
return { seq: signed.created_at };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inbox poll (one-shot relay query — the heartbeat catch-up path)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function pollInbox(opts: {
|
||||
handle: string;
|
||||
seed: Uint8Array;
|
||||
since: number; // ignored: server uses a seq cursor, we keep a time cursor
|
||||
limit?: number;
|
||||
}): Promise<{ messages: InboxMessage[]; cursor: number }> {
|
||||
const myPrimary = identityFromSeed(opts.seed).identity;
|
||||
const addr = addrFromPrimary(myPrimary);
|
||||
const since = readSince(opts.handle);
|
||||
|
||||
const events = await pool().querySync(RELAYS, {
|
||||
kinds: [KEZ_DM_KIND],
|
||||
[`#${ADDR_TAG}`]: [addr],
|
||||
since,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
});
|
||||
|
||||
const messages: InboxMessage[] = [];
|
||||
let maxSeq = 0;
|
||||
for (const ev of events.sort((a, b) => a.created_at - b.created_at)) {
|
||||
if (!markSeen(opts.handle, ev.id)) continue;
|
||||
const m = toInboxMessage(ev);
|
||||
if (!m) continue;
|
||||
messages.push(m);
|
||||
bumpSince(opts.handle, ev.created_at);
|
||||
if (m.seq > maxSeq) maxSeq = m.seq;
|
||||
}
|
||||
return { messages, cursor: maxSeq };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Live subscription (the SSE-equivalent push path)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function streamInbox(opts: {
|
||||
handle: string;
|
||||
seed: Uint8Array;
|
||||
onMessage: (msg: InboxMessage) => void;
|
||||
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
||||
}): StreamHandle {
|
||||
const myPrimary = identityFromSeed(opts.seed).identity;
|
||||
const addr = addrFromPrimary(myPrimary);
|
||||
let closed = false;
|
||||
|
||||
opts.onStatus?.("connecting");
|
||||
const sub = pool().subscribeMany(
|
||||
RELAYS,
|
||||
{ kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) },
|
||||
{
|
||||
onevent(ev: Event) {
|
||||
if (closed) return;
|
||||
if (!markSeen(opts.handle, ev.id)) return;
|
||||
const m = toInboxMessage(ev);
|
||||
if (!m) return;
|
||||
bumpSince(opts.handle, ev.created_at);
|
||||
opts.onMessage(m);
|
||||
},
|
||||
oneose() {
|
||||
// End of stored events from all relays → we're now live-tailing.
|
||||
opts.onStatus?.("live");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
sub.close();
|
||||
},
|
||||
get readyState() {
|
||||
// EventSource-compatible: OPEN(1) while subscribed, CLOSED(2) after.
|
||||
return closed ? 2 : 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { SealedEnvelope };
|
||||
export type { MessagePlaintext } from "./crypto.js";
|
||||
28
kez-chat/web/src/lib/transport.ts
Normal file
28
kez-chat/web/src/lib/transport.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Transport facade. The rest of the app (inbox-service, Messages) imports
|
||||
// send/poll/stream/decrypt from here and never learns which pipe carries
|
||||
// the bytes. Both transports expose an identical surface and both ship the
|
||||
// same sealed envelope from crypto.ts — only the delivery mechanism differs.
|
||||
//
|
||||
// VITE_TRANSPORT=server (default) → kez-chat server inbox over HTTP/SSE
|
||||
// VITE_TRANSPORT=nostr → Nostr relays
|
||||
//
|
||||
// Set it in .env / .env.local. Switching transports is build-time; there's
|
||||
// no reason to flip it at runtime and keeping it static lets Vite tree-shake
|
||||
// the unused transport out of the bundle.
|
||||
|
||||
import * as server from "./messages.js";
|
||||
import * as nostr from "./nostr-transport.js";
|
||||
|
||||
const TRANSPORT = (import.meta.env.VITE_TRANSPORT ?? "server") as "server" | "nostr";
|
||||
|
||||
const impl = TRANSPORT === "nostr" ? nostr : server;
|
||||
|
||||
export const sendMessage = impl.sendMessage;
|
||||
export const pollInbox = impl.pollInbox;
|
||||
export const streamInbox = impl.streamInbox;
|
||||
export const decrypt = impl.decrypt;
|
||||
|
||||
export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js";
|
||||
|
||||
/** Which transport this build is using — handy for a debug line in the UI. */
|
||||
export const activeTransport = TRANSPORT;
|
||||
@ -265,9 +265,9 @@
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
|
||||
onclick={() => push("/chats")}
|
||||
onclick={() => push("/welcome")}
|
||||
>
|
||||
Go to dashboard
|
||||
Get started →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -21,12 +21,25 @@
|
||||
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
|
||||
const pending = $derived(claims.filter((c) => !c.last_verify));
|
||||
|
||||
// Verified badge requires at least this many independently-verified
|
||||
// proofs. Kept in sync with VERIFY_MIN_PROOFS in Messages.svelte so the
|
||||
// badge means the same thing on the profile and in chat.
|
||||
const VERIFY_MIN_PROOFS = 2;
|
||||
const isVerified = $derived(verified.length >= VERIFY_MIN_PROOFS);
|
||||
|
||||
onMount(async () => {
|
||||
if (!session.unlocked) {
|
||||
push("/unlock");
|
||||
return;
|
||||
}
|
||||
claims = await listClaims();
|
||||
// Publish our verified proof subjects to the server profile so peers
|
||||
// can discover + independently verify them (drives our badge in their
|
||||
// chat). Previously this only happened on a manual "reverify all", so
|
||||
// verified users were invisible to peers until they clicked it.
|
||||
if (claims.some((c) => c.last_verify?.status === "ok")) {
|
||||
void publishVerifiedSubjects();
|
||||
}
|
||||
try {
|
||||
registryRecord = await lookup(session.unlocked.handle);
|
||||
} catch (e) {
|
||||
@ -109,7 +122,7 @@
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
|
||||
<span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span>
|
||||
{#if verified.length > 0}<VerifiedBadge size={16} />{/if}
|
||||
{#if isVerified}<VerifiedBadge size={16} />{/if}
|
||||
</span>
|
||||
<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"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import { sendMessage } from "../lib/messages.js";
|
||||
import { sendMessage } from "../lib/transport.js";
|
||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||
import { verifySubject } from "../lib/verify.js";
|
||||
@ -140,6 +140,10 @@
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
// Kick off verification for every existing conversation (24h cache per
|
||||
// peer), so the verified badge shows in the list without opening each
|
||||
// thread one by one.
|
||||
void verifyPeers(conversations);
|
||||
// Subscribe to the always-on inbox service — re-render whenever a
|
||||
// new message lands. The service is already running (it started on
|
||||
// session unlock in store.svelte.ts) regardless of which route the
|
||||
@ -157,41 +161,47 @@
|
||||
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).
|
||||
// Verify the active peer whenever a conversation is opened (covers
|
||||
// conversations started/arrived this session). The 24h per-peer cache
|
||||
// makes this a no-op on repeat opens, so the post-verify refresh can't loop.
|
||||
$effect(() => {
|
||||
const conv = activeConv;
|
||||
if (conv) void verifyPeer(conv);
|
||||
if (conv) void verifyPeers([conv]);
|
||||
});
|
||||
|
||||
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
/** A peer earns the verified badge once at least this many of their
|
||||
* published proofs independently check out. Kept in sync with the
|
||||
* profile rule in Identity.svelte. */
|
||||
const VERIFY_MIN_PROOFS = 2;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Verify a batch of peers' published proofs (24h cache per peer). For
|
||||
* each peer we fetch their claimed subjects from the server, run the
|
||||
* real channel verifiers, count how many pass, and set the verified
|
||||
* badge when ≥ VERIFY_MIN_PROOFS. Refreshes the list once at the end if
|
||||
* anything changed. Runs in the background; 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
|
||||
async function verifyPeers(convs: Conversation[]) {
|
||||
let changed = false;
|
||||
for (const conv of convs) {
|
||||
const fresh =
|
||||
conv.verified_checked_at &&
|
||||
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
|
||||
if (fresh) continue;
|
||||
try {
|
||||
const record = await lookupByPrimary(conv.peer_primary);
|
||||
let verifiedCount = 0;
|
||||
for (const subject of record.proofs ?? []) {
|
||||
if (await verifySubject(subject, conv.peer_primary)) verifiedCount++;
|
||||
}
|
||||
await setVerified(conv.peer_primary, verifiedCount >= VERIFY_MIN_PROOFS);
|
||||
changed = true;
|
||||
} catch {
|
||||
// Peer not resolvable / offline channels — leave badge as-is.
|
||||
}
|
||||
await setVerified(conv.peer_primary, ok);
|
||||
await refresh();
|
||||
} catch {
|
||||
// Peer not resolvable / offline channels — leave badge as-is.
|
||||
}
|
||||
if (changed) await refresh();
|
||||
}
|
||||
|
||||
/** "Start chat with handle" — resolve, ensure conversation, open it. */
|
||||
|
||||
@ -41,6 +41,12 @@ Three crates, ~2,500 lines of Rust, **99 tests**.
|
||||
|
||||
## Quick start
|
||||
|
||||
> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly
|
||||
> step-by-step walkthrough that takes you from "I have a nostr `nsec`"
|
||||
> to "I have a verified, published sigchain." It assumes nothing.
|
||||
>
|
||||
> This README is the reference; the tutorial is the on-ramp.
|
||||
|
||||
```sh
|
||||
# Build, test, and install the `kez` binary to ~/.cargo/bin (one time)
|
||||
cargo build
|
||||
|
||||
557
rust/TUTORIAL.md
Normal file
557
rust/TUTORIAL.md
Normal file
@ -0,0 +1,557 @@
|
||||
# Tutorial — your first KEZ identity, end to end
|
||||
|
||||
This is a hands-on walkthrough. By the end you'll have:
|
||||
|
||||
- ✅ A KEZ identity tied to a key you already trust (your existing nostr
|
||||
`nsec`, or a brand-new Ed25519 key).
|
||||
- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or
|
||||
nostr handle, etc.) — verifiable by anyone, no central server needed.
|
||||
- ✅ A sigchain that ties multiple identities together, exported in a
|
||||
portable format, and published where strangers can find it.
|
||||
- ✅ The ability to verify other people's identities the same way.
|
||||
|
||||
If you've used [Keybase](https://keybase.io), the mental model is the same.
|
||||
The difference: KEZ has no required central authority. Your proofs live
|
||||
wherever you publish them; the verifier just walks the links.
|
||||
|
||||
For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document
|
||||
is the friendly cousin.
|
||||
|
||||
> **Time budget:** 10–15 minutes for the first claim. A bit more if you
|
||||
> want to set up DNS or a sigchain publish.
|
||||
|
||||
---
|
||||
|
||||
## 0. Install
|
||||
|
||||
```sh
|
||||
git clone https://git.ptud.biz/DukeInc/Kez.git
|
||||
cd Kez/rust
|
||||
cargo build --release
|
||||
cargo install --path crates/kez-cli # puts `kez` in ~/.cargo/bin
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```sh
|
||||
kez --help
|
||||
```
|
||||
|
||||
You should see subcommands `identity`, `claim`, `verify`, and `sigchain`.
|
||||
|
||||
> **Don't want to install globally?** Replace every `kez` below with
|
||||
> `cargo run -p kez-cli --` (from the `rust/` directory). Slower to
|
||||
> start each time, but no install side effects.
|
||||
|
||||
> **Optional but recommended:** `export GITHUB_TOKEN=ghp_...` in your
|
||||
> shell before verifying github claims. Anonymous GitHub limits you to
|
||||
> 60 requests/hour; with a token it's 5000/hour. Any read-only token
|
||||
> works; KEZ never sends it anywhere but `api.github.com`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pick your primary key
|
||||
|
||||
Your **primary key** is the one private key the rest of your identity
|
||||
hangs off of. It signs every claim you make. Two choices:
|
||||
|
||||
### Option A: use your existing nostr key (recommended if you have one)
|
||||
|
||||
If you already use nostr (Damus, Amethyst, primal, etc.), you already
|
||||
have an `nsec1...` private key. Use it. KEZ understands nostr keys
|
||||
natively as Schnorr/secp256k1.
|
||||
|
||||
Export the `nsec` from your nostr client (every client has a way —
|
||||
usually Settings → Keys → Show / Export). Keep it secret; treat it the
|
||||
same as a wallet seed.
|
||||
|
||||
> **Warning.** Pasting your `nsec` into a CLI is fine on a machine you
|
||||
> trust. Don't do it on a shared box, and consider whether you want
|
||||
> shell history to remember it (`unset HISTFILE` for the session, or
|
||||
> prefix the command with a space if `HISTCONTROL=ignorespace`).
|
||||
|
||||
You can confirm KEZ accepts your key without signing anything yet:
|
||||
|
||||
```sh
|
||||
kez identity new --key-type nostr # only if you want a NEW key
|
||||
# vs.
|
||||
# (no command needed to "register" an existing nsec — just pass it
|
||||
# directly with --nsec on the first claim you sign)
|
||||
```
|
||||
|
||||
### Option B: generate a fresh Ed25519 primary
|
||||
|
||||
If you'd rather start clean, generate a new Ed25519 key:
|
||||
|
||||
```sh
|
||||
kez identity new --key-type ed25519
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Primary: ed25519:7a3b4c…
|
||||
Public: 7a3b4c… (hex)
|
||||
Secret: 9e3f51… (hex — 64 chars, KEEP SECRET)
|
||||
```
|
||||
|
||||
> **Save the secret.** It's the only thing that can sign as this
|
||||
> identity. There's no recovery flow — lose it and the identity is
|
||||
> gone. Write it down offline, or paste it into a password manager.
|
||||
> From here on this tutorial assumes you stored it.
|
||||
|
||||
For the rest of this tutorial we'll use a nostr key for examples and
|
||||
write the secret as `nsec1FAKE...` — substitute your real one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sign your first claim
|
||||
|
||||
A **claim** is just a signed sentence: *"the key I signed this with also
|
||||
controls `<subject>`."* The subject is a `system:identifier` string —
|
||||
`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc.
|
||||
|
||||
Say you want to prove you control the GitHub username `tudisco`.
|
||||
|
||||
```sh
|
||||
kez claim create github:tudisco \
|
||||
--nsec nsec1FAKE... \
|
||||
--format markdown \
|
||||
--out github-tudisco.kez.md
|
||||
```
|
||||
|
||||
That writes a file like:
|
||||
|
||||
```markdown
|
||||
# KEZ Proof
|
||||
|
||||
This account publishes a signed KEZ identity claim.
|
||||
|
||||
- Primary: `nostr:npub1tkf…`
|
||||
- Subject: `github:tudisco`
|
||||
- Created: `2026-05-27T19:21:46Z`
|
||||
|
||||
```kez
|
||||
{
|
||||
"kez": "claim",
|
||||
"payload": { ... },
|
||||
"signature": {
|
||||
"alg": "ed25519-sha512-jcs" / "nostr-schnorr-bip340-jcs",
|
||||
"key": "nostr:npub1tkf…",
|
||||
"sig": "abc123…"
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Picking the right format
|
||||
|
||||
Same claim, three packagings — same signature inside:
|
||||
|
||||
| Format | When to use | Command |
|
||||
|---|---|---|
|
||||
| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` |
|
||||
| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` |
|
||||
| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) |
|
||||
|
||||
If you skip `--out`, the proof prints to stdout — handy for piping.
|
||||
|
||||
---
|
||||
|
||||
## 3. Publish the proof
|
||||
|
||||
This is where KEZ does its job: you put the signed claim in a place that
|
||||
only *that specific account* could have put it. Anyone who can fetch
|
||||
that place can then verify it themselves.
|
||||
|
||||
Pick the section that matches the subject system you claimed.
|
||||
|
||||
### GitHub
|
||||
|
||||
You signed `github:tudisco`. Publish the markdown block to either:
|
||||
|
||||
**A public gist named `kez.md`** — easiest.
|
||||
1. Go to <https://gist.github.com/>.
|
||||
2. New gist → filename `kez.md` → paste the contents of
|
||||
`github-tudisco.kez.md`.
|
||||
3. Click **Create public gist**.
|
||||
|
||||
**Or your profile README** — fancier but you only get one.
|
||||
1. Make a repo named the same as your username (e.g.
|
||||
`tudisco/tudisco`). GitHub treats it as your profile README.
|
||||
2. Add the markdown block to `README.md`.
|
||||
3. Push.
|
||||
|
||||
KEZ's GitHub verifier checks public gists first, then the profile
|
||||
README.
|
||||
|
||||
### DNS — your own domain
|
||||
|
||||
You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file
|
||||
line for you:
|
||||
|
||||
```sh
|
||||
kez claim dns tud.ink --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
Output (abbreviated):
|
||||
|
||||
```
|
||||
_kez.tud.ink. 3600 IN TXT
|
||||
"kez:z1:KLUv_WAsACUHAD…<chunk 1>…"
|
||||
"<chunk 2>…"
|
||||
```
|
||||
|
||||
Add that TXT record at `_kez.<your-domain>` in your DNS provider's
|
||||
console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered
|
||||
the domain). Most providers will accept the whole compact string in one
|
||||
field and split it for you; the multi-chunk form above is the safe one
|
||||
for providers that don't.
|
||||
|
||||
Wait a minute or two for propagation, then you can verify it.
|
||||
|
||||
### Nostr — your own npub
|
||||
|
||||
You signed `nostr:npub1...`. Three places work (verifiers check all of
|
||||
them):
|
||||
|
||||
- **Profile `about` field** (kind-0 event) — easiest, one-time. Edit
|
||||
your nostr profile and paste the markdown block into your bio.
|
||||
- **A normal post** (kind-1) containing the markdown block — quickest if
|
||||
you're already active.
|
||||
- **A NIP-78 kind-30078 event** with `d` tag = `kez` — cleanest for
|
||||
tooling, but most clients don't expose it.
|
||||
|
||||
### Bluesky
|
||||
|
||||
Post the markdown block (or just the compact `kez:z1:…` string) as a
|
||||
public post on the account you claimed. The verifier scans your recent
|
||||
posts.
|
||||
|
||||
### Mastodon / ActivityPub
|
||||
|
||||
You signed `ap:@user@instance`. Add the markdown block to your profile
|
||||
**metadata** field (most instances expose 4 of them), or post it as a
|
||||
pinned toot. The verifier resolves via WebFinger → actor JSON → checks
|
||||
those fields.
|
||||
|
||||
### Your own website
|
||||
|
||||
You signed `web:https://example.com`. Upload the JSON form to
|
||||
`https://example.com/.well-known/kez.json`:
|
||||
|
||||
```sh
|
||||
kez claim create web:https://example.com --nsec nsec1FAKE... > kez.json
|
||||
scp kez.json youruser@example.com:/var/www/.well-known/kez.json
|
||||
```
|
||||
|
||||
Make sure it's publicly fetchable (no auth gate).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verify it
|
||||
|
||||
This is the moment of truth. Pretend you're a stranger checking that the
|
||||
claim is real:
|
||||
|
||||
```sh
|
||||
kez verify id github:tudisco
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Primary: nostr:npub1tkf...
|
||||
|
||||
Verified identities:
|
||||
- github:tudisco
|
||||
|
||||
Status: valid
|
||||
Confidence: strong
|
||||
```
|
||||
|
||||
Same shape for any channel:
|
||||
|
||||
```sh
|
||||
kez verify id dns:tud.ink
|
||||
kez verify id nostr:npub1tkf...
|
||||
kez verify id bluesky:tudisco.bsky.social
|
||||
kez verify id ap:@tudisco@mastodon.social
|
||||
kez verify id web:https://tud.ink
|
||||
```
|
||||
|
||||
The verifier:
|
||||
|
||||
1. Figured out which channel from the prefix.
|
||||
2. Fetched the proof from where you published it (gist, TXT, etc.).
|
||||
3. Decoded the envelope.
|
||||
4. Verified the cryptographic signature against the key inside.
|
||||
|
||||
**No KEZ server was involved.** Each side of the conversation independently
|
||||
proves the claim — that's the whole point.
|
||||
|
||||
### If verification fails
|
||||
|
||||
A few common ones:
|
||||
|
||||
- **`not_found`** — the proof isn't where the verifier looked. For
|
||||
GitHub, check the gist is public and the filename contains `kez`. For
|
||||
DNS, the TXT record is at `_kez.<domain>`, not `<domain>` itself; give
|
||||
propagation a minute.
|
||||
- **`subject_mismatch`** — you published a proof for one subject but
|
||||
asked the verifier to check a different one. The claim's `subject`
|
||||
must equal the identifier you're verifying.
|
||||
- **`invalid_signature`** — the proof was tampered with, or you
|
||||
re-signed with a different key after publishing. Re-sign and
|
||||
re-publish.
|
||||
- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export
|
||||
`GITHUB_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sigchain — link multiple identities together
|
||||
|
||||
A **sigchain** is an append-only log of "this key controls X" events,
|
||||
each signed by your primary. Once you have more than one claim, you
|
||||
want a sigchain so:
|
||||
|
||||
- Verifiers can discover your full identity graph from a single
|
||||
starting point.
|
||||
- You can later **revoke** a claim (e.g., you lost access to that
|
||||
github account) without invalidating the others.
|
||||
- Old events stay verifiable; the chain head is the current truth.
|
||||
|
||||
Chains live at `~/.kez/sigchains/<safe-primary>.jsonl`. The CLI creates
|
||||
the directory on first use; you don't manage it manually.
|
||||
|
||||
Add the github claim you already signed:
|
||||
|
||||
```sh
|
||||
kez sigchain add github:tudisco --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
Add a DNS claim too:
|
||||
|
||||
```sh
|
||||
kez sigchain add dns:tud.ink --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
You can optionally include a `--proof-url` pointing to where you
|
||||
published this claim's proof (your gist URL, etc.). Verifiers can use
|
||||
it to skip discovery.
|
||||
|
||||
Inspect what you've got:
|
||||
|
||||
```sh
|
||||
kez sigchain show --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Primary: nostr:npub1tkf...
|
||||
Path: /home/you/.kez/sigchains/nostr_npub1tkf….jsonl
|
||||
Length: 2 events
|
||||
Head: sha256:9c3a…
|
||||
Events:
|
||||
1. add github:tudisco proof_url=https://gist.github.com/tudisco/abc
|
||||
2. add dns:tud.ink
|
||||
```
|
||||
|
||||
Read-only view of a published chain (no secret needed):
|
||||
|
||||
```sh
|
||||
kez sigchain show --primary nostr:npub1tkf...
|
||||
```
|
||||
|
||||
This is what other people will do to inspect your identity graph.
|
||||
|
||||
### Revoking
|
||||
|
||||
If you ever lose control of an account (your github gets hacked, you
|
||||
sell a domain), revoke that subject:
|
||||
|
||||
```sh
|
||||
kez sigchain revoke github:tudisco --nsec nsec1FAKE...
|
||||
```
|
||||
|
||||
That appends a revoke event. Subsequent verifications treat that subject
|
||||
as "no longer claimed" by your primary, even if the old proof is still
|
||||
out there.
|
||||
|
||||
---
|
||||
|
||||
## 6. Publish your sigchain
|
||||
|
||||
Now make your chain discoverable so anyone with your primary can walk
|
||||
it. Options, in rough order of how much infra they need:
|
||||
|
||||
### To a kez-sig-server (zero setup)
|
||||
|
||||
If you have access to a [`kez-sig-server`](../rust-sig-server/) (one
|
||||
runs at `https://sig.kez.lat`):
|
||||
|
||||
```sh
|
||||
kez sigchain publish --nsec nsec1FAKE... \
|
||||
--server https://sig.kez.lat
|
||||
```
|
||||
|
||||
Each event is POSTed to the server, which exposes them at predictable
|
||||
URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate
|
||||
by also publishing to one of the channels below.
|
||||
|
||||
### To your own website (self-sovereign)
|
||||
|
||||
Export the chain bundle and host it yourself:
|
||||
|
||||
```sh
|
||||
kez sigchain publish --nsec nsec1FAKE... \
|
||||
--web --out kez-sigchain.jsonl
|
||||
```
|
||||
|
||||
Then upload `kez-sigchain.jsonl` to
|
||||
`https://<your-domain>/.well-known/kez-sigchain.jsonl`. Verifiers
|
||||
fetch it directly. Hardest to censor; you own it.
|
||||
|
||||
### To DNS
|
||||
|
||||
```sh
|
||||
kez sigchain publish --nsec nsec1FAKE... --dns tud.ink
|
||||
```
|
||||
|
||||
Prints a TXT record at `_kez-chain.<domain>` containing the
|
||||
compressed chain. Add it to your zone. Works for short chains; for
|
||||
long chains, prefer `--web` (TXT records are size-limited).
|
||||
|
||||
### To nostr
|
||||
|
||||
```sh
|
||||
kez sigchain publish --nsec nsec1FAKE... \
|
||||
--nostr wss://relay.damus.io
|
||||
```
|
||||
|
||||
Publishes the compact bundle as a kind-30078 event on that relay. Any
|
||||
nostr client / verifier subscribed can find it.
|
||||
|
||||
### Pick more than one
|
||||
|
||||
`publish` accepts any combination of these flags — you can mirror to
|
||||
all four in one shot:
|
||||
|
||||
```sh
|
||||
kez sigchain publish --nsec nsec1FAKE... \
|
||||
--server https://sig.kez.lat \
|
||||
--web --out kez-sigchain.jsonl \
|
||||
--dns tud.ink \
|
||||
--nostr wss://relay.damus.io
|
||||
```
|
||||
|
||||
Redundancy is good. If one channel goes down, the others still serve
|
||||
your identity graph.
|
||||
|
||||
### Export-only (no publish)
|
||||
|
||||
If you want to see the bundle without publishing:
|
||||
|
||||
```sh
|
||||
kez sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt
|
||||
kez sigchain export --nsec nsec1FAKE... --format jsonl > my-chain.jsonl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Verifying someone else
|
||||
|
||||
You've done the publishing side. Here's the receiving side — how to
|
||||
verify someone *else's* identity:
|
||||
|
||||
```sh
|
||||
# Start from any identifier they've published a proof for.
|
||||
kez verify id github:linus
|
||||
|
||||
# Or walk their chain from any known endpoint:
|
||||
kez sigchain show --primary nostr:npub1abc...
|
||||
```
|
||||
|
||||
If you have the chain bundle on disk:
|
||||
|
||||
```sh
|
||||
kez verify file ./their-chain.jsonl
|
||||
```
|
||||
|
||||
`verify id` is the friendly day-to-day verb. `sigchain show
|
||||
--primary <id>` is what you'd reach for to see the whole graph at once.
|
||||
|
||||
---
|
||||
|
||||
## 8. Quick reference card
|
||||
|
||||
```sh
|
||||
# Generate a fresh primary
|
||||
kez identity new
|
||||
kez identity new --key-type ed25519
|
||||
|
||||
# Sign a claim
|
||||
kez claim create <subject> --nsec <nsec> # nostr key
|
||||
kez claim create <subject> --ed25519-seed <hex-seed> # ed25519 key
|
||||
kez claim create <subject> --nsec <nsec> --format markdown --out file.md
|
||||
kez claim create <subject> --nsec <nsec> --format compact # one-liner
|
||||
kez claim dns <domain> --nsec <nsec> # zone-file output
|
||||
|
||||
# Verify
|
||||
kez verify id <subject> # live channel fetch
|
||||
kez verify file <path> # local file
|
||||
|
||||
# Sigchain
|
||||
kez sigchain add <subject> --nsec <nsec> [--proof-url <url>]
|
||||
kez sigchain revoke <subject> --nsec <nsec>
|
||||
kez sigchain show --nsec <nsec> # your own
|
||||
kez sigchain show --primary <id> # someone else's
|
||||
kez sigchain export --nsec <nsec> --format jsonl|compact [--out file]
|
||||
kez sigchain publish --nsec <nsec> \
|
||||
[--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Common confusions
|
||||
|
||||
**"Do I need a sigchain to use KEZ?"** No. A single signed claim,
|
||||
published, works on its own. The sigchain is for when you have several
|
||||
claims and want them discoverable together (and revocable).
|
||||
|
||||
**"Why two key types — nostr and ed25519?"** Different ecosystems use
|
||||
different curves. Nostr is secp256k1/Schnorr; the rest of the world
|
||||
mostly likes Ed25519. KEZ supports both natively so you can use the
|
||||
key you already have rather than spinning up a new one for KEZ
|
||||
specifically.
|
||||
|
||||
**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it
|
||||
locally to sign things. Only the *signed envelope* (public key + claim
|
||||
+ signature) ever leaves your machine.
|
||||
|
||||
**"What if I publish a proof and then someone else copies it and
|
||||
publishes it as theirs?"** They can copy the bytes, but the signature
|
||||
inside is over *your* primary. Their primary won't match, so any
|
||||
verifier sees through it immediately.
|
||||
|
||||
**"What if my key is compromised?"** Append a `sigchain revoke
|
||||
<subject>` for the affected subjects, and ideally rotate to a new
|
||||
primary by signing a final "this primary is succeeded by <new>" event
|
||||
(planned for the spec; not yet enforced by the CLI in v0.1).
|
||||
|
||||
---
|
||||
|
||||
## 10. Where to go next
|
||||
|
||||
- The web client at <https://kez.lat> — same protocol, no CLI.
|
||||
Useful for showing non-technical friends.
|
||||
- [`../SPEC.md`](../SPEC.md) — the formal protocol, if you want to know
|
||||
exactly what every byte means.
|
||||
- [`../rust-sig-server/`](../rust-sig-server/) — run your own
|
||||
sig-server, federate with others.
|
||||
- The channel plugin trait in
|
||||
[`crates/kez-channels/src/lib.rs`](crates/kez-channels/src/lib.rs) —
|
||||
~40 lines, add a new channel in an afternoon.
|
||||
|
||||
That's the whole tutorial. Welcome to KEZ.
|
||||
Loading…
x
Reference in New Issue
Block a user