From e3a0f3d59dfd08696cff80badb2d80d9a5096a61 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Thu, 5 Nov 2026 19:26:22 -0500 Subject: [PATCH] feat(cli): send NIP-FI evidence via credential chain and Buzz desktop (#8999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Relays that enforce federated identity (NIP-FI) reject every protected `buzz` CLI request with `401`. The CLI never sends `Nostr-Federated-Identity`. This PR gives the CLI a credential chain, lets it present NIP-FI evidence, and lets it take its identity from Buzz desktop. Relays without NIP-FI behave exactly as before. **Credential chain** (`crates/buzz-cli/src/credentials.rs`) - Raw `Keys` are replaced by `Credentials`, which provide the public key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth tag. NIP-FI evidence comes through `evidence(relay)`. - The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first link. A later link can add a signer that holds no local key (a desktop provider, for example) without reshaping callers. - Operations that need key material rather than a signature are purpose-bound methods instead of a key accessor: - `engram_d_tag` (the NIP-AE HMAC over the conversation key) - the relay-scoped GIF customer id (random and fixed for the process when there is no local key) - `buzz mem` now encrypts, decrypts and derives the `d` tag through the provider. Invalid ciphertext skips an entry; a signer failure aborts, so an unavailable credential never reads as empty memory. `buzz_core::engram` gains the composable steps behind it: `encode_body`, `event_builder`, `validate_envelope` and `check_d_tag`. `build_event` and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and desktop callers don't move. - Agent-management drafts (`agent_management.rs`) encrypt and sign through the provider. **Evidence on every protected request** (`crates/buzz-cli/src/client.rs`) - A single `authorize` helper gets the NIP-FI assertion first, then signs the proof just before sending, so a slow mint cannot age it past NIP-98's 60 s window. It sets these on every protected route: `Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and `Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`, `/events` (stored and moderation), `/upload`, `/media/upload`, `/media/*`, `get_authed`, `post_json_authed` and `post_json_once_authed`. - Exempt routes (`get_public`) are unchanged. - The WebSocket publish sends the header on the upgrade via `connect_authenticated_with_headers`. NIP-42 AUTH signs through the signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers compile unchanged). - An upgrade denial is an HTTP response under NIP-FI. `WsClientError::upgrade_rejection` returns its status and body, which map like any other relay error, so a `403` still says whether evidence or authorization was refused. A denial after NIP-42 (such as a key mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason and the CLI reports it as an `auth_error`. - The WebSocket publish caps each network phase as a whole, writes included: connect, AUTH challenge (with any Pongs), AUTH send plus OK, EVENT send plus OK, and a best-effort close. NIP-42 signing between phases is bounded by the signer. The relay drops a socket not signed in within 5 s, so a sign-in that is throttled or comes back after the socket closed reconnects and signs the new challenge, at most 3 connections. Each connection fetches current NIP-FI evidence for its upgrade, and a throttled sign-in is waited out between connections, so a dead challenge is never signed. EVENT is signed before connecting and sent once, after AUTH succeeds. Running out is `unavailable` (exit 2, `retryable:true`). - Relay traffic follows no redirects: reqwest strips `Authorization` across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a 3xx is returned as a relay error. Credential header values are marked sensitive. **Headless evidence source** (`crates/buzz-cli/src/federated_identity.rs`) - Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session: a human session for your key, not an agent grant) and `BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both are env only: command lines are visible to other local users. - The CLI requests an assertion and the adapter mints it, through `POST {adapter}/v1/identity/assertions` with a NIP-98 proof in `Nostr-Authorization` (NIP-FA §Relay assertion). - It checks the returned pubkey and expiry, parses the assertion as a compact JWS (three base64url segments, JSON header with `alg`, JSON payload), and requires its `exp` to equal `expires_at`. It caches one assertion per process, bound to the relay when credentials are resolved, and re-mints at the earlier of half-life and 30 s before expiry; the desktop link caches and refreshes its assertion by the same rule. Concurrent callers share one mint. An HTTP proof is signed again, after current evidence, when the assertion changed while it was signed or the evidence check aged it 30 s or more, up to 3 rounds, then fails as `unavailable` (retryable). - Compared with the separate rules before: a 300 s assertion now refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s instead of fetched on every lookup. Desktop assertions now need a readable JWS header with `alg` and a non-empty signature segment. An unsupported headless relay URL now fails when credentials are resolved. - The token goes only to the adapter origin (https, or http on loopback), never follows a redirect, and never appears in errors or `Debug`. - A mint retries network errors, `429` and any `5xx` up to three attempts with jittered backoff (or the adapter's `Retry-After` on `429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on every path (reads, post-once, moderation, stored events, WebSocket). It then fails as `CliError::Adapter` (`adapter_error`, exit 2, `retryable:true`). Relay retry loops never repeat it. A stored write keeps `delivery_unknown` when any earlier attempt may have been delivered, so a later adapter failure never invites a re-sign. Relay mutation retries are otherwise unchanged. - An explicit port 80 relay URL (read numerically from the authority, on any scheme) is a usage error: `wss://host` means port 443, and NIP-FA has no relay on port 80. - A refusal is never retried: a non-transient status with an unreadable or truncated body is still a refusal. - Each NIP-FA denial code maps to an `auth_error` with a specific message. - These settings belong to the explicit key: without one they are not read, and when the desktop link wins a warning names them. - If an enforcing relay answers `401` and no assertion was sent, the CLI returns an `auth_error` that names both settings, or, for a desktop user, says to sign in to the community in Buzz. A desktop user's `403` hint no longer mentions `BUZZ_AUTH_TAG`. **What got simpler** - About ten request sites built `Authorization` and `x-auth-tag` themselves. Five copies of the 401/403 error-mapping block are now one `relay_error`. - The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a three-case `Proof` enum consumed by `authorize`. - `BuzzClient::keys()` (raw secret access from every command) is removed. Commands use `public_key()`, `sign_event` or a purpose-bound credentials method. **Desktop credential provider** (`crates/buzz-cli/src/desktop_provider.rs`, `docs/local-credential-provider.md`) Buzz desktop is a credential provider in the chain, after an explicit key: 1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for the agents it launches). It decides the identity: when it is revoked, not bound to the relay or unreachable, the command fails. A paired token, which acts as the human, is never tried. That keeps a well-behaved CLI from acting as the owner by accident; it is not a boundary against an agent that can run its own commands as the owner's OS user, and the contract says so with the mitigations. 2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS native keychain, Linux Secret Service; no file fallback). A desktop that answers `denied` or `unavailable` fails the command; only an unreachable or unconfirmed advertisement is skipped. Revoked entries are forgotten. New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`. The help no longer marks `BUZZ_PRIVATE_KEY` as required. The CLI talks to the desktop over the loopback contract in `docs/local-credential-provider.md` (NIP-46-shaped envelopes, `Buzz-Credential-Provider` version header, same-user check on the listening socket for paired and inherited tokens, via `/usr/sbin/lsof` on macOS, given 5 s; a check that runs out of time is a retryable `unavailable`, a wrong owner is refused). Every response's `id` must match its request, error envelopes included. Paired tokens live only in the keychain, survive desktop restarts, and are harness-neutral for agents. - **Relay binding:** tokens bind the whole relay URL (`http`/`https` read as `ws`/`wss`, lowercase host, no default port, `/` for an empty path; path and query count). The CLI sends that URL in `pair` and `federated_identity`; NIP-FA audience canonicalization is the desktop's. - **Rate limits:** a `rate_limited` answer refuses before acting, so the CLI re-sends after `retry_after` (capped at 30 s), up to four times, except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom `expiration` moved with it); content events are re-sent exactly. If it is still throttled, the command fails as `rate_limited` (exit 2, `retryable:true`), not `auth_error`, whether the throttled call was NIP-98, event signing, NIP-42 or encryption. Transport failures are never re-sent, and relay commands are never replayed. A `buzz mem` list that is throttled partway through completes. - **Memory:** a `nip44_decrypt` answered with `invalid_request` is invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering); any other provider failure aborts. - **Keychain changes:** each change reloads the map under a short `flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to `0700`, lock opened with `O_NOFOLLOW`) and changes only its own instance; the lock is never held across the approval wait. An unreadable entry is refused, never overwritten; `unpair` with no instance clears it. ### Related Related: [block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the desktop side of the provider contract) --------- Signed-off-by: Luke Tornquist Signed-off-by: Will Pfleger Co-authored-by: Will Pfleger Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 Co-authored-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan --- Cargo.lock | 253 +- Cargo.toml | 1 + crates/buzz-cli/Cargo.toml | 15 + crates/buzz-cli/README.md | 42 + crates/buzz-cli/src/agent_management.rs | 79 +- crates/buzz-cli/src/cached_assertion.rs | 128 + crates/buzz-cli/src/client.rs | 2122 +++++++++++++--- crates/buzz-cli/src/commands/agents.rs | 24 +- .../buzz-cli/src/commands/channel_unread.rs | 28 +- crates/buzz-cli/src/commands/channels.rs | 76 +- crates/buzz-cli/src/commands/dms.rs | 8 +- crates/buzz-cli/src/commands/emoji.rs | 10 +- crates/buzz-cli/src/commands/feed.rs | 2 +- crates/buzz-cli/src/commands/gifs.rs | 100 +- crates/buzz-cli/src/commands/huddles.rs | 6 +- crates/buzz-cli/src/commands/issues.rs | 8 +- crates/buzz-cli/src/commands/mem.rs | 222 +- .../buzz-cli/src/commands/message_report.rs | 17 +- crates/buzz-cli/src/commands/messages.rs | 41 +- crates/buzz-cli/src/commands/moderation.rs | 10 +- crates/buzz-cli/src/commands/notes.rs | 10 +- crates/buzz-cli/src/commands/patches.rs | 4 +- crates/buzz-cli/src/commands/pr.rs | 6 +- .../buzz-cli/src/commands/project_channel.rs | 12 +- crates/buzz-cli/src/commands/projects.rs | 61 +- crates/buzz-cli/src/commands/reactions.rs | 8 +- crates/buzz-cli/src/commands/read_state.rs | 36 +- .../src/commands/repo_default_branch.rs | 8 +- crates/buzz-cli/src/commands/repos.rs | 8 +- crates/buzz-cli/src/commands/social.rs | 6 +- crates/buzz-cli/src/commands/users.rs | 22 +- crates/buzz-cli/src/commands/workflows.rs | 16 +- crates/buzz-cli/src/credentials.rs | 805 ++++++ crates/buzz-cli/src/desktop_provider.rs | 2196 +++++++++++++++++ crates/buzz-cli/src/error.rs | 30 +- crates/buzz-cli/src/federated_identity.rs | 992 ++++++++ crates/buzz-cli/src/lib.rs | 141 +- crates/buzz-core/src/engram.rs | 109 +- crates/buzz-test-client/src/lib.rs | 5 +- crates/buzz-ws-client/src/connection.rs | 147 +- crates/buzz-ws-client/src/error.rs | 22 + crates/buzz-ws-client/src/message.rs | 11 +- docs/local-credential-provider.md | 424 ++++ 43 files changed, 7520 insertions(+), 751 deletions(-) create mode 100644 crates/buzz-cli/src/cached_assertion.rs create mode 100644 crates/buzz-cli/src/credentials.rs create mode 100644 crates/buzz-cli/src/desktop_provider.rs create mode 100644 crates/buzz-cli/src/federated_identity.rs create mode 100644 docs/local-credential-provider.md diff --git a/Cargo.lock b/Cargo.lock index 9e5951c043..d19342367f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" dependencies = [ "keyring-core", "log", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -1114,8 +1114,12 @@ dependencies = [ "clap", "diffy", "dirs", + "futures-util", "hex", + "httpdate", "infer", + "keyring 3.6.3", + "libc", "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", @@ -1126,6 +1130,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.29.0", "url", "uuid", ] @@ -2606,6 +2611,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "openssl", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -3892,6 +3927,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -4818,6 +4862,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "openssl", + "secret-service 4.0.0", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "keyring" version = "4.2.0" @@ -5020,6 +5079,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.9.0" @@ -5512,7 +5581,7 @@ dependencies = [ "http-body-util", "httparse", "iroh", - "keyring", + "keyring 4.2.0", "libc", "mdns-sd", "mesh-llm-api-server", @@ -5599,7 +5668,7 @@ dependencies = [ "dirs", "ed25519-dalek", "hex", - "keyring", + "keyring 4.2.0", "rand 0.10.1", "serde", "serde_json", @@ -8828,7 +8897,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -8856,7 +8925,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -9105,6 +9174,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2 0.10.9", + "zbus 4.4.0", +] + [[package]] name = "secret-service" version = "5.2.0" @@ -9115,13 +9203,26 @@ dependencies = [ "cbc 0.2.1", "futures-util", "getrandom 0.4.3", - "hkdf", + "hkdf 0.13.0", "hybrid-array", "num", "once_cell", "serde", "sha2 0.11.0", - "zbus", + "zbus 5.19.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", ] [[package]] @@ -9980,7 +10081,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", + "hkdf 0.13.0", "hmac 0.13.0", "itoa", "log", @@ -11976,6 +12077,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -12302,6 +12412,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xet-client" version = "1.6.0" @@ -12497,6 +12617,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.19.0" @@ -12527,9 +12679,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -12539,8 +12691,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" dependencies = [ "keyring-core", - "secret-service", - "zbus", + "secret-service 5.2.0", + "zbus 5.19.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -12553,9 +12718,20 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -12566,7 +12742,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -12725,6 +12901,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.15.0" @@ -12736,8 +12925,21 @@ dependencies = [ "serde", "winnow 1.0.3", "zcheapstr", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -12750,7 +12952,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "zvariant_utils", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25a39ea4c7..e0925d9489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ anyhow = "1" # Utilities uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +httpdate = "1" # JWT / JWS verification (NIP-FI federated identity assertions) jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee..fa08534db8 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -40,6 +40,7 @@ uuid = { workspace = true } # RFC3339 observer timestamps for owner-reviewed agent draft requests chrono = { workspace = true } +httpdate = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } @@ -86,10 +87,24 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } +[target.'cfg(unix)'.dependencies] +# getuid for the desktop provider's same-user check +libc = "0.2" + +# OS keychain for paired desktop-provider tokens (never a file) +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "=3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "=3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust", "vendored"] } + [dev-dependencies] # Scratch files for channel-templates.json fixtures in tests tempfile = "3" # Minimal HTTP test server for retry/policy integration tests axum = { workspace = true } +# A raw WebSocket relay for NIP-FI close-classification tests +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } # `test-util` enables paused-time control for deterministic timeout tests tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 7aa760aebc..5a8e39d617 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -20,6 +20,45 @@ export BUZZ_PRIVATE_KEY="nsec1..." buzz channels list ``` +### Which credentials are used + +The CLI uses the first of these that is configured: + +1. **An explicit key** (`--private-key` or `BUZZ_PRIVATE_KEY`), with the + headless NIP-FI settings below when they are set. +2. **The Buzz desktop** (macOS and Linux), through a token it gave this + process or one saved by `buzz auth desktop pair`. The desktop signs and + supplies NIP-FI evidence itself; headless NIP-FI settings are ignored, with + a warning naming them. + +With neither, commands fail with an `auth_error`. + +### Federated identity (NIP-FI) + +A relay that enforces federated identity also needs an identity assertion on +every protected request (`docs/nips/NIP-FI.md`). Give the CLI your NIP-FA +adapter session and it requests one; the adapter mints it +(`docs/nips/NIP-FA.md` §Relay assertion). The CLI caches the assertion for the +process, requests a new one shortly before it expires, and sends it as +`Nostr-Federated-Identity` on HTTP requests and the WebSocket upgrade. + +The session is a human sign-in for your own key: the adapter issues +assertions only for the key bound to the signed-in user. It is not an agent +grant, and it cannot authorize an agent key. + +```bash +export BUZZ_PRIVATE_KEY="nsec1..." +export BUZZ_FEDERATED_IDENTITY_ADAPTER="https://adapter.example" +export BUZZ_FEDERATED_IDENTITY_TOKEN="..." # your adapter session token; env only, never a flag +buzz channels list +``` + +The token goes only to the adapter, never through a redirect, and never into +output. Adapter outages (network errors, 429, 5xx) are retried a bounded number +of times and then fail as a retryable `adapter_error` before anything reaches +the relay; an adapter refusal is an `auth_error` and is not retried. On relays without NIP-FI, leave both unset: nothing changes. An +enforcing relay's `401` without them is an `auth_error` naming both settings. + ## Usage All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. @@ -223,6 +262,9 @@ buzz [flags] ├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API │ (clap) (handlers) (reqwest) │ + ├─ credentials.rs (identity, signer, NIP-FI evidence; every request + │ is authorized through client.rs `authorize`) + ├─ federated_identity.rs (NIP-FA adapter session → NIP-FI assertion) ├─ validate.rs (UUID, hex, content size, percent-encode) └─ error.rs (CliError → JSON stderr + exit code) diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index e5f2513069..54708af764 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -1,9 +1,10 @@ //! Owner-reviewed agent draft requests published through Buzz observer frames. -use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY}; -use nostr::{Event, Keys, PublicKey}; +use buzz_core::observer::{OBSERVER_FRAME_TELEMETRY, OBSERVER_MAX_PLAINTEXT_LEN}; +use nostr::{Event, PublicKey}; use serde::Serialize; +use crate::credentials::Credentials; use crate::error::CliError; const AGENT_REQUEST_KIND: &str = "agent_management_request"; @@ -99,8 +100,8 @@ fn optional(value: Option, label: &str) -> Result, CliErr value.map(|value| required(value, label, 300)).transpose() } -fn build( - keys: &Keys, +async fn build( + credentials: &Credentials, owner: &PublicKey, channel_id: String, request_kind: &'static str, @@ -123,17 +124,23 @@ fn build( request, }, }; - let encrypted = encrypt_observer_payload(keys, owner, &payload) - .map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?; - let event = buzz_sdk::build_agent_observer_frame( + let plaintext = serde_json::to_string(&payload) + .map_err(|error| CliError::Other(format!("could not encode draft request: {error}")))?; + if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN { + return Err(CliError::Usage(format!( + "draft request is too large: {} bytes (max {OBSERVER_MAX_PLAINTEXT_LEN})", + plaintext.len() + ))); + } + let encrypted = credentials.nip44_encrypt(owner, &plaintext).await?; + let builder = buzz_sdk::build_agent_observer_frame( &owner.to_hex(), - &keys.public_key().to_hex(), + &credentials.public_key().to_hex(), OBSERVER_FRAME_TELEMETRY, &encrypted, ) - .map_err(|error| CliError::Other(format!("could not build draft request: {error}")))? - .sign_with_keys(keys) - .map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?; + .map_err(|error| CliError::Other(format!("could not build draft request: {error}")))?; + let event = credentials.sign(builder).await?; Ok(BuiltDraftRequest { event, request_id, @@ -141,8 +148,8 @@ fn build( }) } -pub fn build_create( - keys: &Keys, +pub(crate) async fn build_create( + credentials: &Credentials, owner: &PublicKey, draft: CreateAgentDraft, ) -> Result { @@ -155,17 +162,18 @@ pub fn build_create( system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, }; build( - keys, + credentials, owner, channel_id, AGENT_REQUEST_KIND, "create", request, ) + .await } -pub fn build_update( - keys: &Keys, +pub(crate) async fn build_update( + credentials: &Credentials, owner: &PublicKey, draft: UpdateAgentDraft, ) -> Result { @@ -206,17 +214,18 @@ pub fn build_update( )); } build( - keys, + credentials, owner, channel_id, AGENT_REQUEST_KIND, "update", request, ) + .await } -pub fn build_project_channel( - keys: &Keys, +pub(crate) async fn build_project_channel( + credentials: &Credentials, owner: &PublicKey, draft: CreateProjectChannelDraft, ) -> Result { @@ -242,28 +251,30 @@ pub fn build_project_channel( template_name: optional(draft.template_name, "template")?, }; build( - keys, + credentials, owner, home_channel_id, PROJECT_CHANNEL_REQUEST_KIND, "create", request, ) + .await } #[cfg(test)] mod tests { use super::*; use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG}; + use nostr::Keys; const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9"; - #[test] - fn create_is_owner_encrypted_and_matches_desktop_contract() { + #[tokio::test] + async fn create_is_owner_encrypted_and_matches_desktop_contract() { let agent = Keys::generate(); let owner = Keys::generate(); let built = build_create( - &agent, + &Credentials::from_keys(agent.clone()), &owner.public_key(), CreateAgentDraft { channel_id: CHANNEL.into(), @@ -271,6 +282,7 @@ mod tests { system_prompt: "Find sources.".into(), }, ) + .await .unwrap(); assert_eq!(built.event.kind.as_u16(), 24_200); @@ -306,10 +318,10 @@ mod tests { assert!(payload["payload"]["request"].get("respondTo").is_none()); } - #[test] - fn update_requires_a_change() { + #[tokio::test] + async fn update_requires_a_change() { let error = build_update( - &Keys::generate(), + &Credentials::from_keys(Keys::generate()), &Keys::generate().public_key(), UpdateAgentDraft { channel_id: CHANNEL.into(), @@ -322,14 +334,15 @@ mod tests { respond_to: None, }, ) + .await .unwrap_err(); assert!(error.to_string().contains("at least one field")); } - #[test] - fn create_rejects_invalid_channel() { + #[tokio::test] + async fn create_rejects_invalid_channel() { let error = build_create( - &Keys::generate(), + &Credentials::from_keys(Keys::generate()), &Keys::generate().public_key(), CreateAgentDraft { channel_id: "general".into(), @@ -337,16 +350,17 @@ mod tests { system_prompt: "Help".into(), }, ) + .await .unwrap_err(); assert!(error.to_string().contains("invalid channel UUID")); } - #[test] - fn project_channel_request_is_owner_encrypted() { + #[tokio::test] + async fn project_channel_request_is_owner_encrypted() { let agent = Keys::generate(); let owner = Keys::generate(); let built = build_project_channel( - &agent, + &Credentials::from_keys(agent.clone()), &owner.public_key(), CreateProjectChannelDraft { home_channel_id: CHANNEL.into(), @@ -357,6 +371,7 @@ mod tests { template_name: Some("Release team".into()), }, ) + .await .unwrap(); let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); diff --git a/crates/buzz-cli/src/cached_assertion.rs b/crates/buzz-cli/src/cached_assertion.rs new file mode 100644 index 0000000000..d5df360c9a --- /dev/null +++ b/crates/buzz-cli/src/cached_assertion.rs @@ -0,0 +1,128 @@ +//! The one cached NIP-FI assertion an evidence link holds for its relay, +//! shared by the headless adapter session and the Buzz desktop link: one +//! `exp` parser and one refresh rule. + +use tokio::sync::Mutex; + +use crate::error::CliError; + +/// Refresh at least this long before expiry. +const REFRESH_MARGIN_SECS: u64 = 30; + +/// An evidence link's current assertion, or its answer that it has none. +#[derive(Default)] +pub(crate) struct CachedAssertion(Mutex, u64)>>); + +impl CachedAssertion { + /// The cached answer until its refresh point, else `fetch`'s. A fetched + /// assertion must be a compact JWS with an `exp`; `None` is kept until + /// the next command. Concurrent callers share one fetch. + pub(crate) async fn get(&self, fetch: F) -> Result, CliError> + where + F: std::future::Future, CliError>>, + { + let mut cached = self.0.lock().await; + if let Some((assertion, refresh)) = cached.as_ref() { + if unix_now() < *refresh { + return Ok(assertion.clone()); + } + } + let assertion = fetch.await?; + let refresh = match assertion.as_deref() { + Some(jws) => refresh_at( + unix_now(), + jws_exp(jws).ok_or_else(|| { + CliError::Auth("the evidence source returned an unreadable assertion".into()) + })?, + ), + None => u64::MAX, + }; + *cached = Some((assertion.clone(), refresh)); + Ok(assertion) + } +} + +/// Refresh at the earlier of half-life and [`REFRESH_MARGIN_SECS`] before +/// `exp`; when that margin is already past, at half-life, so a short-lived +/// assertion is not fetched again on every request. +pub(crate) fn refresh_at(now: u64, exp: u64) -> u64 { + let half_life = now + exp.saturating_sub(now) / 2; + match exp.saturating_sub(REFRESH_MARGIN_SECS) { + early if early > now => early.min(half_life), + _ => half_life, + } +} + +/// The `exp` claim of a structurally valid compact JWS: three non-empty +/// unpadded base64url segments, a JSON-object header naming `alg`, and a +/// JSON-object payload with an integer `exp`. The relay verifies the +/// signature; the CLI only refuses output it could not present or refresh. +pub(crate) fn jws_exp(token: &str) -> Option { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let mut segments = token.split('.'); + let (Some(header), Some(payload), Some(signature), None) = ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ) else { + return None; + }; + let json = |segment: &str| -> Option> { + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).ok()?).ok() + }; + if signature.is_empty() || URL_SAFE_NO_PAD.decode(signature).is_err() { + return None; + } + json(header)?.get("alg")?.as_str()?; + json(payload)?.get("exp")?.as_u64() +} + +pub(crate) fn unix_now() -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + #[cfg(test)] + let now = now + test_clock::SKEW.with(std::cell::Cell::get); + now +} + +/// A clock tests move forward instead of sleeping. Per thread, so it moves +/// only for the test (and its current-thread runtime) that advances it. +#[cfg(test)] +pub(crate) mod test_clock { + thread_local! { + pub(super) static SKEW: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + pub(crate) fn advance(secs: u64) { + SKEW.with(|skew| skew.set(skew.get() + secs)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_is_at_the_earlier_of_half_life_and_the_margin() { + assert_eq!(refresh_at(1_000, 1_300), 1_150, "long-lived: half-life"); + assert_eq!(refresh_at(1_000, 1_060), 1_030); + assert_eq!(refresh_at(1_000, 1_040), 1_010, "short-lived: the margin"); + assert_eq!(refresh_at(1_000, 1_020), 1_010, "margin past: half-life"); + assert_eq!(refresh_at(1_000, 1_001), 1_000); + } + + #[test] + fn jws_exp_needs_a_compact_jws_with_alg_and_exp() { + let payload = "eyJleHAiOjE3OTAwMDAzMDB9"; // {"exp":1790000300} + let header = "eyJhbGciOiJFUzI1NiJ9"; // {"alg":"ES256"} + assert_eq!( + jws_exp(&format!("{header}.{payload}.c2ln")), + Some(1_790_000_300) + ); + assert_eq!(jws_exp(&format!("e30.{payload}.c2ln")), None, "no alg"); + assert_eq!(jws_exp(&format!("{header}.{payload}")), None); + assert_eq!(jws_exp(&format!("{header}.{payload}.c2ln.x")), None); + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 1b9726940f..9a03e40629 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -1,13 +1,18 @@ use std::future::Future; use std::time::Duration; -use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag}; use sha2::{Digest, Sha256}; +use crate::credentials::{ + Credentials, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV, +}; use crate::error::CliError; +/// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport). +const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity"; + /// Descriptor returned by the relay after a successful upload. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BlobDescriptor { @@ -75,40 +80,6 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; -/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. -/// -/// The event includes: -/// - `u` tag: the full request URL -/// - `method` tag: HTTP method (GET, POST, PUT, DELETE) -/// - `payload` tag: SHA-256 hex of the request body (if present) -fn sign_nip98( - keys: &Keys, - method: &str, - url: &str, - body: Option<&[u8]>, -) -> Result { - let mut tags = vec![ - Tag::parse(["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - Tag::parse(["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, - // Nonce prevents replay rejection for rapid-fire requests with identical bodies. - Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?, - ]; - if let Some(b) = body { - let hash = hex::encode(Sha256::digest(b)); - tags.push( - Tag::parse(["payload", &hash]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?, - ); - } - let event = EventBuilder::new(Kind::Custom(27235), "") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?; - let json = event.as_json(); - Ok(format!("Nostr {}", B64.encode(json.as_bytes()))) -} - fn relay_server_tag(relay_url: &str) -> Option { let authority = buzz_core::tenant::relay_url_authority(relay_url); if authority.is_empty() { @@ -119,6 +90,14 @@ fn relay_server_tag(relay_url: &str) -> Option { } /// Maximum number of attempts per request (initial attempt + two retries). +/// The WebSocket connect and upgrade, before any NIP-42 wait. +const WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Connections a WebSocket publish may open to complete NIP-42 sign-in. +const WS_SIGN_IN_ATTEMPTS: u32 = 3; +/// Times an HTTP proof is signed while its NIP-FI assertion keeps expiring. +const AUTHORIZE_ROUNDS: u32 = 3; +/// How old an HTTP proof may be when sent, well inside the relay's 60 s. +const PROOF_MAX_AGE_SECS: u64 = 30; const RETRY_MAX_ATTEMPTS: u32 = 3; /// Base sleep durations for full-jitter exponential backoff. @@ -322,61 +301,45 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result Result { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use nostr::Timestamp; - - let now = Timestamp::now().as_secs(); - let exp_str = (now + 60).to_string(); - let domain = relay_server_tag(media_url) - .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; - let tags = vec![ - Tag::parse(["t", "get"]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, - ]; - - let auth_event = EventBuilder::new(Kind::from(24242), "Get media") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - - Ok(format!( - "Nostr {}", - URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) - )) +/// Blossom (BUD-01) `t=get` proof for `media_url`, scoped to its server. +fn blossom_get_event(media_url: &str) -> Result { + blossom_event( + "get", + None, + relay_server_tag(media_url) + .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?, + "Get media", + ) } -fn sign_blossom_upload( - keys: &Keys, - sha256: &str, - _mime: &str, - relay_url: &str, -) -> Result { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use nostr::Timestamp; +/// Blossom (BUD-02) `t=upload` proof for blob `sha256` on this relay. +fn blossom_upload_event(sha256: &str, relay_url: &str) -> Result { + blossom_event( + "upload", + Some(sha256), + relay_server_tag(relay_url) + .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?, + "Upload file", + ) +} - let now = Timestamp::now().as_secs(); - let exp_str = (now + 60).to_string(); - let domain = relay_server_tag(relay_url) - .ok_or_else(|| CliError::Usage(format!("invalid relay URL: {relay_url}")))?; - - let tags = vec![ - Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["x", sha256]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, - Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, - ]; - - let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") - .tags(tags) - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - - Ok(format!( - "Nostr {}", - URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) - )) +fn blossom_event( + verb: &str, + sha256: Option<&str>, + server: String, + content: &str, +) -> Result { + let exp = (nostr::Timestamp::now().as_secs() + 60).to_string(); + let tag = |parts: &[&str]| { + Tag::parse(parts.iter().copied()).map_err(|e| CliError::Other(e.to_string())) + }; + let mut tags = vec![tag(&["t", verb])?]; + if let Some(sha256) = sha256 { + tags.push(tag(&["x", sha256])?); + } + tags.push(tag(&["expiration", &exp])?); + tags.push(tag(&["server", &server])?); + Ok(EventBuilder::new(Kind::from(24242), content).tags(tags)) } #[cfg(test)] @@ -446,19 +409,12 @@ mod media_download_tests { #[test] fn media_get_auth_header_is_server_scoped() { - let keys = Keys::generate(); + let keys = nostr::Keys::generate(); let hash = "a".repeat(64); - let header = sign_blossom_get( - &keys, - &format!("https://relay.example:443/media/{hash}.jpg"), - ) - .unwrap(); - let encoded = header.strip_prefix("Nostr ").unwrap(); - let json = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded) + let event = blossom_get_event(&format!("https://relay.example:443/media/{hash}.jpg")) + .unwrap() + .sign_with_keys(&keys) .unwrap(); - let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); - event.verify().unwrap(); assert_eq!(event.kind, Kind::from(24242)); let tags: Vec> = event @@ -513,14 +469,48 @@ fn advance_query_cursor( Ok(()) } +/// How a protected relay request proves key possession in `Authorization`. +enum Proof<'a> { + /// NIP-98 over the request method, URL and body. + Nip98 { + method: &'a str, + url: &'a str, + body: Option<&'a [u8]>, + }, + /// Blossom kind-24242 upload proof for blob `sha256`. + BlossomUpload { sha256: &'a str }, + /// Blossom kind-24242 read proof for `url`. + BlossomGet { url: &'a str }, +} + +impl<'a> Proof<'a> { + fn post(url: &'a str, body: &'a [u8]) -> Self { + Self::Nip98 { + method: "POST", + url, + body: Some(body), + } + } +} + pub struct BuzzClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place" - keys: Keys, - /// Optional NIP-OA auth tag injected into every signed event. - auth_tag: Option, - /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. - auth_tag_json: Option, + /// The configured URL as a WebSocket URL, path and trailing slash kept, + /// as NIP-42 AUTH must name it. + ws_url: String, + /// Identity, signer, NIP-OA auth tag and NIP-FI evidence source. + credentials: Credentials, + /// Whether any request has carried a NIP-FI assertion, for error hints. + assertion_sent: std::sync::atomic::AtomicBool, +} + +/// A credential header value, marked sensitive so `Debug` output redacts it. +fn sensitive(value: String) -> Result { + let mut value = reqwest::header::HeaderValue::try_from(value) + .map_err(|_| CliError::Auth("credential is not a valid header value".into()))?; + value.set_sensitive(true); + Ok(value) } impl BuzzClient { @@ -533,29 +523,34 @@ impl BuzzClient { /// - `BUZZ_TIMEOUT_SECS` — per-request total timeout (default 30 s) /// /// A value of zero for either variable is treated as invalid and falls back to the default. - pub fn new( - relay_url: String, - keys: Keys, - auth_tag: Option, - auth_tag_json: Option, - ) -> Result { + /// A client for the relay at `relay_url`, as configured. + pub fn new(relay_url: String, credentials: Credentials) -> Result { let http = reqwest::Client::builder() + // Never replay credentials to a redirect target: reqwest strips + // `Authorization` across origins but not `x-auth-tag` or the + // NIP-FI assertion. A 3xx surfaces as a relay error instead. + .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build() .map_err(|e| CliError::Other(e.to_string()))?; Ok(Self { http, - relay_url, - keys, - auth_tag, - auth_tag_json, + ws_url: to_ws_url(&relay_url), + relay_url: normalize_relay_url(&relay_url), + credentials, + assertion_sent: Default::default(), }) } - /// Get the keypair. - pub fn keys(&self) -> &Keys { - &self.keys + /// The public key this client acts as. + pub fn public_key(&self) -> PublicKey { + self.credentials.public_key() + } + + /// The credentials this client acts with. + pub fn credentials(&self) -> &Credentials { + &self.credentials } /// Get the relay base URL. @@ -569,9 +564,9 @@ impl BuzzClient { /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the /// owner pubkey lives at index 1. pub fn auth_tag_owner_hex(&self) -> Option { - self.auth_tag - .as_ref() - .map(|t| t.as_slice()) + self.credentials + .auth_tag() + .map(|t| t.tag.as_slice()) .and_then(|slice| slice.get(1).cloned()) } @@ -580,23 +575,21 @@ impl BuzzClient { /// All event creation should go through this method to ensure consistent /// auth tag injection. Callers MUST NOT add `auth` tags to the builder /// before calling this method. - pub fn sign_event(&self, builder: EventBuilder) -> Result { - let builder = if let Some(ref tag) = self.auth_tag { - builder.tags([tag.clone()]) - } else { - builder + pub async fn sign_event(&self, builder: EventBuilder) -> Result { + let auth_tag = self.credentials.auth_tag(); + let builder = match auth_tag { + Some(tag) => builder.tags([tag.tag.clone()]), + None => builder, }; - let event = builder - .sign_with_keys(&self.keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = self.credentials.sign(builder).await?; - // Enforce: auth tags may only come from self.auth_tag injection. + // Enforce: auth tags may only come from the credentials' auth tag. let auth_count = event .tags .iter() .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) .count(); - let expected = if self.auth_tag.is_some() { 1 } else { 0 }; + let expected = if auth_tag.is_some() { 1 } else { 0 }; if auth_count != expected { return Err(CliError::Other(format!( "event has {auth_count} auth tags — expected {expected}; \ @@ -607,11 +600,146 @@ impl BuzzClient { Ok(event) } - /// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation). - fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - match self.auth_tag_json { - Some(ref json) => req.header("x-auth-tag", json), - None => req, + /// The one place protected relay requests get their credentials: + /// `proof` in `Authorization`, the NIP-OA `x-auth-tag` (relay membership + /// delegation) when configured, and the NIP-FI `Nostr-Federated-Identity` + /// assertion when an evidence source is configured. Called once per + /// attempt so every retry carries a fresh proof. + async fn authorize( + &self, + req: reqwest::RequestBuilder, + proof: Proof<'_>, + ) -> Result { + // Evidence first: a refresh may wait out adapter retries, and the + // proof must be signed after it, just before sending, to stay fresh. + // Signing may wait out throttling in turn, so evidence that is due + // for refresh by then is fetched again, and the proof re-signed when + // the assertion changed or that fetch aged the proof. + let mut assertion = self.evidence().await?; + let mut round = 1; + let authorization = loop { + // The proof is dated before signing, which may be held for long. + let signed_at = crate::cached_assertion::unix_now(); + let authorization = self.proof(&proof).await?; + let current = self.evidence().await?; + let age = crate::cached_assertion::unix_now().saturating_sub(signed_at); + if current == assertion && age < PROOF_MAX_AGE_SECS { + break authorization; + } + if round == AUTHORIZE_ROUNDS { + return Err(CliError::Unavailable( + "the NIP-FI assertion kept expiring while the proof was signed".into(), + )); + } + (assertion, round) = (current, round + 1); + }; + let mut req = req.header(reqwest::header::AUTHORIZATION, sensitive(authorization)?); + if let Some(tag) = self.credentials.auth_tag() { + req = req.header("x-auth-tag", sensitive(tag.json.clone())?); + } + if let Some(assertion) = assertion { + req = req.header( + FEDERATED_IDENTITY_HEADER, + sensitive(format!("Bearer {assertion}"))?, + ); + } + Ok(req) + } + + /// `proof`, signed now, as an `Authorization` value. + async fn proof(&self, proof: &Proof<'_>) -> Result { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + Ok(match *proof { + Proof::Nip98 { method, url, body } => { + format!("Nostr {}", self.credentials.nip98(method, url, body).await?) + } + Proof::BlossomUpload { sha256 } => { + let event = self + .credentials + .sign(blossom_upload_event(sha256, &self.relay_url)?) + .await?; + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) + } + Proof::BlossomGet { url } => { + let event = self.credentials.sign(blossom_get_event(url)?).await?; + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) + } + }) + } + + /// The NIP-FI assertion as WebSocket upgrade headers (empty without an + /// evidence source). + async fn upgrade_headers(&self) -> Result { + let mut headers = buzz_ws_client::HeaderMap::new(); + if let Some(assertion) = self.evidence().await? { + let mut value = + buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}")) + .map_err(|_| CliError::Auth("assertion is not a valid header value".into()))?; + value.set_sensitive(true); + headers.insert(FEDERATED_IDENTITY_HEADER, value); + } + Ok(headers) + } + + /// The NIP-FI assertion for this relay, noting that one was sent. + async fn evidence(&self) -> Result, CliError> { + let assertion = self.credentials.evidence().await?; + if assertion.is_some() { + self.assertion_sent + .store(true, std::sync::atomic::Ordering::Relaxed); + } + Ok(assertion) + } + + /// Map a non-2xx relay response to a `CliError`. + /// + /// The relay's `{"error": …}` / `{"message": …}` field becomes the + /// message. A 401 without a NIP-FI evidence source names the settings, + /// since an enforcing relay answers every request that way; a 403 with an + /// auth tag configured hints that the tag may be stale. + fn relay_error(&self, status: u16, body: String) -> CliError { + let message = extract_relay_message_field(&body).unwrap_or(body); + let message = message.trim_end().to_owned(); + let sent = self + .assertion_sent + .load(std::sync::atomic::Ordering::Relaxed); + let desktop = self.credentials.is_desktop(); + if status == 401 && !sent && desktop { + return CliError::Auth(format!( + "relay requires authentication ({message}); if it enforces federated identity \ + (NIP-FI), the Buzz desktop has no assertion for this relay: sign in to this \ + community in Buzz, then retry" + )); + } + if status == 401 && !sent && !self.credentials.has_evidence_source() { + return CliError::Auth(format!( + "relay requires authentication ({message}); if it enforces federated identity \ + (NIP-FI), set {FEDERATED_IDENTITY_TOKEN_ENV} to your own NIP-FA sign-in session \ + (a human session for this key, not an agent grant) and \ + {FEDERATED_IDENTITY_ADAPTER_ENV} to its adapter, which mints the assertion" + )); + } + if status == 403 && self.credentials.auth_tag().is_some() { + let hint = if desktop { + "the Buzz desktop's NIP-OA auth tag may be stale or revoked; check this \ + identity's owner in Buzz" + } else { + "BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it" + }; + return CliError::Relay { + status, + body: format!("{message} ({hint})"), + }; + } + CliError::Relay { + status, + body: message, } } @@ -750,16 +878,17 @@ impl BuzzClient { /// Used only for NIP-IA identity archive/unarchive requests (kind /// 9035/9036), whose optional `auth` tag is a *content-level* /// owner-of-agent attestation about the *target* identity — unrelated to - /// this client's own NIP-OA membership delegation (`self.auth_tag`, + /// this client's own NIP-OA membership delegation (the credentials' auth tag, /// which [`sign_event`] injects into every other event and which /// `submit_event` separately attaches via the `x-auth-tag` HTTP header). /// Routing an identity archive request through `sign_event` would either /// silently drop the caller's owner attestation or double up an /// unrelated tag. - pub fn sign_event_unchecked(&self, builder: EventBuilder) -> Result { - builder - .sign_with_keys(&self.keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}"))) + pub async fn sign_event_unchecked( + &self, + builder: EventBuilder, + ) -> Result { + self.credentials.sign(builder).await } /// GET a public, unauthenticated relay endpoint (e.g. the NIP-11 `/info` @@ -796,17 +925,16 @@ impl BuzzClient { let body = body.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -826,17 +954,16 @@ impl BuzzClient { let body = body.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -854,11 +981,17 @@ impl BuzzClient { self.with_retry_body(|| { let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "GET", &url, None)?; - let resp = self - .with_auth_tag(self.http.get(&url).header("Authorization", auth)) - .send() + let req = self + .authorize( + self.http.get(&url), + Proof::Nip98 { + method: "GET", + url: &url, + body: None, + }, + ) .await?; + let resp = req.send().await?; self.handle_response(resp).await } }) @@ -884,17 +1017,16 @@ impl BuzzClient { let body_bytes = body_bytes.clone(); let url = url.clone(); async move { - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body_bytes), + .body(body_bytes.clone()), + Proof::post(&url, &body_bytes), ) - .send() .await?; + let resp = req.send().await?; // 204 No Content: return empty string rather than failing on // an empty body that cannot be parsed as JSON. if resp.status() == reqwest::StatusCode::NO_CONTENT { @@ -915,29 +1047,27 @@ impl BuzzClient { ) -> Result { let url = format!("{}{path}", self.relay_url); let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; let unknown = |detail: String| CliError::DeliveryUnknown(detail); let http = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build()?; - let response = self - .with_auth_tag( + let req = self + .authorize( http.post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() - .await - .map_err(|e| { - if e.is_connect() || e.is_builder() { - CliError::Network(e) - } else { - unknown(e.to_string()) - } - })?; + .await?; + let response = req.send().await.map_err(|e| { + if e.is_connect() || e.is_builder() { + CliError::Network(e) + } else { + unknown(e.to_string()) + } + })?; let status = response.status(); let body = response.text().await.map_err(|e| unknown(e.to_string()))?; let message = extract_relay_message_field(&body).unwrap_or_else(|| body.clone()); @@ -948,10 +1078,7 @@ impl BuzzClient { return Err(unknown(format!("HTTP {}: {message}", status.as_u16()))); } if !status.is_success() { - return Err(CliError::Relay { - status: status.as_u16(), - body: message, - }); + return Err(self.relay_error(status.as_u16(), body)); } Ok(body) } @@ -989,18 +1116,17 @@ impl BuzzClient { // Re-sign NIP-98 each attempt: the nonce tag generates a fresh // event ID, keeping retries safe against the relay's replay guard. - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let send_result: Result = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") .body(body.clone()), + Proof::post(&url, &body), ) - .send() - .await - .map_err(CliError::from); + .await?; + let send_result: Result = + req.send().await.map_err(CliError::from); match send_result { Err(e) => { @@ -1086,26 +1212,7 @@ impl BuzzClient { })?; // Map the body through handle_response's error logic inline. if !resp_was_success(status) { - let message = serde_json::from_str::(&body_text) - .ok() - .and_then(|v| { - v.get("error") - .or_else(|| v.get("message")) - .and_then(|m| m.as_str()) - .map(str::to_string) - }) - .unwrap_or(body_text); - let message = if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() { - format!( - "{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)" - ) - } else { - message - }; - return Err(CliError::Relay { - status, - body: message, - }); + return Err(self.relay_error(status, body_text)); } return Ok(body_text); } @@ -1134,25 +1241,36 @@ impl BuzzClient { serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, ); + // Whether any attempt reached the relay without an authoritative + // answer. A later failure, even one before sending, cannot clear it. + let maybe_delivered = std::sync::atomic::AtomicBool::new(false); let result = self .with_retry_body(|| { let body = body.clone(); let url = url.clone(); + let maybe_delivered = &maybe_delivered; async move { // Re-sign NIP-98 each attempt: the nonce tag generates a fresh // event ID, keeping retries safe against the relay's replay guard. - let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .post(&url) - .header("Authorization", auth) .header("Content-Type", "application/json") - .body(body), + .body(body.clone()), + Proof::post(&url, &body), ) - .send() .await?; - self.handle_response(resp).await + let sent = match req.send().await { + Ok(resp) => self.handle_response(resp).await, + Err(e) => Err(e.into()), + }; + if let Err(ref e) = sent { + if is_stored_event_exhaustion_ambiguous(e) { + maybe_delivered.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + sent } }) .await; @@ -1162,7 +1280,13 @@ impl BuzzClient { // Connect failures stay Network (retryable:true) — definitively never received. // Canonical pre-ingest 429 (Relay{429}) stays retryable — definitively not stored. if let Err(ref e) = result { - if is_stored_event_exhaustion_ambiguous(e) { + // Once an attempt may have been stored, no later answer settles + // the outcome, except a canvas 409, which the canvas command + // resolves by looking the canvas up. + let settled = matches!(e, CliError::Relay { status: 409, body } if body.contains("canvas changed")); + if is_stored_event_exhaustion_ambiguous(e) + || (maybe_delivered.load(std::sync::atomic::Ordering::Relaxed) && !settled) + { let kind_u16 = event.kind.as_u16(); return Err(CliError::DeliveryUnknown(format!( "stored event (kind {kind_u16}) outcome unknown after all attempts: {e}" @@ -1179,15 +1303,70 @@ impl BuzzClient { /// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth, /// EVENT send, OK wait, and graceful close. pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result { - let ws_url = to_ws_url(&self.relay_url); - // Hard cap — inner wait ceilings sum to 70 s; connect time and network RTT are - // additional overhead absorbed by this budget. - // See buzz_ws_client::{AUTH_CHALLENGE_TIMEOUT_SECS, AUTH_OK_TIMEOUT_SECS, - // PUBLISH_OK_TIMEOUT_SECS} for the inner ceilings. - let ok = - buzz_ws_client::publish_event(&ws_url, event, &self.keys, self.auth_tag.as_ref(), 75) - .await - .map_err(|e| CliError::Other(e.to_string()))?; + let ws_url = &self.ws_url; + // Two kinds of deadline: each network phase is capped as a whole, + // writes included (the connect below, then buzz_ws_client's challenge, + // AUTH, EVENT and close phases), and NIP-42 signing between them is + // bounded by the signer itself. A + // desktop provider may wait out throttling and hold `sign_event` for a + // person's decision, which no relay budget should cut short. + let signer = crate::credentials::CapturingSigner::new(&self.credentials); + let ws_error = |e: buzz_ws_client::WsClientError| match e.upgrade_rejection() { + // A denial decided on the upgrade is an HTTP response (NIP-FI). + Some((status, body)) => self.relay_error(status, body), + // A post-upgrade denial (e.g. a NIP-FI key mismatch) is a 1008 close. + None if matches!(e, buzz_ws_client::WsClientError::PolicyClose(_)) => { + CliError::Auth(e.to_string()) + } + None => CliError::Other(e.to_string()), + }; + // + // The relay closes a socket that has not signed in within seconds, + // so a slow or throttled sign-in signature reconnects and signs the + // new challenge, a bounded number of times, with current evidence: + // the old assertion may have expired meanwhile. A throttled sign-in + // is waited out between connections, never on one. EVENT, signed + // before any of this, is sent only once AUTH succeeds. + let mut attempt = 1; + let mut conn = loop { + let headers = self.upgrade_headers().await?; + let mut conn = tokio::time::timeout( + WS_CONNECT_TIMEOUT, + buzz_ws_client::NostrWsConnection::connect_with_headers(ws_url, headers), + ) + .await + .map_err(|_| CliError::Other("relay WebSocket connect timed out".into()))? + .map_err(ws_error)?; + let signed_in = conn + .authenticate(&signer, self.credentials.auth_tag().map(|t| &t.tag)) + .await; + let Err(e) = signed_in else { break conn }; + let failure = signer.take_failure(); + let lost = matches!( + e, + buzz_ws_client::WsClientError::ConnectionClosed + | buzz_ws_client::WsClientError::WebSocket(_) + ); + let (failure, wait) = failure.unzip(); + let wait = wait.flatten(); + let throttled = matches!(failure, Some(CliError::Throttled(_))); + let _ = conn.disconnect().await; + if !(lost || throttled) { + return Err(failure.unwrap_or_else(|| ws_error(e))); + } + if attempt == WS_SIGN_IN_ATTEMPTS { + return Err(failure.unwrap_or_else(|| { + CliError::Unavailable(format!( + "the relay closed the WebSocket before sign-in completed, \ + {WS_SIGN_IN_ATTEMPTS} times: {e}" + )) + })); + } + tokio::time::sleep(wait.unwrap_or_else(|| jitter_delay(attempt - 1))).await; + attempt += 1; + }; + let ok = conn.send_event(event).await.map_err(ws_error)?; + let _ = conn.disconnect().await; if !ok.accepted { return Err(CliError::Relay { @@ -1262,25 +1441,23 @@ impl BuzzClient { let mime = mime.clone(); let sha256 = sha256.clone(); async move { - let auth_header = - sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .put(&url) .timeout(upload_timeout) - .header("Authorization", auth_header) .header("Content-Type", &mime) .header("X-SHA-256", &sha256) .body(upload_body), + Proof::BlossomUpload { sha256: &sha256 }, ) - .send() .await?; + let resp = req.send().await?; let status = resp.status(); if !status.is_success() { let s = status.as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status: s, body }); + return Err(self.relay_error(s, body)); } resp.json::().await.map_err(CliError::from) } @@ -1309,23 +1486,22 @@ impl BuzzClient { let mime = mime.clone(); let sha256 = sha256.clone(); async move { - let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; - let resp = self - .with_auth_tag( + let req = self + .authorize( self.http .put(&legacy_url) .timeout(upload_timeout) - .header("Authorization", auth_header) .header("Content-Type", &mime) .header("X-SHA-256", &sha256) .body(upload_body), + Proof::BlossomUpload { sha256: &sha256 }, ) - .send() .await?; + let resp = req.send().await?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status, body }); + return Err(self.relay_error(status, body)); } resp.json::().await.map_err(CliError::from) } @@ -1347,15 +1523,14 @@ impl BuzzClient { let url = url.clone(); let client = client.clone(); async move { - let auth_header = sign_blossom_get(&self.keys, &url)?; - let resp = self - .with_auth_tag(client.get(&url).header("Authorization", auth_header)) - .send() + let req = self + .authorize(client.get(&url), Proof::BlossomGet { url: &url }) .await?; + let resp = req.send().await?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status, body }); + return Err(self.relay_error(status, body)); } resp.bytes().await.map_err(CliError::Network) } @@ -1367,28 +1542,7 @@ impl BuzzClient { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - let message = serde_json::from_str::(&body) - .ok() - .and_then(|v| { - v.get("error") - .or_else(|| v.get("message")) - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or(body); - if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() { - let message = format!( - "{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)" - ); - return Err(CliError::Relay { - status, - body: message, - }); - } - return Err(CliError::Relay { - status, - body: message, - }); + return Err(self.relay_error(status, body)); } Ok(resp.text().await?) } @@ -1754,8 +1908,15 @@ mod retry_policy_tests { } fn test_client(base_url: &str) -> BuzzClient { - let keys = Keys::generate(); - BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() + } + + fn client_keys(client: &BuzzClient) -> Keys { + client.credentials().test_keys() } fn make_moderation_event(keys: &Keys, kind: u16) -> nostr::Event { @@ -1783,7 +1944,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); assert!( matches!(err, CliError::DeliveryUnknown(_)), @@ -1823,7 +1984,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9041); + let event = make_moderation_event(&client_keys(&client), 9041); let t0 = std::time::Instant::now(); let result = client.submit_event(event).await; let elapsed = t0.elapsed(); @@ -1856,7 +2017,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); // Must be Relay(429), not DeliveryUnknown. @@ -1884,7 +2045,7 @@ mod retry_policy_tests { let (url, attempts) = test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await; let client = test_client(&url); - let event = make_moderation_event(client.keys(), 9042); + let event = make_moderation_event(&client_keys(&client), 9042); let err = client.submit_event(event).await.unwrap_err(); assert!( matches!(err, CliError::DeliveryUnknown(_)), @@ -1912,7 +2073,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_moderation_event(client.keys(), 9040); + let event = make_moderation_event(&client_keys(&client), 9040); let err = client.submit_event(event).await.unwrap_err(); // Must be Network (retryable), not DeliveryUnknown (retryable:false). assert!( @@ -1943,7 +2104,7 @@ mod retry_policy_tests { }) .await; let client = test_client(&url); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let result = client.submit_event(event).await; assert!( result.is_ok(), @@ -2203,7 +2364,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let result = client.submit_event(event).await; assert!( result.is_ok(), @@ -2365,7 +2526,7 @@ mod retry_policy_tests { let base = format!("http://{addr}"); let client = test_client(&base); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let err = client.submit_event(event).await.unwrap_err(); // Final error must be DeliveryUnknown — relay may have accepted any attempt. @@ -2397,7 +2558,7 @@ mod retry_policy_tests { let (url, attempts) = test_server(|_n| (StatusCode::BAD_GATEWAY, "bad gateway".to_string())).await; let client = test_client(&url); - let event = make_stored_event(client.keys()); + let event = make_stored_event(&client_keys(&client)); let err = client.submit_event(event).await.unwrap_err(); assert!( @@ -2410,13 +2571,35 @@ mod retry_policy_tests { "all 3 attempts must fire before surfacing DeliveryUnknown" ); } + + /// The first send is stored but its answer is lost; the retry is then + /// refused before ingest. The refusal cannot undo the first send, so the + /// outcome stays unknown rather than a plain rejection. + #[tokio::test] + async fn a_403_after_a_lost_stored_send_stays_delivery_unknown() { + let (url, attempts) = test_server(|n| match n { + 1 => (StatusCode::BAD_GATEWAY, "bad gateway".to_string()), + _ => ( + StatusCode::FORBIDDEN, + r#"{"error":"restricted: not allowed"}"#.to_string(), + ), + }) + .await; + let client = test_client(&url); + let event = make_stored_event(&client_keys(&client)); + let err = client.submit_event(event).await.unwrap_err(); + + assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}"); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!(attempts.load(Ordering::SeqCst), 2, "no retry after the 403"); + } } #[cfg(test)] mod tests { use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - normalize_events, BuzzClient, + normalize_events, BuzzClient, Credentials, Proof, }; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -2536,21 +2719,19 @@ mod tests { (tag, json) } - #[test] - fn sign_event_unchecked_does_not_inject_ambient_auth_tag() { + #[tokio::test] + async fn sign_event_unchecked_does_not_inject_ambient_auth_tag() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json), ) .unwrap(); let builder = EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]); - let event = client.sign_event_unchecked(builder).unwrap(); + let event = client.sign_event_unchecked(builder).await.unwrap(); let auth_tags: Vec<_> = event .tags @@ -2564,15 +2745,13 @@ mod tests { ); } - #[test] - fn sign_event_unchecked_preserves_callers_content_auth_tag() { + #[tokio::test] + async fn sign_event_unchecked_preserves_callers_content_auth_tag() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json), ) .unwrap(); @@ -2586,7 +2765,7 @@ mod tests { let builder = EventBuilder::new(Kind::Custom(9035), "archive") .tags([Tag::parse(["-"]).unwrap(), content_auth]); - let event = client.sign_event_unchecked(builder).unwrap(); + let event = client.sign_event_unchecked(builder).await.unwrap(); let auth_tags: Vec<_> = event .tags @@ -2601,21 +2780,23 @@ mod tests { assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64)); } - #[test] - fn with_auth_tag_sets_header_when_configured() { + #[tokio::test] + async fn authorize_sets_auth_tag_header_when_configured() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( "https://test.relay".into(), - keys, - Some(auth_tag), - Some(auth_json.clone()), + Credentials::from_keys(keys).with_auth_tag(auth_tag, auth_json.clone()), ) .unwrap(); - let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); - let built = req.build().unwrap(); + let url = "https://test.relay/events"; + let built = client + .authorize(client.http.post(url), Proof::post(url, b"{}")) + .await + .unwrap() + .build() + .unwrap(); let header = built .headers() .get("x-auth-tag") @@ -2627,17 +2808,1352 @@ mod tests { ); } - #[test] - fn with_auth_tag_omits_header_when_not_configured() { - let keys = Keys::generate(); - let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap(); + #[tokio::test] + async fn authorize_omits_optional_headers_when_not_configured() { + let client = BuzzClient::new( + "https://test.relay".into(), + Credentials::from_keys(Keys::generate()), + ) + .unwrap(); - let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); - let built = req.build().unwrap(); + let url = "https://test.relay/events"; + let built = client + .authorize(client.http.post(url), Proof::post(url, b"{}")) + .await + .unwrap() + .build() + .unwrap(); + assert!(built.headers().get("authorization").is_some()); assert!( built.headers().get("x-auth-tag").is_none(), "x-auth-tag header must not be present when no auth tag is configured" ); + assert!( + built.headers().get("nostr-federated-identity").is_none(), + "no NIP-FI header without an evidence source" + ); + } +} + +/// NIP-FI evidence on every protected route (Review-Proven Rule #2: every +/// request path that carries auth is enumerated here, plus the WebSocket +/// upgrade). One mock origin serves both the NIP-FA adapter and the relay. +#[cfg(test)] +mod federated_identity_route_tests { + use std::sync::{Arc, Mutex}; + + use axum::extract::Request; + use axum::http::{HeaderMap, StatusCode}; + use axum::response::IntoResponse; + use nostr::{EventBuilder, Keys, Kind}; + + use super::BuzzClient; + use crate::credentials::Credentials; + use crate::error::CliError; + use crate::federated_identity::AdapterSession; + + use crate::federated_identity::test_support::{self, count, raw_adapter}; + + const HASH: &str = "4f2b5e7c1a9d3e6f8b0c2d4e6f8a0b1c3d5e7f9a1b3c5d7e9f0a2b4c6d8e0f1a"; + + #[derive(Default)] + struct Log { + mints: usize, + /// The last assertion the adapter issued. + assertion: String, + requests: Vec<(String, String, HeaderMap)>, + } + + /// Serve the adapter and relay on one loopback origin. Relay routes + /// answer `deny` when set, otherwise a plausible success; `PUT /upload` + /// is 404 so the legacy `/media/upload` fallback runs too. + async fn origin(deny: Option) -> (String, Arc>) { + let log: Arc> = Arc::default(); + let state = log.clone(); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let log = log.clone(); + move |body: axum::body::Bytes| { + let log = log.clone(); + async move { + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let expires_at = nostr::Timestamp::now().as_secs() + 300; + let assertion = test_support::assertion(pubkey, expires_at); + let mut log = log.lock().unwrap(); + log.mints += 1; + log.assertion = assertion.clone(); + axum::Json(serde_json::json!({ + "assertion": assertion, + "nostr_pubkey": pubkey, + "expires_at": expires_at, + })) + } + } + }), + ) + .fallback(move |req: Request| { + let log = state.clone(); + async move { + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + log.lock().unwrap().requests.push(( + method.clone(), + path.clone(), + req.headers().clone(), + )); + if let Some(status) = deny { + return (status, r#"{"error":"authentication required"}"#).into_response(); + } + let body = match (method.as_str(), path.as_str()) { + ("PUT", "/upload") => return StatusCode::NOT_FOUND.into_response(), + // No WebSocket server: the test only needs the upgrade's headers. + ("GET", "/") => return StatusCode::FORBIDDEN.into_response(), + ("PUT", "/media/upload") => serde_json::json!({ + "url": format!("http://x/media/{HASH}.png"), + "sha256": HASH, + "size": 8, + "type": "image/png", + "uploaded": 0, + }) + .to_string(), + ("POST", "/query") => "[]".to_owned(), + ("POST", "/count") => r#"{"count":0}"#.to_owned(), + _ => r#"{"accepted":true}"#.to_owned(), + }; + (StatusCode::OK, body).into_response() + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, log) + } + + fn client(base: &str, evidence: bool) -> BuzzClient { + if evidence { + client_via(base, base) + } else { + BuzzClient::new(base.to_owned(), Credentials::from_keys(Keys::generate())).unwrap() + } + } + + /// A client for the relay at `relay` that mints from the adapter at `adapter`. + fn client_via(relay: &str, adapter: &str) -> BuzzClient { + let credentials = Credentials::from_keys(Keys::generate()) + .with_evidence(AdapterSession::new(adapter, "token", relay).unwrap()); + BuzzClient::new(relay.to_owned(), credentials).unwrap() + } + + /// Drive every protected request path once, ignoring results (the WS + /// publish fails by design once its upgrade headers are recorded). + async fn exercise_every_route(client: &BuzzClient) -> Vec> { + let keys = client.credentials().test_keys(); + let png = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + std::fs::write(png.path(), b"\x89PNG\r\n\x1a\n").unwrap(); + let filter = serde_json::json!({"kinds": [1]}); + let body = serde_json::json!({"k": "v"}); + let event = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "") + .sign_with_keys(&keys) + .unwrap() + }; + let mut results = vec![ + client.query(&filter).await, + client.query_multi(&[filter.clone(), filter.clone()]).await, + client.count(&filter).await, + client.get_authed("/api/get").await, + client.post_json_authed("/api/post", &body).await, + client.post_json_once_authed("/api/post-once", &body).await, + client.submit_event(event(1)).await, + client.submit_event(event(9041)).await, + client.publish_ephemeral_event(event(20001)).await, + ]; + results.push( + client + .upload_file(png.path().to_str().unwrap()) + .await + .map(|d| d.url), + ); + results.push( + client + .download_media(&format!("{HASH}.png")) + .await + .map(|_| String::new()), + ); + results + } + + /// Each `(method, path)` the CLI sends on a protected route. + const PROTECTED: &[(&str, &str)] = &[ + ("POST", "/query"), + ("POST", "/count"), + ("GET", "/api/get"), + ("POST", "/api/post"), + ("POST", "/api/post-once"), + ("POST", "/events"), + ("GET", "/"), + ("PUT", "/upload"), + ("PUT", "/media/upload"), + ]; + + fn covered(log: &Log) -> Vec<(String, String)> { + let mut seen: Vec<(String, String)> = log + .requests + .iter() + .map(|(m, p, _)| (m.clone(), p.clone())) + .collect(); + seen.sort(); + seen.dedup(); + seen + } + + /// The relay proof is signed after the assertion arrives, so a slow + /// mint (adapter retries can exceed NIP-98's 60 s window) cannot age it. + #[tokio::test] + async fn the_relay_proof_is_signed_after_a_slow_mint() { + use base64::Engine; + let minted_at: Arc> = Arc::default(); + let proofs: Arc>> = Arc::default(); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let minted_at = minted_at.clone(); + move |body: axum::body::Bytes| { + let minted_at = minted_at.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let now = nostr::Timestamp::now().as_secs(); + *minted_at.lock().unwrap() = now; + axum::Json(serde_json::json!({ + "assertion": test_support::assertion(pubkey, now + 300), + "nostr_pubkey": pubkey, + "expires_at": now + 300, + })) + } + } + }), + ) + .fallback({ + let proofs = proofs.clone(); + move |headers: HeaderMap| { + let proofs = proofs.clone(); + async move { + let auth = headers["authorization"].to_str().unwrap().to_owned(); + proofs.lock().unwrap().push(auth); + (StatusCode::OK, "{}") + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + client(&base, true).get_authed("/api/get").await.unwrap(); + + let proof = proofs.lock().unwrap()[0].clone(); + let json = base64::engine::general_purpose::STANDARD + .decode(proof.trim_start_matches("Nostr ")) + .unwrap(); + let event: nostr::Event = serde_json::from_slice(&json).unwrap(); + assert!( + event.created_at.as_secs() >= *minted_at.lock().unwrap(), + "the proof was signed before the assertion arrived" + ); + } + + #[tokio::test] + async fn every_protected_route_and_the_upgrade_carry_the_assertion() { + let (base, log) = origin(None).await; + let results = exercise_every_route(&client(&base, true)).await; + + let log = log.lock().unwrap(); + for (method, path) in PROTECTED { + assert!( + covered(&log).contains(&(method.to_string(), path.to_string())), + "{method} {path} was not exercised" + ); + } + assert!( + log.requests + .iter() + .any(|(_, p, _)| p.starts_with("/media/") && p != "/media/upload"), + "GET /media/ was not exercised" + ); + for (method, path, headers) in &log.requests { + assert_eq!( + headers + .get("nostr-federated-identity") + .and_then(|v| v.to_str().ok()), + Some(format!("Bearer {}", log.assertion).as_str()), + "{method} {path}" + ); + if path != "/" { + assert!( + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("Nostr ")), + "{method} {path}: Authorization proof" + ); + } + } + assert_eq!(log.mints, 1, "one assertion serves the whole process"); + // Everything but the WS publish (no WS server) succeeds. + for (i, result) in results.iter().enumerate() { + if i != 8 { + assert!(result.is_ok(), "route {i}: {result:?}"); + } + } + } + + #[tokio::test] + async fn exempt_routes_and_relays_without_a_source_get_no_assertion() { + let (base, log) = origin(None).await; + let plain = client(&base, false); + exercise_every_route(&plain).await; + plain.get_public("/info").await.unwrap(); + + { + let log = log.lock().unwrap(); + assert_eq!(log.mints, 0); + assert!(log.requests.len() >= PROTECTED.len()); + for (method, path, headers) in &log.requests { + assert!( + headers.get("nostr-federated-identity").is_none(), + "{method} {path}" + ); + } + } + + // An exempt route never mints or presents evidence, even with a source. + let (base, log) = origin(None).await; + client(&base, true).get_public("/info").await.unwrap(); + let log = log.lock().unwrap(); + assert_eq!(log.mints, 0); + assert!(log.requests[0].2.get("nostr-federated-identity").is_none()); + } + + /// An enforcing relay's 401 with no evidence source names the settings, + /// on HTTP and on the WebSocket upgrade, and is not retried. + #[tokio::test] + async fn a_401_without_a_source_names_the_evidence_settings() { + let (base, log) = origin(Some(StatusCode::UNAUTHORIZED)).await; + let plain = client(&base, false); + let keys = plain.credentials().test_keys(); + let ws_event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + for err in [ + plain.query(&serde_json::json!({})).await.unwrap_err(), + plain.publish_ephemeral_event(ws_event).await.unwrap_err(), + ] { + assert!( + matches!(&err, CliError::Auth(m) + if m.contains("BUZZ_FEDERATED_IDENTITY_TOKEN") + && m.contains("BUZZ_FEDERATED_IDENTITY_ADAPTER")), + "{err:?}" + ); + } + assert_eq!(log.lock().unwrap().requests.len(), 2, "401 is not retried"); + + // With a source configured, a 401 is the relay's verdict, not a hint. + let (base, _) = origin(Some(StatusCode::UNAUTHORIZED)).await; + let err = client(&base, true) + .query(&serde_json::json!({})) + .await + .unwrap_err(); + assert!( + matches!(err, CliError::Relay { status: 401, .. }), + "{err:?}" + ); + } + + /// The paths a failed mint must stop before anything reaches the relay: + /// a read, a post-once write, a moderation command, a stored message + /// (`messages send`) and the WebSocket upgrade (`users set-presence`). + /// Returns each path's error and how many mints that one call made. + async fn every_path_once( + client: &BuzzClient, + mints: &std::sync::atomic::AtomicUsize, + ) -> Vec<(&'static str, CliError, usize)> { + let keys = client.credentials().test_keys(); + let event = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "hi") + .sign_with_keys(&keys) + .unwrap() + }; + let body = serde_json::json!({"k": "v"}); + let mut out = Vec::new(); + let mut record = |path, result: Result, before| { + out.push((path, result.unwrap_err(), count(mints) - before)); + }; + let before = count(mints); + record( + "read", + client.query(&serde_json::json!({"kinds": [1]})).await, + before, + ); + let before = count(mints); + record( + "post-once", + client.post_json_once_authed("/api/post-once", &body).await, + before, + ); + let before = count(mints); + record("moderation", client.submit_event(event(9041)).await, before); + let before = count(mints); + record("message", client.submit_event(event(9)).await, before); + let before = count(mints); + record( + "websocket", + client.publish_ephemeral_event(event(20001)).await, + before, + ); + out + } + + /// An adapter 500 or 503 is retried a bounded number of times on every + /// path, then fails as a retryable adapter error. Nothing reaches the + /// relay, so a message is never reported as possibly delivered. + #[tokio::test] + async fn adapter_5xx_is_retried_on_every_path_and_never_reaches_the_relay() { + for response in [test_support::HTTP_500, test_support::HTTP_503] { + let (relay, log) = origin(None).await; + let (adapter, mints) = raw_adapter(response).await; + let client = client_via(&relay, &adapter); + for (path, err, attempts) in every_path_once(&client, &mints).await { + assert!(matches!(err, CliError::Adapter(_)), "{path}: {err:?}"); + assert!(crate::error::is_retryable_error(&err), "{path}"); + assert_eq!(attempts, 3, "{path}: three mints, no more"); + } + assert!( + log.lock().unwrap().requests.is_empty(), + "the relay saw a request" + ); + } + } + + /// A first send whose outcome is lost, then an assertion refresh that + /// fails, is still possibly delivered: the adapter error must not invite + /// an outer re-sign. + #[tokio::test] + async fn a_refresh_failure_after_a_lost_send_stays_delivery_unknown() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mints = Arc::new(AtomicUsize::new(0)); + let sends = Arc::new(AtomicUsize::new(0)); + let app = axum::Router::new() + .route( + "/v1/identity/assertions", + axum::routing::post({ + let mints = mints.clone(); + move |body: axum::body::Bytes| { + let mints = mints.clone(); + async move { + if mints.fetch_add(1, Ordering::SeqCst) > 0 { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let req: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let pubkey = req["nostr_pubkey"].as_str().unwrap_or_default(); + let expires_at = nostr::Timestamp::now().as_secs() + 300; + axum::Json(serde_json::json!({ + "assertion": test_support::assertion(pubkey, expires_at), + "nostr_pubkey": pubkey, + "expires_at": expires_at, + })) + .into_response() + } + } + }), + ) + .fallback({ + let sends = sends.clone(); + move || { + let sends = sends.clone(); + async move { + sends.fetch_add(1, Ordering::SeqCst); + // The relay may have stored it; the proxy lost the answer + // after the assertion reached its refresh boundary. + crate::cached_assertion::test_clock::advance(300); + StatusCode::BAD_GATEWAY.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let client = client_via(&base, &base); + let keys = client.credentials().test_keys(); + let event = EventBuilder::new(Kind::Custom(9), "hi") + .sign_with_keys(&keys) + .unwrap(); + let err = client.submit_event(event).await.unwrap_err(); + assert!(matches!(err, CliError::DeliveryUnknown(_)), "{err:?}"); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!( + sends.load(Ordering::SeqCst), + 1, + "one send reached the relay" + ); + assert_eq!( + mints.load(Ordering::SeqCst), + 4, + "one mint, then three failed refreshes" + ); + } + + /// A 403 whose body is cut off is one refusal on every path, not a + /// retryable network error, and nothing reaches the relay. + #[tokio::test] + async fn a_truncated_adapter_403_is_a_single_refusal_on_every_path() { + let (relay, log) = origin(None).await; + let (adapter, mints) = raw_adapter(test_support::TRUNCATED_403).await; + let client = client_via(&relay, &adapter); + for (path, err, attempts) in every_path_once(&client, &mints).await { + assert!(matches!(err, CliError::Auth(_)), "{path}: {err:?}"); + assert!(!crate::error::is_retryable_error(&err), "{path}"); + assert_eq!(attempts, 1, "{path}: one mint"); + } + assert!( + log.lock().unwrap().requests.is_empty(), + "the relay saw a request" + ); + } + + /// A relay redirect to another origin is never followed: the assertion, + /// proof and auth tag stay with the relay that was asked. + #[tokio::test] + async fn a_cross_origin_relay_redirect_is_not_followed() { + let (target, target_log) = origin(None).await; + let location = format!("{target}/query"); + let app = axum::Router::new().fallback(move || { + let location = location.clone(); + async move { (StatusCode::TEMPORARY_REDIRECT, [("location", location)]) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // Mint from the target origin's adapter so the client has an assertion to leak. + let client = client_via(&relay, &target); + let body = serde_json::json!({"k": "v"}); + for err in [ + client.query(&serde_json::json!({})).await.unwrap_err(), + client.get_authed("/api/get").await.unwrap_err(), + client + .post_json_authed("/api/post", &body) + .await + .unwrap_err(), + ] { + assert!( + matches!(err, CliError::Relay { status: 307, .. }), + "{err:?}" + ); + } + let log = target_log.lock().unwrap(); + assert_eq!(log.mints, 1); + assert!( + log.requests.is_empty(), + "redirect followed: {:?}", + covered(&log) + ); + } + + /// A WebSocket refused after NIP-42 with a policy close (a NIP-FI key + /// mismatch) is an auth error, exit 3, not a generic failure. + #[tokio::test] + async fn a_websocket_policy_close_is_an_auth_error() { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + use tokio_tungstenite::tungstenite::Message; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await + .unwrap(); + let _auth = ws.next().await; + let _ = ws + .send(Message::Close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "auth-required: key mismatch".into(), + }))) + .await; + }); + let client = client(&relay, false); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&client.credentials().test_keys()) + .unwrap(); + let err = client.publish_ephemeral_event(event).await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("key mismatch")), + "{err:?}" + ); + assert_eq!(crate::error::exit_code(&err), 3); + assert!(!crate::error::is_retryable_error(&err)); + } +} + +/// A desktop that stays `rate_limited`, or throttles and then holds a +/// signature, through every command path that signs or encrypts. +#[cfg(all(test, unix))] +mod desktop_throttle_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use nostr::{EventBuilder, Kind}; + + use super::ws_deadline_tests::ws_relay; + use super::BuzzClient; + use crate::desktop_provider::{tests as dp, Provider}; + use crate::error::CliError; + + /// A desktop holding `keys` that answers `rate_limited` to `throttled`. + async fn client(relay: &str, keys: &nostr::Keys, throttled: &'static str) -> BuzzClient { + let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]); + let (endpoint, _) = dp::provider(move |version, request| { + if request["method"] == throttled { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + BuzzClient::new(relay.into(), credentials).unwrap() + } + + /// A desktop user's 401 and 403 hints name what they can act on in Buzz, + /// not the headless settings. + #[tokio::test] + async fn desktop_users_get_hints_for_the_desktop() { + let app = axum::Router::new().fallback(|req: axum::extract::Request| async move { + let status = if req.uri().path() == "/forbidden" { + 403 + } else { + 401 + }; + ( + axum::http::StatusCode::from_u16(status).unwrap(), + r#"{"error":"no"}"#, + ) + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let keys = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &keys.public_key(), "").unwrap(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]); + let (endpoint, _) = + dp::provider(move |version, request| match request["method"].as_str() { + Some("federated_identity") => serde_json::json!({"result": ""}), + Some("auth_tag") => serde_json::json!({"result": tag}), + Some("describe") => { + let mut described = answer(version, request); + let mut body: serde_json::Value = + serde_json::from_str(described["result"].as_str().unwrap()).unwrap(); + body["methods"] + .as_array_mut() + .unwrap() + .push("auth_tag".into()); + described["result"] = body.to_string().into(); + described + } + _ => answer(version, request), + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let client = BuzzClient::new(relay, credentials).unwrap(); + + let err = client.get_authed("/api").await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("in Buzz")), + "{err:?}" + ); + let err = client.get_authed("/forbidden").await.unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("in Buzz") && !text.contains("BUZZ_AUTH_TAG"), + "{text}" + ); + } + + /// A desktop holding `keys` whose assertions live `lifetime` s and + /// which answers `sign_event` with `sign` (`None`: sign it), logging the + /// sign-in challenges it signs. + async fn desktop( + relay: &str, + keys: &nostr::Keys, + assertion: impl Fn() -> String + Send + Sync + 'static, + sign: impl Fn(&nostr::UnsignedEvent) -> Option + Send + Sync + 'static, + ) -> (BuzzClient, super::ws_deadline_tests::Log) { + let signed: super::ws_deadline_tests::Log = Default::default(); + let log = signed.clone(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.into()]); + let (endpoint, _) = + dp::provider(move |version, request| match request["method"].as_str() { + Some("federated_identity") => { + serde_json::json!({ "result": assertion() }) + } + Some("sign_event") => { + let unsigned = ::from_json( + request["params"][0].as_str().unwrap(), + ) + .unwrap(); + if let Some(refusal) = sign(&unsigned) { + return refusal; + } + if let Some(c) = unsigned.tags.find(nostr::TagKind::Challenge) { + log.lock().unwrap().push(c.content().unwrap().to_owned()); + } + answer(version, request) + } + _ => answer(version, request), + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + (BuzzClient::new(relay.into(), credentials).unwrap(), signed) + } + + /// A desktop allowing one sign-in signature per 300 ms against a relay + /// that drops unsigned sockets after 200 ms: the throttle is waited out + /// between connections, so only the live challenge is signed, with a + /// fresh assertion on the reconnect after the old one expired. + #[tokio::test] + async fn a_throttled_sign_in_reconnects_with_fresh_evidence() { + use super::ws_deadline_tests::deadline_relay; + let keys = nostr::Keys::generate(); + let (relay, conns, _, bearers) = + deadline_relay(std::time::Duration::from_millis(200)).await; + let last = std::sync::Mutex::new(std::time::Instant::now()); + let (c, signed) = desktop(&relay, &keys, || dp::assertion(40), move |_| { + let mut last = last.lock().unwrap(); + if last.elapsed() < std::time::Duration::from_millis(300) { + crate::cached_assertion::test_clock::advance(60); + return Some( + serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}), + ); + } + *last = std::time::Instant::now(); + None + }) + .await; + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(conns.load(Ordering::SeqCst), 2); + assert_eq!( + *signed.lock().unwrap(), + ["c1"], + "the dead challenge is never signed" + ); + let bearers = bearers.lock().unwrap(); + assert_ne!( + bearers[0], bearers[1], + "the reconnect brings a fresh assertion" + ); + } + + /// An HTTP relay logging each request's NIP-FI assertion. + async fn logging_relay() -> (String, super::ws_deadline_tests::Log) { + let seen: super::ws_deadline_tests::Log = Default::default(); + let log = seen.clone(); + let app = axum::Router::new().fallback(move |headers: axum::http::HeaderMap| { + let bearer = headers["nostr-federated-identity"].to_str().unwrap(); + log.lock() + .unwrap() + .push(bearer.trim_start_matches("Bearer ").to_owned()); + async { axum::Json(serde_json::json!([])) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (relay, seen) + } + + /// A NIP-98 signature that outlives the assertion is signed again after + /// fresh evidence, and goes out with both current. + #[tokio::test] + async fn a_slow_http_signature_goes_out_with_fresh_evidence() { + let (relay, seen) = logging_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + || dp::assertion(40), + move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(60); + } + None + }, + ) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!(signs.load(Ordering::SeqCst), 2, "the proof is signed again"); + let sent = crate::cached_assertion::jws_exp(&seen.lock().unwrap()[0]).unwrap(); + assert!( + sent > crate::cached_assertion::unix_now(), + "the assertion sent is current" + ); + } + + /// A post-sign refresh that waits and returns the same assertion still + /// gets a proof signed after it. + #[tokio::test] + async fn a_slow_refresh_of_the_same_assertion_re_signs_the_proof() { + let (relay, seen) = logging_relay().await; + let exp = crate::cached_assertion::unix_now() + 600; + let fetches = AtomicUsize::new(0); + let same = move || { + if fetches.fetch_add(1, Ordering::SeqCst) == 1 { + // The desktop waits out throttling before it answers. + crate::cached_assertion::test_clock::advance(45); + } + dp::assertion(exp - crate::cached_assertion::unix_now()) + }; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop(&relay, &nostr::Keys::generate(), same, move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(310); + } + None + }) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!( + signs.load(Ordering::SeqCst), + 2, + "the aged proof is signed again" + ); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// A signature slower than a proof's lifetime is signed again even when + /// the evidence did not change. + #[tokio::test] + async fn a_slow_signature_with_unchanged_evidence_is_signed_again() { + let (relay, seen) = logging_relay().await; + let exp = crate::cached_assertion::unix_now() + 3600; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + move || dp::assertion(exp - crate::cached_assertion::unix_now()), + move |_| { + if n.fetch_add(1, Ordering::SeqCst) == 0 { + crate::cached_assertion::test_clock::advance(65); + } + None + }, + ) + .await; + c.query(&serde_json::json!({})).await.unwrap(); + assert_eq!( + signs.load(Ordering::SeqCst), + 2, + "the stale proof is signed again" + ); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// Evidence that keeps expiring while the proof is signed ends retryable + /// after three rounds, sending nothing. + #[tokio::test] + async fn evidence_that_keeps_expiring_ends_retryable() { + let (relay, seen) = logging_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let n = signs.clone(); + let (c, _) = desktop( + &relay, + &nostr::Keys::generate(), + || dp::assertion(40), + move |_| { + n.fetch_add(1, Ordering::SeqCst); + crate::cached_assertion::test_clock::advance(60); + None + }, + ) + .await; + let err = c.query(&serde_json::json!({})).await.unwrap_err(); + assert!(matches!(err, CliError::Unavailable(_)), "{err:?}"); + assert_eq!(signs.load(Ordering::SeqCst), 3); + assert!(seen.lock().unwrap().is_empty()); + } + + /// An HTTP relay answering every query with no events. + async fn http_relay() -> String { + let app = axum::Router::new().fallback(|| async { axum::Json(serde_json::json!([])) }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + relay + } + + fn assert_throttled(err: CliError) { + assert!(matches!(err, CliError::Throttled(_)), "{err:?}"); + assert!(crate::error::is_retryable_error(&err)); + assert_eq!(crate::error::exit_code(&err), 2); + } + + #[tokio::test] + async fn persistent_throttling_is_retryable_on_every_signing_path() { + let keys = nostr::Keys::generate(); + let relay = http_relay().await; + let c = client(&relay, &keys, "sign_event").await; + + // NIP-98 on an ordinary request. + assert_throttled(c.query(&serde_json::json!({})).await.unwrap_err()); + // Event signing. + assert_throttled( + c.sign_event(EventBuilder::new(Kind::TextNote, "x")) + .await + .unwrap_err(), + ); + + // Memory encryption. + let c = client(&relay, &keys, "nip44_encrypt").await; + let owner = nostr::Keys::generate().public_key().to_hex(); + assert_throttled( + crate::commands::mem::cmd_set(&c, "mem/a", "v", Some(&owner), false) + .await + .unwrap_err(), + ); + } + + #[tokio::test] + async fn persistent_throttling_during_nip42_is_retryable() { + let keys = nostr::Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let c = client(&relay, &keys, "sign_event").await; + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + assert_throttled(c.publish_ephemeral_event(event).await.unwrap_err()); + assert_eq!(auths.load(Ordering::SeqCst), 0); + assert_eq!(events.load(Ordering::SeqCst), 0); + } + + /// NIP-42 signing that is throttled, then held for a decision, completes + /// the publish: provider time is outside the relay's deadlines, and the + /// relay sees one AUTH and one EVENT. + #[tokio::test(flavor = "multi_thread")] + async fn a_throttled_then_held_nip42_signature_completes_without_replay() { + let keys = nostr::Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let signs = Arc::new(AtomicUsize::new(0)); + let counted = signs.clone(); + let answer = dp::desktop_answers(keys.clone(), vec![relay.clone()]); + let (endpoint, _) = dp::provider(move |version, request| { + if request["method"] == "sign_event" { + if counted.fetch_add(1, Ordering::SeqCst) == 0 { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 30}); + } + // Held for a person's decision. + std::thread::sleep(std::time::Duration::from_secs(2)); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let c = BuzzClient::new(relay, credentials).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(signs.load(Ordering::SeqCst), 2); + assert_eq!(auths.load(Ordering::SeqCst), 1); + assert_eq!(events.load(Ordering::SeqCst), 1); + } +} + +/// WebSocket publish deadlines, on a paused clock: a signer may take as long +/// as it needs, while each network phase, writes included, is capped. +#[cfg(test)] +mod ws_deadline_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + use futures_util::{SinkExt, StreamExt}; + use nostr::{Event, EventBuilder, Keys, Kind, NostrSigner, PublicKey, UnsignedEvent}; + use tokio_tungstenite::tungstenite::Message; + + use super::BuzzClient; + use crate::credentials::Credentials; + use crate::error::CliError; + + /// A WebSocket relay that sends a challenge, then OKs AUTH and one event. + /// Returns its URL and the count of AUTH and EVENT frames it received. + pub(super) async fn ws_relay() -> (String, Arc, Arc) { + let (auths, events) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let (a, e) = (auths.clone(), events.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (a, e) = (a.clone(), e.clone()); + tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = ws + .send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await; + while let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let counter = if frame[0] == "AUTH" { &a } else { &e }; + counter.fetch_add(1, Ordering::SeqCst); + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + } + }); + } + }); + (relay, auths, events) + } + + /// Keys that take `delay` to sign, like a signer held for a decision. + /// The clock is paused only while signing (after the real connect), so + /// the delay passes at once unless an earlier timer fires first. + #[derive(Debug)] + struct Slow(Keys, Duration); + + /// Keys whose first signature takes `delay`, then sign at once. + #[derive(Debug)] + struct SlowFirst(Slow, std::sync::atomic::AtomicBool); + + impl NostrSigner for Slow { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + nostr::signer::SignerBackend::Custom("slow".into()) + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.0.get_public_key() + } + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { + tokio::time::pause(); + tokio::time::sleep(self.1).await; + tokio::time::resume(); + self.0.sign_event(unsigned).await + }) + } + fn nip04_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_encrypt(p, c) + } + fn nip04_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_decrypt(p, c) + } + fn nip44_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_encrypt(p, c) + } + fn nip44_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_decrypt(p, c) + } + } + + impl NostrSigner for SlowFirst { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + self.0.backend() + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.0.get_public_key() + } + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + if self.1.swap(false, Ordering::SeqCst) { + self.0.sign_event(unsigned) + } else { + self.0 .0.sign_event(unsigned) + } + } + fn nip04_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_encrypt(p, c) + } + fn nip04_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip04_decrypt(p, c) + } + fn nip44_encrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_encrypt(p, c) + } + fn nip44_decrypt<'a>( + &'a self, + p: &'a PublicKey, + c: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + self.0.nip44_decrypt(p, c) + } + } + + /// A relay that, like buzz-relay, drops a socket not signed in within + /// 5 s; otherwise as [`ws_relay`]. Returns its URL, its connection + /// count, and the challenges each AUTH answered. + /// A relay closing sockets not signed in within `deadline`: the + /// connection count, the challenges answered and each upgrade's NIP-FI + /// header. + #[allow(clippy::result_large_err)] // tungstenite's handshake callback type + pub(super) async fn deadline_relay(deadline: Duration) -> (String, Arc, Log, Log) { + let conns = Arc::new(AtomicUsize::new(0)); + let (answered, bearers): (Log, Log) = Default::default(); + let (n, log, seen) = (conns.clone(), answered.clone(), bearers.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let challenge = format!("c{}", n.fetch_add(1, Ordering::SeqCst)); + let (log, seen) = (log.clone(), seen.clone()); + tokio::spawn(async move { + let record = + |req: &tokio_tungstenite::tungstenite::handshake::server::Request, res| { + let bearer = req.headers().get("nostr-federated-identity"); + let bearer = bearer.map(|v| v.to_str().unwrap().to_owned()); + seen.lock().unwrap().push(bearer.unwrap_or_default()); + Ok(res) + }; + let mut ws = tokio_tungstenite::accept_hdr_async(stream, record) + .await + .unwrap(); + let hello = serde_json::json!(["AUTH", challenge]).to_string(); + let _ = ws.send(Message::Text(hello.into())).await; + let deadline = tokio::time::sleep(deadline); + tokio::pin!(deadline); + let mut authed = false; + loop { + let next = tokio::select! { + _ = &mut deadline, if !authed => return, + next = ws.next() => next, + }; + let Some(Ok(Message::Text(text))) = next else { + return; + }; + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + if frame[0] == "AUTH" { + let tags = frame[1]["tags"].as_array().unwrap(); + let c = tags.iter().find(|t| t[0] == "challenge").unwrap(); + log.lock().unwrap().push(c[1].as_str().unwrap().to_owned()); + authed = true; + } + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + } + }); + } + }); + (relay, conns, answered, bearers) + } + + pub(super) type Log = Arc>>; + + /// A sign-in signature that comes back after the relay's 5 s deadline + /// reconnects and signs the new challenge, never the old one. + #[tokio::test] + async fn a_late_sign_in_reconnects_and_signs_the_new_challenge() { + let keys = Keys::generate(); + let (relay, conns, answered, _) = deadline_relay(Duration::from_secs(5)).await; + let signer = Arc::new(SlowFirst( + Slow(keys.clone(), Duration::from_secs(8)), + std::sync::atomic::AtomicBool::new(true), + )); + let c = + BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + c.publish_ephemeral_event(event).await.unwrap(); + assert_eq!(conns.load(Ordering::SeqCst), 2); + assert_eq!( + *answered.lock().unwrap(), + ["c1"], + "only the new challenge is signed" + ); + } + + /// A signer that is always too slow ends retryable after the bound. + #[tokio::test] + async fn a_sign_in_always_too_late_ends_retryable() { + let keys = Keys::generate(); + let (relay, conns, ..) = deadline_relay(Duration::from_secs(5)).await; + let c = client(relay, &keys, Duration::from_secs(8)); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + let err = c.publish_ephemeral_event(event).await.unwrap_err(); + assert!(crate::error::is_retryable_error(&err), "{err:?}"); + assert_eq!( + conns.load(Ordering::SeqCst), + super::WS_SIGN_IN_ATTEMPTS as usize + ); + } + + fn client(relay: String, keys: &Keys, delay: Duration) -> BuzzClient { + let signer = Arc::new(Slow(keys.clone(), delay)); + BuzzClient::new(relay, Credentials::from_signer(signer, keys.public_key())).unwrap() + } + + /// Signing held for 300 s, past the old 225 s single cap, still publishes, + /// once. + #[tokio::test] + async fn a_signature_held_past_every_relay_cap_still_publishes_once() { + let keys = Keys::generate(); + let (relay, auths, events) = ws_relay().await; + let c = client(relay, &keys, Duration::from_secs(300)); + let event = EventBuilder::new(Kind::Custom(20001), "") + .sign_with_keys(&keys) + .unwrap(); + let started = tokio::time::Instant::now(); + c.publish_ephemeral_event(event).await.unwrap(); + assert!(started.elapsed() >= Duration::from_secs(300)); + assert!( + started.elapsed() < Duration::from_secs(330), + "real time stays short" + ); + assert_eq!(auths.load(Ordering::SeqCst), 1); + assert_eq!(events.load(Ordering::SeqCst), 1); + } + + /// A relay that OKs AUTH and then stops reading stalls the EVENT write; + /// the publish fails within its phase cap and is not sent again. + #[tokio::test] + async fn a_stalled_event_write_fails_within_its_phase_cap() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + let accepted = Arc::new(AtomicUsize::new(0)); + let count = accepted.clone(); + // The relay pauses the clock once it has OKed AUTH, so the EVENT + // phase runs on virtual time from that mark. + let authed = Arc::new(std::sync::Mutex::new(None)); + let mark = authed.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + count.fetch_add(1, Ordering::SeqCst); + let mark = mark.clone(); + tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = ws + .send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await; + if let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let ok = serde_json::json!(["OK", frame[1]["id"], true, ""]); + let _ = ws.send(Message::Text(ok.to_string().into())).await; + tokio::time::pause(); + *mark.lock().unwrap() = Some(tokio::time::Instant::now()); + } + // Hold the socket open without reading. + std::future::pending::<()>().await; + drop(ws); + }); + } + }); + let keys = Keys::generate(); + let c = BuzzClient::new(relay, Credentials::from_keys(keys.clone())).unwrap(); + let event = EventBuilder::new(Kind::Custom(20001), "x".repeat(32 * 1024 * 1024)) + .sign_with_keys(&keys) + .unwrap(); + // An unbounded write would wait here for the hour, not forever. + let err = tokio::time::timeout(Duration::from_secs(3600), c.publish_ephemeral_event(event)) + .await + .expect("the stalled write is bounded") + .unwrap_err(); + let stalled = authed.lock().unwrap().expect("AUTH was OKed").elapsed(); + // From the OK the client has at most the rest of the AUTH phase (the + // paused clock jumps to its read deadline before the OK is read) and + // the whole EVENT phase. + use buzz_ws_client::connection::{AUTH_OK_TIMEOUT_SECS, PUBLISH_OK_TIMEOUT_SECS}; + let cap = Duration::from_secs(AUTH_OK_TIMEOUT_SECS + 1 + PUBLISH_OK_TIMEOUT_SECS + 1); + assert!(stalled <= cap, "{stalled:?} {err:?}"); + assert!( + matches!(err, CliError::Other(ref m) if m.contains("Timeout")), + "{err:?}" + ); + assert_eq!(accepted.load(Ordering::SeqCst), 1); + } + + #[test] + fn auth_keeps_the_configured_relay_path() { + let client = BuzzClient::new( + "wss://host.example/team/".into(), + Credentials::from_keys(Keys::generate()), + ) + .unwrap(); + assert_eq!(client.ws_url, "wss://host.example/team/"); + assert_eq!(client.relay_url, "https://host.example/team"); } } diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index fc32edcb9f..9b98129254 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -18,14 +18,15 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { let owner = require_owner(client)?; let built = build_create( - client.keys(), + client.credentials(), &owner, CreateAgentDraft { channel_id: channel, display_name, system_prompt: read_or_stdin(&system_prompt)?, }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; @@ -55,7 +56,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { let owner = require_owner(client)?; let built = build_update( - client.keys(), + client.credentials(), &owner, UpdateAgentDraft { channel_id: channel, @@ -67,7 +68,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli model, respond_to: respond_to.map(RespondToArg::to_wire), }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; @@ -93,7 +95,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli admin, } => { validate_hex64(&target_pubkey)?; - let signer_hex = client.keys().public_key().to_hex(); + let signer_hex = client.public_key().to_hex(); let auth = resolve_auth( client, &target_pubkey, @@ -110,7 +112,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli auth.as_ref(), ) .map_err(|e| CliError::Usage(format!("invalid archive request: {e}")))?; - let event = client.sign_event_unchecked(builder)?; + let event = client.sign_event_unchecked(builder).await?; let event_id = event.id.to_hex(); client.submit_event(event).await?; println!( @@ -132,7 +134,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli admin, } => { validate_hex64(&target_pubkey)?; - let signer_hex = client.keys().public_key().to_hex(); + let signer_hex = client.public_key().to_hex(); let auth = resolve_auth( client, &target_pubkey, @@ -148,7 +150,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli auth.as_ref(), ) .map_err(|e| CliError::Usage(format!("invalid unarchive request: {e}")))?; - let event = client.sign_event_unchecked(builder)?; + let event = client.sign_event_unchecked(builder).await?; let event_id = event.id.to_hex(); client.submit_event(event).await?; println!( @@ -889,7 +891,11 @@ mod tests { fn test_client(base_url: &str) -> crate::client::BuzzClient { let keys = nostr::Keys::generate(); - crate::client::BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + crate::client::BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } fn kind0_response(signer_hex: &str) -> String { diff --git a/crates/buzz-cli/src/commands/channel_unread.rs b/crates/buzz-cli/src/commands/channel_unread.rs index 5d9ec21489..c4ba9df681 100644 --- a/crates/buzz-cli/src/commands/channel_unread.rs +++ b/crates/buzz-cli/src/commands/channel_unread.rs @@ -147,7 +147,7 @@ fn newest_by_d(events: Vec) -> HashMap { async fn member_channel_ids(client: &BuzzClient) -> Result, CliError> { let filter = json!({ "kinds": [KIND_GROUP_MEMBERS], - "#p": [client.keys().public_key().to_hex()], + "#p": [client.public_key().to_hex()], }); let events = client .query_all_bounded(filter, MEMBER_CHANNEL_LIMIT) @@ -241,7 +241,7 @@ async fn count_channel( .query_paginated(filter, bounds.events_per_channel) .await?; let capped = events.len() >= bounds.events_per_channel as usize; - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let unread = events .iter() .filter(|e| is_unread(e, &me, last_read_at, state)) @@ -454,7 +454,11 @@ mod tests { async fn world() -> World { let relay = Shared::default(); let url = fake_relay(relay.clone()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); World { client, relay, @@ -478,7 +482,7 @@ mod tests { } fn member(&self, channel: &str) { - let me = self.client.keys().public_key(); + let me = self.client.public_key(); let tags = vec![nostr::Tag::identifier(channel), nostr::Tag::public_key(me)]; self.push(self.signed(&Keys::generate(), 39002, tags, 1)); } @@ -509,7 +513,12 @@ mod tests { fn read_state(&self, slot: &str, contexts: Value, at: u64) { let plaintext = json!({"v": 1, "client_id": slot, "contexts": contexts}).to_string(); - self.push(slot_event(self.client.keys(), slot, &plaintext, at)); + self.push(slot_event( + &self.client.credentials().test_keys(), + slot, + &plaintext, + at, + )); } fn queries_for(&self, kind: u64) -> Vec { @@ -535,7 +544,7 @@ mod tests { #[tokio::test] async fn counts_unread_with_desktop_rules() { let mut w = world().await; - let me = w.client.keys().clone(); + let me = w.client.credentials().test_keys(); let other = w.other.clone(); for (id, name) in [(GEN, "general"), (DEV, "dev"), (OLD, "old")] { w.member(id); @@ -706,7 +715,12 @@ mod tests { w.member(GEN); w.channel(GEN, "general", &[]); w.read_state("a", json!({GEN: 1}), 1); - w.push(slot_event(w.client.keys(), "b", "garbage", 2)); + w.push(slot_event( + &w.client.credentials().test_keys(), + "b", + "garbage", + 2, + )); let err = unread(&w.client, &[], None, &BOUNDS).await.unwrap_err(); assert!(err.to_string().contains("unreadable"), "{err}"); diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index ee92017570..b8f4e8b196 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -36,7 +36,7 @@ pub async fn cmd_list_channels( let effective_limit = limit.unwrap_or(500); let events = if member == Some(true) { // Step 1: find channel IDs where we're a member (kind:39002) - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let member_filter = serde_json::json!({ "kinds": [39002], "#p": [my_pk], @@ -457,7 +457,7 @@ pub async fn cmd_restore_canvas( let builder = buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let our_id = event.id.to_hex(); let submit_result = client.submit_event(event).await; @@ -733,7 +733,7 @@ pub async fn cmd_create_channel( ) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; print_create_response(&resp, "channel_id", &channel_uuid.to_string()); Ok(()) @@ -1493,7 +1493,7 @@ pub async fn cmd_create_channel_from_template( // be selected. let owner = client .auth_tag_owner_hex() - .unwrap_or_else(|| client.keys().public_key().to_hex()); + .unwrap_or_else(|| client.public_key().to_hex()); let resolved = build_roster_resolution(client, &owner, &template.agents).await?; @@ -1519,7 +1519,7 @@ pub async fn cmd_create_channel_from_template( unlisted, ) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; let mut canvas_applied = false; @@ -1532,7 +1532,7 @@ pub async fn cmd_create_channel_from_template( // `Some("none")` to match the create-assertion convention. let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some("none")) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; Ok(()) } @@ -1554,7 +1554,7 @@ pub async fn cmd_create_channel_from_template( Some(buzz_sdk::MemberRole::Bot), ) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await?; Ok(()) } @@ -1707,7 +1707,7 @@ pub async fn cmd_update_channel( ) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1723,7 +1723,7 @@ pub async fn cmd_set_channel_topic( let builder = buzz_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1739,7 +1739,7 @@ pub async fn cmd_set_channel_purpose( let builder = buzz_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1751,7 +1751,7 @@ pub async fn cmd_join_channel(client: &BuzzClient, channel_id: &str) -> Result<( let builder = buzz_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1763,7 +1763,7 @@ pub async fn cmd_leave_channel(client: &BuzzClient, channel_id: &str) -> Result< let builder = buzz_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1775,7 +1775,7 @@ pub async fn cmd_archive_channel(client: &BuzzClient, channel_id: &str) -> Resul let builder = buzz_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1787,7 +1787,7 @@ pub async fn cmd_unarchive_channel(client: &BuzzClient, channel_id: &str) -> Res let builder = buzz_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1799,7 +1799,7 @@ pub async fn cmd_delete_channel(client: &BuzzClient, channel_id: &str) -> Result let builder = buzz_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1830,7 +1830,7 @@ pub async fn cmd_add_channel_member( let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1847,7 +1847,7 @@ pub async fn cmd_remove_channel_member( let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -1891,7 +1891,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), &content, ) .tags([]); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -1932,7 +1932,7 @@ pub async fn cmd_set_canvas( } .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -2294,8 +2294,11 @@ mod tests { let keys = nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid test key"); - BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None) - .expect("client construction should not fail") + BuzzClient::new( + "ws://localhost:3000".to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction should not fail") } #[tokio::test] @@ -3402,8 +3405,11 @@ mod tests { let keys = nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") .expect("valid test key"); - let client = BuzzClient::new(format!("http://{addr}"), keys, None, None) - .expect("client construction should not fail"); + let client = BuzzClient::new( + format!("http://{addr}"), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction should not fail"); let map = fetch_candidate_hints( &client, @@ -3520,7 +3526,11 @@ mod set_canvas_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn tag_value(event: &Value, key: &str) -> Option { @@ -3889,7 +3899,11 @@ mod restore_canvas_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// Return all filter objects from all /query calls flattened into one vec. @@ -4715,7 +4729,11 @@ mod restore_canvas_already_current_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// The already-current short-circuit must return Ok (exit 0) AND emit @@ -4803,7 +4821,11 @@ mod unlisted_support_tests { } fn client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn has_unlisted_true(event: &Value) -> bool { diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e411827..65df2bda56 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -6,7 +6,7 @@ use crate::validate::{parse_uuid, sdk_err, validate_hex64}; /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let limit = limit.unwrap_or(50).min(200); let filter = serde_json::json!({ "kinds": [41001], @@ -67,7 +67,7 @@ pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), .collect::, _>>()?; tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?); let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; // Try to extract relay-assigned channel_id from response message. @@ -101,7 +101,7 @@ pub async fn cmd_hide_dm(client: &BuzzClient, channel_id: &str) -> Result<(), Cl .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(buzz_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -118,7 +118,7 @@ pub async fn cmd_add_dm_member( validate_hex64(pubkey)?; let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/commands/emoji.rs b/crates/buzz-cli/src/commands/emoji.rs index 32aef2f927..0dc4388c0c 100644 --- a/crates/buzz-cli/src/commands/emoji.rs +++ b/crates/buzz-cli/src/commands/emoji.rs @@ -120,7 +120,7 @@ async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> { /// Fetch the caller's own current custom emoji set (latest kind:30030 under /// the d-tag, authored by the caller). Empty when none published yet. async fn fetch_own_emoji(client: &BuzzClient) -> Result, CliError> { - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [buzz_sdk::kind::KIND_EMOJI_SET], "#d": [CUSTOM_EMOJI_SET_D_TAG], @@ -147,7 +147,7 @@ async fn fetch_own_emoji(client: &BuzzClient) -> Result, CliErr async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> { let builder = buzz_sdk::build_custom_emoji_set(emojis) .map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -636,7 +636,11 @@ mod tests { use tokio::net::TcpListener; fn test_client(base_url: &str) -> BuzzClient { - BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } /// Fake relay: serves a `/query` endpoint returning the given JSON body, diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index 64d82d5714..c5f655ced7 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -14,7 +14,7 @@ pub async fn cmd_get_feed( types: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let limit = limit.unwrap_or(20).min(50); let mut filter = serde_json::json!({ diff --git a/crates/buzz-cli/src/commands/gifs.rs b/crates/buzz-cli/src/commands/gifs.rs index 0352d2cdb3..b09a9da216 100644 --- a/crates/buzz-cli/src/commands/gifs.rs +++ b/crates/buzz-cli/src/commands/gifs.rs @@ -40,37 +40,6 @@ pub(crate) fn safe_relay_path(path: &str) -> bool { && !path.split('/').any(|seg| seg == "." || seg == "..") } -// --------------------------------------------------------------------------- -// Customer ID derivation -// --------------------------------------------------------------------------- - -/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. -/// -/// KLIPY requires a per-installation identifier that is stable and anonymous. -/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the -/// input is public, so the ID is computable by any observer, and the same value -/// would appear across all relays (cross-relay linkability). -/// -/// Instead, we domain-separate with the relay URL and sign with the *secret* key: -/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` -/// This is: -/// - **stable**: deterministic given the same keypair + relay. -/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. -/// - **not computable from public data**: requires secret key material. -/// - **stateless**: no file I/O, no storage. -/// -/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for -/// KLIPY's per-installation needs. -fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(secret_key_bytes); - hasher.update(b"\0"); // domain separator - hasher.update(relay_url.as_bytes()); - let hash = hasher.finalize(); - hex::encode(&hash[..16]) // 16 bytes → 32 hex chars -} - // --------------------------------------------------------------------------- // Locale // --------------------------------------------------------------------------- @@ -336,10 +305,9 @@ pub(crate) async fn search_entries( locale: Option<&str>, ) -> Result, CliError> { let (search_path, _) = resolve_gif_descriptor(client).await?; - let cid = customer_id( - client.keys().secret_key().as_secret_bytes(), - client.relay_url(), - ); + let cid = client + .credentials() + .relay_scoped_customer_id(client.relay_url()); let locale = locale.map(|l| l.to_string()).unwrap_or_else(default_locale); let body = serde_json::json!({ @@ -358,10 +326,9 @@ pub(crate) async fn search_entries( /// `{"accepted": true}` on success. pub async fn cmd_share(client: &BuzzClient, slug: &str) -> Result<(), CliError> { let (_, share_path) = resolve_gif_descriptor(client).await?; - let cid = customer_id( - client.keys().secret_key().as_secret_bytes(), - client.relay_url(), - ); + let cid = client + .credentials() + .relay_scoped_customer_id(client.relay_url()); let body = serde_json::json!({ "slug": slug, @@ -429,51 +396,6 @@ mod tests { assert!(!safe_relay_path("//")); } - // ----------------------------------------------------------------------- - // customer_id - // ----------------------------------------------------------------------- - - #[test] - fn customer_id_is_32_hex_chars_and_stable() { - let sk = [0xab_u8; 32]; - let id = customer_id(&sk, "https://relay.example"); - assert_eq!(id.len(), 32); - assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - assert_eq!(id, customer_id(&sk, "https://relay.example")); - } - - #[test] - fn customer_id_is_relay_scoped() { - let sk = [0xcd_u8; 32]; - let id_a = customer_id(&sk, "https://relay-a.example"); - let id_b = customer_id(&sk, "https://relay-b.example"); - assert_ne!( - id_a, id_b, - "same key, different relay → different customer_id" - ); - } - - #[test] - fn customer_id_differs_for_different_keys() { - let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); - let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); - assert_ne!(id_a, id_b); - } - - #[test] - fn customer_id_not_equal_to_pubkey_hash() { - // The customer_id must NOT be derivable from the public key alone. - use sha2::{Digest, Sha256}; - let sk = [0xde_u8; 32]; - // What the old pubkey-hash approach would have produced (approximately): - let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); - let actual = customer_id(&sk, "https://relay.example"); - assert_ne!( - actual, naive_hash, - "customer_id must not equal SHA-256(pubkey_hex)[..16]" - ); - } - // ----------------------------------------------------------------------- // default_locale // ----------------------------------------------------------------------- @@ -783,7 +705,11 @@ mod tests { /// Client without an auth tag — used for basic NIP-98 / body / path tests. fn test_client(base_url: &str) -> BuzzClient { let keys = Keys::generate(); - BuzzClient::new(base_url.to_string(), keys, None, None).unwrap() + BuzzClient::new( + base_url.to_string(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } /// Client with a synthetic `x-auth-tag` — used to assert that the header @@ -803,9 +729,7 @@ mod tests { let tag = Tag::parse(tag_vec).unwrap(); let client = BuzzClient::new( base_url.to_string(), - keys, - Some(tag), - Some(tag_json.clone()), + crate::credentials::Credentials::from_keys(keys).with_auth_tag(tag, tag_json.clone()), ) .unwrap(); (client, tag_json) diff --git a/crates/buzz-cli/src/commands/huddles.rs b/crates/buzz-cli/src/commands/huddles.rs index db931b1691..e0aec693dd 100644 --- a/crates/buzz-cli/src/commands/huddles.rs +++ b/crates/buzz-cli/src/commands/huddles.rs @@ -763,7 +763,11 @@ mod tests { } fn client(base: &str) -> BuzzClient { - BuzzClient::new(base.to_string(), Keys::generate(), None, None).unwrap() + BuzzClient::new( + base.to_string(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } #[tokio::test] diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 7c90d47b42..ded3d42973 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -255,7 +255,7 @@ pub async fn cmd_create_issue( let builder = with_git_provenance( buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -366,7 +366,7 @@ async fn publish_issue_assignment_operation( owner: repo_owner.to_string(), id: repo_id.to_string(), }; - let signer = client.keys().public_key().to_hex(); + let signer = client.public_key().to_hex(); let is_self_service = assignees.len() == 1 && assignees[0].eq_ignore_ascii_case(&signer) && !signer.eq_ignore_ascii_case(repo_owner); @@ -398,7 +398,7 @@ async fn publish_issue_assignment_operation( } } .map(|builder| builder.custom_created_at(Timestamp::from_secs(context.created_at))); - let event = client.sign_event(builder.map_err(sdk_err)?)?; + let event = client.sign_event(builder.map_err(sdk_err)?).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -583,7 +583,7 @@ pub async fn cmd_issue_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/mem.rs b/crates/buzz-cli/src/commands/mem.rs index eb15921bd4..00485d6c14 100644 --- a/crates/buzz-cli/src/commands/mem.rs +++ b/crates/buzz-cli/src/commands/mem.rs @@ -19,9 +19,7 @@ use std::time::SystemTime; use sha2::{Digest, Sha256}; -use buzz_core::engram::{ - self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, -}; +use buzz_core::engram::{self, normalize_slug, select_head, Body, Listing}; use buzz_core::kind::KIND_AGENT_ENGRAM; use nostr::PublicKey; @@ -64,16 +62,16 @@ fn resolve_reader( } let agent = PublicKey::from_hex(agent) .map_err(|e| CliError::Usage(format!("--agent must be a 64-hex pubkey: {e}")))?; - if agent == client.keys().public_key() { + if agent == client.public_key() { return Err(CliError::Usage( "--agent must differ from the CLI identity; omit --agent for agent-side reads" .into(), )); } - return Ok((agent, client.keys().public_key(), agent)); + return Ok((agent, client.public_key(), agent)); } - let agent = client.keys().public_key(); + let agent = client.public_key(); let owner = resolve_owner(client, owner_flag)?; Ok((agent, owner, owner)) } @@ -132,6 +130,48 @@ fn parse_events(json: &str) -> Result, CliError> { Ok(out) } +/// NIP-AE read validation through the client's credentials: the envelope, +/// then NIP-44 decryption, then the slug re-deriving to the `d` tag. +/// `Ok(None)` means the event is not a valid engram and is skipped; `Err` is +/// a credential failure, which aborts rather than reading as "no entries". +async fn open_engram( + client: &BuzzClient, + ev: &nostr::Event, + agent: &PublicKey, + owner: &PublicKey, + their_pubkey: &PublicKey, +) -> Result, CliError> { + let Ok(d_value) = engram::validate_envelope(ev, agent, owner) else { + return Ok(None); + }; + let credentials = client.credentials(); + let Some(plaintext) = credentials.nip44_decrypt(their_pubkey, &ev.content).await? else { + return Ok(None); + }; + let Ok(body) = Body::from_json_bytes(plaintext.as_bytes()) else { + return Ok(None); + }; + let derived = credentials.engram_d_tag(their_pubkey, body.slug()).await?; + Ok(engram::check_d_tag(&d_value, &derived).ok().map(|()| body)) +} + +/// Encrypt `body` to `owner` and sign the `kind:30174` event with the +/// client's credentials (NIP-AE write path). +async fn seal_engram( + client: &BuzzClient, + owner: &PublicKey, + body: &Body, + created_at: u64, +) -> Result { + let build_failed = |e: engram::EngramError| CliError::Other(format!("build event failed: {e}")); + let credentials = client.credentials(); + let plaintext = engram::encode_body(body).map_err(build_failed)?; + let ciphertext = credentials.nip44_encrypt(owner, &plaintext).await?; + let d = credentials.engram_d_tag(owner, body.slug()).await?; + let builder = engram::event_builder(owner, &d, ciphertext, created_at).map_err(build_failed)?; + credentials.sign(builder).await +} + /// Fetch the head event for `slug`, returning `(Option, Option)`. async fn fetch_head( client: &BuzzClient, @@ -139,13 +179,15 @@ async fn fetch_head( owner: &PublicKey, slug: &str, ) -> Result<(Option, Option), CliError> { - let their_pubkey = if client.keys().public_key() == *agent { + let their_pubkey = if client.public_key() == *agent { owner } else { agent }; - let k_c = conversation_key(client.keys().secret_key(), their_pubkey); - let d = d_tag(&k_c, slug); + let d = client + .credentials() + .engram_d_tag(their_pubkey, slug) + .await?; let filter = serde_json::json!({ "kinds": [KIND_AGENT_ENGRAM], @@ -164,9 +206,8 @@ async fn fetch_head( if ev.verify().is_err() { continue; } - match validate_and_decrypt(&ev, agent, owner, client.keys().secret_key(), their_pubkey) { - Ok(body) => valid_with_body.push((ev, body)), - Err(_) => continue, + if let Some(body) = open_engram(client, &ev, agent, owner, their_pubkey).await? { + valid_with_body.push((ev, body)); } } if valid_with_body.is_empty() { @@ -219,15 +260,8 @@ pub async fn cmd_ls( else { continue; }; - let body = match validate_and_decrypt( - &ev, - &agent, - &owner, - client.keys().secret_key(), - &their_pubkey, - ) { - Ok(b) => b, - Err(_) => continue, + let Some(body) = open_engram(client, &ev, &agent, &owner, &their_pubkey).await? else { + continue; }; groups.entry(d_value).or_default().push((ev, body)); } @@ -357,14 +391,12 @@ pub async fn cmd_set( value: Some(value), } }; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, _) = fetch_head(client, &agent_pubkey, &owner, &slug).await?; let prior_created_at = head.map(|e| e.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("wrote {slug} (event {id}, created_at {created_at})"); @@ -598,7 +630,7 @@ pub async fn cmd_patch( }; let owner = resolve_owner(client, owner_flag)?; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, current) = fetch_value(client, &agent_pubkey, &owner, &slug).await?; // Base-hash gate: concurrent-edit safety. @@ -688,9 +720,7 @@ pub async fn cmd_patch( let prior_created_at = Some(head.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("wrote {slug} (event {id}, created_at {created_at}, sha256 {new_hash})"); @@ -720,14 +750,12 @@ pub async fn cmd_rm( slug: slug.clone(), value: None, }; - let agent_pubkey = client.keys().public_key(); + let agent_pubkey = client.public_key(); let (head, _) = fetch_head(client, &agent_pubkey, &owner, &slug).await?; let prior_created_at = head.map(|e| e.created_at.as_secs()); let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); - let agent = client.keys(); - let event = engram::build_event(agent, &owner, &body, created_at) - .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let event = seal_engram(client, &owner, &body, created_at).await?; let id = event.id.to_hex(); submit_engram(client, event).await?; eprintln!("tombstoned {slug} (event {id}, created_at {created_at})"); @@ -785,8 +813,134 @@ mod tests { // verify base-hash from the shell. Hard-coded vectors from the NIST and // common quick-check inputs. + /// Through the desktop, an engram with bad ciphertext is skipped while + /// a valid one opens; a credential failure aborts instead of reading as + /// "no entries". + #[cfg(unix)] + #[tokio::test] + async fn the_desktop_skips_bad_ciphertext_but_aborts_on_credential_failure() { + use crate::desktop_provider::{tests as dp, Provider}; + const RELAY: &str = "wss://community.example"; + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let body = Body::Memory { + slug: "mem/a".into(), + value: Some("v".into()), + }; + let valid = engram::build_event(&agent, &owner.public_key(), &body, 1).unwrap(); + let k_c = engram::conversation_key(agent.secret_key(), &owner.public_key()); + let corrupt = engram::event_builder( + &owner.public_key(), + &engram::d_tag(&k_c, "mem/a"), + "AAAA".into(), + 2, + ) + .unwrap() + .sign_with_keys(&agent) + .unwrap(); + let open = |endpoint| async move { + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(p) + .await + .unwrap(); + BuzzClient::new("http://127.0.0.1:9".into(), credentials).unwrap() + }; + let (endpoint, _) = dp::desktop(owner.clone(), vec![RELAY.into()]).await; + let client = open(endpoint).await; + let (a, o) = (agent.public_key(), owner.public_key()); + let opened = open_engram(&client, &valid, &a, &o, &a).await.unwrap(); + assert!(matches!(opened, Some(Body::Memory { .. }))); + assert!(open_engram(&client, &corrupt, &a, &o, &a) + .await + .unwrap() + .is_none()); + + let (endpoint, _) = dp::provider(move |_, r| match r["method"].as_str() { + Some("describe") => serde_json::json!({"result": serde_json::json!({ + "pubkey": owner.public_key().to_hex(), "relays": [RELAY], "methods": [], + }).to_string()}), + _ => serde_json::json!({"result": "", "error": "unavailable"}), + }) + .await; + let client = open(endpoint).await; + assert!(open_engram(&client, &valid, &a, &o, &a).await.is_err()); + } + + /// A desktop that throttles partway through `buzz mem ls` is waited out, + /// and the list completes. + #[cfg(unix)] + #[tokio::test] + async fn a_list_completes_when_the_desktop_throttles_partway() { + use crate::desktop_provider::{tests as dp, Provider}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let agent = nostr::Keys::generate(); + let owner = nostr::Keys::generate().public_key(); + let events: Vec = (0..3) + .map(|i| { + let body = Body::Memory { + slug: format!("mem/{i}"), + value: Some("v".into()), + }; + let ev = engram::build_event(&agent, &owner, &body, 1).unwrap(); + serde_json::to_value(&ev).unwrap() + }) + .collect(); + let app = axum::Router::new().route( + "/query", + axum::routing::post(move || async move { axum::Json(events) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // The second decrypt is refused once with `rate_limited`. + let decrypts = std::sync::Arc::new(AtomicUsize::new(0)); + let counted = decrypts.clone(); + let answer = dp::desktop_answers(agent.clone(), vec![relay.clone()]); + let (endpoint, seen) = dp::provider(move |version, request| { + if request["method"] == "nip44_decrypt" && counted.fetch_add(1, Ordering::SeqCst) == 1 { + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let provider = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(provider) + .await + .unwrap(); + let client = BuzzClient::new(relay, credentials).unwrap(); + + cmd_ls(&client, Some(&owner.to_hex()), None, true) + .await + .unwrap(); + assert_eq!( + decrypts.load(Ordering::SeqCst), + 4, + "three entries, one retry" + ); + let d_tags = seen + .lock() + .unwrap() + .iter() + .filter(|(_, _, r)| r["method"] == "engram_d_tag") + .count(); + assert_eq!(d_tags, 3); + } + fn test_client(keys: nostr::Keys) -> BuzzClient { - BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None).unwrap() + BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap() } #[test] diff --git a/crates/buzz-cli/src/commands/message_report.rs b/crates/buzz-cli/src/commands/message_report.rs index 55630df59e..26db0f265a 100644 --- a/crates/buzz-cli/src/commands/message_report.rs +++ b/crates/buzz-cli/src/commands/message_report.rs @@ -82,7 +82,7 @@ pub async fn cmd_report_message( } .map_err(|e| CliError::Usage(format!("invalid report: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client .submit_event(event) .await @@ -177,7 +177,11 @@ mod tests { } fn client(url: String) -> BuzzClient { - BuzzClient::new(url, Keys::generate(), None, None).unwrap() + BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap() } fn parse(args: &[&str]) -> Result { @@ -260,7 +264,11 @@ mod tests { let ok = r#"{"event_id":"e1","accepted":true,"message":""}"#; let (url, captured) = fake_relay(message_by(&me), StatusCode::OK, ok).await; - let client = BuzzClient::new(url, keys.clone(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(keys.clone()), + ) + .unwrap(); cmd_report_message( &client, ReportParams { @@ -282,7 +290,8 @@ mod tests { ); let (url, captured) = fake_relay(serde_json::json!([]), StatusCode::OK, ok).await; - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); cmd_report_message( &client, ReportParams { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 65dfdbe3ca..6e3d476823 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -769,7 +769,7 @@ pub async fn cmd_send_message( } }; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) @@ -865,7 +865,7 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re buzz_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref()) .map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -896,7 +896,7 @@ pub async fn cmd_delete_message( ) .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -919,7 +919,7 @@ pub async fn cmd_edit_message( let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content) .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -950,7 +950,7 @@ pub async fn cmd_vote_on_post( let builder = buzz_sdk::build_vote(channel_uuid, target_eid, vote_dir) .map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -1156,8 +1156,11 @@ mod tests { #[tokio::test] async fn malformed_channel_is_rejected_before_thread_fetch() { - let client = - BuzzClient::new("http://127.0.0.1:1".into(), Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + "http://127.0.0.1:1".into(), + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); let error = cmd_get_thread( &client, "not-a-uuid", @@ -1773,7 +1776,11 @@ mod tests { // Content contains `:wave:` which resolves in the palette. // The submitted event must carry an `emoji` tag for `wave`. let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); cmd_send_message(&client, send_params("hello :wave: everyone")) .await @@ -1825,7 +1832,11 @@ mod tests { // Content has no `:` at all — the palette query must be skipped // entirely (zero RTTs), and the submitted event must have no emoji tags. let (url, query_count, captured_event) = fake_send_relay(send_palette_response()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); cmd_send_message(&client, send_params("plain message no colons")) .await @@ -1904,7 +1915,11 @@ mod tests { let addr: StdSocketAddr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let url = format!("http://{addr}"); - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); // Must not return Err — a palette failure is a soft warning. cmd_send_message(&client, send_params(":wave: message with emoji candidate")) @@ -2025,7 +2040,11 @@ mod tests { stored_message(&"c".repeat(64), other_channel, "elsewhere", &[]), ]) .await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); let send = |from_thread: &str, kind: Option| { let mut params = send_params("decision: ship it"); diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs index c53aecaf85..34393caebc 100644 --- a/crates/buzz-cli/src/commands/moderation.rs +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -42,7 +42,7 @@ async fn cmd_ban( let expiry = resolve_expiry(expires_in, expires_at); let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -52,7 +52,7 @@ async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { validate_hex64(pubkey)?; let builder = buzz_sdk::build_moderation_unban(pubkey) .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -70,7 +70,7 @@ async fn cmd_timeout( .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -80,7 +80,7 @@ async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError validate_hex64(pubkey)?; let builder = buzz_sdk::build_moderation_untimeout(pubkey) .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) @@ -96,7 +96,7 @@ async fn cmd_resolve( validate_hex64(report)?; let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason) .map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); Ok(()) diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be1..9c58dd2196 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -166,7 +166,7 @@ fn parse_events(json: &str) -> Result, CliError> { /// needs its presence to decide first-publish vs. update / deletable-or-not. /// (Quinn's option (b) — single tag-parser, isolated.) pub async fn fetch_own_note(client: &BuzzClient, slug: &str) -> Result, CliError> { - let me = client.keys().public_key(); + let me = client.public_key(); let filter = serde_json::json!({ "kinds": [KIND_LONG_FORM], "authors": [me.to_hex()], @@ -203,7 +203,7 @@ pub async fn fetch_by_slug(client: &BuzzClient, slug: &str) -> Result /// kind:0 profiles. Exact-one match required; ambiguity is a hard error. pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result { if author_flag == "me" { - return Ok(client.keys().public_key()); + return Ok(client.public_key()); } if validate_hex64(author_flag).is_ok() { return PublicKey::from_hex(author_flag) @@ -539,7 +539,7 @@ pub async fn cmd_set( now_secs(), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id; let me = event.pubkey; let raw = client.submit_event(event).await?; @@ -718,7 +718,7 @@ pub async fn cmd_rm(client: &BuzzClient, slug: &str) -> Result<(), CliError> { // Read-before-write: only the author can delete their own note, and we // want a clear "nothing to delete" signal rather than emitting a kind:5 // for a coordinate that was never published. - let me = client.keys().public_key(); + let me = client.public_key(); if fetch_own_note(client, slug).await?.is_none() { return Err(CliError::NotFound(format!( "no note {slug:?} found for you ({}); nothing to delete", @@ -728,7 +728,7 @@ pub async fn cmd_rm(client: &BuzzClient, slug: &str) -> Result<(), CliError> { let coord = coord_for(&me, slug); let builder = build_rm_event(&coord)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id; let raw = client.submit_event(event).await?; let parsed: serde_json::Value = serde_json::from_str(&raw) diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 413934a3c1..283cfa43e3 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -50,7 +50,7 @@ pub async fn cmd_send_patch( let builder = with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -184,7 +184,7 @@ pub async fn cmd_patch_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 74c580a6d6..47a76357e8 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -59,7 +59,7 @@ pub async fn cmd_open_pr( let builder = with_git_provenance( buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let event_id = event.id.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -107,7 +107,7 @@ pub async fn cmd_update_pr( let builder = with_git_provenance( buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -217,7 +217,7 @@ pub async fn cmd_pr_status( let builder = with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index 887239f704..4a04bcf977 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -34,7 +34,7 @@ pub async fn resolve_or_ensure_repo_for_channel( return Ok(repo); } - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); if let Some(repo) = repos.iter().find_map(|event| { event .pubkey @@ -201,7 +201,7 @@ async fn ensure_default_repo( let repo_id = repo_id_from_project_slug(&slug)?; let name = project_name(project).unwrap_or_else(|| slug.clone()); let name = truncate_repo_name(&name); - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); if let Some(existing) = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await? @@ -223,7 +223,7 @@ async fn ensure_default_repo( &[], Some(channel), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; let winner = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await?; verify_default_repo_write(&raw, winner.as_ref(), channel)?; @@ -474,7 +474,11 @@ mod tests { socket.write_all(response.as_bytes()).await.unwrap(); } }); - let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap(); let result = ensure_default_repo(&client, requested, &project).await; diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 3edad04b41..949ffa661c 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -47,7 +47,7 @@ async fn cmd_add_channel_draft( let owner = PublicKey::parse(&owner_hex) .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; let built = build_project_channel( - client.keys(), + client.credentials(), &owner, CreateProjectChannelDraft { home_channel_id: home_channel, @@ -57,7 +57,8 @@ async fn cmd_add_channel_draft( ttl_seconds, template_name, }, - )?; + ) + .await?; let response = client.publish_ephemeral_event(built.event).await?; let mut output: serde_json::Value = serde_json::from_str(&response) .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; @@ -173,7 +174,7 @@ pub async fn add_repos_to_own_project( repos: &[String], ) -> Result { validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); let new_members: Vec = repos .iter() @@ -222,7 +223,7 @@ pub async fn add_repos_to_own_project( } let builder = rebuild_project(&head.content, tags, next_ts)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; client.submit_event(event).await } @@ -233,7 +234,7 @@ pub async fn try_add_own_repo_to_channel_project( repo_id: &str, ) -> Result<(), CliError> { let projects = fetch_projects_for_channel(client, channel).await?; - let caller = client.keys().public_key().to_hex(); + let caller = client.public_key().to_hex(); let Some(event) = projects.iter().find(|candidate| { candidate.pubkey.to_hex().eq_ignore_ascii_case(&caller) && !project_is_unlisted(candidate) }) else { @@ -259,7 +260,7 @@ async fn fetch_project( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let filter = serde_json::json!({ "kinds": [KIND_PROJECT], @@ -306,7 +307,7 @@ async fn submit_project( builder: EventBuilder, link_slug: Option<&str>, ) -> Result<(), CliError> { - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let owner = event.pubkey.to_hex(); let raw = client.submit_event(event).await?; let response = parse_write_response(&raw, "project changed concurrently; retry")?; @@ -369,7 +370,7 @@ pub async fn cmd_create( // ── Local validation (all checks before any .await) ─────────────────── validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); // Expand and validate repo coordinates. let mut members: Vec = repos @@ -492,7 +493,7 @@ pub async fn cmd_list( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let mut filter = serde_json::json!({ "kinds": [KIND_PROJECT], @@ -525,7 +526,7 @@ pub async fn cmd_remove_repo( repos: &[String], ) -> Result<(), CliError> { validate_project_slug(slug)?; - let caller_pubkey = client.keys().public_key().to_hex(); + let caller_pubkey = client.public_key().to_hex(); // ── Local validation before any .await ──────────────────────────────── let to_remove: Vec = repos @@ -697,12 +698,12 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; let next_ts = next_timestamp(&head, Timestamp::now())?; - let pubkey_hex = client.keys().public_key().to_hex(); + let pubkey_hex = client.public_key().to_hex(); let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? .custom_created_at(next_ts); - let event = client.sign_event(tombstone)?; + let event = client.sign_event(tombstone).await?; let raw = client.submit_event(event).await?; parse_write_response(&raw, "delete event was dominated; a newer head exists")?; @@ -742,7 +743,7 @@ async fn ensure_default_create_repo( &[], Some(channel), )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; let winner = fetch_own_repo_announcement(client, &repo_id).await?; verify_default_repo_write(&raw, winner.as_ref(), channel)?; @@ -956,7 +957,11 @@ mod tests { socket.write_all(response.as_bytes()).await.unwrap(); } }); - let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(keys), + ) + .unwrap(); let result = cmd_create( &client, "app", @@ -1029,8 +1034,11 @@ mod tests { .await .unwrap(); }); - let client = - crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); let projects = fetch_projects_for_channel(&client, channel).await.unwrap(); assert!(projects.is_empty()); @@ -1077,8 +1085,11 @@ mod tests { ); socket.write_all(response.as_bytes()).await.unwrap(); }); - let client = - crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap(); + let client = crate::client::BuzzClient::new( + base_url, + crate::credentials::Credentials::from_keys(nostr::Keys::generate()), + ) + .unwrap(); let projects = fetch_projects_for_channel_bounded(&client, channel, 1) .await @@ -1518,8 +1529,11 @@ mod tests { // Port 9 is the discard protocol — any real connect will be refused // immediately, but the guard fires before the first await so this // never reaches the network. - let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) - .expect("client construction"); + let client = crate::client::BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction"); let err = cmd_update( &client, "my-slug", None, false, // name / clear_name @@ -1544,8 +1558,11 @@ mod tests { fn discard_client() -> crate::client::BuzzClient { let keys = nostr::Keys::generate(); - crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) - .expect("client construction") + crate::client::BuzzClient::new( + "http://127.0.0.1:9".into(), + crate::credentials::Credentials::from_keys(keys), + ) + .expect("client construction") } /// Creating without --repo or --channel must fail locally; the default diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d30131..f684a8beca 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -24,7 +24,7 @@ pub async fn cmd_add_reaction( .map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))? }; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -37,10 +37,8 @@ pub async fn cmd_remove_reaction( emoji: &str, ) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = client.keys(); - // Find our reaction event by querying kind:7 reactions on this event from us - let my_pk = keys.public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [7], "#e": [event_id], @@ -70,7 +68,7 @@ pub async fn cmd_remove_reaction( let builder = buzz_sdk::build_remove_reaction(reaction_eid) .map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/commands/read_state.rs b/crates/buzz-cli/src/commands/read_state.rs index 471ed6dc22..c7aad6260e 100644 --- a/crates/buzz-cli/src/commands/read_state.rs +++ b/crates/buzz-cli/src/commands/read_state.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; use buzz_core::kind::KIND_READ_STATE; -use nostr::nips::nip44; use nostr::Event; use serde_json::{json, Value}; @@ -53,7 +52,7 @@ impl ReadState { /// a relay without the extension is an error rather than a partial read. pub(crate) async fn fetch(client: &BuzzClient) -> Result { let community_id = discover_snapshot(client).await?; - let me = client.keys().public_key().to_hex(); + let me = client.public_key().to_hex(); let filter = json!({ "kinds": [KIND_READ_STATE], "authors": [me], @@ -61,7 +60,7 @@ pub(crate) async fn fetch(client: &BuzzClient) -> Result { }); let raw = client.query(&filter).await?; let events = snapshot_events(&raw, &community_id, &me)?; - merge(client.keys(), events) + merge(client.credentials(), events).await } fn cannot_prove_complete(detail: impl std::fmt::Display) -> CliError { @@ -200,8 +199,11 @@ fn decode(event: &Event, plaintext: &str) -> Result, /// Verify, decrypt and max-merge raw relay events. Events that are not read-state /// coordinates are ignored; a coordinate that cannot be read is an error. -pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result { - let me = keys.public_key(); +pub(crate) async fn merge( + credentials: &crate::credentials::Credentials, + raw: Vec, +) -> Result { + let me = credentials.public_key(); let mut heads: HashMap = HashMap::new(); for value in raw { let event: Event = serde_json::from_value(value) @@ -234,8 +236,10 @@ pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result = heads.into_iter().collect(); ordered.sort_by(|a, b| a.0.cmp(&b.0)); for (d_tag, event) in ordered { - let plaintext = nip44::decrypt(keys.secret_key(), &me, &event.content) - .map_err(|e| unreadable(&event, e))?; + let plaintext = credentials + .nip44_decrypt(&me, &event.content) + .await? + .ok_or_else(|| unreadable(&event, "not valid NIP-44 ciphertext for your key"))?; match decode(&event, &plaintext)? { Ok(contexts) => { for (key, ts) in contexts { @@ -253,10 +257,22 @@ pub(crate) fn merge(keys: &nostr::Keys, raw: Vec) -> Result) -> Result { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(super::merge( + &crate::credentials::Credentials::from_keys(keys.clone()), + raw, + )) + } + pub(crate) const COMMUNITY: &str = "5f0c1b9e-2d0b-4f4e-9a7c-0d6f3f3f3f3f"; pub(crate) fn snapshot_envelope(pubkey: &str, events: Vec) -> Value { @@ -432,7 +448,11 @@ pub(crate) mod tests { let url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); ( - BuzzClient::new(url, keys.clone(), None, None).unwrap(), + BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(keys.clone()), + ) + .unwrap(), bodies, ) } diff --git a/crates/buzz-cli/src/commands/repo_default_branch.rs b/crates/buzz-cli/src/commands/repo_default_branch.rs index 23aba9f542..24659df4ed 100644 --- a/crates/buzz-cli/src/commands/repo_default_branch.rs +++ b/crates/buzz-cli/src/commands/repo_default_branch.rs @@ -52,7 +52,7 @@ pub(super) async fn dispatch( } => (id, owner, Some((branch, expected_manifest))), }; crate::validate::validate_repo_id(&id)?; - let owner = owner.unwrap_or_else(|| client.keys().public_key().to_hex()); + let owner = owner.unwrap_or_else(|| client.public_key().to_hex()); crate::validate::validate_hex64(&owner)?; let path = format!("/git/{owner}/{id}/default-branch"); let result = match update { @@ -168,7 +168,8 @@ mod tests { }); let app = Router::new().route(&path, route); let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); let result = dispatch( ReposDefaultBranchCmd::Get { id: "demo".into(), @@ -276,7 +277,8 @@ mod tests { }), ); let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); - let client = BuzzClient::new(url, keys, None, None).unwrap(); + let client = + BuzzClient::new(url, crate::credentials::Credentials::from_keys(keys)).unwrap(); let result = crate::commands::repos::dispatch( crate::ReposCmd::DefaultBranch(ReposDefaultBranchCmd::Set { id: "demo".into(), diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 3e6b0ac15c..e408f7be85 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -20,7 +20,7 @@ pub(crate) async fn fetch_own_repo_announcement( ) -> Result, CliError> { let filter = serde_json::json!({ "kinds": [KIND_GIT_REPO_ANNOUNCEMENT], - "authors": [client.keys().public_key().to_hex()], + "authors": [client.public_key().to_hex()], "#d": [repo_id], "limit": 1, }); @@ -194,7 +194,7 @@ fn validate_write_response(raw: &str) -> Result { } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let raw = client.submit_event(event).await?; println!("{}", validate_write_response(&raw)?); Ok(()) @@ -260,7 +260,7 @@ pub async fn cmd_create_repo( relays, channel, )?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let owner = event.pubkey.to_hex(); let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in @@ -313,7 +313,7 @@ pub async fn cmd_list_repos( crate::validate::validate_hex64(pk)?; pk.to_string() } - None => client.keys().public_key().to_hex(), + None => client.public_key().to_hex(), }; let mut filter = serde_json::json!({ diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 89c028a8ae..e6db415b13 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -33,7 +33,7 @@ pub async fn cmd_publish_note( let builder = buzz_sdk::build_note(content, reply_id) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -61,7 +61,7 @@ pub async fn cmd_set_contact_list( let builder = buzz_sdk::build_contact_list(&contacts) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -175,7 +175,7 @@ pub async fn cmd_set_list( } let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 997cec4912..4b5a895a63 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -39,7 +39,7 @@ pub async fn cmd_get_users( return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); } - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let authors: Vec<&str> = if pubkeys.is_empty() { vec![my_pk.as_str()] } else { @@ -89,7 +89,7 @@ pub async fn cmd_get_users( fn effective_owner(client: &BuzzClient) -> String { client .auth_tag_owner_hex() - .unwrap_or_else(|| client.keys().public_key().to_hex()) + .unwrap_or_else(|| client.public_key().to_hex()) } fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result, CliError> { @@ -419,7 +419,7 @@ pub async fn cmd_set_profile( ) .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -431,7 +431,7 @@ pub async fn cmd_set_profile( async fn fetch_current_profile( client: &BuzzClient, ) -> Result, CliError> { - let my_pk = client.keys().public_key().to_hex(); + let my_pk = client.public_key().to_hex(); let filter = serde_json::json!({ "kinds": [0], "authors": [my_pk], @@ -510,7 +510,7 @@ pub(crate) fn presence_subject(event: &serde_json::Value) -> &str { /// publishes the event directly — bypassing the HTTP bridge. pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), CliError> { let builder = buzz_sdk::build_presence_update(status).map_err(crate::validate::sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.publish_ephemeral_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -590,7 +590,7 @@ async fn publish_status( let expires_at = expires.map(|value| parse_expiry(value, now)).transpose()?; let builder = buzz_sdk::build_user_status_expiring(text, emoji, expires_at) .map_err(crate::validate::sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; Ok(normalize_write_response(&resp)) } @@ -703,7 +703,7 @@ async fn read_statuses( } } if authors.is_empty() { - authors.push(client.keys().public_key().to_hex()); + authors.push(client.public_key().to_hex()); } let filter = serde_json::json!({ "kinds": [buzz_core::kind::KIND_USER_STATUS], @@ -853,7 +853,11 @@ mod tests { async fn setup() -> (BuzzClient, Shared) { let relay = Shared::default(); let url = fake_relay(relay.clone()).await; - let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + let client = BuzzClient::new( + url, + crate::credentials::Credentials::from_keys(Keys::generate()), + ) + .unwrap(); (client, relay) } @@ -978,7 +982,7 @@ mod tests { #[tokio::test] async fn status_read_hides_expired_and_cleared_and_reports_the_rest() { let (client, relay) = setup().await; - let me = client.keys().clone(); + let me = client.credentials().test_keys(); let (expired, cleared, emoji_only, odd, timed) = ( Keys::generate(), Keys::generate(), diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 0028dfc766..aa0f5f047b 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -106,7 +106,7 @@ pub async fn cmd_create_workflow( let workflow_id = uuid::Uuid::new_v4(); let builder = buzz_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition) .map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; let final_workflow_id = extract_relay_response_field(&resp, "workflow_id") @@ -141,7 +141,7 @@ pub async fn cmd_update_workflow( let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition, expected_revision) .map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -151,11 +151,9 @@ pub async fn cmd_update_workflow( /// Delete a workflow — sign and submit a kind:5 deletion event. pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> { let wf_uuid = parse_uuid(workflow_id)?; - let keys = client.keys(); - let builder = - buzz_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + buzz_sdk::build_workflow_delete(&client.public_key().to_hex(), wf_uuid).map_err(sdk_err)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); @@ -190,12 +188,12 @@ pub async fn cmd_trigger_workflow( &content, ) .tags(tags); - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); } else { let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); } @@ -217,7 +215,7 @@ pub async fn cmd_approve_step( let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes())); let builder = buzz_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?; - let event = client.sign_event(builder)?; + let event = client.sign_event(builder).await?; let resp = client.submit_event(event).await?; println!("{}", normalize_write_response(&resp)); diff --git a/crates/buzz-cli/src/credentials.rs b/crates/buzz-cli/src/credentials.rs new file mode 100644 index 0000000000..033ba022f8 --- /dev/null +++ b/crates/buzz-cli/src/credentials.rs @@ -0,0 +1,805 @@ +//! The CLI's credential chain: which identity it acts as, how it signs, and +//! which NIP-FI evidence it presents to the relay. +//! +//! Every relay request goes through [`Credentials`]. Signing and NIP-44 go +//! through a [`NostrSigner`], so a credential link never has to hand the CLI a +//! secret key. [`Credentials::evidence`] supplies the `Nostr-Federated-Identity` +//! assertion when an evidence source is configured and `None` otherwise, so one +//! code path serves relays with and without NIP-FI. +//! +//! [`resolve`] tries each link in order; the first configured link wins. An +//! explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first link, so a +//! caller that chooses a key always gets that key. + +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use nostr::{Event, EventBuilder, JsonUtil, Keys, NostrSigner, PublicKey, Tag, UnsignedEvent}; +use sha2::{Digest, Sha256}; + +use crate::error::CliError; +pub use crate::federated_identity::{ + AdapterSession, FEDERATED_IDENTITY_ADAPTER_ENV, FEDERATED_IDENTITY_TOKEN_ENV, +}; + +/// Raw credential settings, from flags and the environment. +#[derive(Default)] +pub struct Settings { + /// `--private-key` / `BUZZ_PRIVATE_KEY`. + pub private_key: Option, + /// `--auth-tag` / `BUZZ_AUTH_TAG` (NIP-OA owner attestation). + pub auth_tag: Option, + /// `BUZZ_FEDERATED_IDENTITY_TOKEN` (NIP-FA adapter session token). + pub federated_identity_token: Option, + /// `BUZZ_FEDERATED_IDENTITY_ADAPTER` (NIP-FA adapter base URL). + pub federated_identity_adapter: Option, +} + +impl Settings { + /// Read the evidence-source settings from the environment. Flags for the + /// session token are deliberately absent: command lines are visible to + /// other local users. + pub fn with_env_evidence(mut self) -> Self { + let non_empty = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty()); + self.federated_identity_token = non_empty(FEDERATED_IDENTITY_TOKEN_ENV); + self.federated_identity_adapter = non_empty(FEDERATED_IDENTITY_ADAPTER_ENV); + self + } +} + +/// A verified NIP-OA auth tag and its canonical JSON for the `x-auth-tag` header. +pub struct AuthTag { + pub tag: Tag, + pub json: String, +} + +/// The identity the CLI acts as for this process. +pub struct Credentials { + public_key: PublicKey, + signer: Arc, + /// Key material, present only when the link holds the key locally. + local_keys: Option, + auth_tag: Option, + evidence: Option, + /// The Buzz desktop, when it is the link that holds the key. + #[cfg(unix)] + desktop: Option>, +} + +/// Walk the credential chain for `relay_url` and return the first link that +/// applies: the explicit key, then (on Unix) the Buzz desktop through an +/// inherited or paired token. +pub async fn resolve(settings: Settings, relay_url: &str) -> Result { + if let Some(credentials) = resolve_explicit(&settings, relay_url)? { + return Ok(credentials); + } + #[cfg(unix)] + if let Some(provider) = crate::desktop_provider::discover(relay_url).await? { + if let Some(warning) = ignored_headless_settings(&settings) { + eprintln!("{warning}"); + } + return Credentials::from_desktop(provider).await; + } + Err(CliError::Auth( + "no credentials: set BUZZ_PRIVATE_KEY, pass --private-key, or run \ + `buzz auth desktop pair` with Buzz running" + .into(), + )) +} + +/// The explicit-key link, with its headless evidence source. Without an +/// explicit key the evidence settings are not read: they belong to that key. +fn resolve_explicit(settings: &Settings, relay_url: &str) -> Result, CliError> { + if settings.private_key.is_none() { + return Ok(None); + } + let evidence = evidence_source(settings, relay_url)?; + explicit_key(settings, evidence) +} + +/// The warning for headless evidence settings a desktop link leaves unused. +#[cfg_attr(not(unix), allow(dead_code))] +fn ignored_headless_settings(settings: &Settings) -> Option { + let ignored: Vec<&str> = [ + ( + settings.federated_identity_token.is_some(), + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + settings.federated_identity_adapter.is_some(), + FEDERATED_IDENTITY_ADAPTER_ENV, + ), + ] + .into_iter() + .filter_map(|(set, name)| set.then_some(name)) + .collect(); + (!ignored.is_empty()).then(|| { + format!( + "warning: the Buzz desktop link supplies this identity's evidence; ignoring {} \ + (set BUZZ_PRIVATE_KEY to use them)", + ignored.join(" and ") + ) + }) +} + +/// Link 1: an explicit key from `--private-key` or `BUZZ_PRIVATE_KEY`. +fn explicit_key( + settings: &Settings, + evidence: Option, +) -> Result, CliError> { + let Some(raw) = settings.private_key.as_deref() else { + return Ok(None); + }; + let keys = + Keys::parse(raw).map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; + let auth_tag = settings + .auth_tag + .as_deref() + .filter(|input| !input.is_empty()) + .map(|input| parse_auth_tag(input, &keys.public_key())) + .transpose()?; + Ok(Some(Credentials { + auth_tag, + evidence, + ..Credentials::from_keys(keys) + })) +} + +/// The headless NIP-FI evidence source: a NIP-FA adapter session. Both +/// settings are required together; one without the other is a configuration +/// error rather than a silent fallback to no evidence. +fn evidence_source( + settings: &Settings, + relay_url: &str, +) -> Result, CliError> { + match ( + settings.federated_identity_token.as_deref(), + settings.federated_identity_adapter.as_deref(), + ) { + (None, None) => Ok(None), + (Some(token), Some(adapter)) => AdapterSession::new(adapter, token, relay_url).map(Some), + (Some(_), None) => Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_TOKEN_ENV} is set but {FEDERATED_IDENTITY_ADAPTER_ENV} is not" + ))), + (None, Some(_)) => Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_ADAPTER_ENV} is set but {FEDERATED_IDENTITY_TOKEN_ENV} is not" + ))), + } +} + +/// Parse and verify a NIP-OA auth tag for `public_key`. +/// +/// `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw +/// shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input +/// edge only. The SDK grammar and the `x-auth-tag` wire format stay strict +/// JSON; all validation and signature verification happen on the strict path. +fn parse_auth_tag(input: &str, public_key: &PublicKey) -> Result { + let json = crate::normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; + buzz_sdk::nip_oa::verify_auth_tag(&json, public_key).map_err(|e| { + CliError::Auth(format!( + "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", + public_key.to_hex() + )) + })?; + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + Ok(AuthTag { + tag, + json: canonical, + }) +} + +/// The credentials' signer for NIP-42, keeping a desktop signing failure as +/// a [`CliError`]: protocol code carries only its text, and a throttled +/// desktop must stay retryable. +#[derive(Debug)] +pub struct CapturingSigner { + signer: Arc, + #[cfg(unix)] + desktop: Option>, + failure: std::sync::Mutex)>>, +} + +impl CapturingSigner { + pub fn new(credentials: &Credentials) -> Self { + Self { + signer: credentials.signer.clone(), + #[cfg(unix)] + desktop: credentials.desktop.clone(), + failure: std::sync::Mutex::new(None), + } + } + + /// The desktop signing failure, if one happened, with how long to wait + /// before a fresh sign-in when the desktop throttled one. + pub fn take_failure(&self) -> Option<(CliError, Option)> { + self.failure.lock().ok()?.take() + } +} + +impl NostrSigner for CapturingSigner { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + self.signer.backend() + } + + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> { + self.signer.get_public_key() + } + + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return Box::pin(async move { + provider.sign_or_wait(unsigned).await.map_err(|e| { + let text = nostr::signer::SignerError::from(e.0.to_string()); + if let Ok(mut slot) = self.failure.lock() { + slot.get_or_insert(e); + } + text + }) + }); + } + self.signer.sign_event(unsigned) + } + + fn nip04_encrypt<'b>( + &'b self, + peer: &'b PublicKey, + content: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip04_encrypt(peer, content) + } + + fn nip04_decrypt<'b>( + &'b self, + peer: &'b PublicKey, + payload: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip04_decrypt(peer, payload) + } + + fn nip44_encrypt<'b>( + &'b self, + peer: &'b PublicKey, + content: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip44_encrypt(peer, content) + } + + fn nip44_decrypt<'b>( + &'b self, + peer: &'b PublicKey, + payload: &'b str, + ) -> nostr::util::BoxedFuture<'b, Result> { + self.signer.nip44_decrypt(peer, payload) + } +} + +impl Credentials { + /// Credentials for an in-memory key with no auth tag and no evidence source. + pub fn from_keys(keys: Keys) -> Self { + Self { + public_key: keys.public_key(), + signer: Arc::new(keys.clone()), + local_keys: Some(keys), + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + } + } + + /// Credentials signing through `signer` alone, for tests. + #[cfg(test)] + pub(crate) fn from_signer(signer: Arc, public_key: PublicKey) -> Self { + Self { + public_key, + signer, + local_keys: None, + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + } + } + + /// Credentials backed by the Buzz desktop: it signs, encrypts and supplies + /// NIP-FI evidence; the private key never reaches this process. + #[cfg(unix)] + pub(crate) async fn from_desktop( + provider: Arc, + ) -> Result { + let public_key = provider.public_key(); + let auth_tag = provider + .auth_tag() + .await? + .map(|json| parse_auth_tag(&json, &public_key)) + .transpose()?; + Ok(Self { + public_key, + signer: provider.clone(), + local_keys: None, + auth_tag, + evidence: None, + desktop: Some(provider), + }) + } + + /// Attach a verified NIP-OA auth tag. + #[cfg(test)] + pub fn with_auth_tag(mut self, tag: Tag, json: String) -> Self { + self.auth_tag = Some(AuthTag { tag, json }); + self + } + + /// The local keys behind a [`Self::from_keys`] credential. + #[cfg(test)] + pub fn test_keys(&self) -> Keys { + self.local_keys + .clone() + .expect("test credentials hold local keys") + } + + /// Attach a NIP-FA evidence source. + #[cfg(test)] + pub fn with_evidence(mut self, evidence: AdapterSession) -> Self { + self.evidence = Some(evidence); + self + } + + /// The public key this process acts as. + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + /// The NIP-OA auth tag, if configured. + pub fn auth_tag(&self) -> Option<&AuthTag> { + self.auth_tag.as_ref() + } + + /// The NIP-AE engram `d` tag for `slug` in the conversation with `peer`: + /// HMAC-SHA256 over the NIP-44 conversation key, so it needs the key + /// itself rather than a signature (`docs/nips/NIP-AE.md`). + pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.engram_d_tag(peer, slug).await; + } + let keys = self.local_keys.as_ref().ok_or_else(|| { + CliError::Auth( + "buzz mem needs a local key: set BUZZ_PRIVATE_KEY or pass --private-key".into(), + ) + })?; + let k_c = buzz_core::engram::conversation_key(keys.secret_key(), peer); + Ok(buzz_core::engram::d_tag(&k_c, slug)) + } + + /// A stable, anonymous, relay-scoped id for the GIF provider (see + /// [`customer_id`]). A link without local key material gets a random + /// per-process id instead, as Buzz Desktop does per install. + pub fn relay_scoped_customer_id(&self, relay_url: &str) -> String { + match self.local_keys.as_ref() { + Some(keys) => customer_id(keys.secret_key().as_secret_bytes(), relay_url), + None => { + static PROCESS_ID: std::sync::OnceLock = std::sync::OnceLock::new(); + PROCESS_ID + .get_or_init(|| uuid::Uuid::new_v4().simple().to_string()) + .clone() + } + } + } + + /// Whether the Buzz desktop is the link that holds the key. + pub fn is_desktop(&self) -> bool { + #[cfg(unix)] + return self.desktop.is_some(); + #[cfg(not(unix))] + false + } + + /// Whether a NIP-FI evidence source is configured. + pub fn has_evidence_source(&self) -> bool { + #[cfg(unix)] + if self.desktop.is_some() { + return true; + } + self.evidence.is_some() + } + + /// The NIP-FI assertion for the relay the credentials were resolved + /// for, or `None` when no evidence source is configured. + pub async fn evidence(&self) -> Result, CliError> { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.federated_identity().await; + } + match &self.evidence { + Some(source) => { + let keys = self.local_keys.as_ref().ok_or_else(|| { + CliError::Auth("a headless evidence source needs a local key".into()) + })?; + source.assertion(keys).await + } + None => Ok(None), + } + } + + /// Sign `builder` as this identity. Callers own the tags; auth-tag + /// injection is [`crate::client::BuzzClient::sign_event`]'s job. + pub async fn sign(&self, builder: EventBuilder) -> Result { + self.sign_unsigned(builder.build(self.public_key)).await + } + + /// Sign `unsigned` as this identity. A desktop failure keeps its own + /// error, so a throttled desktop stays retryable. + pub async fn sign_unsigned(&self, unsigned: UnsignedEvent) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.sign(unsigned).await; + } + let event = self + .signer + .sign_event(unsigned) + .await + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + if event.pubkey != self.public_key || event.verify().is_err() { + return Err(CliError::Auth( + "signer returned an event that is not validly signed by this identity".into(), + )); + } + Ok(event) + } + + /// NIP-44 encrypt `plaintext` to `peer` as this identity. + pub async fn nip44_encrypt( + &self, + peer: &PublicKey, + plaintext: &str, + ) -> Result { + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.nip44_encrypt(peer, plaintext).await; + } + self.signer + .nip44_encrypt(peer, plaintext) + .await + .map_err(|e| CliError::Other(format!("NIP-44 encryption failed: {e}"))) + } + + /// NIP-44 decrypt `payload` from `peer` as this identity. + /// NIP-44 decrypt `payload` from `peer`. `Ok(None)` means the payload is + /// not valid ciphertext for this key; `Err` means the signer itself failed. + /// A local key decides that itself, and the desktop reports it as + /// `invalid_request`; any other signer's failure is `Err`, since it cannot + /// be told apart from an unavailable credential. + pub async fn nip44_decrypt( + &self, + peer: &PublicKey, + payload: &str, + ) -> Result, CliError> { + if let Some(keys) = &self.local_keys { + return Ok(nostr::nips::nip44::decrypt(keys.secret_key(), peer, payload).ok()); + } + #[cfg(unix)] + if let Some(provider) = &self.desktop { + return provider.nip44_decrypt(peer, payload).await; + } + self.signer + .nip44_decrypt(peer, payload) + .await + .map(Some) + .map_err(|e| CliError::Auth(format!("NIP-44 decryption failed: {e}"))) + } + + /// Sign a NIP-98 HTTP auth event (kind:27235) for `method` + `url` and + /// return it base64-encoded, ready for a `Nostr ` header. + /// + /// The event carries the `u`, `method` and (with a body) `payload` tags, + /// plus a nonce so rapid identical requests don't trip replay guards. + pub async fn nip98( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + ) -> Result { + let event = self + .sign(crate::federated_identity::nip98_event(method, url, body)?) + .await?; + Ok(B64.encode(event.as_json().as_bytes())) + } +} + +/// Derive a stable, relay-scoped anonymous `customer_id` from secret key material. +/// +/// KLIPY requires a per-installation identifier that is stable and anonymous. +/// Using SHA-256 of the *public* key would be stable but NOT anonymous — the +/// input is public, so the ID is computable by any observer, and the same value +/// would appear across all relays (cross-relay linkability). +/// +/// Instead, we domain-separate with the relay URL and sign with the *secret* key: +/// `SHA-256(secret_key_bytes || '\0' || relay_url_bytes)` +/// This is: +/// - **stable**: deterministic given the same keypair + relay. +/// - **relay-scoped**: different relay → different ID, no cross-relay correlation. +/// - **not computable from public data**: requires secret key material. +/// - **stateless**: no file I/O, no storage. +/// +/// The first 16 bytes (32 hex chars) give 128 bits of uniqueness, ample for +/// KLIPY's per-installation needs. +fn customer_id(secret_key_bytes: &[u8], relay_url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(secret_key_bytes); + hasher.update(b"\0"); // domain separator + hasher.update(relay_url.as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) // 16 bytes → 32 hex chars +} + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // customer_id + // ----------------------------------------------------------------------- + + #[test] + fn customer_id_is_32_hex_chars_and_stable() { + let sk = [0xab_u8; 32]; + let id = customer_id(&sk, "https://relay.example"); + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(id, customer_id(&sk, "https://relay.example")); + } + + #[test] + fn customer_id_is_relay_scoped() { + let sk = [0xcd_u8; 32]; + let id_a = customer_id(&sk, "https://relay-a.example"); + let id_b = customer_id(&sk, "https://relay-b.example"); + assert_ne!( + id_a, id_b, + "same key, different relay → different customer_id" + ); + } + + #[test] + fn customer_id_differs_for_different_keys() { + let id_a = customer_id(&[0xaa_u8; 32], "https://relay.example"); + let id_b = customer_id(&[0xbb_u8; 32], "https://relay.example"); + assert_ne!(id_a, id_b); + } + + #[test] + fn customer_id_not_equal_to_pubkey_hash() { + // The customer_id must NOT be derivable from the public key alone. + let sk = [0xde_u8; 32]; + // What the old pubkey-hash approach would have produced (approximately): + let naive_hash = hex::encode(&Sha256::digest(hex::encode(sk).as_bytes())[..16]); + let actual = customer_id(&sk, "https://relay.example"); + assert_ne!( + actual, naive_hash, + "customer_id must not equal SHA-256(pubkey_hex)[..16]" + ); + } + + fn explicit(settings: Settings) -> Result { + resolve_explicit(&settings, "https://relay.example").map(|c| c.expect("explicit key")) + } + + fn settings(key: Option<&str>, token: Option<&str>, adapter: Option<&str>) -> Settings { + Settings { + private_key: key.map(str::to_owned), + auth_tag: None, + federated_identity_token: token.map(str::to_owned), + federated_identity_adapter: adapter.map(str::to_owned), + } + } + + #[test] + fn explicit_key_is_the_first_link() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_secret_hex(); + let credentials = explicit(settings(Some(&nsec), None, None)).unwrap(); + assert_eq!(credentials.public_key(), keys.public_key()); + assert!(!credentials.has_evidence_source()); + } + + #[test] + fn no_link_is_an_auth_error_naming_the_settings() { + assert!( + resolve_explicit(&settings(None, None, None), "https://relay.example") + .unwrap() + .is_none() + ); + } + + /// Headless evidence settings belong to the explicit key: without one + /// they are passed over (and named in a warning), not an error. + #[test] + fn headless_settings_without_a_key_are_passed_over_and_named() { + for (token, adapter) in [(Some("t"), None), (Some("t"), Some("https://a.example"))] { + let unused = settings(None, token, adapter); + assert!(resolve_explicit(&unused, "https://relay.example") + .unwrap() + .is_none()); + let warning = ignored_headless_settings(&unused).unwrap(); + assert!(warning.contains(FEDERATED_IDENTITY_TOKEN_ENV), "{warning}"); + assert_eq!( + warning.contains(FEDERATED_IDENTITY_ADAPTER_ENV), + adapter.is_some(), + "{warning}" + ); + } + assert!(ignored_headless_settings(&settings(None, None, None)).is_none()); + } + + /// The evidence source is bound to the relay when credentials are + /// resolved, so a relay NIP-FA cannot serve fails then, not mid-command. + #[test] + fn the_evidence_source_binds_its_relay_at_resolution() { + let nsec = Keys::generate().secret_key().to_secret_hex(); + let headless = settings(Some(&nsec), Some("t"), Some("https://adapter.example")); + assert!(resolve_explicit(&headless, "https://relay.example").is_ok()); + let err = resolve_explicit(&headless, "https://relay.example:80") + .err() + .unwrap(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + } + + #[test] + fn evidence_settings_must_come_together() { + let nsec = Keys::generate().secret_key().to_secret_hex(); + for (token, adapter, missing) in [ + (Some("t"), None, FEDERATED_IDENTITY_ADAPTER_ENV), + ( + None, + Some("https://adapter.example"), + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ] { + let err = explicit(settings(Some(&nsec), token, adapter)) + .err() + .unwrap(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains(missing)), + "{token:?} {adapter:?}: {err}" + ); + } + let credentials = explicit(settings( + Some(&nsec), + Some("t"), + Some("https://adapter.example"), + )) + .unwrap(); + assert!(credentials.has_evidence_source()); + } + + #[tokio::test] + async fn key_derived_operations_match_the_protocol_definitions() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let credentials = Credentials::from_keys(agent.clone()); + // NIP-AE: either side of the pair derives the same d tag. + let owner_side = buzz_core::engram::d_tag( + &buzz_core::engram::conversation_key(owner.secret_key(), &agent.public_key()), + "core", + ); + assert_eq!( + credentials + .engram_d_tag(&owner.public_key(), "core") + .await + .unwrap(), + owner_side + ); + assert_eq!( + credentials.relay_scoped_customer_id("https://a.example"), + customer_id(agent.secret_key().as_secret_bytes(), "https://a.example") + ); + } + + #[tokio::test] + async fn sign_rejects_events_from_another_key() { + #[derive(Debug)] + struct WrongKey(Keys); + impl NostrSigner for WrongKey { + fn backend(&self) -> nostr::signer::SignerBackend<'_> { + nostr::signer::SignerBackend::Custom("test".into()) + } + fn get_public_key( + &self, + ) -> nostr::util::BoxedFuture<'_, Result> + { + Box::pin(async move { Ok(self.0.public_key()) }) + } + fn sign_event( + &self, + unsigned: nostr::UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> + { + let other = Keys::generate(); + Box::pin(async move { + EventBuilder::new(unsigned.kind, unsigned.content) + .sign_with_keys(&other) + .map_err(nostr::signer::SignerError::backend) + }) + } + fn nip04_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip04_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip44_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + fn nip44_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> + { + Box::pin(async { Err(nostr::signer::SignerError::from("unsupported")) }) + } + } + + let keys = Keys::generate(); + let credentials = Credentials { + public_key: keys.public_key(), + signer: Arc::new(WrongKey(keys)), + local_keys: None, + auth_tag: None, + evidence: None, + #[cfg(unix)] + desktop: None, + }; + let err = credentials + .sign(EventBuilder::text_note("hi")) + .await + .err() + .unwrap(); + assert!(matches!(err, CliError::Auth(_)), "{err}"); + + // Without local key material: key-derived operations degrade or refuse. + let peer = Keys::generate().public_key(); + assert!(credentials.engram_d_tag(&peer, "core").await.is_err()); + let relay = "https://relay.example"; + let id = credentials.relay_scoped_customer_id(relay); + assert_eq!( + id, + credentials.relay_scoped_customer_id(relay), + "one id per process" + ); + assert_ne!( + id, + customer_id(&[0; 32], relay), + "no local key: a random id, not a key derivation" + ); + assert_eq!(id.len(), 32); + } +} diff --git a/crates/buzz-cli/src/desktop_provider.rs b/crates/buzz-cli/src/desktop_provider.rs new file mode 100644 index 0000000000..b0a4247768 --- /dev/null +++ b/crates/buzz-cli/src/desktop_provider.rs @@ -0,0 +1,2196 @@ +//! The desktop link of the credential chain: a running Buzz Desktop acts as +//! one identity over loopback (`docs/local-credential-provider.md`). +//! +//! The caller never holds the private key. It finds a provider through an +//! inherited `BUZZ_CREDENTIAL_PROVIDER` token or a paired token in the OS +//! keychain, and sends each request on a connection whose server side it has +//! confirmed belongs to the same OS user, for inherited and paired tokens +//! alike. +//! +//! Version 1 has no Windows transport, so the link is compiled for Unix only. + +use std::sync::Arc; +use std::time::Duration; + +use nostr::signer::{SignerBackend, SignerError}; +use nostr::{Event, JsonUtil, PublicKey, Tag, UnsignedEvent}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::error::CliError; + +pub const PROVIDER_ENV: &str = "BUZZ_CREDENTIAL_PROVIDER"; + +/// The protocol version this caller speaks. +const VERSION: u32 = 1; +const VERSION_HEADER: &str = "Buzz-Credential-Provider"; + +/// Methods the provider may hold open for a person's decision (up to 120s). +pub(crate) const HELD_TIMEOUT: Duration = Duration::from_secs(150); +const QUICK_TIMEOUT: Duration = Duration::from_secs(10); +/// `lsof` can take seconds on a busy Mac. +const SAME_USER_TIMEOUT: Duration = Duration::from_secs(5); +/// Every result is built from a request's params, and the desktop reads at +/// most 2 MiB of request body, so no result is larger than that in bytes. +/// Escaping a result as a JSON string grows it at most sixfold: a decrypted +/// NIP-44 plaintext (extended lengths have no 64 KiB ceiling) may be all +/// control characters, each becoming `\u00XX`. The 64 KiB on top covers a +/// signed event's added `id`, `pubkey` and `sig`, the envelope and the headers. +const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; +const MAX_RESPONSE_BYTES: usize = 6 * MAX_REQUEST_BYTES + 64 * 1024; + +/// `rate_limited` refuses before acting, so the same envelope is re-sent: +/// up to this many times, each after `retry_after` capped at the wait below. +const RATE_LIMIT_RETRIES: u32 = 4; +const RATE_LIMIT_MAX_WAIT_SECS: u64 = 30; +/// One `retry_after` second; tests shorten it so throttling stays fast. +const RETRY_AFTER_UNIT: Duration = if cfg!(test) { + Duration::from_millis(10) +} else { + Duration::from_secs(1) +}; + +/// A provider failure, by the contract's error code. +#[derive(Debug)] +pub enum ProviderError { + /// `revoked`: a paired token is dropped and the next paired one tried; + /// an inherited one fails the command. + Revoked, + /// No common protocol version: the provider does not apply. + NoCommonVersion, + /// `unknown_method`: degrade where the caller can. + UnknownMethod, + /// Any other code. Unrecognized codes are reported as `denied`. + Code { + code: &'static str, + message: Option, + retry_after: Option, + }, + /// The provider could not be reached or answered outside the contract. + Transport(String), + /// The same-user check ran out of time before anything was sent. + Unconfirmed, +} + +impl ProviderError { + fn into_cli(self, method: &str) -> CliError { + match self { + Self::Revoked => CliError::Auth( + "the Buzz desktop revoked this CLI's token; run `buzz auth desktop pair` again" + .into(), + ), + Self::NoCommonVersion => CliError::Auth( + "the Buzz desktop speaks no credential-provider version this CLI supports".into(), + ), + Self::UnknownMethod => CliError::Auth(format!( + "the Buzz desktop does not implement `{method}`; update Buzz" + )), + Self::Code { + code, + message, + retry_after, + } => { + let detail = message.map(|m| format!(": {m}")).unwrap_or_default(); + match code { + "approval_required" => { + CliError::Auth(format!("approve this request in Buzz, then retry{detail}")) + } + "rate_limited" => CliError::Throttled(format!( + "the Buzz desktop is rate-limiting this CLI; retry in {}s{detail}", + retry_after.unwrap_or(1) + )), + "unavailable" => { + CliError::Auth(format!("the Buzz desktop cannot act now{detail}")) + } + "invalid_request" => CliError::Other(format!( + "the Buzz desktop rejected `{method}` as malformed{detail}" + )), + _ => CliError::Auth(format!("the Buzz desktop refused `{method}`{detail}")), + } + } + Self::Transport(why) => CliError::Auth(format!("Buzz desktop: {why}")), + Self::Unconfirmed => CliError::Unavailable( + "Buzz desktop: the same-user check timed out; nothing was sent".into(), + ), + } + } +} + +/// `unsigned` dated now, if it is a short-lived auth proof (NIP-42, NIP-98 +/// or Blossom); a Blossom `expiration` moves by the same amount. +fn renewed(mut unsigned: UnsignedEvent) -> UnsignedEvent { + if !matches!(unsigned.kind.as_u16(), 22242 | 27235 | 24242) { + return unsigned; + } + let now = nostr::Timestamp::now(); + let shift = now.as_secs().saturating_sub(unsigned.created_at.as_secs()); + if shift == 0 { + return unsigned; + } + let tags = unsigned + .tags + .clone() + .to_vec() + .into_iter() + .map(|tag| match tag.as_slice() { + [name, exp] if name == "expiration" => match exp.parse::() { + Ok(exp) => Tag::parse(["expiration", &(exp + shift).to_string()]).unwrap_or(tag), + Err(_) => tag, + }, + _ => tag, + }); + unsigned.tags = nostr::Tags::from_list(tags.collect()); + unsigned.created_at = now; + unsigned.id = None; + unsigned.ensure_id(); + unsigned +} + +/// The contract's codes; anything else is `denied`. +fn known_code(code: &str) -> &'static str { + match code { + "approval_required" => "approval_required", + "rate_limited" => "rate_limited", + "unavailable" => "unavailable", + "invalid_request" => "invalid_request", + "unsupported_version" => "unsupported_version", + _ => "denied", + } +} + +fn held(method: &str) -> bool { + matches!(method, "sign_event" | "nip44_decrypt" | "pair") +} + +/// A provider endpoint: `http://127.0.0.1:/`. +#[derive(Clone, Debug)] +pub struct Endpoint { + port: u16, + path: String, +} + +impl Endpoint { + pub fn parse(raw: &str) -> Result { + let invalid = + || CliError::Auth("credential provider URL must be http://127.0.0.1:/…".into()); + let url = url::Url::parse(raw).map_err(|_| invalid())?; + if url.scheme() != "http" + || url.host() != Some(url::Host::Ipv4(std::net::Ipv4Addr::LOCALHOST)) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(invalid()); + } + let port = url.port().ok_or_else(invalid)?; + let mut path = url.path().to_owned(); + if let Some(query) = url.query() { + path = format!("{path}?{query}"); + } + Ok(Self { port, path }) + } +} + +/// Exchange one request on `stream` (one request per connection) and return +/// the HTTP status and body. +/// +/// This is a minimal HTTP/1.1 exchange rather than reqwest or hyper because +/// the same-user check in [`connect`] must vet the very TCP connection the +/// token is then sent on; a pooled client opens its own connections, which +/// the check never saw. +async fn exchange( + mut stream: TcpStream, + endpoint: &Endpoint, + token: Option<&str>, + version: u32, + body: &[u8], +) -> Result<(u16, Vec), ProviderError> { + let io = + |e: std::io::Error| ProviderError::Transport(format!("connection failed: {}", e.kind())); + let mut head = format!( + "POST {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n{VERSION_HEADER}: {version}\r\nConnection: close\r\n", + endpoint.path, + endpoint.port, + body.len() + ); + if let Some(token) = token { + head.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes()).await.map_err(io)?; + stream.write_all(body).await.map_err(io)?; + let mut raw = Vec::new(); + (&mut stream) + .take(MAX_RESPONSE_BYTES as u64 + 1) + .read_to_end(&mut raw) + .await + .map_err(io)?; + if raw.len() > MAX_RESPONSE_BYTES { + return Err(ProviderError::Transport("response too large".into())); + } + parse_response(&raw).ok_or_else(|| ProviderError::Transport("malformed HTTP response".into())) +} + +/// Parse an HTTP/1.1 response read to EOF: status plus body, decoding +/// `Content-Length` or chunked framing. +fn parse_response(raw: &[u8]) -> Option<(u16, Vec)> { + let split = raw.windows(4).position(|w| w == b"\r\n\r\n")?; + let head = std::str::from_utf8(&raw[..split]).ok()?; + let rest = &raw[split + 4..]; + let mut lines = head.split("\r\n"); + let status: u16 = lines.next()?.split(' ').nth(1)?.parse().ok()?; + let mut length = None; + let mut chunked = false; + for line in lines { + let (name, value) = line.split_once(':')?; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + length = Some(value.parse::().ok()?); + } else if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value.eq_ignore_ascii_case("chunked"); + } + } + if chunked { + let mut body = Vec::new(); + let mut at = rest; + loop { + let end = at.windows(2).position(|w| w == b"\r\n")?; + let size_hex = std::str::from_utf8(&at[..end]) + .ok()? + .split(';') + .next()? + .trim(); + let size = usize::from_str_radix(size_hex, 16).ok()?; + at = &at[end + 2..]; + if size == 0 { + return Some((status, body)); + } + body.extend_from_slice(at.get(..size)?); + at = at.get(size + 2..)?; + } + } + match length { + Some(n) => Some((status, rest.get(..n)?.to_vec())), + None => Some((status, rest.to_vec())), + } +} + +// --------------------------------------------------------------------------- +// Same-user check +// --------------------------------------------------------------------------- + +/// Connect to the provider port. With `check`, confirm by numeric user ID that +/// a process of this user holds the server side of this very connection, and +/// refuse to use the connection otherwise. +async fn connect(port: u16, check: bool) -> Result { + let stream = tokio::time::timeout(QUICK_TIMEOUT, TcpStream::connect(("127.0.0.1", port))) + .await + .map_err(|_| ProviderError::Transport("connection timed out".into()))? + .map_err(|e| ProviderError::Transport(format!("not reachable: {}", e.kind())))?; + if check { + let local = stream + .local_addr() + .map_err(|_| ProviderError::Transport("no local address".into()))? + .port(); + confirm_same_user(server_side_uid(port, local)).await?; + } + Ok(stream) +} + +/// Refuse unless `owner` finds this user's UID. A lookup that runs out of +/// time decided nothing, so it is retryable; any other answer is final. +async fn confirm_same_user( + owner: impl std::future::Future>, +) -> Result<(), ProviderError> { + match tokio::time::timeout(SAME_USER_TIMEOUT, owner).await { + Ok(Some(uid)) if uid == current_uid() => Ok(()), + Ok(_) => Err(ProviderError::Transport( + "could not confirm the provider runs as this user; nothing was sent".into(), + )), + Err(_) => Err(ProviderError::Unconfirmed), + } +} + +fn current_uid() -> u32 { + #[cfg(test)] + if let Ok(uid) = tests::CALLER_UID.try_with(|uid| *uid) { + return uid; + } + // SAFETY: getuid has no preconditions and cannot fail. + unsafe { libc::getuid() } +} + +/// The owner UID of the socket `127.0.0.1:server -> 127.0.0.1:client`. +#[cfg(target_os = "linux")] +async fn server_side_uid(server: u16, client: u16) -> Option { + let table = tokio::fs::read_to_string("/proc/net/tcp").await.ok()?; + proc_net_tcp_uid(&table, server, client) +} + +#[cfg(any(target_os = "linux", test))] +fn proc_net_tcp_uid(table: &str, server: u16, client: u16) -> Option { + let local = format!("0100007F:{server:04X}"); + let remote = format!("0100007F:{client:04X}"); + table.lines().skip(1).find_map(|line| { + let cols: Vec<&str> = line.split_whitespace().collect(); + (cols.get(1) == Some(&local.as_str()) && cols.get(2) == Some(&remote.as_str())) + .then(|| cols.get(7)?.parse().ok()) + .flatten() + }) +} + +/// macOS: `lsof` lists the socket's owning process with its UID. +#[cfg(not(target_os = "linux"))] +async fn server_side_uid(server: u16, client: u16) -> Option { + let output = tokio::process::Command::new("/usr/sbin/lsof") + .args(["-nP", "-F", "un", &format!("-iTCP@127.0.0.1:{server}")]) + .kill_on_drop(true) + .output() + .await + .ok()?; + lsof_uid(&String::from_utf8_lossy(&output.stdout), server, client) +} + +#[cfg(any(not(target_os = "linux"), test))] +fn lsof_uid(output: &str, server: u16, client: u16) -> Option { + let want = format!("n127.0.0.1:{server}->127.0.0.1:{client}"); + let mut uid = None; + for line in output.lines() { + if let Some(value) = line.strip_prefix('u') { + uid = value.parse().ok(); + } else if line.strip_prefix('p').is_some() { + uid = None; + } else if line == want { + return uid; + } + } + None +} + +// --------------------------------------------------------------------------- +// Provider client +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +struct Envelope { + #[serde(default)] + id: String, + #[serde(default)] + result: String, + error: Option, + message: Option, + retry_after: Option, + versions: Option>, +} + +/// Send one envelope and return its `result`, negotiating the version once +/// and waiting out `rate_limited` a bounded number of times. Transport +/// failures are never re-sent: the provider may have acted. +async fn call( + endpoint: &Endpoint, + token: Option<&str>, + same_user: bool, + method: &str, + params: &[String], +) -> Result { + call_with( + endpoint, + token, + same_user, + method, + RATE_LIMIT_RETRIES, + &|| params.to_vec(), + ) + .await +} + +/// [`call`], building `params` afresh for every envelope, so a re-send after +/// a refusal can carry a renewed auth proof. +async fn call_with( + endpoint: &Endpoint, + token: Option<&str>, + same_user: bool, + method: &str, + retries: u32, + params: &(dyn Fn() -> Vec + Sync), +) -> Result { + let mut version = VERSION; + let mut negotiated = false; + let mut throttled = 0; + loop { + let id = uuid::Uuid::new_v4().simple().to_string(); + let body = serde_json::to_vec(&serde_json::json!({ + "id": id, "method": method, "params": params(), + })) + .map_err(|_| ProviderError::Transport("request serialization failed".into()))?; + let timeout = if held(method) { + HELD_TIMEOUT + } else { + QUICK_TIMEOUT + }; + let attempt = async { + let stream = connect(endpoint.port, same_user).await?; + exchange(stream, endpoint, token, version, &body).await + }; + let (_, raw) = tokio::time::timeout(timeout, attempt) + .await + .map_err(|_| ProviderError::Transport(format!("`{method}` timed out")))??; + let envelope: Envelope = serde_json::from_slice(&raw) + .map_err(|_| ProviderError::Transport("unreadable response".into()))?; + if envelope.id != id { + return Err(ProviderError::Transport("response id mismatch".into())); + } + // Decide on `error`, never on the HTTP status. + let Some(code) = envelope.error.filter(|c| !c.is_empty()) else { + return Ok(envelope.result); + }; + match code.as_str() { + "revoked" => return Err(ProviderError::Revoked), + "unknown_method" => return Err(ProviderError::UnknownMethod), + "unsupported_version" if !negotiated => { + negotiated = true; + let common = envelope + .versions + .unwrap_or_default() + .into_iter() + .filter(|v| *v <= VERSION && *v != version) + .max(); + match common { + Some(v) => version = v, + None => return Err(ProviderError::NoCommonVersion), + } + } + "unsupported_version" => return Err(ProviderError::NoCommonVersion), + // A wait past the cap is reported at once, with its real length. + "rate_limited" + if throttled < retries + && envelope.retry_after.unwrap_or(1) <= RATE_LIMIT_MAX_WAIT_SECS => + { + throttled += 1; + tokio::time::sleep(throttle_wait(envelope.retry_after)).await; + } + other => { + return Err(ProviderError::Code { + code: known_code(other), + message: envelope.message, + retry_after: envelope.retry_after, + }) + } + } + } +} + +/// How long to wait out a `rate_limited` answer. +pub(crate) fn throttle_wait(retry_after: Option) -> Duration { + RETRY_AFTER_UNIT * retry_after.unwrap_or(1).clamp(1, RATE_LIMIT_MAX_WAIT_SECS) as u32 +} + +#[derive(serde::Deserialize)] +struct Describe { + pubkey: String, + #[serde(default)] + relays: Vec, + #[serde(default)] + methods: Vec, +} + +/// A connected provider acting as one identity. +pub struct Provider { + endpoint: Endpoint, + /// The relay it was opened for, as the desktop binds it. + relay: String, + token: String, + same_user: bool, + public_key: PublicKey, + methods: Vec, + cached: crate::cached_assertion::CachedAssertion, +} + +impl std::fmt::Debug for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Provider") + .field("port", &self.endpoint.port) + .field("public_key", &self.public_key.to_hex()) + .finish_non_exhaustive() + } +} + +/// Why a provider was not used for this relay. +pub enum Skip { + /// The token was revoked: the caller drops it. + Revoked, + /// The provider does not apply (relay not bound, no common version). + NotApplicable, + /// The endpoint could not be reached: nothing was decided. + Unreachable, + /// The same-user check timed out before anything was sent. + Unconfirmed, +} + +/// A relay URL as the local provider binds it: `http`/`https` read as +/// `ws`/`wss`, then URL normalization (lowercase host, no default port, `/` +/// for an empty path). The path and query count: one host can serve several +/// communities. This is not NIP-FA's audience form; the desktop derives that. +pub fn provider_relay_url(raw: &str) -> Result { + let invalid = || CliError::Usage(format!("invalid relay URL: {raw}")); + let mut url = url::Url::parse(raw).map_err(|_| invalid())?; + let scheme = match url.scheme() { + "http" | "ws" => "ws", + "https" | "wss" => "wss", + _ => return Err(invalid()), + }; + if !url.username().is_empty() || url.password().is_some() || url.host().is_none() { + return Err(invalid()); + } + url.set_fragment(None); + // Changing between special schemes keeps host, port and path. + url.set_scheme(scheme).map_err(|()| invalid())?; + // Re-parse so a default port for the new scheme is dropped. + url::Url::parse(url.as_str()) + .map(|u| u.to_string()) + .map_err(|_| invalid()) +} + +fn same_relay(a: &str, b: &str) -> bool { + provider_relay_url(b).is_ok_and(|b| b == a) +} + +impl Provider { + /// Connect with `token` and confirm the provider acts for `relay_url`. + pub async fn open( + endpoint: Endpoint, + token: String, + same_user: bool, + relay_url: &str, + ) -> Result, Skip>, CliError> { + let raw = match call(&endpoint, Some(&token), same_user, "describe", &[]).await { + Ok(raw) => raw, + Err(ProviderError::Revoked) => return Ok(Err(Skip::Revoked)), + Err(ProviderError::NoCommonVersion) => return Ok(Err(Skip::NotApplicable)), + Err(ProviderError::Transport(_)) => return Ok(Err(Skip::Unreachable)), + Err(ProviderError::Unconfirmed) => return Ok(Err(Skip::Unconfirmed)), + Err(e) => return Err(e.into_cli("describe")), + }; + let describe: Describe = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth("Buzz desktop: unreadable `describe` answer".into()))?; + let public_key = PublicKey::from_hex(&describe.pubkey) + .map_err(|_| CliError::Auth("Buzz desktop: invalid identity public key".into()))?; + let relay = provider_relay_url(relay_url)?; + if !describe.relays.iter().any(|r| same_relay(&relay, r)) { + return Ok(Err(Skip::NotApplicable)); + } + Ok(Ok(Arc::new(Self { + endpoint, + relay, + token, + same_user, + public_key, + methods: describe.methods, + cached: Default::default(), + }))) + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Sign `unsigned` as this identity; the result is checked. + pub async fn sign(&self, unsigned: UnsignedEvent) -> Result { + self.sign_or_wait(unsigned).await.map_err(|(e, _)| e) + } + + /// [`Self::sign`], except that a throttled NIP-42 sign-in is not waited + /// out here: its challenge dies with the socket meanwhile, so it fails + /// at once with the wait, for the caller to reconnect after. + pub async fn sign_or_wait( + &self, + unsigned: UnsignedEvent, + ) -> Result)> { + let retries = if unsigned.kind == nostr::Kind::Authentication { + 0 + } else { + RATE_LIMIT_RETRIES + }; + // A re-sent auth proof is renewed so it is fresh when it reaches the + // relay; any other event is signed exactly as built. + let signed = call_with( + &self.endpoint, + Some(&self.token), + self.same_user, + "sign_event", + retries, + &|| vec![renewed(unsigned.clone()).as_json()], + ) + .await + .map_err(|e| { + let wait = match &e { + ProviderError::Code { + code: "rate_limited", + retry_after, + .. + } => Some(throttle_wait(*retry_after)), + _ => None, + }; + (e.into_cli("sign_event"), wait) + })?; + let refused = |why: &str| (CliError::Auth(format!("Buzz desktop returned {why}")), None); + let event = Event::from_json(&signed).map_err(|_| refused("an unreadable event"))?; + if event.pubkey != self.public_key || event.verify().is_err() { + return Err(refused("an invalid signature")); + } + Ok(event) + } + + /// NIP-44 encrypt `content` to `peer` as this identity. + pub async fn nip44_encrypt(&self, peer: &PublicKey, content: &str) -> Result { + self.call("nip44_encrypt", &[peer.to_hex(), content.to_owned()]) + .await + } + + async fn call(&self, method: &str, params: &[String]) -> Result { + call( + &self.endpoint, + Some(&self.token), + self.same_user, + method, + params, + ) + .await + .map_err(|e| e.into_cli(method)) + } + + /// The identity's NIP-OA `auth` tag JSON, if it has one. + pub async fn auth_tag(&self) -> Result, CliError> { + if !self.methods.iter().any(|m| m == "auth_tag") { + return Ok(None); + } + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "auth_tag", + &[], + ) + .await + { + Ok(tag) => Ok(Some(tag).filter(|t| !t.is_empty())), + Err(ProviderError::UnknownMethod) => Ok(None), + Err(e) => Err(e.into_cli("auth_tag")), + } + } + + /// The NIP-FI assertion for the relay it was opened for, cached until + /// shortly before its `exp`. `None` when the desktop has none for it. + pub async fn federated_identity(&self) -> Result, CliError> { + let params = [self.relay.clone()]; + self.cached + .get(async { + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "federated_identity", + ¶ms, + ) + .await + { + Ok(a) if a.is_empty() => Ok(None), + Ok(a) => Ok(Some(a)), + Err(ProviderError::UnknownMethod) => Ok(None), + Err(e) => Err(e.into_cli("federated_identity")), + } + }) + .await + } + + /// NIP-44 decrypt `payload` from `peer`. `Ok(None)` when the desktop + /// answers `invalid_request`: the payload is not valid ciphertext for this + /// identity (the CLI's params are always well formed). Any other failure, + /// such as revoked, denied or unreachable, is an error. + pub async fn nip44_decrypt( + &self, + peer: &PublicKey, + payload: &str, + ) -> Result, CliError> { + match call( + &self.endpoint, + Some(&self.token), + self.same_user, + "nip44_decrypt", + &[peer.to_hex(), payload.to_owned()], + ) + .await + { + Ok(plaintext) => Ok(Some(plaintext)), + Err(ProviderError::Code { + code: "invalid_request", + .. + }) => Ok(None), + Err(e) => Err(e.into_cli("nip44_decrypt")), + } + } + + /// The NIP-AE `d` tag for `slug`, derived by the desktop. + pub async fn engram_d_tag(&self, peer: &PublicKey, slug: &str) -> Result { + let tag = self + .call("engram_d_tag", &[peer.to_hex(), slug.to_owned()]) + .await?; + if tag.len() != 64 || !tag.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(CliError::Auth( + "Buzz desktop returned an invalid engram d tag".into(), + )); + } + Ok(tag) + } +} + +fn signer_error(e: CliError) -> SignerError { + SignerError::from(e.to_string()) +} + +impl nostr::NostrSigner for Provider { + fn backend(&self) -> SignerBackend<'_> { + SignerBackend::Custom("buzz-desktop".into()) + } + + fn get_public_key(&self) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { Ok(self.public_key) }) + } + + fn sign_event( + &self, + unsigned: UnsignedEvent, + ) -> nostr::util::BoxedFuture<'_, Result> { + Box::pin(async move { Provider::sign(self, unsigned).await.map_err(signer_error) }) + } + + fn nip04_encrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) }) + } + + fn nip04_decrypt<'a>( + &'a self, + _: &'a PublicKey, + _: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async { Err("the Buzz desktop provider does not offer NIP-04".into()) }) + } + + fn nip44_encrypt<'a>( + &'a self, + peer: &'a PublicKey, + content: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async move { + Provider::nip44_encrypt(self, peer, content) + .await + .map_err(signer_error) + }) + } + + fn nip44_decrypt<'a>( + &'a self, + peer: &'a PublicKey, + payload: &'a str, + ) -> nostr::util::BoxedFuture<'a, Result> { + Box::pin(async move { + self.call("nip44_decrypt", &[peer.to_hex(), payload.to_owned()]) + .await + .map_err(signer_error) + }) + } +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +struct Inherited { + url: String, + token: String, +} + +/// The inherited token from `BUZZ_CREDENTIAL_PROVIDER`, if set. +pub fn inherited() -> Result, CliError> { + let Some(raw) = std::env::var(PROVIDER_ENV) + .ok() + .filter(|v| !v.trim().is_empty()) + else { + return Ok(None); + }; + let inherited: Inherited = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth(format!("{PROVIDER_ENV} is not valid JSON")))?; + let endpoint = Endpoint::parse(&inherited.url)?; + if !usable_token(&inherited.token) { + return Err(CliError::Auth(format!( + "{PROVIDER_ENV} carries an unusable token" + ))); + } + Ok(Some((endpoint, inherited.token))) +} + +fn usable_token(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic()) +} + +#[derive(serde::Deserialize)] +struct Advertisement { + instance: String, + url: String, +} + +fn advertisement_dir() -> Option { + std::env::var_os("HOME") + .map(|home| std::path::Path::new(&home).join(".buzz-foundation/credential-providers")) +} + +/// Every advertisement that passes the ownership and permission checks: +/// a `0700` directory and files owned by this user with no group or other +/// bits. Anything else is ignored, so another user cannot plant one. +fn advertisements_in(dir: &std::path::Path) -> Vec<(String, Endpoint)> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let uid = current_uid(); + let private = + |m: &std::fs::Metadata, mode: u32| m.uid() == uid && m.permissions().mode() & 0o777 == mode; + let Ok(meta) = std::fs::symlink_metadata(dir) else { + return Vec::new(); + }; + if !meta.is_dir() || !private(&meta, 0o700) { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut found: Vec<(String, Endpoint)> = entries + .flatten() + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + !name.starts_with('.') && name.ends_with(".json") + }) + .filter_map(|e| { + let meta = std::fs::symlink_metadata(e.path()).ok()?; + if !meta.is_file() || meta.uid() != uid || meta.permissions().mode() & 0o077 != 0 { + return None; + } + let ad: Advertisement = serde_json::from_slice(&std::fs::read(e.path()).ok()?).ok()?; + Some((ad.instance, Endpoint::parse(&ad.url).ok()?)) + }) + .collect(); + found.sort_by(|a, b| a.0.cmp(&b.0)); + found +} + +fn advertisements() -> Vec<(String, Endpoint)> { + advertisement_dir().map_or_else(Vec::new, |d| advertisements_in(&d)) +} + +// --------------------------------------------------------------------------- +// Paired tokens, in the OS keychain +// --------------------------------------------------------------------------- + +const KEYCHAIN_SERVICE: &str = "buzz-cli"; +const KEYCHAIN_ACCOUNT: &str = "desktop-pairings"; + +/// One pairing per instance. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct Pairings(std::collections::BTreeMap); + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +struct Pairing { + token: String, + pubkey: String, + relays: Vec, +} + +/// A held lock; dropping it releases the lock. +trait Held {} +impl Held for T {} + +/// Where the pairing map lives. Every change goes through [`update`], which +/// holds `lock` only for one reload-change-write. +trait PairingStore: Send + Sync { + fn read(&self) -> Result, CliError>; + /// Write `json`, or delete the entry for `None`. + fn write(&self, json: Option<&str>) -> Result<(), CliError>; + /// A cross-process lock, released when the guard drops. + fn lock(&self) -> Result, CliError>; +} + +struct Keychain; + +fn no_keychain(e: keyring::Error) -> CliError { + CliError::Auth(format!( + "no OS keychain is reachable ({e}); on Linux start a Secret Service such as \ + gnome-keyring, or set BUZZ_PRIVATE_KEY instead. Paired tokens are never written to files." + )) +} + +impl Keychain { + fn entry() -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).map_err(no_keychain) + } +} + +impl PairingStore for Keychain { + fn read(&self) -> Result, CliError> { + match Self::entry()?.get_password() { + Ok(json) => Ok(Some(json)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(no_keychain(e)), + } + } + + fn write(&self, json: Option<&str>) -> Result<(), CliError> { + let entry = Self::entry()?; + match json { + Some(json) => entry.set_password(json).map_err(no_keychain), + None => match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(no_keychain(e)), + }, + } + } + + /// `flock` on `~/.buzz-foundation/cli-pairings.lock` (no secret in it). + fn lock(&self) -> Result, CliError> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; + let failed = |e: std::io::Error| CliError::Other(format!("pairing lock failed: {e}")); + let dir = advertisement_dir() + .and_then(|d| d.parent().map(std::path::Path::to_path_buf)) + .ok_or_else(|| CliError::Other("HOME is not set".into()))?; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir) + .map_err(failed)?; + // An existing directory keeps whatever mode it had; tighten it. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(failed)?; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .custom_flags(libc::O_NOFOLLOW) + .open(dir.join("cli-pairings.lock")) + .map_err(failed)?; + // SAFETY: a valid open descriptor; flock has no other preconditions. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(failed(std::io::Error::last_os_error())); + } + Ok(Box::new(file)) + } +} + +/// The stored map. An unreadable entry is refused, never treated as empty, +/// so no write can replace it with less. +fn parse_pairings(json: Option) -> Result { + match json { + None => Ok(Pairings::default()), + Some(json) => serde_json::from_str(&json).map_err(|_| { + CliError::Auth( + "the CLI's paired tokens in the OS keychain are unreadable; \ + run `buzz auth desktop unpair` to clear them, then pair again" + .into(), + ) + }), + } +} + +/// Reload the map under the lock, apply `change`, and write it back. +fn update( + store: &dyn PairingStore, + change: impl FnOnce(&mut Pairings) -> R, +) -> Result { + let _guard = store.lock()?; + let mut pairings = parse_pairings(store.read()?)?; + let result = change(&mut pairings); + if pairings.0.is_empty() { + store.write(None)?; + } else { + let json = serde_json::to_string(&pairings) + .map_err(|e| CliError::Other(format!("pairing serialization failed: {e}")))?; + store.write(Some(&json))?; + } + Ok(result) +} + +/// Drop each revoked pairing, unless it was replaced since it was read. +fn forget_revoked(store: &dyn PairingStore, revoked: &[(String, String)]) { + if revoked.is_empty() { + return; + } + let _ = update(store, |pairings| { + for (instance, token) in revoked { + if pairings.0.get(instance).is_some_and(|p| &p.token == token) { + pairings.0.remove(instance); + } + } + }); +} + +// --------------------------------------------------------------------------- +// The chain link, and pairing +// --------------------------------------------------------------------------- + +/// The desktop link for `relay_url`. An inherited token decides the identity: +/// when it cannot serve, the command fails, and a paired token (which acts as +/// the human) is never tried, so an agent's process acts only as that agent +/// unless the owner approves pairing it. Without an +/// inherited token, the first paired token whose instance is advertised. +/// `None` when no paired token applies, so the chain moves on. +pub async fn discover(relay_url: &str) -> Result>, CliError> { + discover_with(inherited()?, &Keychain, advertisements, relay_url).await +} + +async fn discover_with( + inherited: Option<(Endpoint, String)>, + store: &dyn PairingStore, + advertised: impl FnOnce() -> Vec<(String, Endpoint)>, + relay_url: &str, +) -> Result>, CliError> { + match inherited { + Some((endpoint, token)) => inherited_link(endpoint, token, relay_url).await.map(Some), + None => paired_link(store, advertised(), relay_url).await, + } +} + +async fn inherited_link( + endpoint: Endpoint, + token: String, + relay_url: &str, +) -> Result, CliError> { + match Provider::open(endpoint, token, true, relay_url).await? { + Ok(provider) => Ok(provider), + Err(Skip::Revoked) => Err(CliError::Auth( + "the Buzz desktop revoked this agent's credential; restart the agent in Buzz".into(), + )), + Err(Skip::NotApplicable) => Err(CliError::Auth(format!( + "this agent's Buzz credential does not act on {relay_url}" + ))), + Err(Skip::Unreachable) => Err(CliError::Auth( + "the Buzz desktop that launched this agent is not reachable".into(), + )), + Err(Skip::Unconfirmed) => Err(ProviderError::Unconfirmed.into_cli("describe")), + } +} + +async fn paired_link( + store: &dyn PairingStore, + advertised: Vec<(String, Endpoint)>, + relay_url: &str, +) -> Result>, CliError> { + // A missing keychain only means there are no paired tokens to use here. + let Ok(Ok(pairings)) = store.read().map(parse_pairings) else { + return Ok(None); + }; + let relay = provider_relay_url(relay_url)?; + let mut revoked = Vec::new(); + let mut found = Ok(None); + for (instance, endpoint) in advertised { + let Some(pairing) = pairings.0.get(&instance) else { + continue; + }; + if !pairing.relays.iter().any(|r| same_relay(&relay, r)) { + continue; + } + match Provider::open(endpoint, pairing.token.clone(), true, relay_url).await { + Ok(Ok(provider)) => { + found = Ok(Some(provider)); + break; + } + Ok(Err(Skip::Revoked)) => { + revoked.push((instance.clone(), pairing.token.clone())); + eprintln!( + "warning: Buzz ({instance}) revoked this CLI's pairing; \ + run `buzz auth desktop pair` to pair again" + ); + } + // A stale or unconfirmed advertisement decides nothing: skip it. + Ok(Err(Skip::NotApplicable | Skip::Unreachable | Skip::Unconfirmed)) => {} + // The desktop answered (denied, unavailable, ...): that is final. + Err(e) => { + found = Err(e); + break; + } + } + } + forget_revoked(store, &revoked); + found +} + +#[derive(serde::Deserialize)] +struct Paired { + token: String, + instance: String, + pubkey: String, + relays: Vec, +} + +/// Ask the running desktop to pair this CLI for `relay_url`, holding until the +/// person decides, and store the token in the OS keychain. +pub async fn pair( + relay_url: &str, + client_name: &str, + instance: Option<&str>, +) -> Result { + let mut found = advertisements(); + if let Some(want) = instance { + found.retain(|(i, _)| i == want); + } + let (instance, endpoint) = match found.len() { + 0 => { + return Err(CliError::Auth( + "no running Buzz desktop found; open Buzz and try again".into(), + )) + } + 1 => found.remove(0), + _ => { + let names: Vec<_> = found.iter().map(|(i, _)| i.as_str()).collect(); + return Err(CliError::Usage(format!( + "several Buzz desktops are running ({}); pass --instance", + names.join(", ") + ))); + } + }; + pair_with(&Keychain, instance, endpoint, relay_url, client_name).await +} + +async fn pair_with( + store: &dyn PairingStore, + instance: String, + endpoint: Endpoint, + relay_url: &str, + client_name: &str, +) -> Result { + // Fail on a missing or unreadable keychain before asking for approval. + parse_pairings(store.read()?)?; + let relay = provider_relay_url(relay_url)?; + let raw = call( + &endpoint, + None, + true, + "pair", + &[client_name.to_owned(), relay], + ) + .await + .map_err(|e| e.into_cli("pair"))?; + let paired: Paired = serde_json::from_str(&raw) + .map_err(|_| CliError::Auth("Buzz desktop: unreadable `pair` answer".into()))?; + if paired.instance != instance + || !usable_token(&paired.token) + || PublicKey::from_hex(&paired.pubkey).is_err() + { + return Err(CliError::Auth( + "Buzz desktop returned an invalid pairing".into(), + )); + } + let summary = serde_json::json!({ + "instance": instance, + "pubkey": paired.pubkey, + "relays": paired.relays, + }); + // Reload at commit time: only this instance changes, and a new pairing + // replaces its old token. + update(store, |pairings| { + pairings.0.insert( + instance, + Pairing { + token: paired.token, + pubkey: paired.pubkey, + relays: paired.relays, + }, + ); + })?; + Ok(summary) +} + +/// Forget paired tokens: one instance's, or all of them. Local only. +pub fn unpair(instance: Option<&str>) -> Result { + unpair_with(&Keychain, instance) +} + +fn unpair_with( + store: &dyn PairingStore, + instance: Option<&str>, +) -> Result { + let removed = match instance { + Some(i) => update(store, |pairings| { + pairings + .0 + .remove(i) + .map(|_| vec![i.to_owned()]) + .unwrap_or_default() + })?, + // Clearing everything needs no read, so it also clears an unreadable entry. + None => { + let _guard = store.lock()?; + let removed = store + .read()? + .map(|json| parse_pairings(Some(json)).map(|p| p.0.into_keys().collect())) + .transpose() + .unwrap_or_default() + .unwrap_or_default(); + store.write(None)?; + removed + } + }; + Ok(serde_json::json!({ "removed": removed })) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::cached_assertion::unix_now; + + tokio::task_local! { + /// Pretend the caller runs as this user ID. + pub(crate) static CALLER_UID: u32; + } + use std::sync::Mutex as StdMutex; + + #[test] + fn endpoint_must_be_loopback_http_ipv4() { + assert!(Endpoint::parse("http://127.0.0.1:4100/rpc").is_ok()); + for bad in [ + "https://127.0.0.1:4100/rpc", + "http://localhost:4100/rpc", + "http://[::1]:4100/rpc", + "http://user@127.0.0.1:4100/rpc", + "http://10.0.0.1:4100/rpc", + "http://127.0.0.1/rpc", + ] { + assert!(Endpoint::parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn unrecognized_codes_are_denied() { + assert_eq!(known_code("approval_required"), "approval_required"); + assert_eq!(known_code("brand_new_code"), "denied"); + } + + #[test] + fn held_methods_get_the_long_timeout() { + assert!(held("sign_event") && held("nip44_decrypt") && held("pair")); + assert!(!held("describe") && !held("federated_identity")); + } + + /// Serves `body` as one raw HTTP response and returns what `exchange` makes of it. + async fn exchanged(body: Vec) -> Result<(u16, Vec), ProviderError> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + // Read the whole request first: closing with any of it unread + // resets the connection and loses the reply. + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n{}") { + let mut chunk = [0u8; 4096]; + match tokio::io::AsyncReadExt::read(&mut stream, &mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.shutdown().await; + }); + let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(); + let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let reply = exchange(stream, &endpoint, Some("t"), VERSION, b"{}").await; + server.abort(); + reply + } + + #[tokio::test] + async fn the_largest_signed_event_fits_and_more_is_refused() { + const CANVAS_BYTES: usize = 512 * 1024; + // An event at the desktop's limit whose content is all quotes, so + // every content byte doubles again when the event is the result string. + let shell = + r#"{"id":"","pubkey":"","created_at":0,"kind":40100,"tags":[],"content":"","sig":""}"#; + let content = "\"".repeat((CANVAS_BYTES - shell.len()) / 2); + let event = serde_json::json!({ + "id": "", "pubkey": "", "created_at": 0, "kind": 40100, "tags": [], + "content": content, "sig": "", + }) + .to_string(); + assert!(event.len() <= CANVAS_BYTES && event.len() > CANVAS_BYTES - 16); + // An agent note isn't bound by the relay frame: 64 KiB of content and + // 99 quote-filled 4 KiB tags, over the old Canvas-based cap once escaped. + let tags: Vec<_> = (0..99) + .map(|i| serde_json::json!(["t", format!("{i}{}", "\"".repeat(4000))])) + .collect(); + let note = serde_json::json!({ + "id": "", "pubkey": "", "created_at": 0, "kind": 30023, "tags": tags, + "content": "\"".repeat(64 * 1024), "sig": "", + }) + .to_string(); + // A decrypt whose base64 payload fills the request: its plaintext, + // three quarters of that, all control characters (`\u0001`, six bytes). + let plaintext = "\u{1}".repeat(MAX_REQUEST_BYTES / 4 * 3 - 64 * 1024); + for result in [event, note, plaintext] { + let body = serde_json::json!({ "id": "1", "result": result }) + .to_string() + .into_bytes(); + let (status, parsed) = exchanged(body.clone()).await.unwrap(); + assert_eq!((status, parsed), (200, body)); + } + let runaway = vec![b'x'; MAX_RESPONSE_BYTES]; + assert!(matches!( + exchanged(runaway).await, + Err(ProviderError::Transport(message)) if message == "response too large" + )); + } + + #[test] + fn parses_content_length_and_chunked_responses() { + let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhiEXTRA"; + assert_eq!(parse_response(fixed), Some((200, b"hi".to_vec()))); + let chunked = + b"HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"; + assert_eq!(parse_response(chunked), Some((403, b"abcde".to_vec()))); + assert_eq!(parse_response(b"garbage"), None); + } + + #[test] + fn proc_net_tcp_finds_the_server_side_of_this_connection() { + let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\ + 0: 0100007F:1004 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 1\n\ + 1: 0100007F:1004 0100007F:C350 01 00000000:00000000 00:00000000 00000000 502 0 2\n\ + 2: 0100007F:C350 0100007F:1004 01 00000000:00000000 00:00000000 00000000 501 0 3\n"; + assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC350), Some(502)); + assert_eq!(proc_net_tcp_uid(table, 0x1004, 0xC351), None); + } + + #[test] + fn lsof_finds_the_server_side_of_this_connection() { + let out = "p100\nu501\nn127.0.0.1:50000->127.0.0.1:4100\np200\nu502\nn127.0.0.1:4100->127.0.0.1:50000\n"; + assert_eq!(lsof_uid(out, 4100, 50000), Some(502)); + assert_eq!(lsof_uid(out, 4100, 50001), None); + } + + #[test] + fn advertisements_require_private_ownership() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let ads = dir.path().join("providers"); + std::fs::create_dir(&ads).unwrap(); + let write = |name: &str, mode: u32| { + let path = ads.join(name); + std::fs::write( + &path, + r#"{"instance":"xyz.block.buzz","url":"http://127.0.0.1:4100/rpc"}"#, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + }; + write("good.json", 0o600); + write("open.json", 0o644); + write(".partial.json", 0o600); + std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + advertisements_in(&ads).is_empty(), + "a group-readable directory is ignored" + ); + std::fs::set_permissions(&ads, std::fs::Permissions::from_mode(0o700)).unwrap(); + let found = advertisements_in(&ads); + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, "xyz.block.buzz"); + assert_eq!(found[0].1.port, 4100); + } + + // ----------------------------------------------------------------------- + // Against a mock provider + // ----------------------------------------------------------------------- + + pub(crate) type Seen = Arc, String, serde_json::Value)>>>; + + /// A provider answering each envelope with `respond(version, request)`. + pub(crate) async fn provider(respond: F) -> (Endpoint, Seen) + where + F: Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static, + { + use axum::http::HeaderMap; + let seen: Seen = Arc::default(); + let log = seen.clone(); + let respond = Arc::new(respond); + let app = axum::Router::new().route( + "/rpc", + axum::routing::post(move |headers: HeaderMap, body: axum::body::Bytes| { + let (log, respond) = (log.clone(), respond.clone()); + async move { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let header = |n: &str| { + headers + .get(n) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + let version = header(VERSION_HEADER).unwrap_or_default(); + let mut answer = respond(&version, &request); + answer["id"] = request["id"].clone(); + log.lock() + .unwrap() + .push((header("authorization"), version, request)); + axum::Json(answer) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + ( + Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(), + seen, + ) + } + + const RELAY: &str = "wss://community.example"; + + fn describe(relays: &[&str]) -> serde_json::Value { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + serde_json::json!({"result": serde_json::json!({ + "instance": "xyz.block.buzz", "pubkey": pubkey, "role": "owner", + "relays": relays, "methods": ["get_public_key", "sign_event"], + }).to_string()}) + } + + #[tokio::test] + async fn describe_sends_token_and_version_and_binds_the_relay() { + let (endpoint, seen) = provider(|_, _| describe(&[RELAY])).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(opened.is_ok()); + let seen = seen.lock().unwrap(); + assert_eq!(seen[0].0.as_deref(), Some("Bearer tok")); + assert_eq!(seen[0].1, "1"); + assert_eq!(seen[0].2["method"], "describe"); + } + + #[tokio::test] + async fn short_and_long_assertions_refresh_at_the_earlier_point() { + use base64::Engine; + for (lifetime, refresh) in [(20, 10), (300, 150)] { + let (endpoint, seen) = provider(move |_, request| match request["method"].as_str() { + Some("federated_identity") => { + let b64 = |v: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string()) + }; + let jws = format!( + "{}.{}.c2ln", + b64(serde_json::json!({"alg": "ES256"})), + b64(serde_json::json!({"exp": unix_now() + lifetime})) + ); + serde_json::json!({ "result": jws }) + } + _ => describe(&[RELAY]), + }) + .await; + let Ok(Ok(provider)) = Provider::open(endpoint, "tok".into(), false, RELAY).await + else { + panic!("the provider should open"); + }; + let mints = || { + let seen = seen.lock().unwrap(); + seen.iter() + .filter(|s| s.2["method"] == "federated_identity") + .count() + }; + provider.federated_identity().await.unwrap(); + crate::cached_assertion::test_clock::advance(refresh - 1); + provider.federated_identity().await.unwrap(); + assert_eq!(mints(), 1, "lifetime {lifetime}"); + crate::cached_assertion::test_clock::advance(1); + provider.federated_identity().await.unwrap(); + assert_eq!(mints(), 2, "lifetime {lifetime}"); + } + } + + #[tokio::test] + async fn an_unbound_relay_or_revoked_token_falls_through() { + let (endpoint, _) = provider(|_, _| describe(&["wss://other.example"])).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::NotApplicable))); + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let opened = Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::Revoked))); + } + + #[tokio::test] + async fn version_negotiation_retries_once_or_gives_up() { + let (endpoint, seen) = provider(|_, _| { + serde_json::json!({"result": "", "error": "unsupported_version", "versions": [2, 3]}) + }) + .await; + let err = call(&endpoint, Some("tok"), false, "describe", &[]) + .await + .unwrap_err(); + assert!(matches!(err, ProviderError::NoCommonVersion)); + assert_eq!(seen.lock().unwrap().len(), 1); + } + + /// A throttled auth proof is re-sent dated now (its Blossom expiration + /// moved with it); a content event is re-sent exactly as built. + #[tokio::test] + async fn a_throttled_auth_proof_is_renewed_and_a_content_event_is_not() { + use std::sync::atomic::{AtomicBool, Ordering}; + let keys = nostr::Keys::generate(); + let answer = desktop_answers(keys.clone(), vec![RELAY.into()]); + let throttle = Arc::new(AtomicBool::new(false)); + let gate = throttle.clone(); + let (endpoint, seen) = provider(move |version, request| { + if request["method"] == "sign_event" && gate.swap(false, Ordering::SeqCst) { + // As long as a real throttle takes, at least a clock second. + std::thread::sleep(Duration::from_millis(1100)); + return serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}); + } + answer(version, request) + }) + .await; + let desktop = match Provider::open(endpoint, "tok".into(), false, RELAY) + .await + .unwrap() + { + Ok(p) => p, + Err(_) => panic!("provider not opened"), + }; + let expiration = |e: &Event| e.tags.expiration().map(|t| t.as_secs()); + + let proof = nostr::EventBuilder::new(nostr::Kind::from(24242), "Upload file") + .tags([Tag::parse(["expiration", &(unix_now() + 60).to_string()]).unwrap()]) + .build(keys.public_key()); + throttle.store(true, Ordering::SeqCst); + let signed = desktop.sign(proof.clone()).await.unwrap(); + assert!( + signed.created_at > proof.created_at, + "the proof kept its old date" + ); + assert_eq!( + expiration(&signed).unwrap() - signed.created_at.as_secs(), + 60, + "the expiration did not move with the date" + ); + + let mut content = + nostr::EventBuilder::new(nostr::Kind::TextNote, "hi").build(keys.public_key()); + let id = content.id(); + throttle.store(true, Ordering::SeqCst); + let signed = desktop.sign(content.clone()).await.unwrap(); + assert_eq!(signed.created_at, content.created_at); + assert_eq!(signed.id, id); + assert!(seen.lock().unwrap().len() >= 5); + } + + /// A same-user lookup that runs out of time is retryable; a wrong or + /// missing owner stays a refusal. + #[tokio::test(start_paused = true)] + async fn a_slow_same_user_check_is_retryable_and_a_mismatch_is_not() { + let slow = async { + tokio::time::sleep(Duration::from_secs(4)).await; + Some(current_uid()) + }; + assert!( + confirm_same_user(slow).await.is_ok(), + "4 s is within the budget" + ); + + let err = confirm_same_user(std::future::pending()).await.unwrap_err(); + assert!(crate::error::is_retryable_error(&err.into_cli("describe"))); + + for owner in [Some(current_uid() + 1), None] { + let err = confirm_same_user(async { owner }).await.unwrap_err(); + assert!(!crate::error::is_retryable_error(&err.into_cli("describe"))); + } + } + + #[tokio::test] + async fn rate_limited_is_waited_out_then_reported_as_retryable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let asked = Arc::new(AtomicUsize::new(0)); + let count = asked.clone(); + let (endpoint, seen) = provider(move |_, _| match count.fetch_add(1, Ordering::SeqCst) { + 0 => serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 1}), + _ => serde_json::json!({"result": "signed"}), + }) + .await; + let started = std::time::Instant::now(); + let result = call(&endpoint, Some("tok"), false, "sign_event", &[]).await; + assert_eq!(result.unwrap(), "signed"); + assert!(started.elapsed() >= RETRY_AFTER_UNIT, "honors retry_after"); + assert_eq!(seen.lock().unwrap().len(), 2); + + let (endpoint, seen) = provider( + |_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 0}), + ) + .await; + // `retry_after: 0` still waits the 1 s floor, so this takes a few seconds. + let err = call(&endpoint, Some("tok"), false, "sign_event", &[]) + .await + .unwrap_err(); + assert_eq!(seen.lock().unwrap().len(), 1 + RATE_LIMIT_RETRIES as usize); + let err = err.into_cli("sign_event"); + assert!(matches!(err, CliError::Throttled(_)), "{err:?}"); + assert!(crate::error::is_retryable_error(&err)); + } + + #[tokio::test] + async fn a_cooldown_past_the_cap_is_reported_without_retrying() { + let (endpoint, seen) = provider( + |_, _| serde_json::json!({"result": "", "error": "rate_limited", "retry_after": 300}), + ) + .await; + let err = call(&endpoint, None, false, "pair", &[]).await.unwrap_err(); + assert_eq!(seen.lock().unwrap().len(), 1); + let err = err.into_cli("pair"); + assert!( + matches!(&err, CliError::Throttled(m) if m.contains("retry in 300s")), + "{err:?}" + ); + } + + #[tokio::test] + async fn an_error_envelope_for_another_request_is_refused() { + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + // The mock echoes the request id; a raw server that does not is below. + assert!(matches!( + call(&endpoint, Some("tok"), false, "describe", &[]).await, + Err(ProviderError::Revoked) + )); + let app = axum::Router::new().route( + "/rpc", + axum::routing::post(|| async { + axum::Json(serde_json::json!({"id": "other", "result": "", "error": "revoked"})) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let endpoint = Endpoint::parse(&format!("http://127.0.0.1:{port}/rpc")).unwrap(); + assert!(matches!( + call(&endpoint, Some("tok"), false, "describe", &[]).await, + Err(ProviderError::Transport(_)) + )); + } + + #[tokio::test] + async fn an_unknown_error_code_is_denied() { + let (endpoint, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "brand_new"})).await; + let err = call(&endpoint, Some("tok"), false, "sign_event", &[]) + .await + .unwrap_err(); + assert!(matches!(err, ProviderError::Code { code: "denied", .. })); + } + + #[tokio::test] + async fn same_user_check_accepts_this_users_provider() { + let (endpoint, _) = provider(|_, _| describe(&[RELAY])).await; + let opened = Provider::open(endpoint, "tok".into(), true, RELAY) + .await + .unwrap(); + assert!(opened.is_ok()); + } + + // ----------------------------------------------------------------------- + // A desktop holding a real key, and an in-memory pairing store + // ----------------------------------------------------------------------- + + /// The assertion the mock desktop hands out for any relay. + pub(crate) fn mock_assertion() -> String { + assertion(600) + } + + /// An assertion that expires `lifetime` s from now. + pub(crate) fn assertion(lifetime: u64) -> String { + use base64::Engine; + let b64 = |v: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string()) + }; + format!( + "{}.{}.c2ln", + b64(serde_json::json!({"alg": "ES256", "typ": "nip-fi+jwt"})), + b64(serde_json::json!({"exp": unix_now() + lifetime})) + ) + } + + /// A desktop acting as `keys` for `relays`: it signs, encrypts, decrypts + /// (answering `invalid_request` for bad ciphertext, as #2260 does), + /// derives engram `d` tags and hands out [`mock_assertion`]. + pub(crate) async fn desktop(keys: nostr::Keys, relays: Vec) -> (Endpoint, Seen) { + provider(desktop_answers(keys, relays)).await + } + + /// [`desktop`]'s answers, for a provider that wraps them. + pub(crate) fn desktop_answers( + keys: nostr::Keys, + relays: Vec, + ) -> impl Fn(&str, &serde_json::Value) -> serde_json::Value + Send + Sync + 'static { + move |_, request| { + let params: Vec = + serde_json::from_value(request["params"].clone()).unwrap_or_default(); + let peer = || PublicKey::from_hex(¶ms[0]).unwrap(); + let ok = |result: String| serde_json::json!({ "result": result }); + match request["method"].as_str().unwrap_or_default() { + "describe" => ok(serde_json::json!({ + "instance": "xyz.block.buzz", "pubkey": keys.public_key().to_hex(), + "role": "owner", "relays": relays, + "methods": ["sign_event", "nip44_decrypt", "federated_identity"], + }) + .to_string()), + "sign_event" => { + let unsigned = UnsignedEvent::from_json(¶ms[0]).unwrap(); + ok(unsigned.sign_with_keys(&keys).unwrap().as_json()) + } + "nip44_encrypt" => ok(nostr::nips::nip44::encrypt( + keys.secret_key(), + &peer(), + ¶ms[1], + nostr::nips::nip44::Version::V2, + ) + .unwrap()), + "nip44_decrypt" => { + match nostr::nips::nip44::decrypt(keys.secret_key(), &peer(), ¶ms[1]) { + Ok(plain) => ok(plain), + Err(_) => serde_json::json!({"result": "", "error": "invalid_request"}), + } + } + "engram_d_tag" => { + let k_c = buzz_core::engram::conversation_key(keys.secret_key(), &peer()); + ok(buzz_core::engram::d_tag(&k_c, ¶ms[1])) + } + "federated_identity" => ok(mock_assertion()), + _ => serde_json::json!({"result": "", "error": "unknown_method"}), + } + } + } + + #[derive(Default)] + struct MemoryStore { + json: StdMutex>, + lock: StdMutex<()>, + } + + impl MemoryStore { + fn with(pairings: &[(&str, &str, &[&str])]) -> Self { + let map: std::collections::BTreeMap<_, _> = pairings + .iter() + .map(|(instance, token, relays)| { + let pairing = Pairing { + token: (*token).into(), + pubkey: nostr::Keys::generate().public_key().to_hex(), + relays: relays.iter().map(|r| (*r).to_owned()).collect(), + }; + ((*instance).to_owned(), pairing) + }) + .collect(); + let store = Self::default(); + *store.json.lock().unwrap() = Some(serde_json::to_string(&Pairings(map)).unwrap()); + store + } + + fn instances(&self) -> Vec { + parse_pairings(self.json.lock().unwrap().clone()) + .unwrap() + .0 + .into_keys() + .collect() + } + } + + impl PairingStore for MemoryStore { + fn read(&self) -> Result, CliError> { + Ok(self.json.lock().unwrap().clone()) + } + fn write(&self, json: Option<&str>) -> Result<(), CliError> { + *self.json.lock().unwrap() = json.map(str::to_owned); + Ok(()) + } + fn lock(&self) -> Result, CliError> { + Ok(Box::new(self.lock.lock().unwrap())) + } + } + + fn methods(seen: &Seen) -> Vec { + seen.lock() + .unwrap() + .iter() + .map(|(_, _, r)| r["method"].as_str().unwrap_or_default().to_owned()) + .collect() + } + + /// An agent whose inherited token is revoked or not bound to the relay + /// fails, even with a valid owner pairing advertised: the owner's + /// desktop gets no request at all. + #[tokio::test] + async fn an_agent_never_falls_back_to_the_owner_pairing() { + let (owner, owner_seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::with(&[("xyz.block.buzz", "owner-token", &[RELAY])]); + let (revoked, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let (unbound, _) = provider(|_, _| describe(&["wss://other.example"])).await; + for agent in [revoked, unbound] { + let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())]; + let result = discover_with(Some((agent, "agent".into())), &store, ads, RELAY).await; + assert!(matches!(result, Err(CliError::Auth(_))), "{result:?}"); + } + assert!(owner_seen.lock().unwrap().is_empty(), "the owner was asked"); + // Without an inherited token the same pairing is used. + let ads = || vec![("xyz.block.buzz".to_owned(), owner.clone())]; + assert!(discover_with(None, &store, ads, RELAY) + .await + .unwrap() + .is_some()); + assert_eq!( + owner_seen.lock().unwrap()[0].0.as_deref(), + Some("Bearer owner-token") + ); + } + + #[test] + fn relay_urls_bind_whole_after_normalization() { + let norm = |u: &str| provider_relay_url(u).unwrap(); + assert_eq!(norm("https://Host.example"), "wss://host.example/"); + assert_eq!(norm("wss://host.example:443/"), "wss://host.example/"); + assert_eq!(norm("http://127.0.0.1:3000"), "ws://127.0.0.1:3000/"); + assert_eq!(norm("ws://127.0.0.1:80/a"), "ws://127.0.0.1/a"); + assert_eq!(norm("wss://host.example/a?x=1"), "wss://host.example/a?x=1"); + assert_ne!(norm("wss://host.example/a"), norm("wss://host.example/b")); + assert_ne!(norm("ws://host.example"), norm("wss://host.example")); + assert!(provider_relay_url("ftp://host.example").is_err()); + } + + #[tokio::test] + async fn a_token_binds_its_relay_path_and_plain_ws() { + let bound = vec![ + "wss://host.example/a".to_owned(), + "ws://127.0.0.1:7000".to_owned(), + ]; + let (endpoint, _) = desktop(nostr::Keys::generate(), bound).await; + let open = |relay: &'static str| { + let endpoint = endpoint.clone(); + async move { + Provider::open(endpoint, "t".into(), false, relay) + .await + .unwrap() + } + }; + assert!(open("https://host.example/a").await.is_ok()); + assert!(matches!( + open("https://host.example/b").await, + Err(Skip::NotApplicable) + )); + assert!(matches!( + open("https://host.example").await, + Err(Skip::NotApplicable) + )); + assert!(open("http://127.0.0.1:7000/").await.is_ok()); + } + + #[tokio::test] + async fn bad_ciphertext_is_invalid_but_a_credential_failure_is_an_error() { + let keys = nostr::Keys::generate(); + let peer = nostr::Keys::generate(); + let (endpoint, _) = desktop(keys.clone(), vec![RELAY.into()]).await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let good = nostr::nips::nip44::encrypt( + peer.secret_key(), + &keys.public_key(), + "hi", + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + let plain = p.nip44_decrypt(&peer.public_key(), &good).await.unwrap(); + assert_eq!(plain.as_deref(), Some("hi")); + assert!(p + .nip44_decrypt(&peer.public_key(), "AAAA") + .await + .unwrap() + .is_none()); + for code in ["revoked", "denied", "unavailable"] { + let (endpoint, _) = provider(move |_, r| match r["method"].as_str() { + Some("describe") => describe(&[RELAY]), + _ => serde_json::json!({"result": "", "error": code}), + }) + .await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + assert!( + p.nip44_decrypt(&peer.public_key(), &good).await.is_err(), + "{code}" + ); + } + } + + #[tokio::test] + async fn a_paired_desktop_that_refuses_fails_and_revoked_entries_are_forgotten() { + let (denied, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "denied"})).await; + let store = MemoryStore::with(&[("a", "ta", &[RELAY])]); + let ads = || vec![("a".to_owned(), denied)]; + assert!(discover_with(None, &store, ads, RELAY).await.is_err()); + + let (revoked, _) = + provider(|_, _| serde_json::json!({"result": "", "error": "revoked"})).await; + let (good, _) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::with(&[("a", "ta", &[RELAY]), ("b", "tb", &[RELAY])]); + let ads = || vec![("a".to_owned(), revoked), ("b".to_owned(), good)]; + assert!(discover_with(None, &store, ads, RELAY) + .await + .unwrap() + .is_some()); + assert_eq!(store.instances(), ["b"]); + } + + /// A pairing waiting for approval does not hold the store, and its commit + /// reloads: a concurrent unpair stays unpaired and a concurrent pairing + /// is kept. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_pair_and_unpair_keep_each_others_changes() { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + // `started` says the approval is pending; `release` lets it answer. + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (release, release_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, release_rx) = (StdMutex::new(started_tx), StdMutex::new(release_rx)); + let (slow, _) = provider(move |_, _| { + started_tx.lock().unwrap().send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + serde_json::json!({"result": serde_json::json!({ + "token": "tb", "instance": "b", "pubkey": pubkey, "relays": [RELAY], + }).to_string()}) + }) + .await; + let store = Arc::new(MemoryStore::with(&[("a", "ta", &[RELAY])])); + let pairing = { + let store = store.clone(); + tokio::spawn( + async move { pair_with(store.as_ref(), "b".into(), slow, RELAY, "cli").await }, + ) + }; + tokio::task::spawn_blocking(move || started.recv().unwrap()) + .await + .unwrap(); + unpair_with(store.as_ref(), Some("a")).unwrap(); + update(store.as_ref(), |p| { + assert!(!p.0.contains_key("b")); + p.0.insert( + "c".into(), + Pairing { + token: "tc".into(), + pubkey: String::new(), + relays: vec![], + }, + ); + }) + .unwrap(); + release.send(()).unwrap(); + pairing.await.unwrap().unwrap(); + assert_eq!(store.instances(), ["b", "c"]); + } + + /// Through the CLI's own wiring of `--relay`, a community ending in a + /// slash reaches the desktop exactly as configured, for inherited + /// discovery, evidence and pairing. + #[tokio::test] + async fn a_non_root_trailing_slash_relay_reaches_the_desktop_unchanged() { + use clap::Parser; + const TEAM: &str = "wss://host.example/team/"; + let parse = |args: &[&str]| crate::Cli::try_parse_from(args).unwrap(); + let store = MemoryStore::default(); + + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![TEAM.into()]).await; + let cli = parse(&["buzz", "--relay", TEAM, "users", "get"]); + let (_, client) = crate::connect(&cli.relay, cli.command, async |_, _| unreachable!(), { + let store = &store; + async move |relay| { + let provider = + discover_with(Some((endpoint, "agent".into())), store, Vec::new, relay) + .await? + .unwrap(); + crate::credentials::Credentials::from_desktop(provider).await + } + }) + .await + .unwrap() + .unwrap(); + client.credentials().evidence().await.unwrap(); + let asked = seen.lock().unwrap().last().unwrap().2.clone(); + assert_eq!(asked["params"], serde_json::json!([TEAM])); + + let pubkey = nostr::Keys::generate().public_key().to_hex(); + let (endpoint, seen) = provider(move |_, _| { + serde_json::json!({"result": serde_json::json!({ + "token": "tok", "instance": "i", "pubkey": pubkey, "relays": [TEAM], + }).to_string()}) + }) + .await; + let cli = parse(&[ + "buzz", "--relay", TEAM, "auth", "desktop", "pair", "--name", "cli", + ]); + let paired = crate::connect( + &cli.relay, + cli.command, + async |sub, relay| { + let crate::AuthCmd::Desktop(crate::DesktopAuthCmd::Pair { name, .. }) = sub else { + unreachable!() + }; + pair_with(&store, "i".into(), endpoint, relay, &name) + .await + .map(drop) + }, + async |_| unreachable!(), + ) + .await + .unwrap(); + assert!(paired.is_none()); + let asked = seen.lock().unwrap()[0].2.clone(); + assert_eq!(asked["params"], serde_json::json!(["cli", TEAM])); + } + + #[tokio::test] + async fn an_unreadable_store_is_refused_not_overwritten() { + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let store = MemoryStore::default(); + *store.json.lock().unwrap() = Some("not json".into()); + assert!( + pair_with(&store, "xyz.block.buzz".into(), endpoint, RELAY, "cli") + .await + .is_err() + ); + assert!(seen.lock().unwrap().is_empty(), "approval was requested"); + assert!(unpair_with(&store, Some("a")).is_err()); + assert_eq!(store.json.lock().unwrap().as_deref(), Some("not json")); + unpair_with(&store, None).unwrap(); + assert!(store.json.lock().unwrap().is_none()); + } + + /// `pair` sends the whole bound URL, and the token it gets back is stored. + #[tokio::test] + async fn pair_sends_the_bound_url_and_stores_the_token() { + let pubkey = nostr::Keys::generate().public_key().to_hex(); + let (endpoint, seen) = provider(move |_, _| { + serde_json::json!({"result": serde_json::json!({ + "token": "tok", "instance": "i", "pubkey": pubkey, + "relays": ["wss://host.example/a"], + }).to_string()}) + }) + .await; + let store = MemoryStore::default(); + pair_with( + &store, + "i".into(), + endpoint, + "https://host.example/a", + "cli", + ) + .await + .unwrap(); + let request = seen.lock().unwrap()[0].2.clone(); + assert_eq!( + request["params"], + serde_json::json!(["cli", "wss://host.example/a"]) + ); + assert_eq!(store.instances(), ["i"]); + unpair_with(&store, Some("i")).unwrap(); + assert!(store.json.lock().unwrap().is_none()); + } + + /// When this user cannot be confirmed on the server side, not one byte + /// (so no token) reaches the endpoint. + #[tokio::test] + async fn an_unconfirmed_user_gets_nothing_sent() { + let (endpoint, seen) = desktop(nostr::Keys::generate(), vec![RELAY.into()]).await; + let opened = CALLER_UID + .scope( + u32::MAX - 1, + Provider::open(endpoint, "secret".into(), true, RELAY), + ) + .await + .unwrap(); + assert!(matches!(opened, Err(Skip::Unreachable))); + assert!(seen.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn signing_goes_through_the_desktop_and_is_verified() { + use nostr::NostrSigner; + let keys = nostr::Keys::generate(); + let (endpoint, seen) = desktop(keys.clone(), vec![RELAY.into()]).await; + let p = Provider::open(endpoint, "t".into(), false, RELAY) + .await + .unwrap() + .ok() + .unwrap(); + let unsigned = + nostr::EventBuilder::new(nostr::Kind::Custom(9), "hi").build(keys.public_key()); + let event = p.sign_event(unsigned).await.unwrap(); + assert!(event.verify().is_ok() && event.pubkey == keys.public_key()); + assert_eq!(methods(&seen), ["describe", "sign_event"]); + } + + /// The desktop's bare compact assertion reaches the relay with exactly + /// one `Bearer` scheme (the shape #2260's `federated_identity` returns). + #[tokio::test] + async fn the_desktop_assertion_is_sent_with_one_bearer_prefix() { + use axum::http::HeaderMap; + let seen: Arc>> = Arc::default(); + let log = seen.clone(); + let app = axum::Router::new().fallback(move |headers: HeaderMap| { + let log = log.clone(); + async move { + let value = headers + .get("nostr-federated-identity") + .and_then(|v| v.to_str().ok()); + log.lock() + .unwrap() + .push(value.unwrap_or_default().to_owned()); + axum::Json(serde_json::json!([])) + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let (endpoint, _) = desktop(nostr::Keys::generate(), vec![relay.clone()]).await; + let p = Provider::open(endpoint, "t".into(), false, &relay) + .await + .unwrap() + .ok() + .unwrap(); + let credentials = crate::credentials::Credentials::from_desktop(p) + .await + .unwrap(); + let client = crate::client::BuzzClient::new(relay, credentials).unwrap(); + client + .query(&serde_json::json!({"kinds": [9]})) + .await + .unwrap(); + let header = seen.lock().unwrap()[0].clone(); + assert_eq!(header.matches("Bearer").count(), 1, "{header}"); + assert_eq!( + header.strip_prefix("Bearer ").unwrap().split('.').count(), + 3 + ); + } +} diff --git a/crates/buzz-cli/src/error.rs b/crates/buzz-cli/src/error.rs index 2edcd6aa9d..1cd3855a37 100644 --- a/crates/buzz-cli/src/error.rs +++ b/crates/buzz-cli/src/error.rs @@ -39,6 +39,25 @@ pub enum CliError { #[error("delivery unknown: {0}")] DeliveryUnknown(String), + /// The NIP-FA identity adapter stayed unavailable through its bounded + /// retries. Nothing was sent to the relay, so the command is safe to + /// re-run later; relay retry loops never repeat it. + #[error("identity adapter error: {0}")] + Adapter(String), + + /// The local credential provider stayed `rate_limited` through its + /// bounded waits. It refused before acting, so a later re-run is safe. + /// Only the Unix desktop provider reports it. + #[cfg_attr(not(unix), allow(dead_code))] + #[error("rate limited: {0}")] + Throttled(String), + + /// The relay or the Buzz desktop could not be used now (the relay kept + /// closing the WebSocket before sign-in, or the desktop's same-user check + /// timed out). Nothing was sent, so a later re-run is safe. + #[error("unavailable: {0}")] + Unavailable(String), + /// Catch-all for unexpected failures #[error("{0}")] Other(String), @@ -47,7 +66,7 @@ pub enum CliError { /// Walk the full `std::error::Error::source()` chain on a `reqwest::Error` /// and render it as a colon-separated string, e.g. /// `error sending request: dns error: failed to lookup address information: ...` -fn fmt_reqwest_error(e: &reqwest::Error) -> String { +pub(crate) fn fmt_reqwest_error(e: &reqwest::Error) -> String { let mut msg = e.to_string(); let mut source: &dyn std::error::Error = e; while let Some(cause) = source.source() { @@ -65,7 +84,9 @@ fn fmt_reqwest_error(e: &reqwest::Error) -> String { /// /// Transport-level network errors (connect failure, timeout, mid-request, /// mid-body transfer, or body decode failure) and relay overload responses -/// (429 / 502 / 503 / 504) are retryable. `DeliveryUnknown` is never +/// (429 / 502 / 503 / 504) are retryable, as are a desktop that stayed +/// `rate_limited` (`Throttled`) and an adapter that stayed +/// unavailable (`Adapter`: nothing reached the relay). `DeliveryUnknown` is never /// retryable: the operation may already have executed. All other errors /// indicate a permanent failure: auth, bad input, builder errors, or logic /// errors. @@ -79,6 +100,7 @@ pub fn is_retryable_error(e: &CliError) -> bool { || net_err.is_decode() } CliError::Relay { status, .. } => matches!(status, 429 | 502 | 503 | 504), + CliError::Adapter(_) | CliError::Throttled(_) | CliError::Unavailable(_) => true, CliError::DeliveryUnknown(_) => false, _ => false, } @@ -103,6 +125,7 @@ pub fn exit_code(e: &CliError) -> i32 { CliError::Conflict(_) => 5, CliError::NotFound(_) => 1, CliError::DeliveryUnknown(_) => 2, + CliError::Adapter(_) | CliError::Throttled(_) | CliError::Unavailable(_) => 2, CliError::Other(_) => 4, } } @@ -125,6 +148,9 @@ pub fn print_error(e: &CliError) { CliError::Conflict(_) => "conflict", CliError::NotFound(_) => "not_found", CliError::DeliveryUnknown(_) => "delivery_unknown", + CliError::Adapter(_) => "adapter_error", + CliError::Throttled(_) => "rate_limited", + CliError::Unavailable(_) => "unavailable", CliError::Other(_) => "error", }; let obj = serde_json::json!({ diff --git a/crates/buzz-cli/src/federated_identity.rs b/crates/buzz-cli/src/federated_identity.rs new file mode 100644 index 0000000000..355a0e30be --- /dev/null +++ b/crates/buzz-cli/src/federated_identity.rs @@ -0,0 +1,992 @@ +//! Headless NIP-FI evidence: mint relay assertions from a NIP-FA adapter +//! session (`docs/nips/NIP-FA.md` §Relay assertion). +//! +//! The adapter session token (`BUZZ_FEDERATED_IDENTITY_TOKEN`) proves who the +//! user is; a NIP-98 proof proves the CLI controls the key. The adapter answers +//! with a short-lived assertion that the CLI attaches as +//! `Nostr-Federated-Identity: Bearer …` on every protected relay request. +//! +//! One assertion is cached per process and re-minted shortly before it +//! expires. The session token is sent only to the adapter's origin, never +//! through a redirect, and never appears in errors. +//! +//! A mint retries network failures, 429 and any 5xx with bounded backoff +//! (NIP-FA §Denials), then fails as [`CliError::Adapter`]: a pre-send failure +//! that relay retry loops never repeat and never report as possibly +//! delivered. A refusal is never retried, even when its body is unreadable. + +use std::time::Duration; + +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; + +use crate::cached_assertion::{unix_now, CachedAssertion}; +use crate::error::CliError; + +/// Environment variable carrying a NIP-FA adapter session token. +pub const FEDERATED_IDENTITY_TOKEN_ENV: &str = "BUZZ_FEDERATED_IDENTITY_TOKEN"; +/// Environment variable carrying the NIP-FA adapter base URL. +pub const FEDERATED_IDENTITY_ADAPTER_ENV: &str = "BUZZ_FEDERATED_IDENTITY_ADAPTER"; + +/// NIP-FA assertion endpoint, relative to the adapter base. +const ASSERTIONS_PATH: &str = "/v1/identity/assertions"; + +/// Largest adapter response body the CLI reads. +const MAX_RESPONSE_BYTES: usize = 64 * 1024; + +/// Attempts per mint, and the backoff ceiling before each retry. +const MINT_ATTEMPTS: usize = 3; +const MINT_BACKOFF: [Duration; MINT_ATTEMPTS - 1] = + [Duration::from_millis(250), Duration::from_millis(750)]; +/// The longest adapter `Retry-After` the CLI waits out before a retry. +const MINT_RETRY_AFTER_MAX: Duration = Duration::from_secs(30); + +/// A NIP-FA adapter session that mints NIP-FI assertions. +pub struct AdapterSession { + endpoint: url::Url, + /// The relay it mints for, in NIP-FA form, bound at creation. + relay_url: String, + token: String, + http: reqwest::Client, + cached: CachedAssertion, +} + +impl std::fmt::Debug for AdapterSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AdapterSession") + .field("endpoint", &self.endpoint.as_str()) + .finish_non_exhaustive() + } +} + +#[derive(serde::Deserialize)] +struct AssertionResponse { + assertion: String, + nostr_pubkey: String, + expires_at: u64, +} + +#[derive(serde::Deserialize)] +struct DenialBody { + error: String, +} + +/// The NIP-FA canonical relay URL: `wss://host[:port]`, default port omitted, +/// no trailing slash, path, query or fragment. +pub fn canonical_relay_url(relay_url: &str) -> Result { + // `wss://host` means 443, so an explicit `:80` would silently alias it; + // NIP-FA has no relay on port 80. The URL parser drops `:80` for + // `http`/`ws`, so read the authority as written. + if explicit_port(relay_url) == Some(80) { + return Err(CliError::Usage(format!( + "NIP-FA does not support a relay on port 80: {relay_url}" + ))); + } + let authority = buzz_core::tenant::relay_url_authority(relay_url); + if authority.is_empty() { + return Err(CliError::Usage(format!("invalid relay URL: {relay_url}"))); + } + Ok(format!("wss://{authority}")) +} + +/// The port written in `url`'s authority, as a number, if any. +fn explicit_port(url: &str) -> Option { + let rest = url.split_once("://")?.1; + let authority = rest.split(['/', '?', '#']).next()?; + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let (host, port) = host_port.rsplit_once(':')?; + if host.is_empty() || port.contains(']') { + return None; + } + port.parse().ok() +} + +/// An adapter `Retry-After`: delta-seconds or an HTTP-date in any of the +/// three RFC 9110 formats. +fn parse_retry_after(value: &str, now: u64) -> Option { + let value = value.trim(); + if let Ok(secs) = value.parse() { + return Some(Duration::from_secs(secs)); + } + let at = httpdate::parse_http_date(value) + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(Duration::from_secs(at.saturating_sub(now))) +} + +/// Read at most `MAX_RESPONSE_BYTES` of the body; `None` when it is larger. +async fn bounded_body(mut resp: reqwest::Response) -> Result>, reqwest::Error> { + let mut body = Vec::new(); + while let Some(chunk) = resp.chunk().await? { + if body.len() + chunk.len() > MAX_RESPONSE_BYTES { + return Ok(None); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) +} + +fn refused(detail: impl std::fmt::Display) -> CliError { + CliError::Auth(format!( + "the identity adapter refused the assertion request ({detail})" + )) +} + +/// Map a NIP-FA denial to the error the CLI reports (NIP-FA §Denials). +/// Retryable statuses (429, 5xx) are handled before this runs. +fn denial(status: u16, code: Option<&str>, relay_url: &str) -> CliError { + match (status, code) { + (401, Some("session_required" | "session_expired")) => CliError::Auth(format!( + "the identity adapter session is missing, invalid or expired ({}): sign in again \ + and update {FEDERATED_IDENTITY_TOKEN_ENV}", + code.unwrap_or_default() + )), + (403, Some("authorization_denied")) => CliError::Auth(format!( + "the identity adapter denied an assertion for this key on {relay_url} \ + (authorization_denied)" + )), + (403, Some("binding_mismatch")) => CliError::Auth( + "the identity adapter has bound this user to a different Nostr key \ + (binding_mismatch)" + .into(), + ), + (403, Some("invalid_proof")) => { + CliError::Auth("the identity adapter rejected the NIP-98 proof (invalid_proof)".into()) + } + (400, Some(code @ "invalid_request")) | (413, Some(code @ "request_too_large")) => { + refused(code) + } + (status, _) => refused(format!("HTTP {status}")), + } +} + +impl AdapterSession { + /// A session minting for `relay_url` from the adapter at `adapter_base`. + /// The base must be `https` (or `http` on a loopback host, for local + /// development) with no query, fragment or credentials. + pub fn new(adapter_base: &str, token: &str, relay_url: &str) -> Result { + let relay_url = canonical_relay_url(relay_url)?; + let invalid = |why: &str| CliError::Auth(format!("{FEDERATED_IDENTITY_ADAPTER_ENV} {why}")); + let base = url::Url::parse(adapter_base.trim()).map_err(|_| invalid("is not a URL"))?; + let loopback = match base.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(name)) => name == "localhost", + None => false, + }; + match base.scheme() { + "https" => {} + "http" if loopback => {} + _ => return Err(invalid("must use https")), + } + if base.query().is_some() + || base.fragment().is_some() + || !base.username().is_empty() + || base.password().is_some() + { + return Err(invalid("must not carry a query, fragment or credentials")); + } + let endpoint = url::Url::parse(&format!( + "{}{ASSERTIONS_PATH}", + base.as_str().trim_end_matches('/') + )) + .map_err(|_| invalid("is not a URL"))?; + let token = token.trim(); + if token.is_empty() || token.contains(['\r', '\n']) { + return Err(CliError::Auth(format!( + "{FEDERATED_IDENTITY_TOKEN_ENV} is not a usable token" + ))); + } + let http = reqwest::Client::builder() + // NIP-FA: never replay the session token or proof to a redirect target. + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .connect_timeout(Duration::from_secs(10)) + .build()?; + Ok(Self { + endpoint, + relay_url, + token: token.to_owned(), + http, + cached: CachedAssertion::default(), + }) + } + + /// The assertion for its relay, minted with `keys` on first use and + /// re-minted at its refresh point. Concurrent callers share one mint. + pub async fn assertion(&self, keys: &Keys) -> Result, CliError> { + self.cached + .get(async { self.mint(&self.relay_url, keys).await.map(Some) }) + .await + } + + /// Mint with bounded retries; a refusal or a definitive error returns at once. + async fn mint(&self, relay_url: &str, keys: &Keys) -> Result { + let mut attempt = 0; + loop { + match self.mint_once(relay_url, keys).await? { + Ok(issued) => return Ok(issued), + Err(transient) if attempt + 1 == MINT_ATTEMPTS => { + return Err(CliError::Adapter(format!( + "the identity adapter could not issue an assertion after \ + {MINT_ATTEMPTS} attempts ({transient}); retry later" + ))); + } + Err(transient) => { + let wait = transient.retry_after.map_or_else( + || MINT_BACKOFF[attempt].mul_f64(rand::random::()), + |d| d.min(MINT_RETRY_AFTER_MAX), + ); + tokio::time::sleep(wait).await; + attempt += 1; + } + } + } + } + + /// One mint attempt. The outer `Err` ends the mint; the inner `Err` is a + /// transient failure worth another attempt. + async fn mint_once( + &self, + relay_url: &str, + keys: &Keys, + ) -> Result, CliError> { + let pubkey = keys.public_key().to_hex(); + let body = serde_json::to_vec(&serde_json::json!({ + "relay_url": relay_url, + "nostr_pubkey": pubkey, + })) + .map_err(|e| CliError::Other(format!("assertion request serialization failed: {e}")))?; + + // A fresh proof per attempt: NIP-FA proofs are single-use. + let proof = nip98_event("POST", self.endpoint.as_str(), Some(&body))? + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?; + use base64::Engine; + let proof = base64::engine::general_purpose::STANDARD.encode(proof.as_json()); + let mut proof = reqwest::header::HeaderValue::try_from(format!("Nostr {proof}")) + .map_err(|_| CliError::Other("NIP-98 proof is not a valid header value".into()))?; + proof.set_sensitive(true); + let sent = self + .http + .post(self.endpoint.clone()) + .bearer_auth(&self.token) + .header("Nostr-Authorization", proof) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .body(body) + .send() + .await; + let resp = match sent { + Ok(resp) => resp, + Err(e) if e.is_builder() => return Err(CliError::Network(e.without_url())), + Err(e) => return Ok(Err(Transient::from(e))), + }; + let status = resp.status().as_u16(); + if status == 429 || resp.status().is_server_error() { + // NIP-FA: honor `Retry-After` (delta-seconds) on 429 and 503. + let retry_after = matches!(status, 429 | 503) + .then(|| resp.headers().get(reqwest::header::RETRY_AFTER)) + .flatten() + .and_then(|v| parse_retry_after(v.to_str().ok()?, unix_now())); + return Ok(Err(Transient { + why: format!("HTTP {status}"), + retry_after, + })); + } + let body = bounded_body(resp).await; + if status != 200 { + // The status is the answer; a lost or unreadable body only loses the code. + let code = body + .ok() + .flatten() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .map(|d| d.error); + return Err(denial(status, code.as_deref(), relay_url)); + } + match body { + Ok(body) => accept(body.as_deref(), &pubkey).map(Ok), + // A 200 whose body never arrived is a transport failure, not a rejected 200. + Err(e) => Ok(Err(Transient::from(e))), + } + } +} + +/// A mint failure worth another attempt, after `retry_after` when the +/// adapter gave one. +struct Transient { + why: String, + retry_after: Option, +} + +impl From for Transient { + fn from(e: reqwest::Error) -> Self { + Self { + why: crate::error::fmt_reqwest_error(&e.without_url()), + retry_after: None, + } + } +} + +impl std::fmt::Display for Transient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.why) + } +} + +/// Validate a 200 response (NIP-FA: a rejected 200 is a refusal). +fn accept(body: Option<&[u8]>, pubkey: &str) -> Result { + let response: AssertionResponse = body + .and_then(|b| serde_json::from_slice(b).ok()) + .ok_or_else(|| refused("unreadable assertion response"))?; + if response.nostr_pubkey != pubkey { + return Err(refused("assertion names a different key")); + } + if response.expires_at <= unix_now() { + return Err(refused("assertion already expired")); + } + match crate::cached_assertion::jws_exp(&response.assertion) { + None => return Err(refused("assertion is not a compact JWS")), + Some(exp) if exp != response.expires_at => { + return Err(refused("assertion exp does not match expires_at")); + } + Some(_) => {} + } + Ok(response.assertion) +} + +/// The NIP-98 HTTP auth event (kind:27235) for `method` + `url`: `u`, +/// `method`, a nonce (so rapid identical requests don't trip replay guards) +/// and, with a body, its `payload` hash. Mints and relay requests sign it. +pub(crate) fn nip98_event( + method: &str, + url: &str, + body: Option<&[u8]>, +) -> Result { + let tag = |parts: &[&str]| { + Tag::parse(parts.iter().copied()).map_err(|e| CliError::Other(format!("tag error: {e}"))) + }; + let mut tags = vec![ + tag(&["u", url])?, + tag(&["method", method])?, + tag(&["nonce", &uuid::Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(tag(&["payload", &hex::encode(Sha256::digest(body))])?); + } + Ok(EventBuilder::new(Kind::HttpAuth, "").tags(tags)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const RELAY: &str = "https://relay.example.com/"; + + #[test] + fn canonical_relay_url_follows_nip_fa() { + for (input, expected) in [ + ("https://relay.example.com", "wss://relay.example.com"), + ("https://Relay.Example.com/", "wss://relay.example.com"), + ("https://relay.example.com:443", "wss://relay.example.com"), + ( + "https://relay.example.com:8443/x?y", + "wss://relay.example.com:8443", + ), + ("http://localhost:3000", "wss://localhost:3000"), + ("https://[2001:db8::1]:8443", "wss://[2001:db8::1]:8443"), + ] { + assert_eq!(canonical_relay_url(input).unwrap(), expected, "{input}"); + } + assert!(canonical_relay_url("not a url").is_err()); + for port_80 in [ + "wss://relay.example.com:80", + "https://relay.example.com:80/x", + "http://relay.example.com:80", + "ws://user@relay.example.com:80", + "http://[::1]:80", + "http://relay.example.com:080", + ] { + assert!( + matches!(canonical_relay_url(port_80), Err(CliError::Usage(_))), + "{port_80}" + ); + } + assert!(canonical_relay_url("http://[::80]").is_ok()); + assert!(canonical_relay_url("http://relay.example.com:8080").is_ok()); + } + + #[test] + fn retry_after_takes_seconds_or_an_http_date() { + let now = 784_111_777; // Sun, 06 Nov 1994 08:49:37 GMT + assert_eq!(parse_retry_after(" 7 ", now), Some(Duration::from_secs(7))); + assert_eq!( + parse_retry_after("Sun, 06 Nov 1994 08:50:07 GMT", now), + Some(Duration::from_secs(30)) + ); + assert_eq!( + parse_retry_after("Sun, 06 Nov 1994 08:49:00 GMT", now), + Some(Duration::ZERO) + ); + assert_eq!( + parse_retry_after("Sunday, 06-Nov-94 08:50:07 GMT", now), + Some(Duration::from_secs(30)) + ); + assert_eq!( + parse_retry_after("Sun Nov 6 08:50:07 1994", now), + Some(Duration::from_secs(30)) + ); + assert_eq!(parse_retry_after("soon", now), None); + } + + #[test] + fn adapter_base_must_be_https_or_loopback_http() { + for ok in [ + "https://adapter.example", + "https://adapter.example/base/", + "http://127.0.0.1:8080", + "http://[::1]:8080", + "http://localhost:8080", + ] { + assert!(AdapterSession::new(ok, "token", RELAY).is_ok(), "{ok}"); + } + for bad in [ + "http://adapter.example", + "ftp://adapter.example", + "https://adapter.example/?q=1", + "https://adapter.example/#f", + "https://user:pw@adapter.example", + "adapter.example", + ] { + assert!(AdapterSession::new(bad, "token", RELAY).is_err(), "{bad}"); + } + assert!(AdapterSession::new("https://adapter.example", " ", RELAY).is_err()); + assert!(AdapterSession::new("https://adapter.example", "a\nb", RELAY).is_err()); + let session = AdapterSession::new("https://adapter.example/base/", "token", RELAY).unwrap(); + assert_eq!( + session.endpoint.as_str(), + "https://adapter.example/base/v1/identity/assertions" + ); + } + + // ----------------------------------------------------------------------- + // Mint against a mock NIP-FA adapter + // ----------------------------------------------------------------------- + + use std::sync::{Arc, Mutex as StdMutex}; + + use axum::http::{HeaderMap, StatusCode}; + use axum::response::IntoResponse; + use base64::Engine; + use nostr::{JsonUtil, Keys}; + + use super::test_support::{self, count, raw_adapter}; + + const TOKEN: &str = "adapter-session-token"; + + /// What the adapter saw: request headers and the JSON body. + type Seen = Arc>>; + + /// A mock adapter answering every assertion request with `respond(seen + /// request body)`. Returns the adapter base URL and the request log. + async fn adapter(respond: F) -> (String, Seen) + where + F: Fn(&serde_json::Value) -> (StatusCode, String) + Send + Sync + 'static, + { + let seen: Seen = Arc::default(); + let log = seen.clone(); + let respond = Arc::new(respond); + let app = axum::Router::new().route( + ASSERTIONS_PATH, + axum::routing::post(move |headers: HeaderMap, body: axum::body::Bytes| { + let log = log.clone(); + let respond = respond.clone(); + async move { + let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let (status, body) = respond(&json); + log.lock().unwrap().push((headers, json)); + (status, [("content-type", "application/json")], body).into_response() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), seen) + } + + /// A realistic NIP-FI assertion for `pubkey` expiring at `expires_at`. + fn issued(pubkey: &str, expires_at: u64) -> (StatusCode, String) { + issued_as( + pubkey, + expires_at, + &test_support::assertion(pubkey, expires_at), + ) + } + + fn issued_as(pubkey: &str, expires_at: u64, assertion: &str) -> (StatusCode, String) { + ( + StatusCode::OK, + serde_json::json!({ + "assertion": assertion, + "nostr_pubkey": pubkey, + "expires_at": expires_at, + }) + .to_string(), + ) + } + + fn denied(status: StatusCode, code: &str) -> (StatusCode, String) { + ( + status, + serde_json::json!({"error": code, "message": "denied"}).to_string(), + ) + } + + #[tokio::test] + async fn mint_sends_the_nip_fa_request_and_caches_per_relay() { + let (base, seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 300)).await; + let keys = Keys::generate(); + let credentials = keys.clone(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + + let first = session.assertion(&credentials).await.unwrap(); + assert!(first + .as_deref() + .and_then(crate::cached_assertion::jws_exp) + .is_some()); + for _ in 0..2 { + let assertion = session.assertion(&credentials).await.unwrap(); + assert_eq!(assertion, first); + } + assert_eq!(seen.lock().unwrap().len(), 1, "one mint serves the process"); + + let (headers, body) = seen.lock().unwrap()[0].clone(); + assert_eq!( + body, + serde_json::json!({ + "relay_url": "wss://relay.example.com", + "nostr_pubkey": keys.public_key().to_hex(), + }) + ); + assert_eq!(headers["authorization"], format!("Bearer {TOKEN}")); + // NIP-98 proof bound to the endpoint, method and body. + let proof = headers["nostr-authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap() + .to_owned(); + let proof = base64::engine::general_purpose::STANDARD + .decode(proof) + .unwrap(); + let proof = nostr::Event::from_json(proof).unwrap(); + proof.verify().unwrap(); + assert_eq!(proof.kind, nostr::Kind::HttpAuth); + assert_eq!(proof.pubkey, keys.public_key()); + let tag = |name: &str| { + proof + .tags + .iter() + .find(|t| t.kind().to_string() == name) + .and_then(|t| t.content()) + .map(str::to_owned) + }; + assert_eq!(tag("u").unwrap(), format!("{base}{ASSERTIONS_PATH}")); + assert_eq!(tag("method").unwrap(), "POST"); + let sent = serde_json::to_vec(&body).unwrap(); + assert_eq!( + tag("payload").unwrap(), + hex::encode(::digest(&sent)) + ); + } + + #[tokio::test] + async fn an_assertion_near_expiry_is_reminted() { + // A 2 s lifetime refreshes at half-life, so the second call re-mints. + let (base, seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 2)).await; + let credentials = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + session.assertion(&credentials).await.unwrap(); + tokio::time::sleep(Duration::from_millis(2_100)).await; + session.assertion(&credentials).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2); + } + + #[tokio::test] + async fn short_and_long_assertions_refresh_at_the_earlier_point() { + // 20 s refreshes at half-life (10 s); 300 s at half-life (150 s), + // earlier than 30 s before expiry. + for (lifetime, refresh) in [(20, 10), (300, 150)] { + let (base, seen) = adapter(move |req| { + issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + lifetime) + }) + .await; + let keys = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + session.assertion(&keys).await.unwrap(); + crate::cached_assertion::test_clock::advance(refresh - 1); + session.assertion(&keys).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 1, "lifetime {lifetime}"); + crate::cached_assertion::test_clock::advance(1); + session.assertion(&keys).await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 2, "lifetime {lifetime}"); + } + } + + /// NIP-FA §Denials: every status/code the adapter can answer, plus the + /// 200 responses a client must refuse. Retryable answers stay retryable + /// (`CliError::Relay` with the status) for the request's retry loop; + /// everything else is an auth error. No error ever carries the token. + #[tokio::test] + async fn denial_table() { + let other = Keys::generate().public_key().to_hex(); + type Respond = Box (StatusCode, String) + Send + Sync>; + let cases: Vec<(&str, Respond, Option, &str)> = vec![ + ( + "invalid_request", + Box::new(|_| denied(StatusCode::BAD_REQUEST, "invalid_request")), + None, + "invalid_request", + ), + ( + "session_required", + Box::new(|_| denied(StatusCode::UNAUTHORIZED, "session_required")), + None, + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + "session_expired", + Box::new(|_| denied(StatusCode::UNAUTHORIZED, "session_expired")), + None, + FEDERATED_IDENTITY_TOKEN_ENV, + ), + ( + "authorization_denied", + Box::new(|_| denied(StatusCode::FORBIDDEN, "authorization_denied")), + None, + "authorization_denied", + ), + ( + "invalid_proof", + Box::new(|_| denied(StatusCode::FORBIDDEN, "invalid_proof")), + None, + "invalid_proof", + ), + ( + "binding_mismatch", + Box::new(|_| denied(StatusCode::FORBIDDEN, "binding_mismatch")), + None, + "binding_mismatch", + ), + ( + "request_too_large", + Box::new(|_| denied(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large")), + None, + "request_too_large", + ), + ( + "unknown 4xx", + Box::new(|_| (StatusCode::IM_A_TEAPOT, "{}".into())), + None, + "HTTP 418", + ), + ( + "rate_limited", + Box::new(|_| denied(StatusCode::TOO_MANY_REQUESTS, "rate_limited")), + Some(429), + "HTTP 429", + ), + ( + "internal error", + Box::new(|_| (StatusCode::INTERNAL_SERVER_ERROR, String::new())), + Some(500), + "HTTP 500", + ), + ( + "not implemented", + Box::new(|_| (StatusCode::NOT_IMPLEMENTED, "".into())), + Some(501), + "HTTP 501", + ), + ( + "issuance_unavailable", + Box::new(|_| denied(StatusCode::SERVICE_UNAVAILABLE, "issuance_unavailable")), + Some(503), + "HTTP 503", + ), + ( + "exp disagrees with expires_at", + Box::new(|pk| { + let exp = unix_now() + 300; + issued_as(pk, exp, &test_support::assertion(pk, exp + 60)) + }), + None, + "does not match expires_at", + ), + ( + "empty signature", + Box::new(|pk| { + let exp = unix_now() + 300; + let jws = test_support::assertion(pk, exp); + let unsigned = &jws[..=jws.rfind('.').unwrap()]; + issued_as(pk, exp, unsigned) + }), + None, + "compact JWS", + ), + ( + "four segments", + Box::new(|pk| { + let exp = unix_now() + 300; + issued_as( + pk, + exp, + &format!("{}.c2ln", test_support::assertion(pk, exp)), + ) + }), + None, + "compact JWS", + ), + ( + "header is not JSON", + Box::new(|pk| issued_as(pk, unix_now() + 300, "bm90LWpzb24.e30.c2ln")), + None, + "compact JWS", + ), + ( + "wrong pubkey", + Box::new(move |_| issued(&other, unix_now() + 300)), + None, + "different key", + ), + ( + "expired", + Box::new(|pk| issued(pk, unix_now() - 1)), + None, + "expired", + ), + ( + "not a compact JWS", + Box::new(|pk| issued_as(pk, unix_now() + 300, "not-a-jws")), + None, + "compact JWS", + ), + ( + "unreadable", + Box::new(|_| (StatusCode::OK, "{\"assertion\":".into())), + None, + "unreadable", + ), + ( + "oversized", + Box::new(|_| (StatusCode::OK, "x".repeat(MAX_RESPONSE_BYTES + 1))), + None, + "unreadable", + ), + ]; + for (name, respond, retry_status, expected) in cases { + let (base, seen) = + adapter(move |req| respond(req["nostr_pubkey"].as_str().unwrap_or_default())).await; + let credentials = Keys::generate(); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&credentials).await.unwrap_err(); + let message = err.to_string(); + assert!(message.contains(expected), "{name}: {message}"); + assert!(!message.contains(TOKEN), "{name}: token leaked: {message}"); + // Transient answers are retried, then fail as a retryable + // adapter error; refusals are a single attempt. + let attempts = match (retry_status, &err) { + (Some(_), CliError::Adapter(_)) => MINT_ATTEMPTS, + (None, CliError::Auth(_)) => 1, + _ => panic!("{name}: unexpected error class {err:?}"), + }; + assert_eq!( + crate::error::is_retryable_error(&err), + retry_status.is_some(), + "{name}" + ); + assert_eq!(seen.lock().unwrap().len(), attempts, "{name}: attempts"); + // A failed mint is not cached: the next call asks again. + let _ = session.assertion(&credentials).await; + assert_eq!( + seen.lock().unwrap().len(), + 2 * attempts, + "{name}: failure not cached" + ); + } + } + + /// NIP-FA: the token and proof never follow a redirect. + #[tokio::test] + async fn a_redirect_is_refused_and_never_followed() { + let (target, target_seen) = + adapter(|req| issued(req["nostr_pubkey"].as_str().unwrap(), unix_now() + 300)).await; + let location = format!("{target}{ASSERTIONS_PATH}"); + let app = axum::Router::new().route( + ASSERTIONS_PATH, + axum::routing::post(move || { + let location = location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [("location", location)], + String::new(), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&Keys::generate()).await.unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("HTTP 307")), + "{err:?}" + ); + assert!(target_seen.lock().unwrap().is_empty(), "redirect followed"); + } + + #[tokio::test] + async fn an_unreachable_adapter_is_retried_then_a_retryable_adapter_error_without_the_token() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let session = AdapterSession::new(&base, TOKEN, RELAY).unwrap(); + let err = session.assertion(&Keys::generate()).await.unwrap_err(); + assert!( + matches!(&err, CliError::Adapter(m) if m.contains("3 attempts")), + "{err:?}" + ); + assert!(crate::error::is_retryable_error(&err)); + assert!(!format!("{err:?}").contains(TOKEN)); + } + + /// A refusal whose body is cut off is still a refusal: one attempt, not + /// retryable. A cut-off 200 is a transport failure and is retried. + #[tokio::test] + async fn a_truncated_body_keeps_the_status_meaning() { + let credentials = Keys::generate(); + let (base, seen) = raw_adapter(test_support::TRUNCATED_403).await; + let err = AdapterSession::new(&base, TOKEN, RELAY) + .unwrap() + .assertion(&credentials) + .await + .unwrap_err(); + assert!( + matches!(&err, CliError::Auth(m) if m.contains("HTTP 403")), + "{err:?}" + ); + assert!(!crate::error::is_retryable_error(&err)); + assert_eq!(count(&seen), 1, "a refusal is not retried"); + + let (base, seen) = raw_adapter( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 200\r\nconnection: close\r\n\r\n{\"assert", + ) + .await; + let err = AdapterSession::new(&base, TOKEN, RELAY) + .unwrap() + .assertion(&credentials) + .await + .unwrap_err(); + assert!(matches!(&err, CliError::Adapter(_)), "{err:?}"); + assert_eq!(count(&seen), MINT_ATTEMPTS); + } + + #[test] + fn debug_output_never_contains_the_token() { + let session = + AdapterSession::new("https://adapter.example", "s3cr3t-token", RELAY).unwrap(); + assert!(!format!("{session:?}").contains("s3cr3t-token")); + } +} + +/// Fixtures shared with the relay-client tests. +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// A NIP-FI human assertion shaped like a real adapter's: ES256 header + /// with `typ: nip-fi+jwt`, the NIP-FI claims, and a 64-byte signature. + /// The CLI never verifies the signature; the relay does. + pub(crate) fn assertion(pubkey: &str, exp: u64) -> String { + let segment = |v: serde_json::Value| URL_SAFE_NO_PAD.encode(v.to_string()); + format!( + "{}.{}.{}", + segment(serde_json::json!({"alg": "ES256", "typ": "nip-fi+jwt", "kid": "k1"})), + segment(serde_json::json!({ + "iss": "https://adapter.example", + "sub": "user-1", + "aud": "https://relay.example.com", + "iat": exp.saturating_sub(300), + "exp": exp, + "jti": "a1b2c3", + "nostr_pubkey": pubkey, + })), + URL_SAFE_NO_PAD.encode([7u8; 64]), + ) + } + + /// An adapter that answers every connection with the raw HTTP bytes + /// `response` and then closes it, so a short body is a truncated one. + /// Returns the base URL and the number of requests it answered. + pub(crate) async fn raw_adapter(response: &'static str) -> (String, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let count = Arc::new(AtomicUsize::new(0)); + let seen = count.clone(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + seen.fetch_add(1, Ordering::SeqCst); + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + // Read the head and the (small) body before answering. + while let Ok(n) = socket.read(&mut buf).await { + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + let text = String::from_utf8_lossy(&request); + if let Some(head_end) = text.find("\r\n\r\n") { + let length = text[..head_end] + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0); + if request.len() >= head_end + 4 + length { + break; + } + } + } + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + } + }); + (base, count) + } + + /// Raw responses for [`raw_adapter`]. + pub(crate) const HTTP_500: &str = + "HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + pub(crate) const HTTP_503: &str = + "HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + /// A 403 whose body is cut off after a few bytes. + pub(crate) const TRUNCATED_403: &str = "HTTP/1.1 403 Forbidden\r\ncontent-type: application/json\r\ncontent-length: 200\r\nconnection: close\r\n\r\n{\"error\":\"auth"; + + pub(crate) fn count(counter: &AtomicUsize) -> usize { + counter.load(Ordering::SeqCst) + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index ceea5af059..40c58992a7 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,7 +1,12 @@ pub mod agent_management; +mod cached_assertion; mod client; mod commands; +mod credentials; +#[cfg(unix)] +mod desktop_provider; mod error; +mod federated_identity; mod help_tree; mod links; mod validate; @@ -9,7 +14,6 @@ mod validate; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use client::BuzzClient; use error::CliError; -use nostr::Keys; use uuid::Uuid; /// Run the Buzz CLI from raw arguments (including `argv[0]`). @@ -125,8 +129,13 @@ Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] - BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] + BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [unless a Buzz desktop provides it] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] + BUZZ_FEDERATED_IDENTITY_TOKEN Your NIP-FA sign-in session token [optional] + BUZZ_FEDERATED_IDENTITY_ADAPTER NIP-FA adapter base URL [optional] + Set both for relays that enforce federated identity (NIP-FI). The token + is a human session for your own key, not an agent grant. The CLI requests + an assertion, the adapter mints it, and the CLI sends it on every request. BUZZ_OUTPUT_FORMAT Default --format; unknown values fall back to json [optional] The 'pack' subcommand runs locally and does not require a relay connection. @@ -329,11 +338,60 @@ enum Cmd { /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), + /// Credentials: pair this CLI with the running Buzz desktop + #[command(subcommand)] + Auth(AuthCmd), /// Community moderation — reports queue, bans, timeouts, audit trail #[command(subcommand)] Moderation(ModerationCmd), } +#[derive(Subcommand)] +enum AuthCmd { + /// The Buzz desktop as a credential provider (macOS and Linux) + #[command(subcommand)] + Desktop(DesktopAuthCmd), +} + +#[derive(Subcommand)] +enum DesktopAuthCmd { + /// Ask the running Buzz desktop to let this CLI act as you on --relay. + /// Approve in Buzz; the token is stored in the OS keychain. + Pair { + /// Name shown in the desktop's approval prompt + #[arg(long, default_value = "buzz CLI")] + name: String, + /// Desktop instance, when more than one is running + #[arg(long)] + instance: Option, + }, + /// Forget paired tokens (all instances unless --instance). Local only. + Unpair { + #[arg(long)] + instance: Option, + }, +} + +#[cfg(unix)] +async fn run_auth(cmd: AuthCmd, relay_url: &str) -> Result<(), CliError> { + let AuthCmd::Desktop(cmd) = cmd; + let out = match cmd { + DesktopAuthCmd::Pair { name, instance } => { + desktop_provider::pair(relay_url, &name, instance.as_deref()).await? + } + DesktopAuthCmd::Unpair { instance } => desktop_provider::unpair(instance.as_deref())?, + }; + println!("{out}"); + Ok(()) +} + +#[cfg(not(unix))] +async fn run_auth(_: AuthCmd, _: &str) -> Result<(), CliError> { + Err(CliError::Usage( + "the Buzz desktop credential provider is not available on Windows yet".into(), + )) +} + #[derive(Clone, Copy, clap::ValueEnum)] pub enum RespondToArg { #[value(name = "owner-only")] @@ -2275,7 +2333,7 @@ pub enum ModerationCmd { /// and the BIP-340 signature. Inputs that are already valid JSON — or not /// recognizable as the shorthand — are returned unchanged so the strict /// parser reports the error on the original bytes. -fn normalize_auth_tag_input(input: &str) -> String { +pub(crate) fn normalize_auth_tag_input(input: &str) -> String { let trimmed = input.trim(); if serde_json::from_str::(trimmed).is_ok() { return trimmed.to_owned(); @@ -2296,9 +2354,28 @@ fn normalize_auth_tag_input(input: &str) -> String { trimmed.to_owned() } -async fn run(cli: Cli) -> Result<(), CliError> { - let relay_url = client::normalize_relay_url(&cli.relay); +/// Binds the configured community: `auth` pairs it, anything else builds +/// the client. A desktop binds the whole community URL, trailing slash +/// included, so pairing and the credential chain take it as configured; only +/// requests use the trimmed base, which the client derives. The two steps are parameters so a test can +/// run them against a fake desktop. +pub(crate) async fn connect( + configured: &str, + command: Cmd, + pair: impl AsyncFnOnce(AuthCmd, &str) -> Result<(), CliError>, + credentials: impl AsyncFnOnce(&str) -> Result, +) -> Result, CliError> { + // Pairing runs before any credential exists. + if let Cmd::Auth(sub) = command { + pair(sub, configured).await?; + return Ok(None); + } + let credentials = credentials(configured).await?; + let client = BuzzClient::new(configured.to_owned(), credentials)?; + Ok(Some((command, client))) +} +async fn run(cli: Cli) -> Result<(), CliError> { // Pack commands are local-only — no relay connection needed. if let Cmd::Pack(ref sub) = cli.command { return match sub { @@ -2307,44 +2384,23 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } - // Auth: private key is required for all relay operations. - // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { - CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into()) - })?; - let keys = Keys::parse(&private_key_str) - .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; - - // NIP-OA: parse and verify the auth tag if provided. - // - // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw - // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input - // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict - // JSON; all validation and signature verification happen on the strict - // path below, unchanged. - let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref input) if !input.is_empty() => { - let json = normalize_auth_tag_input(input); - let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) - .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { - CliError::Auth(format!( - "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", - keys.public_key().to_hex() - )) - })?; - // Canonical wire form derives from the parsed-and-verified tag - // (same shape as buzz-acp's RestClient), never from raw input. - let canonical = serde_json::to_string(tag.as_slice()) - .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; - (Some(tag), Some(canonical)) - } - _ => (None, None), + // Credential chain: the first link that applies to this relay is the + // identity (see `credentials`). + let settings = credentials::Settings { + private_key: cli.private_key, + auth_tag: cli.auth_tag, + ..Default::default() + } + .with_env_evidence(); + let Some((command, client)) = connect(&cli.relay, cli.command, run_auth, async |relay| { + credentials::resolve(settings, relay).await + }) + .await? + else { + return Ok(()); }; - let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; - - match cli.command { + match command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, @@ -2368,7 +2424,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, - Cmd::Pack(_) => unreachable!("handled above"), + Cmd::Pack(_) | Cmd::Auth(_) => unreachable!("handled above"), } } @@ -2561,6 +2617,7 @@ mod tests { fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ "agents", + "auth", "canvas", "channels", "dms", diff --git a/crates/buzz-core/src/engram.rs b/crates/buzz-core/src/engram.rs index 7f38347f80..f2f30c3b6e 100644 --- a/crates/buzz-core/src/engram.rs +++ b/crates/buzz-core/src/engram.rs @@ -438,6 +438,27 @@ pub fn build_event( body: &Body, created_at: u64, ) -> Result { + let plaintext = encode_body(body)?; + let k_c = conversation_key(agent_keys.secret_key(), owner_pubkey); + let ciphertext = nip44::encrypt( + agent_keys.secret_key(), + owner_pubkey, + &plaintext, + Version::V2, + ) + .map_err(|e| EngramError::Encrypt(e.to_string()))?; + let d = d_tag(&k_c, body.slug()); + event_builder(owner_pubkey, &d, ciphertext, created_at)? + .sign_with_keys(agent_keys) + .map_err(|e| EngramError::Sign(e.to_string())) +} + +/// The NIP-44 plaintext for `body`. Returns `BodyTooLarge` if it exceeds +/// 65,535 bytes. +/// +/// Together with [`event_builder`], this lets a caller whose key lives behind +/// a signer build an engram from separate encrypt, `d`-tag and sign steps. +pub fn encode_body(body: &Body) -> Result { let plaintext = body.to_json_bytes(); if plaintext.len() > NIP44_PLAINTEXT_MAX { return Err(EngramError::BodyTooLarge(plaintext.len())); @@ -446,30 +467,28 @@ pub fn build_event( // this is always Ok. We still verify rather than `.expect()` so a future // change to the serializer can't silently introduce a panic on the hot // path. - let plaintext_str = std::str::from_utf8(&plaintext) - .map_err(|e| EngramError::Encrypt(format!("body JSON not UTF-8: {e}")))?; + String::from_utf8(plaintext) + .map_err(|e| EngramError::Encrypt(format!("body JSON not UTF-8: {e}"))) +} - let k_c = conversation_key(agent_keys.secret_key(), owner_pubkey); - let ciphertext = nip44::encrypt( - agent_keys.secret_key(), - owner_pubkey, - plaintext_str, - Version::V2, - ) - .map_err(|e| EngramError::Encrypt(e.to_string()))?; - - let d = d_tag(&k_c, body.slug()); +/// The unsigned `kind:30174` event for an already-encrypted body: `d` is the +/// slug's [`d_tag`], `ciphertext` the NIP-44 payload to the owner. +pub fn event_builder( + owner_pubkey: &PublicKey, + d: &str, + ciphertext: String, + created_at: u64, +) -> Result { let tags = vec![ - Tag::parse(["d", &d]).map_err(|e| EngramError::Encrypt(e.to_string()))?, + Tag::parse(["d", d]).map_err(|e| EngramError::Encrypt(e.to_string()))?, Tag::parse(["p", &owner_pubkey.to_hex()]) .map_err(|e| EngramError::Encrypt(e.to_string()))?, ]; - - EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext) - .tags(tags) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(agent_keys) - .map_err(|e| EngramError::Sign(e.to_string())) + Ok( + EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)), + ) } /// Validate an event against *Head selection* rules (1) and (5) and return @@ -492,6 +511,40 @@ pub fn validate_and_decrypt( my_seckey: &SecretKey, their_pubkey: &PublicKey, ) -> Result { + let d_value = validate_envelope(event, expected_agent, expected_owner)?; + + // Decrypt. `K_c` is symmetric per NIP-44, so the caller's `(my_seckey, + // their_pubkey)` pair yields the same conversation key regardless of + // whether the caller is the agent or the owner. + let plaintext = nip44::decrypt(my_seckey, their_pubkey, &event.content) + .map_err(|_| EngramError::Decrypt)?; + let body = Body::from_json_bytes(plaintext.as_bytes())?; + + // Rule (4): body slug re-derives to the event's d tag. + let k_c = conversation_key(my_seckey, their_pubkey); + check_d_tag(&d_value, &d_tag(&k_c, body.slug()))?; + Ok(body) +} + +/// Rule (4): the decrypted body's slug must re-derive (`derived`) to the +/// event's `d` tag (`d_value`, from [`validate_envelope`]). +pub fn check_d_tag(d_value: &str, derived: &str) -> Result<(), EngramError> { + if derived != d_value { + return Err(EngramError::InvalidEnvelope( + "body slug does not re-derive to d tag".into(), + )); + } + Ok(()) +} + +/// The envelope half of [`validate_and_decrypt`]: kind, author, exactly one +/// canonical `d` tag and exactly one `p` tag naming the owner. Returns the +/// `d` value for [`check_d_tag`] once the content is decrypted. +pub fn validate_envelope( + event: &Event, + expected_agent: &PublicKey, + expected_owner: &PublicKey, +) -> Result { if event.kind.as_u16() as u32 != KIND_AGENT_ENGRAM { return Err(EngramError::InvalidEnvelope(format!( "wrong kind: {}", @@ -537,23 +590,7 @@ pub fn validate_and_decrypt( "p tag != expected_owner".into(), )); } - - // Decrypt. `K_c` is symmetric per NIP-44, so the caller's `(my_seckey, - // their_pubkey)` pair yields the same conversation key regardless of - // whether the caller is the agent or the owner. - let plaintext = nip44::decrypt(my_seckey, their_pubkey, &event.content) - .map_err(|_| EngramError::Decrypt)?; - let body = Body::from_json_bytes(plaintext.as_bytes())?; - - // Rule (4): body slug re-derives to the event's d tag. - let k_c = conversation_key(my_seckey, their_pubkey); - let derived = d_tag(&k_c, body.slug()); - if derived != d_value { - return Err(EngramError::InvalidEnvelope( - "body slug does not re-derive to d tag".into(), - )); - } - Ok(body) + Ok(d_value) } /// Pick the head from a set of events targeting the same slug — greatest diff --git a/crates/buzz-test-client/src/lib.rs b/crates/buzz-test-client/src/lib.rs index c30fa0f8aa..25e47dbe4c 100644 --- a/crates/buzz-test-client/src/lib.rs +++ b/crates/buzz-test-client/src/lib.rs @@ -71,7 +71,10 @@ impl From for TestClientError { WsClientError::EventBuilder(s) => TestClientError::EventBuilder(s), WsClientError::Url(s) => TestClientError::Url(s), WsClientError::Timeout => TestClientError::Timeout, - WsClientError::ConnectionClosed => TestClientError::ConnectionClosed, + // Relay e2e tests treat a policy close as a close. + WsClientError::ConnectionClosed | WsClientError::PolicyClose(_) => { + TestClientError::ConnectionClosed + } WsClientError::UnexpectedMessage(s) => TestClientError::UnexpectedMessage(s), WsClientError::AuthFailed(s) => TestClientError::AuthFailed(s), WsClientError::EventRejected(s) => TestClientError::EventRejected(s), diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index c2b1cc8772..e69941293a 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, Keys, Tag}; +use nostr::{Event, NostrSigner, Tag}; use serde_json::{json, Value}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -26,6 +26,20 @@ pub const AUTH_OK_TIMEOUT_SECS: u64 = 20; /// Seconds to wait for the relay's OK response to a published event. pub const PUBLISH_OK_TIMEOUT_SECS: u64 = 30; +/// Cap on a graceful close. +pub const CLOSE_TIMEOUT_SECS: u64 = 5; + +/// Run one network phase, writes included, within its read `wait` plus a +/// second, so a read that times out reports its own error first. +async fn phase( + wait: Duration, + io: impl std::future::Future>, +) -> Result { + tokio::time::timeout(wait + Duration::from_secs(1), io) + .await + .map_err(|_| WsClientError::Timeout)? +} + /// A NIP-42-capable WebSocket connection to a Nostr relay. pub struct NostrWsConnection { ws: WsStream, @@ -37,29 +51,29 @@ pub struct NostrWsConnection { } impl NostrWsConnection { - /// Connects to the relay at `url` and performs NIP-42 authentication with `keys`. + /// Connects to the relay at `url` and performs NIP-42 authentication with `signer`. /// /// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event. - pub async fn connect_authenticated( + pub async fn connect_authenticated( url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result { - Self::connect_authenticated_with_headers(url, keys, auth_tag, HeaderMap::new()).await + Self::connect_authenticated_with_headers(url, signer, auth_tag, HeaderMap::new()).await } /// [`Self::connect_authenticated`], sending `headers` on the WebSocket upgrade request. /// /// Use this for credentials the relay checks at upgrade time, such as a NIP-FI /// `Nostr-Federated-Identity` assertion that must pair with the NIP-42 AUTH key. - pub async fn connect_authenticated_with_headers( + pub async fn connect_authenticated_with_headers( url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, headers: HeaderMap, ) -> Result { let mut conn = Self::connect_with_headers(url, headers).await?; - conn.authenticate(keys, auth_tag).await?; + conn.authenticate(signer, auth_tag).await?; Ok(conn) } @@ -125,26 +139,29 @@ impl NostrWsConnection { } } - /// Performs NIP-42 authentication using `keys` against the connected relay. + /// Performs NIP-42 authentication using `signer` against the connected relay. /// /// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event. - pub async fn authenticate( + pub async fn authenticate( &mut self, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result<(), WsClientError> { - let challenge = self - .wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS)) - .await?; + // Each network phase is capped as a whole, writes (and Pongs sent + // while reading) included. Signing between them is the signer's to + // bound: a remote signer may hold it for a person's decision. + let wait = Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS); + let challenge = phase(wait, self.wait_for_auth_challenge(wait)).await?; - let auth_event = build_auth_event(&challenge, &self.relay_url, keys, auth_tag)?; + let auth_event = build_auth_event(&challenge, &self.relay_url, signer, auth_tag).await?; let event_id = auth_event.id.to_hex(); - self.send_raw(&json!(["AUTH", auth_event])).await?; - - let ok = self - .wait_for_ok(&event_id, Duration::from_secs(AUTH_OK_TIMEOUT_SECS)) - .await?; + let wait = Duration::from_secs(AUTH_OK_TIMEOUT_SECS); + let ok = phase(wait, async { + self.send_raw(&json!(["AUTH", auth_event])).await?; + self.wait_for_ok(&event_id, wait).await + }) + .await?; if !ok.accepted { return Err(WsClientError::AuthFailed(ok.message)); } @@ -156,9 +173,12 @@ impl NostrWsConnection { /// Sends a signed event to the relay and waits for the OK response. pub async fn send_event(&mut self, event: Event) -> Result { let event_id = event.id.to_hex(); - self.send_raw(&json!(["EVENT", event])).await?; - self.wait_for_ok(&event_id, Duration::from_secs(PUBLISH_OK_TIMEOUT_SECS)) - .await + let wait = Duration::from_secs(PUBLISH_OK_TIMEOUT_SECS); + phase(wait, async { + self.send_raw(&json!(["EVENT", event])).await?; + self.wait_for_ok(&event_id, wait).await + }) + .await } /// Receives the next relay message, waiting up to `timeout_dur`. @@ -185,9 +205,11 @@ impl NostrWsConnection { Ok((Instant::now(), msg)) } - /// Closes the WebSocket connection gracefully. + /// Closes the WebSocket connection gracefully, within [`CLOSE_TIMEOUT_SECS`]. pub async fn disconnect(mut self) -> Result<(), WsClientError> { - self.ws.close(None).await?; + tokio::time::timeout(Duration::from_secs(CLOSE_TIMEOUT_SECS), self.ws.close(None)) + .await + .map_err(|_| WsClientError::Timeout)??; Ok(()) } @@ -218,7 +240,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -278,7 +300,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -332,7 +354,7 @@ impl NostrWsConnection { Message::Ping(data) => { self.ws.send(Message::Pong(data)).await?; } - Message::Close(_) => return Err(WsClientError::ConnectionClosed), + Message::Close(frame) => return Err(closed(frame)), _ => {} } } @@ -341,19 +363,24 @@ impl NostrWsConnection { /// One-shot helper: connect, authenticate, send one event, disconnect. /// -/// Establishes a fresh WebSocket connection, completes NIP-42 authentication, -/// publishes `event`, waits for the relay's OK response, then closes the -/// connection. The entire operation is bounded by `timeout_secs`. -pub async fn publish_event( +/// Establishes a fresh WebSocket connection carrying `headers` on the upgrade +/// (e.g. a NIP-FI `Nostr-Federated-Identity` assertion), completes NIP-42 +/// authentication with `signer`, publishes `event`, waits for the relay's OK +/// response, then closes the connection. The entire operation is bounded by +/// `timeout_secs`. +pub async fn publish_event( relay_url: &str, event: Event, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, + headers: HeaderMap, timeout_secs: u64, ) -> Result { let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async { - let mut conn = NostrWsConnection::connect(relay_url).await?; - conn.authenticate(keys, auth_tag).await?; + let mut conn = NostrWsConnection::connect_authenticated_with_headers( + relay_url, signer, auth_tag, headers, + ) + .await?; let ok = conn.send_event(event).await?; let _ = conn.disconnect().await; Ok::<_, WsClientError>(ok) @@ -363,6 +390,18 @@ pub async fn publish_event( result } +/// The error for a close frame: a policy close keeps its reason, since a +/// relay denial is not an ordinary connection loss. +fn closed(frame: Option) -> WsClientError { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + match frame { + Some(frame) if frame.code == CloseCode::Policy => { + WsClientError::PolicyClose(frame.reason.to_string()) + } + _ => WsClientError::ConnectionClosed, + } +} + #[cfg(test)] mod tests { use super::*; @@ -420,4 +459,42 @@ mod tests { Some(HeaderValue::from_static("Bearer header.payload.signature")) ); } + + /// A relay that answers NIP-42 AUTH by closing with `code`, as the relay + /// does on a NIP-FI key mismatch (1008). + async fn close_after_auth(code: u16) -> WsClientError { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + ws.send(Message::Text(r#"["AUTH","challenge"]"#.into())) + .await + .unwrap(); + let _auth = ws.next().await; + ws.send(Message::Close(Some(CloseFrame { + code: CloseCode::from(code), + reason: "auth-required: key mismatch".into(), + }))) + .await + .unwrap(); + }); + NostrWsConnection::connect_authenticated(&url, &nostr::Keys::generate(), None) + .await + .err() + .unwrap() + } + + #[tokio::test] + async fn a_policy_close_keeps_its_reason_and_other_closes_do_not() { + let err = close_after_auth(1008).await; + assert!( + matches!(&err, WsClientError::PolicyClose(r) if r.contains("key mismatch")), + "{err:?}" + ); + let err = close_after_auth(1000).await; + assert!(matches!(err, WsClientError::ConnectionClosed), "{err:?}"); + } } diff --git a/crates/buzz-ws-client/src/error.rs b/crates/buzz-ws-client/src/error.rs index c8781bb591..429d50b4eb 100644 --- a/crates/buzz-ws-client/src/error.rs +++ b/crates/buzz-ws-client/src/error.rs @@ -27,6 +27,11 @@ pub enum WsClientError { #[error("Connection closed unexpectedly")] ConnectionClosed, + /// The relay closed the connection with a policy close (1008), as a + /// NIP-FI denial does; the string is the close reason. + #[error("Connection refused by relay policy: {0}")] + PolicyClose(String), + /// The relay sent a message that was not expected at this point. #[error("Unexpected relay message: {0}")] UnexpectedMessage(String), @@ -49,3 +54,20 @@ impl From for WsClientError { WsClientError::EventBuilder(e.to_string()) } } + +impl WsClientError { + /// The HTTP status and body when the relay refused the WebSocket upgrade + /// with an HTTP response (e.g. a NIP-FI `401`/`403` decided at upgrade time). + pub fn upgrade_rejection(&self) -> Option<(u16, String)> { + match self { + WsClientError::WebSocket(tokio_tungstenite::tungstenite::Error::Http(response)) => { + let body = response.body().as_deref().unwrap_or_default(); + Some(( + response.status().as_u16(), + String::from_utf8_lossy(body).into_owned(), + )) + } + _ => None, + } + } +} diff --git a/crates/buzz-ws-client/src/message.rs b/crates/buzz-ws-client/src/message.rs index 3c646bc8ab..fffc63e48a 100644 --- a/crates/buzz-ws-client/src/message.rs +++ b/crates/buzz-ws-client/src/message.rs @@ -1,4 +1,4 @@ -use nostr::{Event, EventBuilder, Keys, RelayUrl, Tag}; +use nostr::{Event, EventBuilder, NostrSigner, RelayUrl, Tag}; use serde_json::Value; use crate::error::WsClientError; @@ -166,15 +166,15 @@ pub fn parse_relay_message(text: &str) -> Result { } } -/// Builds a NIP-42 AUTH event, optionally injecting a NIP-OA auth tag. +/// Builds a NIP-42 AUTH event signed by `signer`, optionally injecting a NIP-OA auth tag. /// /// The `auth_tag` parameter allows callers to attach a workspace-scoped /// authorization tag (e.g. `["auth", ""]`) alongside the standard /// relay and challenge tags required by NIP-42. -pub fn build_auth_event( +pub async fn build_auth_event( challenge: &str, relay_url: &str, - keys: &Keys, + signer: &S, auth_tag: Option<&Tag>, ) -> Result { let url = RelayUrl::parse(relay_url).map_err(|e| WsClientError::Url(e.to_string()))?; @@ -185,6 +185,7 @@ pub fn build_auth_event( builder }; builder - .sign_with_keys(keys) + .sign(signer) + .await .map_err(|e| WsClientError::EventBuilder(e.to_string())) } diff --git a/docs/local-credential-provider.md b/docs/local-credential-provider.md new file mode 100644 index 0000000000..2124572c62 --- /dev/null +++ b/docs/local-credential-provider.md @@ -0,0 +1,424 @@ +# Local credential provider + +`draft` + +This document defines how a local process, such as the `buzz` CLI, asks a running +Buzz Desktop to act as one Nostr identity: to give its public key, sign events, +encrypt and decrypt with NIP-44, return NIP-FI evidence for a relay, and return +its NIP-OA `auth` tag. The private key and the sign-in session stay in the +desktop. + +The CLI is the reference client and lives in this repository +(`crates/buzz-cli`). Buzz Desktop ([block/buzz-app](https://github.com/block/buzz-app)) +is the reference provider. Any client or provider that follows this document +interoperates with both. + +## Goals + +- **One identity per caller.** A caller holds a token for exactly one identity. + The desktop never decides on the caller's behalf which identity to use. +- **An agent's process gets only that agent's token.** The desktop gives a + process it launches for an agent a token for that agent alone. Acting as the + owner takes the owner's own approval, given when pairing. +- **The desktop sees what it signs.** The caller sends the complete unsigned + event; the desktop classifies it into an operation and applies a policy before + signing. There is no blind signing of opaque bytes. +- **Old and new versions interoperate.** The bundled CLI follows the desktop's + pinned revision of this repository, but a user can also run a different CLI. + An older client must work against a newer desktop, and the reverse. + +## Non-goals + +- Remote signing. The transport is loopback only. [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) + covers remote signers. +- Windows. Version 1 has no Windows transport. See [Platforms](#platforms). +- Deciding which approval UI the desktop shows. This document fixes what needs + approval, not how it is presented. + +## Terms + +- **Provider**: the desktop process that holds the key and answers requests. +- **Caller**: the local process that sends requests. +- **Token**: an opaque bearer secret that names one identity and the relays it + may act on. Tokens carry at least 128 bits of entropy. +- **Instance**: one installed desktop build, named by a stable identifier such + as its bundle identifier. Production and staging builds are separate + instances and issue separate tokens. +- **Bound relay**: a community relay URL a token may act on. Relay URLs are + compared whole after URL normalization: `http`/`https` read as `ws`/`wss`, + lowercase host, no default port, `/` for an empty path. The path and query + count, because one host can serve several communities. A caller sends the + bound URL itself in `pair` and `federated_identity`; NIP-FA audience + canonicalization is the provider's job. + +## Discovery + +A caller looks for a provider in this order. The first source that yields a +token and a reachable endpoint is used. + +An inherited token decides the identity. A caller that has one MUST use only +it: when it is revoked, does not apply to the relay or fails, the caller fails. +It MUST NOT go on to a paired token, which acts as the human. This keeps a +well-behaved caller from acting as the owner by accident. It is not a boundary +against an agent that can run its own commands. See +[Security considerations](#security-considerations). + +### 1. Inherited token + +When the desktop launches a process for an agent, through any agent harness, it +sets: + +```text +BUZZ_CREDENTIAL_PROVIDER={"url":"http://127.0.0.1:/","token":""} +``` + +- The token's identity is that agent. It is never the owner. +- The token MUST reach only processes that act for that agent. A harness that + hosts several agents in one process MUST give each agent's tool processes + their own agent's token, and MUST NOT expose one agent's token to another + agent's tools. +- The desktop SHOULD revoke the token when the process it launched exits, and + MUST revoke it on the triggers in [Revocation](#revocation). +- A caller MUST reject a `url` whose scheme is not `http`, whose host is not + exactly `127.0.0.1`, or that carries user information. +- A caller also runs the same-user check below for an inherited token: if the + desktop exits while the agent keeps running, another user may bind the + freed port. + +The desktop MUST set this variable only for processes it launches for an agent, +and only in their own environment. It MUST NOT write the token to disk. + +### 2. Paired token + +A human's own terminal, or a service the human runs such as slack2buzz, gets a +token by [pairing](#pairing). The caller stores the token in the OS keychain +(Keychain on macOS, the Secret Service on Linux), never in a file: + +- The CLI keeps one keychain entry, service `buzz-cli` and account + `desktop-pairings`, that maps each instance to its token, identity and bound + relays. Each change reloads the entry under a short cross-process lock and + touches only its own instance; the lock is never held across an approval + wait. An entry that cannot be read is refused, never overwritten. +- A paired caller only tries a paired token when no inherited token is set. + A desktop that answers `denied` or `unavailable` fails the command; only an + unreachable or unconfirmed advertisement is skipped. +- When no keychain is reachable, pairing MUST fail with an error that says so. + A caller MUST NOT fall back to writing the token to disk. +- A paired token survives a desktop restart. The desktop keeps it until it is + revoked or expires (see [Revocation](#revocation)), so a caller pairs once. + +The token does not say where the desktop is listening, because the port changes +on every launch. The desktop advertises its current endpoint, which holds no +secret, in: + +```text +~/.buzz-foundation/credential-providers/.json +{"instance":"","url":"http://127.0.0.1:/"} +``` + +- The desktop writes the file whole under a dot name and renames it into place, + and removes it when it quits. +- The directory MUST be mode `0700` and owned by the user, and each file MUST be + owned by the user with no group or other permission bits. A caller MUST + ignore any file that fails these checks, so another user cannot plant one. +- A caller uses the advertisement whose `instance` matches its stored token. +- **Same-user check.** An advertised port may now belong to another user's + process, for example after the desktop crashed. Before it sends a token to an + advertised endpoint, a caller MUST connect and confirm, by numeric user ID, + that a process of the same user holds the server side of that connection + (`lsof` on macOS, `/proc/net/tcp` on Linux). If it cannot confirm this within + about one second, it MUST send nothing on that connection. This is the check + `buzz-agent-command` performs for advertised agent sessions + (block/buzz-app `crates/agent-command`). +- A refused connection means the desktop is not running. The caller MAY delete + that advertisement. + +### 3. No provider + +Without an inherited token, if no paired token yields a provider, the desktop +link in the caller's credential chain does not apply, and the caller moves on +to its next source. A caller with an inherited token never falls through to a +paired token: when the inherited token cannot serve, the caller fails. + +## Transport + +- HTTP/1.1 `POST` to the discovered `url`, on `127.0.0.1` only. The provider + MUST bind only to `127.0.0.1`. +- `Content-Type: application/json`. +- `Authorization: Bearer ` on every request except `pair`. +- `Buzz-Credential-Provider: ` on every request. See + [Versions](#versions). +- One request per connection is sufficient. Callers MUST NOT rely on keep-alive. + +### Envelope + +Requests and responses use the NIP-46 message shape, unencrypted because they +never leave the machine. That lets both sides reuse existing NIP-46 types. + +```json +{"id":"","method":"","params":["", "..."]} +``` + +```json +{"id":"","result":""} +{"id":"","result":"","error":"","message":"","retry_after":30} +``` + +- `params` and `result` are strings, as in NIP-46. Structured values are + JSON-encoded into the string. +- On failure, `error` is a machine code from [Errors](#errors). `message` is + optional text for a person. `retry_after` is present only for `rate_limited`. +- A response is at most 12,352 KiB (status line, headers and body); larger ones + are refused. +- Both sides MUST ignore fields they do not recognize. + +## Methods + +### NIP-46 methods + +These have the same parameters and results as in NIP-46. + +| Method | Params | Result | +|---|---|---| +| `get_public_key` | `[]` | Hex public key of the token's identity | +| `sign_event` | `[]` | Signed event JSON | +| `nip44_encrypt` | `[, ]` | NIP-44 payload | +| `nip44_decrypt` | `[<peer pubkey hex>, <payload>]` | Plaintext | + +For `sign_event`, the unsigned event carries `kind`, `tags`, `content` and +`created_at`. If it carries `pubkey`, the value MUST equal the token's identity, +or the provider MUST answer `denied`. The provider sets `pubkey`, computes `id` +and signs. `sign_event` is subject to the [operation policy](#operation-policy). + +### Buzz methods + +| Method | Params | Result | +|---|---|---| +| `describe` | `[]` | JSON: identity, role, bound relays and methods | +| `federated_identity` | `[<relay URL>]` | NIP-FI assertion, or empty string | +| `auth_tag` | `[]` | JSON-encoded NIP-OA `auth` tag, or empty string | +| `engram_d_tag` | `[<peer pubkey hex>, <slug>]` | NIP-AE `d` tag for that slug | +| `pair` | `[<client name>, <relay URL>]` | JSON: token and identity | + +`describe` returns: + +```json +{ + "instance": "<instance>", + "pubkey": "<hex>", + "role": "agent", + "owner_pubkey": "<hex>", + "relays": ["wss://community.example"], + "methods": ["get_public_key", "sign_event", "..."] +} +``` + +- `role` is `agent` or `owner`. `owner_pubkey` is present only for `agent`. +- A caller MUST NOT use the provider for a relay that is not in `relays`. It + treats the provider as not applicable to that relay: with a paired token it + moves on to its next source; with an inherited token it fails (see + [Discovery](#discovery)). + +`federated_identity` returns a current [NIP-FI](nips/NIP-FI.md) assertion +whose `nostr_pubkey` is the token's identity and whose audience is the given +relay. It returns an empty string when the relay does not advertise NIP-FI or +the desktop has no enterprise login for it, so a caller sends no evidence on +such relays. It answers `denied` for a relay that is not bound to the token, +and `unavailable` when the relay requires NIP-FI but the desktop's session for +it has lapsed. A caller SHOULD cache the assertion until shortly before its +`exp` and MUST NOT write it to disk. + +`auth_tag` returns the agent's NIP-OA `auth` tag as a JSON array, for example +`["auth","<owner>","<conditions>","<sig>"]`, or an empty string for an `owner` +token. + +`engram_d_tag` returns the 64-hex `d` tag that [NIP-AE](nips/NIP-AE.md#addressing) +derives from the identity's NIP-44 conversation key with `peer` and the slug. +The provider MUST answer `invalid_request` for a slug outside NIP-AE's grammar, +so the method is not a general keyed-hash oracle. Engram content uses +`nip44_encrypt` and `nip44_decrypt` with the same peer, so no method exposes the +conversation key itself. + +The provider never returns the private key or a NIP-44 conversation key. A +caller feature that needs either one, rather than an operation built on it, +needs an explicit key. + +`pair` is described under [Pairing](#pairing). + +## Pairing + +`pair` is the only method accepted without a token. The provider MUST: + +1. Show the human an approval that names the client and the relay, and the + calling process when the provider can identify it. +2. Hold the request open until the human decides, for at most 120 seconds. +3. On approval, answer with + `{"token":"<token>","instance":"<instance>","pubkey":"<hex>","relays":["<relay>"]}`. + The identity is the human's own key for that relay. +4. On refusal, answer `denied`. On timeout, answer `approval_required`. + +The provider MUST rate-limit `pair` so that a local process cannot flood the +human with approvals. A caller MUST treat a new pairing for an instance as +replacing its old token. + +## Operation policy + +The provider classifies each `sign_event` request before signing. Callers MUST +NOT rely on the policy for safety; it exists so that a token holder, such as a +prompt-injected agent, cannot do more than the identity's ordinary work without +a person noticing. + +### Auth proofs + +These kinds are accepted only when bound to one of the token's relays, and only +when fresh: `created_at` within 60 seconds of the provider's clock. + +| Kind | Requirement | +|---|---| +| `22242` NIP-42 AUTH | Exactly one `relay` tag, equal to a bound relay after URL normalization, and exactly one `challenge` tag | +| `27235` NIP-98 HTTP auth | Exactly one `u` tag whose origin is a bound relay's origin (`wss`→`https`, `ws`→`http`), and exactly one `method` tag | +| `24242` Blossom auth | Exactly one `server` tag equal to a bound relay's host, a `t` tag of `upload` or `get`, and an `expiration` no later than `created_at + 60` | + +A NIP-98 event for any other origin is denied. This rules out using the desktop +to sign in to other services, including a NIP-FA adapter: a caller using the +provider gets NIP-FI evidence from `federated_identity` and never mints its own. + +### Ordinary content + +Kinds an identity uses for everyday work are signed without a prompt: messages +and their edits, reactions, threads and forum posts, typing and presence, read +state and user status, the identity's own lists (mute, pin, bookmark, emoji, +follow sets), canvases, artifacts, git patches, issues and status events, DM +open and hide, archive requests, workflow triggers, jobs, agent memory +(`30174`) and observer frames. The provider keeps the exact table, keyed by the +constants in `crates/buzz-core/src/kind.rs`. + +### Sensitive operations + +These kinds change who the identity is, what it controls or what others see +about it. For each request the provider MUST either get a person's approval, +holding the request open for at most 120 seconds, or refuse it with `denied`. +If it asks and gets no decision, it answers `approval_required`; on refusal, +`denied`. + +| Category | Kinds | +|---|---| +| Profile and relays | `0` profile, `10002` relay list, `10100` agent profile | +| Social graph | `3` contact list | +| Deletion | `5` deletion, `9005` group delete event, `45011` artifact removal | +| Membership and admin | `9000`–`9009` group admin, `9021`/`9022` join and leave, `28936` NIP-43 leave, `41011` DM add member | +| Moderation | `9040`–`9044` | +| Agent and team management | `30175` persona, `30176` team, `30177` managed agent, `30179` private managed agent | +| Workflow authority | `30620` workflow definition, `46030`/`46031` approval grant and deny | +| Repositories and projects | `30617` repository announcement, `30618` repository state, `30621` project | + +The provider MAY remember an approval for a short, visible period, such as +repeated deletions in one session, provided the human can see and revoke it. + +### Unknown kinds + +Any kind not in the provider's table is denied, with a `message` naming the +kind. A caller that needs it can use an explicit key instead. Adding a kind to +the table is an additive provider change and does not change the version. + +### Encryption + +`nip44_encrypt` and `nip44_decrypt` act only as the token's identity, so an +agent token can read only what was encrypted to the agent. They are subject to +the token's rate limits. The provider MAY require approval for decryption with a +peer other than the identity itself on an `owner` token. + +## Limits + +- The provider SHOULD rate-limit each token per method and answer + `rate_limited` with `retry_after` in seconds. +- Tokens SHOULD expire. An inherited token lives no longer than the process it + was issued to. A paired token SHOULD expire after a period of disuse that the + human can see. + +## Revocation + +The provider MUST revoke tokens when: + +- the human revokes the caller in the desktop, +- the owner signs out or the desktop's account changes, which revokes every + token, +- an agent is deleted, which revokes that agent's tokens, or +- a token expires. + +A revoked, expired or unknown token gets `revoked`. A caller holding a revoked +paired token SHOULD delete it and tell the human to pair again. + +## Errors + +| Code | HTTP | Meaning | Caller behavior | +|---|---|---|---| +| `approval_required` | 403 | A person must approve and did not decide in time | Tell the human to approve in Buzz, then retry | +| `denied` | 403 | Policy or the human refused | Fail; do not retry | +| `rate_limited` | 429 | Too many requests | Wait `retry_after` seconds | +| `revoked` | 401 | Token revoked, expired or unknown | Drop a paired token and try the next paired one; with an inherited token, fail (see [Discovery](#discovery)) | +| `unavailable` | 503 | The desktop cannot act now, for example signed out or its sign-in session lapsed | Fail with the message | +| `unsupported_version` | 400 | See [Versions](#versions) | Retry with a common version | +| `unknown_method` | 400 | The provider does not implement the method | Degrade (see below) | +| `invalid_request` | 400 | Malformed envelope or params. For `nip44_decrypt`, also a payload that is not valid ciphertext for this identity | Fail; for `nip44_decrypt`, treat the payload as invalid (NIP-AE filters it) | + +Callers MUST decide on `error`, not on the HTTP status, and MUST treat an +unrecognized code as `denied`. + +`sign_event`, `nip44_decrypt` and `pair` can be held for up to 120 seconds, so +callers MUST allow at least 150 seconds for them. Other methods SHOULD answer +within a few seconds. + +## Versions + +The protocol version is a positive integer. This document defines version `1`. + +- A caller sends the highest version it supports in `Buzz-Credential-Provider`. +- A provider that does not support that version answers `unsupported_version` + with a `versions` field listing the versions it supports, for example + `"versions":[1,2]`. The caller retries with the highest version both support, + or treats the provider as not applicable if there is none. +- A provider MUST keep supporting every version that a CLI it bundles, or a CLI + released in the previous six months, speaks. + +Additive changes do not change the version: new methods, new optional fields, +new error codes (treated as `denied` by older callers), and new kinds in the +policy table. Removing or changing the meaning of a method, field or code does. + +When a provider answers `unknown_method`, a caller degrades rather than fails +where it can: no `auth_tag` means the caller sends no `auth` tag, and no +`federated_identity` means it sends no NIP-FI evidence. + +## Platforms + +Version 1 supports macOS and Linux. On Windows the desktop MUST NOT set +`BUZZ_CREDENTIAL_PROVIDER` or write advertisements, and callers MUST skip the +desktop link, because loopback has no same-user check there. A named pipe with +an access control list is the planned Windows transport. + +## Security considerations + +- **Loopback cannot tell processes apart.** Tokens, not the transport, + identify callers. The same-user check stops a token reaching another user's + process; it does not stop another process of the same user, which already has + the user's files. +- **Evidence leaves the desktop.** The caller talks to the relay directly, so + `federated_identity` hands it the assertion. NIP-FI's + [assertion–key binding](nips/NIP-FI.md#assertionkey-binding) means a leaked + assertion is useless without the identity's key. +- **Tokens in the environment.** Child processes of an agent inherit + `BUZZ_CREDENTIAL_PROVIDER` and act as that agent. That matches the agent's + existing authority. +- **An agent running as its owner's user can reach the owner's paired tokens.** + The no-fallback rule binds only callers that follow it. An agent that can run + commands as the same OS user can unset `BUZZ_CREDENTIAL_PROVIDER`, or read the + paired tokens from the OS keychain itself, and then act as the owner. On + macOS the keychain item belongs to the `buzz` binary, so it opens with no + prompt. On Linux, Secret Service is readable once it is unlocked. The + same-user check does not help here, because the caller really is the same + user. Where agents should not act as their owner, sandbox them so they cannot + reach the owner's keychain, or run them as a separate OS user with their own + key. The desktop serves only callers running as its own user, so a + separate-user agent cannot use it. +- **Approval fatigue.** The provider prompts only for sensitive operations, + never for every signature, so prompts stay meaningful.