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
|
# 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).
|
||||||
|
|||||||
@ -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>`.
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user