Files
buzz/docs/local-credential-provider.md
Will Pfleger 630922a9e1 fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417)
## Summary

Fixes to `buzz auth desktop pair` and relay selection:

- **`pair` skips desktops that aren't running.** A desktop that exited
without removing its advertisement no longer counts toward "several Buzz
desktops are running". It uses the same running test as `probe`, where
only a transport failure means not running.
- **`--relay` is global**, so `buzz auth desktop pair --relay …` works
and the flag shows in subcommand help.
- **A bare host means `https://`** for `--relay` and `BUZZ_RELAY_URL`.
An explicit `http://`, `ws://` or `wss://` stays as typed.
- **`pair` with no relay sends `[client]`.** The desktop pairs for its
active community and returns the bound relays, which the CLI stores. An
older desktop answers `invalid_request`, which the CLI reports as "this
Buzz can't pick a community; pass --relay".
- **`docs/local-credential-provider.md`**: covers the one-parameter
`pair`, `unavailable` when no community is active, the older-provider
fallback, skipping stale advertisements, and a lapsed sign-in keeping
the pairing instead of revoking it.
- The existing `concurrent_pair_and_unpair…` test now fails after 60s
instead of hanging when the held pair request never reaches the mock
desktop.
- The CLI tests that run the real `lsof` same-user check now take turns.
A nextest test group, `desktop-same-user-check`, serializes the whole
`desktop_provider::` module for the pre-push hook. Most of its tests
reach the check, and the few that don't run serially too. A test-only
lock around the check does the same in `cargo test`. Waiting for a turn
doesn't count against the request timeout. The check and its 5s limit
are unchanged. `list_tells_a_stale_advertisement_from_a_rejection` keeps
the in-cap `retry_after` of 30 and times from the desktop's first
answer, so waiting for a turn isn't counted.

The desktop half is block/buzz-app#3386.

## Testing

- New tests: `pair_skips_a_desktop_that_is_not_running`,
`an_older_desktop_asks_for_a_relay`,
`relay_flag_is_global_and_a_bare_host_means_https`, plus updated
relay-selection cases.
- `cargo test -p buzz-cli --locked -- --test-threads=4` passes: 689
tests, both with default threads and with `--test-threads=4`, and under
the pre-push hook's nextest run.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-11-12 19:29:12 -05:00

21 KiB
Raw Blame History

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) 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 covers remote signers.
  • Windows. Version 1 has no Windows transport. See 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.

1. Inherited token

When the desktop launches a process for an agent, through any agent harness, it sets:

BUZZ_CREDENTIAL_PROVIDER={"url":"http://127.0.0.1:<port>/<path>","token":"<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.
  • 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. 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), 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:

~/.buzz-foundation/credential-providers/<instance>.json
{"instance":"<instance>","url":"http://127.0.0.1:<port>/<path>"}
  • 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 <token> on every request except pair.
  • Buzz-Credential-Provider: <version> on every request. See 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.

{"id":"<random string>","method":"<method>","params":["<string>", "..."]}
{"id":"<same id>","result":"<string>"}
{"id":"<same id>","result":"","error":"<code>","message":"<human text>","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. 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 [<unsigned event JSON>] Signed event JSON
nip44_encrypt [<peer pubkey hex>, <plaintext>] 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.

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>] or [<client name>] JSON: token and identity

describe returns:

{
  "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).

federated_identity returns a current NIP-FI 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 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

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. Without a relay URL, the relay is the desktop's active community; with none active, answer unavailable without asking the human.
  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.

A caller that sends no relay URL and gets invalid_request is talking to a provider that predates it, and SHOULD tell the human to name the relay. A caller SHOULD skip an advertised provider that does not answer before counting the running desktops.

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 (a sign-in that merely lapsed is not a sign-out: the provider keeps the pairing and answers unavailable, telling the human to sign in again),
  • 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)
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 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 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.