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,
|
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,
|
||||||
|
|||||||
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>
|
</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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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
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