镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(cli): send NIP-FI evidence via credential chain and Buzz desktop - #8999

Merged
wpfleger96 merged 25 commits into
mainfrom
elrond/cli-credential-chain
Nov 6, 2026
Merged

wpfleger96 merged 25 commits into
mainfrom
elrond/cli-credential-chain

Conversation

@TheSentinel454

@TheSentinel454 TheSentinel454 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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 (the desktop side of the provider contract)

@ngthuydiem ngthuydiem left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to unblock CLI

@wpfleger96
wpfleger96 force-pushed the elrond/cli-credential-chain branch from ef12fef to 38fed9f Compare November 3, 2026 23:17
@wpfleger96 wpfleger96 changed the title feat(cli): credential chain and NIP-FI evidence on every request feat(cli): send NIP-FI evidence via credential chain and Buzz desktop Nov 4, 2026
@wpfleger96
wpfleger96 marked this pull request as ready for review November 4, 2026 02:23
@wpfleger96
wpfleger96 requested a review from a team as a code owner November 4, 2026 02:23
@wpfleger96
wpfleger96 force-pushed the elrond/cli-credential-chain branch from 801476a to 152489e Compare November 4, 2026 02:41
@wpfleger96
wpfleger96 force-pushed the elrond/cli-credential-chain branch from 152489e to 4f95aa6 Compare November 4, 2026 02:48

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 through relay_error(status, String::new()). That drops the relay's body, so a 403 can't tell evidence rejected from authorization denied, and a 401 reads relay requires authentication (). The body is in the tungstenite::Error::Http response.
  • 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 for sign_event. Either separate the signer and network deadlines or make the outer budget cover both.
  • federated_identity.rs:298: NIP-FA says clients SHOULD honor Retry-After on adapter 429/503. The mint backoff ignores it.
  • federated_identity.rs:78: normalize_host strips an explicit :80. That makes wss://host:80 canonicalize to wss://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 marks BUZZ_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 response id is compared only on success. Error envelopes, including revoked, which deletes a pairing, are accepted without the comparison.
  • desktop_provider.rs:286: lsof is resolved through PATH. /usr/sbin/lsof is simpler to reason about. It fails closed either way.
  • desktop_provider.rs:865-890: the 0700 mode on ~/.buzz-foundation is set only when the directory is created, and cli-pairings.lock is opened without O_NOFOLLOW.

TheSentinel454 and others added 2 commits November 4, 2026 13:34
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>
…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>
@tbedor-square2

Copy link
Copy Markdown
Contributor

🤖 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 auth tag from its owner.

  1. Will the headless env path (BUZZ_FEDERATED_IDENTITY_TOKEN / BUZZ_FEDERATED_IDENTITY_ADAPTER) support an external agent key acting under a user's grant? As written it mints through POST /v1/identity/assertions with a human session, and NIP-FA says an adapter must not issue an agent-key assertion through the user's own authentication. Would the CLI take an agent grant (from /v1/nip-fi/agent-grant) and mint through /v1/nip-fi/agent-assertion, for example from a separate env variable?
  2. How does such an agent obtain and refresh the adapter session token, and what is its lifetime? Is the intended flow that the owner completes the browser login once, that session is exchanged for an agent grant, and the agent keeps only the grant? If so, how long does a grant last, does use extend it, and what should the agent do when it lapses?

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>
Comment thread crates/buzz-cli/src/client.rs Resolved

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 the discover doc 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 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 the discover doc 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>
Comment thread crates/buzz-cli/src/lib.rs Resolved
@wpfleger96
wpfleger96 dismissed their stale review November 5, 2026 20:31

addressed

- 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>
@wpfleger96
wpfleger96 enabled auto-merge (squash) November 5, 2026 23:27
* 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 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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: authorize now records the time before proof() 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 retryable Unavailable error and never falls back to an owner pairing.
  • Pairing cooldown: a rate_limited reply whose retry_after is 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: BuzzClient keeps the configured URL, path and trailing slash included, for the WebSocket connection and the NIP-42 relay tag. 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_state decrypts through the credentials instead of raw keys, and users, message_report, channel_unread and huddles await sign_event and take Credentials. The Cargo.lock resolution keeps this PR's keychain/DBus entries and takes main'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.

@wpfleger96
wpfleger96 merged commit e3a0f3d into main Nov 6, 2026
89 of 90 checks passed
@wpfleger96
wpfleger96 deleted the elrond/cli-credential-chain branch November 6, 2026 00:26
tellaho added a commit that referenced this pull request Nov 6, 2026
* 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>
wpfleger96 added a commit to block/buzz-app that referenced this pull request Nov 6, 2026
…#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>

This branch was successfully deployed

No deployments
codex-review — f4ad4ed9 Deployed Nov 5, 2026 by wpfleger96 via Run Codex Security Review #13235
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants