diff --git a/.gitignore b/.gitignore
index 7b60514..d1306dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,13 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
+# Python
+.venv/
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+
# Local runtime state
*.db
*.db-journal
diff --git a/README.md b/README.md
index 73db254..9aec743 100644
--- a/README.md
+++ b/README.md
@@ -17,13 +17,15 @@ relay event). Anyone can verify the graph without trusting a server.
├── SPEC.md ← The protocol. Language-agnostic, normative.
├── rust/ ← Rust implementation (kez-core, kez-channels, kez-cli)
├── nodejs/ ← TypeScript/Node implementation (same shape, same CLI)
+├── python/ ← Python implementation (same shape, same CLI)
├── rust-sig-server/ ← Optional HTTP store for sigchains (axum + SQLite)
├── crosstest.sh ← Interop test: artifacts move between implementations
└── README.md ← (this file)
```
-Two parallel implementations. **Wire-compatible**: a claim signed in Rust
-verifies in Node and vice versa. The cross-test harness proves it.
+Three parallel implementations. **Wire-compatible**: a claim signed in Rust
+verifies in Node and Python and vice versa, in every direction. The cross-test
+harness proves it.
A separate [`rust-sig-server/`](rust-sig-server/) crate provides an optional
HTTP storage tier for sigchains — useful when a user doesn't want to set up
@@ -41,6 +43,9 @@ Start here:
- [**`nodejs/README.md`**](nodejs/README.md) — Node/TypeScript port:
same shape as Rust, npm workspaces layout, crypto stack rationale,
CLI reference.
+- [**`python/README.md`**](python/README.md) — Python port: single
+ `kez` package, virtualenv setup, crypto stack rationale (pure-Python
+ BIP-340 Schnorr + `cryptography` for Ed25519), CLI reference.
- [**`rust-sig-server/README.md`**](rust-sig-server/README.md) — the
optional storage server: API reference, no-auth design + threat
model, deployment recipes (bare-metal, Docker, PaaS), and how
@@ -56,7 +61,9 @@ cargo test # 99 tests
cargo install --path crates/kez-cli # → `kez` on PATH
kez verify id github:jason
```
-Full guide: [`rust/README.md`](rust/README.md).
+Full guide: [`rust/README.md`](rust/README.md) (reference) ·
+[`rust/TUTORIAL.md`](rust/TUTORIAL.md) (step-by-step, recommended
+for newcomers).
### Node.js
```sh
@@ -65,7 +72,18 @@ npm install
npm test # 91 tests
npm run cli -- verify id github:jason
```
-Full guide: [`nodejs/README.md`](nodejs/README.md).
+Full guide: [`nodejs/README.md`](nodejs/README.md) (reference) ·
+[`nodejs/TUTORIAL.md`](nodejs/TUTORIAL.md) (step-by-step).
+
+### Python
+```sh
+cd python
+python3 -m venv .venv
+.venv/bin/pip install -r requirements.txt
+.venv/bin/python kez_cli.py identity new
+```
+Full guide: [`python/README.md`](python/README.md) (reference) ·
+[`python/TUTORIAL.md`](python/TUTORIAL.md) (step-by-step).
### Sigchain storage server (optional)
```sh
@@ -81,27 +99,20 @@ Full guide: [`rust-sig-server/README.md`](rust-sig-server/README.md).
./crosstest.sh
```
-Runs 19 scenarios that swap implementations at the artifact boundary:
+Runs 55 scenarios that swap implementations at the artifact boundary:
-| # | Scenario |
+| # | Scenarios |
|---|---|
-| 1–2 | nostr-signed JSON claim, both directions |
-| 3–4 | nostr-signed compact claim, both directions |
-| 5–6 | nostr-signed markdown claim, both directions |
-| 7–8 | nostr-signed DNS zone form, both directions |
-| 9–10 | ed25519-signed JSON claim, both directions |
-| 11–12 | ed25519-signed compact claim, both directions |
-| 13–14 | ed25519-signed markdown claim, both directions |
-| 15 | rust builds 3-event nostr sigchain → node parses + shows |
-| 16 | rust-exported sigchain JSONL == node-exported JSONL (byte-identical) |
-| 17 | node builds 3-event nostr sigchain → rust parses + shows |
-| 18 | rust builds ed25519 sigchain → node parses + shows |
-| 19 | node builds ed25519 sigchain → rust parses + shows |
+| 1–14 | Rust ↔ Node: JSON / compact / markdown / DNS claims, nostr + ed25519 |
+| 15–20 | Rust ↔ Node sigchains: build in one, parse + show in the other; JSONL byte parity |
+| 21–44 | **Python ↔ Rust and Python ↔ Node** claims: every format × key type, both directions |
+| — | Python ↔ both peers DNS zone form, both directions |
+| — | Python ↔ both peers sigchains: build/show both ways, JSONL byte parity, ed25519 |
-If all 19 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
+If all 55 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr
and Ed25519), the compact `kez:z1:` zstd+base64url encoding, the Markdown
fence, the DNS TXT shape, and the sigchain JSONL bundle format are all
-byte-compatible across implementations.
+byte-compatible across all three implementations.
Pass `-v` for verbose output (echoes intermediate commands and proofs).
diff --git a/crosstest.sh b/crosstest.sh
index b2ea405..fdef22c 100755
--- a/crosstest.sh
+++ b/crosstest.sh
@@ -23,6 +23,14 @@ if [[ ! -f "$TSX_LOADER" ]]; then
exit 1
fi
NODE_CLI=(node --import "$TSX_LOADER" "$ROOT/nodejs/packages/kez-cli/src/cli.ts")
+# Python CLI runs inside its own virtualenv so its native deps (cryptography,
+# zstandard) are isolated from the system interpreter.
+PYTHON_VENV="$ROOT/python/.venv/bin/python"
+if [[ ! -x "$PYTHON_VENV" ]]; then
+ printf "Python venv not found at %s — run 'cd python && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt' first\n" "$PYTHON_VENV" >&2
+ exit 1
+fi
+PYTHON_CLI=("$PYTHON_VENV" "$ROOT/python/kez_cli.py")
VERBOSE=0
[[ "${1:-}" == "-v" ]] && VERBOSE=1
@@ -73,6 +81,35 @@ assert_verify_valid() {
fi
}
+# Dispatch to one of the three implementations by name. Keeps the Python
+# interop scenarios below readable without juggling array variables.
+run_cli() {
+ local which="$1"; shift
+ case "$which" in
+ rust) "${RUST_CLI[@]}" "$@" ;;
+ node) "${NODE_CLI[@]}" "$@" ;;
+ py) "${PYTHON_CLI[@]}" "$@" ;;
+ *) printf "unknown impl: %s\n" "$which" >&2; return 2 ;;
+ esac
+}
+
+# Sign a claim with one impl and verify it with another, for a given wire
+# format. Remaining args are the signing-key flags (--nsec / --ed25519-seed).
+claim_roundtrip() {
+ local title="$1" signer="$2" verifier="$3" fmt="$4"; shift 4
+ scenario "$title"
+ case "$fmt" in
+ json)
+ run_cli "$signer" claim create github:jason "$@" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
+ markdown)
+ run_cli "$signer" claim create github:jason "$@" --format markdown --out "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
+ *)
+ run_cli "$signer" claim create github:jason "$@" --format "$fmt" > "$TMP/rt.proof" 2>"$TMP/rt.err" ;;
+ esac
+ run_cli "$verifier" verify file "$TMP/rt.proof" > "$TMP/rt.out" 2>&1
+ assert_verify_valid "$title" "$TMP/rt.out" && ok
+}
+
# Pre-flight: build Rust release once (much faster reruns).
printf "%sBuilding Rust impl…%s\n" "$YELLOW" "$RESET"
cargo build --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli
@@ -181,6 +218,40 @@ scenario "node ed25519 markdown ⇒ rust verify file"
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
assert_verify_valid "node→rust ed25519 markdown" "$TMP/n.out" && ok
+# ── Python interop (claims) ─────────────────────────────────────────────────
+# The Python implementation must round-trip with BOTH peers in BOTH directions,
+# across every wire encoding and both key types — proving its JCS bytes,
+# signatures (pure-Python BIP-340 Schnorr + ed25519) and zstd compact framing
+# are all byte-compatible with Rust and Node.
+printf "%sPython interop (claims):%s\n" "$YELLOW" "$RESET"
+for peer in node rust; do
+ for fmt in json compact markdown; do
+ for kt in nostr ed25519; do
+ if [[ "$kt" == nostr ]]; then key=(--nsec "$NSEC"); else key=(--ed25519-seed "$SEED"); fi
+ claim_roundtrip "py $kt $fmt ⇒ $peer verify" py "$peer" "$fmt" "${key[@]}"
+ claim_roundtrip "$peer $kt $fmt ⇒ py verify" "$peer" py "$fmt" "${key[@]}"
+ done
+ done
+done
+
+# Python DNS zone form → both peers verify the extracted compact token.
+for peer in node rust; do
+ scenario "py DNS zone form ⇒ $peer verify file"
+ "${PYTHON_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/pd.dns"
+ awk '/^Value:/ {print $2}' "$TMP/pd.dns" > "$TMP/pd.compact"
+ run_cli "$peer" verify file "$TMP/pd.compact" > "$TMP/pd.out" 2>&1
+ assert_verify_valid "py DNS→$peer compact" "$TMP/pd.out" && ok
+done
+
+# Each peer's DNS compact token → Python verifies.
+for peer in node rust; do
+ scenario "$peer DNS zone form ⇒ py verify file"
+ run_cli "$peer" claim dns jason.example.com --nsec "$NSEC" > "$TMP/xd.dns"
+ awk '/^Value:/ {print $2}' "$TMP/xd.dns" > "$TMP/xd.compact"
+ "${PYTHON_CLI[@]}" verify file "$TMP/xd.compact" > "$TMP/xd.out" 2>&1
+ assert_verify_valid "$peer DNS→py compact" "$TMP/xd.out" && ok
+done
+
# ── Sigchain interop ────────────────────────────────────────────────────────
# Sigchain state lives in ~/.kez/sigchains/.jsonl. Both CLIs
# read/write the same paths, so we can build a chain in one and inspect it
@@ -289,6 +360,176 @@ if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
fi
rm -f "$SC_ED_FILE"
+# ── Python sigchain interop ─────────────────────────────────────────────────
+# Build chains with Python and inspect them from each peer (and vice versa),
+# over the same ~/.kez/sigchains state files. Uses fresh keys to avoid
+# colliding with the scenarios above.
+printf "%sPython interop (sigchains):%s\n" "$YELLOW" "$RESET"
+
+PY_NOSTR_OUT="$("${PYTHON_CLI[@]}" identity new)"
+PY_NSEC="$(printf '%s\n' "$PY_NOSTR_OUT" | extract_nsec /dev/stdin)"
+PY_NOSTR_PRIMARY="$(printf '%s\n' "$PY_NOSTR_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
+PY_NOSTR_FILE="$(chain_file_for "$PY_NOSTR_PRIMARY")"
+
+PY_ED_OUT="$("${PYTHON_CLI[@]}" identity new --key-type ed25519)"
+PY_SEED="$(printf '%s\n' "$PY_ED_OUT" | extract_ed25519_seed /dev/stdin)"
+PY_ED_PRIMARY="$(printf '%s\n' "$PY_ED_OUT" | sed -n 's/^Primary:[[:space:]]*//p' | head -1)"
+PY_ED_FILE="$(chain_file_for "$PY_ED_PRIMARY")"
+
+rm -f "$PY_NOSTR_FILE" "$PY_ED_FILE"
+
+# Python builds a nostr chain; each peer must see all 3 events incl the revoke.
+"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
+"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
+"${PYTHON_CLI[@]}" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
+for peer in node rust; do
+ scenario "py nostr chain ⇒ $peer sigchain show"
+ run_cli "$peer" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc.out" 2>&1
+ if grep -q "Length: 3 event(s)" "$TMP/psc.out" \
+ && grep -q "op=revoke subject=github:jason" "$TMP/psc.out"; then
+ ok
+ else
+ bad "py→$peer sigchain show" "see $TMP/psc.out"
+ cat "$TMP/psc.out" >&2
+ fi
+done
+rm -f "$PY_NOSTR_FILE"
+
+# Each peer builds a nostr chain; Python must see all 3 events.
+for peer in node rust; do
+ rm -f "$PY_NOSTR_FILE"
+ run_cli "$peer" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
+ run_cli "$peer" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
+ run_cli "$peer" sigchain revoke github:jason --nsec "$PY_NSEC" > /dev/null
+ scenario "$peer nostr chain ⇒ py sigchain show"
+ "${PYTHON_CLI[@]}" sigchain show --primary "$PY_NOSTR_PRIMARY" > "$TMP/psc2.out" 2>&1
+ if grep -q "Length: 3 event(s)" "$TMP/psc2.out" \
+ && grep -q "op=revoke subject=github:jason" "$TMP/psc2.out"; then
+ ok
+ else
+ bad "$peer→py sigchain show" "see $TMP/psc2.out"
+ cat "$TMP/psc2.out" >&2
+ fi
+ rm -f "$PY_NOSTR_FILE"
+done
+
+# JSONL byte parity: Python and each peer must export the same on-disk chain
+# to byte-identical JSONL.
+"${PYTHON_CLI[@]}" sigchain add github:jason --nsec "$PY_NSEC" > /dev/null
+"${PYTHON_CLI[@]}" sigchain add dns:jason.example --nsec "$PY_NSEC" > /dev/null
+for peer in node rust; do
+ scenario "py JSONL == $peer JSONL for same chain"
+ "${PYTHON_CLI[@]}" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_py.jsonl"
+ run_cli "$peer" sigchain export --nsec "$PY_NSEC" --format jsonl > "$TMP/pj_peer.jsonl"
+ if diff -q "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" > /dev/null; then
+ ok
+ else
+ bad "py/$peer JSONL parity" "exported bytes differ"
+ diff "$TMP/pj_py.jsonl" "$TMP/pj_peer.jsonl" | head -20 >&2
+ fi
+done
+rm -f "$PY_NOSTR_FILE"
+
+# Python ed25519 chain ⇒ each peer validates.
+"${PYTHON_CLI[@]}" sigchain add github:jason --ed25519-seed "$PY_SEED" > /dev/null
+"${PYTHON_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$PY_SEED" > /dev/null
+for peer in node rust; do
+ scenario "py ed25519 chain ⇒ $peer sigchain show"
+ run_cli "$peer" sigchain show --primary "$PY_ED_PRIMARY" > "$TMP/pse.out" 2>&1
+ if grep -q "Length: 2 event(s)" "$TMP/pse.out"; then ok; else
+ bad "py→$peer ed25519 chain" "$peer did not see all events"
+ cat "$TMP/pse.out" >&2
+ fi
+done
+rm -f "$PY_ED_FILE"
+
+# ── BIP-39 Mnemonic interop ─────────────────────────────────────────────────
+# 12- and 24-word phrases must derive identical Ed25519 keys across all
+# implementations, and a claim signed with --mnemonic in one impl must
+# verify in the others. See python/MNEMONIC-TEST-VECTORS.md for the
+# definitive ground-truth vectors.
+printf "%sBIP-39 mnemonic interop:%s\n" "$YELLOW" "$RESET"
+
+# Canonical test vectors. Public keys are the expected outputs that all
+# three implementations MUST agree on byte-for-byte. If any of these
+# values change, an implementation has a derivation bug.
+MNEMO_P24="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
+MNEMO_PUB_24="3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
+MNEMO_P12="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+MNEMO_PUB_12="9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70"
+MNEMO_P12B="legal winner thank year wave sausage worth useful legal winner thank yellow"
+MNEMO_PUB_12B="cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03"
+
+# Probe: does the Python CLI know about `identity from-mnemonic` yet?
+PY_HAS_MNEMONIC=0
+if [[ -x "$PYTHON_VENV" ]]; then
+ if "${PYTHON_CLI[@]}" identity from-mnemonic "$MNEMO_P12" 2>/dev/null \
+ | grep -q "^Public:"; then
+ PY_HAS_MNEMONIC=1
+ fi
+fi
+
+# Helper: assert the impl derives the expected pubkey from a phrase.
+assert_pubkey() {
+ local impl="$1" phrase="$2" expected="$3" title="$4"
+ scenario "$title"
+ local actual
+ actual=$(run_cli "$impl" identity from-mnemonic "$phrase" 2>/dev/null \
+ | awk -F': *' '/^Public:/ {print $2; exit}')
+ if [[ "$actual" == "$expected" ]]; then ok; else
+ bad "$title" "expected pubkey $expected, got $actual"
+ fi
+}
+
+# Vector matches per impl.
+for impl in rust node; do
+ assert_pubkey "$impl" "$MNEMO_P24" "$MNEMO_PUB_24" "$impl: V1 24-word vector derives expected pubkey"
+ assert_pubkey "$impl" "$MNEMO_P12" "$MNEMO_PUB_12" "$impl: V2 12-word vector derives expected pubkey"
+ assert_pubkey "$impl" "$MNEMO_P12B" "$MNEMO_PUB_12B" "$impl: V3 12-word vector derives expected pubkey"
+done
+if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
+ assert_pubkey py "$MNEMO_P24" "$MNEMO_PUB_24" "py: V1 24-word vector derives expected pubkey"
+ assert_pubkey py "$MNEMO_P12" "$MNEMO_PUB_12" "py: V2 12-word vector derives expected pubkey"
+ assert_pubkey py "$MNEMO_P12B" "$MNEMO_PUB_12B" "py: V3 12-word vector derives expected pubkey"
+else
+ printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
+ "py vector checks (python CLI lacks identity from-mnemonic — port still in flight)"
+fi
+
+# Cross-impl claim signing with --mnemonic. Each impl signs, each other
+# verifies. Uses the V3 phrase because it has non-trivial entropy.
+for fmt in json compact markdown; do
+ claim_roundtrip "rust mnemonic ($fmt) ⇒ node verify" rust node "$fmt" --mnemonic "$MNEMO_P12B"
+ claim_roundtrip "node mnemonic ($fmt) ⇒ rust verify" node rust "$fmt" --mnemonic "$MNEMO_P12B"
+ if [[ "$PY_HAS_MNEMONIC" -eq 1 ]]; then
+ claim_roundtrip "py mnemonic ($fmt) ⇒ rust verify" py rust "$fmt" --mnemonic "$MNEMO_P12B"
+ claim_roundtrip "rust mnemonic ($fmt) ⇒ py verify" rust py "$fmt" --mnemonic "$MNEMO_P12B"
+ claim_roundtrip "py mnemonic ($fmt) ⇒ node verify" py node "$fmt" --mnemonic "$MNEMO_P12B"
+ claim_roundtrip "node mnemonic ($fmt) ⇒ py verify" node py "$fmt" --mnemonic "$MNEMO_P12B"
+ fi
+done
+if [[ "$PY_HAS_MNEMONIC" -ne 1 ]]; then
+ printf " %sskip%s %s\n" "$YELLOW" "$RESET" \
+ "py mnemonic claim round-trips (port still in flight)"
+fi
+
+# Bijection sanity: 24-word phrase ⇄ seed must be exact. Each impl must
+# produce the canonical phrase from a known 32-byte seed via the
+# mnemonic-from-seed path (we drive it indirectly via the printed output
+# of `identity from-mnemonic`).
+scenario "24-word phrase is canonical form of its seed (rust)"
+got=$("${RUST_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
+ | awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
+if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
+ bad "rust canonical-24" "round-trip phrase differs"
+fi
+scenario "24-word phrase is canonical form of its seed (node)"
+got=$("${NODE_CLI[@]}" identity from-mnemonic "$MNEMO_P24" 2>/dev/null \
+ | awk -F': *' '/^Mnemonic .24 words/ { match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2); exit }')
+if [[ "$got" == "$MNEMO_P24" ]]; then ok; else
+ bad "node canonical-24" "round-trip phrase differs"
+fi
+
printf "\n"
if [[ $FAIL -eq 0 ]]; then
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
diff --git a/kez-chat/web/NOSTR-CHAT.md b/kez-chat/web/NOSTR-CHAT.md
new file mode 100644
index 0000000..6c5e7d4
--- /dev/null
+++ b/kez-chat/web/NOSTR-CHAT.md
@@ -0,0 +1,267 @@
+# 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.
+
+> **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.
+
+---
+
+## 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
+```
+
+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.
+
+---
+
+## 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:
+
+### 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:
+
+```
+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).
+
+### 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):
+
+```
+addr = HKDF-SHA256(
+ ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…"
+ 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
+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.
+
+---
+
+## 3. The event format
+
+| Field | Value |
+|--------------|--------------------------------------------------------------------|
+| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (1000–9999), so relays persist it |
+| `tags` | `[["h", ]]` — `h` = `ADDR_TAG`, the routing label |
+| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope |
+| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) |
+| `sig` | Schnorr signature over the event (so relays accept it) |
+| `created_at` | unix seconds |
+
+A subscriber filters with:
+
+```json
+{ "kinds": [4242], "#h": [""], "since": }
+```
+
+---
+
+## 4. Message lifecycle
+
+### Sending (`nostr-transport.ts` → `sendMessage`)
+
+1. Resolve the recipient handle → ed25519 primary via the directory
+ (`/v1/u/` — still served by the kez-chat server).
+2. `sealMessage(...)` → `SealedEnvelope` (identical to the server transport).
+3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`,
+ `content = JSON.stringify(envelope)`.
+4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`).
+5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts.
+ If every relay rejects, `sendMessage` throws `"no relay accepted the message"`.
+
+### Receiving (`streamInbox` + `pollInbox`)
+
+The global `inbox-service` runs both, exactly as it did for the server
+transport:
+
+- **`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.
+
+---
+
+## 5. Cursors & de-duplication
+
+Relays can resend events, and two transports (live sub + heartbeat poll) can
+deliver the same event. Both are made idempotent with per-handle
+`localStorage` state:
+
+- **`kez-chat:nostr:since:`** — the relay `since` filter (unix
+ seconds). Advances as events arrive, so reloads resume instead of
+ re-fetching. A fresh device defaults to `now − 7 days` to catch recent
+ history.
+- **`kez-chat:nostr:seen:`** — a capped set (last `500` ids) of
+ processed event ids. An id already in the set is skipped.
+
+### Mapping to `seq`
+
+The conversation store and the notification watermark were built around a
+monotonic server `seq`. Nostr has no per-recipient sequence, so the transport
+synthesizes one from the event's timestamp:
+
+```
+seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000)
+```
+
+This is monotonic-by-time across sessions (so the unread/notify watermark
+keeps working) and spreads messages within the same one-second granularity
+using the event id, avoiding collisions between distinct same-second messages.
+
+---
+
+## 6. Configuration
+
+Build-time, via Vite env (`.env` / `.env.local`):
+
+| Variable | Default | Meaning |
+|----------------------|-----------------------------------------------------------|------------------------------------------|
+| `VITE_TRANSPORT` | `server` | `server` or `nostr` — which pipe to use |
+| `VITE_NOSTR_RELAYS` | `wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net` | comma-separated relay list |
+
+> The **code default is `server`**, so `main` and other branches are
+> unaffected. This branch ships a committed `.env` that sets
+> `VITE_TRANSPORT=nostr`.
+
+### The facade (`transport.ts`)
+
+`inbox-service` and the `Messages` route import `sendMessage` / `pollInbox` /
+`streamInbox` / `decrypt` from `transport.ts`, which re-exports either the
+server (`messages.ts`) or Nostr (`nostr-transport.ts`) implementation based on
+`VITE_TRANSPORT`. Neither consumer knows which pipe is active. Switching
+transports is a one-line env change + rebuild.
+
+---
+
+## 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.
+
+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.
+
+---
+
+## 8. Known limitations
+
+- **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.
+
+---
+
+## 9. File map
+
+| 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`. |
+
+---
+
+## 10. How to verify it's really on Nostr
+
+Open DevTools → **Network → WS** on the running app:
+
+- You should see live WebSocket connections to the configured relays
+ (`wss://relay.damus.io`, etc.).
+- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives
+ as an incoming event frame.
+- You should **not** see `POST /v1/messages` or an SSE connection to
+ `/v1/inbox//stream` (those were the server transport).
+- The only remaining server call is the directory lookup `GET /v1/u/`.
diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json
index 3293800..55812e0 100644
--- a/kez-chat/web/package-lock.json
+++ b/kez-chat/web/package-lock.json
@@ -12,6 +12,7 @@
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
+ "@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",
@@ -3164,22 +3165,22 @@
}
},
"node_modules/@scure/bip39": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
- "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
+ "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
- "@noble/hashes": "2.0.1",
- "@scure/base": "2.0.0"
+ "@noble/hashes": "2.2.0",
+ "@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
- "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -3189,9 +3190,9 @@
}
},
"node_modules/@scure/bip39/node_modules/@scure/base": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
- "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
+ "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -6051,6 +6052,19 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/nostr-tools/node_modules/@scure/bip39": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
+ "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.0.1",
+ "@scure/base": "2.0.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json
index 0196881..913d2ca 100644
--- a/kez-chat/web/package.json
+++ b/kez-chat/web/package.json
@@ -14,6 +14,7 @@
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
+ "@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",
diff --git a/kez-chat/web/public/favicon.ico b/kez-chat/web/public/favicon.ico
index bfa4c20..c086dac 100644
Binary files a/kez-chat/web/public/favicon.ico and b/kez-chat/web/public/favicon.ico differ
diff --git a/kez-chat/web/src/lib/api.ts b/kez-chat/web/src/lib/api.ts
index ff06f50..b73e5d2 100644
--- a/kez-chat/web/src/lib/api.ts
+++ b/kez-chat/web/src/lib/api.ts
@@ -3,7 +3,7 @@
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
-import type { SignedRegistration } from "./kez.js";
+import type { SignedRegistration, SignedSigchainEvent } from "./kez.js";
export interface HandleResponse {
handle: string;
@@ -118,3 +118,53 @@ export async function register(
});
return unwrap(resp);
}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Chain service (sigchain storage server)
+//
+// `sigchainUrl` is the per-user base URL the chat server hands back in a
+// handle lookup (`HandleResponse.sigchain_url`), e.g.
+// `https://sig.kez.lat/v1/sigchains/ed25519/`. It's a different origin
+// than the chat server, so these talk to it directly (it sends permissive
+// CORS). The signatures are the source of truth — the chain service just
+// stores them.
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Fetch the full sigchain for a user as an ordered list of signed events.
+ * The chain service returns `application/jsonl` (one envelope per line);
+ * an unknown/empty chain yields an empty list.
+ */
+export async function getSigchain(
+ sigchainUrl: string,
+): Promise {
+ const resp = await fetch(sigchainUrl);
+ if (resp.status === 404) return [];
+ if (!resp.ok) {
+ throw new ApiError(resp.status, `getSigchain → HTTP ${resp.status}`);
+ }
+ const text = await resp.text();
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as SignedSigchainEvent);
+}
+
+/**
+ * Append one signed event to the user's sigchain on the chain service.
+ * The server re-runs the full integrity check (tag, primary, seq, prev,
+ * signature) and returns the recorded `{ seq, hash }` on success.
+ */
+export async function postSigchainEvent(
+ sigchainUrl: string,
+ event: SignedSigchainEvent,
+): Promise<{ seq: number; hash: string }> {
+ const base = sigchainUrl.replace(/\/$/, "");
+ const resp = await fetch(`${base}/events`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(event),
+ });
+ return unwrap(resp);
+}
diff --git a/kez-chat/web/src/lib/claims-store.ts b/kez-chat/web/src/lib/claims-store.ts
index 333102c..40acdf4 100644
--- a/kez-chat/web/src/lib/claims-store.ts
+++ b/kez-chat/web/src/lib/claims-store.ts
@@ -17,8 +17,25 @@ export interface StoredClaim {
notes?: string;
/** Latest verification result, if we've checked. */
last_verify?: VerifyResult;
+ // ── Chain-service mirror (sigchain) ──
+ /** Chain-service base URL this claim was mirrored to (its sigchain URL). */
+ chain_service?: string;
+ /** Sequence number of the sigchain `add` event for this claim. */
+ sigchain_seq?: number;
+ /** Sync state of the sigchain mirror. */
+ sigchain_status?: "synced" | "error";
+ /** Error detail when sigchain_status === "error". */
+ sigchain_error?: string;
}
+/** Fields the caller may patch after a chain-service sync attempt. */
+export type SigchainSyncPatch = Partial<
+ Pick<
+ StoredClaim,
+ "chain_service" | "sigchain_seq" | "sigchain_status" | "sigchain_error"
+ >
+>;
+
export async function listClaims(): Promise {
return (await get(KEY)) ?? [];
}
@@ -50,6 +67,18 @@ export async function setVerifyResult(
}
}
+/** Record the outcome of a chain-service (sigchain) sync for a claim. */
+export async function setSigchainSync(
+ id: string,
+ patch: SigchainSyncPatch,
+): Promise {
+ const existing = await listClaims();
+ const target = existing.find((c) => c.id === id);
+ if (!target) return;
+ Object.assign(target, patch);
+ await set(KEY, existing);
+}
+
export async function removeClaim(id: string): Promise {
const existing = await listClaims();
await set(
diff --git a/kez-chat/web/src/lib/identity-store.ts b/kez-chat/web/src/lib/identity-store.ts
index c12487a..0a29c93 100644
--- a/kez-chat/web/src/lib/identity-store.ts
+++ b/kez-chat/web/src/lib/identity-store.ts
@@ -33,6 +33,12 @@ interface StoredIdentity {
salt: string; // hex, 16 bytes
nonce: string; // hex, 12 bytes
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
+ // Optional: encrypted 12-word recovery phrase (added in the mnemonic
+ // rollout). New accounts have both; pre-mnemonic accounts have only
+ // the seed. Encrypted under the SAME PBKDF2 key as the seed; uses its
+ // own nonce because AES-GCM reuse is unsafe.
+ phrase_nonce?: string; // hex, 12 bytes
+ phrase_ciphertext?: string; // hex; AES-GCM(utf8(phrase))
// Metadata:
created_at: string; // RFC3339
}
@@ -42,6 +48,8 @@ export interface UnlockedIdentity {
server: string;
primary: Identity;
seed: Uint8Array;
+ /** 12-word recovery phrase. Absent for pre-mnemonic accounts. */
+ phrase?: string;
}
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
@@ -76,6 +84,15 @@ export async function hasStoredIdentity(): Promise {
return !!stored;
}
+/** True iff the stored identity carries an encrypted recovery phrase
+ * (created via the 12-word-mnemonic flow). Used to distinguish a
+ * truly-legacy hex-only account from a phrase account that just isn't
+ * available in the current session (e.g. biometric unlock didn't decrypt it). */
+export async function hasStoredPhrase(): Promise {
+ const stored = await get(IDB_KEY);
+ return !!(stored?.phrase_ciphertext && stored?.phrase_nonce);
+}
+
export async function loadStoredIdentityMeta(): Promise<
Pick | null
> {
@@ -91,6 +108,11 @@ export async function saveIdentity(opts: {
primary: Identity;
seed: Uint8Array;
passphrase: string;
+ /** Optional 12-word phrase — stored encrypted so the user can re-display
+ * it later. The seed is derived from the phrase one-way (SHA-256 with
+ * domain tag); we can't recover the phrase from the seed, hence
+ * storing it explicitly. */
+ phrase?: string;
}): Promise {
const salt = crypto.getRandomValues(new Uint8Array(16));
const nonce = crypto.getRandomValues(new Uint8Array(12));
@@ -113,6 +135,21 @@ export async function saveIdentity(opts: {
ciphertext: bytesToHex(ciphertext),
created_at: new Date().toISOString(),
};
+
+ if (opts.phrase) {
+ // Fresh nonce — AES-GCM nonce reuse under the same key is fatal.
+ const phraseNonce = crypto.getRandomValues(new Uint8Array(12));
+ const phraseCt = new Uint8Array(
+ await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv: asBuffer(phraseNonce) },
+ key,
+ asBuffer(new TextEncoder().encode(opts.phrase)),
+ ),
+ );
+ record.phrase_nonce = bytesToHex(phraseNonce);
+ record.phrase_ciphertext = bytesToHex(phraseCt);
+ }
+
await set(IDB_KEY, record);
}
@@ -144,11 +181,34 @@ export async function unlockIdentity(
`unlocked seed is ${plaintext.length} bytes, expected 32`,
);
}
+
+ let phrase: string | undefined;
+ if (stored.phrase_nonce && stored.phrase_ciphertext) {
+ try {
+ const pn = hexToBytes(stored.phrase_nonce);
+ const pc = hexToBytes(stored.phrase_ciphertext);
+ const pBytes = new Uint8Array(
+ await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: asBuffer(pn) },
+ key,
+ asBuffer(pc),
+ ),
+ );
+ phrase = new TextDecoder().decode(pBytes);
+ } catch {
+ // The seed unlocked fine, so the passphrase is right; a phrase
+ // decrypt failure here would point at IDB corruption. Surface it
+ // by logging, but don't block unlock — the user can still chat.
+ console.error("identity-store: phrase decrypt failed (seed OK)");
+ }
+ }
+
return {
handle: stored.handle,
server: stored.server,
primary: stored.primary,
seed: plaintext,
+ phrase,
};
}
diff --git a/kez-chat/web/src/lib/kez.ts b/kez-chat/web/src/lib/kez.ts
index ac21362..393cd59 100644
--- a/kez-chat/web/src/lib/kez.ts
+++ b/kez-chat/web/src/lib/kez.ts
@@ -7,7 +7,7 @@
// reconsider depending on the Node port.
import { ed25519 } from "@noble/curves/ed25519";
-import { sha512 } from "@noble/hashes/sha2";
+import { sha256, sha512 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import canonicalize from "canonicalize";
@@ -19,6 +19,8 @@ export const CLAIM_TYPE = "kez.claim";
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
export const REGISTRATION_ENVELOPE = "handle_registration";
export const CLAIM_ENVELOPE = "claim";
+export const SIGCHAIN_EVENT_TYPE = "kez.sigchain.event";
+export const SIGCHAIN_ENVELOPE = "sigchain_event";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const FORMAT_VERSION = 1;
export const COMPACT_PROOF_PREFIX = "kez:z1:";
@@ -70,6 +72,33 @@ export interface SignedRegistration {
signature: SignatureBlock;
}
+export type SigchainOp = "add" | "revoke";
+
+export interface SigchainEventPayload {
+ type: typeof SIGCHAIN_EVENT_TYPE;
+ version: number;
+ primary: Identity;
+ seq: number;
+ /** `sha256:` of the prior envelope's JCS bytes. Omitted iff seq === 0. */
+ prev?: string;
+ created_at: string;
+ op: SigchainOp;
+ /** Op-specific fields, e.g. `{ subject, proof_url? }`. */
+ payload: Record;
+}
+
+export interface SignedSigchainEvent {
+ kez: typeof SIGCHAIN_ENVELOPE;
+ payload: SigchainEventPayload;
+ signature: SignatureBlock;
+}
+
+/** Where the next event sits in the chain: its `seq` and `prev` hash. */
+export interface ChainCursor {
+ seq: number;
+ prev?: string;
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Key generation + restoration
// ─────────────────────────────────────────────────────────────────────────────
@@ -137,6 +166,22 @@ function signWith(
};
}
+/**
+ * RFC 3339 UTC timestamp at SECOND precision (no fractional part), e.g.
+ * `2026-05-19T18:00:00Z` — matching the SPEC.md examples.
+ *
+ * Why drop the milliseconds `toISOString()` emits: the Rust kez-core
+ * verifier (used by the chat server and the chain service) deserializes
+ * `created_at` into a `chrono::DateTime` and re-serializes it to
+ * re-canonicalize for signature checking. Chrono's `AutoSi` seconds format
+ * drops a zero fractional part, so a browser-signed `…:00.000Z` would
+ * re-serialize as `…:00Z` and fail verification ~1 in 1000. Emitting whole
+ * seconds round-trips byte-stably through chrono for every timestamp.
+ */
+export function rfc3339Utc(date: Date = new Date()): string {
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Verification
// ─────────────────────────────────────────────────────────────────────────────
@@ -211,7 +256,7 @@ export function signClaim(
version: FORMAT_VERSION,
subject,
primary: signer.identity,
- created_at: createdAt.toISOString(),
+ created_at: rfc3339Utc(createdAt),
};
return {
kez: CLAIM_ENVELOPE,
@@ -233,7 +278,7 @@ export function signRegistration(
handle,
primary: signer.identity,
server,
- created_at: createdAt.toISOString(),
+ created_at: rfc3339Utc(createdAt),
};
return {
kez: REGISTRATION_ENVELOPE,
@@ -242,6 +287,59 @@ export function signRegistration(
};
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Sigchain events (Spec §8)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * `sha256:` of the JCS-canonicalized bytes of the WHOLE signed envelope
+ * (not just the payload). This is what the next event's `prev` points at.
+ */
+export function sigchainEventHash(event: SignedSigchainEvent): string {
+ return `sha256:${bytesToHex(sha256(canonicalBytes(event)))}`;
+}
+
+/**
+ * Given the current (validated, ordered) chain, return where the next event
+ * goes: `seq` one past the head, and `prev` = the head's envelope hash.
+ * An empty chain yields `{ seq: 0 }` with no `prev` (per spec, seq 0 has none).
+ */
+export function nextChainCursor(events: SignedSigchainEvent[]): ChainCursor {
+ if (events.length === 0) return { seq: 0 };
+ const head = events[events.length - 1];
+ return { seq: head.payload.seq + 1, prev: sigchainEventHash(head) };
+}
+
+/**
+ * Build + sign a sigchain event (add/revoke a subject) at the given cursor.
+ * Insertion order matches the Rust/Node/Python impls (type, version, primary,
+ * seq, prev?, created_at, op, payload) so the JSONL form lines up; JCS sorts
+ * keys for signing regardless.
+ */
+export function signSigchainEvent(
+ signer: Ed25519Identity,
+ op: SigchainOp,
+ opPayload: Record,
+ cursor: ChainCursor,
+ createdAt: Date = new Date(),
+): SignedSigchainEvent {
+ const payload: SigchainEventPayload = {
+ type: SIGCHAIN_EVENT_TYPE,
+ version: FORMAT_VERSION,
+ primary: signer.identity,
+ seq: cursor.seq,
+ ...(cursor.prev !== undefined ? { prev: cursor.prev } : {}),
+ created_at: rfc3339Utc(createdAt),
+ op,
+ payload: opPayload,
+ };
+ return {
+ kez: SIGCHAIN_ENVELOPE,
+ payload,
+ signature: signWith(payload, signer),
+ };
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/kez-chat/web/src/lib/mnemonic.ts b/kez-chat/web/src/lib/mnemonic.ts
new file mode 100644
index 0000000..8349b25
--- /dev/null
+++ b/kez-chat/web/src/lib/mnemonic.ts
@@ -0,0 +1,111 @@
+// Browser-native KEZ mnemonic helpers — mirrors:
+// • rust/crates/kez-core/src/mnemonic.rs
+// • nodejs/packages/kez-core/src/mnemonic.ts
+// • python/kez/mnemonic.py
+//
+// We use 12-word phrases as the user-facing backup form in the chat app
+// (shorter to write down than the 64-char hex seed). The seed itself is
+// derived deterministically from the phrase, so the phrase IS the
+// canonical backup.
+//
+// Semantics (must match the other implementations byte-for-byte):
+// • 12 words → 16 bytes of BIP-39 entropy → seed = SHA-256(DOMAIN_TAG ||
+// entropy) where DOMAIN_TAG = "kez-bip39-12-v1" (15 ASCII bytes).
+// The derivation is one-way: you can't recover the phrase from the
+// seed. That's why we ALSO store the phrase (encrypted at rest) so
+// the user can re-display it later via Settings → Reveal phrase.
+// • 24 words → entropy IS the 32-byte seed (bijection). Accepted on
+// restore for parity with the CLI, but the chat app generates 12-word
+// phrases by default.
+//
+// NB: We deliberately do NOT use BIP-39's PBKDF2 to_seed function. That
+// produces a 64-byte BIP-32 wallet seed, which is the wrong primitive
+// for KEZ's single-identity-per-phrase model.
+
+import {
+ entropyToMnemonic,
+ generateMnemonic as bip39Generate,
+ mnemonicToEntropy,
+} from "@scure/bip39";
+import { wordlist } from "@scure/bip39/wordlists/english.js";
+import { sha256 } from "@noble/hashes/sha2";
+import { identityFromSeed, type Ed25519Identity } from "./kez.js";
+
+const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
+
+/** Generate a fresh 12-word phrase. */
+export function generateMnemonic12(): string {
+ // BIP-39 strength: 128 bits → 12 words.
+ return bip39Generate(wordlist, 128);
+}
+
+/**
+ * Decode a 12- or 24-word phrase into a 32-byte Ed25519 seed. Auto-detects
+ * length. Whitespace-tolerant (trims, collapses runs of spaces).
+ */
+export function seedFromMnemonic(phrase: string): Uint8Array {
+ const trimmed = phrase.trim().replace(/\s+/g, " ");
+ let entropy: Uint8Array;
+ try {
+ entropy = mnemonicToEntropy(trimmed, wordlist);
+ } catch (e) {
+ throw new Error(`invalid recovery phrase: ${(e as Error).message}`);
+ }
+ if (entropy.length === 32) {
+ // 24-word: entropy is the seed.
+ return new Uint8Array(entropy);
+ }
+ if (entropy.length === 16) {
+ // 12-word: SHA-256 of (DOMAIN_TAG || entropy).
+ const buf = new Uint8Array(DOMAIN_TAG_12.length + entropy.length);
+ buf.set(DOMAIN_TAG_12, 0);
+ buf.set(entropy, DOMAIN_TAG_12.length);
+ return sha256(buf);
+ }
+ throw new Error(
+ `mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
+ );
+}
+
+/** Inverse for the 24-word case ONLY. Throws on any other length. */
+export function mnemonicFromSeed24(seed: Uint8Array): string {
+ if (seed.length !== 32) {
+ throw new Error(
+ `mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
+ );
+ }
+ return entropyToMnemonic(seed, wordlist);
+}
+
+/** Construct a KEZ Ed25519 identity from a phrase. */
+export function ed25519FromMnemonic(phrase: string): Ed25519Identity {
+ return identityFromSeed(seedFromMnemonic(phrase));
+}
+
+/**
+ * Generate a fresh identity AND the phrase that derives it. The phrase
+ * is the canonical user-facing backup; the identity carries the seed
+ * for crypto ops.
+ */
+export function generateIdentityWithMnemonic(): {
+ identity: Ed25519Identity;
+ phrase: string;
+} {
+ const phrase = generateMnemonic12();
+ const identity = ed25519FromMnemonic(phrase);
+ return { identity, phrase };
+}
+
+/**
+ * Cheap input check (does the typed text look like a valid phrase?) so
+ * the restore form can give live feedback. Returns true only if the
+ * phrase parses + checksum-validates.
+ */
+export function isValidMnemonic(phrase: string): boolean {
+ try {
+ seedFromMnemonic(phrase);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/kez-chat/web/src/lib/sigchain-service.ts b/kez-chat/web/src/lib/sigchain-service.ts
new file mode 100644
index 0000000..e30935e
--- /dev/null
+++ b/kez-chat/web/src/lib/sigchain-service.ts
@@ -0,0 +1,117 @@
+// Mirrors the user's claims into their sigchain on the chain service.
+//
+// The chain service (the rust-sig-server) is where a user's append-only,
+// signed sigchain lives. When the user adds a claim we append an `add`
+// event for that subject; when they remove a claim we append a `revoke`.
+// This keeps the sigchain an accurate, verifiable record of what the user
+// currently claims — the spec's mechanism (SPEC.md §8), not a per-claim
+// field.
+//
+// We read the current chain to compute the next `seq` + `prev` hash so
+// appends stay monotonic even across devices, then sign locally (the
+// chain service never sees the seed) and POST the event.
+
+import {
+ identityFromSeed,
+ nextChainCursor,
+ sigchainEventHash,
+ signSigchainEvent,
+ type Identity,
+ type SignedSigchainEvent,
+ type SigchainOp,
+} from "./kez.js";
+import { getSigchain, lookup, postSigchainEvent } from "./api.js";
+
+export interface SigchainSyncResult {
+ /** The chain-service base URL the event was posted to. */
+ chainService: string;
+ /** Sequence number of the event (or the existing add, when noop). */
+ seq: number;
+ /** `sha256:` head hash of the chain. */
+ hash: string;
+ /**
+ * True when the chain already reflected the desired state, so no new
+ * event was posted (e.g. re-syncing a claim already on the chain, or
+ * removing a claim that was never added). Makes sync idempotent — safe
+ * to run repeatedly and safe for claims created before sigchain support.
+ */
+ noop: boolean;
+}
+
+/**
+ * Walk the chain and return the currently-active subjects (added and not
+ * later revoked), mapped to the `seq` of the `add` that activated each.
+ * This is how we keep appends idempotent.
+ */
+function activeSubjects(events: SignedSigchainEvent[]): Map {
+ const active = new Map();
+ for (const event of events) {
+ const subject = (event.payload.payload as { subject?: unknown })?.subject;
+ if (typeof subject !== "string") continue;
+ if (event.payload.op === "add") active.set(subject, event.payload.seq);
+ else if (event.payload.op === "revoke") active.delete(subject);
+ }
+ return active;
+}
+
+/**
+ * Resolve the user's chain-service URL (their sigchain base URL) from their
+ * handle. The chat server constructs this from the configured sig-server and
+ * the user's primary key.
+ */
+export async function resolveChainService(handle: string): Promise {
+ const record = await lookup(handle);
+ if (!record.sigchain_url) {
+ throw new Error("server did not return a sigchain URL for this handle");
+ }
+ return record.sigchain_url;
+}
+
+/**
+ * Append an add/revoke event for `subject` to the user's sigchain on the
+ * chain service. Reads the current chain to compute the next cursor, signs
+ * the event with the user's seed, and POSTs it.
+ */
+export async function appendSubjectEvent(opts: {
+ seed: Uint8Array;
+ chainService: string;
+ subject: Identity;
+ op: SigchainOp;
+ /** Optional URL where the channel proof is published (spec: add.proof_url). */
+ proofUrl?: string;
+}): Promise {
+ const signer = identityFromSeed(opts.seed);
+ const events = await getSigchain(opts.chainService);
+ const active = activeSubjects(events);
+ const head = events.length > 0 ? events[events.length - 1] : null;
+ const headHash = head ? sigchainEventHash(head) : "";
+
+ // Idempotency: don't duplicate state the chain already has. Re-adding an
+ // already-active subject, or revoking one that isn't active, is a no-op.
+ if (opts.op === "add" && active.has(opts.subject)) {
+ return {
+ chainService: opts.chainService,
+ seq: active.get(opts.subject)!,
+ hash: headHash,
+ noop: true,
+ };
+ }
+ if (opts.op === "revoke" && !active.has(opts.subject)) {
+ return {
+ chainService: opts.chainService,
+ seq: head ? head.payload.seq : -1,
+ hash: headHash,
+ noop: true,
+ };
+ }
+
+ const opPayload: Record = { subject: opts.subject };
+ if (opts.op === "add" && opts.proofUrl) {
+ opPayload.proof_url = opts.proofUrl;
+ }
+
+ const cursor = nextChainCursor(events);
+ const event = signSigchainEvent(signer, opts.op, opPayload, cursor);
+ const { seq, hash } = await postSigchainEvent(opts.chainService, event);
+ return { chainService: opts.chainService, seq, hash, noop: false };
+}
diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte
index 30761e5..740b655 100644
--- a/kez-chat/web/src/routes/AddClaim.svelte
+++ b/kez-chat/web/src/routes/AddClaim.svelte
@@ -9,7 +9,11 @@
toCompact,
type SignedClaimEnvelope,
} from "../lib/kez.js";
- import { addClaim } from "../lib/claims-store.js";
+ import { addClaim, setSigchainSync } from "../lib/claims-store.js";
+ import {
+ appendSubjectEvent,
+ resolveChainService,
+ } from "../lib/sigchain-service.js";
import { session } from "../lib/store.svelte.js";
import {
hasNip07,
@@ -154,6 +158,14 @@
| { status: "error"; message: string }
>({ status: "idle" });
+ /** Outcome of mirroring this claim into the user's sigchain on the chain service. */
+ let sigchainSync = $state<
+ | { status: "idle" }
+ | { status: "pending" }
+ | { status: "ok"; seq: number; noop: boolean }
+ | { status: "error"; message: string }
+ >({ status: "idle" });
+
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
const nip07Available = $derived(hasNip07());
@@ -226,13 +238,15 @@
}
async function saveAndDone() {
- if (!envelope || !selected) return;
+ if (!envelope || !selected || !session.unlocked) return;
+ const id = crypto.randomUUID();
+ const subject = envelope.payload.subject;
try {
// $state wraps `envelope` in a deep Proxy; structuredClone (used
// by idb-keyval) can't clone proxies and throws DataCloneError.
// $state.snapshot returns a plain, cloneable object.
await addClaim({
- id: crypto.randomUUID(),
+ id,
envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
channel: selected.key,
});
@@ -240,6 +254,36 @@
} catch (e) {
console.error("saveAndDone failed", e);
alert(`Failed to save claim: ${(e as Error).message}`);
+ return;
+ }
+ // Mirror the claim into the user's sigchain on the chain service: append
+ // a signed `add` event for this subject. Best-effort — the claim is
+ // already saved locally; if the chain service is unreachable we record
+ // the error and the user can retry from the Claims page later.
+ sigchainSync = { status: "pending" };
+ try {
+ const chainService = await resolveChainService(session.unlocked.handle);
+ const proofUrl =
+ nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
+ const result = await appendSubjectEvent({
+ seed: session.unlocked.seed,
+ chainService,
+ subject,
+ op: "add",
+ proofUrl,
+ });
+ await setSigchainSync(id, {
+ chain_service: result.chainService,
+ sigchain_seq: result.seq,
+ sigchain_status: "synced",
+ });
+ sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
+ } catch (e) {
+ await setSigchainSync(id, {
+ sigchain_status: "error",
+ sigchain_error: (e as Error).message,
+ });
+ sigchainSync = { status: "error", message: (e as Error).message };
}
}
@@ -452,6 +496,28 @@
Once you've published the proof on that channel, come back to the
Claims page and mark it published.
+
+
+
+ {#if sigchainSync.status === "pending"}
+
⏳ Updating your sigchain on the chain service…
+ {:else if sigchainSync.status === "ok"}
+
+ {#if sigchainSync.noop}
+ ⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
+ {:else}
+ ⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
+ {/if}
+
+ {:else if sigchainSync.status === "error"}
+
+ ⚠ Couldn't update your sigchain on the chain service:
+ {sigchainSync.message}. The claim is saved locally — retry the
+ sigchain sync from the Claims page.
+