Initial implementation of KEZ — protocol, two impls, and storage server

KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.

Layout
------
- SPEC.md            Language-agnostic protocol spec (v0.2)
- rust/              Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/            TypeScript port at full parity
- rust-sig-server/   Optional axum + SQLite storage server for sigchains
- crosstest.sh       Cross-implementation interop harness

Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
  Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
  Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
    dns:        system resolver, _kez.<domain> TXT records
    github:     public gist scan + <user>/<user> profile README fallback
    nostr:      kind-30078 events from default relays
    bluesky:    public AppView author feed
    ap:         WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
    kez identity new [--key-type nostr|ed25519]
    kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
    kez claim dns <domain>     (--nsec | --ed25519-seed)
    kez verify file <path>
    kez verify id <identifier>
    kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
  stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
  record print), nostr (kind-30078 wrapping event).

kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.

- No auth — the cryptography is the access control. The server validates
  every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
  201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
  GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
  running more later (clients gain a configurable list, verifiers
  reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
  server is unavailable.

Tests
-----
- rust/                 99 tests
- rust-sig-server/      10 integration tests (real HTTP, real SQLite)
- nodejs/               91 tests (vitest)
- crosstest.sh          19 cross-impl scenarios — proves JCS bytes,
                        Schnorr + Ed25519 sigs, all four claim encodings,
                        and the sigchain JSONL bundle are byte-compatible
                        between Rust and Node in both directions.

What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
  just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
This commit is contained in:
Tudisco 2026-05-24 14:41:00 -06:00
commit d0db6f00f1
72 changed files with 18626 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Rust
target/
**/*.rs.bk
Cargo.lock.bak
# Node
node_modules/
dist/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local runtime state
*.db
*.db-journal
*.db-wal
*.db-shm
.kez/
kez-sigchains.db
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
*.swo
*~
# Cross-test artifacts
/tmp/

103
README.md Normal file
View File

@ -0,0 +1,103 @@
# KEZ
KEZ is a portable, decentralized identity graph. It lets a person say:
> "These accounts, keys, domains, and identities are all me."
…without depending on any central authority. Every connection is proven by a
cryptographic signature against a key the user already controls (a nostr key,
an Ed25519 key, etc.), and the proofs are published in places only the
claimed account itself can publish to (their gist, their DNS, their nostr
relay event). Anyone can verify the graph without trusting a server.
## Repository layout
```
.
├── SPEC.md ← The protocol. Language-agnostic, normative.
├── rust/ ← Rust implementation (kez-core, kez-channels, kez-cli)
├── nodejs/ ← TypeScript/Node 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.
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
DNS/hosting/nostr, but **never required**; the protocol stays decentralized.
## Quick start
### Rust
```sh
cd rust
cargo build
cargo test # 81 tests
cargo run -p kez-cli -- verify id github:jason
```
Full guide: [`rust/README.md`](rust/README.md).
### Node.js
```sh
cd nodejs
npm install
npm test # 72 tests
npm run cli -- verify id github:jason
```
Full guide: [`nodejs/README.md`](nodejs/README.md).
## Cross-testing
```sh
./crosstest.sh
```
Runs 19 scenarios that swap implementations at the artifact boundary:
| # | Scenario |
|---|---|
| 12 | nostr-signed JSON claim, both directions |
| 34 | nostr-signed compact claim, both directions |
| 56 | nostr-signed markdown claim, both directions |
| 78 | nostr-signed DNS zone form, both directions |
| 910 | ed25519-signed JSON claim, both directions |
| 1112 | ed25519-signed compact claim, both directions |
| 1314 | 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 |
If all 19 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.
Pass `-v` for verbose output (echoes intermediate commands and proofs).
## What ships in v0.2
- **Five channel plugins** in each implementation: `dns:`, `github:`,
`nostr:`, `bluesky:`, `ap:` (alias `mastodon:`).
- **Four wire encodings**: JSON, compact, Markdown fence, DNS TXT.
- **Two primary-key algorithms**: nostr/secp256k1 Schnorr (BIP-340) and
Ed25519 (RFC 8032).
- **JCS (RFC 8785)** canonicalization for everything signed.
- **No API keys required for any channel.**
## What's not done yet
Tracked in both [`rust/README.md`](rust/README.md#whats-not-done-yet) and the
spec:
- Sigchain walker (types exist; no append/walk/revoke flow yet).
- `expires_at` enforcement during verify.
- Typed `VerificationStatus.status` reflecting the five failure modes.
## License
Dual-licensed under MIT or Apache-2.0.

366
SPEC.md Normal file
View File

@ -0,0 +1,366 @@
# KEZ Specification
**Status:** Draft v0.2
**Date:** 2026-05-23
KEZ is a portable, decentralized identity graph. It lets a person say:
> "These accounts, keys, domains, and identities are all me."
…without depending on any central authority to bless the claim. Verification is done by cryptographic signatures against keys the user already controls.
This document is the language-agnostic specification. Implementations (Rust, and others) MUST conform to the formats and behaviors defined here.
---
## 1. Core Concepts
### 1.1 Primary Key
The user chooses one key as their current "main identity key." KEZ does not invent a new key type — it reuses keys people already hold. Supported key types in v1:
- `nostr` / `secp256k1` (Schnorr signatures, BIP-340)
- `ed25519`
Future, non-normative: passkeys, Bluesky DID keys, Ethereum keys, GPG keys.
### 1.2 Claim
A signed JSON object asserting that the primary key also controls some other identity (a GitHub account, a domain, a Bluesky handle, etc.).
### 1.3 Proof
A claim, plus evidence that it was published in a location only the claimed identity can publish to (e.g. the GitHub user's own gist, the domain's DNS).
### 1.4 Sigchain
A signed, append-only log of identity events (adds, revokes, key rotations) owned by a primary key.
### 1.5 Identity Graph
The set of all currently-active proofs reachable from a starting identifier, walked through the sigchain.
---
## 2. Identifiers
A KEZ identifier is always `system:identifier` — the system prefix is required, never optional. This lets a human glance at an identifier and know where the key/account lives.
Canonical examples:
| System | Example |
|------------|--------------------------------------|
| `nostr` | `nostr:npub1abc...` |
| `github` | `github:jason` |
| `dns` | `dns:jason.example.com` |
| `bluesky` | `bluesky:jason.bsky.social` |
| `did` | `did:plc:abc...` |
| `ens` | `ens:jason.eth` |
| `mastodon` | `mastodon:@jason@server.social` |
| `farcaster`| `farcaster:fid:12345` |
| `web` | `web:https://jason.example.com` |
A bare `npub1...` (no `nostr:` prefix) is **not** a valid KEZ identifier. CLI tools MAY accept it as input ergonomics, but they MUST normalize it to `nostr:npub1...` before signing, publishing, or storing.
Identifier strings are case-sensitive except where the underlying system normalizes case (e.g. DNS — lowercased before comparison).
KEZ is **not a phone book.** It cannot resolve "Jason" from a human name. A verifier always needs a concrete starting identifier.
---
## 3. The Claim Format
A claim is a UTF-8 JSON object with the following required fields:
```json
{
"type": "kez.claim",
"version": 1,
"primary": "nostr:npub1abc...",
"subject": "github:jason",
"created_at": "2026-05-19T18:00:00Z"
}
```
KEZ uses dotted, namespaced type strings throughout: `kez.claim`, `kez.sigchain.event`, `kez.device`, etc.
Optional fields:
- `expires_at` — RFC 3339 timestamp; after this time the claim is treated as expired.
- `nonce` — opaque string to prevent replay across publishing channels.
- `note` — short human-readable note (≤256 chars).
### 3.1 Canonicalization
For signing and verification, the claim MUST be serialized as **JCS** (RFC 8785, JSON Canonicalization Scheme). Implementations MUST NOT rely on incidental key ordering.
### 3.2 Signature Envelope
A signed claim is wrapped in:
```json
{
"kez": "claim",
"payload": { ... the claim object ... },
"signature": {
"alg": "nostr-secp256k1-schnorr-sha256-jcs",
"key": "nostr:npub1abc...",
"sig": "<hex signature over JCS(payload)>"
}
}
```
- `kez` (top-level) tags the envelope kind: `"claim"`, `"sigchain_event"`, etc. This lets generic tooling route envelopes without parsing the payload.
- `alg` is the **full signature suite**, identifying key family + hash + canonicalization. Defined suites in v1:
- `nostr-secp256k1-schnorr-sha256-jcs` — BIP-340 Schnorr over secp256k1, SHA-256, JCS.
- `ed25519-sha512-jcs` — Ed25519 (RFC 8032), JCS-canonicalized payload.
- `key` MUST equal `payload.primary` (same key, expressed as a `system:identifier` string in the encoding native to its system).
- `sig` is the signature, **hex-encoded** (lowercase, no `0x` prefix). The signature covers `JCS(payload)`, not the envelope.
The on-disk file extension for a signed claim envelope is `.kez`. The MIME type is `application/vnd.kez+json`.
---
## 4. Proof Encodings
A "proof" is a signed claim envelope, encoded for transport over a specific channel. KEZ defines four encodings; the same envelope round-trips losslessly between them.
### 4.1 JSON
The signature envelope serialized as standard JSON (pretty or compact). Native form. Used for HTTP fetches, `.well-known/kez.json`, and developer tooling.
### 4.2 Compact (`kez:z1:`)
A URL-safe, copy-pasteable form designed for QR codes, DNS TXT records, chat messages, and other tight channels:
```
kez:z1:<base64url-no-pad(zstd(json-envelope))>
```
- `z1` is the compact format version. Future versions (`z2`, …) MAY change the compression or framing.
- Compression: zstd, default level 3.
- Encoding: base64url (RFC 4648 §5), **no padding**.
- The decoded payload MUST be a valid signature envelope per section 3.2.
Implementations MUST support encoding and decoding `kez:z1:`.
### 4.3 Markdown
Human-readable proof file suitable for a GitHub gist, profile README, or any Markdown-rendering surface. The machine-readable proof lives inside a fenced code block tagged ```` ```kez ````:
````markdown
# KEZ Proof
This account publishes a signed KEZ identity claim.
- Primary: `nostr:npub1abc...`
- Subject: `github:jason`
- Created: `2026-05-19T18:00:00Z`
```kez
{
"kez": "claim",
"payload": { ... },
"signature": { ... }
}
```
````
Parsers MUST locate the first ```` ```kez ```` fence, take the contents up to the next ```` ``` ````, trim whitespace, and parse as JSON. Surrounding prose is informational only.
### 4.4 DNS TXT
For `dns:` subjects, the proof is published as a TXT record at:
```
_kez.<domain>
```
The TXT record value is the **compact encoding** (section 4.2):
```
kez:z1:KLUv_QBY...
```
Raw JSON is NOT a valid DNS TXT proof — JSON exceeds the 255-byte TXT segment limit too easily and complicates quoting. The compact form fits common claims in a single segment.
If a claim is too large for a single TXT record even compressed, implementations MUST publish a `web:` proof instead and add a `dns:` claim pointing at the domain — chaining, not splitting.
---
## 5. Publishing Proofs
The claim must be published somewhere only the claimed identity can publish to. Each `system` defines its own channel(s) and default encoding:
| System | Channel | Encoding |
|------------|------------------------------------------------------------|------------------|
| `github` | Public gist owned by the user, or `README` on profile repo | Markdown (§4.3) |
| `dns` | TXT record at `_kez.<domain>` | Compact (§4.2) |
| `web` | `https://<domain>/.well-known/kez.json` | JSON (§4.1) |
| `nostr` | Nostr event of kind `30078` (parameterized replaceable) | JSON (§4.1) |
| `bluesky` | Public post or record on the user's PDS | JSON or Markdown |
| `mastodon` | Public profile post or pinned status | Markdown (§4.3) |
Verifying a proof always means: **fetch from the channel the identity itself controls, then verify the signature against the primary key.** A claim that signs correctly but is not found at the identity's own channel is **not a valid proof.**
---
## 6. The Sigchain
The sigchain is an append-only, signed log of events for a single primary key. Each entry's payload is:
```json
{
"type": "kez.sigchain.event",
"version": 1,
"primary": "nostr:npub1abc...",
"seq": 7,
"prev": "sha256:<hex SHA-256 of the prior entry's JCS envelope>",
"created_at": "2026-05-20T12:00:00Z",
"op": "add" | "revoke" | "rotate" | "add_device",
"payload": { ... op-specific fields ... }
}
```
Wrapped in the same signature envelope as a claim, with top-level tag `"kez": "sigchain_event"`.
### 6.1 Operations
- **`add`** — payload: `{ "subject": "github:jason", "proof_url": "..." }`. Adds an identity to the graph.
- **`revoke`** — payload: `{ "subject": "github:jason" }`. Removes a previously-added identity.
- **`rotate`** — payload: `{ "new_primary": "nostr:npub1xyz...", "new_key_sig": "<hex>" }`. Rotates the primary key. `new_key_sig` is the new key signing over the JCS of the rotation event with `new_key_sig` omitted, proving the rotation is two-way authorized.
- **`add_device`** — payload: `{ "device_key": "...", "label": "..." }`. Adds a secondary key authorized to sign on behalf of this primary.
### 6.2 Integrity Rules
- `seq` MUST be strictly monotonic, starting at 0.
- `prev` MUST equal `sha256:` + hex SHA-256 of the **JCS-canonicalized envelope** (not just payload) of the prior entry. The first entry (`seq: 0`) has no `prev` field.
- A verifier MUST reject any sigchain with a broken hash chain, missing `prev`, or non-monotonic `seq`.
- Multiple sigchain copies MAY exist across stores; the longest valid chain wins. If two valid chains diverge at the same `seq` with different envelope hashes, the verifier MUST report a **fork** rather than silently picking one.
### 6.3 Key Rotation
After a `rotate`, all subsequent entries MUST be signed by the new primary, and each entry's `primary` field MUST reflect the new key. A verifier walks rotations by following the chain: the old key's `rotate` event authorizes the new key, and the new key's `new_key_sig` over that same event closes the loop.
---
## 7. Storage
KEZ is store-agnostic. The verifier MUST be able to fetch sigchains and proofs from any of:
- nostr relays
- GitHub (gists, repos)
- HTTP(S) URLs (websites, `/.well-known/kez.json`)
- DNS (TXT records)
- IPFS
- Bluesky PDS
- Local filesystem (for development/testing)
There is no required KEZ server. Implementations SHOULD support multiple stores in parallel and treat them as mirrors, not sources of truth — the signatures are the source of truth.
---
## 8. Verification
### 8.1 Verifier Input
```
kez verify <identifier>
```
Example: `kez verify github:jason`
### 8.2 Algorithm
1. **Fetch the proof** at the channel native to the input identifier's system (real network fetch — see §8.5).
2. **Decode** the proof from its on-the-wire encoding (JSON, compact, or Markdown) into a signature envelope.
3. **Verify signature** against the embedded primary key over `JCS(payload)`.
4. **Confirm channel ownership** — the claim was found at a location only the input identity controls.
5. **Locate the sigchain** for the primary key (try known stores; multiple stores allowed).
6. **Validate the sigchain** end-to-end (signatures, hash chain, monotonic seq, rotation closures).
7. **Check current status** of the input identity — its `add` must not be followed by a matching `revoke`, and any `expires_at` must not have passed.
8. **Walk other identities** in the sigchain. For each, re-fetch and re-verify its proof against its own channel (best-effort; some may be unreachable).
9. **Return the graph** with per-identity status.
### 8.3 Verifier Output
```
Primary: nostr:npub1abc...
Verified identities:
github:jason valid
dns:jason.example valid
bluesky:jason.bsky.social valid
ens:jason.eth unreachable
Status: valid
Confidence: strong
```
`Confidence` is a function of: number of independently-verified identities, channel diversity, sigchain length and age.
### 8.4 Failure Modes
A verifier MUST distinguish between:
- **invalid** — a signature failed, or the chain is broken.
- **revoked** — the identity was explicitly removed via a sigchain `revoke`.
- **expired** — the claim's `expires_at` has passed.
- **unreachable** — the channel could not be fetched (network/transient).
- **fork** — the sigchain has diverging valid branches at the same `seq`.
These statuses MUST NOT be conflated. A verifier MUST surface the distinction to its caller.
### 8.5 Network Fetching Is Required
A conformant verifier performs **real network fetches** for each channel:
- `dns:` — real DNS resolution against the system resolver (or a configurable resolver), reading the TXT record at `_kez.<domain>`. Verifying a TXT *value* a caller already obtained is a development helper, not conformant verification.
- `github:` — HTTPS fetch against `api.github.com` and/or `raw.githubusercontent.com` for the user's gist or profile README.
- `web:` — HTTPS fetch of `/.well-known/kez.json`.
- `nostr:` — connect to one or more relays and fetch the relevant event.
- `bluesky:` — HTTPS fetch against the user's PDS.
A verifier that only reads local files is not a conformant verifier — it's a signature-checking helper. Conformant verifiers MAY expose a local-file mode for testing, but it MUST be clearly labeled as such.
---
## 9. Starting Points (Discovery)
A KEZ verifier does not magically know every account a user has. It always starts from one identifier provided externally (by an app, a QR code, a chat message, a posting key). From that starting point it walks outward via the sigchain.
This is the same model as the web: you don't know every URL, you start at one and follow links. KEZ is the signed-identity equivalent.
---
## 10. MVP Scope (v0.2)
A v0.2 implementation MUST support:
1. The claim format defined in section 3, including the envelope (§3.2).
2. All four proof encodings: JSON, Compact (`kez:z1:`), Markdown, and DNS TXT (sections 4.14.4).
3. Two proof channels with **real network fetch** (§8.5):
- GitHub gist / profile README
- DNS TXT (`_kez.<domain>`)
4. Two key types:
- `ed25519` (suite: `ed25519-sha512-jcs`)
- `secp256k1-schnorr` (suite: `nostr-secp256k1-schnorr-sha256-jcs`)
5. The sigchain format defined in section 6, including hash-chain validation and rotation closure.
6. A verifier CLI matching section 8, distinguishing all five failure modes (§8.4).
Anything beyond this list is post-MVP.
---
## 11. Suggested Implementation Layout
Language-agnostic, but recommended:
```
kez-core claim + sigchain types, signing, verification, all four encodings
kez-channels network adapters: github, dns, web, nostr, bluesky, ...
kez-cli command-line verifier
kez-web paste an identifier, see the graph
```
Each language implementation lives in its own top-level directory (e.g. `rust/`, `ts/`, `go/`). All implementations conform to this spec; cross-language conformance is verified by sharing test vectors (see section 12).
---
## 12. Test Vectors
A `vectors/` directory at the repo root contains canonical inputs and expected outputs:
- `vectors/claims/` — signed claim JSON + the expected JCS bytes + signature + compact encoding + markdown encoding.
- `vectors/sigchains/` — full sigchain examples (valid, revoked, rotated, forked).
- `vectors/proofs/` — fixtures for each channel adapter.
Every implementation's test suite MUST run against these vectors. Two implementations conform iff they produce identical outputs for the same vectors.
---
## 13. One-Sentence Summary
KEZ is a portable identity graph where users sign claims connecting their many accounts, publish those claims in places they control, and anyone can verify the connections without trusting a central server.

299
crosstest.sh Executable file
View File

@ -0,0 +1,299 @@
#!/usr/bin/env bash
# Cross-implementation interop tests for KEZ.
#
# Generates artifacts with one implementation and verifies them with the
# other, in every direction. Proves the JCS canonicalization, the signature
# format, and all four wire encodings are byte-compatible.
#
# Usage:
# ./crosstest.sh # run every scenario
# ./crosstest.sh -v # verbose (echo every command + intermediate output)
#
# Exits 0 iff every scenario passes.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
RUST_CLI=(cargo run --quiet --manifest-path "$ROOT/rust/Cargo.toml" -p kez-cli --)
# Use the absolute path to the installed tsx loader so the Node CLI works
# regardless of the caller's cwd.
TSX_LOADER="$ROOT/nodejs/node_modules/tsx/dist/esm/index.mjs"
if [[ ! -f "$TSX_LOADER" ]]; then
printf "tsx loader not found at %s — run 'cd nodejs && npm install' first\n" "$TSX_LOADER" >&2
exit 1
fi
NODE_CLI=(node --import "$TSX_LOADER" "$ROOT/nodejs/packages/kez-cli/src/cli.ts")
VERBOSE=0
[[ "${1:-}" == "-v" ]] && VERBOSE=1
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
PASS=0
FAIL=0
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; DIM=$'\e[2m'; RESET=$'\e[0m'
scenario() {
local title="$1"
printf " %-60s " "$title"
}
ok() { printf "%spass%s\n" "$GREEN" "$RESET"; PASS=$((PASS+1)); }
bad() {
printf "%sFAIL%s\n" "$RED" "$RESET"
FAIL=$((FAIL+1))
shift
if [[ $# -gt 0 ]]; then
printf " %s\n" "$@" >&2
fi
}
vlog() { [[ $VERBOSE -eq 1 ]] && printf "%s %s%s\n" "$DIM" "$*" "$RESET" >&2 || true; }
extract_nsec() {
awk -F': *' '/^Secret:/ {print $2; exit}' "$1"
}
# Pull the 32-byte hex seed out of `identity new --key-type ed25519` output.
# That output is "Secret: <hex> (32-byte seed)".
extract_ed25519_seed() {
awk -F': *' '/^Secret:/ { sub(/ \(.*$/, "", $2); print $2; exit }' "$1"
}
assert_verify_valid() {
local label="$1"
local file="$2"
if grep -q '^Status: valid' "$file"; then
return 0
else
cat "$file" >&2
bad "$label" "verifier did not report Status: valid"
return 1
fi
}
# 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
printf "%sCross-implementation interop:%s\n" "$YELLOW" "$RESET"
# Generate one shared identity (Node — Rust accepts the same nsec).
"${NODE_CLI[@]}" identity new > "$TMP/identity.txt"
NSEC="$(extract_nsec "$TMP/identity.txt")"
vlog "shared nsec: $NSEC"
# ── Scenario 1: Rust signs JSON → Node verifies ─────────────────────────────
scenario "rust signs JSON ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" > "$TMP/a.json"
"${NODE_CLI[@]}" verify file "$TMP/a.json" > "$TMP/a.out" 2>&1
assert_verify_valid "rust→node JSON" "$TMP/a.out" && ok
# ── Scenario 2: Node signs JSON → Rust verifies ─────────────────────────────
scenario "node signs JSON ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" > "$TMP/b.json"
"${RUST_CLI[@]}" verify file "$TMP/b.json" > "$TMP/b.out" 2>&1
assert_verify_valid "node→rust JSON" "$TMP/b.out" && ok
# ── Scenario 3: Rust signs compact → Node verifies ──────────────────────────
scenario "rust signs compact ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" --format compact > "$TMP/c.kez"
"${NODE_CLI[@]}" verify file "$TMP/c.kez" > "$TMP/c.out" 2>&1
assert_verify_valid "rust→node compact" "$TMP/c.out" && ok
# ── Scenario 4: Node signs compact → Rust verifies ──────────────────────────
scenario "node signs compact ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" --format compact > "$TMP/d.kez"
"${RUST_CLI[@]}" verify file "$TMP/d.kez" > "$TMP/d.out" 2>&1
assert_verify_valid "node→rust compact" "$TMP/d.out" && ok
# ── Scenario 5: Rust signs markdown → Node verifies ─────────────────────────
scenario "rust signs markdown ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --nsec "$NSEC" --format markdown \
--out "$TMP/e.kez.md"
"${NODE_CLI[@]}" verify file "$TMP/e.kez.md" > "$TMP/e.out" 2>&1
assert_verify_valid "rust→node markdown" "$TMP/e.out" && ok
# ── Scenario 6: Node signs markdown → Rust verifies ─────────────────────────
scenario "node signs markdown ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --nsec "$NSEC" --format markdown \
--out "$TMP/f.kez.md"
"${RUST_CLI[@]}" verify file "$TMP/f.kez.md" > "$TMP/f.out" 2>&1
assert_verify_valid "node→rust markdown" "$TMP/f.out" && ok
# ── Scenario 7: Rust signs DNS-shape proof → Node verifies compact body ─────
scenario "rust DNS zone form ⇒ node verify file"
"${RUST_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/g.dns"
# Extract just the compact token from "Value: kez:z1:..." line.
awk '/^Value:/ {print $2}' "$TMP/g.dns" > "$TMP/g.compact"
"${NODE_CLI[@]}" verify file "$TMP/g.compact" > "$TMP/g.out" 2>&1
assert_verify_valid "rust DNS→node compact" "$TMP/g.out" && ok
# ── Scenario 8: Node DNS compact → Rust verifies ────────────────────────────
scenario "node DNS zone form ⇒ rust verify file"
"${NODE_CLI[@]}" claim dns jason.example.com --nsec "$NSEC" > "$TMP/h.dns"
awk '/^Value:/ {print $2}' "$TMP/h.dns" > "$TMP/h.compact"
"${RUST_CLI[@]}" verify file "$TMP/h.compact" > "$TMP/h.out" 2>&1
assert_verify_valid "node DNS→rust compact" "$TMP/h.out" && ok
# ── Ed25519 interop ─────────────────────────────────────────────────────────
# Generate one shared ed25519 seed (Node — Rust accepts the same hex).
"${NODE_CLI[@]}" identity new --key-type ed25519 > "$TMP/ed_identity.txt"
SEED="$(extract_ed25519_seed "$TMP/ed_identity.txt")"
vlog "shared ed25519 seed: $SEED"
# ── Scenario 9: Rust signs ed25519 JSON → Node verifies ─────────────────────
scenario "rust ed25519 JSON ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" > "$TMP/i.json"
"${NODE_CLI[@]}" verify file "$TMP/i.json" > "$TMP/i.out" 2>&1
assert_verify_valid "rust→node ed25519 JSON" "$TMP/i.out" && ok
# ── Scenario 10: Node signs ed25519 JSON → Rust verifies ────────────────────
scenario "node ed25519 JSON ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" > "$TMP/j.json"
"${RUST_CLI[@]}" verify file "$TMP/j.json" > "$TMP/j.out" 2>&1
assert_verify_valid "node→rust ed25519 JSON" "$TMP/j.out" && ok
# ── Scenario 11: Rust signs ed25519 compact → Node verifies ─────────────────
scenario "rust ed25519 compact ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format compact > "$TMP/k.kez"
"${NODE_CLI[@]}" verify file "$TMP/k.kez" > "$TMP/k.out" 2>&1
assert_verify_valid "rust→node ed25519 compact" "$TMP/k.out" && ok
# ── Scenario 12: Node signs ed25519 compact → Rust verifies ─────────────────
scenario "node ed25519 compact ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format compact > "$TMP/l.kez"
"${RUST_CLI[@]}" verify file "$TMP/l.kez" > "$TMP/l.out" 2>&1
assert_verify_valid "node→rust ed25519 compact" "$TMP/l.out" && ok
# ── Scenario 13: Rust signs ed25519 markdown → Node verifies ────────────────
scenario "rust ed25519 markdown ⇒ node verify file"
"${RUST_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format markdown \
--out "$TMP/m.kez.md"
"${NODE_CLI[@]}" verify file "$TMP/m.kez.md" > "$TMP/m.out" 2>&1
assert_verify_valid "rust→node ed25519 markdown" "$TMP/m.out" && ok
# ── Scenario 14: Node signs ed25519 markdown → Rust verifies ────────────────
scenario "node ed25519 markdown ⇒ rust verify file"
"${NODE_CLI[@]}" claim create github:jason --ed25519-seed "$SEED" --format markdown \
--out "$TMP/n.kez.md"
"${RUST_CLI[@]}" verify file "$TMP/n.kez.md" > "$TMP/n.out" 2>&1
assert_verify_valid "node→rust ed25519 markdown" "$TMP/n.out" && ok
# ── Sigchain interop ────────────────────────────────────────────────────────
# Sigchain state lives in ~/.kez/sigchains/<safe-primary>.jsonl. Both CLIs
# read/write the same paths, so we can build a chain in one and inspect it
# from the other. We isolate per scenario by using fresh keys so the state
# files don't collide with anything the user already has.
CHAIN_DIR="$HOME/.kez/sigchains"
# Helper: derive the JSONL path for a primary identifier "scheme:value".
chain_file_for() {
local primary="$1"
echo "$CHAIN_DIR/${primary/:/_}.jsonl"
}
# Scenarios use two separate keys (nostr + ed25519) so chains don't overlap
# with anything else.
"${RUST_CLI[@]}" identity new > "$TMP/sc_nostr_identity.txt"
SC_NSEC="$(extract_nsec "$TMP/sc_nostr_identity.txt")"
"${RUST_CLI[@]}" identity new --key-type ed25519 > "$TMP/sc_ed_identity.txt"
SC_SEED="$(extract_ed25519_seed "$TMP/sc_ed_identity.txt")"
# Derive the chain primaries so we can locate the JSONL files. We can't use
# `awk -F': *'` here because the primary itself contains `:` (`nostr:npub...`,
# `ed25519:hex...`) — that'd truncate the value.
SC_NOSTR_PRIMARY=$(sed -n 's/^Primary:[[:space:]]*//p' "$TMP/sc_nostr_identity.txt" | head -1)
SC_ED_PRIMARY=$(sed -n 's/^Primary:[[:space:]]*//p' "$TMP/sc_ed_identity.txt" | head -1)
SC_NOSTR_FILE="$(chain_file_for "$SC_NOSTR_PRIMARY")"
SC_ED_FILE="$(chain_file_for "$SC_ED_PRIMARY")"
# Clean any pre-existing state from these primaries.
rm -f "$SC_NOSTR_FILE" "$SC_ED_FILE"
# ── Scenario 15: Rust builds chain (nostr) → Node parses + validates ────────
scenario "rust nostr chain ⇒ node sigchain show"
"${RUST_CLI[@]}" sigchain add github:jason --nsec "$SC_NSEC" > /dev/null
"${RUST_CLI[@]}" sigchain add dns:jason.example --nsec "$SC_NSEC" > /dev/null
"${RUST_CLI[@]}" sigchain revoke github:jason --nsec "$SC_NSEC" > /dev/null
"${NODE_CLI[@]}" sigchain show --primary "$SC_NOSTR_PRIMARY" > "$TMP/sc_a.out" 2>&1
if grep -q "Length: 3 event(s)" "$TMP/sc_a.out" \
&& grep -q "op=revoke subject=github:jason" "$TMP/sc_a.out"; then
ok
else
bad "rust→node sigchain show" "see $TMP/sc_a.out for details"
cat "$TMP/sc_a.out" >&2
fi
# ── Scenario 16: Rust JSONL export round-trips through Node JSONL export ────
# Easiest way to prove byte-level interop given the current CLI: have both
# implementations export the *same* on-disk chain and compare the JSONL.
# The chain was just built by Rust; now ask Node to export from the same
# state file and assert the byte contents match.
scenario "rust JSONL == node JSONL for same chain"
"${RUST_CLI[@]}" sigchain export --nsec "$SC_NSEC" --format jsonl > "$TMP/sc_b_rust.jsonl"
"${NODE_CLI[@]}" sigchain export --nsec "$SC_NSEC" --format jsonl > "$TMP/sc_b_node.jsonl"
if diff -q "$TMP/sc_b_rust.jsonl" "$TMP/sc_b_node.jsonl" > /dev/null; then
ok
else
bad "JSONL byte parity" "rust and node disagree on the exported bytes"
diff "$TMP/sc_b_rust.jsonl" "$TMP/sc_b_node.jsonl" | head -20 >&2
fi
# Reset state, swap directions.
rm -f "$SC_NOSTR_FILE"
# ── Scenario 17: Node builds chain → Rust shows + validates ─────────────────
scenario "node nostr chain ⇒ rust sigchain show"
"${NODE_CLI[@]}" sigchain add github:jason --nsec "$SC_NSEC" > /dev/null
"${NODE_CLI[@]}" sigchain add dns:jason.example --nsec "$SC_NSEC" > /dev/null
"${NODE_CLI[@]}" sigchain revoke github:jason --nsec "$SC_NSEC" > /dev/null
"${RUST_CLI[@]}" sigchain show --primary "$SC_NOSTR_PRIMARY" > "$TMP/sc_c.out" 2>&1
if grep -q "Length: 3 event(s)" "$TMP/sc_c.out" \
&& grep -q "op=revoke subject=github:jason" "$TMP/sc_c.out"; then
ok
else
bad "node→rust sigchain show" "rust did not see all 3 events"
cat "$TMP/sc_c.out" >&2
fi
# (A "compact bundle Rust↔Node round-trip" scenario would go here, but
# neither CLI has a `sigchain import` command yet. Both impls' unit tests
# cover the local round-trip; we'll add a cross-impl version once import
# lands in the CLI.)
# Reset state.
rm -f "$SC_NOSTR_FILE"
# ── Scenario 19: Rust ed25519 chain → Node validates ────────────────────────
scenario "rust ed25519 chain ⇒ node sigchain show"
"${RUST_CLI[@]}" sigchain add github:jason --ed25519-seed "$SC_SEED" > /dev/null
"${RUST_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$SC_SEED" > /dev/null
"${NODE_CLI[@]}" sigchain show --primary "$SC_ED_PRIMARY" > "$TMP/sc_e.out" 2>&1
if grep -q "Length: 2 event(s)" "$TMP/sc_e.out"; then ok; else
bad "rust→node ed25519 chain" "node did not see all events"
cat "$TMP/sc_e.out" >&2
fi
rm -f "$SC_ED_FILE"
# ── Scenario 20: Node ed25519 chain → Rust validates ────────────────────────
scenario "node ed25519 chain ⇒ rust sigchain show"
"${NODE_CLI[@]}" sigchain add github:jason --ed25519-seed "$SC_SEED" > /dev/null
"${NODE_CLI[@]}" sigchain add dns:jason.example --ed25519-seed "$SC_SEED" > /dev/null
"${RUST_CLI[@]}" sigchain show --primary "$SC_ED_PRIMARY" > "$TMP/sc_f.out" 2>&1
if grep -q "Length: 2 event(s)" "$TMP/sc_f.out"; then ok; else
bad "node→rust ed25519 chain" "rust did not see all events"
cat "$TMP/sc_f.out" >&2
fi
rm -f "$SC_ED_FILE"
printf "\n"
if [[ $FAIL -eq 0 ]]; then
printf "%sAll %d scenarios passed.%s\n" "$GREEN" "$PASS" "$RESET"
exit 0
else
printf "%s%d passed, %d failed.%s\n" "$RED" "$PASS" "$FAIL" "$RESET"
exit 1
fi

140
nodejs/README.md Normal file
View File

@ -0,0 +1,140 @@
# KEZ — Node.js Implementation
TypeScript port of [KEZ](../SPEC.md), structurally mirroring the
[Rust implementation](../rust/README.md) — three packages (`core`, `channels`,
`cli`) with the same CLI surface, the same proof formats, and the same
five channel plugins. Wire-compatible with the Rust version: a claim signed
in Rust verifies in Node and vice versa.
```
nodejs/
├── package.json npm workspaces root
├── tsconfig.base.json
├── packages/
│ ├── kez-core/ Types, signing, verification, JCS, all four encodings
│ ├── kez-channels/ One file per channel (github, dns, nostr, bluesky, activitypub)
│ └── kez-cli/ Thin CLI dispatching through the channel registry
└── README.md (this file)
```
## Requirements
- Node.js 22+ (for the built-in WebSocket the nostr channel uses)
- npm 9+ (for `workspaces`)
## Install & test
```sh
npm install # one-time
npm test # runs all packages' vitest suites
npm run typecheck # strict tsc --build across all packages
```
## CLI
The CLI mirrors the Rust CLI exactly. Run it via the workspace script:
```sh
# Create a key
npm run cli -- identity new
# Sign a claim — pick either key type
npm run cli -- claim create github:jason --nsec nsec1... --format markdown --out github.kez.md
npm run cli -- claim create github:jason --ed25519-seed <64-char-hex> --format markdown --out github.kez.md
# Generate an ed25519 identity instead of nostr
npm run cli -- identity new --key-type ed25519
# Local sigchain (state at ~/.kez/sigchains/<safe-primary>.jsonl)
npm run cli -- sigchain add github:jason --nsec nsec1...
npm run cli -- sigchain revoke github:jason --nsec nsec1...
npm run cli -- sigchain show --nsec nsec1...
npm run cli -- sigchain export --nsec nsec1... --format jsonl
# Publish the sigchain to one or more destinations
npm run cli -- sigchain publish --nsec nsec1... \
--server http://localhost:7878 \
--web --out chain.jsonl \
--dns example.com \
--nostr wss://relay.damus.io
# Verify a local file
npm run cli -- verify file github.kez.md
# Verify any KEZ identifier over the network
npm run cli -- verify id github:jason
npm run cli -- verify id dns:jason.example.com
npm run cli -- verify id nostr:npub1...
npm run cli -- verify id bluesky:jason.bsky.social
npm run cli -- verify id ap:@jason@mastodon.social
npm run cli -- verify id mastodon:@jason@mastodon.social
```
## Channels
| File | System | Implementation |
|---|---|---|
| [`dns.ts`](packages/kez-channels/src/dns.ts) | `dns:` | Node `dns/promises` resolver, abstracted behind `TxtResolver` for testing |
| [`github.ts`](packages/kez-channels/src/github.ts) | `github:` | `fetch` against the public REST API, no auth |
| [`nostr.ts`](packages/kez-channels/src/nostr.ts) | `nostr:` | Built-in `WebSocket` to default relays, abstracted behind `NostrFetcher` |
| [`bluesky.ts`](packages/kez-channels/src/bluesky.ts) | `bluesky:` | `fetch` against the public Bluesky AppView, no auth |
| [`activitypub.ts`](packages/kez-channels/src/activitypub.ts) | `ap:`, `mastodon:` | WebFinger + actor JSON, no auth |
Each channel implements:
```ts
interface Channel {
readonly system: string;
fetchAndVerify(identity: Identity): Promise<ChannelHit>;
}
```
…and is registered in `Registry`. Adding a new channel is one file + one
`r.register(new MyChannel())` line in
[`defaultRegistry`](packages/kez-channels/src/index.ts).
## Library use
```ts
import { Identity } from "@kez/core";
import { defaultRegistry } from "@kez/channels";
const registry = await defaultRegistry();
const hit = await registry.verify(Identity.parse("github:jason"));
console.log(hit.status); // VerificationStatus
```
## Crypto stack
- **Schnorr signatures**`@noble/curves/secp256k1` (BIP-340)
- **SHA-256**`@noble/hashes/sha2`
- **bech32 (npub/nsec)**`@scure/base`
- **JCS (RFC 8785)**`canonicalize`
- **zstd**`fzstd` (pure JS, no native deps)
- **base64url**`@scure/base`
- **HTTP** — Node 18+ built-in `fetch`
- **WebSocket** — Node 22+ built-in `WebSocket`
- **DNS TXT** — Node `dns/promises`
No native dependencies. Runs on Node, Bun, and (mostly) Deno.
## Cross-implementation interop
The whole point of having two implementations is to demonstrate that the
proof format is portable. The repo root has a `crosstest.sh` script that
generates artifacts in Rust and verifies them in Node, and vice versa. See
[`../README.md`](../README.md#cross-testing) for the runner.
## Tests
```sh
npm test # full suite
npx vitest run --project core # one workspace package
```
The test suite hits no network — HTTP channels use an injected `fetch`,
DNS uses a `TxtResolver` interface, nostr uses a `NostrFetcher` interface.
## License
Dual-licensed under MIT or Apache-2.0.

2191
nodejs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
nodejs/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "kez-nodejs",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --build",
"cli": "tsx packages/kez-cli/src/cli.ts"
},
"devDependencies": {
"@types/node": "^22.7.5",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.3"
}
}

View File

@ -0,0 +1,22 @@
{
"name": "@kez/channels",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./github": "./src/github.ts",
"./dns": "./src/dns.ts",
"./nostr": "./src/nostr.ts",
"./bluesky": "./src/bluesky.ts",
"./activitypub": "./src/activitypub.ts"
},
"dependencies": {
"@kez/core": "*"
},
"devDependencies": {
"nock": "^14.0.0-beta.16"
}
}

View File

@ -0,0 +1,174 @@
// ActivityPub channel: WebFinger → actor JSON → attachment / summary scan.
// Works for Mastodon, Pleroma, Akkoma, Misskey, GoToSocial, Friendica, PeerTube.
import type { Identity } from "@kez/core";
import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)";
export interface ActivityPubChannelOptions {
/** Test override: route every fetch here regardless of server name. */
baseOverride?: string;
/** Canonical scheme this instance reports as `system`. Default "ap". */
system?: string;
fetch?: typeof fetch;
}
export class ActivityPubChannel implements Channel {
readonly system: string;
private readonly baseOverride?: string;
private readonly fetch: typeof fetch;
constructor(opts: ActivityPubChannelOptions = {}) {
this.system = opts.system ?? "ap";
this.baseOverride = opts.baseOverride;
this.fetch = opts.fetch ?? globalThis.fetch;
}
async fetchAndVerify(identity: Identity): Promise<ChannelHit> {
const { user, server } = parseHandle(identity.id);
const base = this.baseOverride ?? `https://${server}`;
// 1. WebFinger → actor URL
const wfUrl = webfingerUrl(base, user, server);
const wf = await this.fetchJson(wfUrl, "application/jrd+json");
const actorUrl = extractActorUrl(wf);
if (!actorUrl) throw ChannelError.notFound(identity);
// 2. Actor JSON → candidate proof strings
const actor = await this.fetchJson(actorUrl, "application/activity+json");
const candidates = extractActorCandidates(actor);
// 3. parse + verify each candidate
let lastError: ChannelError | undefined;
for (const raw of candidates) {
try {
return parseAndVerifyFor(raw, identity);
} catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e);
}
}
throw lastError ?? ChannelError.notFound(identity);
}
private async fetchJson(url: string, accept: string): Promise<unknown> {
let resp: Response;
try {
resp = await this.fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: accept },
});
} catch (e) {
throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e);
}
if (resp.status === 404) throw ChannelError.unreachable(`GET ${url}: 404`);
if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`);
return resp.json();
}
}
export function parseHandle(value: string): { user: string; server: string } {
const trimmed = value.startsWith("@") ? value.slice(1) : value;
const at = trimmed.indexOf("@");
if (at < 0) throw ChannelError.other(`expected @user@server, got: ${value}`);
const user = trimmed.slice(0, at);
const server = trimmed.slice(at + 1);
if (!user || !server) throw ChannelError.other(`invalid handle (empty part): ${value}`);
return { user, server };
}
export function webfingerUrl(base: string, user: string, server: string): string {
// Match Rust verbatim: no URL encoding — WebFinger servers accept both
// forms, and keeping bytes identical helps cross-implementation tests.
return `${base}/.well-known/webfinger?resource=acct:${user}@${server}`;
}
export function extractActorUrl(webfinger: unknown): string | undefined {
if (typeof webfinger !== "object" || webfinger === null) return undefined;
const links = (webfinger as Record<string, unknown>).links;
if (!Array.isArray(links)) return undefined;
for (const link of links) {
if (typeof link !== "object" || link === null) continue;
const rel = (link as Record<string, unknown>).rel;
const typ = (link as Record<string, unknown>).type;
const href = (link as Record<string, unknown>).href;
if (
rel === "self" &&
typeof typ === "string" &&
(typ.includes("activity+json") || typ.includes("ld+json")) &&
typeof href === "string"
) {
return href;
}
}
return undefined;
}
export function extractActorCandidates(actor: unknown): string[] {
if (typeof actor !== "object" || actor === null) return [];
const out: string[] = [];
const attachments = (actor as Record<string, unknown>).attachment;
if (Array.isArray(attachments)) {
for (const att of attachments) {
const value = (att as Record<string, unknown>)?.value;
if (typeof value === "string") out.push(stripHtml(value));
}
}
const summary = (actor as Record<string, unknown>).summary;
if (typeof summary === "string") out.push(stripHtml(summary));
return out;
}
/** Drop HTML tags and decode the small set of named entities a bio uses. */
export function stripHtml(html: string): string {
let out = "";
let i = 0;
while (i < html.length) {
const c = html[i];
if (c === "<") {
const end = html.indexOf(">", i + 1);
if (end < 0) break;
i = end + 1;
} else if (c === "&") {
// try to read an entity name
const semi = html.indexOf(";", i + 1);
if (semi < 0 || semi > i + 9) {
out += c;
i++;
continue;
}
const entity = html.slice(i + 1, semi);
const decoded = decodeEntity(entity);
if (decoded !== undefined) {
out += decoded;
i = semi + 1;
} else {
out += c;
i++;
}
} else {
out += c;
i++;
}
}
return out;
}
function decodeEntity(name: string): string | undefined {
switch (name) {
case "amp":
return "&";
case "lt":
return "<";
case "gt":
return ">";
case "quot":
return '"';
case "apos":
case "#39":
return "'";
case "nbsp":
return " ";
default:
return undefined;
}
}

View File

@ -0,0 +1,67 @@
// Bluesky channel: queries the public AppView's getAuthorFeed (no auth)
// and scans post text for KEZ proofs.
import type { Identity } from "@kez/core";
import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
const DEFAULT_APPVIEW = "https://public.api.bsky.app";
const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)";
export interface BlueskyChannelOptions {
appviewBase?: string;
fetch?: typeof fetch;
}
export class BlueskyChannel implements Channel {
readonly system = "bluesky";
private readonly base: string;
private readonly fetch: typeof fetch;
constructor(opts: BlueskyChannelOptions = {}) {
this.base = opts.appviewBase ?? DEFAULT_APPVIEW;
this.fetch = opts.fetch ?? globalThis.fetch;
}
async fetchAndVerify(identity: Identity): Promise<ChannelHit> {
const actor = identity.id;
if (!actor) throw ChannelError.other("bluesky identity has empty handle");
const url = authorFeedUrl(this.base, actor);
let resp: Response;
try {
resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT } });
} catch (e) {
throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e);
}
if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`);
const body = (await resp.json()) as unknown;
const candidates = extractPostTexts(body);
let lastError: ChannelError | undefined;
for (const text of candidates) {
try {
return parseAndVerifyFor(text, identity);
} catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e);
}
}
throw lastError ?? ChannelError.notFound(identity);
}
}
export function authorFeedUrl(base: string, actor: string): string {
return `${base}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=100`;
}
export function extractPostTexts(body: unknown): string[] {
if (typeof body !== "object" || body === null) return [];
const feed = (body as Record<string, unknown>).feed;
if (!Array.isArray(feed)) return [];
const out: string[] = [];
for (const item of feed) {
const text = (((item as Record<string, unknown>)?.post as Record<string, unknown>)
?.record as Record<string, unknown>)?.text;
if (typeof text === "string" && text.trim().length > 0) out.push(text);
}
return out;
}

View File

@ -0,0 +1,53 @@
// DNS channel: queries `_kez.<domain>` TXT records. Resolver is abstracted
// so tests can substitute a fake.
import { resolveTxt } from "node:dns/promises";
import { COMPACT_PROOF_PREFIX, type Identity, dnsTxtName } from "@kez/core";
import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
export interface TxtResolver {
lookupTxt(name: string): Promise<string[]>;
}
export class SystemResolver implements TxtResolver {
async lookupTxt(name: string): Promise<string[]> {
try {
// Node returns TXT as string[][] (one inner array per record,
// each segment <=255 bytes). Concat segments per record.
const records = await resolveTxt(name);
return records.map((parts) => parts.join(""));
} catch (e) {
throw ChannelError.unreachable(`TXT lookup ${name}: ${(e as Error).message}`, e);
}
}
}
export class DnsChannel implements Channel {
readonly system = "dns";
private resolver: TxtResolver;
constructor(resolver: TxtResolver = new SystemResolver()) {
this.resolver = resolver;
}
async fetchAndVerify(identity: Identity): Promise<ChannelHit> {
const name = dnsTxtName(identity);
const records = await this.resolver.lookupTxt(name);
let lastError: ChannelError | undefined;
for (const value of records) {
if (!looksLikeKezTxt(value)) continue;
try {
return parseAndVerifyFor(value, identity);
} catch (e) {
if (e instanceof ChannelError) lastError = e;
else lastError = ChannelError.invalid((e as Error).message, e);
}
}
throw lastError ?? ChannelError.notFound(identity);
}
}
export function looksLikeKezTxt(value: string): boolean {
return value.startsWith(COMPACT_PROOF_PREFIX) || value.startsWith("kez1:");
}

View File

@ -0,0 +1,130 @@
// GitHub channel: scans the user's public gists, falls back to `<user>/<user>`
// profile README. No auth needed.
import type { Identity } from "@kez/core";
import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
const DEFAULT_API_BASE = "https://api.github.com";
const DEFAULT_RAW_BASE = "https://raw.githubusercontent.com";
const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)";
export interface GithubChannelOptions {
apiBase?: string;
rawBase?: string;
fetch?: typeof fetch;
}
export class GithubChannel implements Channel {
readonly system = "github";
private readonly apiBase: string;
private readonly rawBase: string;
private readonly fetch: typeof fetch;
constructor(opts: GithubChannelOptions = {}) {
this.apiBase = opts.apiBase ?? DEFAULT_API_BASE;
this.rawBase = opts.rawBase ?? DEFAULT_RAW_BASE;
this.fetch = opts.fetch ?? globalThis.fetch;
}
async fetchAndVerify(identity: Identity): Promise<ChannelHit> {
const user = identity.id;
if (!user) throw ChannelError.other("github identity has empty user");
let lastError: ChannelError | undefined;
// 1. gists
try {
const candidates = await this.fetchGistCandidates(user);
for (const url of candidates) {
try {
const body = await this.fetchText(url);
return parseAndVerifyFor(body, identity);
} catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e);
}
}
} catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e);
}
// 2. profile README fallback (main, then master)
for (const branch of ["main", "master"]) {
const url = `${this.rawBase}/${user}/${user}/${branch}/README.md`;
try {
const body = await this.fetchText(url);
return parseAndVerifyFor(body, identity);
} catch (e) {
if (e instanceof ChannelError && e.kind === "Unreachable") continue;
lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e);
}
}
throw lastError ?? ChannelError.notFound(identity);
}
private async fetchText(url: string): Promise<string> {
let resp: Response;
try {
resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT } });
} catch (e) {
throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e);
}
if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`);
return resp.text();
}
private async fetchGistCandidates(user: string): Promise<string[]> {
const url = gistsUrl(this.apiBase, user);
let resp: Response;
try {
resp = await this.fetch(url, {
headers: {
"User-Agent": USER_AGENT,
Accept: "application/vnd.github+json",
},
});
} catch (e) {
throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e);
}
if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`);
const body = (await resp.json()) as unknown;
return parseGistCandidates(body);
}
}
export function looksLikeKezFilename(name: string): boolean {
const lower = name.toLowerCase();
return (
lower.endsWith(".kez") ||
lower.endsWith(".kez.md") ||
lower.endsWith(".kez.json") ||
lower.includes("kez")
);
}
export function gistsUrl(apiBase: string, user: string): string {
return `${apiBase}/users/${user}/gists?per_page=100`;
}
export function profileReadmeUrls(rawBase: string, user: string): string[] {
return [
`${rawBase}/${user}/${user}/main/README.md`,
`${rawBase}/${user}/${user}/master/README.md`,
];
}
export function parseGistCandidates(body: unknown): string[] {
if (!Array.isArray(body)) return [];
const out: string[] = [];
for (const gist of body) {
if (typeof gist !== "object" || gist === null) continue;
const files = (gist as Record<string, unknown>).files;
if (typeof files !== "object" || files === null) continue;
for (const [name, file] of Object.entries(files)) {
if (!looksLikeKezFilename(name)) continue;
const rawUrl = (file as Record<string, unknown>)?.raw_url;
if (typeof rawUrl === "string") out.push(rawUrl);
}
}
return out;
}

View File

@ -0,0 +1,172 @@
// Channel adapter trait, registry, error model. Mirrors Rust kez-channels.
import {
COMPACT_PROOF_PREFIX,
Identity,
type SignedClaimEnvelope,
type VerificationStatus,
extractMarkdownProof,
fromCompact,
fromJson,
parseDnsTxtValue,
verifyClaim,
} from "@kez/core";
export type ChannelErrorKind =
| "Unreachable"
| "NotFound"
| "Invalid"
| "SubjectMismatch"
| "NoChannelForSystem"
| "Other";
export class ChannelError extends Error {
readonly kind: ChannelErrorKind;
readonly expected?: Identity;
readonly found?: Identity;
readonly cause?: unknown;
constructor(
kind: ChannelErrorKind,
message: string,
opts: { cause?: unknown; expected?: Identity; found?: Identity } = {},
) {
super(message);
this.name = "ChannelError";
this.kind = kind;
this.cause = opts.cause;
this.expected = opts.expected;
this.found = opts.found;
}
static unreachable(msg: string, cause?: unknown): ChannelError {
return new ChannelError("Unreachable", `channel unreachable: ${msg}`, { cause });
}
static notFound(identity: Identity): ChannelError {
return new ChannelError("NotFound", `no KEZ proof found for ${identity}`);
}
static invalid(reason: string, cause?: unknown): ChannelError {
return new ChannelError("Invalid", `proof failed verification: ${reason}`, { cause });
}
static subjectMismatch(expected: Identity, found: Identity): ChannelError {
return new ChannelError(
"SubjectMismatch",
`proof subject ${found} did not match expected identity ${expected}`,
{ expected, found },
);
}
static noChannelForSystem(system: string): ChannelError {
return new ChannelError("NoChannelForSystem", `no channel registered for system: ${system}`);
}
static other(msg: string, cause?: unknown): ChannelError {
return new ChannelError("Other", msg, { cause });
}
}
export interface ChannelHit {
proof: SignedClaimEnvelope;
status: VerificationStatus;
}
export interface Channel {
/** The `system:` prefix this channel handles. */
readonly system: string;
fetchAndVerify(identity: Identity): Promise<ChannelHit>;
}
/** system: prefix → channel adapter, with alias support. */
export class Registry {
private channels = new Map<string, Channel>();
register(channel: Channel): void {
this.channels.set(channel.system, channel);
}
/** Register the same adapter under a different scheme (e.g. `mastodon` → `ap`). */
registerAs(system: string, channel: Channel): void {
this.channels.set(system, channel);
}
get(system: string): Channel | undefined {
return this.channels.get(system);
}
async verify(identity: Identity): Promise<ChannelHit> {
const channel = this.channels.get(identity.scheme);
if (!channel) throw ChannelError.noChannelForSystem(identity.scheme);
return channel.fetchAndVerify(identity);
}
}
/** Build a Registry with every channel shipped in this package. */
export async function defaultRegistry(): Promise<Registry> {
const r = new Registry();
const { GithubChannel } = await import("./github.js");
const { DnsChannel } = await import("./dns.js");
const { NostrChannel } = await import("./nostr.js");
const { BlueskyChannel } = await import("./bluesky.js");
const { ActivityPubChannel } = await import("./activitypub.js");
r.register(new GithubChannel());
r.register(new DnsChannel());
r.register(new NostrChannel());
r.register(new BlueskyChannel());
const ap = new ActivityPubChannel();
r.register(ap);
r.registerAs("mastodon", ap);
return r;
}
// ─────────────────────────────────────────────────────────────────────────────
// parseProof / parseAndVerifyFor — shared by every channel
// ─────────────────────────────────────────────────────────────────────────────
/** Try all four wire encodings. Compact form may be embedded in prose. */
export function parseProof(raw: string): SignedClaimEnvelope {
const trimmed = raw.trim();
if (trimmed.includes("```kez")) return extractMarkdownProof(trimmed);
if (trimmed.startsWith("{")) return fromJson(trimmed);
if (trimmed.startsWith("kez1:")) return parseDnsTxtValue(trimmed);
const token = extractCompactToken(trimmed);
if (token) return fromCompact(token);
throw new Error("unknown KEZ proof format");
}
/** Parse, verify signature, and require the subject to equal `expected`. */
export function parseAndVerifyFor(raw: string, expected: Identity): ChannelHit {
let proof: SignedClaimEnvelope;
try {
proof = parseProof(raw);
} catch (e) {
throw ChannelError.invalid((e as Error).message, e);
}
let status: VerificationStatus;
try {
status = verifyClaim(proof);
} catch (e) {
throw ChannelError.invalid((e as Error).message, e);
}
if (proof.payload.subject !== expected.toString()) {
throw ChannelError.subjectMismatch(expected, Identity.parse(proof.payload.subject));
}
return { proof, status };
}
/** Find `kez:z1:<base64url>` anywhere in `text` and return the full token. */
export function extractCompactToken(text: string): string | undefined {
const idx = text.indexOf(COMPACT_PROOF_PREFIX);
if (idx < 0) return undefined;
const after = text.slice(idx + COMPACT_PROOF_PREFIX.length);
let end = 0;
while (end < after.length) {
const c = after.charCodeAt(end);
const isAlphaNum =
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x41 && c <= 0x5a) || // A-Z
(c >= 0x61 && c <= 0x7a); // a-z
const isUrlSafe = c === 0x5f /* _ */ || c === 0x2d; /* - */
if (!isAlphaNum && !isUrlSafe) break;
end++;
}
if (end === 0) return undefined;
return COMPACT_PROOF_PREFIX + after.slice(0, end);
}

View File

@ -0,0 +1,273 @@
// Nostr channel: queries relays for kind-30078 events authored by the
// requested npub, then runs each event's content through parseAndVerifyFor.
// Fetcher is abstracted so tests use canned events.
import { type Identity, NostrSecret, nostrPubkeyHex } from "@kez/core";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex } from "@noble/hashes/utils";
import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js";
export const KEZ_NOSTR_KIND = 30078;
const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net"];
const FETCH_TIMEOUT_MS = 8_000;
export interface NostrFilter {
authors: string[]; // lowercase hex pubkeys
kinds: number[];
limit?: number;
}
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
export interface NostrFetcher {
fetchEvents(filter: NostrFilter): Promise<NostrEvent[]>;
}
export class RelayPoolFetcher implements NostrFetcher {
constructor(private relays: string[] = DEFAULT_RELAYS) {}
async fetchEvents(filter: NostrFilter): Promise<NostrEvent[]> {
let lastError: Error | undefined;
const events: NostrEvent[] = [];
for (const relay of this.relays) {
try {
events.push(...(await queryRelay(relay, filter)));
} catch (e) {
lastError = e as Error;
}
if (events.length > 0) break;
}
if (events.length === 0 && lastError) {
throw ChannelError.unreachable(lastError.message, lastError);
}
return events;
}
}
async function queryRelay(url: string, filter: NostrFilter): Promise<NostrEvent[]> {
// Node 22+ ships a global WebSocket; we use it directly.
// eslint-disable-next-line no-undef
const ws = new WebSocket(url);
const subId = "kez-1";
const events: NostrEvent[] = [];
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
try {
ws.close();
} catch {
/* noop */
}
reject(new Error(`relay ${url} timed out after ${FETCH_TIMEOUT_MS}ms`));
}, FETCH_TIMEOUT_MS);
ws.addEventListener("open", () => {
ws.send(buildReqMessage(subId, filter));
});
ws.addEventListener("message", (ev: MessageEvent) => {
const parsed = parseRelayMessage(typeof ev.data === "string" ? ev.data : String(ev.data));
if (parsed.kind === "event") events.push(parsed.event);
else if (parsed.kind === "eose") {
clearTimeout(timer);
try {
ws.send(JSON.stringify(["CLOSE", subId]));
} catch {
/* noop */
}
try {
ws.close();
} catch {
/* noop */
}
resolve();
}
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error(`relay ${url} websocket error`));
});
ws.addEventListener("close", () => {
clearTimeout(timer);
resolve();
});
});
return events;
}
export class NostrChannel implements Channel {
readonly system = "nostr";
private readonly fetcher: NostrFetcher;
constructor(fetcher: NostrFetcher = new RelayPoolFetcher()) {
this.fetcher = fetcher;
}
async fetchAndVerify(identity: Identity): Promise<ChannelHit> {
const pubkeyHex = nostrPubkeyHex(identity);
const filter: NostrFilter = {
authors: [pubkeyHex],
kinds: [KEZ_NOSTR_KIND],
limit: 20,
};
let events: NostrEvent[];
try {
events = await this.fetcher.fetchEvents(filter);
} catch (e) {
if (e instanceof ChannelError) throw e;
throw ChannelError.unreachable((e as Error).message, e);
}
let lastError: ChannelError | undefined;
for (const ev of events) {
if (!eventMatchesAuthor(ev, pubkeyHex)) continue;
try {
return parseAndVerifyFor(ev.content, identity);
} catch (e) {
lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e);
}
}
throw lastError ?? ChannelError.notFound(identity);
}
}
/**
* Build and sign a NIP-01 event. Event id = sha256 of the canonical array
* [0, pubkey, created_at, kind, tags, content]; signature = Schnorr over
* that id. Matches Rust's `build_signed_event` byte-for-byte.
*/
export function buildSignedEvent(
signer: NostrSecret,
createdAt: number,
kind: number,
tags: string[][],
content: string,
): NostrEvent {
const pubkey = signer.pubkeyHex();
const canonical = JSON.stringify([0, pubkey, createdAt, kind, tags, content]);
const digest = sha256(new TextEncoder().encode(canonical));
const id = bytesToHex(digest);
const sig = bytesToHex(signer.signDigest(digest));
return { id, pubkey, created_at: createdAt, kind, tags, content, sig };
}
/**
* Publish a single event to one relay. Waits up to 5s for `["OK", id, true]`;
* silently accepts timeouts (many relays accept without replying).
*/
export async function publishEventToRelay(
relayUrl: string,
event: NostrEvent,
): Promise<void> {
// eslint-disable-next-line no-undef
const ws = new WebSocket(relayUrl);
const deadline = 5_000;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
try {
ws.close();
} catch {
/* noop */
}
// Timeout = treat as accepted; client can re-fetch to confirm.
resolve();
}, deadline);
ws.addEventListener("open", () => {
try {
ws.send(JSON.stringify(["EVENT", event]));
} catch (e) {
clearTimeout(timer);
reject(ChannelError.unreachable(`send EVENT ${relayUrl}: ${(e as Error).message}`));
}
});
ws.addEventListener("message", (ev: MessageEvent) => {
const text = typeof ev.data === "string" ? ev.data : String(ev.data);
let arr: unknown;
try {
arr = JSON.parse(text);
} catch {
return;
}
if (!Array.isArray(arr)) return;
if (arr[0] === "OK") {
clearTimeout(timer);
try {
ws.close();
} catch {
/* noop */
}
if (arr[2] === false) {
const reason = typeof arr[3] === "string" ? arr[3] : "";
reject(
ChannelError.other(`relay ${relayUrl} rejected event: ${reason}`),
);
} else {
resolve();
}
}
// NOTICE messages are informational; keep waiting.
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(ChannelError.unreachable(`relay ${relayUrl} websocket error`));
});
});
}
export function buildReqMessage(subId: string, filter: NostrFilter): string {
const spec: Record<string, unknown> = {
authors: filter.authors,
kinds: filter.kinds,
};
if (filter.limit !== undefined) spec.limit = filter.limit;
return JSON.stringify(["REQ", subId, spec]);
}
export function eventMatchesAuthor(event: NostrEvent, expectedHex: string): boolean {
return event.pubkey.toLowerCase() === expectedHex.toLowerCase();
}
export type RelayMessage =
| { kind: "event"; event: NostrEvent }
| { kind: "eose" }
| { kind: "other" };
export function parseRelayMessage(text: string): RelayMessage {
try {
const arr = JSON.parse(text);
if (!Array.isArray(arr)) return { kind: "other" };
if (arr[0] === "EVENT" && typeof arr[2] === "object" && arr[2] !== null) {
const ev = arr[2] as NostrEvent;
if (
typeof ev.id === "string" &&
typeof ev.pubkey === "string" &&
typeof ev.kind === "number" &&
typeof ev.content === "string" &&
typeof ev.sig === "string"
) {
return { kind: "event", event: ev };
}
}
if (arr[0] === "EOSE") return { kind: "eose" };
return { kind: "other" };
} catch {
return { kind: "other" };
}
}

View File

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
Identity,
NostrSecret,
newClaimPayload,
signClaim,
toCompact,
} from "@kez/core";
import {
ActivityPubChannel,
extractActorCandidates,
extractActorUrl,
parseHandle,
stripHtml,
webfingerUrl,
} from "../src/activitypub.js";
function sign(subject: string) {
const secret = NostrSecret.generate();
return signClaim(
newClaimPayload(Identity.parse(subject), secret.identity(), new Date()),
secret,
);
}
const BASE = "https://ap.test";
function makeFetch(routes: Record<string, unknown>): typeof fetch {
return (async (input: string | URL) => {
const key = input.toString();
const body = routes[key];
if (body === undefined) return new Response("", { status: 404 });
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status: 200 });
}) as unknown as typeof fetch;
}
describe("ActivityPubChannel", () => {
it("verifies proof in profile attachment", async () => {
const signed = sign("ap:@jason@mastodon.social");
const actorUrl = `${BASE}/users/jason`;
const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`;
const fetch = makeFetch({
[wfUrl]: {
subject: "acct:jason@mastodon.social",
links: [{ rel: "self", type: "application/activity+json", href: actorUrl }],
},
[actorUrl]: {
id: actorUrl,
type: "Person",
attachment: [
{ type: "PropertyValue", name: "site", value: '<a href="https://x">x</a>' },
{ type: "PropertyValue", name: "kez", value: toCompact(signed) },
],
summary: "<p>hi</p>",
},
});
const channel = new ActivityPubChannel({ baseOverride: BASE, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social"));
expect(hit.proof).toEqual(signed);
});
it("verifies proof embedded in bio", async () => {
const signed = sign("ap:@jason@mastodon.social");
const actorUrl = `${BASE}/users/jason`;
const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`;
const fetch = makeFetch({
[wfUrl]: {
links: [{ rel: "self", type: "application/activity+json", href: actorUrl }],
},
[actorUrl]: {
summary: `<p>portable identity: ${toCompact(signed)}</p>`,
attachment: [],
},
});
const channel = new ActivityPubChannel({ baseOverride: BASE, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social"));
expect(hit.proof).toEqual(signed);
});
it("rejects proof for wrong subject", async () => {
const signed = sign("ap:@mallory@mastodon.social");
const actorUrl = `${BASE}/users/jason`;
const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`;
const fetch = makeFetch({
[wfUrl]: {
links: [{ rel: "self", type: "application/activity+json", href: actorUrl }],
},
[actorUrl]: {
attachment: [{ type: "PropertyValue", name: "kez", value: toCompact(signed) }],
},
});
const channel = new ActivityPubChannel({ baseOverride: BASE, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")),
).rejects.toMatchObject({ kind: "SubjectMismatch" });
});
it("WebFinger 404 is Unreachable", async () => {
const fetch = makeFetch({});
const channel = new ActivityPubChannel({ baseOverride: BASE, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("ap:@ghost@mastodon.social")),
).rejects.toMatchObject({ kind: "Unreachable" });
});
it("WebFinger with no self link is NotFound", async () => {
const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`;
const fetch = makeFetch({
[wfUrl]: {
links: [{ rel: "profile", type: "text/html", href: "https://example/@jason" }],
},
});
const channel = new ActivityPubChannel({ baseOverride: BASE, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")),
).rejects.toMatchObject({ kind: "NotFound" });
});
});
describe("ActivityPub pure helpers", () => {
it("parseHandle accepts both forms", () => {
expect(parseHandle("@jason@mastodon.social")).toEqual({
user: "jason",
server: "mastodon.social",
});
expect(parseHandle("jason@mastodon.social")).toEqual({
user: "jason",
server: "mastodon.social",
});
});
it("parseHandle rejects malformed", () => {
expect(() => parseHandle("jason")).toThrow();
expect(() => parseHandle("@@server")).toThrow();
expect(() => parseHandle("@jason@")).toThrow();
});
it("webfingerUrl matches spec shape", () => {
expect(webfingerUrl("https://mastodon.social", "jason", "mastodon.social")).toBe(
"https://mastodon.social/.well-known/webfinger?resource=acct:jason@mastodon.social",
);
});
it("extractActorUrl picks self activity+json", () => {
expect(
extractActorUrl({
links: [
{ rel: "profile", type: "text/html", href: "https://x/@jason" },
{
rel: "self",
type: "application/activity+json",
href: "https://x/users/jason",
},
],
}),
).toBe("https://x/users/jason");
});
it("extractActorUrl accepts ld+json", () => {
expect(
extractActorUrl({
links: [
{ rel: "self", type: "application/ld+json; profile=...", href: "https://x/u" },
],
}),
).toBe("https://x/u");
});
it("extractActorCandidates returns attachment then summary", () => {
expect(
extractActorCandidates({
attachment: [
{ value: '<a href="...">x</a>' },
{ value: "kez:z1:abc" },
],
summary: "<p>kez:z1:def</p>",
}),
).toEqual(["x", "kez:z1:abc", "kez:z1:def"]);
});
it("stripHtml drops tags and decodes entities", () => {
expect(stripHtml("<p>hello <b>world</b></p>")).toBe("hello world");
expect(stripHtml("a &amp; b &lt;c&gt;")).toBe("a & b <c>");
expect(stripHtml("&quot;quoted&quot;")).toBe('"quoted"');
expect(stripHtml("&#39;apos&#39;")).toBe("'apos'");
});
it("stripHtml preserves compact kez prefix", () => {
expect(stripHtml("<p>my proof: kez:z1:KLUv_QBYabc</p>")).toBe(
"my proof: kez:z1:KLUv_QBYabc",
);
});
});

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
Identity,
NostrSecret,
newClaimPayload,
signClaim,
toCompact,
toMarkdown,
} from "@kez/core";
import {
BlueskyChannel,
authorFeedUrl,
extractPostTexts,
} from "../src/bluesky.js";
function sign(subject: string) {
const secret = NostrSecret.generate();
return signClaim(
newClaimPayload(Identity.parse(subject), secret.identity(), new Date()),
secret,
);
}
function fakeFetch(url: string, body: unknown, status = 200): typeof fetch {
return (async (input: string | URL) => {
if (input.toString() === url) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status });
}
return new Response("", { status: 404 });
}) as unknown as typeof fetch;
}
const APPVIEW = "https://appview.test";
const FEED_URL = `${APPVIEW}/xrpc/app.bsky.feed.getAuthorFeed?actor=jason.bsky.social&limit=100`;
describe("BlueskyChannel", () => {
it("verifies compact proof in post text", async () => {
const signed = sign("bluesky:jason.bsky.social");
const fetch = fakeFetch(FEED_URL, {
feed: [
{ post: { record: { text: "good morning" } } },
{ post: { record: { text: toCompact(signed) } } },
],
});
const channel = new BlueskyChannel({ appviewBase: APPVIEW, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("bluesky:jason.bsky.social"));
expect(hit.proof).toEqual(signed);
});
it("verifies markdown-fenced proof in post", async () => {
const signed = sign("bluesky:jason.bsky.social");
const fetch = fakeFetch(FEED_URL, {
feed: [{ post: { record: { text: toMarkdown(signed) } } }],
});
const channel = new BlueskyChannel({ appviewBase: APPVIEW, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("bluesky:jason.bsky.social"));
expect(hit.proof).toEqual(signed);
});
it("rejects proof for wrong handle", async () => {
const signed = sign("bluesky:mallory.bsky.social");
const fetch = fakeFetch(FEED_URL, {
feed: [{ post: { record: { text: toCompact(signed) } } }],
});
const channel = new BlueskyChannel({ appviewBase: APPVIEW, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("bluesky:jason.bsky.social")),
).rejects.toMatchObject({ kind: "SubjectMismatch" });
});
it("empty feed yields NotFound", async () => {
const fetch = fakeFetch(FEED_URL, { feed: [] });
const channel = new BlueskyChannel({ appviewBase: APPVIEW, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("bluesky:jason.bsky.social")),
).rejects.toMatchObject({ kind: "NotFound" });
});
it("AppView 503 is Unreachable", async () => {
const fetch = fakeFetch(FEED_URL, "boom", 503);
const channel = new BlueskyChannel({ appviewBase: APPVIEW, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("bluesky:jason.bsky.social")),
).rejects.toMatchObject({ kind: "Unreachable" });
});
});
describe("bluesky pure helpers", () => {
it("authorFeedUrl matches AppView contract", () => {
expect(authorFeedUrl("https://public.api.bsky.app", "jason.bsky.social")).toBe(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=jason.bsky.social&limit=100",
);
});
it("extractPostTexts pulls text out of feed items", () => {
const body = {
feed: [
{ post: { record: { text: "hello" } } },
{ post: { record: { text: "kez:z1:abc" } } },
{ post: { record: {} } },
{ no_post: true },
],
};
expect(extractPostTexts(body)).toEqual(["hello", "kez:z1:abc"]);
});
it("extractPostTexts handles missing feed", () => {
expect(extractPostTexts({})).toEqual([]);
expect(extractPostTexts({ feed: "no" })).toEqual([]);
});
});

View File

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
COMPACT_PROOF_PREFIX,
Identity,
NostrSecret,
newClaimPayload,
signClaim,
toCompact,
toMarkdown,
toPrettyJson,
} from "@kez/core";
import {
ChannelError,
Registry,
defaultRegistry,
extractCompactToken,
parseAndVerifyFor,
parseProof,
} from "../src/index.js";
function sign(subjectStr: string) {
const secret = NostrSecret.generate();
const primary = secret.identity();
const subject = Identity.parse(subjectStr);
return signClaim(newClaimPayload(subject, primary, new Date()), secret);
}
describe("parseProof", () => {
it("handles all four encodings", () => {
const signed = sign("github:jason");
expect(parseProof(toPrettyJson(signed))).toEqual(signed);
expect(parseProof(toCompact(signed))).toEqual(signed);
expect(parseProof(toMarkdown(signed))).toEqual(signed);
});
it("rejects unknown format", () => {
expect(() => parseProof("just some text")).toThrow(/unknown/);
});
it("extracts compact token from surrounding prose", () => {
const signed = sign("ap:@jason@mastodon.social");
const compact = toCompact(signed);
const bio = `hello world! my proof: ${compact} — verify it`;
expect(parseProof(bio)).toEqual(signed);
});
});
describe("extractCompactToken", () => {
it("stops at non-base64url chars", () => {
expect(extractCompactToken("before kez:z1:KLUv_QBYabc. after")).toBe(
"kez:z1:KLUv_QBYabc",
);
});
it("returns undefined when missing or empty body", () => {
expect(extractCompactToken("nothing")).toBeUndefined();
expect(extractCompactToken("kez:z1:")).toBeUndefined();
});
it("uses the canonical prefix constant", () => {
expect(COMPACT_PROOF_PREFIX).toBe("kez:z1:");
});
});
describe("parseAndVerifyFor", () => {
it("passes on matching subject", () => {
const signed = sign("github:jason");
const hit = parseAndVerifyFor(toPrettyJson(signed), Identity.parse("github:jason"));
expect(hit.proof).toEqual(signed);
});
it("flags subject mismatch", () => {
const signed = sign("github:jason");
expect(() =>
parseAndVerifyFor(toPrettyJson(signed), Identity.parse("github:mallory")),
).toThrow(ChannelError);
});
});
describe("Registry", () => {
it("dispatches by scheme", async () => {
const r = await defaultRegistry();
expect(r.get("github")).toBeDefined();
expect(r.get("dns")).toBeDefined();
expect(r.get("nostr")).toBeDefined();
expect(r.get("bluesky")).toBeDefined();
expect(r.get("ap")).toBeDefined();
expect(r.get("mastodon")).toBeDefined();
expect(r.get("did")).toBeUndefined();
});
it("reports unknown system", async () => {
const r = new Registry();
await expect(r.verify(Identity.parse("github:jason"))).rejects.toBeInstanceOf(ChannelError);
});
});

View File

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
Identity,
NostrSecret,
dnsTxtValue,
newClaimPayload,
signClaim,
toCompact,
} from "@kez/core";
import { ChannelError } from "../src/index.js";
import { DnsChannel, looksLikeKezTxt, type TxtResolver } from "../src/dns.js";
class CapturingResolver implements TxtResolver {
constructor(
private readonly records: string[],
private readonly expectedName: string,
) {}
async lookupTxt(name: string): Promise<string[]> {
expect(name).toBe(this.expectedName);
return this.records;
}
}
class FailingResolver implements TxtResolver {
async lookupTxt(): Promise<string[]> {
throw ChannelError.unreachable("simulated network failure");
}
}
function signDns(subject: string) {
const secret = NostrSecret.generate();
return signClaim(
newClaimPayload(Identity.parse(subject), secret.identity(), new Date()),
secret,
);
}
describe("DnsChannel", () => {
it("queries _kez.<domain>", async () => {
const signed = signDns("dns:jason.example.com");
const channel = new DnsChannel(
new CapturingResolver([toCompact(signed)], "_kez.jason.example.com"),
);
const hit = await channel.fetchAndVerify(Identity.parse("dns:jason.example.com"));
expect(hit.proof).toEqual(signed);
});
it("supports legacy kez1: prefix", async () => {
const signed = signDns("dns:jason.example.com");
const legacy = dnsTxtValue(signed);
expect(legacy.startsWith("kez1:")).toBe(true);
const channel = new DnsChannel(
new CapturingResolver([legacy], "_kez.jason.example.com"),
);
const hit = await channel.fetchAndVerify(Identity.parse("dns:jason.example.com"));
expect(hit.proof).toEqual(signed);
});
it("returns NotFound when no records", async () => {
const channel = new DnsChannel(new CapturingResolver([], "_kez.jason.example.com"));
await expect(
channel.fetchAndVerify(Identity.parse("dns:jason.example.com")),
).rejects.toMatchObject({ kind: "NotFound" });
});
it("skips non-KEZ TXT records", async () => {
const channel = new DnsChannel(
new CapturingResolver(
["v=spf1 -all", "google-site-verification=abc"],
"_kez.jason.example.com",
),
);
await expect(
channel.fetchAndVerify(Identity.parse("dns:jason.example.com")),
).rejects.toMatchObject({ kind: "NotFound" });
});
it("surfaces resolver failure as Unreachable", async () => {
const channel = new DnsChannel(new FailingResolver());
await expect(
channel.fetchAndVerify(Identity.parse("dns:jason.example.com")),
).rejects.toMatchObject({ kind: "Unreachable" });
});
});
describe("looksLikeKezTxt", () => {
it("accepts both prefixes", () => {
expect(looksLikeKezTxt("kez:z1:foo")).toBe(true);
expect(looksLikeKezTxt("kez1:{...}")).toBe(true);
expect(looksLikeKezTxt("v=spf1")).toBe(false);
expect(looksLikeKezTxt("")).toBe(false);
});
});

View File

@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import {
Identity,
NostrSecret,
newClaimPayload,
signClaim,
toMarkdown,
} from "@kez/core";
import {
GithubChannel,
gistsUrl,
looksLikeKezFilename,
parseGistCandidates,
profileReadmeUrls,
} from "../src/github.js";
import { ChannelError } from "../src/index.js";
function sign(subject: string) {
const secret = NostrSecret.generate();
return signClaim(
newClaimPayload(Identity.parse(subject), secret.identity(), new Date()),
secret,
);
}
/** Tiny in-memory router so we don't need nock — keeps the harness pure-Node. */
function makeFakeFetch(routes: Record<string, { status: number; body: string }>): typeof fetch {
return (async (url: string | URL) => {
const key = url.toString();
const route = routes[key];
if (!route) {
return new Response("", { status: 404 });
}
return new Response(route.body, { status: route.status });
}) as unknown as typeof fetch;
}
describe("GithubChannel", () => {
it("verifies a proof published in a gist", async () => {
const signed = sign("github:jason");
const md = toMarkdown(signed);
const apiBase = "https://api.test";
const rawBase = "https://raw.test";
const fetch = makeFakeFetch({
[`${apiBase}/users/jason/gists?per_page=100`]: {
status: 200,
body: JSON.stringify([
{
files: {
"notes.txt": { raw_url: `${rawBase}/raw/notes.txt` },
"github-jason.kez.md": { raw_url: `${rawBase}/raw/kez.md` },
},
},
]),
},
[`${rawBase}/raw/kez.md`]: { status: 200, body: md },
});
const channel = new GithubChannel({ apiBase, rawBase, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("github:jason"));
expect(hit.proof).toEqual(signed);
});
it("falls back to profile README on main", async () => {
const signed = sign("github:jason");
const md = toMarkdown(signed);
const apiBase = "https://api.test";
const rawBase = "https://raw.test";
const fetch = makeFakeFetch({
[`${apiBase}/users/jason/gists?per_page=100`]: { status: 200, body: "[]" },
[`${rawBase}/jason/jason/main/README.md`]: { status: 200, body: md },
});
const channel = new GithubChannel({ apiBase, rawBase, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("github:jason"));
expect(hit.proof).toEqual(signed);
});
it("falls back to master when main missing", async () => {
const signed = sign("github:jason");
const md = toMarkdown(signed);
const apiBase = "https://api.test";
const rawBase = "https://raw.test";
const fetch = makeFakeFetch({
[`${apiBase}/users/jason/gists?per_page=100`]: { status: 200, body: "[]" },
[`${rawBase}/jason/jason/master/README.md`]: { status: 200, body: md },
});
const channel = new GithubChannel({ apiBase, rawBase, fetch });
const hit = await channel.fetchAndVerify(Identity.parse("github:jason"));
expect(hit.proof).toEqual(signed);
});
it("rejects proof signed for wrong subject", async () => {
const signed = sign("github:mallory");
const md = toMarkdown(signed);
const apiBase = "https://api.test";
const rawBase = "https://raw.test";
const fetch = makeFakeFetch({
[`${apiBase}/users/jason/gists?per_page=100`]: {
status: 200,
body: JSON.stringify([
{ files: { "kez.md": { raw_url: `${rawBase}/raw/kez.md` } } },
]),
},
[`${rawBase}/raw/kez.md`]: { status: 200, body: md },
});
const channel = new GithubChannel({ apiBase, rawBase, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("github:jason")),
).rejects.toMatchObject({ kind: "SubjectMismatch" });
});
it("returns NotFound when nothing matches", async () => {
const apiBase = "https://api.test";
const rawBase = "https://raw.test";
const fetch = makeFakeFetch({
[`${apiBase}/users/jason/gists?per_page=100`]: { status: 200, body: "[]" },
});
const channel = new GithubChannel({ apiBase, rawBase, fetch });
await expect(
channel.fetchAndVerify(Identity.parse("github:jason")),
).rejects.toBeInstanceOf(ChannelError);
});
});
describe("github pure helpers", () => {
it("looksLikeKezFilename accepts kez files", () => {
expect(looksLikeKezFilename("github-jason.kez.md")).toBe(true);
expect(looksLikeKezFilename("proof.kez")).toBe(true);
expect(looksLikeKezFilename("KEZ-PROOF.txt")).toBe(true);
expect(looksLikeKezFilename("README.md")).toBe(false);
expect(looksLikeKezFilename(".gitignore")).toBe(false);
});
it("gistsUrl includes user and pagination", () => {
expect(gistsUrl("https://api.github.com", "jason")).toBe(
"https://api.github.com/users/jason/gists?per_page=100",
);
});
it("profileReadmeUrls tries main then master", () => {
const urls = profileReadmeUrls("https://raw.githubusercontent.com", "jason");
expect(urls).toHaveLength(2);
expect(urls[0]).toMatch(/main\/README\.md$/);
expect(urls[1]).toMatch(/master\/README\.md$/);
});
it("parseGistCandidates extracts kez raw_urls", () => {
const body = [
{
files: {
"notes.txt": { raw_url: "https://example/notes" },
"github-jason.kez.md": { raw_url: "https://example/kez" },
},
},
];
expect(parseGistCandidates(body)).toEqual(["https://example/kez"]);
});
});

View File

@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
Identity,
NostrSecret,
newClaimPayload,
nostrPubkeyHex,
signClaim,
toCompact,
} from "@kez/core";
import {
KEZ_NOSTR_KIND,
NostrChannel,
type NostrEvent,
type NostrFetcher,
type NostrFilter,
buildReqMessage,
buildSignedEvent,
eventMatchesAuthor,
parseRelayMessage,
} from "../src/nostr.js";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex } from "@noble/hashes/utils";
class CapturingFetcher implements NostrFetcher {
constructor(
private events: NostrEvent[],
private expectedAuthors: string[],
private expectedKinds: number[],
) {}
async fetchEvents(filter: NostrFilter): Promise<NostrEvent[]> {
expect(filter.authors).toEqual(this.expectedAuthors);
expect(filter.kinds).toEqual(this.expectedKinds);
return this.events;
}
}
function makeEvent(pubkeyHex: string, content: string): NostrEvent {
return {
id: "0".repeat(64),
pubkey: pubkeyHex,
created_at: Math.floor(Date.now() / 1000),
kind: KEZ_NOSTR_KIND,
tags: [["d", "kez"]],
content,
sig: "f".repeat(128),
};
}
function signForSelf() {
const secret = NostrSecret.generate();
const identity = secret.identity();
const signed = signClaim(newClaimPayload(identity, identity, new Date()), secret);
return { secret, identity, signed };
}
describe("NostrChannel", () => {
it("verifies a self-published proof", async () => {
const { identity, signed } = signForSelf();
const pubkey = nostrPubkeyHex(identity);
const fetcher = new CapturingFetcher(
[makeEvent(pubkey, toCompact(signed))],
[pubkey],
[KEZ_NOSTR_KIND],
);
const channel = new NostrChannel(fetcher);
const hit = await channel.fetchAndVerify(identity);
expect(hit.proof).toEqual(signed);
});
it("skips events whose pubkey field mismatches", async () => {
const a = signForSelf();
const b = signForSelf();
const pubkeyB = nostrPubkeyHex(b.identity);
const compactA = toCompact(a.signed);
const fetcher = new CapturingFetcher(
[makeEvent(pubkeyB, compactA)],
[nostrPubkeyHex(a.identity)],
[KEZ_NOSTR_KIND],
);
const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({
kind: "NotFound",
});
});
it("rejects proof signed for a different subject", async () => {
const a = signForSelf();
const b = signForSelf();
// a signs a claim with subject = b
const claimForB = signClaim(
newClaimPayload(b.identity, a.identity, new Date()),
a.secret,
);
const fetcher = new CapturingFetcher(
[makeEvent(nostrPubkeyHex(a.identity), toCompact(claimForB))],
[nostrPubkeyHex(a.identity)],
[KEZ_NOSTR_KIND],
);
const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({
kind: "SubjectMismatch",
});
});
it("no events yields NotFound", async () => {
const { identity } = signForSelf();
const fetcher = new CapturingFetcher(
[],
[nostrPubkeyHex(identity)],
[KEZ_NOSTR_KIND],
);
const channel = new NostrChannel(fetcher);
await expect(channel.fetchAndVerify(identity)).rejects.toMatchObject({
kind: "NotFound",
});
});
});
describe("nostr wire helpers", () => {
it("buildReqMessage includes filter fields", () => {
const req = buildReqMessage("sub-1", { authors: ["aa"], kinds: [30078], limit: 20 });
expect(JSON.parse(req)).toEqual([
"REQ",
"sub-1",
{ authors: ["aa"], kinds: [30078], limit: 20 },
]);
});
it("buildReqMessage omits limit when undefined", () => {
const req = buildReqMessage("s", { authors: ["aa"], kinds: [1] });
expect(JSON.parse(req)[2]).toEqual({ authors: ["aa"], kinds: [1] });
});
it("parseRelayMessage handles EVENT / EOSE / other", () => {
const ev = JSON.stringify([
"EVENT",
"s",
{
id: "0".repeat(64),
pubkey: "a".repeat(64),
created_at: 0,
kind: 30078,
tags: [],
content: "x",
sig: "f".repeat(128),
},
]);
expect(parseRelayMessage(ev).kind).toBe("event");
expect(parseRelayMessage(JSON.stringify(["EOSE", "s"])).kind).toBe("eose");
expect(parseRelayMessage(JSON.stringify(["NOTICE", "hi"])).kind).toBe("other");
expect(parseRelayMessage("not json").kind).toBe("other");
});
it("buildSignedEvent produces valid NIP-01 event", () => {
const signer = NostrSecret.generate();
const event = buildSignedEvent(
signer,
1_700_000_000,
KEZ_NOSTR_KIND,
[["d", "kez-sigchain"]],
"hello",
);
expect(event.id).toHaveLength(64);
expect(event.pubkey).toHaveLength(64);
expect(event.sig).toHaveLength(128);
expect(event.pubkey).toBe(signer.pubkeyHex());
// id must equal sha256(canonical [0, pubkey, created_at, kind, tags, content])
const canonical = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content,
]);
const expected = bytesToHex(sha256(new TextEncoder().encode(canonical)));
expect(event.id).toBe(expected);
});
it("eventMatchesAuthor is case-insensitive", () => {
const ev = makeEvent("ABCDEF", "");
expect(eventMatchesAuthor(ev, "abcdef")).toBe(true);
expect(eventMatchesAuthor(ev, "ababab")).toBe(false);
});
});

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../kez-core" }],
"include": ["src/**/*"]
}

View File

@ -0,0 +1,14 @@
{
"name": "@kez/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/cli.ts",
"bin": {
"kez": "./src/cli.ts"
},
"dependencies": {
"@kez/channels": "*",
"@kez/core": "*"
}
}

View File

@ -0,0 +1,479 @@
#!/usr/bin/env node
// Mirrors the Rust kez-cli surface verbatim so the cross-test harness can
// substitute either implementation interchangeably.
//
// kez identity new [--key-type nostr|ed25519]
// kez claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>) [--format ...] [--out ...]
// kez claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)
// kez verify file <path>
// kez verify id <identifier>
// kez sigchain add <subject> (--nsec | --ed25519-seed) [--proof-url <url>]
// kez sigchain revoke <subject> (--nsec | --ed25519-seed)
// kez sigchain show [--primary <id>] | (--nsec | --ed25519-seed)
// kez sigchain export [--primary <id>] | (--nsec | --ed25519-seed) [--format jsonl|compact] [--out <path>]
// kez sigchain publish [--primary <id>] | (--nsec | --ed25519-seed)
// [--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
Ed25519Secret,
Identity,
NostrSecret,
Sigchain,
type Signer,
type VerificationStatus,
dnsTxtName,
eventHash,
newClaimPayload,
signClaim,
toCompact,
toMarkdown,
toPrettyJson,
verifyClaim,
} from "@kez/core";
import { defaultRegistry, parseProof } from "@kez/channels";
import {
KEZ_NOSTR_KIND,
buildSignedEvent,
publishEventToRelay,
} from "@kez/channels/nostr";
function usageAndExit(msg?: string): never {
if (msg) process.stderr.write(`error: ${msg}\n\n`);
process.stderr.write(
[
"Usage: kez <command> ...",
"",
"Commands:",
" identity new [--key-type nostr|ed25519]",
" claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>)",
" [--format json|markdown|compact] [--out <path>]",
" claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)",
" verify file <path>",
" verify id <identifier>",
" sigchain add <subject> (--nsec | --ed25519-seed) [--proof-url <url>]",
" sigchain revoke <subject> (--nsec | --ed25519-seed)",
" sigchain show [--primary <id>] | (--nsec | --ed25519-seed)",
" sigchain export [--primary <id>] | (--nsec | --ed25519-seed)",
" [--format jsonl|compact] [--out <path>]",
" sigchain publish [--primary <id>] | (--nsec | --ed25519-seed)",
" [--server <url>] [--web --out <path>] [--dns <domain>] [--nostr <relay>]",
"",
].join("\n"),
);
process.exit(2);
}
interface Flags {
nsec?: string;
ed25519Seed?: string;
keyType?: "nostr" | "ed25519";
format?: "json" | "markdown" | "compact" | "jsonl";
out?: string;
primary?: string;
proofUrl?: string;
server?: string;
web?: boolean;
dns?: string;
nostr?: string;
positional: string[];
}
function parseFlags(args: string[]): Flags {
const out: Flags = { positional: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--nsec") {
out.nsec = args[++i];
} else if (a === "--ed25519-seed") {
out.ed25519Seed = args[++i];
} else if (a === "--key-type") {
const v = args[++i];
if (v !== "nostr" && v !== "ed25519") usageAndExit(`bad --key-type value: ${v}`);
out.keyType = v;
} else if (a === "--format") {
const v = args[++i];
if (v !== "json" && v !== "markdown" && v !== "compact" && v !== "jsonl") {
usageAndExit(`bad --format value: ${v}`);
}
out.format = v;
} else if (a === "--out") {
out.out = args[++i];
} else if (a === "--primary") {
out.primary = args[++i];
} else if (a === "--proof-url") {
out.proofUrl = args[++i];
} else if (a === "--server") {
out.server = args[++i];
} else if (a === "--web") {
out.web = true;
} else if (a === "--dns") {
out.dns = args[++i];
} else if (a === "--nostr") {
out.nostr = args[++i];
} else if (a.startsWith("--")) {
usageAndExit(`unknown flag: ${a}`);
} else {
out.positional.push(a);
}
}
if (out.nsec && out.ed25519Seed) {
usageAndExit("--nsec and --ed25519-seed are mutually exclusive");
}
return out;
}
function writeOrPrint(text: string, outPath?: string): void {
if (outPath) writeFileSync(outPath, text);
else process.stdout.write(text.endsWith("\n") ? text : text + "\n");
}
function printStatus(status: VerificationStatus): void {
process.stdout.write(`Primary: ${status.primary}\n`);
process.stdout.write("\n");
process.stdout.write("Verified identities:\n");
for (const id of status.verified) process.stdout.write(`- ${id}\n`);
process.stdout.write("\n");
process.stdout.write(`Status: ${status.status}\n`);
process.stdout.write(`Confidence: ${status.confidence}\n`);
}
function loadSigner(args: Flags): Signer {
if (args.nsec) return NostrSecret.fromNsec(args.nsec);
if (args.ed25519Seed) return Ed25519Secret.fromSeedHex(args.ed25519Seed);
usageAndExit("missing key: pass --nsec or --ed25519-seed");
}
function buildClaim(subjectStr: string, signer: Signer) {
const primary =
signer instanceof Ed25519Secret
? signer.identity()
: Identity.parse(`nostr:${signer.npub()}`);
const subject = Identity.parse(subjectStr);
return signClaim(newClaimPayload(subject, primary, new Date()), signer);
}
function identityNew(args: Flags): void {
const keyType = args.keyType ?? "nostr";
if (keyType === "ed25519") {
const s = Ed25519Secret.generate();
process.stdout.write(`Primary: ${s.identity()}\n`);
process.stdout.write(`Public: ${s.pubkeyHex()}\n`);
process.stdout.write(`Secret: ${s.seedHex()} (32-byte seed)\n`);
process.stdout.write("\n");
process.stdout.write(
"Store the secret somewhere safe. Anyone with the seed can sign as this identity.\n",
);
return;
}
const s = NostrSecret.generate();
process.stdout.write(`Primary: nostr:${s.npub()}\n`);
process.stdout.write(`Public: ${s.npub()}\n`);
process.stdout.write(`Secret: ${s.nsec()}\n`);
process.stdout.write("\n");
process.stdout.write(
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity.\n",
);
}
function claimCreate(args: Flags): void {
if (args.positional.length !== 1) usageAndExit("claim create needs <subject>");
const signer = loadSigner(args);
const signed = buildClaim(args.positional[0], signer);
const format = args.format ?? "json";
const out =
format === "json"
? toPrettyJson(signed)
: format === "markdown"
? toMarkdown(signed)
: toCompact(signed);
writeOrPrint(out, args.out);
}
function quoteDnsTxtValue(value: string): string {
const chunks: string[] = [];
for (let i = 0; i < value.length; i += 240) {
const segment = value
.slice(i, i + 240)
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
chunks.push(`"${segment}"`);
}
return chunks.join(" ");
}
function claimDns(args: Flags): void {
if (args.positional.length !== 1) usageAndExit("claim dns needs <domain>");
const signer = loadSigner(args);
const subject = args.positional[0].startsWith("dns:")
? args.positional[0]
: `dns:${args.positional[0]}`;
const signed = buildClaim(subject, signer);
const name = dnsTxtName(Identity.parse(signed.payload.subject));
const value = toCompact(signed);
process.stdout.write(`Name: ${name}\n`);
process.stdout.write(`Value: ${value}\n`);
process.stdout.write("\n");
process.stdout.write("Zone file:\n");
process.stdout.write(`${name} TXT ${quoteDnsTxtValue(value)}\n`);
}
function verifyFile(args: Flags): void {
if (args.positional.length !== 1) usageAndExit("verify file needs <path>");
const raw = readFileSync(args.positional[0], "utf8");
const proof = parseProof(raw);
const status = verifyClaim(proof);
printStatus(status);
}
async function verifyId(args: Flags): Promise<void> {
if (args.positional.length !== 1) usageAndExit("verify id needs <identifier>");
const identity = Identity.parse(args.positional[0]);
const registry = await defaultRegistry();
const hit = await registry.verify(identity);
printStatus(hit.status);
}
async function main(): Promise<void> {
const [cmd, sub, ...rest] = process.argv.slice(2);
if (!cmd) usageAndExit();
const flags = parseFlags(rest);
try {
if (cmd === "identity" && sub === "new") return identityNew(flags);
if (cmd === "claim" && sub === "create") return claimCreate(flags);
if (cmd === "claim" && sub === "dns") return claimDns(flags);
if (cmd === "verify" && sub === "file") return verifyFile(flags);
if (cmd === "verify" && sub === "id") return verifyId(flags);
if (cmd === "sigchain") return sigchainDispatch(sub, flags);
usageAndExit(`unknown command: ${cmd} ${sub ?? ""}`);
} catch (e) {
process.stderr.write(`Error: ${(e as Error).message}\n`);
process.exit(1);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain commands
// ─────────────────────────────────────────────────────────────────────────────
async function sigchainDispatch(sub: string | undefined, flags: Flags): Promise<void> {
switch (sub) {
case "add":
return sigchainAdd(flags);
case "revoke":
return sigchainRevoke(flags);
case "show":
return sigchainShow(flags);
case "export":
return sigchainExport(flags);
case "publish":
return sigchainPublish(flags);
default:
usageAndExit(`unknown sigchain subcommand: ${sub ?? ""}`);
}
}
function sigchainDir(): string {
const dir = join(homedir(), ".kez", "sigchains");
mkdirSync(dir, { recursive: true });
return dir;
}
function sigchainPath(primary: Identity): string {
const safe = primary.toString().replace(":", "_");
return join(sigchainDir(), `${safe}.jsonl`);
}
function loadChain(primary: Identity): Sigchain {
const path = sigchainPath(primary);
if (!existsSync(path)) return Sigchain.create(primary);
return Sigchain.fromJsonl(readFileSync(path, "utf8"));
}
function saveChain(chain: Sigchain): void {
writeFileSync(sigchainPath(chain.primary), chain.toJsonl());
}
/** Load whichever signing key the user passed; primary is derived from it. */
function loadSignerStrict(flags: Flags): { signer: Signer; primary: Identity } {
const signer = loadSigner(flags);
const primary =
signer instanceof Ed25519Secret
? signer.identity()
: Identity.parse(`nostr:${signer.npub()}`);
return { signer, primary };
}
/** Read-only commands accept --primary <id> *or* a signing key. */
function resolvePrimaryReadonly(flags: Flags): Identity {
if (flags.primary) return Identity.parse(flags.primary);
return loadSignerStrict(flags).primary;
}
function sigchainAdd(flags: Flags): void {
if (flags.positional.length !== 1) usageAndExit("sigchain add needs <subject>");
const { signer, primary } = loadSignerStrict(flags);
const subject = Identity.parse(flags.positional[0]);
const chain = loadChain(primary);
const event = chain.signAdd(subject, flags.proofUrl, signer);
saveChain(chain);
process.stdout.write(
`Appended add ${subject} at seq ${event.payload.seq} (head hash: ${eventHash(event)})\n`,
);
process.stdout.write(`Chain saved to ${sigchainPath(primary)}\n`);
}
function sigchainRevoke(flags: Flags): void {
if (flags.positional.length !== 1) usageAndExit("sigchain revoke needs <subject>");
const { signer, primary } = loadSignerStrict(flags);
const subject = Identity.parse(flags.positional[0]);
const chain = loadChain(primary);
const event = chain.signRevoke(subject, signer);
saveChain(chain);
process.stdout.write(
`Appended revoke ${subject} at seq ${event.payload.seq} (head hash: ${eventHash(event)})\n`,
);
process.stdout.write(`Chain saved to ${sigchainPath(primary)}\n`);
}
function sigchainShow(flags: Flags): void {
const primary = resolvePrimaryReadonly(flags);
const chain = loadChain(primary);
process.stdout.write(`Primary: ${primary}\n`);
process.stdout.write(`Path: ${sigchainPath(primary)}\n`);
process.stdout.write(`Length: ${chain.length} event(s)\n\n`);
chain.events().forEach((event, i) => {
const subjStr =
typeof event.payload.payload.subject === "string"
? event.payload.payload.subject
: "<no subject>";
const op = event.payload.op.padEnd(6);
process.stdout.write(` [${i}] seq=${event.payload.seq} op=${op} subject=${subjStr}\n`);
});
if (!chain.isEmpty) {
process.stdout.write(`\nHead hash: ${chain.headHash()}\n`);
}
}
function sigchainExport(flags: Flags): void {
const primary = resolvePrimaryReadonly(flags);
const chain = loadChain(primary);
if (chain.isEmpty) {
process.stderr.write(`Error: no chain found for ${primary}\n`);
process.exit(1);
}
const fmt = flags.format ?? "jsonl";
let output: string;
if (fmt === "compact") output = chain.toCompactBundle();
else output = chain.toJsonl();
writeOrPrint(output, flags.out);
}
async function sigchainPublish(flags: Flags): Promise<void> {
if (!flags.server && !flags.web && !flags.dns && !flags.nostr) {
usageAndExit("no publish destination: pass --server / --web / --dns / --nostr");
}
// Get primary (and optionally signer for --nostr).
let primary: Identity;
let nsecSigner: NostrSecret | undefined;
if (flags.primary) {
primary = Identity.parse(flags.primary);
} else {
const { signer, primary: p } = loadSignerStrict(flags);
primary = p;
if (signer instanceof NostrSecret) nsecSigner = signer;
}
const chain = loadChain(primary);
if (chain.isEmpty) throw new Error(`no chain found for ${primary}`);
if (flags.server) await publishToServer(chain, flags.server);
if (flags.web) {
if (!flags.out) usageAndExit("--web requires --out <path>");
publishToWeb(chain, flags.out);
}
if (flags.dns) publishToDns(chain, flags.dns);
if (flags.nostr) {
if (!nsecSigner) {
throw new Error(
"--nostr publish requires --nsec (nostr key needed to sign the wrapping event)",
);
}
const signerPrimary = Identity.parse(`nostr:${nsecSigner.npub()}`);
if (!signerPrimary.equals(primary)) {
throw new Error(
`--nostr publish requires the signing nsec to match the chain primary (${primary}); got ${signerPrimary}`,
);
}
await publishToNostr(chain, flags.nostr, nsecSigner);
}
}
async function publishToServer(chain: Sigchain, serverUrl: string): Promise<void> {
const base = serverUrl.replace(/\/+$/, "");
const scheme = chain.primary.scheme;
const id = chain.primary.id;
const endpoint = `${base}/v1/sigchains/${scheme}/${id}/events`;
let posted = 0;
let alreadyPresent = 0;
for (const event of chain.events()) {
const resp = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "kez-cli-node/0.1",
},
body: JSON.stringify(event),
});
if (resp.ok) posted++;
else if (resp.status === 409) alreadyPresent++;
else {
const body = await resp.text();
throw new Error(`POST ${endpoint}: ${resp.status} ${body}`);
}
}
process.stdout.write(
`server(${serverUrl}): posted ${posted} event(s), ${alreadyPresent} already present\n`,
);
}
function publishToWeb(chain: Sigchain, path: string): void {
writeFileSync(path, chain.toJsonl());
process.stdout.write(
`web: wrote ${chain.length} event(s) to ${path} (upload to https://<your-domain>/.well-known/kez-sigchain.jsonl)\n`,
);
}
function publishToDns(chain: Sigchain, domain: string): void {
const compact = chain.toCompactBundle();
const name = `_kez-chain.${domain}`;
process.stdout.write(`dns(${domain}):\n`);
process.stdout.write(` Name: ${name}\n`);
process.stdout.write(` Value: ${compact}\n\n`);
process.stdout.write(` Zone file (install in your DNS registrar):\n`);
process.stdout.write(` ${name} TXT ${quoteDnsTxtValue(compact)}\n`);
}
async function publishToNostr(
chain: Sigchain,
relay: string,
signer: NostrSecret,
): Promise<void> {
const content = chain.toCompactBundle();
const event = buildSignedEvent(
signer,
Math.floor(Date.now() / 1000),
KEZ_NOSTR_KIND,
[["d", "kez-sigchain"]],
content,
);
await publishEventToRelay(relay, event);
process.stdout.write(
`nostr(${relay}): published kind-${KEZ_NOSTR_KIND} event ${event.id}\n`,
);
}
await main();

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../kez-core" }, { "path": "../kez-channels" }],
"include": ["src/**/*"]
}

View File

@ -0,0 +1,17 @@
{
"name": "@kez/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"canonicalize": "^2.0.0"
}
}

View File

@ -0,0 +1,136 @@
// Sign and verify SignedClaimEnvelope. Two algorithms supported:
// - `nostr-secp256k1-schnorr-sha256-jcs` — BIP-340 Schnorr over SHA-256(JCS(payload))
// - `ed25519-sha512-jcs` — Ed25519 over JCS(payload) directly (PureEdDSA)
//
// Matches Rust's `SignedClaim::sign_with / ::verify` dispatch.
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { canonicalBytes } from "./jcs.js";
import {
type ClaimPayload,
type SignedClaimEnvelope,
ED25519_SHA512_ALG,
NOSTR_SCHNORR_ALG,
} from "./envelope.js";
import { Identity } from "./identity.js";
import { Ed25519Secret, verifyEd25519 } from "./ed25519.js";
import { NostrSecret, nostrPubkeyHex, verifySchnorr } from "./nostr.js";
export class VerificationError extends Error {
constructor(message: string) {
super(message);
this.name = "VerificationError";
}
}
export interface VerificationStatus {
primary: Identity;
verified: Identity[];
status: "valid" | "invalid";
confidence: "strong" | "weak";
}
/** Unified signing key — either of the two supported algorithms. */
export type Signer = NostrSecret | Ed25519Secret;
/**
* Sign a claim payload. Dispatches on the signer type:
* NostrSecret schnorr over SHA-256(JCS(payload))
* Ed25519Secret ed25519 over JCS(payload)
*/
export function signClaim(payload: ClaimPayload, signer: Signer): SignedClaimEnvelope {
if (signer instanceof Ed25519Secret) {
const expectedKey = signer.identity().toString();
if (payload.primary !== expectedKey) {
throw new VerificationError(
`payload.primary (${payload.primary}) does not match signer (${expectedKey})`,
);
}
const jcs = canonicalBytes(payload);
const sig = signer.sign(jcs);
return {
kez: "claim",
payload,
signature: {
alg: ED25519_SHA512_ALG,
key: expectedKey,
sig: bytesToHex(sig),
},
};
}
// NostrSecret
const expectedKey = `nostr:${signer.npub()}`;
if (payload.primary !== expectedKey) {
throw new VerificationError(
`payload.primary (${payload.primary}) does not match signer (${expectedKey})`,
);
}
const digest = sha256(canonicalBytes(payload));
const sig = signer.signDigest(digest);
return {
kez: "claim",
payload,
signature: {
alg: NOSTR_SCHNORR_ALG,
key: expectedKey,
sig: bytesToHex(sig),
},
};
}
/** Verify a SignedClaimEnvelope. Throws on failure; returns status on success. */
export function verifyClaim(envelope: SignedClaimEnvelope): VerificationStatus {
if (envelope.kez !== "claim") {
throw new VerificationError(`envelope kez tag must be "claim", got: ${envelope.kez}`);
}
if (envelope.signature.key !== envelope.payload.primary) {
throw new VerificationError(
`signature.key (${envelope.signature.key}) does not match payload.primary (${envelope.payload.primary})`,
);
}
const primary = Identity.parse(envelope.payload.primary);
let sigBytes: Uint8Array;
try {
sigBytes = hexToBytes(envelope.signature.sig);
} catch (e) {
throw new VerificationError(`signature is not valid hex: ${(e as Error).message}`);
}
if (sigBytes.length !== 64) {
throw new VerificationError(`signature must be 64 bytes, got ${sigBytes.length}`);
}
switch (envelope.signature.alg) {
case NOSTR_SCHNORR_ALG: {
const pubkeyHex = nostrPubkeyHex(primary);
const digest = sha256(canonicalBytes(envelope.payload));
if (!verifySchnorr(sigBytes, digest, pubkeyHex)) {
throw new VerificationError("schnorr signature did not verify");
}
break;
}
case ED25519_SHA512_ALG: {
if (primary.scheme !== "ed25519") {
throw new VerificationError(
`ed25519 alg requires ed25519: primary, got: ${primary}`,
);
}
const jcs = canonicalBytes(envelope.payload);
if (!verifyEd25519(sigBytes, jcs, primary.id)) {
throw new VerificationError("ed25519 signature did not verify");
}
break;
}
default:
throw new VerificationError(`unsupported algorithm: ${envelope.signature.alg}`);
}
return {
primary,
verified: [Identity.parse(envelope.payload.subject)],
status: "valid",
confidence: "strong",
};
}

View File

@ -0,0 +1,85 @@
// Ed25519 primary keys (RFC 8032). Suite: `ed25519-sha512-jcs`.
//
// Identifier shape: `ed25519:<64-char-lowercase-hex>` matching Rust exactly.
// PureEdDSA signs the message bytes directly — the library does the
// SHA-512 internally — so we sign JCS(payload) without pre-hashing.
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { Identity, IdentityError } from "./identity.js";
export class Ed25519Secret {
private readonly seed: Uint8Array; // 32 bytes
private constructor(seed: Uint8Array) {
if (seed.length !== 32) {
throw new Error(`ed25519 seed must be 32 bytes, got ${seed.length}`);
}
this.seed = seed;
}
static generate(): Ed25519Secret {
return new Ed25519Secret(ed25519.utils.randomPrivateKey());
}
static fromSeedHex(seedHex: string): Ed25519Secret {
let bytes: Uint8Array;
try {
bytes = hexToBytes(seedHex);
} catch (e) {
throw new IdentityError(`invalid ed25519 seed hex: ${(e as Error).message}`);
}
if (bytes.length !== 32) {
throw new IdentityError(
`ed25519 seed must be 32 bytes / 64 hex chars, got ${bytes.length} bytes`,
);
}
return new Ed25519Secret(bytes);
}
/** 32-byte secret seed as lowercase hex. */
seedHex(): string {
return bytesToHex(this.seed);
}
/** 32-byte public key as lowercase hex. */
pubkeyHex(): string {
return bytesToHex(ed25519.getPublicKey(this.seed));
}
identity(): Identity {
return Identity.parse(`ed25519:${this.pubkeyHex()}`);
}
/** Sign raw bytes (no pre-hash — PureEdDSA does SHA-512 internally). */
sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.seed);
}
}
/** Verify a 64-byte Ed25519 signature over `message` against `pubkeyHex`. */
export function verifyEd25519(
signature: Uint8Array,
message: Uint8Array,
pubkeyHex: string,
): boolean {
return ed25519.verify(signature, message, pubkeyHex);
}
/** Validate the canonical `ed25519:<64-lowercase-hex>` form. */
export function validateEd25519Hex(value: string): void {
if (value.length !== 64) {
throw new IdentityError(
`ed25519 pubkey must be 64 hex chars, got ${value.length}`,
);
}
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
const isLowerHex =
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x61 && c <= 0x66); // a-f
if (!isLowerHex) {
throw new IdentityError(`ed25519 pubkey must be lowercase hex: ${value}`);
}
}
}

View File

@ -0,0 +1,109 @@
// Four wire encodings: JSON, compact (kez:z1:...), Markdown fence, legacy
// DNS (kez1:...). Round-trips match Rust exactly.
import { base64url } from "@scure/base";
import { zstdCompressSync, zstdDecompressSync } from "node:zlib";
import {
type SignedClaimEnvelope,
COMPACT_PROOF_PREFIX,
} from "./envelope.js";
import { Identity } from "./identity.js";
const MARKDOWN_FENCE = "```kez";
// ─────────────────────────────────────────────────────────────────────────────
// JSON
// ─────────────────────────────────────────────────────────────────────────────
export function toPrettyJson(envelope: SignedClaimEnvelope): string {
return JSON.stringify(envelope, null, 2);
}
export function fromJson(json: string): SignedClaimEnvelope {
return JSON.parse(json) as SignedClaimEnvelope;
}
// ─────────────────────────────────────────────────────────────────────────────
// Compact: kez:z1:<base64url-no-pad(zstd(json))>
// ─────────────────────────────────────────────────────────────────────────────
export function toCompact(envelope: SignedClaimEnvelope): string {
const json = new TextEncoder().encode(JSON.stringify(envelope));
const compressed = zstdCompressSync(json);
return (
COMPACT_PROOF_PREFIX +
base64url.encode(new Uint8Array(compressed)).replace(/=+$/, "")
);
}
export function fromCompact(value: string): SignedClaimEnvelope {
const trimmed = value.trim();
if (!trimmed.startsWith(COMPACT_PROOF_PREFIX)) {
throw new Error("compact proof missing kez:z1: prefix");
}
const body = trimmed.slice(COMPACT_PROOF_PREFIX.length);
// @scure/base's base64url requires standard padding; restore it.
const padded = body + "=".repeat((4 - (body.length % 4)) % 4);
const compressed = base64url.decode(padded);
const json = zstdDecompressSync(compressed);
return JSON.parse(new TextDecoder().decode(json)) as SignedClaimEnvelope;
}
// ─────────────────────────────────────────────────────────────────────────────
// Markdown fence: ```kez ... ```
// ─────────────────────────────────────────────────────────────────────────────
export function toMarkdown(envelope: SignedClaimEnvelope): string {
const json = toPrettyJson(envelope);
return [
"# KEZ Proof",
"",
"This account publishes a signed KEZ identity claim.",
"",
`- Primary: \`${envelope.payload.primary}\``,
`- Subject: \`${envelope.payload.subject}\``,
`- Created: \`${envelope.payload.created_at}\``,
"",
MARKDOWN_FENCE,
json,
"```",
"",
].join("\n");
}
export function extractMarkdownProof(markdown: string): SignedClaimEnvelope {
const start = markdown.indexOf(MARKDOWN_FENCE);
if (start < 0) {
throw new Error("missing ```kez proof block");
}
const bodyStart = start + MARKDOWN_FENCE.length;
const endRel = markdown.slice(bodyStart).indexOf("```");
if (endRel < 0) {
throw new Error("unterminated ```kez proof block");
}
const json = markdown.slice(bodyStart, bodyStart + endRel).trim();
return JSON.parse(json) as SignedClaimEnvelope;
}
// ─────────────────────────────────────────────────────────────────────────────
// DNS TXT
// ─────────────────────────────────────────────────────────────────────────────
export function dnsTxtName(identity: Identity): string {
if (identity.scheme !== "dns") {
throw new Error(`dns_txt_name requires dns: identity, got: ${identity}`);
}
return `_kez.${identity.id}`;
}
/** Legacy `kez1:` DNS encoding — Rust parser still accepts it. */
export function dnsTxtValue(envelope: SignedClaimEnvelope): string {
return "kez1:" + JSON.stringify(envelope);
}
export function parseDnsTxtValue(value: string): SignedClaimEnvelope {
if (!value.startsWith("kez1:")) {
throw new Error("DNS TXT proof missing kez1: prefix");
}
return JSON.parse(value.slice("kez1:".length)) as SignedClaimEnvelope;
}

View File

@ -0,0 +1,76 @@
// Wire types: claim payload, signature block, full envelope.
// Field names and ordering match Rust exactly so JCS bytes are identical.
import type { Identity } from "./identity.js";
export const CLAIM_TYPE = "kez.claim";
export const SIGCHAIN_EVENT_TYPE = "kez.sigchain.event";
export const FORMAT_VERSION = 1;
export const NOSTR_SCHNORR_ALG = "nostr-secp256k1-schnorr-sha256-jcs";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const COMPACT_PROOF_PREFIX = "kez:z1:";
export const COMPACT_CHAIN_PREFIX = "kez:zc1:";
export interface ClaimPayload {
type: typeof CLAIM_TYPE;
version: number;
subject: string; // serialized Identity
primary: string; // serialized Identity
created_at: string; // RFC 3339
expires_at?: string;
}
export interface SignatureBlock {
alg: string;
key: string; // serialized Identity (matches `primary`)
sig: string; // hex
}
export interface SignedClaimEnvelope {
kez: "claim";
payload: ClaimPayload;
signature: SignatureBlock;
}
/**
* Spec §6 sigchain event payload. Field order/names match Rust exactly so
* JCS-canonicalized bytes are byte-identical across implementations.
*/
export interface SigchainEventPayload {
type: typeof SIGCHAIN_EVENT_TYPE;
version: number;
primary: string; // serialized Identity, e.g. "nostr:npub1..."
seq: number;
prev?: string; // "sha256:<hex>" of prior envelope; omitted iff seq === 0
created_at: string;
op: SigchainOp;
payload: Record<string, unknown>;
}
export type SigchainOp = "add" | "revoke" | "rotate" | "add_device";
export interface SignedSigchainEvent {
kez: "sigchain_event";
payload: SigchainEventPayload;
signature: SignatureBlock;
}
/** Build a fresh ClaimPayload with the right `type` and `version` fields. */
export function newClaimPayload(
subject: Identity,
primary: Identity,
createdAt: Date,
): ClaimPayload {
return {
type: CLAIM_TYPE,
version: FORMAT_VERSION,
subject: subject.toString(),
primary: primary.toString(),
created_at: rfc3339Utc(createdAt),
};
}
/** RFC 3339 / ISO 8601 in UTC, matching the format Rust's chrono emits. */
export function rfc3339Utc(date: Date): string {
return date.toISOString().replace(/Z$/, "Z");
}

View File

@ -0,0 +1,102 @@
// KEZ identifiers: always `system:value`. Mirrors Rust's `Identity` type.
import { bech32 } from "@scure/base";
export class IdentityError extends Error {
constructor(message: string) {
super(message);
this.name = "IdentityError";
}
}
export class Identity {
/** Internal canonical form (`system:value`). */
readonly value: string;
private constructor(canonical: string) {
this.value = canonical;
}
/** Parse a KEZ identifier. Bare `npub1...` is normalized to `nostr:npub1...`. */
static parse(raw: string): Identity {
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new IdentityError(`empty identity: "${raw}"`);
}
if (trimmed.startsWith("npub1")) {
validateNpub(trimmed);
return new Identity(`nostr:${trimmed}`);
}
const colon = trimmed.indexOf(":");
if (colon <= 0 || colon === trimmed.length - 1) {
throw new IdentityError(`invalid identity (need scheme:value): "${raw}"`);
}
const scheme = trimmed.slice(0, colon);
const rest = trimmed.slice(colon + 1);
if (scheme === "nostr") {
validateNpub(rest);
} else if (scheme === "ed25519") {
validateEd25519HexShape(rest);
}
return new Identity(`${scheme}:${rest}`);
}
get scheme(): string {
const i = this.value.indexOf(":");
return i < 0 ? "" : this.value.slice(0, i);
}
get id(): string {
const i = this.value.indexOf(":");
return i < 0 ? "" : this.value.slice(i + 1);
}
toString(): string {
return this.value;
}
toJSON(): string {
return this.value;
}
equals(other: Identity): boolean {
return this.value === other.value;
}
}
/** Validate the canonical ed25519 pubkey shape (64 lowercase hex chars). */
function validateEd25519HexShape(value: string): void {
if (value.length !== 64) {
throw new IdentityError(`ed25519 pubkey must be 64 hex chars, got ${value.length}`);
}
for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i);
const ok =
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x61 && c <= 0x66); // a-f
if (!ok) {
throw new IdentityError(`ed25519 pubkey must be lowercase hex: ${value}`);
}
}
}
/** Validate that `npub` is a well-formed bech32 npub1 string. */
export function validateNpub(npub: string): void {
try {
const decoded = bech32.decode(npub as `${string}1${string}`, 1023);
if (decoded.prefix !== "npub") {
throw new IdentityError(`expected npub bech32, got hrp=${decoded.prefix}`);
}
const bytes = bech32.fromWords(decoded.words);
if (bytes.length !== 32) {
throw new IdentityError(`npub must decode to 32 bytes, got ${bytes.length}`);
}
} catch (e) {
if (e instanceof IdentityError) throw e;
throw new IdentityError(`invalid npub: ${(e as Error).message}`);
}
}

View File

@ -0,0 +1,60 @@
// Public surface of @kez/core. Mirrors the Rust crate's pub exports.
export { Identity, IdentityError, validateNpub } from "./identity.js";
export {
NostrSecret,
nostrPubkeyHex,
npubToPubkeyBytes,
verifySchnorr,
} from "./nostr.js";
export {
Ed25519Secret,
validateEd25519Hex,
verifyEd25519,
} from "./ed25519.js";
export {
CLAIM_TYPE,
COMPACT_CHAIN_PREFIX,
COMPACT_PROOF_PREFIX,
ED25519_SHA512_ALG,
FORMAT_VERSION,
NOSTR_SCHNORR_ALG,
SIGCHAIN_EVENT_TYPE,
newClaimPayload,
rfc3339Utc,
type ClaimPayload,
type SignatureBlock,
type SignedClaimEnvelope,
type SigchainEventPayload,
type SigchainOp,
type SignedSigchainEvent,
} from "./envelope.js";
export {
signClaim,
verifyClaim,
VerificationError,
type Signer,
type VerificationStatus,
} from "./claim.js";
export {
Sigchain,
SigchainError,
eventHash,
eventSubject,
newAddPayload,
newRevokePayload,
signSigchainEvent,
verifySigchainEvent,
} from "./sigchain.js";
export {
toPrettyJson,
fromJson,
toCompact,
fromCompact,
toMarkdown,
extractMarkdownProof,
dnsTxtName,
dnsTxtValue,
parseDnsTxtValue,
} from "./encodings.js";
export { canonicalBytes, canonicalString } from "./jcs.js";

View File

@ -0,0 +1,25 @@
// JSON Canonicalization Scheme (RFC 8785) — produces deterministic bytes
// for signing. Wraps the `canonicalize` package so we have a single chokepoint.
//
// Cross-implementation requirement: byte-identical output to Rust's
// `serde_jcs` for the claim and sigchain shapes we sign.
import canonicalize from "canonicalize";
/** Canonical UTF-8 bytes of `value` per RFC 8785. */
export function canonicalBytes(value: unknown): Uint8Array {
const text = canonicalize(value);
if (text === undefined) {
throw new Error("canonicalize returned undefined (value contained undefined?)");
}
return new TextEncoder().encode(text);
}
/** Canonical string form (UTF-8 decoded) — useful for debugging interop. */
export function canonicalString(value: unknown): string {
const text = canonicalize(value);
if (text === undefined) {
throw new Error("canonicalize returned undefined");
}
return text;
}

View File

@ -0,0 +1,92 @@
// Nostr/secp256k1 primary keys. Schnorr (BIP-340), bech32 nsec/npub.
// Mirrors Rust's NostrSecret. Signatures are deterministic (auxRand = zeros)
// to match the Rust `sign_schnorr_no_aux_rand` path.
import { schnorr } from "@noble/curves/secp256k1";
import { bech32 } from "@scure/base";
import { bytesToHex } from "@noble/hashes/utils";
import { Identity, validateNpub } from "./identity.js";
const ZERO_AUX = new Uint8Array(32);
export class NostrSecret {
private readonly secretKey: Uint8Array; // 32 bytes
private constructor(secretKey: Uint8Array) {
if (secretKey.length !== 32) {
throw new Error(`secret key must be 32 bytes, got ${secretKey.length}`);
}
this.secretKey = secretKey;
}
static generate(): NostrSecret {
return new NostrSecret(schnorr.utils.randomPrivateKey());
}
static fromNsec(nsec: string): NostrSecret {
const decoded = bech32.decode(nsec as `${string}1${string}`, 1023);
if (decoded.prefix !== "nsec") {
throw new Error(`expected nsec bech32, got hrp=${decoded.prefix}`);
}
const bytes = bech32.fromWords(decoded.words);
return new NostrSecret(Uint8Array.from(bytes));
}
/** Lowercase 32-byte x-only public key, hex-encoded. */
pubkeyHex(): string {
return bytesToHex(schnorr.getPublicKey(this.secretKey));
}
nsec(): string {
return bech32.encode("nsec", bech32.toWords(this.secretKey), 1023);
}
npub(): string {
const pubkey = schnorr.getPublicKey(this.secretKey);
return bech32.encode("npub", bech32.toWords(pubkey), 1023);
}
identity(): Identity {
return Identity.parse(`nostr:${this.npub()}`);
}
/**
* Sign a digest with deterministic Schnorr (zero auxRand) to match Rust's
* `sign_schnorr_no_aux_rand`. Returns a 64-byte BIP-340 signature.
*/
signDigest(digest: Uint8Array): Uint8Array {
if (digest.length !== 32) {
throw new Error(`digest must be 32 bytes, got ${digest.length}`);
}
return schnorr.sign(digest, this.secretKey, ZERO_AUX);
}
}
/** Lowercase 32-byte x-only public key (hex) for a `nostr:npub1...` identity. */
export function nostrPubkeyHex(identity: Identity): string {
if (identity.scheme !== "nostr") {
throw new Error(`expected nostr: identity, got: ${identity}`);
}
const decoded = bech32.decode(identity.id as `${string}1${string}`, 1023);
if (decoded.prefix !== "npub") {
throw new Error(`expected npub bech32, got hrp=${decoded.prefix}`);
}
return bytesToHex(Uint8Array.from(bech32.fromWords(decoded.words)));
}
/** Verify a Schnorr signature over a 32-byte digest. */
export function verifySchnorr(
signature: Uint8Array,
digest: Uint8Array,
pubkeyHex: string,
): boolean {
// Normalize hex → bytes via noble's utility (accepts hex strings or bytes).
return schnorr.verify(signature, digest, pubkeyHex);
}
/** Decode an npub to its raw x-only pubkey bytes. */
export function npubToPubkeyBytes(npub: string): Uint8Array {
validateNpub(npub);
const decoded = bech32.decode(npub as `${string}1${string}`, 1023);
return Uint8Array.from(bech32.fromWords(decoded.words));
}

View File

@ -0,0 +1,387 @@
// Sigchain — append-only, validated chain of signed events for one primary.
// Mirrors Rust's `Sigchain` exactly so the JCS bytes round-trip across impls.
import { base64url } from "@scure/base";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { zstdCompressSync, zstdDecompressSync } from "node:zlib";
import { canonicalBytes } from "./jcs.js";
import {
COMPACT_CHAIN_PREFIX,
ED25519_SHA512_ALG,
FORMAT_VERSION,
NOSTR_SCHNORR_ALG,
SIGCHAIN_EVENT_TYPE,
type SigchainEventPayload,
type SigchainOp,
type SignedSigchainEvent,
} from "./envelope.js";
import { Identity } from "./identity.js";
import { Ed25519Secret, verifyEd25519 } from "./ed25519.js";
import { NostrSecret, nostrPubkeyHex, verifySchnorr } from "./nostr.js";
import type { Signer } from "./claim.js";
export class SigchainError extends Error {
readonly code:
| "WrongPrimary"
| "SeqMismatch"
| "PrevMismatch"
| "BadSignature"
| "WrongEnvelopeTag"
| "Empty"
| "BadJsonl";
constructor(code: SigchainError["code"], message: string) {
super(message);
this.code = code;
this.name = "SigchainError";
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Payload constructors — match Rust's SigchainEventPayload::new_add / new_revoke
// ─────────────────────────────────────────────────────────────────────────────
/**
* Field insertion order matches Rust's struct field order so that raw
* `JSON.stringify` produces byte-identical output across implementations.
* Signature verification doesn't require this (JCS sorts keys), but
* downstream consumers (`to_jsonl`, on-wire storage) do.
*/
function buildPayload(
primary: Identity,
seq: number,
prev: string | undefined,
createdAt: Date,
op: SigchainOp,
payload: Record<string, unknown>,
): SigchainEventPayload {
// Order matters: type, version, primary, seq, prev?, created_at, op, payload.
const result = {
type: SIGCHAIN_EVENT_TYPE,
version: FORMAT_VERSION,
primary: primary.toString(),
seq,
} as SigchainEventPayload;
if (prev !== undefined) result.prev = prev;
result.created_at = createdAt.toISOString();
result.op = op;
result.payload = payload;
return result;
}
export function newAddPayload(
primary: Identity,
seq: number,
prev: string | undefined,
subject: Identity,
proofUrl: string | undefined,
createdAt: Date,
): SigchainEventPayload {
const payload: Record<string, unknown> = { subject: subject.toString() };
if (proofUrl !== undefined) payload.proof_url = proofUrl;
return buildPayload(primary, seq, prev, createdAt, "add", payload);
}
export function newRevokePayload(
primary: Identity,
seq: number,
prev: string | undefined,
subject: Identity,
createdAt: Date,
): SigchainEventPayload {
return buildPayload(primary, seq, prev, createdAt, "revoke", {
subject: subject.toString(),
});
}
/** Extract the `subject` field for `add`/`revoke` payloads. */
export function eventSubject(event: SignedSigchainEvent): Identity | undefined {
const s = event.payload.payload.subject;
if (typeof s !== "string") return undefined;
try {
return Identity.parse(s);
} catch {
return undefined;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sign / verify a single sigchain event
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sign a sigchain event payload. Does NOT check that `payload.primary` matches
* the signer (matches Rust's `SignedSigchainEvent::sign_with`); cross-key
* mismatches are caught later by `verifySigchainEvent` / `Sigchain.append`.
*/
export function signSigchainEvent(
payload: SigchainEventPayload,
signer: Signer,
): SignedSigchainEvent {
if (signer instanceof Ed25519Secret) {
const key = signer.identity().toString();
const jcs = canonicalBytes(payload);
const sig = signer.sign(jcs);
return {
kez: "sigchain_event",
payload,
signature: { alg: ED25519_SHA512_ALG, key, sig: bytesToHex(sig) },
};
}
// NostrSecret
const key = `nostr:${signer.npub()}`;
const digest = sha256(canonicalBytes(payload));
const sig = signer.signDigest(digest);
return {
kez: "sigchain_event",
payload,
signature: { alg: NOSTR_SCHNORR_ALG, key, sig: bytesToHex(sig) },
};
}
export function verifySigchainEvent(event: SignedSigchainEvent): void {
if (event.kez !== "sigchain_event") {
throw new SigchainError("WrongEnvelopeTag", `expected sigchain_event, got: ${event.kez}`);
}
if (event.signature.key !== event.payload.primary) {
throw new SigchainError(
"BadSignature",
`signature.key (${event.signature.key}) != payload.primary (${event.payload.primary})`,
);
}
const primary = Identity.parse(event.payload.primary);
let sigBytes: Uint8Array;
try {
sigBytes = hexToBytes(event.signature.sig);
} catch (e) {
throw new SigchainError("BadSignature", `sig not valid hex: ${(e as Error).message}`);
}
if (sigBytes.length !== 64) {
throw new SigchainError("BadSignature", `sig must be 64 bytes, got ${sigBytes.length}`);
}
switch (event.signature.alg) {
case NOSTR_SCHNORR_ALG: {
const pubkeyHex = nostrPubkeyHex(primary);
const digest = sha256(canonicalBytes(event.payload));
if (!verifySchnorr(sigBytes, digest, pubkeyHex)) {
throw new SigchainError("BadSignature", "schnorr verify failed");
}
return;
}
case ED25519_SHA512_ALG: {
if (primary.scheme !== "ed25519") {
throw new SigchainError("BadSignature", `ed25519 alg requires ed25519: primary`);
}
const jcs = canonicalBytes(event.payload);
if (!verifyEd25519(sigBytes, jcs, primary.id)) {
throw new SigchainError("BadSignature", "ed25519 verify failed");
}
return;
}
default:
throw new SigchainError("BadSignature", `unsupported alg: ${event.signature.alg}`);
}
}
/** `sha256:<hex>` of the JCS-canonicalized envelope. */
export function eventHash(event: SignedSigchainEvent): string {
const bytes = canonicalBytes(event);
return `sha256:${bytesToHex(sha256(bytes))}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain — ordered, validated chain
// ─────────────────────────────────────────────────────────────────────────────
export class Sigchain {
private readonly _primary: Identity;
private readonly _events: SignedSigchainEvent[];
private constructor(primary: Identity, events: SignedSigchainEvent[]) {
this._primary = primary;
this._events = events;
}
static create(primary: Identity): Sigchain {
return new Sigchain(primary, []);
}
get primary(): Identity {
return this._primary;
}
get length(): number {
return this._events.length;
}
get isEmpty(): boolean {
return this._events.length === 0;
}
events(): readonly SignedSigchainEvent[] {
return this._events;
}
head(): SignedSigchainEvent | undefined {
return this._events[this._events.length - 1];
}
headHash(): string | undefined {
const h = this.head();
return h === undefined ? undefined : eventHash(h);
}
nextSeq(): number {
const h = this.head();
return h === undefined ? 0 : h.payload.seq + 1;
}
/** Append a signed event after re-running the spec §6.2 integrity rules. */
append(event: SignedSigchainEvent): void {
if (event.kez !== "sigchain_event") {
throw new SigchainError("WrongEnvelopeTag", `expected sigchain_event, got: ${event.kez}`);
}
if (event.payload.primary !== this._primary.toString()) {
throw new SigchainError(
"WrongPrimary",
`expected primary ${this._primary}, got ${event.payload.primary}`,
);
}
const expectedSeq = this.nextSeq();
if (event.payload.seq !== expectedSeq) {
throw new SigchainError(
"SeqMismatch",
`expected seq ${expectedSeq}, got ${event.payload.seq}`,
);
}
const expectedPrev = this.headHash();
if (event.payload.prev !== expectedPrev) {
throw new SigchainError(
"PrevMismatch",
`expected prev ${expectedPrev ?? "<none>"}, got ${event.payload.prev ?? "<none>"}`,
);
}
verifySigchainEvent(event);
this._events.push(event);
}
/** Re-validate the entire chain from scratch. */
validate(): void {
const rebuilt = Sigchain.create(this._primary);
for (const e of this._events) rebuilt.append(e);
}
isRevoked(subject: Identity): boolean {
for (let i = this._events.length - 1; i >= 0; i--) {
const s = eventSubject(this._events[i]);
if (s && s.equals(subject)) return this._events[i].payload.op === "revoke";
}
return false;
}
isActive(subject: Identity): boolean {
for (let i = this._events.length - 1; i >= 0; i--) {
const s = eventSubject(this._events[i]);
if (s && s.equals(subject)) return this._events[i].payload.op === "add";
}
return false;
}
/** Convenience: build, sign, and append an `add` event. */
signAdd(subject: Identity, proofUrl: string | undefined, signer: Signer): SignedSigchainEvent {
const payload = newAddPayload(
this._primary,
this.nextSeq(),
this.headHash(),
subject,
proofUrl,
new Date(),
);
const signed = signSigchainEvent(payload, signer);
this.append(signed);
return signed;
}
/** Convenience: build, sign, and append a `revoke` event. */
signRevoke(subject: Identity, signer: Signer): SignedSigchainEvent {
const payload = newRevokePayload(
this._primary,
this.nextSeq(),
this.headHash(),
subject,
new Date(),
);
const signed = signSigchainEvent(payload, signer);
this.append(signed);
return signed;
}
/** JSONL — one envelope per line. The portable bundle format. */
toJsonl(): string {
return this._events.map((e) => JSON.stringify(e)).join("\n") + (this._events.length ? "\n" : "");
}
/** `kez:zc1:<base64url-no-pad(zstd(jsonl))>` — single-string portable form. */
toCompactBundle(): string {
const jsonl = this.toJsonl();
const compressed = zstdCompressSync(Buffer.from(jsonl, "utf8"));
return (
COMPACT_CHAIN_PREFIX +
base64url.encode(new Uint8Array(compressed)).replace(/=+$/, "")
);
}
static fromJsonl(text: string): Sigchain {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) {
throw new SigchainError("BadJsonl", "empty input");
}
let first: SignedSigchainEvent;
try {
first = JSON.parse(lines[0]) as SignedSigchainEvent;
} catch (e) {
throw new SigchainError("BadJsonl", `line 0: ${(e as Error).message}`);
}
const chain = Sigchain.create(Identity.parse(first.payload.primary));
chain.append(first);
for (let i = 1; i < lines.length; i++) {
let ev: SignedSigchainEvent;
try {
ev = JSON.parse(lines[i]) as SignedSigchainEvent;
} catch (e) {
throw new SigchainError("BadJsonl", `line ${i}: ${(e as Error).message}`);
}
chain.append(ev);
}
return chain;
}
static fromCompactBundle(value: string): Sigchain {
const trimmed = value.trim();
if (!trimmed.startsWith(COMPACT_CHAIN_PREFIX)) {
throw new SigchainError(
"BadJsonl",
`missing ${COMPACT_CHAIN_PREFIX} prefix`,
);
}
const body = trimmed.slice(COMPACT_CHAIN_PREFIX.length);
const padded = body + "=".repeat((4 - (body.length % 4)) % 4);
let compressed: Uint8Array;
try {
compressed = base64url.decode(padded);
} catch (e) {
throw new SigchainError("BadJsonl", `base64url: ${(e as Error).message}`);
}
const jsonl = zstdDecompressSync(compressed);
return Sigchain.fromJsonl(new TextDecoder().decode(jsonl));
}
}
// Lookups used by sign/verify here to keep one type the export surface. Avoids
// circular imports: this module brings together Identity + crypto + envelope.
export { NostrSecret, Ed25519Secret };

View File

@ -0,0 +1,246 @@
import { describe, expect, it } from "vitest";
import {
COMPACT_PROOF_PREFIX,
ED25519_SHA512_ALG,
Ed25519Secret,
Identity,
IdentityError,
NOSTR_SCHNORR_ALG,
NostrSecret,
VerificationError,
canonicalBytes,
dnsTxtName,
dnsTxtValue,
extractMarkdownProof,
fromCompact,
fromJson,
newClaimPayload,
nostrPubkeyHex,
parseDnsTxtValue,
signClaim,
toCompact,
toMarkdown,
toPrettyJson,
verifyClaim,
} from "../src/index.js";
function makeSigned(subjectStr: string) {
const secret = NostrSecret.generate();
const primary = secret.identity();
const subject = Identity.parse(subjectStr);
const payload = newClaimPayload(subject, primary, new Date());
return { secret, primary, subject, signed: signClaim(payload, secret) };
}
describe("Identity", () => {
it("parses bare npub as nostr identity", () => {
const secret = NostrSecret.generate();
const npub = secret.npub();
const id = Identity.parse(npub);
expect(id.toString()).toBe(`nostr:${npub}`);
expect(id.scheme).toBe("nostr");
});
it("rejects invalid inputs", () => {
expect(() => Identity.parse("")).toThrow(IdentityError);
expect(() => Identity.parse(" ")).toThrow(IdentityError);
expect(() => Identity.parse("no-colon")).toThrow(IdentityError);
expect(() => Identity.parse(":missing-scheme")).toThrow(IdentityError);
expect(() => Identity.parse("scheme:")).toThrow(IdentityError);
expect(() => Identity.parse("nostr:not-a-real-npub")).toThrow(IdentityError);
});
it("splits scheme and id", () => {
const id = Identity.parse("github:jason");
expect(id.scheme).toBe("github");
expect(id.id).toBe("jason");
expect(id.toString()).toBe("github:jason");
});
});
describe("NostrSecret", () => {
it("round-trips nsec", () => {
const secret = NostrSecret.generate();
const nsec = secret.nsec();
const restored = NostrSecret.fromNsec(nsec);
expect(restored.npub()).toBe(secret.npub());
expect(restored.pubkeyHex()).toBe(secret.pubkeyHex());
});
it("nostrPubkeyHex returns 32-byte lowercase", () => {
const secret = NostrSecret.generate();
const hex = nostrPubkeyHex(secret.identity());
expect(hex).toHaveLength(64);
expect(hex).toBe(hex.toLowerCase());
});
});
describe("signClaim / verifyClaim", () => {
it("signs and verifies", () => {
const { signed, primary } = makeSigned("github:jason");
const status = verifyClaim(signed);
expect(status.status).toBe("valid");
expect(status.primary.equals(primary)).toBe(true);
expect(status.verified[0].toString()).toBe("github:jason");
});
it("rejects tampered subject", () => {
const { signed } = makeSigned("github:jason");
signed.payload.subject = "github:mallory";
expect(() => verifyClaim(signed)).toThrow(VerificationError);
});
it("rejects unsupported algorithm", () => {
const { signed } = makeSigned("github:jason");
signed.signature.alg = "made-up-suite";
expect(() => verifyClaim(signed)).toThrow(/unsupported algorithm/);
});
it("rejects signature.key mismatched against payload.primary", () => {
const { signed } = makeSigned("github:jason");
const other = NostrSecret.generate();
signed.signature.key = `nostr:${other.npub()}`;
expect(() => verifyClaim(signed)).toThrow(/does not match payload.primary/);
});
it("uses the expected algorithm string", () => {
const { signed } = makeSigned("github:jason");
expect(signed.signature.alg).toBe(NOSTR_SCHNORR_ALG);
});
});
describe("encodings", () => {
it("round-trips JSON", () => {
const { signed } = makeSigned("github:jason");
const json = toPrettyJson(signed);
const back = fromJson(json);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips compact", () => {
const { signed } = makeSigned("github:jason");
const compact = toCompact(signed);
expect(compact.startsWith(COMPACT_PROOF_PREFIX)).toBe(true);
const back = fromCompact(compact);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips markdown", () => {
const { signed } = makeSigned("github:jason");
const md = toMarkdown(signed);
expect(md).toContain("```kez");
const back = extractMarkdownProof(md);
expect(back).toEqual(signed);
verifyClaim(back);
});
it("round-trips legacy DNS TXT", () => {
const { signed } = makeSigned("dns:jason.example.com");
const txt = dnsTxtValue(signed);
expect(txt.startsWith("kez1:")).toBe(true);
const back = parseDnsTxtValue(txt);
expect(back).toEqual(signed);
});
it("extractMarkdownProof rejects malformed input", () => {
expect(() => extractMarkdownProof("no fence here")).toThrow();
expect(() => extractMarkdownProof("```kez\n{ unterminated")).toThrow();
});
it("fromCompact rejects missing prefix", () => {
expect(() => fromCompact("hello")).toThrow();
expect(() => fromCompact("kez1:foo")).toThrow();
});
});
describe("dns helpers", () => {
it("dnsTxtName requires dns: scheme", () => {
expect(dnsTxtName(Identity.parse("dns:jason.example.com"))).toBe(
"_kez.jason.example.com",
);
expect(() => dnsTxtName(Identity.parse("github:jason"))).toThrow();
});
});
describe("JCS", () => {
it("produces stable bytes for object regardless of key ordering", () => {
const a = canonicalBytes({ b: 1, a: 2, c: [3, 4] });
const b = canonicalBytes({ c: [3, 4], a: 2, b: 1 });
expect(a).toEqual(b);
});
});
describe("Ed25519Secret", () => {
it("round-trips seed hex", () => {
const secret = Ed25519Secret.generate();
const seed = secret.seedHex();
const restored = Ed25519Secret.fromSeedHex(seed);
expect(restored.pubkeyHex()).toBe(secret.pubkeyHex());
expect(restored.seedHex()).toBe(seed);
});
it("identity is lowercase 64-hex with ed25519: scheme", () => {
const secret = Ed25519Secret.generate();
const id = secret.identity();
expect(id.scheme).toBe("ed25519");
expect(id.id).toHaveLength(64);
expect(id.id).toBe(id.id.toLowerCase());
});
it("rejects malformed seed", () => {
expect(() => Ed25519Secret.fromSeedHex("notHex")).toThrow();
expect(() => Ed25519Secret.fromSeedHex("ab".repeat(31))).toThrow(/32 bytes/);
});
});
describe("Identity ed25519 validation", () => {
it("accepts well-formed identifiers", () => {
expect(() =>
Identity.parse(`ed25519:${"ab".repeat(32)}`),
).not.toThrow();
});
it("rejects malformed identifiers", () => {
expect(() => Identity.parse("ed25519:tooshort")).toThrow();
expect(() => Identity.parse(`ed25519:${"AB".repeat(32)}`)).toThrow();
expect(() => Identity.parse(`ed25519:${"Z".repeat(64)}`)).toThrow();
});
});
describe("Ed25519 signClaim / verifyClaim", () => {
function signEd25519(subjectStr: string) {
const secret = Ed25519Secret.generate();
const primary = secret.identity();
const subject = Identity.parse(subjectStr);
const payload = newClaimPayload(subject, primary, new Date());
return { secret, primary, subject, signed: signClaim(payload, secret) };
}
it("uses the ed25519 alg string", () => {
const { signed } = signEd25519("github:jason");
expect(signed.signature.alg).toBe(ED25519_SHA512_ALG);
});
it("signs and verifies", () => {
const { signed, primary } = signEd25519("github:jason");
const status = verifyClaim(signed);
expect(status.status).toBe("valid");
expect(status.primary.equals(primary)).toBe(true);
});
it("rejects tampered subject", () => {
const { signed } = signEd25519("github:jason");
signed.payload.subject = "github:mallory";
expect(() => verifyClaim(signed)).toThrow(VerificationError);
});
it("rejects ed25519 sig over non-ed25519 primary", () => {
const { signed } = signEd25519("github:jason");
// Forge a nostr-shaped primary while keeping the ed25519 alg.
const other = NostrSecret.generate();
signed.payload.primary = `nostr:${other.npub()}`;
signed.signature.key = signed.payload.primary;
expect(() => verifyClaim(signed)).toThrow(/ed25519/);
});
});

View File

@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
COMPACT_CHAIN_PREFIX,
Ed25519Secret,
Identity,
NostrSecret,
Sigchain,
SigchainError,
newAddPayload,
signSigchainEvent,
} from "../src/index.js";
function fresh() {
const s = NostrSecret.generate();
const id = Identity.parse(`nostr:${s.npub()}`);
return { secret: s, primary: id };
}
describe("Sigchain", () => {
it("appends and validates an add event", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
expect(chain.isEmpty).toBe(true);
expect(chain.nextSeq()).toBe(0);
const subject = Identity.parse("github:jason");
chain.signAdd(subject, undefined, secret);
expect(chain.length).toBe(1);
expect(chain.nextSeq()).toBe(1);
expect(chain.isActive(subject)).toBe(true);
expect(chain.isRevoked(subject)).toBe(false);
expect(() => chain.validate()).not.toThrow();
});
it("revoke flips isActive/isRevoked", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
const subject = Identity.parse("github:jason");
chain.signAdd(subject, undefined, secret);
chain.signRevoke(subject, secret);
expect(chain.isRevoked(subject)).toBe(true);
expect(chain.isActive(subject)).toBe(false);
expect(() => chain.validate()).not.toThrow();
});
it("rejects events for a different primary", () => {
const a = fresh();
const b = fresh();
const chain = Sigchain.create(a.primary);
const payload = newAddPayload(
b.primary, // wrong
0,
undefined,
Identity.parse("github:jason"),
undefined,
new Date(),
);
const signed = signSigchainEvent(payload, a.secret);
expect(() => chain.append(signed)).toThrow(SigchainError);
try {
chain.append(signed);
} catch (e) {
expect((e as SigchainError).code).toBe("WrongPrimary");
}
});
it("rejects seq skip", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
chain.signAdd(Identity.parse("github:a"), undefined, secret);
// hand-craft seq=2 with correct prev
const payload = newAddPayload(
primary,
2,
chain.headHash(),
Identity.parse("github:b"),
undefined,
new Date(),
);
const signed = signSigchainEvent(payload, secret);
try {
chain.append(signed);
expect.fail("expected SigchainError");
} catch (e) {
expect((e as SigchainError).code).toBe("SeqMismatch");
}
});
it("rejects bad prev hash", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
chain.signAdd(Identity.parse("github:a"), undefined, secret);
const payload = newAddPayload(
primary,
1,
"sha256:0000",
Identity.parse("github:b"),
undefined,
new Date(),
);
const signed = signSigchainEvent(payload, secret);
try {
chain.append(signed);
expect.fail("expected SigchainError");
} catch (e) {
expect((e as SigchainError).code).toBe("PrevMismatch");
}
});
it("round-trips JSONL", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
const subject = Identity.parse("github:jason");
chain.signAdd(subject, undefined, secret);
chain.signRevoke(subject, secret);
const jsonl = chain.toJsonl();
const restored = Sigchain.fromJsonl(jsonl);
expect(restored.length).toBe(chain.length);
expect(restored.isRevoked(subject)).toBe(true);
expect(restored.headHash()).toBe(chain.headHash());
});
it("round-trips compact bundle", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
chain.signAdd(Identity.parse("github:jason"), undefined, secret);
chain.signAdd(Identity.parse("dns:example.com"), undefined, secret);
const compact = chain.toCompactBundle();
expect(compact.startsWith(COMPACT_CHAIN_PREFIX)).toBe(true);
const restored = Sigchain.fromCompactBundle(compact);
expect(restored.length).toBe(2);
expect(restored.headHash()).toBe(chain.headHash());
});
it("fromJsonl detects tampering", () => {
const { secret, primary } = fresh();
const chain = Sigchain.create(primary);
chain.signAdd(Identity.parse("github:a"), undefined, secret);
chain.signAdd(Identity.parse("github:b"), undefined, secret);
let jsonl = chain.toJsonl();
jsonl = jsonl.replace("github:b", "github:c");
try {
Sigchain.fromJsonl(jsonl);
expect.fail("expected SigchainError");
} catch (e) {
const code = (e as SigchainError).code;
expect(["BadSignature", "PrevMismatch"]).toContain(code);
}
});
it("works with ed25519 signer", () => {
const secret = Ed25519Secret.generate();
const primary = secret.identity();
const chain = Sigchain.create(primary);
const subject = Identity.parse("github:jason");
chain.signAdd(subject, undefined, secret);
expect(chain.isActive(subject)).toBe(true);
expect(() => chain.validate()).not.toThrow();
});
});

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}

20
nodejs/tsconfig.base.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"composite": true
}
}

8
nodejs/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./packages/kez-core" },
{ "path": "./packages/kez-channels" },
{ "path": "./packages/kez-cli" }
]
}

9
nodejs/vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["packages/*/test/**/*.test.ts"],
pool: "threads",
testTimeout: 10_000,
},
});

2530
rust-sig-server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
[package]
name = "kez-sig-server"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
description = "Optional storage server for KEZ sigchains. One of many places to publish a chain — never required."
[dependencies]
anyhow = "1"
axum = "0.7"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
kez-core = { path = "../rust/crates/kez-core" }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync", "signal"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tempfile = "3"

498
rust-sig-server/README.md Normal file
View File

@ -0,0 +1,498 @@
# kez-sig-server
A central HTTP server that stores [KEZ](../SPEC.md) sigchains.
> A sigchain is a signed, append-only log of identity events for one KEZ
> primary key — "I added github:jason," "I revoked dns:old.example,"
> "I rotated my key." Verifiers walk it to answer questions like "is
> this identity still active?"
For now, this is **the** central server for sigchain storage in the KEZ
ecosystem. If you don't want to publish your sigchain anywhere else, this
server is enough on its own.
If the server is unavailable (down, blocked, or you just don't want to
trust one operator), the protocol lets you publish the same sigchain
through any of the channel plugins instead — **as a GitHub gist, a nostr
event, a `/.well-known/kez-sigchain.jsonl` on your own website**, etc.
Verifiers know how to fetch from any of those. The server is the easy
default; the channels are the always-available fallback.
---
## Why this exists
KEZ sigchains *can* be published to nostr relays, DNS TXT records,
`/.well-known/kez-sigchain.jsonl` on your own website, GitHub gists, IPFS,
ActivityPub profile fields, Bluesky posts, etc. Every one of those works,
and every one of those needs setup: domain + hosting, relay selection, a
gist you keep current, an IPFS pinning service.
This server exists to be the **easy default**:
- One binary, one SQLite file.
- POST your signed sigchain events to it.
- Anyone with the URL can fetch them.
The other protocol-level storage paths remain useful as backup or for users
who want full self-hosting, but most users will just point at this server
and be done.
### Important framing
- **The crypto is the auth** (see next section). No accounts.
- **Self-hostable.** Single binary, SQLite file, any host with outbound TCP.
No external services required. If you want to run your own instance for
privacy or independence, you can.
- **The spec leaves room for multiple instances** if that ever becomes
necessary. Today it's one.
---
## No auth — why?
Most web services that accept user data have user accounts: passwords, API
keys, OAuth tokens. This server has none of those. **There's nothing to log
into.** Anyone on the internet can `POST` to `/v1/sigchains/.../events`
and that's by design.
The reason it works: **every sigchain event is signed with the user's
private key.** The server can verify those signatures itself. So:
- A malicious attacker who doesn't have your private key can POST garbage all
day, but every event will fail signature verification and be rejected
with `400 Bad Request`. Nothing they send gets stored.
- A user who *does* have their private key signs an event with it; the
server's signature check passes; the event is stored.
The cryptography is the access control. No accounts, no tokens, no password
resets, no email verification. The model is the same one git uses for signed
commits: anyone can submit a signed object, but only the holder of the right
key can produce a valid one.
### What the server validates on every POST
Before accepting an event, the server (using [`kez-core`](../rust/crates/kez-core)'s
`Sigchain::append`) checks:
1. The envelope tag is `"sigchain_event"`.
2. The URL primary (`/v1/sigchains/<scheme>/<id>/events`) matches the
`event.payload.primary` field. (Prevents POSTing valid chains for key A
under key B's URL.)
3. `event.payload.seq` equals `head.seq + 1` for the existing chain
(or `0` if no chain exists yet).
4. `event.payload.prev` equals `sha256:<hex>` of the JCS-canonicalized
*envelope* of the prior event.
5. `event.signature.sig` verifies against `event.payload.primary` using the
algorithm in `event.signature.alg` (`nostr-secp256k1-schnorr-sha256-jcs`
or `ed25519-sha512-jcs`).
If **any** check fails, the event is rejected and **never stored**.
### Threat model
| Attacker | What they can do | Why it's fine |
|---|---|---|
| Has no private key | POST malformed events, hammer endpoints | All events rejected at signature check; rate-limit at proxy |
| Has someone else's private key | Append a `revoke` to that person's chain | They've already compromised the identity — this is downstream of that breach, not caused by us |
| Runs a malicious server | Serve a fake chain over `GET` | Clients are supposed to consult multiple sources; mismatch is detectable; signatures fail verification |
| Has the server admin login | Read/edit the SQLite file | Sigchains are public anyway; tampering breaks the hash chain, detectable on next fetch |
We don't protect against attackers who already have your private key —
that's outside the protocol's scope. We do guarantee that an attacker
without your key cannot publish a valid event to your chain on this server.
### What we DON'T do for auth (and shouldn't)
- ❌ User accounts / passwords / email verification
- ❌ API keys / tokens / OAuth
- ❌ Rate limits per "user" (no user concept; rate-limit per IP at the proxy)
- ❌ Captchas
- ❌ TLS client certificates
- ❌ Mutual auth
Any of those would add complexity without adding security. The cryptography
already does the job.
---
## Quick start
```sh
# Build
cargo build --release
# Run with defaults — binds 0.0.0.0:7878, uses ./kez-sigchains.db
cargo run --release
# Or with explicit flags
cargo run --release -- --bind 127.0.0.1:8080 --db /var/lib/kez/chains.db
```
Configuration:
| Flag | Env var | Default | Meaning |
|---|---|---|---|
| `--bind` | `KEZ_BIND` | `0.0.0.0:7878` | Address to listen on |
| `--db` | `KEZ_DB` | `kez-sigchains.db` | SQLite file path (created if missing) |
Logging via `RUST_LOG` (default `info`). Standard `tracing` filter syntax:
```sh
RUST_LOG=debug,hyper=info cargo run
```
---
## Try it: end-to-end POST → GET
```sh
# 1. Start the server
cargo run --release &
# 2. Health check
curl -s http://localhost:7878/v1/healthz
# → {"status":"ok"}
# 3. Generate a key + sign a seq-0 sigchain event using the kez CLI
# (Assumes you've built the main rust workspace too.)
cd ../rust
SEED=$(cargo run -q -p kez-cli -- identity new --key-type ed25519 \
| awk -F': *' '/^Secret:/ {sub(/ \(.*$/, "", $2); print $2}')
echo "Seed: $SEED"
# 4. POST the event (today: hand-build via kez-core; a `kez sigchain` CLI
# is on the roadmap and will make this one-line)
# For now see the integration tests in tests/http.rs for a worked example.
# 5. Fetch the chain back
PRIMARY="ed25519:<your-pubkey-hex>"
SCHEME=$(echo "$PRIMARY" | cut -d: -f1)
ID=$(echo "$PRIMARY" | cut -d: -f2)
curl -s http://localhost:7878/v1/sigchains/$SCHEME/$ID
# → JSONL of every event in this chain
```
---
## API reference
| Method | Path | Response |
|---|---|---|
| `GET` | `/v1/healthz` | `{"status":"ok"}` |
| `GET` | `/v1/sigchains/{scheme}/{id}` | `application/jsonl` — every event for this primary, in order |
| `GET` | `/v1/sigchains/{scheme}/{id}/head` | `application/json` — the latest event envelope |
| `POST` | `/v1/sigchains/{scheme}/{id}/events` | `application/json``{"seq": N, "hash": "sha256:..."}` on 201 |
The `{scheme}/{id}` path split represents a canonical KEZ identifier
(`nostr:npub1abc...` becomes `/nostr/npub1abc.../`). This keeps the colon
out of URL-encoded path segments.
### POST request body
A `SignedSigchainEvent` envelope, exactly as `kez-core` produces:
```json
{
"kez": "sigchain_event",
"payload": {
"type": "kez.sigchain.event",
"version": 1,
"primary": "ed25519:1bc0d49f0c5992961dec3f92afb55c06c93e91e392529417faac08cec9504ed8",
"seq": 0,
"created_at": "2026-05-24T19:00:00Z",
"op": "add",
"payload": { "subject": "github:jason" }
},
"signature": {
"alg": "ed25519-sha512-jcs",
"key": "ed25519:1bc0d49f0c5992961dec3f92afb55c06c93e91e392529417faac08cec9504ed8",
"sig": "<128-char-hex>"
}
}
```
### Success response
`201 Created`:
```json
{
"seq": 0,
"hash": "sha256:5e3a6f...the JCS sha256 of the envelope just stored"
}
```
The `hash` is what the next event's `prev` field must equal.
### Status codes & error shape
| Code | When | Body |
|---|---|---|
| `200 OK` | Chain fetched successfully | JSONL (or JSON for `/head`) |
| `201 Created` | Event appended | `{"seq", "hash"}` |
| `400 Bad Request` | Bad signature, wrong primary, malformed JSON, invalid envelope | Error JSON |
| `404 Not Found` | No chain exists for this primary | Error JSON |
| `409 Conflict` | Event doesn't extend the existing chain (wrong seq, bad prev, duplicate seq) | Error JSON |
| `500 Internal Server Error` | DB or other server-side failure | Error JSON |
Error response body:
```json
{
"error": {
"code": "conflict",
"message": "expected seq 3, got 5"
}
}
```
`code` is stable for programmatic handling; `message` is human-friendly and
may change.
---
## Storage
SQLite, one table:
```sql
CREATE TABLE sigchain_events (
primary_scheme TEXT NOT NULL,
primary_id TEXT NOT NULL,
seq INTEGER NOT NULL,
envelope_json TEXT NOT NULL,
envelope_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (primary_scheme, primary_id, seq)
);
CREATE INDEX idx_primary ON sigchain_events (primary_scheme, primary_id);
```
The `(primary_scheme, primary_id, seq)` primary key prevents duplicate-seq
inserts at the database level — even racing writers can't both win.
**Concurrency:** a single `tokio::sync::Mutex<Connection>` serializes all
DB access. At single-instance scale (one server, low per-identity write
rate) this is fine. Reads are cheap; writes are rare (one per identity
change). For horizontal scaling, swap rusqlite for Postgres with row-level
locks — the `Store` API surface stays the same.
**Backup:** the SQLite file is the entire server state. `cp kez-sigchains.db
backup-$(date +%F).db` while the server's stopped, or use SQLite's online
backup API while running. Sigchains are public, so unencrypted backups are
fine.
**Vacuum / pruning:** never needed for this workload. Sigchains are
append-only; deletions aren't part of the protocol. If you really need to
discard old data, drop the SQLite file and start fresh — clients that care
will re-POST their chains.
---
## Deployment
### Bare metal / VPS
```sh
cargo build --release
scp target/release/kez-sig-server user@host:/usr/local/bin/
# systemd unit:
cat > /etc/systemd/system/kez-sig-server.service <<EOF
[Unit]
Description=KEZ sigchain server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/kez-sig-server --db /var/lib/kez/chains.db
Restart=on-failure
User=kez
Group=kez
Environment=RUST_LOG=info
Environment=KEZ_BIND=127.0.0.1:7878
StateDirectory=kez
[Install]
WantedBy=multi-user.target
EOF
systemctl enable --now kez-sig-server
```
Put nginx or Caddy in front for TLS + rate limiting.
### Docker
```dockerfile
FROM rust:1.85-slim AS build
WORKDIR /src
COPY . .
RUN cargo build --release -p kez-sig-server
FROM debian:bookworm-slim
COPY --from=build /src/target/release/kez-sig-server /usr/local/bin/
RUN useradd -r kez && mkdir /data && chown kez:kez /data
USER kez
ENV KEZ_BIND=0.0.0.0:7878 KEZ_DB=/data/chains.db
VOLUME /data
EXPOSE 7878
ENTRYPOINT ["/usr/local/bin/kez-sig-server"]
```
```sh
docker build -t kez-sig-server .
docker run -d -p 7878:7878 -v kez-data:/data kez-sig-server
```
### Fly.io / Render / single-container PaaS
Same Docker image. Mount a persistent volume at `/data`. That's it.
### Reverse proxy notes
The server doesn't speak TLS itself — terminate at your reverse proxy
(nginx, Caddy, Cloudflare, etc). Recommended proxy config:
- **TLS** — Let's Encrypt is fine.
- **Per-IP rate limiting** — e.g. nginx `limit_req_zone $binary_remote_addr
zone=kez:10m rate=10r/s`. The server itself has no rate limiting and
doesn't need any beyond what your proxy provides.
- **Request size limit** — cap POST body at ~64 KB. Sigchain events are
tiny; anything larger is abuse.
- **CORS** — enabled wide-open in the binary (`Any` origin). Override in
your proxy if you want to restrict who can hit the API from a browser.
---
## How clients use this server
A KEZ client (CLI, web app, library) treats this server as **the** sigchain
store for now:
1. After a `kez sigchain add/revoke`, push the new event to the server.
2. During `kez verify id <identifier>`, fetch the relevant chain from the
server to check for revocations.
That's the happy path.
## Fallback: publishing the sigchain through existing channels
If you don't want to depend on this server (operator went silent, region
blocked, privacy preference, "I just don't trust any one place"), you can
publish your sigchain via the same channel plugins that already exist for
proofs. Same JSONL bundle, different transport. **Verifiers fetch from
whichever source they can reach.**
Concrete options, in order of ease:
| Where | How to publish | How a verifier fetches |
|---|---|---|
| **GitHub gist** | Create a public gist with a `kez-sigchain.jsonl` file | `github:` channel scans your gists, recognizes the filename, returns the chain |
| **Your own website** | Drop the file at `https://<domain>/.well-known/kez-sigchain.jsonl` | `web:` channel does a single HTTPS fetch |
| **DNS** | Publish a compact-encoded sigchain URL hint in a `_kez-chain.<domain>` TXT record | `dns:` channel reads the TXT, follows the URL |
| **Nostr** | Publish the chain as a kind-30078 event (one event per sigchain entry, or a single event holding a `kez:zc1:` compact bundle) | `nostr:` channel queries relays by your pubkey |
| **ActivityPub profile field** | Tight: only fits a URL hint, not the chain itself. Point at where the real bundle lives. | `ap:` channel reads the field, follows the hint |
| **Bluesky** | Pin a post containing the compact `kez:zc1:` bundle | `bluesky:` channel scans your feed |
A user who's worried about availability publishes to *both*: the server for
the easy path, plus a gist (or nostr event, or `/.well-known` URL) for the
"server is down" case. Verifiers consult both and take the longest valid
chain (spec §6.2).
The channel plugins that fetch proofs already exist
([`kez-channels`](../rust/crates/kez-channels)). Extending them to *also*
fetch sigchain bundles is straightforward implementation work in the
client; **the server itself doesn't change** to support any of this.
---
## Future: multiple instances
(Distinct from the channel-fallback story above — this is about running
several kez-sig-server instances, not about publishing through gists/nostr.)
We're starting with one server. The design doesn't preclude running more
later: each event is signed and self-validating, so two instances can't
meaningfully disagree about whether to accept an event. If we ever do
spin up additional instances, clients gain a configurable list of server
URLs and verifiers reconcile per spec §6.2's "longest valid chain wins"
rule. Server code and wire format stay the same.
Not on the roadmap today.
---
## Tests
```sh
cargo test
```
Spins up the real router on a random local port and drives it over real
HTTP with `reqwest`. No mocks. **10 integration scenarios:**
| # | Test | Asserts |
|---|---|---|
| 1 | `healthz_returns_ok` | Liveness endpoint works |
| 2 | `empty_chain_returns_404` | GET on unknown primary → 404 |
| 3 | `post_then_get_round_trip` | POST event, GET returns it as JSONL |
| 4 | `head_endpoint_returns_latest` | Two events posted; `/head` returns the second |
| 5 | `rejects_event_for_wrong_primary_url` | Event signed for key A, POSTed under B's URL → 400 |
| 6 | `rejects_bad_signature` | Tampered signature → 400 |
| 7 | `rejects_seq_skip` | seq 0 then seq 2 (skipping 1) → 409 |
| 8 | `rejects_bad_prev_hash` | Event with wrong `prev` → 409 |
| 9 | `rejects_duplicate_seq` | POSTing same event twice → 409 |
| 10 | `invalid_primary_in_url_is_bad_request` | Malformed identity in path → 400 |
The chain-validation logic lives in [`kez-core::Sigchain`](../rust/crates/kez-core/src/lib.rs)
and has its own unit tests (8 scenarios covering append/revoke/jsonl/
tamper-detection/wrong-primary/seq-skip/bad-prev/ed25519). The server is a
thin wrapper around that logic — most of the security comes from the
underlying type, not the HTTP layer.
---
## What this server explicitly does NOT do
| Anti-feature | Why |
|---|---|
| User accounts / API keys / login | Keys are the auth |
| Encryption at rest | Sigchains are public data |
| Push notifications / WebSockets | Poll `/head`; sigchain writes are rare |
| Search / discovery / browse UI | Out of scope; clients know which primary they want |
| Multi-tenant features (orgs, teams) | Sigchains are per-primary already |
| Soft deletes / "archived" rows | Append-only is the whole point |
| `rotate` / `add_device` op support | Land those in `kez-core` first; this server validates them automatically via `Sigchain::append` |
| Fork resolution / merge logic | Per spec §6.2: report forks, don't pick one. Multiple chains for same `(primary, seq)` are caught by the PK; the second one is rejected with 409 |
| Server-to-server replication / peer gossip | Not needed for a single-instance deployment. If we ever want it, see [Future: multiple instances](#future-multiple-instances) |
Keep the server boring. The interesting stuff lives in the protocol.
---
## Project layout
```
rust-sig-server/
├── Cargo.toml
├── README.md ← this file
├── src/
│ ├── main.rs binary: clap CLI, axum serve, graceful shutdown
│ ├── lib.rs re-exports (so tests can drive the router)
│ ├── api.rs HTTP routes + handlers
│ ├── store.rs SQLite-backed Store
│ └── error.rs typed ApiError → JSON response mapping
└── tests/
└── http.rs end-to-end integration tests (10 scenarios)
```
Total: ~600 lines of Rust including tests. The chain logic lives in
[`kez-core`](../rust/crates/kez-core); this crate is a thin HTTP wrapper
around it.
---
## License
Dual-licensed under MIT or Apache-2.0. See the parent repository's licence
files.

117
rust-sig-server/src/api.rs Normal file
View File

@ -0,0 +1,117 @@
//! HTTP API routes. Endpoints (all under `/v1`):
//!
//! POST /v1/sigchains/:scheme/:id/events append signed event
//! GET /v1/sigchains/:scheme/:id full chain as JSONL
//! GET /v1/sigchains/:scheme/:id/head latest event as JSON
//! GET /v1/healthz service liveness
//!
//! The path-segment split (`/:scheme/:id`) avoids percent-encoding the colon
//! that's part of canonical KEZ identifiers (`nostr:npub1...` etc).
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use kez_core::{Identity, SignedSigchainEvent};
use serde_json::json;
use crate::error::ApiError;
use crate::store::Store;
#[derive(Clone)]
pub struct AppState {
pub store: Store,
}
pub fn router(state: AppState) -> axum::Router {
axum::Router::new()
.route("/v1/healthz", get(healthz))
.route("/v1/sigchains/:scheme/:id", get(get_chain))
.route("/v1/sigchains/:scheme/:id/head", get(get_head))
.route("/v1/sigchains/:scheme/:id/events", post(post_event))
.with_state(state)
}
async fn healthz() -> impl IntoResponse {
Json(json!({ "status": "ok" }))
}
/// GET /v1/sigchains/:scheme/:id → `application/jsonl` body of all events.
async fn get_chain(
State(state): State<AppState>,
Path((scheme, id)): Path<(String, String)>,
) -> Result<impl IntoResponse, ApiError> {
let primary = parse_primary(&scheme, &id)?;
let chain = state.store.load_chain(&primary).await?;
if chain.is_empty() {
return Err(ApiError::NotFound);
}
let body = chain
.to_jsonl()
.map_err(|e| ApiError::Internal(format!("serialize: {e}")))?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/jsonl")],
body,
))
}
/// GET /v1/sigchains/:scheme/:id/head → JSON of the latest event.
async fn get_head(
State(state): State<AppState>,
Path((scheme, id)): Path<(String, String)>,
) -> Result<impl IntoResponse, ApiError> {
let primary = parse_primary(&scheme, &id)?;
match state.store.head(&primary).await? {
Some(event) => Ok(Json(event)),
None => Err(ApiError::NotFound),
}
}
/// POST /v1/sigchains/:scheme/:id/events
///
/// Body: one `SignedSigchainEvent` JSON envelope.
/// Server validates against the existing chain (re-running the full
/// `Sigchain::append` integrity rules) and stores on success.
async fn post_event(
State(state): State<AppState>,
Path((scheme, id)): Path<(String, String)>,
Json(event): Json<SignedSigchainEvent>,
) -> Result<impl IntoResponse, ApiError> {
let primary = parse_primary(&scheme, &id)?;
// The URL must match the event's declared primary. Otherwise a caller
// could POST a valid chain for key A to the URL of key B.
if event.payload.primary != primary {
return Err(ApiError::BadRequest(format!(
"event primary {} does not match URL primary {}",
event.payload.primary, primary
)));
}
// Load existing chain and try to append. The append() method does the
// full integrity check (envelope tag, primary, seq, prev, signature).
let mut chain = state.store.load_chain(&primary).await?;
chain.append(event.clone())?;
// Persist. The (primary, seq) PK on the table guards against a racing
// writer beating us to the same seq.
state.store.append(&primary, &event).await?;
let hash = event
.hash()
.map_err(|e| ApiError::Internal(format!("hash: {e}")))?;
Ok((
StatusCode::CREATED,
Json(json!({
"seq": event.payload.seq,
"hash": hash,
})),
))
}
fn parse_primary(scheme: &str, id: &str) -> Result<Identity, ApiError> {
Identity::parse(format!("{scheme}:{id}"))
.map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}")))
}

View File

@ -0,0 +1,88 @@
//! Structured API errors → JSON responses.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use kez_core::{KezError, SigchainError};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal: {0}")]
Internal(String),
}
impl ApiError {
fn status(&self) -> StatusCode {
match self {
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Conflict(_) => StatusCode::CONFLICT,
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
match self {
ApiError::NotFound => "not_found",
ApiError::BadRequest(_) => "bad_request",
ApiError::Conflict(_) => "conflict",
ApiError::Internal(_) => "internal",
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
let body = Json(json!({
"error": {
"code": self.code(),
"message": self.to_string(),
}
}));
(status, body).into_response()
}
}
/// Map sigchain-specific errors to HTTP status codes:
/// integrity failures = 409 Conflict (caller sent something that doesn't
/// extend the existing chain); signature failures = 400 Bad Request.
impl From<SigchainError> for ApiError {
fn from(e: SigchainError) -> Self {
match e {
SigchainError::BadSignature(_) => ApiError::BadRequest(e.to_string()),
SigchainError::WrongEnvelopeTag(_) => ApiError::BadRequest(e.to_string()),
SigchainError::WrongPrimary { .. } => ApiError::BadRequest(e.to_string()),
SigchainError::SeqMismatch { .. } => ApiError::Conflict(e.to_string()),
SigchainError::PrevMismatch { .. } => ApiError::Conflict(e.to_string()),
SigchainError::Empty => ApiError::NotFound,
SigchainError::BadJsonl(_) => ApiError::BadRequest(e.to_string()),
}
}
}
impl From<KezError> for ApiError {
fn from(e: KezError) -> Self {
ApiError::BadRequest(e.to_string())
}
}
impl From<rusqlite::Error> for ApiError {
fn from(e: rusqlite::Error) -> Self {
ApiError::Internal(format!("db: {e}"))
}
}
impl From<serde_json::Error> for ApiError {
fn from(e: serde_json::Error) -> Self {
ApiError::BadRequest(format!("json: {e}"))
}
}

View File

@ -0,0 +1,10 @@
//! KEZ sigchain server — library crate so integration tests can drive
//! the same router the binary serves.
pub mod api;
pub mod error;
pub mod store;
pub use api::{AppState, router};
pub use error::ApiError;
pub use store::Store;

View File

@ -0,0 +1,57 @@
//! Binary entry. Defaults to `0.0.0.0:7878`, SQLite file `kez-sigchains.db`
//! in the current directory.
use std::net::SocketAddr;
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use kez_sig_server::{AppState, Store, router};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(name = "kez-sig-server", about = "KEZ sigchain storage server")]
struct Cli {
/// Bind address.
#[arg(long, env = "KEZ_BIND", default_value = "0.0.0.0:7878")]
bind: SocketAddr,
/// SQLite database path.
#[arg(long, env = "KEZ_DB", default_value = "kez-sigchains.db")]
db: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let store = Store::open(&cli.db)?;
tracing::info!(db = ?cli.db, "opened sigchain store");
let app = router(AppState { store })
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
tracing::info!(addr = %cli.bind, "kez-sig-server listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutdown signal received");
}

View File

@ -0,0 +1,137 @@
//! SQLite-backed sigchain store. One table, one row per event.
//!
//! Concurrency: a single `tokio::sync::Mutex<Connection>` serializes all
//! writes. This is fine at any realistic single-instance scale — sigchain
//! writes are rare events (one per identity change) and read paths can be
//! served from the same lock without contention worth optimizing.
use std::path::Path;
use std::sync::Arc;
use kez_core::{Identity, Sigchain, SignedSigchainEvent};
use rusqlite::{Connection, OptionalExtension, params};
use tokio::sync::Mutex;
use crate::error::ApiError;
/// Shared store handle. Cheap to clone — wraps an `Arc`.
#[derive(Clone)]
pub struct Store {
inner: Arc<Mutex<Connection>>,
}
impl Store {
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
init_schema(&conn)?;
Ok(Self {
inner: Arc::new(Mutex::new(conn)),
})
}
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
let conn = Connection::open_in_memory()?;
init_schema(&conn)?;
Ok(Self {
inner: Arc::new(Mutex::new(conn)),
})
}
/// Load all events for `primary` and return them as a validated `Sigchain`.
/// Returns an empty `Sigchain` if no events exist for this primary.
pub async fn load_chain(&self, primary: &Identity) -> Result<Sigchain, ApiError> {
let conn = self.inner.lock().await;
let mut stmt = conn.prepare(
"SELECT envelope_json FROM sigchain_events
WHERE primary_scheme = ?1 AND primary_id = ?2
ORDER BY seq ASC",
)?;
let rows = stmt
.query_map(params![primary.scheme(), primary.value()], |row| {
row.get::<_, String>(0)
})?
.collect::<Result<Vec<_>, _>>()?;
let mut chain = Sigchain::new(primary.clone());
for json in rows {
let event: SignedSigchainEvent = serde_json::from_str(&json)?;
chain.append(event)?;
}
Ok(chain)
}
/// Append a pre-validated event. Caller must have already passed it
/// through `Sigchain::append`. We re-do the write transactionally to
/// guard against a racing writer (INSERT OR ABORT on the (primary, seq)
/// PK provides this).
pub async fn append(
&self,
primary: &Identity,
event: &SignedSigchainEvent,
) -> Result<(), ApiError> {
let envelope_json = serde_json::to_string(event)?;
let envelope_hash = event
.hash()
.map_err(|e| ApiError::Internal(format!("hash: {e}")))?;
let seq = event.payload.seq as i64;
let created_at = event.payload.created_at.to_rfc3339();
let conn = self.inner.lock().await;
conn.execute(
"INSERT INTO sigchain_events
(primary_scheme, primary_id, seq, envelope_json, envelope_hash, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
primary.scheme(),
primary.value(),
seq,
envelope_json,
envelope_hash,
created_at,
],
)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(err, _)
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
ApiError::Conflict(format!("seq {} already exists for this primary", seq))
}
other => ApiError::Internal(format!("db: {other}")),
})?;
Ok(())
}
/// Just the head event, if any.
pub async fn head(&self, primary: &Identity) -> Result<Option<SignedSigchainEvent>, ApiError> {
let conn = self.inner.lock().await;
let row = conn
.query_row(
"SELECT envelope_json FROM sigchain_events
WHERE primary_scheme = ?1 AND primary_id = ?2
ORDER BY seq DESC LIMIT 1",
params![primary.scheme(), primary.value()],
|row| row.get::<_, String>(0),
)
.optional()?;
match row {
None => Ok(None),
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
}
}
}
fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sigchain_events (
primary_scheme TEXT NOT NULL,
primary_id TEXT NOT NULL,
seq INTEGER NOT NULL,
envelope_json TEXT NOT NULL,
envelope_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (primary_scheme, primary_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_primary
ON sigchain_events (primary_scheme, primary_id);",
)
}

View File

@ -0,0 +1,281 @@
//! Integration tests: stand up the real router on a random local port and
//! drive it with `reqwest`. No mocks — exercises the full HTTP + SQLite +
//! kez-core validation path.
use std::net::SocketAddr;
use chrono::Utc;
use kez_core::{
Identity, NostrSecret, SigchainEventPayload, SignedSigchainEvent,
};
use kez_sig_server::{AppState, Store, router};
use reqwest::StatusCode;
use serde_json::Value;
struct TestServer {
base: String,
#[allow(dead_code)]
handle: tokio::task::JoinHandle<()>,
}
async fn spawn_server() -> TestServer {
let store = Store::open_in_memory().unwrap();
let app = router(AppState { store });
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base: format!("http://{addr}"),
handle,
}
}
fn fresh_nostr() -> (NostrSecret, Identity) {
let s = NostrSecret::generate();
let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap();
(s, id)
}
fn signed_add(
secret: &NostrSecret,
primary: &Identity,
seq: u64,
prev: Option<String>,
subject: &str,
) -> SignedSigchainEvent {
let payload = SigchainEventPayload::new_add(
primary.clone(),
seq,
prev,
Identity::parse(subject).unwrap(),
None,
Utc::now(),
);
SignedSigchainEvent::sign(payload, secret).unwrap()
}
fn chain_url(base: &str, primary: &Identity) -> String {
format!("{base}/v1/sigchains/{}/{}", primary.scheme(), primary.value())
}
#[tokio::test]
async fn healthz_returns_ok() {
let server = spawn_server().await;
let resp = reqwest::get(format!("{}/v1/healthz", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn empty_chain_returns_404() {
let server = spawn_server().await;
let (_, primary) = fresh_nostr();
let resp = reqwest::get(chain_url(&server.base, &primary)).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_then_get_round_trip() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let event = signed_add(&secret, &primary, 0, None, "github:jason");
let client = reqwest::Client::new();
let post = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(post.status(), StatusCode::CREATED);
let posted: Value = post.json().await.unwrap();
assert_eq!(posted["seq"], 0);
let head_hash_after_post = posted["hash"].as_str().unwrap().to_owned();
assert!(head_hash_after_post.starts_with("sha256:"));
// GET returns the event we just stored, as JSONL.
let get = reqwest::get(chain_url(&server.base, &primary)).await.unwrap();
assert_eq!(get.status(), StatusCode::OK);
assert_eq!(
get.headers()["content-type"]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap(),
"application/jsonl"
);
let body = get.text().await.unwrap();
let lines: Vec<&str> = body.trim().lines().collect();
assert_eq!(lines.len(), 1);
let round_tripped: SignedSigchainEvent = serde_json::from_str(lines[0]).unwrap();
assert_eq!(round_tripped, event);
}
#[tokio::test]
async fn head_endpoint_returns_latest() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
// seq 0
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
// seq 1 — prev = sha256 of e0 envelope
let e1 = signed_add(&secret, &primary, 1, Some(e0.hash().unwrap()), "github:b");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e1)
.send()
.await
.unwrap();
let head_resp = reqwest::get(format!("{}/head", chain_url(&server.base, &primary)))
.await
.unwrap();
assert_eq!(head_resp.status(), StatusCode::OK);
let head: SignedSigchainEvent = head_resp.json().await.unwrap();
assert_eq!(head, e1);
}
#[tokio::test]
async fn rejects_event_for_wrong_primary_url() {
let server = spawn_server().await;
let (secret_a, primary_a) = fresh_nostr();
let (_secret_b, primary_b) = fresh_nostr();
// Event signed for A's key, but POSTed under B's URL.
let event = signed_add(&secret_a, &primary_a, 0, None, "github:jason");
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary_b)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_bad_signature() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let mut event = signed_add(&secret, &primary, 0, None, "github:jason");
// Flip the sig hex's last byte (still 128 chars, but no longer valid).
let mut sig_bytes = event.signature.sig.into_bytes();
let last = sig_bytes.len() - 1;
sig_bytes[last] = if sig_bytes[last] == b'a' { b'b' } else { b'a' };
event.signature.sig = String::from_utf8(sig_bytes).unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&event)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn rejects_seq_skip() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
// seq 0 — succeeds
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
let r0 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0.status(), StatusCode::CREATED);
// seq 2 (skipping 1) — must be rejected as Conflict
let e2 = signed_add(&secret, &primary, 2, Some(e0.hash().unwrap()), "github:b");
let r2 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e2)
.send()
.await
.unwrap();
assert_eq!(r2.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn rejects_bad_prev_hash() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
// seq 1 with a bogus prev hash.
let e1 = signed_add(&secret, &primary, 1, Some("sha256:dead".into()), "github:b");
let resp = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e1)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn rejects_duplicate_seq() {
let server = spawn_server().await;
let (secret, primary) = fresh_nostr();
let client = reqwest::Client::new();
let e0 = signed_add(&secret, &primary, 0, None, "github:a");
let r0 = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0.status(), StatusCode::CREATED);
// Posting the same seq-0 event again must fail.
let r0_dup = client
.post(format!("{}/events", chain_url(&server.base, &primary)))
.json(&e0)
.send()
.await
.unwrap();
assert_eq!(r0_dup.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn invalid_primary_in_url_is_bad_request() {
let server = spawn_server().await;
// Not a valid `system:value` shape (no value after the colon).
let resp = reqwest::get(format!("{}/v1/sigchains/nostr/garbage", server.base))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

2978
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
rust/Cargo.toml Normal file
View File

@ -0,0 +1,36 @@
[workspace]
members = [
"crates/kez-core",
"crates/kez-channels",
"crates/kez-cli",
]
resolver = "3"
[workspace.package]
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://example.invalid/kez"
[workspace.dependencies]
anyhow = "1.0"
async-trait = "0.1"
base64 = "0.22"
bech32 = "0.9"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
futures-util = "0.3"
hex = "0.4"
hickory-resolver = "0.26"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
secp256k1 = { version = "0.29", features = ["rand", "global-context"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_jcs = "0.1"
sha2 = "0.10"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
wiremock = "0.6"
zstd = "0.13"

356
rust/README.md Normal file
View File

@ -0,0 +1,356 @@
# KEZ — Rust Implementation
KEZ is a portable, decentralized identity graph. It lets one person say:
> "These accounts, keys, domains, and identities are all me."
…without depending on any central authority to vouch for it. Every connection
is proven by a signature against a key the user already controls — a nostr
key, an Ed25519 key, a passkey, an Ethereum key, a GPG key, whatever they've
got.
The protocol itself is specified in [`../SPEC.md`](../SPEC.md). This directory
is the Rust implementation of that spec.
If you've used [Keybase](https://keybase.io), the mental model is similar:
you publish a signed "I control X" proof in a place only X can publish to
(your gist, your DNS, your nostr key), and anyone can fetch + verify it.
The difference: KEZ has no central server. The proofs live wherever you
publish them; the verifier just walks the links.
---
## What's in this directory
```
rust/
├── Cargo.toml Workspace manifest
├── crates/
│ ├── kez-core/ Types, signing, verification, JCS, all four encodings
│ ├── kez-channels/ One file per channel (github, dns, nostr, bluesky, ap)
│ └── kez-cli/ Thin CLI that dispatches through the channel registry
└── README.md (this file)
```
Three crates, ~1,500 lines of Rust, **81 tests**.
---
## Quick start
```sh
# Build everything
cargo build
# Run the test suite
cargo test
```
### End-to-end walkthrough
**1. Create a primary key.**
```sh
cargo run -p kez-cli -- identity new
```
Outputs:
```
Primary: nostr:npub1tkf...
Public: npub1tkf...
Secret: nsec1...
```
Save the `nsec` somewhere safe — it's the only thing that can sign as this
identity.
**2. Sign a claim** that this primary key also controls your GitHub account.
Pick the output format that fits where you'll publish:
```sh
# Markdown (for a GitHub gist or profile README)
cargo run -p kez-cli -- claim create github:jason \
--nsec nsec1... --format markdown --out github-jason.kez.md
# Compact (one-liner for QR codes, chat, DNS TXT)
cargo run -p kez-cli -- claim create github:jason --nsec nsec1... --format compact
# JSON envelope (for /.well-known/kez.json)
cargo run -p kez-cli -- claim create github:jason --nsec nsec1...
```
**3. Publish the proof** somewhere only the claimed account can publish to:
| Channel | Where to put the proof |
|---|---|
| `github:` | A public gist whose filename includes `kez`, or your `<user>/<user>` profile README |
| `dns:` | TXT record at `_kez.<domain>` (use `kez claim dns ...` to get the zone-file line) |
| `nostr:` | A kind-30078 event published by the same key |
| `bluesky:` | A public post containing the compact form or the Markdown fence |
| `ap:` / `mastodon:` | Your profile metadata field (preferred) or anywhere in your bio |
**4. Verify it** from anywhere:
```sh
cargo run -p kez-cli -- verify id github:jason
```
Output:
```
Primary: nostr:npub1tkf...
Verified identities:
- github:jason
Status: valid
Confidence: strong
```
---
## CLI reference
### `identity new`
Generate a new primary key. Defaults to nostr/secp256k1 (prints `nsec` /
`npub`); pass `--key-type ed25519` to generate an Ed25519 key instead
(prints the 32-byte seed and pubkey, both in hex). Stores nothing on disk.
### `claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>) [--format json|markdown|compact] [--out <path>]`
Sign a KEZ claim asserting that the supplied signing key also controls
`<subject>`. Pass exactly one of `--nsec` (nostr) or `--ed25519-seed`
(Ed25519). Defaults to JSON output. `--out` writes to a file; otherwise
prints to stdout.
### `claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)`
Like `claim create dns:<domain>` but additionally prints a ready-to-paste
zone-file line with the proof properly chunked into TXT segments.
### `verify file <path>`
Parse and verify a local proof file (any encoding). Developer helper — not a
real channel.
### `verify id <identifier>`
Fetch the proof for `<identifier>` from its native channel and verify it.
The identifier's `system:` prefix selects the channel plugin:
```sh
cargo run -p kez-cli -- verify id dns:jason.example.com
cargo run -p kez-cli -- verify id github:jason
cargo run -p kez-cli -- verify id nostr:npub1...
cargo run -p kez-cli -- verify id bluesky:jason.bsky.social
cargo run -p kez-cli -- verify id ap:@jason@mastodon.social
cargo run -p kez-cli -- verify id mastodon:@jason@mastodon.social
```
### `sigchain add <subject> --nsec | --ed25519-seed [--proof-url <url>]`
Append an `add` event to the local sigchain for the signing key. Chain
files live at `~/.kez/sigchains/<safe-primary>.jsonl`.
### `sigchain revoke <subject> --nsec | --ed25519-seed`
Append a `revoke` event for a previously added subject.
### `sigchain show [--primary <id>] | [--nsec | --ed25519-seed]`
Print the chain: primary, file path, length, one line per event, head hash.
Read-only — `--primary` works without a key.
### `sigchain export [--primary <id>] | [--nsec | --ed25519-seed] [--format jsonl|compact] [--out <path>]`
Export the chain in a portable format (`jsonl` per spec §6, or
`compact` = `kez:zc1:<base64url(zstd(jsonl))>`).
### `sigchain publish [--primary <id>] | [--nsec | --ed25519-seed] [destinations...]`
Push the chain to one or more places. Destinations are flags and any
combination can be passed:
- `--server <url>` — POST every event to a [kez-sig-server](../rust-sig-server)
- `--web --out <path>` — write the JSONL bundle to a file (you upload it
to `https://<your-domain>/.well-known/kez-sigchain.jsonl`)
- `--dns <domain>` — print the TXT zone records for `_kez-chain.<domain>`
- `--nostr <relay>` — publish the compact bundle as a kind-30078 event
signed by your nostr key (requires `--nsec`)
---
## Channels
Every channel lives in its own file under
[`crates/kez-channels/src/`](crates/kez-channels/src/) and implements one
trait:
```rust
#[async_trait]
pub trait Channel: Send + Sync {
fn system(&self) -> &'static str;
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit>;
}
```
| File | System | Fetches | API key needed? |
|---|---|---|---|
| [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.<domain>` TXT via system resolver | No |
| [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `<user>/<user>` profile README | No (60 req/hr anon, 5000 with `GITHUB_TOKEN`) |
| [`nostr.rs`](crates/kez-channels/src/nostr.rs) | `nostr:` | Kind-30078 events from damus / nos.lol / primal relays | No |
| [`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 |
Each channel has a sibling test file in
[`crates/kez-channels/tests/`](crates/kez-channels/tests/) using either
`wiremock` (HTTP channels) or a fake fetcher trait (DNS, nostr).
---
## Adding a new channel
The pattern is small and self-contained.
1. **Add the channel file.** Create
`crates/kez-channels/src/<system>.rs`. Implement `Channel`. Keep pure
helpers (URL builders, parsers) as standalone `pub fn`s so they can be
unit-tested without I/O.
2. **Register it.** In [`lib.rs`](crates/kez-channels/src/lib.rs), add
`pub mod <system>;` and a line in `Registry::with_defaults`:
```rust
r.register(Arc::new(my_channel::MyChannel::new().map_err(ChannelError::Other)?));
```
If one adapter handles multiple identifier prefixes, use `register_as`:
```rust
let adapter = Arc::new(...);
r.register(adapter.clone()); // canonical
r.register_as("alias", adapter); // alias
```
3. **Add tests.** Create `crates/kez-channels/tests/<system>.rs`. For HTTP
channels, use `wiremock` with a constructor like
`MyChannel::with_base(client, mock_server.uri())`. For network-protocol
channels (DNS, nostr), abstract the fetcher behind a trait and inject a
fake.
4. **Done.** `kez verify id <system>:...` now works through the CLI without
any CLI changes.
---
## Library use
The crates are usable directly:
```rust
use kez_channels::Registry;
use kez_core::Identity;
let registry = Registry::with_defaults()?;
let identity = Identity::parse("github:jason")?;
let hit = registry.verify(&identity).await?;
println!("verified {} via {}", hit.proof.payload.subject, identity.scheme());
```
`kez-core` exports the claim/envelope types, signing primitives, and the four
encoding round-trips. `kez-channels` exports the `Channel` trait, the
`ChannelError` enum, the `Registry`, and one module per built-in channel.
---
## Proof formats
A signed claim is one envelope, four wire forms.
**Envelope shape:**
```json
{
"kez": "claim",
"payload": {
"type": "kez.claim",
"version": 1,
"subject": "github:jason",
"primary": "nostr:npub1...",
"created_at": "2026-05-22T12:00:00Z"
},
"signature": {
"alg": "nostr-secp256k1-schnorr-sha256-jcs",
"key": "nostr:npub1...",
"sig": "<hex>"
}
}
```
| Form | Where | Encoding |
|---|---|---|
| JSON | `/.well-known/kez.json`, HTTP APIs | Standard JSON of the envelope |
| Compact | DNS TXT, QR codes, chat | `kez:z1:<base64url-no-pad(zstd(JSON))>` |
| Markdown | GitHub gist, README, bio | Human prose + a ```` ```kez ```` fenced block |
| Legacy DNS | (deprecated) | `kez1:<raw JSON>` — parser still accepts it |
Signatures are computed over **JCS** (RFC 8785) of the payload, not the
envelope. That makes the bytes-being-signed deterministic across
implementations.
---
## Failure modes
A verifier returns one of five distinct statuses (mapped to `ChannelError`
variants):
| Variant | Meaning |
|---|---|
| `Unreachable(_)` | Channel couldn't be reached (DNS failure, HTTP 5xx, relay down) |
| `NotFound(_)` | Channel reachable but no KEZ proof was found |
| `Invalid(_)` | A proof was found but failed signature or format check |
| `SubjectMismatch { expected, found }` | Signature valid, but the proof claims a different subject than what was requested |
| `NoChannelForSystem(_)` | The identifier's `system:` has no registered channel |
The CLI surfaces these as error messages today; the typed enum is in place
for the verifier to expose them programmatically.
---
## What's not done yet
This implementation covers the spec's v0.2 MVP plus four channels. Known gaps:
- **Sigchain walking during `verify`** — the sigchain type and CLI commands
exist (see `kez sigchain ...` above), and a separate
[chain server](../rust-sig-server/) can store them, but `verify id` doesn't
yet fetch a chain to check for revocations. Today every verify is a single
one-shot proof check. `rotate` and `add_device` ops are also not
implemented yet.
- **`expires_at` enforcement** — the field exists on `ClaimPayload` and
serializes correctly, but `SignedClaim::verify` doesn't reject expired
proofs yet.
- **Typed `VerificationStatus.status`** — currently hardcoded strings
(`"valid"`, `"strong"`). The `ChannelError` enum is ready to plumb the
five failure modes through into the CLI output.
- **Nostr event signature verification** — for the common case (subject ==
primary == the npub) the embedded KEZ proof's own signature is sufficient.
Cross-key proofs (e.g. ed25519 primary claiming a nostr identity) need
NIP-01 event-sig verification to be safe.
- **GitHub authentication** — anonymous requests work but are limited to 60
req/hr per IP. A `GITHUB_TOKEN` env var read would raise this to 5,000/hr.
See [`../SPEC.md`](../SPEC.md) for the full v0.2 spec these gaps reference.
---
## Tests
```sh
cargo test # all 81 tests
cargo test -p kez-core # claim/envelope/encoding tests (15)
cargo test -p kez-channels # channel logic + integration (61)
cargo test -p kez-channels --test github # one channel's integration tests
```
No network is hit in the test suite — HTTP channels use `wiremock`, DNS uses
a fake `TxtResolver`, nostr uses a fake `NostrFetcher`.
---
## License
Dual-licensed under MIT or Apache-2.0 (see workspace `Cargo.toml`).

View File

@ -0,0 +1,24 @@
[package]
name = "kez-channels"
version = "0.1.0"
edition.workspace = true
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
futures-util.workspace = true
hex.workspace = true
hickory-resolver.workspace = true
kez-core = { path = "../kez-core" }
reqwest.workspace = true
serde = { workspace = true }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
tokio = { workspace = true }
tokio-tungstenite.workspace = true
[dev-dependencies]
chrono.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
wiremock.workspace = true

View File

@ -0,0 +1,345 @@
//! ActivityPub channel: works for any ActivityPub-compatible service —
//! Mastodon, Pleroma, Akkoma, Misskey, GoToSocial, Friendica, PeerTube, …
//!
//! Two public endpoints, no auth:
//!
//! 1. **WebFinger** — `GET https://<server>/.well-known/webfinger?resource=acct:<user>@<server>`
//! Returns the user's ActivityPub actor URL.
//! 2. **Actor JSON** — `GET <actor-url>` with `Accept: application/activity+json`
//! Returns the user's profile, including `attachment` (Mastodon profile
//! fields) and `summary` (bio).
//!
//! Proof discovery order:
//! 1. `attachment[].value` (profile fields — Mastodon's explicit "user-published
//! metadata" surface)
//! 2. `summary` (bio)
//!
//! Pinned posts (`featured` collection) are a TODO for v0.2; not needed for the
//! minimal flow because the compact `kez:z1:` form fits in both attachment
//! values and bios on every major instance.
//!
//! Identifier shape: `ap:@<user>@<server>` is canonical. `mastodon:@<user>@<server>`
//! is registered as an alias and dispatches to the same adapter.
use async_trait::async_trait;
use kez_core::Identity;
use reqwest::Client;
use serde_json::Value;
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)";
#[derive(Clone)]
pub struct ActivityPubChannel {
client: Client,
/// If set, every fetch (WebFinger + actor) goes here instead of
/// `https://<server>`. Used by tests pointing at wiremock. None in prod.
base_override: Option<String>,
/// Canonical scheme this instance reports via `Channel::system()`.
/// In production this is "ap"; aliases (e.g. "mastodon") get registered
/// separately in the registry.
canonical_system: &'static str,
}
impl ActivityPubChannel {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder().user_agent(USER_AGENT).build()?;
Ok(Self {
client,
base_override: None,
canonical_system: "ap",
})
}
/// For tests: route every HTTP call to `base` regardless of server name.
pub fn with_base(client: Client, base: String) -> Self {
Self {
client,
base_override: Some(base),
canonical_system: "ap",
}
}
fn base_for(&self, server: &str) -> String {
self.base_override
.clone()
.unwrap_or_else(|| format!("https://{server}"))
}
async fn fetch_json(&self, url: &str, accept: &str) -> ChannelResult<Value> {
let resp = self
.client
.get(url)
.header("Accept", accept)
.send()
.await
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
// Distinguish 404 (NotFound) from other failures (Unreachable).
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(ChannelError::Unreachable(format!("GET {url}: 404")));
}
let resp = resp
.error_for_status()
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
resp.json()
.await
.map_err(|e| ChannelError::Other(anyhow::anyhow!("parse JSON {url}: {e}")))
}
}
#[async_trait]
impl Channel for ActivityPubChannel {
fn system(&self) -> &'static str {
self.canonical_system
}
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let (user, server) = parse_handle(identity.value())?;
let base = self.base_for(&server);
// 1. WebFinger → actor URL.
let wf_url = webfinger_url(&base, &user, &server);
let wf = self
.fetch_json(&wf_url, "application/jrd+json")
.await?;
let actor_url = extract_actor_url(&wf)
.ok_or_else(|| ChannelError::NotFound(identity.clone()))?;
// 2. Actor JSON → candidate proof strings.
let actor = self
.fetch_json(&actor_url, "application/activity+json")
.await?;
let candidates = extract_actor_candidates(&actor);
// 3. Try each candidate against parse_and_verify_for.
let mut last_error: Option<ChannelError> = None;
for raw in candidates {
match parse_and_verify_for(&raw, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
}
}
/// Pure: split a Mastodon-style handle (`@user@server` or `user@server`) into
/// its parts. Leading `@` is stripped.
pub fn parse_handle(value: &str) -> ChannelResult<(String, String)> {
let trimmed = value.strip_prefix('@').unwrap_or(value);
let (user, server) = trimmed.split_once('@').ok_or_else(|| {
ChannelError::Other(anyhow::anyhow!(
"expected `@user@server`, got: {value}"
))
})?;
if user.is_empty() || server.is_empty() {
return Err(ChannelError::Other(anyhow::anyhow!(
"invalid handle (empty part): {value}"
)));
}
Ok((user.to_owned(), server.to_owned()))
}
/// Pure: WebFinger URL.
pub fn webfinger_url(base: &str, user: &str, server: &str) -> String {
format!("{base}/.well-known/webfinger?resource=acct:{user}@{server}")
}
/// Pure: pull the actor URL out of a WebFinger response. We accept either
/// `application/activity+json` or `application/ld+json` in the link's
/// `type` (Mastodon uses the former, some servers use the latter).
pub fn extract_actor_url(webfinger: &Value) -> Option<String> {
let links = webfinger.get("links")?.as_array()?;
for link in links {
let rel = link.get("rel").and_then(|v| v.as_str()).unwrap_or("");
let typ = link.get("type").and_then(|v| v.as_str()).unwrap_or("");
if rel == "self" && (typ.contains("activity+json") || typ.contains("ld+json"))
&& let Some(href) = link.get("href").and_then(|v| v.as_str())
{
return Some(href.to_owned());
}
}
None
}
/// Pure: pull candidate proof strings out of an Actor JSON. Attachments come
/// first (explicit user-published metadata fields), bio second.
pub fn extract_actor_candidates(actor: &Value) -> Vec<String> {
let mut out = Vec::new();
if let Some(attachments) = actor.get("attachment").and_then(|v| v.as_array()) {
for att in attachments {
if let Some(value) = att.get("value").and_then(|v| v.as_str()) {
out.push(strip_html(value));
}
}
}
if let Some(summary) = actor.get("summary").and_then(|v| v.as_str()) {
out.push(strip_html(summary));
}
out
}
/// Pure: strip HTML tags and decode a small set of named entities so that
/// `parse_proof` can run against the underlying text. Good enough for
/// Mastodon-style bios and PropertyValue fields; we are not building a
/// general HTML parser.
pub fn strip_html(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut chars = html.chars().peekable();
while let Some(c) = chars.next() {
match c {
'<' => {
// Drop everything up to and including the next '>'.
for next in chars.by_ref() {
if next == '>' {
break;
}
}
}
'&' => {
let mut entity = String::new();
let mut closed = false;
for _ in 0..8 {
match chars.peek() {
Some(';') => {
chars.next();
closed = true;
break;
}
Some(&c2) if c2.is_ascii_alphanumeric() || c2 == '#' => {
chars.next();
entity.push(c2);
}
_ => break,
}
}
if closed {
match entity.as_str() {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" | "#39" => out.push('\''),
"nbsp" => out.push(' '),
_ => {
out.push('&');
out.push_str(&entity);
out.push(';');
}
}
} else {
out.push('&');
out.push_str(&entity);
}
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_handle_accepts_canonical_and_unprefixed() {
let (u, s) = parse_handle("@jason@mastodon.social").unwrap();
assert_eq!(u, "jason");
assert_eq!(s, "mastodon.social");
let (u, s) = parse_handle("jason@mastodon.social").unwrap();
assert_eq!(u, "jason");
assert_eq!(s, "mastodon.social");
}
#[test]
fn parse_handle_rejects_malformed() {
assert!(parse_handle("jason").is_err()); // no server
assert!(parse_handle("@@server").is_err()); // empty user
assert!(parse_handle("@jason@").is_err()); // empty server
}
#[test]
fn webfinger_url_matches_spec_shape() {
let url = webfinger_url("https://mastodon.social", "jason", "mastodon.social");
assert_eq!(
url,
"https://mastodon.social/.well-known/webfinger?resource=acct:jason@mastodon.social"
);
}
#[test]
fn extract_actor_url_picks_self_activity_json() {
let wf = json!({
"subject": "acct:jason@mastodon.social",
"links": [
{"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://mastodon.social/@jason"},
{"rel": "self", "type": "application/activity+json", "href": "https://mastodon.social/users/jason"}
]
});
assert_eq!(
extract_actor_url(&wf).as_deref(),
Some("https://mastodon.social/users/jason")
);
}
#[test]
fn extract_actor_url_accepts_ld_json() {
let wf = json!({
"links": [{"rel": "self", "type": "application/ld+json; profile=\"...\"", "href": "https://example/u/jason"}]
});
assert_eq!(extract_actor_url(&wf).as_deref(), Some("https://example/u/jason"));
}
#[test]
fn extract_actor_url_missing_returns_none() {
assert!(extract_actor_url(&json!({})).is_none());
assert!(extract_actor_url(&json!({"links": []})).is_none());
assert!(extract_actor_url(&json!({
"links": [{"rel": "self", "type": "text/html", "href": "..."}]
}))
.is_none());
}
#[test]
fn extract_actor_candidates_attachment_then_summary() {
let actor = json!({
"attachment": [
{"type": "PropertyValue", "name": "site", "value": "<a href=\"https://x\">x</a>"},
{"type": "PropertyValue", "name": "kez", "value": "kez:z1:abc"}
],
"summary": "<p>bio with kez:z1:def in it</p>"
});
let cands = extract_actor_candidates(&actor);
// attachments are emitted first, in order
assert_eq!(cands[0], "x");
assert_eq!(cands[1], "kez:z1:abc");
assert_eq!(cands[2], "bio with kez:z1:def in it");
}
#[test]
fn strip_html_handles_tags_and_entities() {
assert_eq!(strip_html("<p>hello <b>world</b></p>"), "hello world");
assert_eq!(strip_html("a &amp; b &lt;c&gt;"), "a & b <c>");
assert_eq!(strip_html("&quot;quoted&quot;"), r#""quoted""#);
assert_eq!(strip_html("&#39;apos&#39;"), "'apos'");
}
#[test]
fn strip_html_preserves_compact_kez_prefix() {
let html = "<p>my proof: kez:z1:KLUv_QBYabc</p>";
assert_eq!(strip_html(html), "my proof: kez:z1:KLUv_QBYabc");
}
#[test]
fn strip_html_preserves_markdown_fence_chars() {
let html = "<p>```kez\n{...}\n```</p>";
assert_eq!(strip_html(html), "```kez\n{...}\n```");
}
}

View File

@ -0,0 +1,158 @@
//! Bluesky channel: queries the public AppView (no auth) for the user's
//! recent posts and tries each post's text as a KEZ proof.
//!
//! Endpoint: `GET https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=<handle>&limit=100`
//!
//! Each post in the feed has `post.record.text`. We feed that text through
//! the standard proof parser, which handles Markdown-fenced, compact, and
//! JSON forms uniformly.
use async_trait::async_trait;
use kez_core::Identity;
use reqwest::Client;
use serde_json::Value;
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
const DEFAULT_APPVIEW: &str = "https://public.api.bsky.app";
const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)";
#[derive(Clone)]
pub struct BlueskyChannel {
client: Client,
appview_base: String,
}
impl BlueskyChannel {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder().user_agent(USER_AGENT).build()?;
Ok(Self::with_base(client, DEFAULT_APPVIEW.to_owned()))
}
/// For tests / custom AppViews.
pub fn with_base(client: Client, appview_base: String) -> Self {
Self {
client,
appview_base,
}
}
}
#[async_trait]
impl Channel for BlueskyChannel {
fn system(&self) -> &'static str {
"bluesky"
}
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let actor = identity.value();
if actor.is_empty() {
return Err(ChannelError::Other(anyhow::anyhow!(
"bluesky identity has empty handle"
)));
}
let url = author_feed_url(&self.appview_base, actor);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?
.error_for_status()
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
let body: Value = resp
.json()
.await
.map_err(|e| ChannelError::Other(anyhow::anyhow!("parse feed: {e}")))?;
let candidates = extract_post_texts(&body);
let mut last_error: Option<ChannelError> = None;
for text in candidates {
match parse_and_verify_for(&text, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
}
}
/// Pure: build the `getAuthorFeed` URL.
pub fn author_feed_url(base: &str, actor: &str) -> String {
// We URL-encode minimally; AppView is forgiving with handles and
// reqwest will re-quote anything truly malformed.
format!("{base}/xrpc/app.bsky.feed.getAuthorFeed?actor={actor}&limit=100")
}
/// Pure: pull every post's text out of a `getAuthorFeed` response body.
/// Skips posts without text (reposts, replies-only structures, etc.).
pub fn extract_post_texts(body: &Value) -> Vec<String> {
let Some(feed) = body.get("feed").and_then(|f| f.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for item in feed {
let Some(text) = item
.get("post")
.and_then(|p| p.get("record"))
.and_then(|r| r.get("text"))
.and_then(|t| t.as_str())
else {
continue;
};
if text.trim().is_empty() {
continue;
}
out.push(text.to_owned());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn author_feed_url_includes_actor_and_limit() {
let url = author_feed_url("https://public.api.bsky.app", "jason.bsky.social");
assert_eq!(
url,
"https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=jason.bsky.social&limit=100"
);
}
#[test]
fn extract_post_texts_pulls_from_feed_items() {
let body = json!({
"feed": [
{ "post": { "record": { "text": "hello" } } },
{ "post": { "record": { "text": "kez:z1:abc" } } },
{ "post": { "record": {} } }, // no text
{ "no_post_field": true },
]
});
let texts = extract_post_texts(&body);
assert_eq!(texts, vec!["hello".to_owned(), "kez:z1:abc".to_owned()]);
}
#[test]
fn extract_post_texts_skips_blank_and_whitespace() {
let body = json!({
"feed": [
{ "post": { "record": { "text": " " } } },
{ "post": { "record": { "text": "" } } },
{ "post": { "record": { "text": "real" } } },
]
});
assert_eq!(extract_post_texts(&body), vec!["real".to_owned()]);
}
#[test]
fn extract_post_texts_handles_missing_feed() {
assert!(extract_post_texts(&json!({})).is_empty());
assert!(extract_post_texts(&json!({ "feed": "not an array" })).is_empty());
}
}

View File

@ -0,0 +1,175 @@
//! DNS channel: looks up `_kez.<domain>` TXT records and verifies the first
//! one whose value parses as a KEZ proof (compact or legacy form).
use std::sync::Arc;
use async_trait::async_trait;
use hickory_resolver::{Resolver, proto::rr::RData};
use kez_core::{COMPACT_PROOF_PREFIX, Identity, dns_txt_name};
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
/// Resolver abstraction so tests can substitute a fake. The real
/// implementation uses `hickory-resolver` against the system config.
#[async_trait]
pub trait TxtResolver: Send + Sync {
async fn lookup_txt(&self, name: &str) -> ChannelResult<Vec<String>>;
}
/// Production resolver: builds a tokio-backed hickory resolver per call.
pub struct SystemResolver;
#[async_trait]
impl TxtResolver for SystemResolver {
async fn lookup_txt(&self, name: &str) -> ChannelResult<Vec<String>> {
let resolver = Resolver::builder_tokio()
.map_err(|e| ChannelError::Unreachable(format!("resolver config: {e}")))?
.build()
.map_err(|e| ChannelError::Unreachable(format!("resolver build: {e}")))?;
let lookup = resolver
.txt_lookup(name)
.await
.map_err(|e| ChannelError::Unreachable(format!("TXT lookup {name}: {e}")))?;
let mut out = Vec::new();
for record in lookup.answers() {
let RData::TXT(txt) = &record.data else {
continue;
};
// TXT RDATA is a sequence of <=255-byte segments; concatenate them
// back into the original payload.
let value: String = txt
.txt_data
.iter()
.map(|bytes| String::from_utf8_lossy(bytes))
.collect();
out.push(value);
}
Ok(out)
}
}
#[derive(Clone)]
pub struct DnsChannel {
resolver: Arc<dyn TxtResolver>,
}
impl DnsChannel {
pub fn new() -> Self {
Self {
resolver: Arc::new(SystemResolver),
}
}
/// Inject a custom resolver (used by tests and any non-system DNS path).
pub fn with_resolver(resolver: Arc<dyn TxtResolver>) -> Self {
Self { resolver }
}
}
impl Default for DnsChannel {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Channel for DnsChannel {
fn system(&self) -> &'static str {
"dns"
}
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let name = dns_txt_name(identity).map_err(|e| ChannelError::Other(e.into()))?;
let records = self.resolver.lookup_txt(&name).await?;
let mut last_error: Option<ChannelError> = None;
for value in records {
if !looks_like_kez_txt(&value) {
continue;
}
match parse_and_verify_for(&value, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
}
}
/// Pure: a TXT value looks like a KEZ proof if it starts with the compact
/// prefix (spec form) or the legacy `kez1:` prefix.
pub fn looks_like_kez_txt(value: &str) -> bool {
value.starts_with(COMPACT_PROOF_PREFIX) || value.starts_with("kez1:")
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use kez_core::{ClaimPayload, NostrSecret, SignedClaim};
struct FakeResolver(Vec<String>);
#[async_trait]
impl TxtResolver for FakeResolver {
async fn lookup_txt(&self, _name: &str) -> ChannelResult<Vec<String>> {
Ok(self.0.clone())
}
}
fn sign_dns(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap()
}
#[test]
fn looks_like_kez_txt_accepts_both_prefixes() {
assert!(looks_like_kez_txt("kez:z1:foo"));
assert!(looks_like_kez_txt("kez1:{...}"));
assert!(!looks_like_kez_txt("v=spf1 -all"));
assert!(!looks_like_kez_txt(""));
}
#[tokio::test]
async fn no_records_yields_not_found() {
let channel = DnsChannel::with_resolver(Arc::new(FakeResolver(vec![])));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}
#[tokio::test]
async fn ignores_non_kez_txt_then_falls_through() {
let channel = DnsChannel::with_resolver(Arc::new(FakeResolver(vec![
"v=spf1 -all".into(),
"google-site-verification=abc".into(),
])));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}
#[tokio::test]
async fn verifies_compact_proof() {
let signed = sign_dns("dns:jason.example.com");
let compact = signed.to_compact().unwrap();
let channel = DnsChannel::with_resolver(Arc::new(FakeResolver(vec![compact])));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn rejects_proof_for_wrong_subject() {
let signed = sign_dns("dns:mallory.example.com");
let compact = signed.to_compact().unwrap();
let channel = DnsChannel::with_resolver(Arc::new(FakeResolver(vec![compact])));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::SubjectMismatch { .. }));
}
}

View File

@ -0,0 +1,223 @@
//! GitHub channel: scans a user's public gists, then falls back to the
//! `<user>/<user>` profile README. Reachable proof formats: Markdown,
//! JSON, or compact, in any file whose name suggests a KEZ proof.
use async_trait::async_trait;
use kez_core::Identity;
use reqwest::Client;
use serde_json::Value;
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
const DEFAULT_API_BASE: &str = "https://api.github.com";
const DEFAULT_RAW_BASE: &str = "https://raw.githubusercontent.com";
const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)";
#[derive(Clone)]
pub struct GithubChannel {
client: Client,
api_base: String,
raw_base: String,
}
impl GithubChannel {
pub fn new() -> anyhow::Result<Self> {
let client = Client::builder().user_agent(USER_AGENT).build()?;
Ok(Self::with_bases(
client,
DEFAULT_API_BASE.to_owned(),
DEFAULT_RAW_BASE.to_owned(),
))
}
/// For tests / custom endpoints (enterprise GitHub, mock server).
pub fn with_bases(client: Client, api_base: String, raw_base: String) -> Self {
Self {
client,
api_base,
raw_base,
}
}
}
#[async_trait]
impl Channel for GithubChannel {
fn system(&self) -> &'static str {
"github"
}
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let user = identity.value();
if user.is_empty() {
return Err(ChannelError::Other(anyhow::anyhow!(
"github identity has empty user"
)));
}
let mut last_error: Option<ChannelError> = None;
// 1. Try the user's public gists.
match self.fetch_gist_candidates(user).await {
Ok(candidates) => {
for raw_url in candidates {
match self.fetch_text(&raw_url).await {
Ok(body) => match parse_and_verify_for(&body, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
},
Err(err) => last_error = Some(err),
}
}
}
Err(err) => last_error = Some(err),
}
// 2. Fall back to the GitHub profile README convention.
for url in profile_readme_urls(&self.raw_base, user) {
match self.fetch_text(&url).await {
Ok(body) => match parse_and_verify_for(&body, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
},
Err(_) => continue, // 404s on profile READMEs are expected.
}
}
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
}
}
impl GithubChannel {
async fn fetch_text(&self, url: &str) -> ChannelResult<String> {
let resp = self
.client
.get(url)
.send()
.await
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?
.error_for_status()
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
resp.text()
.await
.map_err(|e| ChannelError::Unreachable(format!("read body {url}: {e}")))
}
async fn fetch_gist_candidates(&self, user: &str) -> ChannelResult<Vec<String>> {
let url = gists_url(&self.api_base, user);
let resp = self
.client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?
.error_for_status()
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
let body: Value = resp
.json()
.await
.map_err(|e| ChannelError::Other(anyhow::anyhow!("parse gist listing: {e}")))?;
Ok(parse_gist_candidates(&body))
}
}
/// Pure: which file names look like they hold a KEZ proof?
pub fn looks_like_kez_filename(name: &str) -> bool {
let lower = name.to_lowercase();
lower.ends_with(".kez")
|| lower.ends_with(".kez.md")
|| lower.ends_with(".kez.json")
|| lower.contains("kez")
}
/// Pure: build the gist-listing URL for a user.
pub fn gists_url(api_base: &str, user: &str) -> String {
format!("{api_base}/users/{user}/gists?per_page=100")
}
/// Pure: profile README URLs to try, in order.
pub fn profile_readme_urls(raw_base: &str, user: &str) -> Vec<String> {
vec![
format!("{raw_base}/{user}/{user}/main/README.md"),
format!("{raw_base}/{user}/{user}/master/README.md"),
]
}
/// Pure: extract raw-URLs of KEZ-looking files from a gist listing payload.
pub fn parse_gist_candidates(body: &Value) -> Vec<String> {
let Some(gists) = body.as_array() else {
return Vec::new();
};
let mut out = Vec::new();
for gist in gists {
let Some(files) = gist.get("files").and_then(|f| f.as_object()) else {
continue;
};
for (name, file) in files {
if !looks_like_kez_filename(name) {
continue;
}
if let Some(raw_url) = file.get("raw_url").and_then(|u| u.as_str()) {
out.push(raw_url.to_owned());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn filename_filter_accepts_kez_files() {
assert!(looks_like_kez_filename("github-jason.kez.md"));
assert!(looks_like_kez_filename("proof.kez"));
assert!(looks_like_kez_filename("kez.json"));
assert!(looks_like_kez_filename("my.kez.json"));
assert!(looks_like_kez_filename("KEZ-PROOF.txt")); // case-insensitive, contains "kez"
}
#[test]
fn filename_filter_rejects_unrelated() {
assert!(!looks_like_kez_filename("README.md"));
assert!(!looks_like_kez_filename("notes.txt"));
assert!(!looks_like_kez_filename(".gitignore"));
}
#[test]
fn gists_url_includes_user_and_pagination() {
let url = gists_url("https://api.github.com", "jason");
assert_eq!(url, "https://api.github.com/users/jason/gists?per_page=100");
}
#[test]
fn profile_readme_urls_tries_main_then_master() {
let urls = profile_readme_urls("https://raw.githubusercontent.com", "jason");
assert_eq!(urls.len(), 2);
assert!(urls[0].ends_with("/jason/jason/main/README.md"));
assert!(urls[1].ends_with("/jason/jason/master/README.md"));
}
#[test]
fn parse_gist_candidates_skips_non_kez_files() {
let body = json!([
{
"files": {
"notes.txt": { "raw_url": "https://example/notes" },
"github-jason.kez.md": { "raw_url": "https://example/kez" }
}
}
]);
let candidates = parse_gist_candidates(&body);
assert_eq!(candidates, vec!["https://example/kez".to_owned()]);
}
#[test]
fn parse_gist_candidates_handles_empty_and_malformed() {
assert!(parse_gist_candidates(&json!([])).is_empty());
assert!(parse_gist_candidates(&json!({})).is_empty());
assert!(parse_gist_candidates(&json!([{ "no_files_field": true }])).is_empty());
}
}

View File

@ -0,0 +1,287 @@
//! Channel adapters for KEZ.
//!
//! A `Channel` knows how to fetch a published proof for a given `system:` and
//! verify it against the channel's ownership rules. Each channel lives in its
//! own module (one per file) so adding a new system (`bluesky`, `web`, …) is a
//! self-contained drop-in.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use kez_core::{Identity, SignedClaim, VerificationStatus};
use thiserror::Error;
pub mod activitypub;
pub mod bluesky;
pub mod dns;
pub mod github;
pub mod nostr;
/// The single error type every channel returns. Variants map directly to the
/// failure modes the spec (§8.4) requires a verifier to distinguish.
#[derive(Debug, Error)]
pub enum ChannelError {
#[error("channel unreachable: {0}")]
Unreachable(String),
#[error("no KEZ proof found for {0}")]
NotFound(Identity),
#[error("proof failed verification: {0}")]
Invalid(#[source] anyhow::Error),
#[error("proof subject {found} did not match expected identity {expected}")]
SubjectMismatch { expected: Identity, found: Identity },
#[error("no channel registered for system: {0}")]
NoChannelForSystem(String),
#[error("{0}")]
Other(#[source] anyhow::Error),
}
pub type ChannelResult<T> = Result<T, ChannelError>;
/// Output of a successful verification.
#[derive(Debug, Clone)]
pub struct ChannelHit {
pub proof: SignedClaim,
pub status: VerificationStatus,
}
/// A channel adapter: fetch + verify a published proof for one `system:`.
#[async_trait]
pub trait Channel: Send + Sync {
/// The `system` prefix this channel handles (e.g. "github", "dns").
fn system(&self) -> &'static str;
/// Fetch the proof for `identity` from the channel and verify it.
/// Implementations MUST confirm the proof's subject equals `identity`.
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit>;
}
/// A small registry mapping `system:` → channel adapter. Lets the CLI (and any
/// future caller) dispatch a `verify <identifier>` request without knowing
/// which adapters are loaded.
#[derive(Default, Clone)]
pub struct Registry {
channels: HashMap<&'static str, Arc<dyn Channel>>,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
/// Build a registry with the channels shipped in this crate.
pub fn with_defaults() -> ChannelResult<Self> {
let mut r = Self::new();
r.register(Arc::new(
github::GithubChannel::new().map_err(ChannelError::Other)?,
));
r.register(Arc::new(dns::DnsChannel::new()));
r.register(Arc::new(nostr::NostrChannel::new()));
r.register(Arc::new(
bluesky::BlueskyChannel::new().map_err(ChannelError::Other)?,
));
let ap = Arc::new(
activitypub::ActivityPubChannel::new().map_err(ChannelError::Other)?,
);
r.register(ap.clone()); // canonical: ap:
r.register_as("mastodon", ap); // alias: mastodon:
Ok(r)
}
pub fn register(&mut self, channel: Arc<dyn Channel>) {
self.channels.insert(channel.system(), channel);
}
/// Register a channel under an additional alias scheme. Useful when one
/// adapter handles multiple identifier prefixes (e.g. `ap:` and
/// `mastodon:` both routed to `ActivityPubChannel`).
pub fn register_as(&mut self, system: &'static str, channel: Arc<dyn Channel>) {
self.channels.insert(system, channel);
}
pub fn get(&self, system: &str) -> Option<Arc<dyn Channel>> {
self.channels.get(system).cloned()
}
pub async fn verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let channel = self
.get(identity.scheme())
.ok_or_else(|| ChannelError::NoChannelForSystem(identity.scheme().to_owned()))?;
channel.fetch_and_verify(identity).await
}
}
/// Helper used by every channel: parse a raw proof string, verify its signature,
/// and confirm its subject matches what we asked for. Lives at the crate root
/// because it's identical for every channel.
pub fn parse_and_verify_for(raw: &str, expected: &Identity) -> ChannelResult<ChannelHit> {
let proof = parse_proof(raw).map_err(ChannelError::Invalid)?;
let status = proof
.verify()
.map_err(|err| ChannelError::Invalid(err.into()))?;
if proof.payload.subject != *expected {
return Err(ChannelError::SubjectMismatch {
expected: expected.clone(),
found: proof.payload.subject,
});
}
Ok(ChannelHit { proof, status })
}
/// Best-effort parse of any of the four wire encodings (compact, JSON,
/// Markdown, legacy DNS). For the compact form, the prefix may be embedded
/// in surrounding prose (e.g. a Mastodon bio or a Bluesky post) — we extract
/// the base64url token after `kez:z1:` regardless of what comes before.
pub fn parse_proof(raw: &str) -> anyhow::Result<SignedClaim> {
use anyhow::bail;
use kez_core::{decode_compact_claim, extract_markdown_proof, from_json, parse_dns_txt_value};
let trimmed = raw.trim();
// Markdown fence is the most specific marker — check it first.
if trimmed.contains("```kez") {
return Ok(extract_markdown_proof(trimmed)?);
}
// Raw JSON envelope.
if trimmed.starts_with('{') {
return Ok(from_json(trimmed)?);
}
// Legacy DNS prefix — JSON payload, won't be embedded in prose.
if trimmed.starts_with("kez1:") {
return Ok(parse_dns_txt_value(trimmed)?);
}
// Compact: extract the kez:z1:<base64url> token anywhere in the input.
if let Some(token) = extract_compact_token(trimmed) {
return Ok(decode_compact_claim(&token)?);
}
bail!("unknown KEZ proof format")
}
/// Pure: find a `kez:z1:<base64url>` token anywhere in `text` and return it.
/// The token ends at the first non-base64url-alphabet character.
pub fn extract_compact_token(text: &str) -> Option<String> {
use kez_core::COMPACT_PROOF_PREFIX;
let idx = text.find(COMPACT_PROOF_PREFIX)?;
let after = &text[idx + COMPACT_PROOF_PREFIX.len()..];
let body: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if body.is_empty() {
None
} else {
Some(format!("{COMPACT_PROOF_PREFIX}{body}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use kez_core::{ClaimPayload, NostrSecret, SignedClaim};
fn make_signed(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(
ClaimPayload::new(subject, primary, Utc::now()),
&secret,
)
.unwrap()
}
#[test]
fn parse_proof_handles_all_four_encodings() {
let signed = make_signed("github:jason");
// JSON
let json = signed.to_pretty_json().unwrap();
let p_json = parse_proof(&json).unwrap();
assert_eq!(p_json, signed);
// Compact
let compact = signed.to_compact().unwrap();
let p_compact = parse_proof(&compact).unwrap();
assert_eq!(p_compact, signed);
// Markdown
let md = signed.to_markdown_proof().unwrap();
let p_md = parse_proof(&md).unwrap();
assert_eq!(p_md, signed);
// Legacy DNS (`kez1:` prefix)
let dns = kez_core::dns_txt_value(&signed).unwrap();
let p_dns = parse_proof(&dns).unwrap();
assert_eq!(p_dns, signed);
}
#[test]
fn parse_proof_rejects_unknown_format() {
let err = parse_proof("just some text").unwrap_err();
assert!(err.to_string().contains("unknown"));
}
#[test]
fn parse_proof_extracts_compact_token_from_surrounding_prose() {
let signed = make_signed("ap:@jason@mastodon.social");
let compact = signed.to_compact().unwrap();
let bio = format!("hello world! my proof: {compact} — verify it");
let parsed = parse_proof(&bio).unwrap();
assert_eq!(parsed, signed);
}
#[test]
fn extract_compact_token_stops_at_non_base64url_char() {
let text = "before kez:z1:KLUv_QBYabc. after";
let token = extract_compact_token(text).unwrap();
assert_eq!(token, "kez:z1:KLUv_QBYabc");
}
#[test]
fn extract_compact_token_returns_none_when_missing() {
assert!(extract_compact_token("nothing here").is_none());
assert!(extract_compact_token("kez:z1:").is_none(), "empty body");
}
#[test]
fn parse_and_verify_for_flags_subject_mismatch() {
let signed = make_signed("github:jason");
let json = signed.to_pretty_json().unwrap();
let wrong = Identity::parse("github:mallory").unwrap();
let err = parse_and_verify_for(&json, &wrong).unwrap_err();
assert!(matches!(err, ChannelError::SubjectMismatch { .. }));
}
#[test]
fn parse_and_verify_for_passes_on_match() {
let signed = make_signed("github:jason");
let json = signed.to_pretty_json().unwrap();
let expected = Identity::parse("github:jason").unwrap();
let hit = parse_and_verify_for(&json, &expected).unwrap();
assert_eq!(hit.proof, signed);
}
#[test]
fn registry_dispatches_by_scheme() {
let registry = Registry::with_defaults().unwrap();
assert!(registry.get("github").is_some());
assert!(registry.get("dns").is_some());
assert!(registry.get("nostr").is_some());
assert!(registry.get("bluesky").is_some());
assert!(registry.get("ap").is_some());
assert!(
registry.get("mastodon").is_some(),
"mastodon: must alias to ap:"
);
assert!(registry.get("did").is_none(), "did: is not implemented yet");
}
#[tokio::test]
async fn registry_reports_unknown_system() {
let registry = Registry::new();
let identity = Identity::parse("github:jason").unwrap();
let err = registry.verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NoChannelForSystem(_)));
}
}

View File

@ -0,0 +1,453 @@
//! Nostr channel: fetches events from one or more relays and verifies that
//! the event content is a KEZ proof for the requested `nostr:npub1...`
//! identity.
//!
//! Spec §5: KEZ proofs on nostr are published as kind `30078`
//! (parameterized replaceable) events. We query a relay for events with
//! `authors == [<hex pubkey>]` and `kinds == [30078]`, then run each
//! event's `content` through the standard proof parser.
//!
//! Trust model for this minimal cut: a malicious relay could forge events,
//! but the embedded KEZ proof carries its own signature over the primary
//! key. As long as the proof's `primary == subject` (the npub case), the
//! relay cannot mint a valid proof without the user's private key. Event
//! signature verification is TODO for the cross-key case (e.g. an ed25519
//! primary claiming a nostr identity).
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use kez_core::{Identity, NostrSecret, nostr_pubkey_hex};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
pub const KEZ_NOSTR_KIND: u32 = 30078;
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
];
const FETCH_TIMEOUT: Duration = Duration::from_secs(8);
/// A nostr event in the wire shape we care about (a subset of NIP-01).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NostrEvent {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub kind: u32,
#[serde(default)]
pub tags: Vec<Vec<String>>,
pub content: String,
pub sig: String,
}
/// Filter sent in a nostr REQ message.
#[derive(Debug, Clone)]
pub struct NostrFilter {
pub authors: Vec<String>, // lowercase hex pubkeys
pub kinds: Vec<u32>,
pub limit: Option<u32>,
}
/// Fetcher abstraction so tests can substitute canned events without
/// touching the network.
#[async_trait]
pub trait NostrFetcher: Send + Sync {
async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>>;
}
/// Real fetcher: queries each relay in turn (websocket), merges events,
/// and times out if relays are unresponsive.
pub struct RelayPoolFetcher {
relays: Vec<String>,
}
impl RelayPoolFetcher {
pub fn new(relays: Vec<String>) -> Self {
Self { relays }
}
pub fn defaults() -> Self {
Self::new(DEFAULT_RELAYS.iter().map(|s| (*s).to_owned()).collect())
}
}
#[async_trait]
impl NostrFetcher for RelayPoolFetcher {
async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
let mut last_error: Option<ChannelError> = None;
let mut events: Vec<NostrEvent> = Vec::new();
for relay in &self.relays {
match query_relay(relay, filter).await {
Ok(mut batch) => events.append(&mut batch),
Err(err) => last_error = Some(err),
}
// First relay that returns anything is enough for a discovery hit;
// we keep going only if we still have nothing.
if !events.is_empty() {
break;
}
}
if events.is_empty()
&& let Some(err) = last_error
{
return Err(err);
}
Ok(events)
}
}
async fn query_relay(url: &str, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
let (mut ws, _) = connect_async(url)
.await
.map_err(|e| ChannelError::Unreachable(format!("connect {url}: {e}")))?;
let sub_id = "kez-1";
let req = build_req_message(sub_id, filter);
ws.send(Message::Text(req.into()))
.await
.map_err(|e| ChannelError::Unreachable(format!("send REQ {url}: {e}")))?;
let mut events = Vec::new();
loop {
let next = tokio::time::timeout(FETCH_TIMEOUT, ws.next()).await;
let Ok(Some(msg)) = next else { break };
let msg = match msg {
Ok(m) => m,
Err(e) => return Err(ChannelError::Unreachable(format!("ws read {url}: {e}"))),
};
let Message::Text(text) = msg else { continue };
match parse_relay_message(&text) {
RelayMessage::Event(ev) => events.push(ev),
RelayMessage::EndOfStored => break,
RelayMessage::Other => continue,
}
}
let _ = ws
.send(Message::Text(
json!(["CLOSE", sub_id]).to_string().into(),
))
.await;
let _ = ws.close(None).await;
Ok(events)
}
#[derive(Clone)]
pub struct NostrChannel {
fetcher: Arc<dyn NostrFetcher>,
}
impl NostrChannel {
pub fn new() -> Self {
Self {
fetcher: Arc::new(RelayPoolFetcher::defaults()),
}
}
pub fn with_fetcher(fetcher: Arc<dyn NostrFetcher>) -> Self {
Self { fetcher }
}
}
impl Default for NostrChannel {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Channel for NostrChannel {
fn system(&self) -> &'static str {
"nostr"
}
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
let pubkey_hex = nostr_pubkey_hex(identity).map_err(|e| ChannelError::Other(e.into()))?;
let filter = NostrFilter {
authors: vec![pubkey_hex.clone()],
kinds: vec![KEZ_NOSTR_KIND],
limit: Some(20),
};
let events = self.fetcher.fetch_events(&filter).await?;
let mut last_error: Option<ChannelError> = None;
for event in events {
if !event_matches_author(&event, &pubkey_hex) {
continue;
}
match parse_and_verify_for(&event.content, identity) {
Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
}
}
/// Build and sign a NIP-01 event. The event id is `sha256` of the
/// canonically-serialized array `[0, pubkey, created_at, kind, tags,
/// content]`; the signature is Schnorr over that id.
pub fn build_signed_event(
signer: &NostrSecret,
created_at: i64,
kind: u32,
tags: Vec<Vec<String>>,
content: String,
) -> ChannelResult<NostrEvent> {
let pubkey_hex = signer.pubkey_hex();
let canonical = json!([0, pubkey_hex, created_at, kind, tags, content]);
let canonical_str = serde_json::to_string(&canonical)
.map_err(|e| ChannelError::Other(anyhow::anyhow!("event serialize: {e}")))?;
let digest: [u8; 32] = Sha256::digest(canonical_str.as_bytes()).into();
let id_hex = hex::encode(digest);
let sig = signer
.sign_raw(&digest)
.map_err(|e| ChannelError::Other(anyhow::anyhow!("schnorr sign: {e}")))?;
Ok(NostrEvent {
id: id_hex,
pubkey: pubkey_hex,
created_at,
kind,
tags,
content,
sig: hex::encode(sig),
})
}
/// Publish one event to a single relay over WebSocket. Returns Ok if the
/// relay either acknowledges with `["OK", id, true, ...]` or closes the
/// connection without rejecting.
pub async fn publish_event_to_relay(
relay_url: &str,
event: &NostrEvent,
) -> ChannelResult<()> {
let (mut ws, _) = connect_async(relay_url)
.await
.map_err(|e| ChannelError::Unreachable(format!("connect {relay_url}: {e}")))?;
let msg = json!(["EVENT", event]).to_string();
ws.send(Message::Text(msg.into()))
.await
.map_err(|e| ChannelError::Unreachable(format!("send EVENT {relay_url}: {e}")))?;
// Wait briefly for an OK / NOTICE response. Don't hang forever if the
// relay never sends one — many relays accept and stay silent.
let deadline = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(msg) = ws.next().await {
let Ok(Message::Text(text)) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Value>(&text) else {
continue;
};
let Some(arr) = arr.as_array() else { continue };
match arr.first().and_then(|v| v.as_str()) {
Some("OK") => {
// ["OK", <event-id>, <accepted: bool>, <message>]
if arr.get(2).and_then(|v| v.as_bool()) == Some(false) {
let reason = arr.get(3).and_then(|v| v.as_str()).unwrap_or("");
return Err(ChannelError::Other(anyhow::anyhow!(
"relay {relay_url} rejected event: {reason}"
)));
}
return Ok(());
}
Some("NOTICE") => {
// Informational; not failure on its own. Keep reading.
continue;
}
_ => continue,
}
}
Ok(())
})
.await;
let _ = ws.close(None).await;
match deadline {
Ok(result) => result,
Err(_) => Ok(()), // Timeout — assume accepted; we'll retry by GET later.
}
}
/// Pure: build the JSON REQ message a nostr relay expects.
pub fn build_req_message(sub_id: &str, filter: &NostrFilter) -> String {
let mut spec = serde_json::Map::new();
spec.insert("authors".into(), json!(filter.authors));
spec.insert("kinds".into(), json!(filter.kinds));
if let Some(limit) = filter.limit {
spec.insert("limit".into(), json!(limit));
}
json!(["REQ", sub_id, Value::Object(spec)]).to_string()
}
/// Pure: defense against a relay that lies about authorship in the array
/// envelope but sets a different `pubkey` inside the event JSON.
pub fn event_matches_author(event: &NostrEvent, expected_hex: &str) -> bool {
event.pubkey.eq_ignore_ascii_case(expected_hex)
}
/// Parsed shape of a single relay → client message.
pub enum RelayMessage {
Event(NostrEvent),
EndOfStored,
Other,
}
/// Pure: parse one inbound `["EVENT", sub, {…}]` / `["EOSE", sub]` / other
/// frame into our enum.
pub fn parse_relay_message(text: &str) -> RelayMessage {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return RelayMessage::Other;
};
let Some(arr) = value.as_array() else {
return RelayMessage::Other;
};
match arr.first().and_then(|v| v.as_str()) {
Some("EVENT") => {
let Some(ev_val) = arr.get(2) else {
return RelayMessage::Other;
};
match serde_json::from_value::<NostrEvent>(ev_val.clone()) {
Ok(ev) => RelayMessage::Event(ev),
Err(_) => RelayMessage::Other,
}
}
Some("EOSE") => RelayMessage::EndOfStored,
_ => RelayMessage::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_req_includes_filter_fields() {
let filter = NostrFilter {
authors: vec!["aa".into()],
kinds: vec![30078],
limit: Some(20),
};
let req = build_req_message("sub-1", &filter);
let parsed: Value = serde_json::from_str(&req).unwrap();
assert_eq!(parsed[0], "REQ");
assert_eq!(parsed[1], "sub-1");
assert_eq!(parsed[2]["authors"], json!(["aa"]));
assert_eq!(parsed[2]["kinds"], json!([30078]));
assert_eq!(parsed[2]["limit"], json!(20));
}
#[test]
fn build_req_omits_limit_when_none() {
let filter = NostrFilter {
authors: vec!["aa".into()],
kinds: vec![1],
limit: None,
};
let req = build_req_message("s", &filter);
let parsed: Value = serde_json::from_str(&req).unwrap();
assert!(parsed[2].get("limit").is_none());
}
#[test]
fn parse_event_message() {
let frame = json!([
"EVENT",
"sub-1",
{
"id": "0".repeat(64),
"pubkey": "a".repeat(64),
"created_at": 1700000000_i64,
"kind": 30078,
"tags": [["d", "kez"]],
"content": "hello",
"sig": "f".repeat(128),
}
])
.to_string();
match parse_relay_message(&frame) {
RelayMessage::Event(ev) => {
assert_eq!(ev.kind, 30078);
assert_eq!(ev.content, "hello");
assert_eq!(ev.tags, vec![vec!["d".to_owned(), "kez".to_owned()]]);
}
_ => panic!("expected Event"),
}
}
#[test]
fn parse_eose_message() {
let frame = json!(["EOSE", "sub-1"]).to_string();
assert!(matches!(
parse_relay_message(&frame),
RelayMessage::EndOfStored
));
}
#[test]
fn parse_garbage_message_is_other() {
assert!(matches!(parse_relay_message("not json"), RelayMessage::Other));
assert!(matches!(parse_relay_message("{}"), RelayMessage::Other));
assert!(matches!(
parse_relay_message(r#"["NOTICE","hi"]"#),
RelayMessage::Other
));
}
#[test]
fn build_signed_event_produces_valid_nip01_event() {
let signer = kez_core::NostrSecret::generate();
let event = build_signed_event(
&signer,
1_700_000_000,
30078,
vec![vec!["d".into(), "kez-sigchain".into()]],
"hello".into(),
)
.unwrap();
// Basic shape: 32-byte id, 32-byte pubkey, 64-byte sig (all hex).
assert_eq!(event.id.len(), 64);
assert_eq!(event.pubkey.len(), 64);
assert_eq!(event.sig.len(), 128);
assert_eq!(event.pubkey, signer.pubkey_hex());
assert_eq!(event.kind, 30078);
assert_eq!(event.content, "hello");
// The id MUST equal sha256 of the canonical serialization.
let canonical = serde_json::json!([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
let canonical_str = serde_json::to_string(&canonical).unwrap();
let expected_id =
hex::encode(<Sha256 as sha2::Digest>::digest(canonical_str.as_bytes()));
assert_eq!(event.id, expected_id);
}
#[test]
fn event_matches_author_is_case_insensitive() {
let ev = NostrEvent {
id: "x".into(),
pubkey: "ABCDEF".into(),
created_at: 0,
kind: 30078,
tags: vec![],
content: String::new(),
sig: String::new(),
};
assert!(event_matches_author(&ev, "abcdef"));
assert!(!event_matches_author(&ev, "ababab"));
}
}

View File

@ -0,0 +1,196 @@
//! Integration tests for the ActivityPub channel. A `wiremock` server stands
//! in for `mastodon.social` (or any AP server) and serves WebFinger + actor
//! responses.
use chrono::Utc;
use kez_channels::activitypub::ActivityPubChannel;
use kez_channels::{Channel, ChannelError};
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim};
use reqwest::Client;
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sign(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap()
}
fn channel_pointing_at(server: &MockServer) -> ActivityPubChannel {
let client = Client::builder()
.user_agent("kez-channels-test/0.1")
.build()
.unwrap();
ActivityPubChannel::with_base(client, server.uri())
}
fn mock_webfinger(user: &str, host: &str, actor_url: &str) -> Mock {
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.and(query_param("resource", format!("acct:{user}@{host}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": format!("acct:{user}@{host}"),
"links": [
{"rel": "self", "type": "application/activity+json", "href": actor_url}
]
})))
}
#[tokio::test]
async fn verifies_proof_in_profile_attachment() {
let server = MockServer::start().await;
let signed = sign("ap:@jason@mastodon.social");
let compact = signed.to_compact().unwrap();
let actor_url = format!("{}/users/jason", server.uri());
mock_webfinger("jason", "mastodon.social", &actor_url)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/users/jason"))
.and(header("accept", "application/activity+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": actor_url,
"type": "Person",
"preferredUsername": "jason",
"summary": "<p>hi</p>",
"attachment": [
{"type": "PropertyValue", "name": "site", "value": "<a href=\"https://x\">x</a>"},
{"type": "PropertyValue", "name": "kez", "value": compact}
]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@jason@mastodon.social").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn verifies_proof_embedded_in_bio() {
let server = MockServer::start().await;
let signed = sign("ap:@jason@mastodon.social");
let compact = signed.to_compact().unwrap();
let actor_url = format!("{}/users/jason", server.uri());
mock_webfinger("jason", "mastodon.social", &actor_url)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/users/jason"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": actor_url,
"type": "Person",
"preferredUsername": "jason",
"summary": format!("<p>portable identity: {compact}</p>"),
"attachment": []
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@jason@mastodon.social").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn rejects_proof_for_wrong_subject() {
let server = MockServer::start().await;
let signed = sign("ap:@mallory@mastodon.social");
let compact = signed.to_compact().unwrap();
let actor_url = format!("{}/users/jason", server.uri());
mock_webfinger("jason", "mastodon.social", &actor_url)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/users/jason"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": actor_url,
"attachment": [
{"type": "PropertyValue", "name": "kez", "value": compact}
]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@jason@mastodon.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::SubjectMismatch { .. }),
"expected SubjectMismatch, got {err:?}"
);
}
#[tokio::test]
async fn webfinger_404_is_unreachable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@ghost@mastodon.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::Unreachable(_)),
"expected Unreachable on 404, got {err:?}"
);
}
#[tokio::test]
async fn actor_with_no_candidates_is_not_found() {
let server = MockServer::start().await;
let actor_url = format!("{}/users/jason", server.uri());
mock_webfinger("jason", "mastodon.social", &actor_url)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/users/jason"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": actor_url,
"preferredUsername": "jason"
// no summary, no attachments
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@jason@mastodon.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}
#[tokio::test]
async fn webfinger_with_no_self_link_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/webfinger"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subject": "acct:jason@mastodon.social",
"links": [
{"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://example/@jason"}
]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("ap:@jason@mastodon.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}

View File

@ -0,0 +1,122 @@
//! Integration tests for the Bluesky channel using `wiremock` as a stand-in
//! for `public.api.bsky.app`.
use chrono::Utc;
use kez_channels::bluesky::BlueskyChannel;
use kez_channels::{Channel, ChannelError};
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim};
use reqwest::Client;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sign(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap()
}
fn channel_pointing_at(server: &MockServer) -> BlueskyChannel {
let client = Client::builder()
.user_agent("kez-channels-test/0.1")
.build()
.unwrap();
BlueskyChannel::with_base(client, server.uri())
}
#[tokio::test]
async fn verifies_compact_proof_in_post_text() {
let server = MockServer::start().await;
let signed = sign("bluesky:jason.bsky.social");
let compact = signed.to_compact().unwrap();
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getAuthorFeed"))
.and(query_param("actor", "jason.bsky.social"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"feed": [
{ "post": { "record": { "text": "good morning" } } },
{ "post": { "record": { "text": compact } } }
]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("bluesky:jason.bsky.social").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn verifies_markdown_fenced_proof_in_post() {
let server = MockServer::start().await;
let signed = sign("bluesky:jason.bsky.social");
let markdown = signed.to_markdown_proof().unwrap();
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getAuthorFeed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"feed": [{ "post": { "record": { "text": markdown } } }]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("bluesky:jason.bsky.social").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn rejects_proof_for_wrong_handle() {
let server = MockServer::start().await;
// Signed for mallory but posted on jason's feed.
let signed = sign("bluesky:mallory.bsky.social");
let compact = signed.to_compact().unwrap();
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getAuthorFeed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"feed": [{ "post": { "record": { "text": compact } } }]
})))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("bluesky:jason.bsky.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::SubjectMismatch { .. }),
"expected SubjectMismatch, got {err:?}"
);
}
#[tokio::test]
async fn empty_feed_yields_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getAuthorFeed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] })))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("bluesky:jason.bsky.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}
#[tokio::test]
async fn appview_error_status_is_unreachable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getAuthorFeed"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("bluesky:jason.bsky.social").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::Unreachable(_)));
}

View File

@ -0,0 +1,84 @@
//! Integration tests for the DNS channel using a fake `TxtResolver`.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use kez_channels::{
Channel, ChannelError,
dns::{DnsChannel, TxtResolver},
parse_proof,
};
use kez_channels::ChannelResult;
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim};
struct CapturingResolver {
records: Vec<String>,
expected_name: String,
}
#[async_trait]
impl TxtResolver for CapturingResolver {
async fn lookup_txt(&self, name: &str) -> ChannelResult<Vec<String>> {
assert_eq!(
name, self.expected_name,
"DnsChannel must query `_kez.<domain>`"
);
Ok(self.records.clone())
}
}
struct FailingResolver;
#[async_trait]
impl TxtResolver for FailingResolver {
async fn lookup_txt(&self, _name: &str) -> ChannelResult<Vec<String>> {
Err(ChannelError::Unreachable("simulated network failure".into()))
}
}
fn sign(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap()
}
#[tokio::test]
async fn queries_kez_underscore_name() {
let signed = sign("dns:jason.example.com");
let compact = signed.to_compact().unwrap();
let channel = DnsChannel::with_resolver(Arc::new(CapturingResolver {
records: vec![compact],
expected_name: "_kez.jason.example.com".to_owned(),
}));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn supports_legacy_kez1_prefix() {
let signed = sign("dns:jason.example.com");
let legacy = kez_core::dns_txt_value(&signed).unwrap();
assert!(legacy.starts_with("kez1:"));
let channel = DnsChannel::with_resolver(Arc::new(CapturingResolver {
records: vec![legacy.clone()],
expected_name: "_kez.jason.example.com".to_owned(),
}));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
// round-trips back to the same envelope.
assert_eq!(hit.proof, parse_proof(&legacy).unwrap());
}
#[tokio::test]
async fn surfaces_resolver_failure_as_unreachable() {
let channel = DnsChannel::with_resolver(Arc::new(FailingResolver));
let identity = Identity::parse("dns:jason.example.com").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::Unreachable(_)),
"expected Unreachable, got {err:?}"
);
}

View File

@ -0,0 +1,193 @@
//! Integration tests for the GitHub channel using a `wiremock` HTTP server
//! standing in for `api.github.com` and `raw.githubusercontent.com`.
use chrono::Utc;
use kez_channels::{Channel, ChannelError, github::GithubChannel};
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim};
use reqwest::Client;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sign(subject: &str) -> SignedClaim {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse(subject).unwrap();
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap()
}
fn channel_pointing_at(server: &MockServer) -> GithubChannel {
let client = Client::builder()
.user_agent("kez-channels-test/0.1")
.build()
.unwrap();
GithubChannel::with_bases(client, server.uri(), server.uri())
}
#[tokio::test]
async fn verifies_proof_published_in_a_gist() {
let server = MockServer::start().await;
let signed = sign("github:jason");
let markdown = signed.to_markdown_proof().unwrap();
Mock::given(method("GET"))
.and(path("/users/jason/gists"))
.and(header("accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"files": {
"notes.txt": {
"raw_url": format!("{}/raw/notes.txt", server.uri())
},
"github-jason.kez.md": {
"raw_url": format!("{}/raw/github-jason.kez.md", server.uri())
}
}
}
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/raw/github-jason.kez.md"))
.respond_with(ResponseTemplate::new(200).set_body_string(markdown))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("github:jason").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn falls_back_to_profile_readme_on_main() {
let server = MockServer::start().await;
let signed = sign("github:jason");
let markdown = signed.to_markdown_proof().unwrap();
// No matching gists.
Mock::given(method("GET"))
.and(path("/users/jason/gists"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/jason/jason/main/README.md"))
.respond_with(ResponseTemplate::new(200).set_body_string(markdown))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("github:jason").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn falls_back_to_master_when_main_missing() {
let server = MockServer::start().await;
let signed = sign("github:jason");
let markdown = signed.to_markdown_proof().unwrap();
Mock::given(method("GET"))
.and(path("/users/jason/gists"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
// main is 404, master serves the proof.
Mock::given(method("GET"))
.and(path("/jason/jason/main/README.md"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/jason/jason/master/README.md"))
.respond_with(ResponseTemplate::new(200).set_body_string(markdown))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("github:jason").unwrap();
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn rejects_proof_signed_for_wrong_subject() {
let server = MockServer::start().await;
// Signed for github:mallory, but published in jason's gist.
let signed = sign("github:mallory");
let markdown = signed.to_markdown_proof().unwrap();
Mock::given(method("GET"))
.and(path("/users/jason/gists"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"files": {
"kez.md": {
"raw_url": format!("{}/raw/kez.md", server.uri())
}
}
}
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/raw/kez.md"))
.respond_with(ResponseTemplate::new(200).set_body_string(markdown))
.mount(&server)
.await;
// No fallback README either.
Mock::given(method("GET"))
.and(path("/jason/jason/main/README.md"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/jason/jason/master/README.md"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("github:jason").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::SubjectMismatch { .. }),
"expected SubjectMismatch, got {err:?}"
);
}
#[tokio::test]
async fn returns_not_found_when_no_proof_anywhere() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/jason/gists"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/jason/jason/main/README.md"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/jason/jason/master/README.md"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let channel = channel_pointing_at(&server);
let identity = Identity::parse("github:jason").unwrap();
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(
matches!(err, ChannelError::NotFound(_) | ChannelError::Unreachable(_)),
"expected NotFound/Unreachable, got {err:?}"
);
}

View File

@ -0,0 +1,150 @@
//! Integration tests for the nostr channel using a fake `NostrFetcher`.
//! The real fetcher uses websockets to live relays; tests substitute
//! canned events so they're hermetic.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use kez_channels::nostr::{KEZ_NOSTR_KIND, NostrChannel, NostrEvent, NostrFetcher, NostrFilter};
use kez_channels::{Channel, ChannelError, ChannelResult};
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim, nostr_pubkey_hex};
struct CapturingFetcher {
events: Vec<NostrEvent>,
expected_authors: Vec<String>,
expected_kinds: Vec<u32>,
}
#[async_trait]
impl NostrFetcher for CapturingFetcher {
async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
assert_eq!(filter.authors, self.expected_authors, "wrong authors filter");
assert_eq!(filter.kinds, self.expected_kinds, "wrong kinds filter");
Ok(self.events.clone())
}
}
struct FailingFetcher;
#[async_trait]
impl NostrFetcher for FailingFetcher {
async fn fetch_events(&self, _filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
Err(ChannelError::Unreachable("all relays down".into()))
}
}
fn make_event(pubkey_hex: &str, content: String) -> NostrEvent {
NostrEvent {
id: "0".repeat(64),
pubkey: pubkey_hex.to_owned(),
created_at: Utc::now().timestamp(),
kind: KEZ_NOSTR_KIND,
tags: vec![vec!["d".to_owned(), "kez".to_owned()]],
content,
sig: "f".repeat(128),
}
}
fn sign_for_self() -> (NostrSecret, Identity, SignedClaim) {
let secret = NostrSecret::generate();
let identity = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let signed = SignedClaim::sign(
ClaimPayload::new(identity.clone(), identity.clone(), Utc::now()),
&secret,
)
.unwrap();
(secret, identity, signed)
}
#[tokio::test]
async fn verifies_self_published_proof_from_relay() {
let (_secret, identity, signed) = sign_for_self();
let pubkey_hex = nostr_pubkey_hex(&identity).unwrap();
let compact = signed.to_compact().unwrap();
let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_hex, compact)],
expected_authors: vec![pubkey_hex.clone()],
expected_kinds: vec![KEZ_NOSTR_KIND],
};
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
let hit = channel.fetch_and_verify(&identity).await.unwrap();
assert_eq!(hit.proof, signed);
}
#[tokio::test]
async fn skips_events_whose_pubkey_field_mismatches() {
// Relay returns an event with a forged content-pubkey discrepancy:
// event.pubkey claims to be someone else even though the filter asked
// for `identity`. We must not trust the content in that case.
let (_secret_a, identity_a, signed_a) = sign_for_self();
let (_secret_b, identity_b, _signed_b) = sign_for_self();
let pubkey_b_hex = nostr_pubkey_hex(&identity_b).unwrap();
let compact_a = signed_a.to_compact().unwrap();
let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_b_hex, compact_a)],
expected_authors: vec![nostr_pubkey_hex(&identity_a).unwrap()],
expected_kinds: vec![KEZ_NOSTR_KIND],
};
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
let err = channel.fetch_and_verify(&identity_a).await.unwrap_err();
assert!(
matches!(err, ChannelError::NotFound(_)),
"expected NotFound (all events rejected by author check), got {err:?}"
);
}
#[tokio::test]
async fn rejects_proof_signed_for_different_subject() {
// The event is correctly authored, but the embedded claim is for a
// different identity. The subject-mismatch check must fire.
let (secret_a, identity_a, _signed_self_a) = sign_for_self();
let (_secret_b, identity_b, _signed_self_b) = sign_for_self();
let pubkey_a_hex = nostr_pubkey_hex(&identity_a).unwrap();
// A signs a claim with subject == B (legitimate proof but for B, not A).
let claim_for_b = SignedClaim::sign(
ClaimPayload::new(identity_b.clone(), identity_a.clone(), Utc::now()),
&secret_a,
)
.unwrap();
let compact = claim_for_b.to_compact().unwrap();
let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_a_hex, compact)],
expected_authors: vec![pubkey_a_hex.clone()],
expected_kinds: vec![KEZ_NOSTR_KIND],
};
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
let err = channel.fetch_and_verify(&identity_a).await.unwrap_err();
assert!(
matches!(err, ChannelError::SubjectMismatch { .. }),
"expected SubjectMismatch, got {err:?}"
);
}
#[tokio::test]
async fn no_events_yields_not_found() {
let (_s, identity, _signed) = sign_for_self();
let fetcher = CapturingFetcher {
events: vec![],
expected_authors: vec![nostr_pubkey_hex(&identity).unwrap()],
expected_kinds: vec![KEZ_NOSTR_KIND],
};
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_)));
}
#[tokio::test]
async fn fetcher_failure_surfaces_as_unreachable() {
let (_s, identity, _signed) = sign_for_self();
let channel = NostrChannel::with_fetcher(Arc::new(FailingFetcher));
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::Unreachable(_)));
}

1
rust/crates/kez-cli/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

View File

@ -0,0 +1,14 @@
[package]
name = "kez-cli"
version = "0.1.0"
edition.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
dirs = "5"
kez-channels = { path = "../kez-channels" }
kez-core = { path = "../kez-core" }
reqwest.workspace = true
tokio.workspace = true

View File

@ -0,0 +1,752 @@
use anyhow::{Context, Result, bail};
use chrono::Utc;
use clap::{Parser, Subcommand, ValueEnum};
use kez_channels::nostr as nostr_chan;
use kez_channels::{ChannelHit, Registry, parse_proof};
use kez_core::{
ClaimPayload, Ed25519Secret, Identity, NostrSecret, SignedClaim, Signer, Sigchain,
VerificationStatus, dns_txt_name,
};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Parser)]
#[command(name = "kez")]
#[command(about = "KEZ portable identity graph CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Identity {
#[command(subcommand)]
command: IdentityCommand,
},
Claim {
#[command(subcommand)]
command: ClaimCommand,
},
Verify {
#[command(subcommand)]
command: VerifyCommand,
},
Sigchain {
#[command(subcommand)]
command: SigchainCommand,
},
}
#[derive(Debug, Subcommand)]
enum SigchainCommand {
/// Append an `add` event to the chain for the signing key.
Add {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long)]
proof_url: Option<String>,
},
/// Append a `revoke` event to the chain for the signing key.
Revoke {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
},
/// Print the chain (events one per line, plus a summary).
Show {
/// Read-only: identify the chain by primary alone, no key needed.
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
},
/// Export the chain in a portable format.
Export {
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
format: ExportFormat,
#[arg(long)]
out: Option<PathBuf>,
},
/// Publish the chain to one or more destinations.
Publish {
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
/// POST every event to a kez-sig-server at this URL.
#[arg(long)]
server: Option<String>,
/// Write the chain as JSONL to a file (you upload it yourself).
#[arg(long)]
web: bool,
/// Output path for `--web` (required if `--web` is set).
#[arg(long, requires = "web")]
out: Option<PathBuf>,
/// Print a DNS TXT zone record for `_kez-chain.<domain>` with the
/// compact bundle. You install it in your registrar.
#[arg(long)]
dns: Option<String>,
/// Publish the compact bundle as a kind-30078 event to this nostr
/// relay. Requires `--nsec` (not `--ed25519-seed`).
#[arg(long)]
nostr: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ExportFormat {
Jsonl,
Compact,
}
#[derive(Debug, Subcommand)]
enum IdentityCommand {
New {
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
key_type: KeyType,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum KeyType {
Nostr,
Ed25519,
}
#[derive(Debug, Subcommand)]
enum ClaimCommand {
Create {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
#[arg(long)]
out: Option<PathBuf>,
},
Dns {
domain: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum VerifyCommand {
/// Verify a local proof file (developer helper).
File { path: PathBuf },
/// Verify any KEZ identifier (dns:, github:, ...) by dispatching to its channel.
Id { identifier: String },
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Json,
Markdown,
Compact,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Identity { command } => match command {
IdentityCommand::New { key_type } => identity_new(key_type),
},
Command::Claim { command } => match command {
ClaimCommand::Create {
subject,
nsec,
ed25519_seed,
format,
out,
} => claim_create(subject, nsec, ed25519_seed, format, out),
ClaimCommand::Dns {
domain,
nsec,
ed25519_seed,
} => claim_dns(domain, nsec, ed25519_seed),
},
Command::Verify { command } => match command {
VerifyCommand::File { path } => verify_file(path),
VerifyCommand::Id { identifier } => verify_identifier(identifier).await,
},
Command::Sigchain { command } => sigchain_dispatch(command).await,
}
}
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
match cmd {
SigchainCommand::Add {
subject,
nsec,
ed25519_seed,
proof_url,
} => sigchain_add(subject, nsec, ed25519_seed, proof_url),
SigchainCommand::Revoke {
subject,
nsec,
ed25519_seed,
} => sigchain_revoke(subject, nsec, ed25519_seed),
SigchainCommand::Show {
primary,
nsec,
ed25519_seed,
} => sigchain_show(primary, nsec, ed25519_seed),
SigchainCommand::Export {
primary,
nsec,
ed25519_seed,
format,
out,
} => sigchain_export(primary, nsec, ed25519_seed, format, out),
SigchainCommand::Publish {
primary,
nsec,
ed25519_seed,
server,
web,
out,
dns,
nostr,
} => {
sigchain_publish(
primary,
nsec,
ed25519_seed,
server,
web,
out,
dns,
nostr,
)
.await
}
}
}
fn identity_new(key_type: KeyType) -> Result<()> {
match key_type {
KeyType::Nostr => {
let secret = NostrSecret::generate();
println!("Primary: nostr:{}", secret.npub());
println!("Public: {}", secret.npub());
println!("Secret: {}", secret.nsec());
println!();
println!(
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
);
}
KeyType::Ed25519 => {
let secret = Ed25519Secret::generate();
let id = secret.identity()?;
println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!();
println!(
"Store the secret somewhere safe. Anyone with the seed can sign as this identity."
);
}
}
Ok(())
}
/// Build a signed claim from whichever signing key the caller supplied.
/// Exactly one of `nsec` / `ed25519_seed` must be present (clap enforces).
fn build_claim(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<SignedClaim> {
let subject = Identity::parse(subject)?;
match (nsec, ed25519_seed) {
(Some(nsec), None) => {
let signer = NostrSecret::from_nsec(&nsec).context("invalid nsec")?;
let primary = Identity::parse(format!("nostr:{}", signer.npub()))?;
let payload = ClaimPayload::new(subject, primary, Utc::now());
Ok(SignedClaim::sign_with(payload, Signer::Nostr(&signer))?)
}
(None, Some(seed)) => {
let signer = Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?;
let primary = signer.identity()?;
let payload = ClaimPayload::new(subject, primary, Utc::now());
Ok(SignedClaim::sign_with(payload, Signer::Ed25519(&signer))?)
}
(None, None) => anyhow::bail!("missing key: pass --nsec or --ed25519-seed"),
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
}
fn claim_create(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
format: OutputFormat,
out: Option<PathBuf>,
) -> Result<()> {
let signed = build_claim(subject, nsec, ed25519_seed)?;
let output = match format {
OutputFormat::Json => signed.to_pretty_json()?,
OutputFormat::Markdown => signed.to_markdown_proof()?,
OutputFormat::Compact => signed.to_compact()?,
};
write_or_print(out, &output)
}
fn claim_dns(
domain: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let subject = if domain.starts_with("dns:") {
domain
} else {
format!("dns:{domain}")
};
let signed = build_claim(subject, nsec, ed25519_seed)?;
let name = dns_txt_name(&signed.payload.subject)?;
let value = signed.to_compact()?;
println!("Name: {name}");
println!("Value: {value}");
println!();
println!("Zone file:");
println!("{name} TXT {}", quote_dns_txt_value(&value));
Ok(())
}
fn verify_file(path: PathBuf) -> Result<()> {
let raw =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let proof = parse_proof(&raw).context("failed to parse KEZ proof")?;
let status = proof.verify().context("signature verification failed")?;
print_status(&status);
Ok(())
}
async fn verify_identifier(identifier: String) -> Result<()> {
let identity = Identity::parse(identifier).context("invalid KEZ identifier")?;
let registry = Registry::with_defaults()
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to build channel registry")?;
let hit: ChannelHit = registry
.verify(&identity)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
print_status(&hit.status);
Ok(())
}
fn print_status(status: &VerificationStatus) {
println!("Primary: {}", status.primary);
println!();
println!("Verified identities:");
for identity in &status.verified {
println!("- {identity}");
}
println!();
println!("Status: {}", status.status);
println!("Confidence: {}", status.confidence);
}
fn write_or_print(out: Option<PathBuf>, output: &str) -> Result<()> {
match out {
Some(path) => {
fs::write(&path, output).with_context(|| format!("failed to write {}", path.display()))
}
None => {
// Match Node's `writeOrPrint`: avoid double-newlines if `output`
// already ends in one (the sigchain JSONL case).
if output.ends_with('\n') {
print!("{output}");
} else {
println!("{output}");
}
Ok(())
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain commands
// ─────────────────────────────────────────────────────────────────────────────
/// Where the local chain for `primary` lives on disk.
fn sigchain_path(primary: &Identity) -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
let dir = home.join(".kez").join("sigchains");
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let safe = primary.as_str().replace(':', "_");
Ok(dir.join(format!("{safe}.jsonl")))
}
/// Load the chain for `primary` from disk, or return an empty chain if no
/// file exists.
fn load_chain(primary: &Identity) -> Result<Sigchain> {
let path = sigchain_path(primary)?;
if !path.exists() {
return Ok(Sigchain::new(primary.clone()));
}
let text = fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
Ok(Sigchain::from_jsonl(&text)?)
}
fn save_chain(chain: &Sigchain) -> Result<()> {
let path = sigchain_path(chain.primary())?;
let text = chain.to_jsonl()?;
fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Build a `Signer` borrow from whichever flag the user passed.
/// Returns the loaded keys so the caller can keep them alive.
enum SignerKeys {
Nostr(NostrSecret),
Ed25519(Ed25519Secret),
}
impl SignerKeys {
fn from_flags(nsec: Option<String>, ed25519_seed: Option<String>) -> Result<Self> {
match (nsec, ed25519_seed) {
(Some(nsec), None) => Ok(Self::Nostr(
NostrSecret::from_nsec(&nsec).context("invalid nsec")?,
)),
(None, Some(seed)) => Ok(Self::Ed25519(
Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?,
)),
(None, None) => bail!("missing key: pass --nsec or --ed25519-seed"),
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
}
fn primary(&self) -> Result<Identity> {
match self {
SignerKeys::Nostr(s) => Identity::parse(format!("nostr:{}", s.npub())),
SignerKeys::Ed25519(s) => s.identity(),
}
.map_err(Into::into)
}
fn as_signer(&self) -> Signer<'_> {
match self {
SignerKeys::Nostr(s) => Signer::Nostr(s),
SignerKeys::Ed25519(s) => Signer::Ed25519(s),
}
}
}
/// Resolve the primary identity for a read-only command. Accepts `--primary`
/// directly, or derives it from a signing key.
fn resolve_primary_readonly(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<Identity> {
if let Some(p) = primary {
return Ok(Identity::parse(p)?);
}
SignerKeys::from_flags(nsec, ed25519_seed)?.primary()
}
fn sigchain_add(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
proof_url: Option<String>,
) -> Result<()> {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let subject = Identity::parse(subject)?;
let mut chain = load_chain(&primary)?;
let event = chain.sign_add(subject.clone(), proof_url, keys.as_signer())?;
println!(
"Appended add {} at seq {} (head hash: {})",
subject,
event.payload.seq,
event.hash()?
);
save_chain(&chain)?;
println!("Chain saved to {}", sigchain_path(&primary)?.display());
Ok(())
}
fn sigchain_revoke(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let subject = Identity::parse(subject)?;
let mut chain = load_chain(&primary)?;
let event = chain.sign_revoke(subject.clone(), keys.as_signer())?;
println!(
"Appended revoke {} at seq {} (head hash: {})",
subject,
event.payload.seq,
event.hash()?
);
save_chain(&chain)?;
println!("Chain saved to {}", sigchain_path(&primary)?.display());
Ok(())
}
fn sigchain_show(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?;
let chain = load_chain(&primary)?;
println!("Primary: {primary}");
println!("Path: {}", sigchain_path(&primary)?.display());
println!("Length: {} event(s)", chain.len());
println!();
for (i, event) in chain.events().iter().enumerate() {
let subject = event
.payload
.subject()
.map(|s| s.to_string())
.unwrap_or_else(|| "<no subject>".into());
println!(
" [{i}] seq={} op={:6} subject={subject}",
event.payload.seq, event.payload.op
);
}
if !chain.is_empty() {
println!();
println!("Head hash: {}", chain.head_hash()?.unwrap());
}
Ok(())
}
fn sigchain_export(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
format: ExportFormat,
out: Option<PathBuf>,
) -> Result<()> {
let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?;
let chain = load_chain(&primary)?;
if chain.is_empty() {
bail!("no chain found for {primary}");
}
let output = match format {
ExportFormat::Jsonl => chain.to_jsonl()?,
ExportFormat::Compact => chain.to_compact_bundle()?,
};
write_or_print(out, &output)
}
async fn sigchain_publish(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
server: Option<String>,
web: bool,
out: Option<PathBuf>,
dns: Option<String>,
nostr: Option<String>,
) -> Result<()> {
// Need at least one destination.
if server.is_none() && !web && dns.is_none() && nostr.is_none() {
bail!("no publish destination: pass --server / --web / --dns / --nostr");
}
// Resolve the primary and (optionally) the signer.
// For --nostr we need a NostrSecret specifically (to sign the wrapping
// event); for other destinations we just need the chain.
let (primary, nsec_signer): (Identity, Option<NostrSecret>) = if let Some(p) = primary {
(Identity::parse(p)?, None)
} else {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let nsec_signer = match keys {
SignerKeys::Nostr(s) => Some(s),
SignerKeys::Ed25519(_) => None,
};
(primary, nsec_signer)
};
let chain = load_chain(&primary)?;
if chain.is_empty() {
bail!("no chain found for {primary}");
}
if let Some(server_url) = server.as_deref() {
publish_to_server(&chain, server_url).await?;
}
if web {
let out = out.context("--web requires --out <path>")?;
publish_to_web(&chain, &out)?;
}
if let Some(domain) = dns.as_deref() {
publish_to_dns(&chain, domain)?;
}
if let Some(relay) = nostr.as_deref() {
let signer = nsec_signer
.as_ref()
.context("--nostr publish requires --nsec (nostr key needed to sign the wrapping event)")?;
// The wrapping nostr key must match the chain's primary, otherwise
// verifiers can't tie the event to the identity.
let signer_primary = Identity::parse(format!("nostr:{}", signer.npub()))?;
if signer_primary != primary {
bail!(
"--nostr publish requires the signing nsec to match the chain primary ({primary}); got {signer_primary}"
);
}
publish_to_nostr(&chain, relay, signer).await?;
}
Ok(())
}
async fn publish_to_server(chain: &Sigchain, server_url: &str) -> Result<()> {
let base = server_url.trim_end_matches('/');
let scheme = chain.primary().scheme();
let id = chain.primary().value();
let client = reqwest::Client::builder()
.user_agent("kez-cli/0.1")
.build()?;
let endpoint = format!("{base}/v1/sigchains/{scheme}/{id}/events");
let mut posted = 0;
let mut already_present = 0;
for event in chain.events() {
let resp = client
.post(&endpoint)
.json(event)
.send()
.await
.with_context(|| format!("POST {endpoint}"))?;
let status = resp.status();
if status.is_success() {
posted += 1;
} else if status == reqwest::StatusCode::CONFLICT {
// Idempotent: server already has this seq, fine.
already_present += 1;
} else {
let body = resp.text().await.unwrap_or_default();
bail!("POST {endpoint}: {status} {body}");
}
}
println!(
"server({server_url}): posted {posted} event(s), {already_present} already present"
);
Ok(())
}
fn publish_to_web(chain: &Sigchain, path: &Path) -> Result<()> {
let text = chain.to_jsonl()?;
fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
println!(
"web: wrote {} event(s) to {} (upload to https://<your-domain>/.well-known/kez-sigchain.jsonl)",
chain.len(),
path.display()
);
Ok(())
}
fn publish_to_dns(chain: &Sigchain, domain: &str) -> Result<()> {
let compact = chain.to_compact_bundle()?;
let name = format!("_kez-chain.{domain}");
println!("dns({domain}):");
println!(" Name: {name}");
println!(" Value: {compact}");
println!();
println!(" Zone file (install in your DNS registrar):");
println!(" {name} TXT {}", quote_dns_txt_value(&compact));
Ok(())
}
async fn publish_to_nostr(
chain: &Sigchain,
relay: &str,
signer: &NostrSecret,
) -> Result<()> {
let content = chain.to_compact_bundle()?;
let event = nostr_chan::build_signed_event(
signer,
Utc::now().timestamp(),
nostr_chan::KEZ_NOSTR_KIND,
vec![vec!["d".into(), "kez-sigchain".into()]],
content,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
nostr_chan::publish_event_to_relay(relay, &event)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"nostr({relay}): published kind-{} event {}",
nostr_chan::KEZ_NOSTR_KIND,
event.id
);
Ok(())
}
fn quote_dns_txt_value(value: &str) -> String {
value
.chars()
.collect::<Vec<_>>()
.chunks(240)
.map(|chunk| {
let escaped = chunk
.iter()
.flat_map(|ch| match ch {
'"' => ['\\', '"'].into_iter().collect::<Vec<_>>(),
'\\' => ['\\', '\\'].into_iter().collect::<Vec<_>>(),
other => [*other].into_iter().collect::<Vec<_>>(),
})
.collect::<String>();
format!("\"{escaped}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_dns_txt_value_chunks_long_inputs() {
let value = "a".repeat(500);
let quoted = quote_dns_txt_value(&value);
// 500 chars / 240 chunk -> 3 quoted segments.
let segments = quoted.split(' ').count();
assert_eq!(segments, 3);
assert!(quoted.starts_with('"'));
assert!(quoted.ends_with('"'));
}
#[test]
fn quote_dns_txt_value_escapes_quotes_and_backslashes() {
let quoted = quote_dns_txt_value(r#"hello "kez" \n"#);
assert!(quoted.contains(r#"\""#));
assert!(quoted.contains(r"\\"));
}
}

1
rust/crates/kez-core/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

View File

@ -0,0 +1,19 @@
[package]
name = "kez-core"
version = "0.1.0"
edition.workspace = true
[dependencies]
base64.workspace = true
bech32.workspace = true
chrono.workspace = true
ed25519-dalek.workspace = true
hex.workspace = true
rand.workspace = true
secp256k1.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_jcs.workspace = true
sha2.workspace = true
thiserror.workspace = true
zstd.workspace = true

File diff suppressed because it is too large Load Diff

1
rust/github-jason.kez Normal file
View File

@ -0,0 +1 @@
kez:z1:KLUv_QBYnQkARthIH1Bn9QhakN2lQptR9RtJSTPGsTOTh-_5QgnfoZiZmTVBAEMAPgB1xQh64KzU8CxcaTJBM6Et9jFQJsAYMbVZFm7tJ8SIbrHgPoaGzi8AmN5ZuA9JdRNwnQ0FiaEtlqDhQi_c6Z1NBMpDFoBrcDV4qIDGDKcMhCm2cCsP-Ff-2Vvse-iJGq3sOYJYmCIdEIhDUxNC8ug1hjkUp-bkcpEIk4FD0uFRVTCxdAIcG6v3zL3bR2QdLGXH9p1Exq2p5wt3aeg7WyxtgoE4xgw15jQ8TFNjOYhjUD81lgJvAu7PqPKzNHSLgxVIjgcHqxLF2Vm2s8b4D9nK_Sle8VA8Udy3QzHm6luxVFR03Inz1Ywtj7Ld2yle7rIPXm41X1JZccXInsgXAwDKmqE3U5gnHV71BA

25
rust/github-jason.kez.md Normal file
View File

@ -0,0 +1,25 @@
# KEZ Proof
This account publishes a signed KEZ identity claim.
- Primary: `nostr:npub1tkfazv0s7qypdwzjygfdzr0594hh37udyxz03qxxqy92gx6d72ts3unpe7`
- Subject: `github:jason`
- Created: `2026-05-23 04:18:12.737822 UTC`
```kez
{
"kez": "claim",
"payload": {
"type": "kez.claim",
"version": 1,
"subject": "github:jason",
"primary": "nostr:npub1tkfazv0s7qypdwzjygfdzr0594hh37udyxz03qxxqy92gx6d72ts3unpe7",
"created_at": "2026-05-23T04:18:12.737822Z"
},
"signature": {
"alg": "nostr-secp256k1-schnorr-sha256-jcs",
"key": "nostr:npub1tkfazv0s7qypdwzjygfdzr0594hh37udyxz03qxxqy92gx6d72ts3unpe7",
"sig": "b40b630d5d6f802236cdeb9e2cfb679d81270bead5e78e66a48f87443bbbd4ee400d18789cc932c87bfebfe88536b685b91ae1d0008ea0d6c668241f7b97f945"
}
}
```