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:
commit
d0db6f00f1
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
103
README.md
Normal 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 |
|
||||
|---|---|
|
||||
| 1–2 | nostr-signed JSON claim, both directions |
|
||||
| 3–4 | nostr-signed compact claim, both directions |
|
||||
| 5–6 | nostr-signed markdown claim, both directions |
|
||||
| 7–8 | nostr-signed DNS zone form, both directions |
|
||||
| 9–10 | ed25519-signed JSON claim, both directions |
|
||||
| 11–12 | ed25519-signed compact claim, both directions |
|
||||
| 13–14 | ed25519-signed markdown claim, both directions |
|
||||
| 15 | rust builds 3-event nostr sigchain → node parses + shows |
|
||||
| 16 | rust-exported sigchain JSONL == node-exported JSONL (byte-identical) |
|
||||
| 17 | node builds 3-event nostr sigchain → rust parses + shows |
|
||||
| 18 | rust builds ed25519 sigchain → node parses + shows |
|
||||
| 19 | node builds ed25519 sigchain → rust parses + shows |
|
||||
|
||||
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
366
SPEC.md
Normal 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.1–4.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
299
crosstest.sh
Executable 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
140
nodejs/README.md
Normal 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
2191
nodejs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
nodejs/package.json
Normal file
21
nodejs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
nodejs/packages/kez-channels/package.json
Normal file
22
nodejs/packages/kez-channels/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
174
nodejs/packages/kez-channels/src/activitypub.ts
Normal file
174
nodejs/packages/kez-channels/src/activitypub.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
nodejs/packages/kez-channels/src/bluesky.ts
Normal file
67
nodejs/packages/kez-channels/src/bluesky.ts
Normal 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;
|
||||
}
|
||||
53
nodejs/packages/kez-channels/src/dns.ts
Normal file
53
nodejs/packages/kez-channels/src/dns.ts
Normal 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:");
|
||||
}
|
||||
130
nodejs/packages/kez-channels/src/github.ts
Normal file
130
nodejs/packages/kez-channels/src/github.ts
Normal 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;
|
||||
}
|
||||
172
nodejs/packages/kez-channels/src/index.ts
Normal file
172
nodejs/packages/kez-channels/src/index.ts
Normal 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);
|
||||
}
|
||||
273
nodejs/packages/kez-channels/src/nostr.ts
Normal file
273
nodejs/packages/kez-channels/src/nostr.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
193
nodejs/packages/kez-channels/test/activitypub.test.ts
Normal file
193
nodejs/packages/kez-channels/test/activitypub.test.ts
Normal 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 & b <c>")).toBe("a & b <c>");
|
||||
expect(stripHtml(""quoted"")).toBe('"quoted"');
|
||||
expect(stripHtml("'apos'")).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",
|
||||
);
|
||||
});
|
||||
});
|
||||
111
nodejs/packages/kez-channels/test/bluesky.test.ts
Normal file
111
nodejs/packages/kez-channels/test/bluesky.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
96
nodejs/packages/kez-channels/test/core.test.ts
Normal file
96
nodejs/packages/kez-channels/test/core.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
nodejs/packages/kez-channels/test/dns.test.ts
Normal file
93
nodejs/packages/kez-channels/test/dns.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
nodejs/packages/kez-channels/test/github.test.ts
Normal file
157
nodejs/packages/kez-channels/test/github.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
186
nodejs/packages/kez-channels/test/nostr.test.ts
Normal file
186
nodejs/packages/kez-channels/test/nostr.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
nodejs/packages/kez-channels/tsconfig.json
Normal file
9
nodejs/packages/kez-channels/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"references": [{ "path": "../kez-core" }],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
14
nodejs/packages/kez-cli/package.json
Normal file
14
nodejs/packages/kez-cli/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
479
nodejs/packages/kez-cli/src/cli.ts
Executable file
479
nodejs/packages/kez-cli/src/cli.ts
Executable 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();
|
||||
9
nodejs/packages/kez-cli/tsconfig.json
Normal file
9
nodejs/packages/kez-cli/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"references": [{ "path": "../kez-core" }, { "path": "../kez-channels" }],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
17
nodejs/packages/kez-core/package.json
Normal file
17
nodejs/packages/kez-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
136
nodejs/packages/kez-core/src/claim.ts
Normal file
136
nodejs/packages/kez-core/src/claim.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
85
nodejs/packages/kez-core/src/ed25519.ts
Normal file
85
nodejs/packages/kez-core/src/ed25519.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
nodejs/packages/kez-core/src/encodings.ts
Normal file
109
nodejs/packages/kez-core/src/encodings.ts
Normal 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;
|
||||
}
|
||||
76
nodejs/packages/kez-core/src/envelope.ts
Normal file
76
nodejs/packages/kez-core/src/envelope.ts
Normal 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");
|
||||
}
|
||||
102
nodejs/packages/kez-core/src/identity.ts
Normal file
102
nodejs/packages/kez-core/src/identity.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
60
nodejs/packages/kez-core/src/index.ts
Normal file
60
nodejs/packages/kez-core/src/index.ts
Normal 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";
|
||||
25
nodejs/packages/kez-core/src/jcs.ts
Normal file
25
nodejs/packages/kez-core/src/jcs.ts
Normal 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;
|
||||
}
|
||||
92
nodejs/packages/kez-core/src/nostr.ts
Normal file
92
nodejs/packages/kez-core/src/nostr.ts
Normal 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));
|
||||
}
|
||||
387
nodejs/packages/kez-core/src/sigchain.ts
Normal file
387
nodejs/packages/kez-core/src/sigchain.ts
Normal 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 };
|
||||
246
nodejs/packages/kez-core/test/core.test.ts
Normal file
246
nodejs/packages/kez-core/test/core.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
160
nodejs/packages/kez-core/test/sigchain.test.ts
Normal file
160
nodejs/packages/kez-core/test/sigchain.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
8
nodejs/packages/kez-core/tsconfig.json
Normal file
8
nodejs/packages/kez-core/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
20
nodejs/tsconfig.base.json
Normal file
20
nodejs/tsconfig.base.json
Normal 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
8
nodejs/tsconfig.json
Normal 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
9
nodejs/vitest.config.ts
Normal 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
2530
rust-sig-server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
rust-sig-server/Cargo.toml
Normal file
25
rust-sig-server/Cargo.toml
Normal 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
498
rust-sig-server/README.md
Normal 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
117
rust-sig-server/src/api.rs
Normal 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}")))
|
||||
}
|
||||
88
rust-sig-server/src/error.rs
Normal file
88
rust-sig-server/src/error.rs
Normal 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}"))
|
||||
}
|
||||
}
|
||||
10
rust-sig-server/src/lib.rs
Normal file
10
rust-sig-server/src/lib.rs
Normal 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;
|
||||
57
rust-sig-server/src/main.rs
Normal file
57
rust-sig-server/src/main.rs
Normal 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");
|
||||
}
|
||||
137
rust-sig-server/src/store.rs
Normal file
137
rust-sig-server/src/store.rs
Normal 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);",
|
||||
)
|
||||
}
|
||||
281
rust-sig-server/tests/http.rs
Normal file
281
rust-sig-server/tests/http.rs
Normal 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
2978
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
rust/Cargo.toml
Normal file
36
rust/Cargo.toml
Normal 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
356
rust/README.md
Normal 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`).
|
||||
24
rust/crates/kez-channels/Cargo.toml
Normal file
24
rust/crates/kez-channels/Cargo.toml
Normal 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
|
||||
345
rust/crates/kez-channels/src/activitypub.rs
Normal file
345
rust/crates/kez-channels/src/activitypub.rs
Normal 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 & b <c>"), "a & b <c>");
|
||||
assert_eq!(strip_html(""quoted""), r#""quoted""#);
|
||||
assert_eq!(strip_html("'apos'"), "'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```");
|
||||
}
|
||||
}
|
||||
158
rust/crates/kez-channels/src/bluesky.rs
Normal file
158
rust/crates/kez-channels/src/bluesky.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
175
rust/crates/kez-channels/src/dns.rs
Normal file
175
rust/crates/kez-channels/src/dns.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
223
rust/crates/kez-channels/src/github.rs
Normal file
223
rust/crates/kez-channels/src/github.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
287
rust/crates/kez-channels/src/lib.rs
Normal file
287
rust/crates/kez-channels/src/lib.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
453
rust/crates/kez-channels/src/nostr.rs
Normal file
453
rust/crates/kez-channels/src/nostr.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
196
rust/crates/kez-channels/tests/activitypub.rs
Normal file
196
rust/crates/kez-channels/tests/activitypub.rs
Normal 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(_)));
|
||||
}
|
||||
122
rust/crates/kez-channels/tests/bluesky.rs
Normal file
122
rust/crates/kez-channels/tests/bluesky.rs
Normal 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(_)));
|
||||
}
|
||||
84
rust/crates/kez-channels/tests/dns.rs
Normal file
84
rust/crates/kez-channels/tests/dns.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
193
rust/crates/kez-channels/tests/github.rs
Normal file
193
rust/crates/kez-channels/tests/github.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
150
rust/crates/kez-channels/tests/nostr.rs
Normal file
150
rust/crates/kez-channels/tests/nostr.rs
Normal 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
1
rust/crates/kez-cli/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
14
rust/crates/kez-cli/Cargo.toml
Normal file
14
rust/crates/kez-cli/Cargo.toml
Normal 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
|
||||
752
rust/crates/kez-cli/src/main.rs
Normal file
752
rust/crates/kez-cli/src/main.rs
Normal 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
1
rust/crates/kez-core/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
19
rust/crates/kez-core/Cargo.toml
Normal file
19
rust/crates/kez-core/Cargo.toml
Normal 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
|
||||
1361
rust/crates/kez-core/src/lib.rs
Normal file
1361
rust/crates/kez-core/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
1
rust/github-jason.kez
Normal file
1
rust/github-jason.kez
Normal file
@ -0,0 +1 @@
|
||||
kez:z1:KLUv_QBYnQkARthIH1Bn9QhakN2lQptR9RtJSTPGsTOTh-_5QgnfoZiZmTVBAEMAPgB1xQh64KzU8CxcaTJBM6Et9jFQJsAYMbVZFm7tJ8SIbrHgPoaGzi8AmN5ZuA9JdRNwnQ0FiaEtlqDhQi_c6Z1NBMpDFoBrcDV4qIDGDKcMhCm2cCsP-Ff-2Vvse-iJGq3sOYJYmCIdEIhDUxNC8ug1hjkUp-bkcpEIk4FD0uFRVTCxdAIcG6v3zL3bR2QdLGXH9p1Exq2p5wt3aeg7WyxtgoE4xgw15jQ8TFNjOYhjUD81lgJvAu7PqPKzNHSLgxVIjgcHqxLF2Vm2s8b4D9nK_Sle8VA8Udy3QzHm6luxVFR03Inz1Ywtj7Ld2yle7rIPXm41X1JZccXInsgXAwDKmqE3U5gnHV71BA
|
||||
25
rust/github-jason.kez.md
Normal file
25
rust/github-jason.kez.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user