docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current

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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-09-27 10:00:51 -06:00
parent 3628199a5b
commit b237ac2d9e
7 changed files with 399 additions and 230 deletions

113
SPEC.md
View File

@ -1,7 +1,7 @@
# KEZ Specification # KEZ Specification
**Status:** Draft v0.3 **Status:** Draft v0.4
**Date:** 2026-05-25 **Date:** 2026-09-27
**Conformance:** Implementations MUST conform to the formats and behaviors **Conformance:** Implementations MUST conform to the formats and behaviors
defined here. Two implementations conform iff a claim signed by one defined here. Two implementations conform iff a claim signed by one
verifies in the other and vice versa (see §15). 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 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. | | **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. | | **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 KEZ is **not a phone book.** It cannot resolve "Jason" from a human
name. A verifier always needs a concrete starting identifier (see §10). 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 ## 4. The Signature Envelope
@ -122,7 +161,7 @@ algorithms) or universally-verifiable signatures (for randomized ones).
## 5. Payload Shapes ## 5. Payload Shapes
Two payload shapes are defined in v0.3. Two payload shapes are defined in this version.
### 5.1 Claim payload ### 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 `<user>/<user>` profile repo | Markdown (§6.3) | | `github` | Public gist (filename should include `kez`), or `README` on the user's `<user>/<user>` profile repo | Markdown (§6.3) |
| `dns` | TXT record at `_kez.<domain>` | Compact (§6.2) | | `dns` | TXT record at `_kez.<domain>` | Compact (§6.2) |
| `web` | `https://<domain>/.well-known/kez.json` | JSON (§6.1) | | `web` | `https://<domain>/.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 | | `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 | | `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 Implementations SHOULD support multiple stores in parallel and treat
them as mirrors. them as mirrors.
### 9.1 Sigchain HTTP store (informative)
The reference store ([`rust-sig-server/`](rust-sig-server/)) exposes:
| Method | Path | |
|---|---|---|
| GET | `/v1/sigchains/<scheme>/<id>` | Whole chain as JSONL (one signed envelope per line); 404 if empty |
| GET | `/v1/sigchains/<scheme>/<id>/head` | Latest `{seq, hash}` |
| POST | `/v1/sigchains/<scheme>/<id>/events` | Append one signed sigchain event; the store re-checks tag, primary, `seq`, `prev`, and signature before accepting |
`<scheme>/<id>` 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://<server>/v1/sigchains/…`); clients should use
whatever `sigchain_url` they are given rather than constructing one.
--- ---
## 10. Verification ## 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). | | **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. | | **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. | | **SHA-512** | Internal to Ed25519. |
| **JCS** (RFC 8785) | Canonicalize the bytes to be signed. | | **JCS** (RFC 8785) | Canonicalize the bytes to be signed. |
| **zstd** (default level 3) | Compress the JSON envelope for the compact wire form. | | **zstd** (default level 3) | Compress the JSON envelope for the compact wire form. |
@ -601,7 +656,7 @@ signature.
## 13. Versioning & Wire Compatibility ## 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) ### What's non-breaking (minor bump)
@ -623,7 +678,7 @@ Spec versions are `major.minor`. The current spec is **v0.3**.
### Implementation policy ### 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 - Implementations MAY accept newer minor versions if they ignore
unknown additive content safely. unknown additive content safely.
- Verifiers that encounter an event with an unknown `op` MUST chain - 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 | | 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.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). | | 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`) | | `rust/` | Rust | `kez-core` (lib), `kez-channels` (lib), `kez-cli` (binary `kez`) |
| `nodejs/` | TypeScript | `@kez/core`, `@kez/channels`, `@kez/cli` — npm workspaces | | `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 Conformance between implementations is verified by `crosstest.sh` at
the repo root — a harness that generates artifacts with one the repo root — a harness that generates artifacts with one
implementation and verifies them with the other, in every direction implementation and verifies them with another, in every direction and
and every wire encoding. A passing cross-test is the operational every wire encoding, including recovery phrases (§3.1). A passing
definition of "two implementations interoperate." cross-test is the operational definition of "implementations
interoperate."
A future `vectors/` directory may hold canonical test fixtures for Canonical fixtures live next to the implementation that owns them
third-party implementations to validate against. Not required while (currently [`python/MNEMONIC-TEST-VECTORS.md`](python/MNEMONIC-TEST-VECTORS.md)).
two interop-tested implementations exist. 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).

View File

@ -1,267 +1,260 @@
# Nostr Chat # Nostr Chat
How the `nostr` branch carries kez-chat messages over Nostr relays instead How kez-chat carries messages over public Nostr relays: addressing, event
of the kez-chat server inbox — without changing the identity model or the formats, encryption, delivery, and what relays can and can't see.
end-to-end encryption.
> **One-line summary:** the chat transport is swapped from an HTTP/SSE server > **One-line summary:** messages are sealed on the device into a
> inbox to Nostr relays. Everything else — your ed25519 identity, the > KEZ-signed, end-to-end encrypted envelope and published as Nostr events
> `SealedEnvelope` encryption, the UI — is untouched. Nostr only moves bytes. > 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 ## 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 ─► sealForRecipient() ─► envelope (v4) ─► kind-4242 event ─► relays
plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer (crypto.ts: X25519 + ML-KEM-768, tag ["q", addr]
│ (ed25519/x25519 + AES-GCM, crypto.ts) │ AES-256-GCM, Ed25519 signature)
└──────────────────────────────────────────────────────────┘ │
recipient ◄─ openMessage() ◄─ envelope ◄─ subscription {"#q":[myAddr]}┘
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
``` ```
The `SealedEnvelope` is the **"extra layer of encryption using our own key"** — The envelope does all the protecting — confidentiality, sender
it exists independently of Nostr and is what actually protects the message authentication, integrity. Nostr only moves bytes.
body. Nostr is a dumb pipe underneath it.
--- ---
## 2. Identity: bridging ed25519 onto Nostr ## 2. Identity: bridging Ed25519 onto Nostr
KEZ identities are **ed25519**. Nostr signs events with **secp256k1** KEZ identities are **Ed25519**. Nostr signs events with **secp256k1**
(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's (Schnorr). The curves can't be cross-derived, so `nostr-id.ts` bridges
ed25519 public key into "their" Nostr public key. The bridge them in two halves.
(`nostr-id.ts`) solves this in two halves:
### 2a. Signing key (derived from your own seed) ### 2a. Signing key (derived from your own seed)
Every account needs a secp256k1 key to sign Nostr events (relays reject Relays only accept signed events, so every account derives a secp256k1
unsigned events). We derive it deterministically from the user's 32-byte key from its 32-byte Ed25519 seed:
ed25519 seed:
``` ```
nostrSecret = HKDF-SHA256( nostrSecret = HKDF-SHA256(ikm = ed25519_seed,
ikm = ed25519_seed, salt = "kez-chat:nostr-signkey", info = "v1", len = 32)
salt = "kez-chat:nostr-signkey",
info = "v1",
len = 32,
)
``` ```
Properties: - **Deterministic** — the same account gets the same Nostr key on every
device, with nothing extra to store.
- **Deterministic** — the same account always produces the same Nostr signer, - **App-specific** — it's not the user's personal Nostr account and is
on any device, with no extra storage. never shown in the UI. It *is* public, though: it signs every message,
- **Internal** — it is a pure transport credential. It is *not* the user's heartbeat, receipt, and the account's profile / relay-list events, so
real Nostr account, it is never surfaced in the UI, and its public key is it's a stable pseudonym relays can observe (see §7).
never advertised or used for addressing. - **One-way** — it reveals nothing about the Ed25519 seed.
- **One-way** — HKDF means the Nostr key reveals nothing about the ed25519
seed (the actual secret).
### 2b. Addressing (derived from the recipient's *public* primary) ### 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 Events aren't addressed to a Nostr pubkey. Each one carries a routing
a pubkey at all. Instead each event carries a routing label derived from the label derived from the recipient's public Ed25519 primary, which any
recipient's **public** ed25519 primary (which any sender can look up in the sender can get from the directory:
directory):
``` ```
addr = HKDF-SHA256( addr = hex(HKDF-SHA256(ikm = utf8(recipient_primary), // "ed25519:abc…"
ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…" salt = "kez-chat:nostr-addr", info = "v1", len = 32))
salt = "kez-chat:nostr-addr",
info = "v1",
len = 32,
) // → 32-byte hex
``` ```
The sender stamps this on the event as a tag; the recipient subscribes for It goes in a `q` tag, and the recipient subscribes with `{"#q": [myAddr]}`.
events carrying their own `addr`. Both sides compute the same value from the (`q`, not `h`: NIP-29 relays treat `h` as a group id and may drop the
same public primary — the sender from a directory lookup, the recipient from event.) Hashing keeps the raw primary out of a relay-queryable tag — but
their own identity. Using a hash (rather than the raw primary) keeps the anyone who knows your primary can compute your `addr`, so it hides
plaintext primary out of a relay-queryable tag. nothing from someone who already knows who you are.
--- ---
## 3. The event format ## 3. Event kinds
| Field | Value | | Kind | What | Tags | Content |
|--------------|--------------------------------------------------------------------| |---|---|---|---|
| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (1000–9999), so relays persist it | | `4242` | Message (`KEZ_DM_KIND`) — regular kind, so relays store it | `["q", addr]` | `JSON.stringify(envelope)` |
| `tags` | `[["h", <recipient addr hex>]]` — `h` = `ADDR_TAG`, the routing label | | `4244` | Delivery receipt (`KEZ_ACK_KIND`) | `["q", addr of original sender]`, `["e", acked event id]`, `["kez-sig", …]`, optional `["p", sender's nostr pubkey]` | empty |
| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope | | `10142` | Heartbeat — replaceable, one per author | `["d","heartbeat-v1"]` (informational), `["x25519", pub]` | `SignedHeartbeat` JSON (§4) |
| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) | | `0` | Profile | — | name, about, visually-encrypted picture + per-contact key wraps |
| `sig` | Schnorr signature over the event (so relays accept it) | | `10002` | NIP-65 relay list | `["r", url]` | empty |
| `created_at` | unix seconds |
A subscriber filters with: The subscription covers both conversation kinds:
```json ```json
{ "kinds": [4242], "#h": ["<my addr hex>"], "since": <unix-seconds> } { "kinds": [4242, 4244], "#q": ["<my addr>"], "since": <unix seconds> }
``` ```
--- ---
## 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": "<x25519 hex>", "mlkem_pub": "<ml-kem-768 hex>",
"created_at": "<ISO>", "sig": "<ed25519 over JCS({t:\"kez-heartbeat-v1\", pub, created_at, mlkem_pub})>" }
```
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`) ### Sending (`nostr-transport.ts` → `sendMessage`)
1. Resolve the recipient handle → ed25519 primary via the directory 1. Use the recipient's primary from the conversation row (or look up
(`/v1/u/<handle>` — still served by the kez-chat server). `/v1/u/<handle>` for a new contact).
2. `sealMessage(...)` → `SealedEnvelope` (identical to the server transport). 2. Resolve their heartbeat (cache → relays), then seal a v4 (or v3)
3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`, envelope.
`content = JSON.stringify(envelope)`. 3. Build a kind-4242 event with `["q", addrFromPrimary(recipient)]`, sign
4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`). it with the derived Nostr key.
5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts. 4. Publish to an ordered, de-duplicated relay set (max 10): the relay
If every relay rejects, `sendMessage` throws `"no relay accepted the message"`. 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`) ### Receiving (`streamInbox` + `pollInbox`)
The global `inbox-service` runs both, exactly as it did for the server `inbox-service` runs a live subscription plus a catch-up query every 30
transport: seconds (and on startup). Each event is:
- **`streamInbox`** opens a live subscription 1. Checked for a sane `created_at`, then de-duplicated by event id.
(`pool.subscribeMany`) filtered on the user's own `addr`. Each event fires 2. Parsed and decrypted (`openMessage`), which verifies the sender's
`onevent`; after the relays finish replaying stored events, `oneose` flips signature. For v3/v4 it looks up the matching heartbeat private key.
the UI status to **live**. 3. Appended to the local conversation (or routed to file reassembly).
- **`pollInbox`** is a one-shot `pool.querySync` used as a heartbeat catch-up 4. Acknowledged: a kind-4244 receipt with the recipient's KEZ signature
(every 30s and on startup), so nothing is missed if the subscription drops. over the event id goes back to the sender's `addr`. Failed receipts
are retried next session.
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.
--- ---
## 5. Cursors & de-duplication ## 6. Cursors, sequence numbers, relays
Relays can resend events, and two transports (live sub + heartbeat poll) can - **`kez-chat:nostr:since:<handle>`** — the `since` filter. Advances as
deliver the same event. Both are made idempotent with per-handle events arrive; a fresh device starts 48 hours back (most public relays
`localStorage` state: don't keep more).
- **`kez-chat:nostr:seen:<handle>`** — processed event ids (last 10,000).
- **`kez-chat:nostr:since:<handle>`** — the relay `since` filter (unix - **Synthetic `seq`** — the conversation store wants a monotonic number,
seconds). Advances as events arrive, so reloads resume instead of so `seq = created_at * 1000 + (parseInt(id.slice(0,3), 16) % 1000)`.
re-fetching. A fresh device defaults to `now − 7 days` to catch recent - **NIP-42 AUTH** — the relay pool signs auth challenges with the derived
history. key, so relays that gate kinds behind AUTH still deliver.
- **`kez-chat:nostr:seen:<handle>`** — a capped set (last `500` ids) of - **Relay list** — user-editable in Settings (wss:// only; ws:// allowed
processed event ids. An id already in the set is skipped. for localhost), stored in `localStorage`, applied on reload. Changes
republish your NIP-65 list.
### 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.
--- ---
## 7. Privacy model ## 7. Privacy model
- **Confidential:** the message *body*. It is AES-256-GCM encrypted inside the - **Confidential:** message content, attachments, and the sender's KEZ
`SealedEnvelope` before it ever reaches a relay. Relays (and anyone reading identity — all inside the envelope. Post-quantum hybrid (v4), so
them) see only ciphertext. recorded traffic stays safe against a future quantum computer.
- **Visible to relays:** the social-graph metadata. The envelope carries the - **Forward-secret:** heartbeat keys are random and deleted after the
sender's primary (`from`) and recipient handle (`to`) in its JSON, and the retention window; stealing the seed later doesn't decrypt old
`#h` tag routes by recipient. A relay can therefore observe who is talking to messages.
whom and when — **the same metadata the kez-chat server saw with the old - **Visible to relays:** which Nostr key published an event, the
transport.** This is parity, not a regression. recipient's `q` tag, and the time. The Nostr key is stable per account
- **Authenticity:** every envelope is signed by the sender's ed25519 key and and its kind-0 profile carries your handle, so a relay can map who
verified on decrypt, so a relay (or anyone) cannot forge or tamper with a talks to whom and when. There's no onion routing or padding.
message without detection. - **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 Full threat model: [`../SECURITY.md`](../SECURITY.md).
an outer NIP-44 wrap around the envelope. It was deliberately left out to keep
this a clean, minimal transport swap.
--- ---
## 8. Known limitations ## 8. Configuration and the transport facade
- **No backfill of old server-era messages.** Messages delivered over the old | Variable | Default | |
server transport were never published to relays and cannot be fetched over |---|---|---|
Nostr. Locally cached history (IndexedDB) still renders, but it is a stale | `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays; `server` = HTTP mailbox |
snapshot, not relay-backed. | `VITE_NOSTR_RELAYS` | damus, nos.lol, primal, snort.social, nostr.wine | Default relay list; users can override in Settings |
- **Relay acceptance varies.** Some public relays restrict event kinds or rate.
If all configured relays reject `kind 4242`, sends fail loudly. Run your own `transport.ts` re-exports `sendMessage` / `sendFile` / `sendAck` /
relay or pick permissive ones via `VITE_NOSTR_RELAYS` for reliability. `pollInbox` / `streamInbox` / `decrypt` from either `nostr-transport.ts`
- **Eventual, not guaranteed, delivery.** Delivery depends on the recipient and or `messages.ts`. The server mailbox still seals v2 envelopes and has no
sender sharing at least one reachable relay. More relays = more resilience, heartbeats or receipts — production doesn't use it.
more metadata exposure.
- **Same-second ordering** is approximated (see §5), not exact.
--- ---
## 9. File map ## 9. Known limitations
- **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. File map
| File | Role | | File | Role |
|----------------------------|-------------------------------------------------------------| |---|---|
| `src/lib/crypto.ts` | **Unchanged.** The `SealedEnvelope` E2E layer (our own key).| | `src/lib/nostr-id.ts` | Derived Nostr signing key; recipient `addr`; kind and tag constants |
| `src/lib/nostr-id.ts` | Derive the secp256k1 signing key; compute recipient `addr`. | | `src/lib/nostr-transport.ts` | Relays, send/receive, heartbeats publish + fetch + cache, receipts, files, cursors |
| `src/lib/nostr-transport.ts` | Nostr `send`/`poll`/`stream`; cursor + dedupe. | | `src/lib/crypto.ts` | Envelopes v1–v4, heartbeat signing/verification |
| `src/lib/transport.ts` | Facade selecting server vs nostr via `VITE_TRANSPORT`. | | `src/lib/heartbeat-store.ts` | Heartbeat key rotation and retention |
| `src/lib/messages.ts` | **Unchanged.** The original server transport. | | `src/lib/relay-store.ts` | User relay list |
| `src/lib/inbox-service.svelte.ts` | Imports from the facade; otherwise unchanged. | | `src/lib/inbox-service.svelte.ts` | Receive pipeline, dedupe, ack, heartbeat lifecycle |
| `src/routes/Messages.svelte` | Imports `sendMessage` from the facade. | | `src/lib/transport.ts` | Picks nostr vs server transport |
| `.env` | Flips this branch to `VITE_TRANSPORT=nostr`. | | `src/lib/messages.ts` | Legacy HTTP mailbox transport |
--- ---
## 10. How to verify it's really on Nostr ## 11. How to see it on the wire
Open DevTools → **Network → WS** on the running app: DevTools → **Network → WS**:
- You should see live WebSocket connections to the configured relays - WebSocket connections to your relays.
(`wss://relay.damus.io`, etc.). - Sending emits `["EVENT", {kind: 4242, tags: [["q", …]], content: "{\"v\":4, …"}]`.
- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives - No `POST /v1/messages` and no `/v1/inbox/<handle>/stream`.
as an incoming event frame. - The only chat-server calls are the directory (`/v1/u/…`,
- You should **not** see `POST /v1/messages` or an SSE connection to `/v1/by-primary/…`), push subscription, and sigchain sync.
`/v1/inbox/<handle>/stream` (those were the server transport).
- The only remaining server call is the directory lookup `GET /v1/u/<handle>`.

View File

@ -55,7 +55,8 @@ export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array {
* Deterministic 32-byte (hex) address for a recipient, derived from their * Deterministic 32-byte (hex) address for a recipient, derived from their
* public ed25519 primary. Both parties can compute it: the sender from the * public ed25519 primary. Both parties can compute it: the sender from the
* directory lookup, the recipient from their own primary. Used as the value * 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 * 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 * opaque routing label. Using a hash also means the raw primary isn't sitting

View File

@ -10,10 +10,11 @@
// crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves // crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves
// the bytes; our key still does the encrypting. // the bytes; our key still does the encrypting.
// //
// Addressing: events carry an `#h` tag = addrFromPrimary(recipient). We // Addressing: events carry a `q` tag = addrFromPrimary(recipient). We
// subscribe with a `{ "#h": [myAddr] }` filter. Events are signed by a // subscribe with a `{ "#q": [myAddr] }` filter. Events are signed by a
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so // secp256k1 key derived from our ed25519 seed (see nostr-id.ts) so relays
// relays accept them — that key is never surfaced to the user. // accept them — never shown in the UI, but a stable public pseudonym.
// Protocol overview: ../../NOSTR-CHAT.md.
import { ed25519 } from "@noble/curves/ed25519"; import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";

View File

@ -1,5 +1,12 @@
// Nostr channel: queries relays for kind-30078 events authored by the // Nostr channel: queries relays for events authored by the requested npub
// requested npub, then runs each event's content through parseAndVerifyFor. // 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. // Fetcher is abstracted so tests use canned events.
import { type Identity, NostrSecret, nostrPubkeyHex } from "@kez/core"; 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"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
export const KEZ_NOSTR_KIND = 30078; 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 DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net"];
const FETCH_TIMEOUT_MS = 8_000; const FETCH_TIMEOUT_MS = 8_000;
@ -119,8 +130,8 @@ export class NostrChannel implements Channel {
const pubkeyHex = nostrPubkeyHex(identity); const pubkeyHex = nostrPubkeyHex(identity);
const filter: NostrFilter = { const filter: NostrFilter = {
authors: [pubkeyHex], authors: [pubkeyHex],
kinds: [KEZ_NOSTR_KIND], kinds: VERIFY_KINDS,
limit: 20, limit: 200,
}; };
let events: NostrEvent[]; let events: NostrEvent[];
@ -134,8 +145,10 @@ export class NostrChannel implements Channel {
let lastError: ChannelError | undefined; let lastError: ChannelError | undefined;
for (const ev of events) { for (const ev of events) {
if (!eventMatchesAuthor(ev, pubkeyHex)) continue; if (!eventMatchesAuthor(ev, pubkeyHex)) continue;
const body = extractProofBody(ev);
if (body === undefined) continue;
try { try {
return parseAndVerifyFor(ev.content, identity); return parseAndVerifyFor(body, identity);
} catch (e) { } catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, 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 { export function buildReqMessage(subId: string, filter: NostrFilter): string {
const spec: Record<string, unknown> = { const spec: Record<string, unknown> = {
authors: filter.authors, authors: filter.authors,

View File

@ -6,9 +6,13 @@ import {
nostrPubkeyHex, nostrPubkeyHex,
signClaim, signClaim,
toCompact, toCompact,
toMarkdown,
} from "@kez/core"; } from "@kez/core";
import { import {
KEZ_NOSTR_KIND, KEZ_NOSTR_KIND,
NOSTR_KIND_NOTE,
NOSTR_KIND_PROFILE,
VERIFY_KINDS,
NostrChannel, NostrChannel,
type NostrEvent, type NostrEvent,
type NostrFetcher, type NostrFetcher,
@ -16,6 +20,7 @@ import {
buildReqMessage, buildReqMessage,
buildSignedEvent, buildSignedEvent,
eventMatchesAuthor, eventMatchesAuthor,
extractProofBody,
parseRelayMessage, parseRelayMessage,
} from "../src/nostr.js"; } from "../src/nostr.js";
import { sha256 } from "@noble/hashes/sha2"; 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 { return {
id: "0".repeat(64), id: "0".repeat(64),
pubkey: pubkeyHex, pubkey: pubkeyHex,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: KEZ_NOSTR_KIND, kind,
tags: [["d", "kez"]], tags: [["d", "kez"]],
content, content,
sig: "f".repeat(128), sig: "f".repeat(128),
@ -60,7 +69,7 @@ describe("NostrChannel", () => {
const fetcher = new CapturingFetcher( const fetcher = new CapturingFetcher(
[makeEvent(pubkey, toCompact(signed))], [makeEvent(pubkey, toCompact(signed))],
[pubkey], [pubkey],
[KEZ_NOSTR_KIND], VERIFY_KINDS,
); );
const channel = new NostrChannel(fetcher); const channel = new NostrChannel(fetcher);
const hit = await channel.fetchAndVerify(identity); const hit = await channel.fetchAndVerify(identity);
@ -76,7 +85,7 @@ describe("NostrChannel", () => {
const fetcher = new CapturingFetcher( const fetcher = new CapturingFetcher(
[makeEvent(pubkeyB, compactA)], [makeEvent(pubkeyB, compactA)],
[nostrPubkeyHex(a.identity)], [nostrPubkeyHex(a.identity)],
[KEZ_NOSTR_KIND], VERIFY_KINDS,
); );
const channel = new NostrChannel(fetcher); const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({
@ -95,7 +104,7 @@ describe("NostrChannel", () => {
const fetcher = new CapturingFetcher( const fetcher = new CapturingFetcher(
[makeEvent(nostrPubkeyHex(a.identity), toCompact(claimForB))], [makeEvent(nostrPubkeyHex(a.identity), toCompact(claimForB))],
[nostrPubkeyHex(a.identity)], [nostrPubkeyHex(a.identity)],
[KEZ_NOSTR_KIND], VERIFY_KINDS,
); );
const channel = new NostrChannel(fetcher); const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({
@ -108,7 +117,7 @@ describe("NostrChannel", () => {
const fetcher = new CapturingFetcher( const fetcher = new CapturingFetcher(
[], [],
[nostrPubkeyHex(identity)], [nostrPubkeyHex(identity)],
[KEZ_NOSTR_KIND], VERIFY_KINDS,
); );
const channel = new NostrChannel(fetcher); const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(identity)).rejects.toMatchObject({ 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", () => { describe("nostr wire helpers", () => {
it("buildReqMessage includes filter fields", () => { it("buildReqMessage includes filter fields", () => {
const req = buildReqMessage("sub-1", { authors: ["aa"], kinds: [30078], limit: 20 }); const req = buildReqMessage("sub-1", { authors: ["aa"], kinds: [30078], limit: 20 });

View File

@ -97,7 +97,7 @@ kez claim create github:jason --nsec nsec1...
|---|---| |---|---|
| `github:` | A public gist whose filename includes `kez`, or your `<user>/<user>` profile README | | `github:` | A public gist whose filename includes `kez`, or your `<user>/<user>` profile README |
| `dns:` | TXT record at `_kez.<domain>` (use `kez claim dns ...` to get the zone-file line) | | `dns:` | TXT record at `_kez.<domain>` (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 | | `bluesky:` | A public post containing the compact form or the Markdown fence |
| `ap:` / `mastodon:` | Your profile metadata field (preferred) or anywhere in your bio | | `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.<domain>` TXT via system resolver | No | | [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.<domain>` TXT via system resolver | No |
| [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `<user>/<user>` profile README | No (60 req/hr anon, 5000 with `GITHUB_TOKEN`) | | [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `<user>/<user>` 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 | | [`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 | | [`activitypub.rs`](crates/kez-channels/src/activitypub.rs) | `ap:`, `mastodon:` | WebFinger → actor JSON → profile fields + bio | No |