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:
parent
3628199a5b
commit
b237ac2d9e
113
SPEC.md
113
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 `<user>/<user>` profile repo | Markdown (§6.3) |
|
||||
| `dns` | TXT record at `_kez.<domain>` | Compact (§6.2) |
|
||||
| `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 |
|
||||
| `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/<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
|
||||
@ -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).
|
||||
|
||||
@ -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", <recipient addr hex>]]` — `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": ["<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`)
|
||||
|
||||
1. Resolve the recipient handle → ed25519 primary via the directory
|
||||
(`/v1/u/<handle>` — 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/<handle>` 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:<handle>`** — 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:<handle>`** — 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:<handle>`** — 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:<handle>`** — 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/<handle>/stream` (those were the server transport).
|
||||
- The only remaining server call is the directory lookup `GET /v1/u/<handle>`.
|
||||
---
|
||||
|
||||
## 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/<handle>/stream`.
|
||||
- The only chat-server calls are the directory (`/v1/u/…`,
|
||||
`/v1/by-primary/…`), push subscription, and sigchain sync.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<string, unknown> = {
|
||||
authors: filter.authors,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| [`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 |
|
||||
| [`activitypub.rs`](crates/kez-channels/src/activitypub.rs) | `ap:`, `mastodon:` | WebFinger → actor JSON → profile fields + bio | No |
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user