commit d0db6f00f12ba658d3d0588c7548c644d1cd7e10 Author: Tudisco Date: Sun May 24 14:41:00 2026 -0600 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:. - JCS (RFC 8785) canonicalization for everything signed. - Four proof encodings: JSON envelope, compact (kez:z1:), Markdown fence, DNS TXT. - Five channel plugins (no API keys, no auth needed for any of them): dns: system resolver, _kez. TXT records github: public gist scan + / 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 (--nsec | --ed25519-seed) [--format ...] [--out ...] kez claim dns (--nsec | --ed25519-seed) kez verify file kez verify id 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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..132423a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b086dc --- /dev/null +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..27abbd4 --- /dev/null +++ b/SPEC.md @@ -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": "" + } +} +``` + +- `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: +``` + +- `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. +``` + +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.` | Compact (§4.2) | +| `web` | `https:///.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:", + "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": "" }`. 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 +``` + +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.`. 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.`) +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. diff --git a/crosstest.sh b/crosstest.sh new file mode 100755 index 0000000..b2ea405 --- /dev/null +++ b/crosstest.sh @@ -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: (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/.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 diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 0000000..4c99720 --- /dev/null +++ b/nodejs/README.md @@ -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/.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; +} +``` + +…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. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json new file mode 100644 index 0000000..cf83304 --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,2191 @@ +{ + "name": "kez-nodejs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kez-nodejs", + "version": "0.1.0", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@types/node": "^22.7.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^2.1.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@kez/channels": { + "resolved": "packages/kez-channels", + "link": true + }, + "node_modules/@kez/cli": { + "resolved": "packages/kez-cli", + "link": true + }, + "node_modules/@kez/core": { + "resolved": "packages/kez-core", + "link": true + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nock": { + "version": "14.0.15", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", + "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.41.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "packages/kez-channels": { + "name": "@kez/channels", + "version": "0.1.0", + "dependencies": { + "@kez/core": "*" + }, + "devDependencies": { + "nock": "^14.0.0-beta.16" + } + }, + "packages/kez-cli": { + "name": "@kez/cli", + "version": "0.1.0", + "dependencies": { + "@kez/channels": "*", + "@kez/core": "*" + }, + "bin": { + "kez": "src/cli.ts" + } + }, + "packages/kez-core": { + "name": "@kez/core", + "version": "0.1.0", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/base": "^1.1.9", + "canonicalize": "^2.0.0" + } + } + } +} diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..6cc0d58 --- /dev/null +++ b/nodejs/package.json @@ -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" + } +} diff --git a/nodejs/packages/kez-channels/package.json b/nodejs/packages/kez-channels/package.json new file mode 100644 index 0000000..b6db895 --- /dev/null +++ b/nodejs/packages/kez-channels/package.json @@ -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" + } +} diff --git a/nodejs/packages/kez-channels/src/activitypub.ts b/nodejs/packages/kez-channels/src/activitypub.ts new file mode 100644 index 0000000..baaa761 --- /dev/null +++ b/nodejs/packages/kez-channels/src/activitypub.ts @@ -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 { + 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 { + 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).links; + if (!Array.isArray(links)) return undefined; + for (const link of links) { + if (typeof link !== "object" || link === null) continue; + const rel = (link as Record).rel; + const typ = (link as Record).type; + const href = (link as Record).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).attachment; + if (Array.isArray(attachments)) { + for (const att of attachments) { + const value = (att as Record)?.value; + if (typeof value === "string") out.push(stripHtml(value)); + } + } + const summary = (actor as Record).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; + } +} diff --git a/nodejs/packages/kez-channels/src/bluesky.ts b/nodejs/packages/kez-channels/src/bluesky.ts new file mode 100644 index 0000000..d9db89f --- /dev/null +++ b/nodejs/packages/kez-channels/src/bluesky.ts @@ -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 { + 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).feed; + if (!Array.isArray(feed)) return []; + const out: string[] = []; + for (const item of feed) { + const text = (((item as Record)?.post as Record) + ?.record as Record)?.text; + if (typeof text === "string" && text.trim().length > 0) out.push(text); + } + return out; +} diff --git a/nodejs/packages/kez-channels/src/dns.ts b/nodejs/packages/kez-channels/src/dns.ts new file mode 100644 index 0000000..9d4a3f2 --- /dev/null +++ b/nodejs/packages/kez-channels/src/dns.ts @@ -0,0 +1,53 @@ +// DNS channel: queries `_kez.` 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; +} + +export class SystemResolver implements TxtResolver { + async lookupTxt(name: string): Promise { + 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 { + 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:"); +} diff --git a/nodejs/packages/kez-channels/src/github.ts b/nodejs/packages/kez-channels/src/github.ts new file mode 100644 index 0000000..7754086 --- /dev/null +++ b/nodejs/packages/kez-channels/src/github.ts @@ -0,0 +1,130 @@ +// GitHub channel: scans the user's public gists, falls back to `/` +// 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 { + 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 { + 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 { + 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).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)?.raw_url; + if (typeof rawUrl === "string") out.push(rawUrl); + } + } + return out; +} diff --git a/nodejs/packages/kez-channels/src/index.ts b/nodejs/packages/kez-channels/src/index.ts new file mode 100644 index 0000000..0432052 --- /dev/null +++ b/nodejs/packages/kez-channels/src/index.ts @@ -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; +} + +/** system: prefix → channel adapter, with alias support. */ +export class Registry { + private channels = new Map(); + + 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 { + 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 { + 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:` 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); +} diff --git a/nodejs/packages/kez-channels/src/nostr.ts b/nodejs/packages/kez-channels/src/nostr.ts new file mode 100644 index 0000000..d7fe85c --- /dev/null +++ b/nodejs/packages/kez-channels/src/nostr.ts @@ -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; +} + +export class RelayPoolFetcher implements NostrFetcher { + constructor(private relays: string[] = DEFAULT_RELAYS) {} + + async fetchEvents(filter: NostrFilter): Promise { + 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 { + // 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((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 { + 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 { + // eslint-disable-next-line no-undef + const ws = new WebSocket(relayUrl); + const deadline = 5_000; + + await new Promise((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 = { + 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" }; + } +} diff --git a/nodejs/packages/kez-channels/test/activitypub.test.ts b/nodejs/packages/kez-channels/test/activitypub.test.ts new file mode 100644 index 0000000..cf2399c --- /dev/null +++ b/nodejs/packages/kez-channels/test/activitypub.test.ts @@ -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): 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: 'x' }, + { type: "PropertyValue", name: "kez", value: toCompact(signed) }, + ], + summary: "

hi

", + }, + }); + 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: `

portable identity: ${toCompact(signed)}

`, + 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: 'x' }, + { value: "kez:z1:abc" }, + ], + summary: "

kez:z1:def

", + }), + ).toEqual(["x", "kez:z1:abc", "kez:z1:def"]); + }); + + it("stripHtml drops tags and decodes entities", () => { + expect(stripHtml("

hello world

")).toBe("hello world"); + expect(stripHtml("a & b <c>")).toBe("a & b "); + expect(stripHtml(""quoted"")).toBe('"quoted"'); + expect(stripHtml("'apos'")).toBe("'apos'"); + }); + + it("stripHtml preserves compact kez prefix", () => { + expect(stripHtml("

my proof: kez:z1:KLUv_QBYabc

")).toBe( + "my proof: kez:z1:KLUv_QBYabc", + ); + }); +}); diff --git a/nodejs/packages/kez-channels/test/bluesky.test.ts b/nodejs/packages/kez-channels/test/bluesky.test.ts new file mode 100644 index 0000000..d905dea --- /dev/null +++ b/nodejs/packages/kez-channels/test/bluesky.test.ts @@ -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([]); + }); +}); diff --git a/nodejs/packages/kez-channels/test/core.test.ts b/nodejs/packages/kez-channels/test/core.test.ts new file mode 100644 index 0000000..266f050 --- /dev/null +++ b/nodejs/packages/kez-channels/test/core.test.ts @@ -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); + }); +}); diff --git a/nodejs/packages/kez-channels/test/dns.test.ts b/nodejs/packages/kez-channels/test/dns.test.ts new file mode 100644 index 0000000..0a368ec --- /dev/null +++ b/nodejs/packages/kez-channels/test/dns.test.ts @@ -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 { + expect(name).toBe(this.expectedName); + return this.records; + } +} + +class FailingResolver implements TxtResolver { + async lookupTxt(): Promise { + 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.", 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); + }); +}); diff --git a/nodejs/packages/kez-channels/test/github.test.ts b/nodejs/packages/kez-channels/test/github.test.ts new file mode 100644 index 0000000..260c6d8 --- /dev/null +++ b/nodejs/packages/kez-channels/test/github.test.ts @@ -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): 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"]); + }); +}); diff --git a/nodejs/packages/kez-channels/test/nostr.test.ts b/nodejs/packages/kez-channels/test/nostr.test.ts new file mode 100644 index 0000000..f269692 --- /dev/null +++ b/nodejs/packages/kez-channels/test/nostr.test.ts @@ -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 { + 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); + }); +}); diff --git a/nodejs/packages/kez-channels/tsconfig.json b/nodejs/packages/kez-channels/tsconfig.json new file mode 100644 index 0000000..cf98798 --- /dev/null +++ b/nodejs/packages/kez-channels/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [{ "path": "../kez-core" }], + "include": ["src/**/*"] +} diff --git a/nodejs/packages/kez-cli/package.json b/nodejs/packages/kez-cli/package.json new file mode 100644 index 0000000..e43d74a --- /dev/null +++ b/nodejs/packages/kez-cli/package.json @@ -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": "*" + } +} diff --git a/nodejs/packages/kez-cli/src/cli.ts b/nodejs/packages/kez-cli/src/cli.ts new file mode 100755 index 0000000..6227d48 --- /dev/null +++ b/nodejs/packages/kez-cli/src/cli.ts @@ -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 (--nsec | --ed25519-seed ) [--format ...] [--out ...] +// kez claim dns (--nsec | --ed25519-seed ) +// kez verify file +// kez verify id +// kez sigchain add (--nsec | --ed25519-seed) [--proof-url ] +// kez sigchain revoke (--nsec | --ed25519-seed) +// kez sigchain show [--primary ] | (--nsec | --ed25519-seed) +// kez sigchain export [--primary ] | (--nsec | --ed25519-seed) [--format jsonl|compact] [--out ] +// kez sigchain publish [--primary ] | (--nsec | --ed25519-seed) +// [--server ] [--web --out ] [--dns ] [--nostr ] + +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 ...", + "", + "Commands:", + " identity new [--key-type nostr|ed25519]", + " claim create (--nsec | --ed25519-seed )", + " [--format json|markdown|compact] [--out ]", + " claim dns (--nsec | --ed25519-seed )", + " verify file ", + " verify id ", + " sigchain add (--nsec | --ed25519-seed) [--proof-url ]", + " sigchain revoke (--nsec | --ed25519-seed)", + " sigchain show [--primary ] | (--nsec | --ed25519-seed)", + " sigchain export [--primary ] | (--nsec | --ed25519-seed)", + " [--format jsonl|compact] [--out ]", + " sigchain publish [--primary ] | (--nsec | --ed25519-seed)", + " [--server ] [--web --out ] [--dns ] [--nostr ]", + "", + ].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 "); + 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 "); + 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 "); + const raw = readFileSync(args.positional[0], "utf8"); + const proof = parseProof(raw); + const status = verifyClaim(proof); + printStatus(status); +} + +async function verifyId(args: Flags): Promise { + if (args.positional.length !== 1) usageAndExit("verify id needs "); + const identity = Identity.parse(args.positional[0]); + const registry = await defaultRegistry(); + const hit = await registry.verify(identity); + printStatus(hit.status); +} + +async function main(): Promise { + 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 { + 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 *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 "); + 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 "); + 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 + : ""; + 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 { + 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 "); + 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 { + 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:///.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 { + 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(); diff --git a/nodejs/packages/kez-cli/tsconfig.json b/nodejs/packages/kez-cli/tsconfig.json new file mode 100644 index 0000000..d68016f --- /dev/null +++ b/nodejs/packages/kez-cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [{ "path": "../kez-core" }, { "path": "../kez-channels" }], + "include": ["src/**/*"] +} diff --git a/nodejs/packages/kez-core/package.json b/nodejs/packages/kez-core/package.json new file mode 100644 index 0000000..9eb2874 --- /dev/null +++ b/nodejs/packages/kez-core/package.json @@ -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" + } +} diff --git a/nodejs/packages/kez-core/src/claim.ts b/nodejs/packages/kez-core/src/claim.ts new file mode 100644 index 0000000..3b60968 --- /dev/null +++ b/nodejs/packages/kez-core/src/claim.ts @@ -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", + }; +} diff --git a/nodejs/packages/kez-core/src/ed25519.ts b/nodejs/packages/kez-core/src/ed25519.ts new file mode 100644 index 0000000..4c4252e --- /dev/null +++ b/nodejs/packages/kez-core/src/ed25519.ts @@ -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}`); + } + } +} diff --git a/nodejs/packages/kez-core/src/encodings.ts b/nodejs/packages/kez-core/src/encodings.ts new file mode 100644 index 0000000..aa6197f --- /dev/null +++ b/nodejs/packages/kez-core/src/encodings.ts @@ -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: +// ───────────────────────────────────────────────────────────────────────────── + +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; +} diff --git a/nodejs/packages/kez-core/src/envelope.ts b/nodejs/packages/kez-core/src/envelope.ts new file mode 100644 index 0000000..d7e1aed --- /dev/null +++ b/nodejs/packages/kez-core/src/envelope.ts @@ -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:" of prior envelope; omitted iff seq === 0 + created_at: string; + op: SigchainOp; + payload: Record; +} + +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"); +} diff --git a/nodejs/packages/kez-core/src/identity.ts b/nodejs/packages/kez-core/src/identity.ts new file mode 100644 index 0000000..3ff54dd --- /dev/null +++ b/nodejs/packages/kez-core/src/identity.ts @@ -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}`); + } +} diff --git a/nodejs/packages/kez-core/src/index.ts b/nodejs/packages/kez-core/src/index.ts new file mode 100644 index 0000000..344046b --- /dev/null +++ b/nodejs/packages/kez-core/src/index.ts @@ -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"; diff --git a/nodejs/packages/kez-core/src/jcs.ts b/nodejs/packages/kez-core/src/jcs.ts new file mode 100644 index 0000000..ba5ed5c --- /dev/null +++ b/nodejs/packages/kez-core/src/jcs.ts @@ -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; +} diff --git a/nodejs/packages/kez-core/src/nostr.ts b/nodejs/packages/kez-core/src/nostr.ts new file mode 100644 index 0000000..4369b1f --- /dev/null +++ b/nodejs/packages/kez-core/src/nostr.ts @@ -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)); +} diff --git a/nodejs/packages/kez-core/src/sigchain.ts b/nodejs/packages/kez-core/src/sigchain.ts new file mode 100644 index 0000000..2f8b025 --- /dev/null +++ b/nodejs/packages/kez-core/src/sigchain.ts @@ -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, +): 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 = { 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:` 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 ?? ""}, got ${event.payload.prev ?? ""}`, + ); + } + 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:` — 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 }; diff --git a/nodejs/packages/kez-core/test/core.test.ts b/nodejs/packages/kez-core/test/core.test.ts new file mode 100644 index 0000000..ede276c --- /dev/null +++ b/nodejs/packages/kez-core/test/core.test.ts @@ -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/); + }); +}); diff --git a/nodejs/packages/kez-core/test/sigchain.test.ts b/nodejs/packages/kez-core/test/sigchain.test.ts new file mode 100644 index 0000000..ba0330c --- /dev/null +++ b/nodejs/packages/kez-core/test/sigchain.test.ts @@ -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(); + }); +}); diff --git a/nodejs/packages/kez-core/tsconfig.json b/nodejs/packages/kez-core/tsconfig.json new file mode 100644 index 0000000..c2104f6 --- /dev/null +++ b/nodejs/packages/kez-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/nodejs/tsconfig.base.json b/nodejs/tsconfig.base.json new file mode 100644 index 0000000..fe61436 --- /dev/null +++ b/nodejs/tsconfig.base.json @@ -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 + } +} diff --git a/nodejs/tsconfig.json b/nodejs/tsconfig.json new file mode 100644 index 0000000..9ab55c6 --- /dev/null +++ b/nodejs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./packages/kez-core" }, + { "path": "./packages/kez-channels" }, + { "path": "./packages/kez-cli" } + ] +} diff --git a/nodejs/vitest.config.ts b/nodejs/vitest.config.ts new file mode 100644 index 0000000..e5a005a --- /dev/null +++ b/nodejs/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["packages/*/test/**/*.test.ts"], + pool: "threads", + testTimeout: 10_000, + }, +}); diff --git a/rust-sig-server/Cargo.lock b/rust-sig-server/Cargo.lock new file mode 100644 index 0000000..0e1aa2a --- /dev/null +++ b/rust-sig-server/Cargo.lock @@ -0,0 +1,2530 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kez-core" +version = "0.1.0" +dependencies = [ + "base64", + "bech32", + "chrono", + "ed25519-dalek", + "hex", + "rand 0.8.6", + "secp256k1", + "serde", + "serde_jcs", + "serde_json", + "sha2", + "thiserror", + "zstd", +] + +[[package]] +name = "kez-sig-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "kez-core", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_jcs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/rust-sig-server/Cargo.toml b/rust-sig-server/Cargo.toml new file mode 100644 index 0000000..0749783 --- /dev/null +++ b/rust-sig-server/Cargo.toml @@ -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" diff --git a/rust-sig-server/README.md b/rust-sig-server/README.md new file mode 100644 index 0000000..6862986 --- /dev/null +++ b/rust-sig-server/README.md @@ -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///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:` 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:" +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` 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 <`, 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:///.well-known/kez-sigchain.jsonl` | `web:` channel does a single HTTPS fetch | +| **DNS** | Publish a compact-encoded sigchain URL hint in a `_kez-chain.` 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. diff --git a/rust-sig-server/src/api.rs b/rust-sig-server/src/api.rs new file mode 100644 index 0000000..d5bd2a4 --- /dev/null +++ b/rust-sig-server/src/api.rs @@ -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, + Path((scheme, id)): Path<(String, String)>, +) -> Result { + 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, + Path((scheme, id)): Path<(String, String)>, +) -> Result { + 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, + Path((scheme, id)): Path<(String, String)>, + Json(event): Json, +) -> Result { + 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::parse(format!("{scheme}:{id}")) + .map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}"))) +} diff --git a/rust-sig-server/src/error.rs b/rust-sig-server/src/error.rs new file mode 100644 index 0000000..0937df2 --- /dev/null +++ b/rust-sig-server/src/error.rs @@ -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 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 for ApiError { + fn from(e: KezError) -> Self { + ApiError::BadRequest(e.to_string()) + } +} + +impl From for ApiError { + fn from(e: rusqlite::Error) -> Self { + ApiError::Internal(format!("db: {e}")) + } +} + +impl From for ApiError { + fn from(e: serde_json::Error) -> Self { + ApiError::BadRequest(format!("json: {e}")) + } +} diff --git a/rust-sig-server/src/lib.rs b/rust-sig-server/src/lib.rs new file mode 100644 index 0000000..8814ec7 --- /dev/null +++ b/rust-sig-server/src/lib.rs @@ -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; diff --git a/rust-sig-server/src/main.rs b/rust-sig-server/src/main.rs new file mode 100644 index 0000000..6c8d452 --- /dev/null +++ b/rust-sig-server/src/main.rs @@ -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"); +} diff --git a/rust-sig-server/src/store.rs b/rust-sig-server/src/store.rs new file mode 100644 index 0000000..f98f710 --- /dev/null +++ b/rust-sig-server/src/store.rs @@ -0,0 +1,137 @@ +//! SQLite-backed sigchain store. One table, one row per event. +//! +//! Concurrency: a single `tokio::sync::Mutex` 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>, +} + +impl Store { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + init_schema(&conn)?; + Ok(Self { + inner: Arc::new(Mutex::new(conn)), + }) + } + + pub fn open_in_memory() -> Result { + 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 { + 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::, _>>()?; + + 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, 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);", + ) +} diff --git a/rust-sig-server/tests/http.rs b/rust-sig-server/tests/http.rs new file mode 100644 index 0000000..bd99329 --- /dev/null +++ b/rust-sig-server/tests/http.rs @@ -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, + 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); +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..10c38e4 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,2978 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kez-channels" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures-util", + "hex", + "hickory-resolver", + "kez-core", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "wiremock", +] + +[[package]] +name = "kez-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "dirs", + "kez-channels", + "kez-core", + "reqwest", + "tokio", +] + +[[package]] +name = "kez-core" +version = "0.1.0" +dependencies = [ + "base64", + "bech32", + "chrono", + "ed25519-dalek", + "hex", + "rand 0.8.6", + "secp256k1", + "serde", + "serde_jcs", + "serde_json", + "sha2", + "thiserror 2.0.18", + "zstd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.6", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_jcs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..5fbcb96 --- /dev/null +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..3349ee2 --- /dev/null +++ b/rust/README.md @@ -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 `/` profile README | +| `dns:` | TXT record at `_kez.` (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 (--nsec | --ed25519-seed ) [--format json|markdown|compact] [--out ]` +Sign a KEZ claim asserting that the supplied signing key also controls +``. 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 (--nsec | --ed25519-seed )` +Like `claim create dns:` but additionally prints a ready-to-paste +zone-file line with the proof properly chunked into TXT segments. + +### `verify file ` +Parse and verify a local proof file (any encoding). Developer helper — not a +real channel. + +### `verify id ` +Fetch the proof for `` 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 --nsec | --ed25519-seed [--proof-url ]` +Append an `add` event to the local sigchain for the signing key. Chain +files live at `~/.kez/sigchains/.jsonl`. + +### `sigchain revoke --nsec | --ed25519-seed` +Append a `revoke` event for a previously added subject. + +### `sigchain show [--primary ] | [--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 ] | [--nsec | --ed25519-seed] [--format jsonl|compact] [--out ]` +Export the chain in a portable format (`jsonl` per spec §6, or +`compact` = `kez:zc1:`). + +### `sigchain publish [--primary ] | [--nsec | --ed25519-seed] [destinations...]` +Push the chain to one or more places. Destinations are flags and any +combination can be passed: + +- `--server ` — POST every event to a [kez-sig-server](../rust-sig-server) +- `--web --out ` — write the JSONL bundle to a file (you upload it + to `https:///.well-known/kez-sigchain.jsonl`) +- `--dns ` — print the TXT zone records for `_kez-chain.` +- `--nostr ` — 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; +} +``` + +| File | System | Fetches | API key needed? | +|---|---|---|---| +| [`dns.rs`](crates/kez-channels/src/dns.rs) | `dns:` | `_kez.` TXT via system resolver | No | +| [`github.rs`](crates/kez-channels/src/github.rs) | `github:` | Public gists then `/` 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/.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 ;` 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/.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 :...` 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": "" + } +} +``` + +| Form | Where | Encoding | +|---|---|---| +| JSON | `/.well-known/kez.json`, HTTP APIs | Standard JSON of the envelope | +| Compact | DNS TXT, QR codes, chat | `kez:z1:` | +| Markdown | GitHub gist, README, bio | Human prose + a ```` ```kez ```` fenced block | +| Legacy DNS | (deprecated) | `kez1:` — 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`). diff --git a/rust/crates/kez-channels/Cargo.toml b/rust/crates/kez-channels/Cargo.toml new file mode 100644 index 0000000..e2b6e7a --- /dev/null +++ b/rust/crates/kez-channels/Cargo.toml @@ -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 diff --git a/rust/crates/kez-channels/src/activitypub.rs b/rust/crates/kez-channels/src/activitypub.rs new file mode 100644 index 0000000..12a4780 --- /dev/null +++ b/rust/crates/kez-channels/src/activitypub.rs @@ -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:///.well-known/webfinger?resource=acct:@` +//! Returns the user's ActivityPub actor URL. +//! 2. **Actor JSON** — `GET ` 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:@@` is canonical. `mastodon:@@` +//! 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://`. Used by tests pointing at wiremock. None in prod. + base_override: Option, + /// 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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": "x"}, + {"type": "PropertyValue", "name": "kez", "value": "kez:z1:abc"} + ], + "summary": "

bio with kez:z1:def in it

" + }); + 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("

hello world

"), "hello world"); + assert_eq!(strip_html("a & b <c>"), "a & b "); + assert_eq!(strip_html(""quoted""), r#""quoted""#); + assert_eq!(strip_html("'apos'"), "'apos'"); + } + + #[test] + fn strip_html_preserves_compact_kez_prefix() { + let html = "

my proof: kez:z1:KLUv_QBYabc

"; + assert_eq!(strip_html(html), "my proof: kez:z1:KLUv_QBYabc"); + } + + #[test] + fn strip_html_preserves_markdown_fence_chars() { + let html = "

```kez\n{...}\n```

"; + assert_eq!(strip_html(html), "```kez\n{...}\n```"); + } +} diff --git a/rust/crates/kez-channels/src/bluesky.rs b/rust/crates/kez-channels/src/bluesky.rs new file mode 100644 index 0000000..d6e89ca --- /dev/null +++ b/rust/crates/kez-channels/src/bluesky.rs @@ -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=&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 { + 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 { + 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 = 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 { + 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()); + } +} diff --git a/rust/crates/kez-channels/src/dns.rs b/rust/crates/kez-channels/src/dns.rs new file mode 100644 index 0000000..be4a948 --- /dev/null +++ b/rust/crates/kez-channels/src/dns.rs @@ -0,0 +1,175 @@ +//! DNS channel: looks up `_kez.` 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>; +} + +/// 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> { + 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, +} + +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) -> 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 { + 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 = 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); + + #[async_trait] + impl TxtResolver for FakeResolver { + async fn lookup_txt(&self, _name: &str) -> ChannelResult> { + 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 { .. })); + } +} diff --git a/rust/crates/kez-channels/src/github.rs b/rust/crates/kez-channels/src/github.rs new file mode 100644 index 0000000..3dcd582 --- /dev/null +++ b/rust/crates/kez-channels/src/github.rs @@ -0,0 +1,223 @@ +//! GitHub channel: scans a user's public gists, then falls back to the +//! `/` 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 { + 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 { + let user = identity.value(); + if user.is_empty() { + return Err(ChannelError::Other(anyhow::anyhow!( + "github identity has empty user" + ))); + } + + let mut last_error: Option = 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 { + 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> { + 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 { + 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 { + 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()); + } +} diff --git a/rust/crates/kez-channels/src/lib.rs b/rust/crates/kez-channels/src/lib.rs new file mode 100644 index 0000000..7e5d7e6 --- /dev/null +++ b/rust/crates/kez-channels/src/lib.rs @@ -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 = Result; + +/// 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; +} + +/// A small registry mapping `system:` → channel adapter. Lets the CLI (and any +/// future caller) dispatch a `verify ` request without knowing +/// which adapters are loaded. +#[derive(Default, Clone)] +pub struct Registry { + channels: HashMap<&'static str, Arc>, +} + +impl Registry { + pub fn new() -> Self { + Self::default() + } + + /// Build a registry with the channels shipped in this crate. + pub fn with_defaults() -> ChannelResult { + 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) { + 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) { + self.channels.insert(system, channel); + } + + pub fn get(&self, system: &str) -> Option> { + self.channels.get(system).cloned() + } + + pub async fn verify(&self, identity: &Identity) -> ChannelResult { + 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 { + 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 { + 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: 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:` 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 { + 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(_))); + } +} diff --git a/rust/crates/kez-channels/src/nostr.rs b/rust/crates/kez-channels/src/nostr.rs new file mode 100644 index 0000000..e00330a --- /dev/null +++ b/rust/crates/kez-channels/src/nostr.rs @@ -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 == []` 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>, + pub content: String, + pub sig: String, +} + +/// Filter sent in a nostr REQ message. +#[derive(Debug, Clone)] +pub struct NostrFilter { + pub authors: Vec, // lowercase hex pubkeys + pub kinds: Vec, + pub limit: Option, +} + +/// 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>; +} + +/// Real fetcher: queries each relay in turn (websocket), merges events, +/// and times out if relays are unresponsive. +pub struct RelayPoolFetcher { + relays: Vec, +} + +impl RelayPoolFetcher { + pub fn new(relays: Vec) -> 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> { + let mut last_error: Option = None; + let mut events: Vec = 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> { + 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, +} + +impl NostrChannel { + pub fn new() -> Self { + Self { + fetcher: Arc::new(RelayPoolFetcher::defaults()), + } + } + pub fn with_fetcher(fetcher: Arc) -> 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 { + 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 = 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>, + content: String, +) -> ChannelResult { + 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::(&text) else { + continue; + }; + let Some(arr) = arr.as_array() else { continue }; + match arr.first().and_then(|v| v.as_str()) { + Some("OK") => { + // ["OK", , , ] + 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::(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::(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(::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")); + } +} diff --git a/rust/crates/kez-channels/tests/activitypub.rs b/rust/crates/kez-channels/tests/activitypub.rs new file mode 100644 index 0000000..5d5b5a1 --- /dev/null +++ b/rust/crates/kez-channels/tests/activitypub.rs @@ -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": "

hi

", + "attachment": [ + {"type": "PropertyValue", "name": "site", "value": "x"}, + {"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!("

portable identity: {compact}

"), + "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(_))); +} diff --git a/rust/crates/kez-channels/tests/bluesky.rs b/rust/crates/kez-channels/tests/bluesky.rs new file mode 100644 index 0000000..8385eba --- /dev/null +++ b/rust/crates/kez-channels/tests/bluesky.rs @@ -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(_))); +} diff --git a/rust/crates/kez-channels/tests/dns.rs b/rust/crates/kez-channels/tests/dns.rs new file mode 100644 index 0000000..1e7e991 --- /dev/null +++ b/rust/crates/kez-channels/tests/dns.rs @@ -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, + expected_name: String, +} + +#[async_trait] +impl TxtResolver for CapturingResolver { + async fn lookup_txt(&self, name: &str) -> ChannelResult> { + assert_eq!( + name, self.expected_name, + "DnsChannel must query `_kez.`" + ); + Ok(self.records.clone()) + } +} + +struct FailingResolver; + +#[async_trait] +impl TxtResolver for FailingResolver { + async fn lookup_txt(&self, _name: &str) -> ChannelResult> { + 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:?}" + ); +} diff --git a/rust/crates/kez-channels/tests/github.rs b/rust/crates/kez-channels/tests/github.rs new file mode 100644 index 0000000..b3e4d98 --- /dev/null +++ b/rust/crates/kez-channels/tests/github.rs @@ -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:?}" + ); +} diff --git a/rust/crates/kez-channels/tests/nostr.rs b/rust/crates/kez-channels/tests/nostr.rs new file mode 100644 index 0000000..0071ceb --- /dev/null +++ b/rust/crates/kez-channels/tests/nostr.rs @@ -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, + expected_authors: Vec, + expected_kinds: Vec, +} + +#[async_trait] +impl NostrFetcher for CapturingFetcher { + async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult> { + 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> { + 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(_))); +} diff --git a/rust/crates/kez-cli/.gitignore b/rust/crates/kez-cli/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/crates/kez-cli/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/crates/kez-cli/Cargo.toml b/rust/crates/kez-cli/Cargo.toml new file mode 100644 index 0000000..18b5807 --- /dev/null +++ b/rust/crates/kez-cli/Cargo.toml @@ -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 diff --git a/rust/crates/kez-cli/src/main.rs b/rust/crates/kez-cli/src/main.rs new file mode 100644 index 0000000..efd4ef4 --- /dev/null +++ b/rust/crates/kez-cli/src/main.rs @@ -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, + #[arg(long = "ed25519-seed", conflicts_with = "nsec")] + ed25519_seed: Option, + #[arg(long)] + proof_url: Option, + }, + /// Append a `revoke` event to the chain for the signing key. + Revoke { + subject: String, + #[arg(long, conflicts_with = "ed25519_seed")] + nsec: Option, + #[arg(long = "ed25519-seed", conflicts_with = "nsec")] + ed25519_seed: Option, + }, + /// 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, + #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] + nsec: Option, + #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] + ed25519_seed: Option, + }, + /// Export the chain in a portable format. + Export { + #[arg(long)] + primary: Option, + #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] + nsec: Option, + #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] + ed25519_seed: Option, + #[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)] + format: ExportFormat, + #[arg(long)] + out: Option, + }, + /// Publish the chain to one or more destinations. + Publish { + #[arg(long)] + primary: Option, + #[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])] + nsec: Option, + #[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])] + ed25519_seed: Option, + /// POST every event to a kez-sig-server at this URL. + #[arg(long)] + server: Option, + /// 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, + /// Print a DNS TXT zone record for `_kez-chain.` with the + /// compact bundle. You install it in your registrar. + #[arg(long)] + dns: Option, + /// Publish the compact bundle as a kind-30078 event to this nostr + /// relay. Requires `--nsec` (not `--ed25519-seed`). + #[arg(long)] + nostr: Option, + }, +} + +#[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, + #[arg(long = "ed25519-seed", conflicts_with = "nsec")] + ed25519_seed: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Json)] + format: OutputFormat, + #[arg(long)] + out: Option, + }, + Dns { + domain: String, + #[arg(long, conflicts_with = "ed25519_seed")] + nsec: Option, + #[arg(long = "ed25519-seed", conflicts_with = "nsec")] + ed25519_seed: Option, + }, +} + +#[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, + ed25519_seed: Option, +) -> Result { + 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, + ed25519_seed: Option, + format: OutputFormat, + out: Option, +) -> 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, + ed25519_seed: Option, +) -> 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, 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 { + 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 { + 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, ed25519_seed: Option) -> Result { + 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 { + 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, + nsec: Option, + ed25519_seed: Option, +) -> Result { + if let Some(p) = primary { + return Ok(Identity::parse(p)?); + } + SignerKeys::from_flags(nsec, ed25519_seed)?.primary() +} + +fn sigchain_add( + subject: String, + nsec: Option, + ed25519_seed: Option, + proof_url: Option, +) -> 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, + ed25519_seed: Option, +) -> 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, + nsec: Option, + ed25519_seed: Option, +) -> 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(|| "".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, + nsec: Option, + ed25519_seed: Option, + format: ExportFormat, + out: Option, +) -> 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, + nsec: Option, + ed25519_seed: Option, + server: Option, + web: bool, + out: Option, + dns: Option, + nostr: Option, +) -> 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) = 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 ")?; + 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:///.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::>() + .chunks(240) + .map(|chunk| { + let escaped = chunk + .iter() + .flat_map(|ch| match ch { + '"' => ['\\', '"'].into_iter().collect::>(), + '\\' => ['\\', '\\'].into_iter().collect::>(), + other => [*other].into_iter().collect::>(), + }) + .collect::(); + format!("\"{escaped}\"") + }) + .collect::>() + .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"\\")); + } +} diff --git a/rust/crates/kez-core/.gitignore b/rust/crates/kez-core/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/crates/kez-core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/crates/kez-core/Cargo.toml b/rust/crates/kez-core/Cargo.toml new file mode 100644 index 0000000..284a636 --- /dev/null +++ b/rust/crates/kez-core/Cargo.toml @@ -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 diff --git a/rust/crates/kez-core/src/lib.rs b/rust/crates/kez-core/src/lib.rs new file mode 100644 index 0000000..cb91a19 --- /dev/null +++ b/rust/crates/kez-core/src/lib.rs @@ -0,0 +1,1361 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use bech32::{FromBase32, ToBase32, Variant}; +use chrono::{DateTime, Utc}; +use ed25519_dalek::{ + Signature as Ed25519Signature, Signer as Ed25519Signer, SigningKey as Ed25519SigningKey, + VerifyingKey as Ed25519VerifyingKey, +}; +use rand::RngCore; +use secp256k1::schnorr::Signature; +use secp256k1::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::str::FromStr; + +pub const CLAIM_TYPE: &str = "kez.claim"; +pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event"; +pub const FORMAT_VERSION: u8 = 1; +pub const NOSTR_SCHNORR_ALG: &str = "nostr-secp256k1-schnorr-sha256-jcs"; +pub const ED25519_SHA512_ALG: &str = "ed25519-sha512-jcs"; +pub const COMPACT_PROOF_PREFIX: &str = "kez:z1:"; +pub const COMPACT_CHAIN_PREFIX: &str = "kez:zc1:"; + +#[derive(Debug, thiserror::Error)] +pub enum KezError { + #[error("unsupported key algorithm: {0}")] + UnsupportedAlgorithm(String), + #[error("invalid identity: {0}")] + InvalidIdentity(String), + #[error("invalid nostr key")] + InvalidNostrKey, + #[error("invalid signature")] + InvalidSignature, + #[error("canonical json failed: {0}")] + CanonicalJson(String), + #[error("json failed: {0}")] + Json(#[from] serde_json::Error), + #[error("bech32 failed: {0}")] + Bech32(#[from] bech32::Error), + #[error("secp256k1 failed: {0}")] + Secp256k1(#[from] secp256k1::Error), + #[error("hex failed: {0}")] + Hex(#[from] hex::FromHexError), + #[error("base64 failed: {0}")] + Base64(#[from] base64::DecodeError), + #[error("io failed: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Identity(String); + +impl Identity { + pub fn parse(value: impl AsRef) -> Result { + let raw = value.as_ref().trim(); + if raw.is_empty() { + return Err(KezError::InvalidIdentity(raw.to_owned())); + } + + if raw.starts_with("npub1") { + validate_npub(raw)?; + return Ok(Self(format!("nostr:{raw}"))); + } + + let Some((scheme, rest)) = raw.split_once(':') else { + return Err(KezError::InvalidIdentity(raw.to_owned())); + }; + + if scheme.is_empty() || rest.is_empty() { + return Err(KezError::InvalidIdentity(raw.to_owned())); + } + + if scheme == "nostr" { + validate_npub(rest)?; + } else if scheme == "ed25519" { + validate_ed25519_hex(rest)?; + } + + Ok(Self(format!("{scheme}:{rest}"))) + } + + pub fn scheme(&self) -> &str { + self.0 + .split_once(':') + .map(|(scheme, _)| scheme) + .unwrap_or("") + } + + pub fn value(&self) -> &str { + self.0.split_once(':').map(|(_, value)| value).unwrap_or("") + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for Identity { + type Err = KezError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimPayload { + #[serde(rename = "type")] + pub kind: String, + pub version: u8, + pub subject: Identity, + pub primary: Identity, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, +} + +impl ClaimPayload { + pub fn new(subject: Identity, primary: Identity, created_at: DateTime) -> Self { + Self { + kind: CLAIM_TYPE.to_owned(), + version: FORMAT_VERSION, + subject, + primary, + created_at, + expires_at: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignatureBlock { + pub alg: String, + pub key: Identity, + pub sig: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedClaim { + pub kez: String, + pub payload: ClaimPayload, + pub signature: SignatureBlock, +} + +/// A signing key of either supported type. Lets `SignedClaim::sign_with` +/// dispatch on the underlying algorithm without exposing two near-identical +/// entry points. +pub enum Signer<'a> { + Nostr(&'a NostrSecret), + Ed25519(&'a Ed25519Secret), +} + +impl SignedClaim { + /// Sign with a nostr/secp256k1 primary key (back-compat convenience). + pub fn sign(payload: ClaimPayload, signer: &NostrSecret) -> Result { + Self::sign_with(payload, Signer::Nostr(signer)) + } + + /// Sign with an Ed25519 primary key (back-compat convenience). + pub fn sign_ed25519(payload: ClaimPayload, signer: &Ed25519Secret) -> Result { + Self::sign_with(payload, Signer::Ed25519(signer)) + } + + /// Unified signing entry: dispatches on the signer type. + pub fn sign_with(payload: ClaimPayload, signer: Signer<'_>) -> Result { + let (alg, key, sig_hex) = match signer { + Signer::Nostr(s) => { + let key = Identity::parse(format!("nostr:{}", s.npub()))?; + let sig = sign_jcs_schnorr_hex(&payload, s)?; + (NOSTR_SCHNORR_ALG.to_owned(), key, sig) + } + Signer::Ed25519(s) => { + let key = s.identity()?; + let jcs = canonical_bytes(&payload)?; + let sig = s.sign(&jcs); + (ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig)) + } + }; + Ok(Self { + kez: "claim".to_owned(), + payload, + signature: SignatureBlock { + alg, + key, + sig: sig_hex, + }, + }) + } + + pub fn verify(&self) -> Result { + if self.signature.key != self.payload.primary { + return Err(KezError::InvalidSignature); + } + + match self.signature.alg.as_str() { + NOSTR_SCHNORR_ALG => { + verify_jcs_schnorr_hex( + &self.payload, + self.signature.key.value(), + &self.signature.sig, + )?; + } + ED25519_SHA512_ALG => { + let jcs = canonical_bytes(&self.payload)?; + verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig)?; + } + other => return Err(KezError::UnsupportedAlgorithm(other.to_owned())), + } + + Ok(VerificationStatus { + primary: self.payload.primary.clone(), + verified: vec![self.payload.subject.clone()], + status: "valid".to_owned(), + confidence: "strong".to_owned(), + }) + } + + pub fn to_pretty_json(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + pub fn to_markdown_proof(&self) -> Result { + let json = self.to_pretty_json()?; + Ok(format!( + "# KEZ Proof\n\nThis account publishes a signed KEZ identity claim.\n\n- Primary: `{}`\n- Subject: `{}`\n- Created: `{}`\n\n```kez\n{}\n```\n", + self.payload.primary, self.payload.subject, self.payload.created_at, json + )) + } + + pub fn to_compact(&self) -> Result { + encode_compact_claim(self) + } +} + +/// Spec §6 sigchain event payload. JCS-canonicalized + signed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SigchainEventPayload { + #[serde(rename = "type")] + pub kind: String, + pub version: u8, + /// The primary key this event belongs to. After a `rotate`, this is + /// the new primary; before, the old one. + pub primary: Identity, + pub seq: u64, + /// `sha256:` of JCS of the prior signed envelope. None iff `seq == 0`. + #[serde(skip_serializing_if = "Option::is_none")] + pub prev: Option, + pub created_at: DateTime, + /// Operation name: "add" | "revoke" | "rotate" | "add_device". + pub op: String, + /// Op-specific fields, kept as Value so the schema can grow without a + /// breaking type change. + pub payload: serde_json::Value, +} + +/// Convenience constructors for the supported ops. Use these instead of +/// hand-rolling `op`/`payload` strings so impls stay consistent. +impl SigchainEventPayload { + /// `add` op: assert that `primary` controls `subject`. + pub fn new_add( + primary: Identity, + seq: u64, + prev: Option, + subject: Identity, + proof_url: Option, + created_at: DateTime, + ) -> Self { + let mut payload = serde_json::Map::new(); + payload.insert("subject".to_owned(), serde_json::json!(subject)); + if let Some(url) = proof_url { + payload.insert("proof_url".to_owned(), serde_json::json!(url)); + } + Self { + kind: SIGCHAIN_EVENT_TYPE.to_owned(), + version: FORMAT_VERSION, + primary, + seq, + prev, + created_at, + op: "add".to_owned(), + payload: serde_json::Value::Object(payload), + } + } + + /// `revoke` op: retract a previously-added subject. + pub fn new_revoke( + primary: Identity, + seq: u64, + prev: Option, + subject: Identity, + created_at: DateTime, + ) -> Self { + Self { + kind: SIGCHAIN_EVENT_TYPE.to_owned(), + version: FORMAT_VERSION, + primary, + seq, + prev, + created_at, + op: "revoke".to_owned(), + payload: serde_json::json!({ "subject": subject }), + } + } + + /// Pull the `subject` field out of an add/revoke payload, if present. + pub fn subject(&self) -> Option { + self.payload + .get("subject") + .and_then(|v| v.as_str()) + .and_then(|s| Identity::parse(s).ok()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedSigchainEvent { + pub kez: String, + pub payload: SigchainEventPayload, + pub signature: SignatureBlock, +} + +impl SignedSigchainEvent { + /// Sign with a nostr key (back-compat convenience). + pub fn sign(payload: SigchainEventPayload, signer: &NostrSecret) -> Result { + Self::sign_with(payload, Signer::Nostr(signer)) + } + + /// Sign with an Ed25519 key. + pub fn sign_ed25519( + payload: SigchainEventPayload, + signer: &Ed25519Secret, + ) -> Result { + Self::sign_with(payload, Signer::Ed25519(signer)) + } + + /// Unified signing entry — same dispatch as `SignedClaim::sign_with`. + pub fn sign_with(payload: SigchainEventPayload, signer: Signer<'_>) -> Result { + let (alg, key, sig_hex) = match signer { + Signer::Nostr(s) => { + let key = Identity::parse(format!("nostr:{}", s.npub()))?; + let sig = sign_jcs_schnorr_hex(&payload, s)?; + (NOSTR_SCHNORR_ALG.to_owned(), key, sig) + } + Signer::Ed25519(s) => { + let key = s.identity()?; + let jcs = canonical_bytes(&payload)?; + let sig = s.sign(&jcs); + (ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig)) + } + }; + Ok(Self { + kez: "sigchain_event".to_owned(), + payload, + signature: SignatureBlock { + alg, + key, + sig: sig_hex, + }, + }) + } + + /// Verify the signature over `payload`. Dispatches on `signature.alg`. + pub fn verify(&self) -> Result<()> { + if self.signature.key != self.payload.primary { + return Err(KezError::InvalidSignature); + } + match self.signature.alg.as_str() { + NOSTR_SCHNORR_ALG => verify_jcs_schnorr_hex( + &self.payload, + self.signature.key.value(), + &self.signature.sig, + ), + ED25519_SHA512_ALG => { + let jcs = canonical_bytes(&self.payload)?; + verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig) + } + other => Err(KezError::UnsupportedAlgorithm(other.to_owned())), + } + } + + /// `sha256:` of the JCS-canonicalized envelope. Used by `prev` + /// on the next event in the chain. + pub fn hash(&self) -> Result { + let bytes = canonical_bytes(self)?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sigchain — append-only, validated chain of signed events for one primary. +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum SigchainError { + #[error("expected primary {expected}, got {found}")] + WrongPrimary { expected: Identity, found: Identity }, + #[error("expected seq {expected}, got {found}")] + SeqMismatch { expected: u64, found: u64 }, + #[error("prev hash mismatch: expected {expected:?}, got {found:?}")] + PrevMismatch { + expected: Option, + found: Option, + }, + #[error("signature failed: {0}")] + BadSignature(#[from] KezError), + #[error("envelope tag must be \"sigchain_event\", got {0:?}")] + WrongEnvelopeTag(String), + #[error("sigchain is empty")] + Empty, + #[error("invalid JSONL: {0}")] + BadJsonl(String), +} + +/// An ordered, validated chain of signed events for a single primary. +/// All mutations go through `append` (or the `sign_*` helpers), which +/// enforce the spec §6.2 integrity rules. +#[derive(Debug, Clone)] +pub struct Sigchain { + primary: Identity, + events: Vec, +} + +impl Sigchain { + /// Create an empty sigchain for `primary`. The first appended event + /// must have seq 0 and no prev. + pub fn new(primary: Identity) -> Self { + Self { + primary, + events: Vec::new(), + } + } + + pub fn primary(&self) -> &Identity { + &self.primary + } + + pub fn head(&self) -> Option<&SignedSigchainEvent> { + self.events.last() + } + + /// `sha256:` of the head envelope. `None` iff the chain is empty. + pub fn head_hash(&self) -> Result> { + match self.head() { + None => Ok(None), + Some(head) => Ok(Some(head.hash()?)), + } + } + + /// The seq the next appended event must use. + pub fn next_seq(&self) -> u64 { + self.events.last().map(|e| e.payload.seq + 1).unwrap_or(0) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn events(&self) -> &[SignedSigchainEvent] { + &self.events + } + + /// Append a signed event. Validates: envelope tag, primary, seq + /// monotonicity, prev hash chain, and signature. + pub fn append(&mut self, event: SignedSigchainEvent) -> std::result::Result<(), SigchainError> { + if event.kez != "sigchain_event" { + return Err(SigchainError::WrongEnvelopeTag(event.kez.clone())); + } + if event.payload.primary != self.primary { + return Err(SigchainError::WrongPrimary { + expected: self.primary.clone(), + found: event.payload.primary.clone(), + }); + } + let expected_seq = self.next_seq(); + if event.payload.seq != expected_seq { + return Err(SigchainError::SeqMismatch { + expected: expected_seq, + found: event.payload.seq, + }); + } + let expected_prev = self.head_hash()?; + if event.payload.prev != expected_prev { + return Err(SigchainError::PrevMismatch { + expected: expected_prev, + found: event.payload.prev.clone(), + }); + } + event.verify()?; + self.events.push(event); + Ok(()) + } + + /// Full-chain re-validation (signatures + integrity). Useful after + /// loading from external storage. + pub fn validate(&self) -> std::result::Result<(), SigchainError> { + let mut rebuilt = Sigchain::new(self.primary.clone()); + for event in &self.events { + rebuilt.append(event.clone())?; + } + Ok(()) + } + + /// True if the most recent op for `subject` is `revoke`. False if the + /// most recent op is `add` or the subject was never added. + pub fn is_revoked(&self, subject: &Identity) -> bool { + for event in self.events.iter().rev() { + let Some(s) = event.payload.subject() else { + continue; + }; + if &s == subject { + return event.payload.op == "revoke"; + } + } + false + } + + /// True if `subject` was added (and not later revoked). + pub fn is_active(&self, subject: &Identity) -> bool { + for event in self.events.iter().rev() { + let Some(s) = event.payload.subject() else { + continue; + }; + if &s == subject { + return event.payload.op == "add"; + } + } + false + } + + /// Convenience: build, sign, and append an `add` event in one call. + pub fn sign_add( + &mut self, + subject: Identity, + proof_url: Option, + signer: Signer<'_>, + ) -> std::result::Result<&SignedSigchainEvent, SigchainError> { + let prev = self.head_hash()?; + let payload = SigchainEventPayload::new_add( + self.primary.clone(), + self.next_seq(), + prev, + subject, + proof_url, + Utc::now(), + ); + let signed = SignedSigchainEvent::sign_with(payload, signer)?; + self.append(signed)?; + Ok(self.events.last().unwrap()) + } + + /// Convenience: build, sign, and append a `revoke` event. + pub fn sign_revoke( + &mut self, + subject: Identity, + signer: Signer<'_>, + ) -> std::result::Result<&SignedSigchainEvent, SigchainError> { + let prev = self.head_hash()?; + let payload = SigchainEventPayload::new_revoke( + self.primary.clone(), + self.next_seq(), + prev, + subject, + Utc::now(), + ); + let signed = SignedSigchainEvent::sign_with(payload, signer)?; + self.append(signed)?; + Ok(self.events.last().unwrap()) + } + + /// Serialize as JSONL (one envelope per line). The portable format + /// used by `kez sigchain export` and the chain server's GET endpoint. + pub fn to_jsonl(&self) -> Result { + let mut out = String::new(); + for event in &self.events { + out.push_str(&serde_json::to_string(event)?); + out.push('\n'); + } + Ok(out) + } + + /// Encode the whole chain as `kez:zc1:`. + /// Portable single-string form for embedding in nostr events, DNS TXT + /// values, ActivityPub fields, etc. + pub fn to_compact_bundle(&self) -> Result { + let jsonl = self.to_jsonl()?; + let compressed = zstd::encode_all(jsonl.as_bytes(), 3)?; + Ok(format!( + "{COMPACT_CHAIN_PREFIX}{}", + URL_SAFE_NO_PAD.encode(compressed) + )) + } + + /// Inverse of `to_compact_bundle`. + pub fn from_compact_bundle(value: &str) -> std::result::Result { + let encoded = value.trim().strip_prefix(COMPACT_CHAIN_PREFIX).ok_or_else(|| { + SigchainError::BadJsonl(format!("missing {COMPACT_CHAIN_PREFIX} prefix")) + })?; + let compressed = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|e| SigchainError::BadJsonl(format!("base64url: {e}")))?; + let jsonl = zstd::decode_all(compressed.as_slice()) + .map_err(|e| SigchainError::BadJsonl(format!("zstd: {e}")))?; + let text = String::from_utf8(jsonl) + .map_err(|e| SigchainError::BadJsonl(format!("utf8: {e}")))?; + Self::from_jsonl(&text) + } + + /// Parse a JSONL bundle. Validates as it appends, so the result is + /// guaranteed to be a well-formed chain. + pub fn from_jsonl(text: &str) -> std::result::Result { + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let first = lines + .next() + .ok_or_else(|| SigchainError::BadJsonl("empty input".to_owned()))?; + let first_event: SignedSigchainEvent = serde_json::from_str(first) + .map_err(|e| SigchainError::BadJsonl(format!("line 0: {e}")))?; + let mut chain = Sigchain::new(first_event.payload.primary.clone()); + chain.append(first_event)?; + for (i, line) in lines.enumerate() { + let event: SignedSigchainEvent = serde_json::from_str(line) + .map_err(|e| SigchainError::BadJsonl(format!("line {}: {e}", i + 1)))?; + chain.append(event)?; + } + Ok(chain) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerificationStatus { + pub primary: Identity, + pub verified: Vec, + pub status: String, + pub confidence: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NostrSecret { + secret_key: SecretKey, +} + +impl NostrSecret { + pub fn generate() -> Self { + let mut rng = rand::thread_rng(); + Self { + secret_key: SecretKey::new(&mut rng), + } + } + + pub fn from_nsec(nsec: &str) -> Result { + let (hrp, data, variant) = bech32::decode(nsec)?; + if hrp != "nsec" || variant != Variant::Bech32 { + return Err(KezError::InvalidNostrKey); + } + + let bytes = Vec::::from_base32(&data)?; + let secret_key = SecretKey::from_slice(&bytes)?; + Ok(Self { secret_key }) + } + + pub fn nsec(&self) -> String { + bech32::encode( + "nsec", + self.secret_key.secret_bytes().to_base32(), + Variant::Bech32, + ) + .expect("nsec encoding cannot fail for static hrp") + } + + pub fn npub(&self) -> String { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &self.secret_key); + let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair); + bech32::encode("npub", public_key.serialize().to_base32(), Variant::Bech32) + .expect("npub encoding cannot fail for static hrp") + } + + /// 32-byte x-only public key as lowercase hex (the form nostr filters + /// and event `pubkey` fields use). + pub fn pubkey_hex(&self) -> String { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &self.secret_key); + let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair); + hex::encode(public_key.serialize()) + } + + /// BIP-340 Schnorr signature over a 32-byte digest (no aux rand). + /// Used for signing NIP-01 nostr event ids when publishing. + pub fn sign_raw(&self, digest: &[u8; 32]) -> Result<[u8; 64]> { + let msg = Message::from_digest_slice(digest)?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &self.secret_key); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + Ok(*sig.as_ref()) + } +} + +#[derive(Clone)] +pub struct Ed25519Secret { + signing_key: Ed25519SigningKey, +} + +impl Ed25519Secret { + pub fn generate() -> Self { + let mut rng = rand::thread_rng(); + let mut seed = [0u8; 32]; + rng.fill_bytes(&mut seed); + Self { + signing_key: Ed25519SigningKey::from_bytes(&seed), + } + } + + pub fn from_seed_hex(seed_hex: &str) -> Result { + let bytes = hex::decode(seed_hex)?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| { + KezError::InvalidIdentity("ed25519 seed must be 32 bytes / 64 hex chars".to_owned()) + })?; + Ok(Self { + signing_key: Ed25519SigningKey::from_bytes(&seed), + }) + } + + /// 32-byte seed (secret material) as lowercase hex. Anyone with this can sign as this identity. + pub fn seed_hex(&self) -> String { + hex::encode(self.signing_key.to_bytes()) + } + + /// 32-byte public key as lowercase hex. + pub fn pubkey_hex(&self) -> String { + hex::encode(self.signing_key.verifying_key().to_bytes()) + } + + pub fn identity(&self) -> Result { + Identity::parse(format!("ed25519:{}", self.pubkey_hex())) + } + + /// Sign raw bytes (no pre-hash — RFC 8032 PureEdDSA does SHA-512 internally). + pub fn sign(&self, message: &[u8]) -> [u8; 64] { + self.signing_key.sign(message).to_bytes() + } +} + +impl std::fmt::Debug for Ed25519Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the seed. + f.debug_struct("Ed25519Secret") + .field("pubkey_hex", &self.pubkey_hex()) + .finish() + } +} + +impl PartialEq for Ed25519Secret { + fn eq(&self, other: &Self) -> bool { + self.signing_key.to_bytes() == other.signing_key.to_bytes() + } +} +impl Eq for Ed25519Secret {} + +pub fn dns_txt_name(identity: &Identity) -> Result { + if identity.scheme() != "dns" { + return Err(KezError::InvalidIdentity(identity.to_string())); + } + + Ok(format!("_kez.{}", identity.value())) +} + +/// For a `nostr:npub1...` identity, return the 32-byte x-only pubkey as a +/// lowercase hex string. This is the form nostr relay filters require. +pub fn nostr_pubkey_hex(identity: &Identity) -> Result { + if identity.scheme() != "nostr" { + return Err(KezError::InvalidIdentity(identity.to_string())); + } + let pk = decode_npub(identity.value())?; + Ok(hex::encode(pk.serialize())) +} + +pub fn dns_txt_value(claim: &SignedClaim) -> Result { + Ok(format!("kez1:{}", serde_json::to_string(claim)?)) +} + +pub fn parse_dns_txt_value(value: &str) -> Result { + let json = value + .strip_prefix("kez1:") + .ok_or_else(|| KezError::InvalidIdentity("DNS TXT proof missing kez1 prefix".to_owned()))?; + Ok(serde_json::from_str(json)?) +} + +pub fn encode_compact_claim(claim: &SignedClaim) -> Result { + let json = serde_json::to_vec(claim)?; + let compressed = zstd::encode_all(json.as_slice(), 3)?; + Ok(format!( + "{COMPACT_PROOF_PREFIX}{}", + URL_SAFE_NO_PAD.encode(compressed) + )) +} + +pub fn decode_compact_claim(value: &str) -> Result { + let encoded = value + .trim() + .strip_prefix(COMPACT_PROOF_PREFIX) + .ok_or_else(|| { + KezError::InvalidIdentity("compact proof missing kez:z1 prefix".to_owned()) + })?; + let compressed = URL_SAFE_NO_PAD.decode(encoded)?; + let json = zstd::decode_all(compressed.as_slice())?; + Ok(serde_json::from_slice(&json)?) +} + +pub fn extract_markdown_proof(markdown: &str) -> Result { + let fence = "```kez"; + let Some(start) = markdown.find(fence) else { + return Err(KezError::InvalidIdentity( + "missing ```kez proof block".to_owned(), + )); + }; + let body_start = start + fence.len(); + let Some(end) = markdown[body_start..].find("```") else { + return Err(KezError::InvalidIdentity( + "unterminated ```kez proof block".to_owned(), + )); + }; + let json = markdown[body_start..body_start + end].trim(); + Ok(serde_json::from_str(json)?) +} + +pub fn canonical_bytes(value: &T) -> Result> { + serde_jcs::to_vec(value).map_err(|err| KezError::CanonicalJson(err.to_string())) +} + +pub fn from_json(json: &str) -> Result { + Ok(serde_json::from_str(json)?) +} + +fn sign_jcs_schnorr_hex(payload: &T, signer: &NostrSecret) -> Result { + let digest = Sha256::digest(canonical_bytes(payload)?); + let msg = Message::from_digest_slice(&digest)?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &signer.secret_key); + let signature = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + Ok(hex::encode(signature.as_ref())) +} + +fn verify_jcs_schnorr_hex(payload: &T, npub: &str, sig: &str) -> Result<()> { + let public_key = decode_npub(npub)?; + let signature = Signature::from_slice(&hex::decode(sig)?)?; + let digest = Sha256::digest(canonical_bytes(payload)?); + let msg = Message::from_digest_slice(&digest)?; + let secp = Secp256k1::verification_only(); + secp.verify_schnorr(&signature, &msg, &public_key)?; + Ok(()) +} + +fn verify_ed25519_hex(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> { + let pubkey_bytes: [u8; 32] = hex::decode(pubkey_hex)? + .try_into() + .map_err(|_| KezError::InvalidIdentity("ed25519 pubkey must be 32 bytes".to_owned()))?; + let sig_bytes: [u8; 64] = hex::decode(sig_hex)? + .try_into() + .map_err(|_| KezError::InvalidSignature)?; + let verifying_key = Ed25519VerifyingKey::from_bytes(&pubkey_bytes) + .map_err(|_| KezError::InvalidSignature)?; + let signature = Ed25519Signature::from_bytes(&sig_bytes); + verifying_key + .verify_strict(message, &signature) + .map_err(|_| KezError::InvalidSignature) +} + +fn validate_npub(npub: &str) -> Result<()> { + decode_npub(npub).map(|_| ()) +} + +fn validate_ed25519_hex(value: &str) -> Result<()> { + if value.len() != 64 { + return Err(KezError::InvalidIdentity(format!( + "ed25519 pubkey must be 64 hex chars, got {} chars", + value.len() + ))); + } + if !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err(KezError::InvalidIdentity(format!( + "ed25519 pubkey must be lowercase hex: {value}" + ))); + } + Ok(()) +} + +fn decode_npub(npub: &str) -> Result { + let (hrp, data, variant) = bech32::decode(npub)?; + if hrp != "npub" || variant != Variant::Bech32 { + return Err(KezError::InvalidNostrKey); + } + + let bytes = Vec::::from_base32(&data)?; + Ok(XOnlyPublicKey::from_slice(&bytes)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signs_and_verifies_claim() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("dns:jason.example.com").unwrap(); + let payload = ClaimPayload::new(subject, primary.clone(), Utc::now()); + + let signed = SignedClaim::sign(payload, &secret).unwrap(); + let status = signed.verify().unwrap(); + + assert_eq!(status.primary, primary); + assert_eq!(status.status, "valid"); + } + + #[test] + fn parses_bare_npub_as_nostr_identity() { + let secret = NostrSecret::generate(); + let npub = secret.npub(); + let identity = Identity::parse(&npub).unwrap(); + + assert_eq!(identity.as_str(), format!("nostr:{npub}")); + } + + #[test] + fn round_trips_dns_txt_value() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("dns:jason.example.com").unwrap(); + let signed = + SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); + + let txt = dns_txt_value(&signed).unwrap(); + let parsed = parse_dns_txt_value(&txt).unwrap(); + + assert_eq!(parsed, signed); + } + + #[test] + fn extracts_markdown_proof() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let signed = + SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); + + let markdown = signed.to_markdown_proof().unwrap(); + let parsed = extract_markdown_proof(&markdown).unwrap(); + + assert_eq!(parsed, signed); + } + + #[test] + fn round_trips_compact_claim() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let signed = + SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); + + let compact = signed.to_compact().unwrap(); + let parsed = decode_compact_claim(&compact).unwrap(); + + assert!(compact.starts_with(COMPACT_PROOF_PREFIX)); + assert_eq!(parsed, signed); + parsed.verify().unwrap(); + } + + #[test] + fn tampered_claim_fails_verification() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let mut signed = + SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); + + signed.payload.subject = Identity::parse("github:mallory").unwrap(); + + assert!(signed.verify().is_err()); + } + + #[test] + fn identity_parse_rejects_invalid_inputs() { + assert!(Identity::parse("").is_err(), "empty must fail"); + assert!(Identity::parse(" ").is_err(), "whitespace-only must fail"); + assert!(Identity::parse("no-colon").is_err(), "missing colon must fail"); + assert!(Identity::parse(":missing-scheme").is_err()); + assert!(Identity::parse("scheme:").is_err()); + assert!( + Identity::parse("nostr:not-a-real-npub").is_err(), + "nostr scheme must validate the npub" + ); + } + + #[test] + fn identity_parse_round_trips_scheme_and_value() { + let id = Identity::parse("github:jason").unwrap(); + assert_eq!(id.scheme(), "github"); + assert_eq!(id.value(), "jason"); + assert_eq!(id.as_str(), "github:jason"); + } + + #[test] + fn verify_rejects_unsupported_algorithm() { + let secret = NostrSecret::generate(); + let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let mut signed = + SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap(); + + signed.signature.alg = "made-up-suite".to_owned(); + let err = signed.verify().unwrap_err(); + assert!(matches!(err, KezError::UnsupportedAlgorithm(_))); + } + + #[test] + fn verify_rejects_signature_key_not_matching_primary() { + let secret_a = NostrSecret::generate(); + let secret_b = NostrSecret::generate(); + let primary_a = Identity::parse(format!("nostr:{}", secret_a.npub())).unwrap(); + let primary_b = Identity::parse(format!("nostr:{}", secret_b.npub())).unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let mut signed = SignedClaim::sign( + ClaimPayload::new(subject, primary_a.clone(), Utc::now()), + &secret_a, + ) + .unwrap(); + + // Swap the envelope's declared signing key to a different identity. + signed.signature.key = primary_b; + assert!(signed.verify().is_err()); + } + + #[test] + fn nostr_pubkey_hex_returns_32_byte_lowercase() { + let secret = NostrSecret::generate(); + let id = Identity::parse(format!("nostr:{}", secret.npub())).unwrap(); + let hex_pk = nostr_pubkey_hex(&id).unwrap(); + assert_eq!(hex_pk.len(), 64, "x-only pubkey is 32 bytes = 64 hex chars"); + assert_eq!(hex_pk, hex_pk.to_lowercase()); + } + + #[test] + fn nostr_pubkey_hex_rejects_non_nostr() { + let id = Identity::parse("github:jason").unwrap(); + assert!(nostr_pubkey_hex(&id).is_err()); + } + + #[test] + fn dns_txt_name_requires_dns_scheme() { + let dns = Identity::parse("dns:jason.example.com").unwrap(); + assert_eq!(dns_txt_name(&dns).unwrap(), "_kez.jason.example.com"); + let github = Identity::parse("github:jason").unwrap(); + assert!(dns_txt_name(&github).is_err()); + } + + #[test] + fn decode_compact_claim_rejects_missing_prefix() { + assert!(decode_compact_claim("hello").is_err()); + assert!(decode_compact_claim("kez1:foo").is_err()); + } + + #[test] + fn extract_markdown_proof_rejects_missing_or_unterminated_fence() { + assert!(extract_markdown_proof("no fence here").is_err()); + assert!(extract_markdown_proof("```kez\n{ unterminated").is_err()); + } + + #[test] + fn ed25519_round_trips_seed_hex() { + let secret = Ed25519Secret::generate(); + let seed = secret.seed_hex(); + let restored = Ed25519Secret::from_seed_hex(&seed).unwrap(); + assert_eq!(restored.pubkey_hex(), secret.pubkey_hex()); + assert_eq!(restored, secret); + } + + #[test] + fn ed25519_identity_is_lowercase_hex() { + let secret = Ed25519Secret::generate(); + let id = secret.identity().unwrap(); + assert_eq!(id.scheme(), "ed25519"); + assert_eq!(id.value().len(), 64); + assert_eq!(id.value(), id.value().to_lowercase()); + } + + #[test] + fn identity_parse_validates_ed25519_format() { + assert!(Identity::parse("ed25519:tooshort").is_err()); + assert!(Identity::parse(&format!("ed25519:{}", "Z".repeat(64))).is_err()); + assert!(Identity::parse(&format!("ed25519:{}", "AB".repeat(32))).is_err()); // uppercase + assert!(Identity::parse(&format!("ed25519:{}", "ab".repeat(32))).is_ok()); + } + + #[test] + fn signs_and_verifies_ed25519_claim() { + let secret = Ed25519Secret::generate(); + let primary = secret.identity().unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let payload = ClaimPayload::new(subject.clone(), primary.clone(), Utc::now()); + + let signed = SignedClaim::sign_ed25519(payload, &secret).unwrap(); + assert_eq!(signed.signature.alg, ED25519_SHA512_ALG); + let status = signed.verify().unwrap(); + assert_eq!(status.primary, primary); + assert_eq!(status.verified, vec![subject]); + assert_eq!(status.status, "valid"); + } + + #[test] + fn tampered_ed25519_claim_fails_verification() { + let secret = Ed25519Secret::generate(); + let primary = secret.identity().unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let mut signed = SignedClaim::sign_ed25519( + ClaimPayload::new(subject, primary, Utc::now()), + &secret, + ) + .unwrap(); + signed.payload.subject = Identity::parse("github:mallory").unwrap(); + assert!(signed.verify().is_err()); + } + + #[test] + fn ed25519_sign_with_unified_entry_point() { + let secret = Ed25519Secret::generate(); + let primary = secret.identity().unwrap(); + let subject = Identity::parse("github:jason").unwrap(); + let payload = ClaimPayload::new(subject, primary, Utc::now()); + let signed = SignedClaim::sign_with(payload, Signer::Ed25519(&secret)).unwrap(); + signed.verify().unwrap(); + } + + // ── Sigchain ──────────────────────────────────────────────────────────── + + fn nostr_signer() -> (NostrSecret, Identity) { + let s = NostrSecret::generate(); + let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap(); + (s, id) + } + + #[test] + fn sigchain_appends_and_validates() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary.clone()); + assert!(chain.is_empty()); + assert_eq!(chain.next_seq(), 0); + + let subject = Identity::parse("github:jason").unwrap(); + chain + .sign_add(subject.clone(), None, Signer::Nostr(&secret)) + .unwrap(); + assert_eq!(chain.len(), 1); + assert_eq!(chain.next_seq(), 1); + assert!(chain.is_active(&subject)); + assert!(!chain.is_revoked(&subject)); + + chain.validate().unwrap(); + } + + #[test] + fn sigchain_revoke_flips_is_active() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary); + let subject = Identity::parse("github:jason").unwrap(); + chain + .sign_add(subject.clone(), None, Signer::Nostr(&secret)) + .unwrap(); + chain + .sign_revoke(subject.clone(), Signer::Nostr(&secret)) + .unwrap(); + assert!(chain.is_revoked(&subject)); + assert!(!chain.is_active(&subject)); + chain.validate().unwrap(); + } + + #[test] + fn sigchain_rejects_wrong_primary() { + let (secret_a, primary_a) = nostr_signer(); + let (_secret_b, primary_b) = nostr_signer(); + let mut chain = Sigchain::new(primary_a); + // Build an event whose payload claims a different primary. + let payload = SigchainEventPayload::new_add( + primary_b, + 0, + None, + Identity::parse("github:jason").unwrap(), + None, + Utc::now(), + ); + let signed = SignedSigchainEvent::sign(payload, &secret_a).unwrap(); + let err = chain.append(signed).unwrap_err(); + assert!(matches!(err, SigchainError::WrongPrimary { .. })); + } + + #[test] + fn sigchain_rejects_seq_skip() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary.clone()); + chain + .sign_add( + Identity::parse("github:a").unwrap(), + None, + Signer::Nostr(&secret), + ) + .unwrap(); + // Hand-craft an event with seq=2 (skipping seq=1) but correct prev. + let head_hash = chain.head_hash().unwrap(); + let payload = SigchainEventPayload::new_add( + primary, + 2, + head_hash, + Identity::parse("github:b").unwrap(), + None, + Utc::now(), + ); + let signed = SignedSigchainEvent::sign(payload, &secret).unwrap(); + let err = chain.append(signed).unwrap_err(); + assert!(matches!(err, SigchainError::SeqMismatch { .. })); + } + + #[test] + fn sigchain_rejects_bad_prev_hash() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary.clone()); + chain + .sign_add( + Identity::parse("github:a").unwrap(), + None, + Signer::Nostr(&secret), + ) + .unwrap(); + // seq=1 but with the wrong prev hash. + let payload = SigchainEventPayload::new_add( + primary, + 1, + Some("sha256:0000".to_owned()), + Identity::parse("github:b").unwrap(), + None, + Utc::now(), + ); + let signed = SignedSigchainEvent::sign(payload, &secret).unwrap(); + let err = chain.append(signed).unwrap_err(); + assert!(matches!(err, SigchainError::PrevMismatch { .. })); + } + + #[test] + fn sigchain_round_trips_jsonl() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary); + let subject = Identity::parse("github:jason").unwrap(); + chain + .sign_add(subject.clone(), None, Signer::Nostr(&secret)) + .unwrap(); + chain + .sign_revoke(subject.clone(), Signer::Nostr(&secret)) + .unwrap(); + let jsonl = chain.to_jsonl().unwrap(); + let restored = Sigchain::from_jsonl(&jsonl).unwrap(); + assert_eq!(restored.events(), chain.events()); + assert!(restored.is_revoked(&subject)); + } + + #[test] + fn sigchain_from_jsonl_detects_tamper() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary); + chain + .sign_add( + Identity::parse("github:a").unwrap(), + None, + Signer::Nostr(&secret), + ) + .unwrap(); + chain + .sign_add( + Identity::parse("github:b").unwrap(), + None, + Signer::Nostr(&secret), + ) + .unwrap(); + let mut jsonl = chain.to_jsonl().unwrap(); + // Flip a byte in the middle line's content. + jsonl = jsonl.replacen("github:b", "github:c", 1); + let err = Sigchain::from_jsonl(&jsonl).unwrap_err(); + // Either signature breaks first, or prev-hash check catches re-serialization. + assert!(matches!( + err, + SigchainError::BadSignature(_) | SigchainError::PrevMismatch { .. } + )); + } + + #[test] + fn sigchain_round_trips_compact_bundle() { + let (secret, primary) = nostr_signer(); + let mut chain = Sigchain::new(primary); + chain + .sign_add( + Identity::parse("github:jason").unwrap(), + None, + Signer::Nostr(&secret), + ) + .unwrap(); + chain + .sign_revoke( + Identity::parse("github:jason").unwrap(), + Signer::Nostr(&secret), + ) + .unwrap(); + let compact = chain.to_compact_bundle().unwrap(); + assert!(compact.starts_with(COMPACT_CHAIN_PREFIX)); + let restored = Sigchain::from_compact_bundle(&compact).unwrap(); + assert_eq!(restored.events(), chain.events()); + } + + #[test] + fn nostr_secret_pubkey_hex_matches_nostr_pubkey_hex_helper() { + let s = NostrSecret::generate(); + let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap(); + assert_eq!(s.pubkey_hex(), nostr_pubkey_hex(&id).unwrap()); + } + + #[test] + fn nostr_secret_sign_raw_produces_valid_64_byte_sig() { + let s = NostrSecret::generate(); + let digest = [42u8; 32]; + let sig = s.sign_raw(&digest).unwrap(); + assert_eq!(sig.len(), 64); + // Verifying with secp256k1 directly proves the sig is correct. + let sig_obj = Signature::from_slice(&sig).unwrap(); + let pubkey = decode_npub(&s.npub()).unwrap(); + let msg = Message::from_digest_slice(&digest).unwrap(); + let secp = Secp256k1::verification_only(); + secp.verify_schnorr(&sig_obj, &msg, &pubkey).unwrap(); + } + + #[test] + fn sigchain_works_with_ed25519_signer() { + let secret = Ed25519Secret::generate(); + let primary = secret.identity().unwrap(); + let mut chain = Sigchain::new(primary); + let subject = Identity::parse("github:jason").unwrap(); + chain + .sign_add(subject.clone(), None, Signer::Ed25519(&secret)) + .unwrap(); + assert!(chain.is_active(&subject)); + chain.validate().unwrap(); + } +} diff --git a/rust/github-jason.kez b/rust/github-jason.kez new file mode 100644 index 0000000..04aff13 --- /dev/null +++ b/rust/github-jason.kez @@ -0,0 +1 @@ +kez:z1:KLUv_QBYnQkARthIH1Bn9QhakN2lQptR9RtJSTPGsTOTh-_5QgnfoZiZmTVBAEMAPgB1xQh64KzU8CxcaTJBM6Et9jFQJsAYMbVZFm7tJ8SIbrHgPoaGzi8AmN5ZuA9JdRNwnQ0FiaEtlqDhQi_c6Z1NBMpDFoBrcDV4qIDGDKcMhCm2cCsP-Ff-2Vvse-iJGq3sOYJYmCIdEIhDUxNC8ug1hjkUp-bkcpEIk4FD0uFRVTCxdAIcG6v3zL3bR2QdLGXH9p1Exq2p5wt3aeg7WyxtgoE4xgw15jQ8TFNjOYhjUD81lgJvAu7PqPKzNHSLgxVIjgcHqxLF2Vm2s8b4D9nK_Sle8VA8Udy3QzHm6luxVFR03Inz1Ywtj7Ld2yle7rIPXm41X1JZccXInsgXAwDKmqE3U5gnHV71BA \ No newline at end of file diff --git a/rust/github-jason.kez.md b/rust/github-jason.kez.md new file mode 100644 index 0000000..f0bbcd1 --- /dev/null +++ b/rust/github-jason.kez.md @@ -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" + } +} +```