Compare commits

...

4 Commits

Author SHA1 Message Date
Jason Tudisco
d10dfb93f2 docs(rust): add TUTORIAL.md — friendly step-by-step for first-time users
The existing README is a solid reference but assumes you already know
what KEZ is and what each subcommand does. Add a parallel TUTORIAL.md
that takes a complete newcomer from "I have a nostr nsec" to "I have
a published, verified sigchain" in ~15 minutes.

Sections (~500 lines):
  0. Install (incl. cargo-run alternative + GITHUB_TOKEN tip)
  1. Pick your primary key — use your existing nsec (recommended) OR
     generate a fresh ed25519. Concrete warnings about nsec handling.
  2. Sign your first claim — full markdown/compact/json walkthrough
     with a real github:tudisco example.
  3. Publish the proof — separate concrete how-tos per channel:
     github (gist + profile README), DNS (zone-file output), nostr
     (3 places it can live), bluesky, ActivityPub, your own website.
  4. Verify it — `kez verify id` + a full "if verification fails"
     troubleshooting block (not_found, subject_mismatch, bad sig,
     github rate limit).
  5. Sigchain basics — when you actually need one, add/show/revoke,
     where chain files live on disk.
  6. Publish your sigchain — server, web (.well-known), DNS,
     nostr (kind-30078), and how to combine destinations.
  7. Verify someone else — the reverse direction (verify id, walk
     a chain by --primary, verify a chain bundle from disk).
  8. Quick-reference command card.
  9. Common confusions FAQ — sigchain optional? two key types?
     nsec leakage? proof copying? key rotation?
  10. Where to go next — kez.lat, SPEC.md, sig-server, channel plugin
      trait.

All commands cross-checked against crates/kez-cli/src/main.rs (every
flag and output format quoted in the tutorial actually exists in the
binary).

README now points to TUTORIAL.md as the on-ramp; the existing reference
content stays put.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:54:10 -06:00
Jason Tudisco
7bbf8baf86 feat(kez-chat/web): show verified badge in chat; require 2+ proofs
The verified checkmark only appeared on the profile, never in chat, even
for clearly-verified peers. Three gaps fixed:

- Chat verified only the open conversation, so list items never showed a
  badge. Verify all conversations on load (24h per-peer cache).
- A peer's proofs were only published to the server on a manual "reverify
  all", so verified users were invisible to peers. Auto-publish verified
  subjects when the Identity page loads.
- Unify the threshold: a badge now requires >=2 independently-verified
  proofs, in both chat (VERIFY_MIN_PROOFS) and the profile (isVerified),
  so "verified" means the same thing everywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:37:13 -06:00
Jason Tudisco
41f9442650 feat(kez-chat/web): Nostr-relay chat transport (behind VITE_TRANSPORT)
Swap the chat transport from the kez-chat server inbox to Nostr relays
without touching the identity model or the E2E crypto. The existing
SealedEnvelope (ed25519/x25519 + AES-GCM, our own key) is unchanged and
becomes the content of a Nostr event — Nostr only moves the bytes.

- nostr-id.ts: derive a secp256k1 signing key from the ed25519 seed
  (HKDF, domain-separated — internal transport credential, never the
  user's real Nostr account); route by a hash of the recipient's public
  ed25519 primary since the curves can't be cross-derived.
- nostr-transport.ts: send/poll/stream mirroring messages.ts, via
  SimplePool; per-handle time cursor + seen-id dedupe in localStorage.
- transport.ts: facade selecting server vs nostr via VITE_TRANSPORT
  (code default stays "server"; this branch's .env flips it to nostr).
- inbox-service + Messages import from the facade.

Directory lookup (handle->primary) still runs on the kez-chat server;
identity stays internal. Metadata privacy is at parity with the server
transport (relay sees the from/to graph, body stays confidential).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:30:20 -06:00
Jason Tudisco
e1f2514fae feat(kez-chat/web): route new accounts to /welcome onboarding checklist
New-account success screen sent users straight to /chats; first-run users
never saw the Getting Started checklist. Route to /welcome instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:30:05 -06:00
10 changed files with 956 additions and 29 deletions

9
kez-chat/web/.env Normal file
View 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

View File

@ -27,7 +27,7 @@ import {
streamInbox, streamInbox,
type InboxMessage, type InboxMessage,
type StreamHandle, type StreamHandle,
} from "./messages.js"; } from "./transport.js";
import { lookupByPrimary } from "./api.js"; import { lookupByPrimary } from "./api.js";
import { import {
appendInbound, appendInbound,

View 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 (10009999 → 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));
}

View 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";

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

View File

@ -265,9 +265,9 @@
</p> </p>
<button <button
class="mt-4 px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim" 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> </button>
</div> </div>
{/if} {/if}

View File

@ -21,12 +21,25 @@
const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail")); const failed = $derived(claims.filter((c) => c.last_verify?.status === "fail"));
const pending = $derived(claims.filter((c) => !c.last_verify)); 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 () => { onMount(async () => {
if (!session.unlocked) { if (!session.unlocked) {
push("/unlock"); push("/unlock");
return; return;
} }
claims = await listClaims(); 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 { try {
registryRecord = await lookup(session.unlocked.handle); registryRecord = await lookup(session.unlocked.handle);
} catch (e) { } catch (e) {
@ -109,7 +122,7 @@
<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 inline-flex items-center gap-1"> <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> <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> </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"

View File

@ -2,7 +2,7 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
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/transport.js";
import { lookup, lookupByPrimary, 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 { verifySubject } from "../lib/verify.js";
@ -140,6 +140,10 @@
return; return;
} }
await refresh(); 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 // Subscribe to the always-on inbox service — re-render whenever a
// new message lands. The service is already running (it started on // new message lands. The service is already running (it started on
// session unlock in store.svelte.ts) regardless of which route the // session unlock in store.svelte.ts) regardless of which route the
@ -157,41 +161,47 @@
conversations = await listConversations(); conversations = await listConversations();
} }
// Verify the peer whenever a conversation is opened. The 24h cache in // Verify the active peer whenever a conversation is opened (covers
// verifyPeer makes this a no-op on repeat opens (and after setVerified // conversations started/arrived this session). The 24h per-peer cache
// refreshes the list, so no loop). // makes this a no-op on repeat opens, so the post-verify refresh can't loop.
$effect(() => { $effect(() => {
const conv = activeConv; const conv = activeConv;
if (conv) void verifyPeer(conv); if (conv) void verifyPeers([conv]);
}); });
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000; 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 * Verify a batch of peers' published proofs (24h cache per peer). For
* subjects from the server, runs the real channel verifiers against * each peer we fetch their claimed subjects from the server, run the
* each, and caches whether ≥1 passed → drives the verified badge. * real channel verifiers, count how many pass, and set the verified
* Runs in the background on conversation open; never blocks the UI. * 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) { async function verifyPeers(convs: Conversation[]) {
const fresh = let changed = false;
conv.verified_checked_at && for (const conv of convs) {
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS; const fresh =
if (fresh) return; conv.verified_checked_at &&
try { Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
const record = await lookupByPrimary(conv.peer_primary); if (fresh) continue;
let ok = false; try {
for (const subject of record.proofs ?? []) { const record = await lookupByPrimary(conv.peer_primary);
if (await verifySubject(subject, conv.peer_primary)) { let verifiedCount = 0;
ok = true; for (const subject of record.proofs ?? []) {
break; // one verified proof is enough for the badge 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. */ /** "Start chat with handle" — resolve, ensure conversation, open it. */

View File

@ -41,6 +41,12 @@ Three crates, ~2,500 lines of Rust, **99 tests**.
## Quick start ## 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 ```sh
# Build, test, and install the `kez` binary to ~/.cargo/bin (one time) # Build, test, and install the `kez` binary to ~/.cargo/bin (one time)
cargo build cargo build

557
rust/TUTORIAL.md Normal file
View 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:** 1015 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.