Repository navigation
feat(cli): send NIP-FI evidence via credential chain and Buzz desktop - #8999
Conversation
ef12fef to
38fed9f
Compare
801476a to
152489e
Compare
152489e to
4f95aa6
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Requesting changes. Most of this checks out. The adapter session token goes only to the adapter's origin, with redirects off. Adapter 429, 5xx and network failures get bounded retries, and refusals are not retried. The relay retry loop never repeats an adapter failure. A stored write stays delivery_unknown once an earlier attempt may have landed. Every protected route goes through authorize with sensitive header values. The Proof enum and relay_error replace the per-route copies of the auth headers and the error mapping. The buzz_core::engram split keeps the wire format and checks unchanged. A live run of the built CLI and relay also passed: with NIP-FI off, channels, messages, buzz mem and uploads behave as before. With enforcement on, missing evidence returns auth_error naming both settings. With a stub adapter that verifies the real NIP-98 proof, the same commands succeed. Wrong-audience and wrong-signature assertions are denied. Two problems block this, though.
1. A desktop rate_limited answer ends the command as a permanent auth error instead of waiting. call (crates/buzz-cli/src/desktop_provider.rs:378-383) returns rate_limited at once. Its only retry is version negotiation. The error mapping (desktop_provider.rs:81-84) turns that answer into CliError::Auth, which is_retryable_error reports as retryable:false. docs/local-credential-provider.md:351 says callers wait retry_after seconds. buzz mem list (commands/mem.rs:238-265) reads up to 5,000 entries and makes two provider calls for each, a nip44_decrypt and the d-tag derivation (:148-154). It aborts on the first credential error. The desktop side of this contract budgets each token and method at a 120-request burst and then 20 per second. A provider-backed agent with a large memory collection, or a token shared with other running CLI calls, can therefore get throttled partway through a list. The command then fails as an auth error, and re-running it starts over and can hit the same limit. Fix: handle rate_limited at the provider-call boundary with bounded retries that honor retry_after, capped. Don't retry ambiguous transport failures, and don't replay a whole mutating relay command. If persistent throttling still fails, report it as retryable, not auth_error. A test where the provider throttles mid-list and then succeeds, and the list completes, would cover it.
2. The provider contract promises agent isolation the code can't enforce. docs/local-credential-provider.md:58-61 says a caller with an inherited token must not fall back to a paired token, "so an agent never acts as its owner". Security considerations (:401-403) says an inherited token "never grants the owner's" authority. But discover_with (desktop_provider.rs:951-961) takes the paired path whenever BUZZ_CREDENTIAL_PROVIDER is unset. An agent that can run a shell runs as the same OS user and can run env -u BUZZ_CREDENTIAL_PROVIDER buzz …. Its own buzz binary then reads the human's paired token from the keychain with no prompt. On macOS the keychain item belongs to that binary. On Linux, Secret Service is readable once unlocked. The same-user check passes because the caller really is the same user. The no-fallback rule stops a well-behaved CLI from picking the owner's identity by accident. It isn't a boundary against an agent with shell access, and the doc's general note that loopback "does not stop another process of the same user" (:394-397) isn't tied to this case. Fix: state that limit in both places and name the real mitigations: agents sandboxed or run as a separate OS user, and no paired tokens where agents run. Gating paired-token calls on the desktop side, for example by refusing callers inside a launched agent's process tree, would be a further option, but it belongs to the desktop implementation.
Non-blocking:
client.rs:1255: a WebSocket upgrade denial maps throughrelay_error(status, String::new()). That drops the relay's body, so a 403 can't tellevidence rejectedfromauthorization denied, and a 401 readsrelay requires authentication (). The body is in thetungstenite::Error::Httpresponse.client.rs:1239-1250: the 75 s WebSocket publish budget was sized from relay waits only. NIP-42 signing now goes through the provider inside that budget, and the contract (docs/local-credential-provider.md:361-363) asks callers to allow at least 150 s forsign_event. Either separate the signer and network deadlines or make the outer budget cover both.federated_identity.rs:298: NIP-FA says clients SHOULD honorRetry-Afteron adapter 429/503. The mint backoff ignores it.federated_identity.rs:78:normalize_hoststrips an explicit:80. That makeswss://host:80canonicalize towss://host, which means port 443. NIP-FA doesn't support relays on port 80, so returning a usage error would be clearer than silently aliasing it.lib.rs:131: the help text still marksBUZZ_PRIVATE_KEY[required], but a paired desktop can now supply the identity.desktop_provider.rs:968: inherited tokens skip the same-user check. If the desktop exits while an agent keeps running, another local user who binds the freed port receives the agent token. The token should be revoked by then, but the check costs one socket lookup.desktop_provider.rs:357-361: the responseidis compared only on success. Error envelopes, includingrevoked, which deletes a pairing, are accepted without the comparison.desktop_provider.rs:286:lsofis resolved throughPATH./usr/sbin/lsofis simpler to reason about. It fails closed either way.desktop_provider.rs:865-890: the0700mode on~/.buzz-foundationis set only when the directory is created, andcli-pairings.lockis opened withoutO_NOFOLLOW.
The CLI signs through a Credentials provider (NostrSigner) instead of raw Keys; the explicit key is the first link. One `authorize` helper sets Authorization, x-auth-tag and Nostr-Federated-Identity on every protected request, and the WebSocket upgrade carries the assertion too. A headless evidence source mints assertions from a NIP-FA adapter session (BUZZ_FEDERATED_IDENTITY_TOKEN + BUZZ_FEDERATED_IDENTITY_ADAPTER). An enforcing relay's 401 without a source names those settings. Relays without NIP-FI behave as before. buzz mem uses the provider for NIP-44 and the NIP-AE d tag; buzz-core's engram module gains the composable steps, keeping its existing wrappers. The Buzz desktop app is also a credential provider (docs/local-credential- provider.md): an agent never falls back to its owner, and the desktop gets the community URL as configured. Co-authored-by: Will Pfleger <wpfleger@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
A desktop rate_limited answer is waited out at the provider call, up to four times, each after retry_after capped at 30 s; a persistent one is a retryable rate_limited error rather than auth_error. Transport failures are never re-sent. Also: keep the relay's body on a denied WebSocket upgrade; give WebSocket publish the provider's sign_event allowance; honor adapter Retry-After on 429/503; refuse an explicit :80 for NIP-FA; stop marking BUZZ_PRIVATE_KEY required; run the same-user check for inherited tokens; check the response id on error envelopes; use /usr/sbin/lsof; and tighten ~/.buzz-foundation to 0700 and open its lock file with O_NOFOLLOW. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
4f95aa6 to
b49e4c3
Compare
…ning from relay deadlines A desktop failure while signing or encrypting now keeps its CliError, on NIP-98, event signing, memory encryption and NIP-42 alike, so persistent throttling stays a retryable rate_limited. WebSocket publish caps each relay wait on its own and leaves NIP-42 signing to the signer's bounds, which also removes the Unix-only timeout from shared code. Also: refuse an explicit :80 written on any scheme, parse HTTP-date Retry-After values, and state the agent-isolation limits and the same-user check as they now work. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
|
🤖 Two questions about the headless path, for one use case: a headless bot posting to channels on a user's behalf. The bot has its own Nostr key (never the user's), runs unattended with no desktop app, and carries a NIP-OA
|
Each phase now has one cap covering its writes as well as its read: the AUTH challenge (with any Pongs), AUTH send plus OK, EVENT send plus OK, and a best-effort close. NIP-42 signing between phases stays outside them, and nothing is re-sent. Deadline tests run on a paused clock. Also: read an explicit port numerically, so :080 is refused too, and accept all three HTTP-date forms in adapter Retry-After (httpdate, already in the lockfile). Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Only the Unix desktop provider reports it. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
A refresh can wait out adapter Retry-After for longer than NIP-98's 60 s window, so the proof is now signed after the assertion arrives. Signed-off-by: Will Pfleger <wpfleger@block.xyz>
A rate_limited sign_event is re-sent after retry_after waits. NIP-42, NIP-98 and Blossom proofs are now re-dated (with a new id and a moved Blossom expiration) on each re-send; content events stay exact. Signed-off-by: Will Pfleger <wpfleger@block.xyz>
The relay drops a socket not signed in within 5 s. A throttled or slow sign-in now closes, reconnects and signs the new challenge, at most 3 connections; EVENT is still sent once, after AUTH succeeds. Running out is a retryable relay_unavailable error. Signed-off-by: Will Pfleger <wpfleger@block.xyz>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Requesting changes. Both blockers from the last review are fixed. A desktop rate_limited answer is now re-sent up to four times, each after retry_after capped at 30 s. Transport failures are never re-sent. A throttle that persists reports as retryable rate_limited, and a buzz mem list throttled partway through completes. The provider doc now says plainly that a same-user agent can reach the owner's paired tokens, and names the mitigations. All nine earlier non-blocking items are fixed as well. A live run of the head also passed. With NIP-FI off and with it enforced through a stub adapter that verifies the real NIP-98 proof: channel create and list, message send and readback, buzz mem set, get and list, a PNG upload with a byte-identical authenticated download, and WebSocket presence. Missing evidence returns auth_error naming both settings. A wrong-audience assertion is rejected on HTTP and on WebSocket. One problem blocks this.
A slow but successful signature sends a stale proof. authorize (crates/buzz-cli/src/client.rs:616-620) records signed_at after self.proof(..).await returns. The PROOF_MAX_AGE_SECS check therefore times only the evidence lookup that follows, not the proof's age. The proof is dated before the provider call (renewed, desktop_provider.rs:603-609), and sign_event gets the 150 s held timeout for every kind, NIP-98 included (desktop_provider.rs:28-29,154-156). If that call succeeds after more than 60 s and the assertion is unchanged, or there is none, the CLI sends a NIP-98 proof outside the relay's ±60 s window (crates/buzz-auth/src/nip98.rs:77-84). The relay answers 401, and the CLI reports that as a non-retryable auth_error for what was a transient delay. Blossom proofs have the same gap: their expiration is set before signing. Fix: check the age of the signed proof's own created_at after the final evidence lookup, and re-sign within the existing three-round bound. Content events stay unchanged. The existing slow-refresh test (client.rs:3631-3657) delays after signing. A test where the first successful signature is slow, with evidence absent or unchanged, would cover this case.
Non-blocking:
docs/local-credential-provider.md:20-21: the Goals bullet still says an agent "can act only as that agent", and thediscoverdoc comment (desktop_provider.rs:1028-1031) repeats "an agent never acts as its owner". Qualify both as applying to callers that follow the contract, or link them to the security note at:408-418.
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Any signed event or result comes from a request of at most 2 MiB, so a reply is at most 2x that escaped plus 64 KiB: 4,160 KiB. State the limit in the provider contract. Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Requesting changes. The new commit is fine: raising MAX_RESPONSE_BYTES to twice a 512 KiB event plus 64 KiB covers the largest event the desktop signs once it is escaped into the JSON result string, and the new test checks both that the largest event is accepted and that an oversized response is refused. But the blocker from the last review is still there at this head. crates/buzz-cli/src/client.rs has not changed since that review.
A slow but successful signature sends a stale proof. authorize (crates/buzz-cli/src/client.rs:616-620) records signed_at after self.proof(..).await returns. So the PROOF_MAX_AGE_SECS check measures only how long the evidence lookup that follows takes, not how old the proof is. The proof is dated before the provider call (renewed, desktop_provider.rs:118-130), and sign_event gets the 150 s held timeout for every kind, NIP-98 included. If that call succeeds after more than 60 s and the assertion is unchanged, or there is none, the CLI sends a NIP-98 proof outside the relay's ±60 s window (crates/buzz-auth/src/nip98.rs:77-84). The relay answers 401, and the CLI reports that as a non-retryable auth_error for what was only a delay. Blossom proofs have the same gap, because their expiration is set before signing. Fix: after the final evidence lookup, check the age of the signed proof's own created_at, and re-sign within the existing three-round bound. Content events stay unchanged. Add a test where the first successful signature is slow and evidence is absent or unchanged. The inline comment at client.rs:620 reports the same problem.
Non-blocking (also from the last review, still unchanged):
docs/local-credential-provider.md:19-20: the Goals bullet still says an agent "can act only as that agent", and thediscoverdoc comment (desktop_provider.rs:1034) still says "an agent never acts as its owner". Either qualify both as applying to callers that follow the contract, or link them to the security note that says a same-user agent can reach the owner's paired tokens.
An extended NIP-44 plaintext from a 2 MiB request can be all control characters, each escaped to six bytes, so a reply is at most 6 x 2 MiB + 64 KiB: 12,352 KiB. Update the provider contract. Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
- Date a proof's age from before signing, so a slow signature is redone. - Skip a paired desktop whose same-user check timed out. - Report a provider cooldown past the retry cap at once, with its length. - Keep the configured relay path for NIP-42 AUTH. - Read the whole request in the reply-limit test server so it cannot reset the connection and hang the test. Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
A same-user check that times out is its own skip: paired discovery tries the next desktop, while an inherited token reports retryable unavailable again. Test that a slow signature with unchanged evidence is re-signed. Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
… accurately Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
* origin/main: (60 commits) feat(cli): user status expiry and status read (#8777) feat(cli): send a thread message to the channel (#8778) feat(relay): add kind 30180 custom emoji entry (#8783) feat(cli): read-only unread state (NIP-RS) (#8789) feat(cli): report a message to relay moderators (#8829) buzz-db: read explicit #h sets one channel at a time (#9110) feat(cli): list huddles and their participants (#8830) docs(agents): add open-source boundary and drop private repo list (#9114) fix(mobile): answer push snapshot bridge calls at user-initiated QoS; report hop latency on callback timeout (#8916) test(mobile): lock in invite-page and bare-host community link entry (#9047) fix(desktop): keep an own send out of the frozen timeline tail (#9095) fix(db): end each table's maintenance transaction before the next (#9096) perf(mobile): show a static placeholder for posterless videos (#9138) fix(desktop): compensate head trims from the unclamped scroll offset (#9094) fix(relay): read kind:39000/39001 inputs behind the replacement lock (#9035) fix(db): unlock the storage-accounting lease before its session closes (#9097) test(relay,db): remove three timing races from Postgres and E2E tests (#9098) fix(db): keep busy-reader history reads off the writer and cap them per pod (#9132) perf(mobile): memoize npub labels (#9137) mobile: stop re-parsing messages on every resume (#9140) ... Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz> # Conflicts: # crates/buzz-cli/src/commands/users.rs
* origin/main: perf(db): hydrate search hits with exact event timestamps (#9126) perf(db): prune post-commit push enqueue by signed event timestamp (#9128) perf(db): use exact event timestamps for transactional deletion (#9130) perf(redis): index mesh ready records and share one Redis connection (#9113) fix(mobile): delay iOS message actions until a deliberate hold (#9049) Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz> # Conflicts: # Cargo.lock
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 No blocking issues at this head. All five findings from the previous review are fixed, and the two merges from main only update main's new commands to use the new credential API.
- Expired proof after slow signing:
authorizenow records the time beforeproof()runs, so the 30 s age check includes the time spent signing. A signature that takes longer is signed again, and after three tries the request fails with a retryable error instead of sending. A CLI build of this head was run against a stub provider that held the first signature for 65 s. It sent exactly one/query, carrying a proof 0.8 s old. - Same-user check timeout on a paired desktop: this now maps to
Skip::Unconfirmed, so discovery moves on to the next paired desktop. The same-user check still runs before anything is sent. For the desktop that launched the agent, the timeout stays a retryableUnavailableerror and never falls back to an owner pairing. - Pairing cooldown: a
rate_limitedreply whoseretry_afteris over 30 s is now returned at once, with the real wait in the hint, instead of being retried until the attempts run out. - WebSocket URL:
BuzzClientkeeps the configured URL, path and trailing slash included, for the WebSocket connection and the NIP-42relaytag. HTTP requests use the trimmed base. Run against a test server,ws://127.0.0.1:<port>/tenant/upgraded on/tenant/and signed["relay","ws://127.0.0.1:<port>/tenant/"]. Root URLs and a path without a trailing slash also passed. - Reply size cap: the cap is now six times the request limit plus 64 KiB, which covers a decrypted plaintext made entirely of control characters. The contract states the same figure.
- Merges:
read_statedecrypts through the credentials instead of raw keys, andusers,message_report,channel_unreadandhuddlesawaitsign_eventand takeCredentials. TheCargo.lockresolution keeps this PR's keychain/DBus entries and takesmain's removal of deadpool.
The test runs above used local stand-in servers. The full NIP-FI off/enforce runs against a real relay were not repeated for this head; the earlier full run passed, and this round's changes are client-side.
Optional, not blocking: the timeout test calls confirm_same_user directly. A test where the first paired desktop times out and a second one is used, and one for an inherited token timing out, would cover the path through paired_link/inherited_link. Similarly, auth_keeps_the_configured_relay_path checks the stored fields; asserting on the AUTH event that is actually sent would catch a future regression on the wire.
* origin/main: (54 commits) release(mobile): push candidate tags as the operator, not the App (#9084) test(mobile): check posterless video previews renew the relay assertion (#9144) feat(relay): log WebSocket EVENT writes on the access target (#9154) perf(db): plan multi-value #d reads with their values (#9131) fix(mobile): confirm before starting a Huddle from the header (#9155) feat(cli): send NIP-FI evidence via credential chain and Buzz desktop (#8999) fix(mobile): find verified people who have no profile (#9086) feat(protocol): define compact membership discovery (BUZZ-615) (#9117) perf(db): hydrate search hits with exact event timestamps (#9126) perf(db): prune post-commit push enqueue by signed event timestamp (#9128) perf(db): use exact event timestamps for transactional deletion (#9130) perf(redis): index mesh ready records and share one Redis connection (#9113) fix(mobile): delay iOS message actions until a deliberate hold (#9049) feat(cli): user status expiry and status read (#8777) feat(cli): send a thread message to the channel (#8778) feat(relay): add kind 30180 custom emoji entry (#8783) feat(cli): read-only unread state (NIP-RS) (#8789) feat(cli): report a message to relay moderators (#8829) buzz-db: read explicit #h sets one channel at a time (#9110) feat(cli): list huddles and their participants (#8830) ... Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…#2260) ## What this does The desktop app serves Nostr credentials to the agents it runs and to your own terminal CLI. That way an agent's `buzz` CLI, or yours, can work on relays that enforce NIP-FI without ever holding a key. ### Per-agent credential service - A local listener (`127.0.0.1`, advertised to the CLI) answers `describe`, `get_public_key`, `sign_event`, `nip44_encrypt`/`nip44_decrypt` and `federated_identity`, which returns the NIP-FI JWT. - Every agent the app starts gets its own token, bound to that agent and its incarnation. All four harnesses (Claude Code, goose, Codex, Pi) run the CLI as their agent this way. - goose now runs one `goose-acp` process per agent instead of one shared process, so each process's environment carries only its own agent's token. - A token is revoked when its agent is removed, when the owner signs out, or when the owner's identity is retired. ### Protected agents - A protected goose or Codex engine is launched by its protection plugin, not its runtime plugin. Native code records which agent each protected launch is for, after checking that the agent's saved protection names that plugin. That plugin can then bind the credential for that one agent only. - That credential also records the protection plugin and revision. It is rechecked before and after every request and before a cached Codex tool credential is reused. A policy change or a switch of plugin revokes it. ### Pairing your own CLI, and Settings → Security - `buzz auth desktop pair` asks the app to let a terminal tool act as you. The app shows an approval prompt that denies by default. Only one request can wait at a time, at most 3 per 10 minutes, and each waits up to 120 s. A request for a community not saved on this device is declined without a prompt; communities are compared by their whole normalized URL. - Only each token's SHA-256 is stored, in the OS keychain, bound to the human key, the store account and the NIP-FI account that approved it. Records are updated in place, so a failed write never erases existing pairings. - One running app at a time holds the pairing records, through a file lease beside them. A second app over the same records serves no pairing and isn't advertised, so it can't bring back one the first revoked. - Each NIP-FI sign-out, close or account change starts a new revoke generation. Every paired request checks it, and so does every approval before its token is returned. Until the revocation is saved, no pairing acts. If the save fails, a marker carries the revocation into the next launch. - A sign-out or account change saves that marker before it commits. Until it commits or fails, no pairing is approved or admitted, and nothing clears the marker. So a pairing can't be saved under the old account once the marker has been used. - Plain sign-out goes ahead when the paired records can't be cleared but the marker was saved, because the next launch finishes the revocation. Sign out and erase needs the revocation fully done. If the marker can't be saved, both are refused and nothing is signed out. - Settings → Security (desktop only) lists paired tools with their community, last use and expiry, and can revoke them. Pairings unused for 30 days are revoked. - The three page commands are granted to the main webview only. ## Merge order - Merges after [block/buzz#8999](block/buzz#8999) (the CLI side). The CLI version bump lands on this branch once that merges. --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Summary
Relays that enforce federated identity (NIP-FI) reject every protected
buzzCLI request with401. The CLI never sendsNostr-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)Keysare replaced byCredentials, which provide the public key, signing through nostr'sNostrSigner, NIP-44, and the NIP-OA auth tag. NIP-FI evidence comes throughevidence(relay).--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.engram_d_tag(the NIP-AE HMAC over the conversation key)buzz memnow encrypts, decrypts and derives thedtag through the provider. Invalid ciphertext skips an entry; a signer failure aborts, so an unavailable credential never reads as empty memory.buzz_core::engramgains the composable steps behind it:encode_body,event_builder,validate_envelopeandcheck_d_tag.build_eventandvalidate_and_decryptare unchanged wrappers, sobuzz-acpand desktop callers don't move.agent_management.rs) encrypt and sign through the provider.Evidence on every protected request (
crates/buzz-cli/src/client.rs)authorizehelper 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, andNostr-Federated-Identity: Bearer …. The routes are/query,/count,/events(stored and moderation),/upload,/media/upload,/media/*,get_authed,post_json_authedandpost_json_once_authed.get_public) are unchanged.connect_authenticated_with_headers. NIP-42 AUTH signs through the signer (buzz-ws-clientnow takesNostrSigner;&Keyscallers compile unchanged).WsClientError::upgrade_rejectionreturns its status and body, which map like any other relay error, so a403still says whether evidence or authorization was refused. A denial after NIP-42 (such as a key mismatch) is a 1008 close;WsClientError::PolicyClosekeeps its reason and the CLI reports it as anauth_error.unavailable(exit 2,retryable:true).Authorizationacross origins but notx-auth-tagorNostr-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)BUZZ_FEDERATED_IDENTITY_TOKEN(your own NIP-FA sign-in session: a human session for your key, not an agent grant) andBUZZ_FEDERATED_IDENTITY_ADAPTER(the adapter base) to enable it. Both are env only: command lines are visible to other local users.POST {adapter}/v1/identity/assertionswith a NIP-98 proof inNostr-Authorization(NIP-FA §Relay assertion).alg, JSON payload), and requires itsexpto equalexpires_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 asunavailable(retryable).algand a non-empty signature segment. An unsupported headless relay URL now fails when credentials are resolved.Debug.429and any5xxup to three attempts with jittered backoff (or the adapter'sRetry-Afteron429/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 asCliError::Adapter(adapter_error, exit 2,retryable:true). Relay retry loops never repeat it. A stored write keepsdelivery_unknownwhen any earlier attempt may have been delivered, so a later adapter failure never invites a re-sign. Relay mutation retries are otherwise unchanged.wss://hostmeans port 443, and NIP-FA has no relay on port 80.auth_errorwith a specific message.401and no assertion was sent, the CLI returns anauth_errorthat names both settings, or, for a desktop user, says to sign in to the community in Buzz. A desktop user's403hint no longer mentionsBUZZ_AUTH_TAG.What got simpler
Authorizationandx-auth-tagthemselves. Five copies of the 401/403 error-mapping block are now onerelay_error.sign_nip98/sign_blossom_*helpers are gone. Proofs are a three-caseProofenum consumed byauthorize.BuzzClient::keys()(raw secret access from every command) is removed. Commands usepublic_key(),sign_eventor 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:
BUZZ_CREDENTIAL_PROVIDERtoken (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.keyring; macOS native keychain, Linux Secret Service; no file fallback). A desktop that answersdeniedorunavailablefails the command; only an unreachable or unconfirmed advertisement is skipped. Revoked entries are forgotten.New commands:
buzz auth desktop pairandbuzz auth desktop unpair. The help no longer marksBUZZ_PRIVATE_KEYas required.The CLI talks to the desktop over the loopback contract in
docs/local-credential-provider.md(NIP-46-shaped envelopes,Buzz-Credential-Providerversion header, same-user check on the listening socket for paired and inherited tokens, via/usr/sbin/lsofon macOS, given 5 s; a check that runs out of time is a retryableunavailable, a wrong owner is refused). Every response'sidmust match its request, error envelopes included. Paired tokens live only in the keychain, survive desktop restarts, and are harness-neutral for agents.http/httpsread asws/wss, lowercase host, no default port,/for an empty path; path and query count). The CLI sends that URL inpairandfederated_identity; NIP-FA audience canonicalization is the desktop's.rate_limitedanswer refuses before acting, so the CLI re-sends afterretry_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, Blossomexpirationmoved with it); content events are re-sent exactly. If it is still throttled, the command fails asrate_limited(exit 2,retryable:true), notauth_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. Abuzz memlist that is throttled partway through completes.nip44_decryptanswered withinvalid_requestis invalid ciphertext, sobuzz memskips that entry (NIP-AE filtering); any other provider failure aborts.flockon~/.buzz-foundation/cli-pairings.lock(directory forced to0700, lock opened withO_NOFOLLOW) and changes only its own instance; the lock is never held across the approval wait. An unreadable entry is refused, never overwritten;unpairwith no instance clears it.Related
Related: block/buzz-app#2260 (the desktop side of the provider contract)