From b237ac2d9ed9863cf9ebf888de048a46354b1f39 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Sun, 27 Sep 2026 10:00:51 -0600 Subject: [PATCH] docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC.md → v0.4 - §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1", 24-word = seed), parsing rules, test vectors. - §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check kind-1 notes and the kind-0 `about`. - §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and kez-chat; §16 applications (kez-chat tags); changelog. nodejs kez-channels - NostrChannel now requests kinds 0, 1, 30078 and extracts the proof body per kind (kind-0 `about` JSON-decoded), mirroring Rust's extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07 from kez-chat) previously failed to verify in Node. - Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody. Docs - kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q` routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats, v1–v4 envelopes, receipts, relay ordering, honest metadata model. - Stale `#h` comments in nostr-id.ts / nostr-transport.ts. - rust/README.md nostr channel rows list all three kinds. Co-Authored-By: Claude Opus 5.5 --- SPEC.md | 113 ++++- kez-chat/web/NOSTR-CHAT.md | 387 +++++++++--------- kez-chat/web/src/lib/nostr-id.ts | 3 +- kez-chat/web/src/lib/nostr-transport.ts | 9 +- nodejs/packages/kez-channels/src/nostr.ts | 47 ++- .../packages/kez-channels/test/nostr.test.ts | 66 ++- rust/README.md | 4 +- 7 files changed, 399 insertions(+), 230 deletions(-) diff --git a/SPEC.md b/SPEC.md index d006815..af0c427 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,7 +1,7 @@ # KEZ Specification -**Status:** Draft v0.3 -**Date:** 2026-05-25 +**Status:** Draft v0.4 +**Date:** 2026-09-27 **Conformance:** Implementations MUST conform to the formats and behaviors defined here. Two implementations conform iff a claim signed by one verifies in the other and vice versa (see §15). @@ -37,6 +37,7 @@ cryptographic signatures against keys the user already controls. | **Sigchain event** | An entry in a user's sigchain — `add`, `revoke`, `rotate`, etc. §5.2. | | **Sigchain** | The append-only signed log of sigchain events for one primary key. §8. | | **Identity graph** | The set of currently-active subjects reachable from a primary key, walked through its sigchain. | +| **Recovery phrase** | A BIP-39 English word list (12 or 24 words) that deterministically yields an Ed25519 primary key's 32-byte seed. §3.1. | --- @@ -72,6 +73,44 @@ system normalizes case (e.g. DNS — lowercased before comparison). KEZ is **not a phone book.** It cannot resolve "Jason" from a human name. A verifier always needs a concrete starting identifier (see §10). +### 3.1 Recovery phrases (Ed25519 seeds) + +An Ed25519 primary key is a 32-byte seed (RFC 8032). Implementations +that offer a human-writable backup MUST encode it as a BIP-39 phrase +over the **BIP-39 English wordlist**, with these semantics: + +| Words | Entropy | Seed | +|---|---|---| +| **24** | 32 bytes | `seed = entropy` — a bijection; the phrase can be recovered from any seed | +| **12** | 16 bytes | `seed = SHA-256("kez-bip39-12-v1" ‖ entropy)` — one-way; a seed has no 12-word form | + +- The domain tag is the 15 ASCII bytes `kez-bip39-12-v1`. Changing it + would break every existing 12-word identity; it MUST NOT change. +- Entropy is the raw BIP-39 entropy (checksum-validated). BIP-39's + PBKDF2 `to_seed(passphrase)` and BIP-32 derivation are **not** used: + KEZ has one identity per phrase and no derivation tree. KEZ phrases + are therefore not interchangeable with wallet derivations of the + same words. +- Parsers MUST trim surrounding whitespace and collapse internal runs + of whitespace to single spaces before decoding. Any other word count + is an error. +- New identities SHOULD be generated as 12-word phrases. Restore flows + MUST accept both lengths. To display a backup for a seed that has no + known 12-word phrase, use its 24-word encoding. + +Test vectors (all implementations MUST match byte-for-byte; more in +[`python/MNEMONIC-TEST-VECTORS.md`](python/MNEMONIC-TEST-VECTORS.md)): + +``` +24 words: abandon ×23, art + seed 0000000000000000000000000000000000000000000000000000000000000000 + primary ed25519:3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29 + +12 words: abandon ×11, about + seed 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da + primary ed25519:9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70 +``` + --- ## 4. The Signature Envelope @@ -122,7 +161,7 @@ algorithms) or universally-verifiable signatures (for randomized ones). ## 5. Payload Shapes -Two payload shapes are defined in v0.3. +Two payload shapes are defined in this version. ### 5.1 Claim payload @@ -287,7 +326,7 @@ so a verifier can fetch it. | `github` | Public gist (filename should include `kez`), or `README` on the user's `/` profile repo | Markdown (§6.3) | | `dns` | TXT record at `_kez.` | Compact (§6.2) | | `web` | `https:///.well-known/kez.json` | JSON (§6.1) | -| `nostr` | Nostr event of kind `30078` (parameterized replaceable), `d` tag `kez` | JSON in event content | +| `nostr` | Canonical: event of kind `30078` (NIP-78 app data), `d` tag `kez`. Verifiers MUST check kind 30078 and SHOULD also check kind `1` text notes and the kind `0` profile `about` field, where clients like NIP-07 extensions commonly publish | JSON in kind-30078 content; Markdown fence (§6.3) or compact (§6.2) in kinds 1 and 0 | | `bluesky` | Public post on the user's PDS | JSON or Markdown | | `ap` (alias `mastodon`) | Profile field (preferred) or bio of the user's actor object; resolved via WebFinger → actor JSON | Compact (§6.2) inline, or Markdown | @@ -397,6 +436,21 @@ not trust a server's claim about validity. Implementations SHOULD support multiple stores in parallel and treat them as mirrors. +### 9.1 Sigchain HTTP store (informative) + +The reference store ([`rust-sig-server/`](rust-sig-server/)) exposes: + +| Method | Path | | +|---|---|---| +| GET | `/v1/sigchains//` | Whole chain as JSONL (one signed envelope per line); 404 if empty | +| GET | `/v1/sigchains///head` | Latest `{seq, hash}` | +| POST | `/v1/sigchains///events` | Append one signed sigchain event; the store re-checks tag, primary, `seq`, `prev`, and signature before accepting | + +`/` is the primary key split at the colon (e.g. +`ed25519/2152f8…`). A store MAY be fronted by another origin (kez-chat +proxies it at `https:///v1/sigchains/…`); clients should use +whatever `sigchain_url` they are given rather than constructing one. + --- ## 10. Verification @@ -487,7 +541,8 @@ local-file mode for testing; it MUST be clearly labeled as such. |---|---| | **BIP-340 Schnorr** (over secp256k1) | nostr-key signatures. Suite: `nostr-secp256k1-schnorr-sha256-jcs`. Signatures are deterministic (zero aux randomness). | | **Ed25519** (RFC 8032) | ed25519-key signatures. Suite: `ed25519-sha512-jcs`. PureEdDSA — the signature is over the raw JCS bytes; SHA-512 is performed internally. | -| **SHA-256** | Sigchain `prev` chaining; pre-hash for Schnorr signing (`sig = schnorr(SHA-256(JCS(payload)), key)`). | +| **BIP-39** (English wordlist) | Recovery phrases for Ed25519 seeds (§3.1). Raw entropy only — no PBKDF2 seed, no BIP-32. | +| **SHA-256** | Sigchain `prev` chaining; pre-hash for Schnorr signing (`sig = schnorr(SHA-256(JCS(payload)), key)`); 12-word phrase → seed (§3.1). | | **SHA-512** | Internal to Ed25519. | | **JCS** (RFC 8785) | Canonicalize the bytes to be signed. | | **zstd** (default level 3) | Compress the JSON envelope for the compact wire form. | @@ -601,7 +656,7 @@ signature. ## 13. Versioning & Wire Compatibility -Spec versions are `major.minor`. The current spec is **v0.3**. +Spec versions are `major.minor`. The current spec is **v0.4**. ### What's non-breaking (minor bump) @@ -623,7 +678,7 @@ Spec versions are `major.minor`. The current spec is **v0.3**. ### Implementation policy -- An implementation declaring `v0.3` MUST accept all v0.3 features. +- An implementation declaring `v0.4` MUST accept all v0.4 features. - Implementations MAY accept newer minor versions if they ignore unknown additive content safely. - Verifiers that encounter an event with an unknown `op` MUST chain @@ -636,7 +691,8 @@ Spec versions are `major.minor`. The current spec is **v0.3**. | Version | Notable | |---|---| -| **v0.3** (current) | Restructured around the signature envelope; added glossary, cryptographic primitives table, reproducible worked example, versioning policy. Added `kez:zc1:` sigchain bundle encoding. ActivityPub channel formalized; `mastodon:` is an alias. | +| **v0.4** (current) | BIP-39 recovery phrases for Ed25519 seeds (§3.1), with test vectors. Nostr channel: verifiers SHOULD also accept kinds 1 and 0. Sigchain HTTP store API documented (§9.1). Python implementation added to the conformance set. kez-chat listed as an application (§16). | +| v0.3 | Restructured around the signature envelope; added glossary, cryptographic primitives table, reproducible worked example, versioning policy. Added `kez:zc1:` sigchain bundle encoding. ActivityPub channel formalized; `mastodon:` is an alias. | | v0.2 | Spec-aligned types across implementations. Added Ed25519 (`ed25519-sha512-jcs`). JCS canonicalization required. Five channels formalized (dns, github, nostr, bluesky, ap). | | v0.1 | Initial draft. Single signature suite (Schnorr/nostr). | @@ -651,14 +707,41 @@ Current implementations: |---|---|---| | `rust/` | Rust | `kez-core` (lib), `kez-channels` (lib), `kez-cli` (binary `kez`) | | `nodejs/` | TypeScript | `@kez/core`, `@kez/channels`, `@kez/cli` — npm workspaces | -| `rust-sig-server/` | Rust | Optional HTTP server for sigchain storage | +| `python/` | Python | `kez` package + CLI (`python -m kez`) | +| `rust-sig-server/` | Rust | Optional HTTP server for sigchain storage (§9.1) | +| `kez-chat/` | Rust + TypeScript | Application built on KEZ (§16) — not part of the conformance set | Conformance between implementations is verified by `crosstest.sh` at the repo root — a harness that generates artifacts with one -implementation and verifies them with the other, in every direction -and every wire encoding. A passing cross-test is the operational -definition of "two implementations interoperate." +implementation and verifies them with another, in every direction and +every wire encoding, including recovery phrases (§3.1). A passing +cross-test is the operational definition of "implementations +interoperate." -A future `vectors/` directory may hold canonical test fixtures for -third-party implementations to validate against. Not required while -two interop-tested implementations exist. +Canonical fixtures live next to the implementation that owns them +(currently [`python/MNEMONIC-TEST-VECTORS.md`](python/MNEMONIC-TEST-VECTORS.md)). +A shared `vectors/` directory may collect them for third-party +implementations. + +--- + +## 16. Applications (informative) + +Applications MAY define their own envelope tags and payload types on +top of §4; these are outside KEZ conformance but reuse its envelope, +canonicalization, and signature suites. + +**[kez-chat](kez-chat/README.md)** — end-to-end encrypted chat over +nostr, live at `kez.lat`. A chat account *is* an Ed25519 KEZ primary +key (recovered from a §3.1 phrase), and profile proofs are ordinary +KEZ claims mirrored to the user's sigchain. It adds: + +| Tag / type | Purpose | +|---|---| +| `handle_registration` / `kez.chat.handle_registration` | Signed claim of a `handle@server` name on a chat server | +| `kez-heartbeat-v1` (`t` field of a JCS payload signed with the primary key, `ed25519-sha512-jcs`) | Binds a published message-encryption key (X25519 + ML-KEM-768) to the primary key | + +Its messaging protocol (envelope versions, heartbeat ratchet, +post-quantum hybrid, nostr event kinds) is specified in +[`kez-chat/README.md`](kez-chat/README.md) and +[`kez-chat/SECURITY.md`](kez-chat/SECURITY.md). diff --git a/kez-chat/web/NOSTR-CHAT.md b/kez-chat/web/NOSTR-CHAT.md index 6c5e7d4..2b0dc60 100644 --- a/kez-chat/web/NOSTR-CHAT.md +++ b/kez-chat/web/NOSTR-CHAT.md @@ -1,267 +1,260 @@ # Nostr Chat -How the `nostr` branch carries kez-chat messages over Nostr relays instead -of the kez-chat server inbox — without changing the identity model or the -end-to-end encryption. +How kez-chat carries messages over public Nostr relays: addressing, event +formats, encryption, delivery, and what relays can and can't see. -> **One-line summary:** the chat transport is swapped from an HTTP/SSE server -> inbox to Nostr relays. Everything else — your ed25519 identity, the -> `SealedEnvelope` encryption, the UI — is untouched. Nostr only moves bytes. +> **One-line summary:** messages are sealed on the device into a +> KEZ-signed, end-to-end encrypted envelope and published as Nostr events +> routed by a tag derived from the recipient's public key. Relays are a +> dumb pipe; the kez-chat server is only a directory and never carries +> messages. + +Production builds use this transport (`VITE_TRANSPORT=nostr` in `.env`). +The older HTTP mailbox on the chat-server (`messages.ts`) still exists +behind the same interface; see §8. --- ## 1. The core idea -kez-chat already had its own end-to-end encryption. A message is sealed by -`crypto.ts` into a **`SealedEnvelope`** (AES-256-GCM body + an ed25519 sender -signature, keyed off the user's ed25519 identity). The original transport -(`messages.ts`) just `POST`s that opaque envelope to the server's -`/v1/messages` endpoint and reads it back from `/v1/inbox`. - -Because the envelope is already encrypted and self-authenticating, the -transport is interchangeable. The Nostr build keeps the exact same envelope -and changes only **how it travels**: - ``` - ┌─────────────────────── unchanged ───────────────────────┐ - plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer - │ (ed25519/x25519 + AES-GCM, crypto.ts) │ - └──────────────────────────────────────────────────────────┘ - - server transport: POST /v1/messages ┐ - GET /v1/inbox (poll+SSE) ┘ ← kez-chat server + SQLite - - nostr transport: publish event to relays ┐ - subscribe by #h tag ┘ ← public Nostr relays + plaintext ─► sealForRecipient() ─► envelope (v4) ─► kind-4242 event ─► relays + (crypto.ts: X25519 + ML-KEM-768, tag ["q", addr] + AES-256-GCM, Ed25519 signature) + │ + recipient ◄─ openMessage() ◄─ envelope ◄─ subscription {"#q":[myAddr]}┘ ``` -The `SealedEnvelope` is the **"extra layer of encryption using our own key"** — -it exists independently of Nostr and is what actually protects the message -body. Nostr is a dumb pipe underneath it. +The envelope does all the protecting — confidentiality, sender +authentication, integrity. Nostr only moves bytes. --- -## 2. Identity: bridging ed25519 onto Nostr +## 2. Identity: bridging Ed25519 onto Nostr -KEZ identities are **ed25519**. Nostr signs events with **secp256k1** -(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's -ed25519 public key into "their" Nostr public key. The bridge -(`nostr-id.ts`) solves this in two halves: +KEZ identities are **Ed25519**. Nostr signs events with **secp256k1** +(Schnorr). The curves can't be cross-derived, so `nostr-id.ts` bridges +them in two halves. ### 2a. Signing key (derived from your own seed) -Every account needs a secp256k1 key to sign Nostr events (relays reject -unsigned events). We derive it deterministically from the user's 32-byte -ed25519 seed: +Relays only accept signed events, so every account derives a secp256k1 +key from its 32-byte Ed25519 seed: ``` -nostrSecret = HKDF-SHA256( - ikm = ed25519_seed, - salt = "kez-chat:nostr-signkey", - info = "v1", - len = 32, -) +nostrSecret = HKDF-SHA256(ikm = ed25519_seed, + salt = "kez-chat:nostr-signkey", info = "v1", len = 32) ``` -Properties: - -- **Deterministic** — the same account always produces the same Nostr signer, - on any device, with no extra storage. -- **Internal** — it is a pure transport credential. It is *not* the user's - real Nostr account, it is never surfaced in the UI, and its public key is - never advertised or used for addressing. -- **One-way** — HKDF means the Nostr key reveals nothing about the ed25519 - seed (the actual secret). +- **Deterministic** — the same account gets the same Nostr key on every + device, with nothing extra to store. +- **App-specific** — it's not the user's personal Nostr account and is + never shown in the UI. It *is* public, though: it signs every message, + heartbeat, receipt, and the account's profile / relay-list events, so + it's a stable pseudonym relays can observe (see §7). +- **One-way** — it reveals nothing about the Ed25519 seed. ### 2b. Addressing (derived from the recipient's *public* primary) -Since we can't compute a recipient's Nostr pubkey, we don't address events to -a pubkey at all. Instead each event carries a routing label derived from the -recipient's **public** ed25519 primary (which any sender can look up in the -directory): +Events aren't addressed to a Nostr pubkey. Each one carries a routing +label derived from the recipient's public Ed25519 primary, which any +sender can get from the directory: ``` -addr = HKDF-SHA256( - ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…" - salt = "kez-chat:nostr-addr", - info = "v1", - len = 32, -) // → 32-byte hex +addr = hex(HKDF-SHA256(ikm = utf8(recipient_primary), // "ed25519:abc…" + salt = "kez-chat:nostr-addr", info = "v1", len = 32)) ``` -The sender stamps this on the event as a tag; the recipient subscribes for -events carrying their own `addr`. Both sides compute the same value from the -same public primary — the sender from a directory lookup, the recipient from -their own identity. Using a hash (rather than the raw primary) keeps the -plaintext primary out of a relay-queryable tag. +It goes in a `q` tag, and the recipient subscribes with `{"#q": [myAddr]}`. +(`q`, not `h`: NIP-29 relays treat `h` as a group id and may drop the +event.) Hashing keeps the raw primary out of a relay-queryable tag — but +anyone who knows your primary can compute your `addr`, so it hides +nothing from someone who already knows who you are. --- -## 3. The event format +## 3. Event kinds -| Field | Value | -|--------------|--------------------------------------------------------------------| -| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (1000–9999), so relays persist it | -| `tags` | `[["h", ]]` — `h` = `ADDR_TAG`, the routing label | -| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope | -| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) | -| `sig` | Schnorr signature over the event (so relays accept it) | -| `created_at` | unix seconds | +| Kind | What | Tags | Content | +|---|---|---|---| +| `4242` | Message (`KEZ_DM_KIND`) — regular kind, so relays store it | `["q", addr]` | `JSON.stringify(envelope)` | +| `4244` | Delivery receipt (`KEZ_ACK_KIND`) | `["q", addr of original sender]`, `["e", acked event id]`, `["kez-sig", …]`, optional `["p", sender's nostr pubkey]` | empty | +| `10142` | Heartbeat — replaceable, one per author | `["d","heartbeat-v1"]` (informational), `["x25519", pub]` | `SignedHeartbeat` JSON (§4) | +| `0` | Profile | — | name, about, visually-encrypted picture + per-contact key wraps | +| `10002` | NIP-65 relay list | `["r", url]` | empty | -A subscriber filters with: +The subscription covers both conversation kinds: ```json -{ "kinds": [4242], "#h": [""], "since": } +{ "kinds": [4242, 4244], "#q": [""], "since": } ``` --- -## 4. Message lifecycle +## 4. Encryption: envelopes and heartbeats + +### Heartbeats + +Once a day while you're active, the app generates a fresh random X25519 +keypair **and** an ML-KEM-768 keypair (`heartbeat-store.ts`) and +publishes the public halves as a kind-10142 event. The content is +signed with your **KEZ Ed25519 key**: + +```json +{ "pub": "", "mlkem_pub": "", + "created_at": "", "sig": "" } +``` + +Senders verify that signature against the recipient's primary before +using the keys. The Nostr event signature alone isn't enough: a +recipient's Nostr pubkey is learned from the outer event of an inbound +message, which anyone can re-wrap. Heartbeats older than 7 days are +rejected, and senders cache the last verified heartbeat per contact +(5-minute refresh) so a relay withholding it can't force a downgrade. + +Private halves stay on the device and are deleted a configurable number +of days (3–60, default 16) after the heartbeat is *replaced* — that's +the forward-secrecy window. The newest heartbeat is never deleted. + +### Envelope versions + +| v | Key agreement | Sent when | +|---|---|---| +| **4** | X25519 (sender ephemeral × recipient heartbeat) **+** ML-KEM-768 encapsulation to the heartbeat; `AES key = HKDF(x25519_ss ‖ mlkem_ss)` | recipient's heartbeat has an ML-KEM key (normal case) | +| 3 | X25519 to the heartbeat | heartbeat without ML-KEM (older client) | +| 3 `fallback` | X25519 to the recipient's identity key | no heartbeat found — first message to someone who's never opened the app | +| 1, 2 | legacy | receive-only | + +Every version: AES-256-GCM body, routing fields bound as AAD, whole +envelope signed with the sender's Ed25519 key. The sender's identity +(`from`) is **inside** the ciphertext — relays never see it. + +### Replay and freshness + +- Events with `created_at` more than 7 days old or 5 minutes in the + future are ignored. +- Decrypted messages whose `sent_at` is older than 7 days are rejected. +- Envelopes are deduplicated by their Ed25519 signature (last 5,000), so + a replayed envelope re-wrapped in a new event is ignored. + +--- + +## 5. Message lifecycle ### Sending (`nostr-transport.ts` → `sendMessage`) -1. Resolve the recipient handle → ed25519 primary via the directory - (`/v1/u/` — still served by the kez-chat server). -2. `sealMessage(...)` → `SealedEnvelope` (identical to the server transport). -3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`, - `content = JSON.stringify(envelope)`. -4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`). -5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts. - If every relay rejects, `sendMessage` throws `"no relay accepted the message"`. +1. Use the recipient's primary from the conversation row (or look up + `/v1/u/` for a new contact). +2. Resolve their heartbeat (cache → relays), then seal a v4 (or v3) + envelope. +3. Build a kind-4242 event with `["q", addrFromPrimary(recipient)]`, sign + it with the derived Nostr key. +4. Publish to an ordered, de-duplicated relay set (max 10): the relay + their last message arrived on, then their NIP-65 read relays, then + your configured relays. Succeeds when **one** relay accepts; the UI + shows "sent" and records which relay took it. + +Files use the same path: up to 80 KB inline in one envelope, larger +(up to 10 MB) as encrypted 80 KB chunks plus a pointer message. ### Receiving (`streamInbox` + `pollInbox`) -The global `inbox-service` runs both, exactly as it did for the server -transport: +`inbox-service` runs a live subscription plus a catch-up query every 30 +seconds (and on startup). Each event is: -- **`streamInbox`** opens a live subscription - (`pool.subscribeMany`) filtered on the user's own `addr`. Each event fires - `onevent`; after the relays finish replaying stored events, `oneose` flips - the UI status to **live**. -- **`pollInbox`** is a one-shot `pool.querySync` used as a heartbeat catch-up - (every 30s and on startup), so nothing is missed if the subscription drops. - -Each incoming event is: - -1. De-duplicated by event id (see §5). -2. Parsed: `content` → `SealedEnvelope`. -3. Decrypted by the **unchanged** `decrypt()` (`crypto.ts` → `openMessage`), - which verifies the sender's ed25519 signature and AES-GCM-decrypts the body. -4. Appended to the local conversation store and rendered. +1. Checked for a sane `created_at`, then de-duplicated by event id. +2. Parsed and decrypted (`openMessage`), which verifies the sender's + signature. For v3/v4 it looks up the matching heartbeat private key. +3. Appended to the local conversation (or routed to file reassembly). +4. Acknowledged: a kind-4244 receipt with the recipient's KEZ signature + over the event id goes back to the sender's `addr`. Failed receipts + are retried next session. --- -## 5. Cursors & de-duplication +## 6. Cursors, sequence numbers, relays -Relays can resend events, and two transports (live sub + heartbeat poll) can -deliver the same event. Both are made idempotent with per-handle -`localStorage` state: - -- **`kez-chat:nostr:since:`** — the relay `since` filter (unix - seconds). Advances as events arrive, so reloads resume instead of - re-fetching. A fresh device defaults to `now − 7 days` to catch recent - history. -- **`kez-chat:nostr:seen:`** — a capped set (last `500` ids) of - processed event ids. An id already in the set is skipped. - -### Mapping to `seq` - -The conversation store and the notification watermark were built around a -monotonic server `seq`. Nostr has no per-recipient sequence, so the transport -synthesizes one from the event's timestamp: - -``` -seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000) -``` - -This is monotonic-by-time across sessions (so the unread/notify watermark -keeps working) and spreads messages within the same one-second granularity -using the event id, avoiding collisions between distinct same-second messages. - ---- - -## 6. Configuration - -Build-time, via Vite env (`.env` / `.env.local`): - -| Variable | Default | Meaning | -|----------------------|-----------------------------------------------------------|------------------------------------------| -| `VITE_TRANSPORT` | `server` | `server` or `nostr` — which pipe to use | -| `VITE_NOSTR_RELAYS` | `wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net` | comma-separated relay list | - -> The **code default is `server`**, so `main` and other branches are -> unaffected. This branch ships a committed `.env` that sets -> `VITE_TRANSPORT=nostr`. - -### The facade (`transport.ts`) - -`inbox-service` and the `Messages` route import `sendMessage` / `pollInbox` / -`streamInbox` / `decrypt` from `transport.ts`, which re-exports either the -server (`messages.ts`) or Nostr (`nostr-transport.ts`) implementation based on -`VITE_TRANSPORT`. Neither consumer knows which pipe is active. Switching -transports is a one-line env change + rebuild. +- **`kez-chat:nostr:since:`** — the `since` filter. Advances as + events arrive; a fresh device starts 48 hours back (most public relays + don't keep more). +- **`kez-chat:nostr:seen:`** — processed event ids (last 10,000). +- **Synthetic `seq`** — the conversation store wants a monotonic number, + so `seq = created_at * 1000 + (parseInt(id.slice(0,3), 16) % 1000)`. +- **NIP-42 AUTH** — the relay pool signs auth challenges with the derived + key, so relays that gate kinds behind AUTH still deliver. +- **Relay list** — user-editable in Settings (wss:// only; ws:// allowed + for localhost), stored in `localStorage`, applied on reload. Changes + republish your NIP-65 list. --- ## 7. Privacy model -- **Confidential:** the message *body*. It is AES-256-GCM encrypted inside the - `SealedEnvelope` before it ever reaches a relay. Relays (and anyone reading - them) see only ciphertext. -- **Visible to relays:** the social-graph metadata. The envelope carries the - sender's primary (`from`) and recipient handle (`to`) in its JSON, and the - `#h` tag routes by recipient. A relay can therefore observe who is talking to - whom and when — **the same metadata the kez-chat server saw with the old - transport.** This is parity, not a regression. -- **Authenticity:** every envelope is signed by the sender's ed25519 key and - verified on decrypt, so a relay (or anyone) cannot forge or tamper with a - message without detection. +- **Confidential:** message content, attachments, and the sender's KEZ + identity — all inside the envelope. Post-quantum hybrid (v4), so + recorded traffic stays safe against a future quantum computer. +- **Forward-secret:** heartbeat keys are random and deleted after the + retention window; stealing the seed later doesn't decrypt old + messages. +- **Visible to relays:** which Nostr key published an event, the + recipient's `q` tag, and the time. The Nostr key is stable per account + and its kind-0 profile carries your handle, so a relay can map who + talks to whom and when. There's no onion routing or padding. +- **Push:** the chat-server watches relays for `q` tags of registered + handles and sends an empty Web Push; the push provider learns only + that a device was woken. -If hiding the social graph from relays becomes a requirement, the next step is -an outer NIP-44 wrap around the envelope. It was deliberately left out to keep -this a clean, minimal transport swap. +Full threat model: [`../SECURITY.md`](../SECURITY.md). --- -## 8. Known limitations +## 8. Configuration and the transport facade -- **No backfill of old server-era messages.** Messages delivered over the old - server transport were never published to relays and cannot be fetched over - Nostr. Locally cached history (IndexedDB) still renders, but it is a stale - snapshot, not relay-backed. -- **Relay acceptance varies.** Some public relays restrict event kinds or rate. - If all configured relays reject `kind 4242`, sends fail loudly. Run your own - relay or pick permissive ones via `VITE_NOSTR_RELAYS` for reliability. -- **Eventual, not guaranteed, delivery.** Delivery depends on the recipient and - sender sharing at least one reachable relay. More relays = more resilience, - more metadata exposure. -- **Same-second ordering** is approximated (see §5), not exact. +| Variable | Default | | +|---|---|---| +| `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays; `server` = HTTP mailbox | +| `VITE_NOSTR_RELAYS` | damus, nos.lol, primal, snort.social, nostr.wine | Default relay list; users can override in Settings | + +`transport.ts` re-exports `sendMessage` / `sendFile` / `sendAck` / +`pollInbox` / `streamInbox` / `decrypt` from either `nostr-transport.ts` +or `messages.ts`. The server mailbox still seals v2 envelopes and has no +heartbeats or receipts — production doesn't use it. --- -## 9. File map +## 9. Known limitations -| File | Role | -|----------------------------|-------------------------------------------------------------| -| `src/lib/crypto.ts` | **Unchanged.** The `SealedEnvelope` E2E layer (our own key).| -| `src/lib/nostr-id.ts` | Derive the secp256k1 signing key; compute recipient `addr`. | -| `src/lib/nostr-transport.ts` | Nostr `send`/`poll`/`stream`; cursor + dedupe. | -| `src/lib/transport.ts` | Facade selecting server vs nostr via `VITE_TRANSPORT`. | -| `src/lib/messages.ts` | **Unchanged.** The original server transport. | -| `src/lib/inbox-service.svelte.ts` | Imports from the facade; otherwise unchanged. | -| `src/routes/Messages.svelte` | Imports `sendMessage` from the facade. | -| `.env` | Flips this branch to `VITE_TRANSPORT=nostr`. | +- **Delivery is best-effort.** It needs one relay both sides can reach, + and public relays drop old events (hence the 48-hour lookback). +- **Relay acceptance varies.** Some relays reject unknown kinds or rate + limit; sends fail only if every relay rejects. +- **One device per account.** A second device mints its own heartbeats; + the first stops receiving messages sent to the new ones. +- **Same-second ordering** is approximate (see §6). --- -## 10. How to verify it's really on Nostr +## 10. File map -Open DevTools → **Network → WS** on the running app: +| File | Role | +|---|---| +| `src/lib/nostr-id.ts` | Derived Nostr signing key; recipient `addr`; kind and tag constants | +| `src/lib/nostr-transport.ts` | Relays, send/receive, heartbeats publish + fetch + cache, receipts, files, cursors | +| `src/lib/crypto.ts` | Envelopes v1–v4, heartbeat signing/verification | +| `src/lib/heartbeat-store.ts` | Heartbeat key rotation and retention | +| `src/lib/relay-store.ts` | User relay list | +| `src/lib/inbox-service.svelte.ts` | Receive pipeline, dedupe, ack, heartbeat lifecycle | +| `src/lib/transport.ts` | Picks nostr vs server transport | +| `src/lib/messages.ts` | Legacy HTTP mailbox transport | -- You should see live WebSocket connections to the configured relays - (`wss://relay.damus.io`, etc.). -- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives - as an incoming event frame. -- You should **not** see `POST /v1/messages` or an SSE connection to - `/v1/inbox//stream` (those were the server transport). -- The only remaining server call is the directory lookup `GET /v1/u/`. +--- + +## 11. How to see it on the wire + +DevTools → **Network → WS**: + +- WebSocket connections to your relays. +- Sending emits `["EVENT", {kind: 4242, tags: [["q", …]], content: "{\"v\":4, …"}]`. +- No `POST /v1/messages` and no `/v1/inbox//stream`. +- The only chat-server calls are the directory (`/v1/u/…`, + `/v1/by-primary/…`), push subscription, and sigchain sync. diff --git a/kez-chat/web/src/lib/nostr-id.ts b/kez-chat/web/src/lib/nostr-id.ts index 733818a..c2788bf 100644 --- a/kez-chat/web/src/lib/nostr-id.ts +++ b/kez-chat/web/src/lib/nostr-id.ts @@ -55,7 +55,8 @@ export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array { * 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. + * of the `q` tag (ADDR_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 diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts index 380db1a..b41e655 100644 --- a/kez-chat/web/src/lib/nostr-transport.ts +++ b/kez-chat/web/src/lib/nostr-transport.ts @@ -10,10 +10,11 @@ // 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. +// Addressing: events carry a `q` tag = addrFromPrimary(recipient). We +// subscribe with a `{ "#q": [myAddr] }` filter. Events are signed by a +// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) so relays +// accept them — never shown in the UI, but a stable public pseudonym. +// Protocol overview: ../../NOSTR-CHAT.md. import { ed25519 } from "@noble/curves/ed25519"; import { bytesToHex } from "@noble/hashes/utils"; diff --git a/nodejs/packages/kez-channels/src/nostr.ts b/nodejs/packages/kez-channels/src/nostr.ts index d7fe85c..164bcae 100644 --- a/nodejs/packages/kez-channels/src/nostr.ts +++ b/nodejs/packages/kez-channels/src/nostr.ts @@ -1,5 +1,12 @@ -// Nostr channel: queries relays for kind-30078 events authored by the -// requested npub, then runs each event's content through parseAndVerifyFor. +// Nostr channel: queries relays for events authored by the requested npub +// and runs each candidate through parseAndVerifyFor. A KEZ proof can live +// in three places (SPEC §7) — we check all three, like the Rust channel: +// +// • kind 0 — profile metadata; the proof is in the `about` field +// • kind 1 — a normal text note containing the proof (e.g. posted +// via a NIP-07 extension) +// • kind 30078 — NIP-78 app data; canonical, content is the envelope +// // Fetcher is abstracted so tests use canned events. import { type Identity, NostrSecret, nostrPubkeyHex } from "@kez/core"; @@ -8,6 +15,10 @@ import { bytesToHex } from "@noble/hashes/utils"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js"; export const KEZ_NOSTR_KIND = 30078; +export const NOSTR_KIND_PROFILE = 0; +export const NOSTR_KIND_NOTE = 1; +/** Kinds requested when verifying. Mirrors Rust's VERIFY_KINDS. */ +export const VERIFY_KINDS = [NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND]; const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net"]; const FETCH_TIMEOUT_MS = 8_000; @@ -119,8 +130,8 @@ export class NostrChannel implements Channel { const pubkeyHex = nostrPubkeyHex(identity); const filter: NostrFilter = { authors: [pubkeyHex], - kinds: [KEZ_NOSTR_KIND], - limit: 20, + kinds: VERIFY_KINDS, + limit: 200, }; let events: NostrEvent[]; @@ -134,8 +145,10 @@ export class NostrChannel implements Channel { let lastError: ChannelError | undefined; for (const ev of events) { if (!eventMatchesAuthor(ev, pubkeyHex)) continue; + const body = extractProofBody(ev); + if (body === undefined) continue; try { - return parseAndVerifyFor(ev.content, identity); + return parseAndVerifyFor(body, identity); } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e); } @@ -231,6 +244,30 @@ export async function publishEventToRelay( }); } +/** + * The text to hand to parseAndVerifyFor for one event, or undefined if the + * event can't carry a proof. Kind 0 content is JSON metadata: the proof is + * in `about`, which must be JSON-decoded so escaped newlines in a Markdown + * fence become real ones. Matches Rust's `extract_proof_body`. + */ +export function extractProofBody(event: NostrEvent): string | undefined { + switch (event.kind) { + case NOSTR_KIND_PROFILE: { + try { + const about = (JSON.parse(event.content) as { about?: unknown }).about; + return typeof about === "string" ? about : undefined; + } catch { + return undefined; + } + } + case NOSTR_KIND_NOTE: + case KEZ_NOSTR_KIND: + return event.content; + default: + return undefined; + } +} + export function buildReqMessage(subId: string, filter: NostrFilter): string { const spec: Record = { authors: filter.authors, diff --git a/nodejs/packages/kez-channels/test/nostr.test.ts b/nodejs/packages/kez-channels/test/nostr.test.ts index f269692..3916639 100644 --- a/nodejs/packages/kez-channels/test/nostr.test.ts +++ b/nodejs/packages/kez-channels/test/nostr.test.ts @@ -6,9 +6,13 @@ import { nostrPubkeyHex, signClaim, toCompact, + toMarkdown, } from "@kez/core"; import { KEZ_NOSTR_KIND, + NOSTR_KIND_NOTE, + NOSTR_KIND_PROFILE, + VERIFY_KINDS, NostrChannel, type NostrEvent, type NostrFetcher, @@ -16,6 +20,7 @@ import { buildReqMessage, buildSignedEvent, eventMatchesAuthor, + extractProofBody, parseRelayMessage, } from "../src/nostr.js"; import { sha256 } from "@noble/hashes/sha2"; @@ -34,12 +39,16 @@ class CapturingFetcher implements NostrFetcher { } } -function makeEvent(pubkeyHex: string, content: string): NostrEvent { +function makeEvent( + pubkeyHex: string, + content: string, + kind: number = KEZ_NOSTR_KIND, +): NostrEvent { return { id: "0".repeat(64), pubkey: pubkeyHex, created_at: Math.floor(Date.now() / 1000), - kind: KEZ_NOSTR_KIND, + kind, tags: [["d", "kez"]], content, sig: "f".repeat(128), @@ -60,7 +69,7 @@ describe("NostrChannel", () => { const fetcher = new CapturingFetcher( [makeEvent(pubkey, toCompact(signed))], [pubkey], - [KEZ_NOSTR_KIND], + VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); const hit = await channel.fetchAndVerify(identity); @@ -76,7 +85,7 @@ describe("NostrChannel", () => { const fetcher = new CapturingFetcher( [makeEvent(pubkeyB, compactA)], [nostrPubkeyHex(a.identity)], - [KEZ_NOSTR_KIND], + VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ @@ -95,7 +104,7 @@ describe("NostrChannel", () => { const fetcher = new CapturingFetcher( [makeEvent(nostrPubkeyHex(a.identity), toCompact(claimForB))], [nostrPubkeyHex(a.identity)], - [KEZ_NOSTR_KIND], + VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ @@ -108,7 +117,7 @@ describe("NostrChannel", () => { const fetcher = new CapturingFetcher( [], [nostrPubkeyHex(identity)], - [KEZ_NOSTR_KIND], + VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(identity)).rejects.toMatchObject({ @@ -117,6 +126,51 @@ describe("NostrChannel", () => { }); }); +describe("NostrChannel — kinds 1 and 0", () => { + it("verifies a Markdown proof inside a kind-1 note", async () => { + const { identity, signed } = signForSelf(); + const pubkey = nostrPubkeyHex(identity); + const note = `Verifying my KEZ identity.\n\n${toMarkdown(signed)}\n#kez`; + const fetcher = new CapturingFetcher( + [makeEvent(pubkey, note, NOSTR_KIND_NOTE)], + [pubkey], + VERIFY_KINDS, + ); + const hit = await new NostrChannel(fetcher).fetchAndVerify(identity); + expect(hit.proof).toEqual(signed); + }); + + it("verifies a proof in a kind-0 profile `about`", async () => { + const { identity, signed } = signForSelf(); + const pubkey = nostrPubkeyHex(identity); + const profile = JSON.stringify({ name: "me", about: toMarkdown(signed) }); + const fetcher = new CapturingFetcher( + [makeEvent(pubkey, profile, NOSTR_KIND_PROFILE)], + [pubkey], + VERIFY_KINDS, + ); + const hit = await new NostrChannel(fetcher).fetchAndVerify(identity); + expect(hit.proof).toEqual(signed); + }); +}); + +describe("extractProofBody", () => { + const pk = "a".repeat(64); + it("pulls and JSON-decodes `about` from kind 0", () => { + const ev = makeEvent(pk, JSON.stringify({ about: "line1\nline2" }), 0); + expect(extractProofBody(ev)).toBe("line1\nline2"); + }); + it("passes kind 1 and 30078 content through", () => { + expect(extractProofBody(makeEvent(pk, "kez:z1:abc", 1))).toBe("kez:z1:abc"); + expect(extractProofBody(makeEvent(pk, "kez:z1:abc", KEZ_NOSTR_KIND))).toBe("kez:z1:abc"); + }); + it("skips kind 0 without `about`, bad JSON, and unknown kinds", () => { + expect(extractProofBody(makeEvent(pk, JSON.stringify({ name: "x" }), 0))).toBeUndefined(); + expect(extractProofBody(makeEvent(pk, "not json", 0))).toBeUndefined(); + expect(extractProofBody(makeEvent(pk, "kez:z1:abc", 7))).toBeUndefined(); + }); +}); + describe("nostr wire helpers", () => { it("buildReqMessage includes filter fields", () => { const req = buildReqMessage("sub-1", { authors: ["aa"], kinds: [30078], limit: 20 }); diff --git a/rust/README.md b/rust/README.md index 2eff3ab..21503d6 100644 --- a/rust/README.md +++ b/rust/README.md @@ -97,7 +97,7 @@ kez claim create github:jason --nsec nsec1... |---|---| | `github:` | A public gist whose filename includes `kez`, or your `/` profile README | | `dns:` | TXT record at `_kez.` (use `kez claim dns ...` to get the zone-file line) | -| `nostr:` | A kind-30078 event published by the same key | +| `nostr:` | An event by the same key: kind 30078 (canonical), a kind-1 note, or the kind-0 profile `about` | | `bluesky:` | A public post containing the compact form or the Markdown fence | | `ap:` / `mastodon:` | Your profile metadata field (preferred) or anywhere in your bio | @@ -201,7 +201,7 @@ pub trait Channel: Send + Sync { |---|---|---|---| | [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.` TXT via system resolver | No | | [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `/` profile README | No (60 req/hr anon, 5000 with `GITHUB_TOKEN`) | -| [`nostr.rs`](crates/kez-channels/src/nostr.rs) | `nostr:` | Kind-30078 events from damus / nos.lol / primal relays | No | +| [`nostr.rs`](crates/kez-channels/src/nostr.rs) | `nostr:` | Kind 0 / 1 / 30078 events from damus / nos.lol / primal relays | No | | [`bluesky.rs`](crates/kez-channels/src/bluesky.rs) | `bluesky:` | Author feed via the public Bluesky AppView | No | | [`activitypub.rs`](crates/kez-channels/src/activitypub.rs) | `ap:`, `mastodon:` | WebFinger → actor JSON → profile fields + bio | No |