Compare commits
No commits in common. "d10dfb93f25468ef972bad5430fd5175d369b110" and "dac98486c5ed4550ca68b276fb68e90f777edbbf" have entirely different histories.
d10dfb93f2
...
dac98486c5
@ -1,9 +0,0 @@
|
|||||||
# 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 "./transport.js";
|
} from "./messages.js";
|
||||||
import { lookupByPrimary } from "./api.js";
|
import { lookupByPrimary } from "./api.js";
|
||||||
import {
|
import {
|
||||||
appendInbound,
|
appendInbound,
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
// 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));
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
// 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("/welcome")}
|
onclick={() => push("/chats")}
|
||||||
>
|
>
|
||||||
Get started →
|
Go to dashboard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -21,25 +21,12 @@
|
|||||||
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) {
|
||||||
@ -122,7 +109,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 isVerified}<VerifiedBadge size={16} />{/if}
|
{#if verified.length > 0}<VerifiedBadge size={16} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
|
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"
|
||||||
|
|||||||
@ -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/transport.js";
|
import { sendMessage } from "../lib/messages.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,10 +140,6 @@
|
|||||||
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
|
||||||
@ -161,47 +157,41 @@
|
|||||||
conversations = await listConversations();
|
conversations = await listConversations();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the active peer whenever a conversation is opened (covers
|
// Verify the peer whenever a conversation is opened. The 24h cache in
|
||||||
// conversations started/arrived this session). The 24h per-peer cache
|
// verifyPeer makes this a no-op on repeat opens (and after setVerified
|
||||||
// makes this a no-op on repeat opens, so the post-verify refresh can't loop.
|
// refreshes the list, so no loop).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const conv = activeConv;
|
const conv = activeConv;
|
||||||
if (conv) void verifyPeers([conv]);
|
if (conv) void verifyPeer(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 batch of peers' published proofs (24h cache per peer). For
|
* Verify a peer's published proofs (24h cache). Fetches their claimed
|
||||||
* each peer we fetch their claimed subjects from the server, run the
|
* subjects from the server, runs the real channel verifiers against
|
||||||
* real channel verifiers, count how many pass, and set the verified
|
* each, and caches whether ≥1 passed → drives the verified badge.
|
||||||
* badge when ≥ VERIFY_MIN_PROOFS. Refreshes the list once at the end if
|
* Runs in the background on conversation open; never blocks the UI.
|
||||||
* anything changed. Runs in the background; never blocks the UI.
|
|
||||||
*/
|
*/
|
||||||
async function verifyPeers(convs: Conversation[]) {
|
async function verifyPeer(conv: Conversation) {
|
||||||
let changed = false;
|
const fresh =
|
||||||
for (const conv of convs) {
|
conv.verified_checked_at &&
|
||||||
const fresh =
|
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
|
||||||
conv.verified_checked_at &&
|
if (fresh) return;
|
||||||
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
|
try {
|
||||||
if (fresh) continue;
|
const record = await lookupByPrimary(conv.peer_primary);
|
||||||
try {
|
let ok = false;
|
||||||
const record = await lookupByPrimary(conv.peer_primary);
|
for (const subject of record.proofs ?? []) {
|
||||||
let verifiedCount = 0;
|
if (await verifySubject(subject, conv.peer_primary)) {
|
||||||
for (const subject of record.proofs ?? []) {
|
ok = true;
|
||||||
if (await verifySubject(subject, conv.peer_primary)) verifiedCount++;
|
break; // one verified proof is enough for the badge
|
||||||
}
|
}
|
||||||
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,12 +41,6 @@ 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
557
rust/TUTORIAL.md
@ -1,557 +0,0 @@
|
|||||||
# 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