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

433 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Local credential provider
`draft`
This document defines how a local process, such as the `buzz` CLI, asks a running
Buzz Desktop to act as one Nostr identity: to give its public key, sign events,
encrypt and decrypt with NIP-44, return NIP-FI evidence for a relay, and return
its NIP-OA `auth` tag. The private key and the sign-in session stay in the
desktop.
The CLI is the reference client and lives in this repository
(`crates/buzz-cli`). Buzz Desktop ([block/buzz-app](https://github.com/block/buzz-app))
is the reference provider. Any client or provider that follows this document
interoperates with both.
## Goals
- **One identity per caller.** A caller holds a token for exactly one identity.
The desktop never decides on the caller's behalf which identity to use.
- **An agent's process gets only that agent's token.** The desktop gives a
process it launches for an agent a token for that agent alone. Acting as the
owner takes the owner's own approval, given when pairing.
- **The desktop sees what it signs.** The caller sends the complete unsigned
event; the desktop classifies it into an operation and applies a policy before
signing. There is no blind signing of opaque bytes.
- **Old and new versions interoperate.** The bundled CLI follows the desktop's
pinned revision of this repository, but a user can also run a different CLI.
An older client must work against a newer desktop, and the reverse.
## Non-goals
- Remote signing. The transport is loopback only. [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md)
covers remote signers.
- Windows. Version 1 has no Windows transport. See [Platforms](#platforms).
- Deciding which approval UI the desktop shows. This document fixes what needs
approval, not how it is presented.
## Terms
- **Provider**: the desktop process that holds the key and answers requests.
- **Caller**: the local process that sends requests.
- **Token**: an opaque bearer secret that names one identity and the relays it
may act on. Tokens carry at least 128 bits of entropy.
- **Instance**: one installed desktop build, named by a stable identifier such
as its bundle identifier. Production and staging builds are separate
instances and issue separate tokens.
- **Bound relay**: a community relay URL a token may act on. Relay URLs are
compared whole after URL normalization: `http`/`https` read as `ws`/`wss`,
lowercase host, no default port, `/` for an empty path. The path and query
count, because one host can serve several communities. A caller sends the
bound URL itself in `pair` and `federated_identity`; NIP-FA audience
canonicalization is the provider's job.
## Discovery
A caller looks for a provider in this order. The first source that yields a
token and a reachable endpoint is used.
An inherited token decides the identity. A caller that has one MUST use only
it: when it is revoked, does not apply to the relay or fails, the caller fails.
It MUST NOT go on to a paired token, which acts as the human. This keeps a
well-behaved caller from acting as the owner by accident. It is not a boundary
against an agent that can run its own commands. See
[Security considerations](#security-considerations).
### 1. Inherited token
When the desktop launches a process for an agent, through any agent harness, it
sets:
```text
BUZZ_CREDENTIAL_PROVIDER={"url":"http://127.0.0.1:<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](#revocation).
- A caller MUST reject a `url` whose scheme is not `http`, whose host is not
exactly `127.0.0.1`, or that carries user information.
- A caller also runs the same-user check below for an inherited token: if the
desktop exits while the agent keeps running, another user may bind the
freed port.
The desktop MUST set this variable only for processes it launches for an agent,
and only in their own environment. It MUST NOT write the token to disk.
### 2. Paired token
A human's own terminal, or a service the human runs such as slack2buzz, gets a
token by [pairing](#pairing). The caller stores the token in the OS keychain
(Keychain on macOS, the Secret Service on Linux), never in a file:
- The CLI keeps one keychain entry, service `buzz-cli` and account
`desktop-pairings`, that maps each instance to its token, identity and bound
relays. Each change reloads the entry under a short cross-process lock and
touches only its own instance; the lock is never held across an approval
wait. An entry that cannot be read is refused, never overwritten.
- A paired caller only tries a paired token when no inherited token is set.
A desktop that answers `denied` or `unavailable` fails the command; only an
unreachable or unconfirmed advertisement is skipped.
- When no keychain is reachable, pairing MUST fail with an error that says so.
A caller MUST NOT fall back to writing the token to disk.
- A paired token survives a desktop restart. The desktop keeps it until it is
revoked or expires (see [Revocation](#revocation)), so a caller pairs once.
The token does not say where the desktop is listening, because the port changes
on every launch. The desktop advertises its current endpoint, which holds no
secret, in:
```text
~/.buzz-foundation/credential-providers/<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](#versions).
- One request per connection is sufficient. Callers MUST NOT rely on keep-alive.
### Envelope
Requests and responses use the NIP-46 message shape, unencrypted because they
never leave the machine. That lets both sides reuse existing NIP-46 types.
```json
{"id":"<random string>","method":"<method>","params":["<string>", "..."]}
```
```json
{"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](#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](#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:
```json
{
"instance": "<instance>",
"pubkey": "<hex>",
"role": "agent",
"owner_pubkey": "<hex>",
"relays": ["wss://community.example"],
"methods": ["get_public_key", "sign_event", "..."]
}
```
- `role` is `agent` or `owner`. `owner_pubkey` is present only for `agent`.
- A caller MUST NOT use the provider for a relay that is not in `relays`. It
treats the provider as not applicable to that relay: with a paired token it
moves on to its next source; with an inherited token it fails (see
[Discovery](#discovery)).
`federated_identity` returns a current [NIP-FI](nips/NIP-FI.md) assertion
whose `nostr_pubkey` is the token's identity and whose audience is the given
relay. It returns an empty string when the relay does not advertise NIP-FI or
the desktop has no enterprise login for it, so a caller sends no evidence on
such relays. It answers `denied` for a relay that is not bound to the token,
and `unavailable` when the relay requires NIP-FI but the desktop's session for
it has lapsed. A caller SHOULD cache the assertion until shortly before its
`exp` and MUST NOT write it to disk.
`auth_tag` returns the agent's NIP-OA `auth` tag as a JSON array, for example
`["auth","<owner>","<conditions>","<sig>"]`, or an empty string for an `owner`
token.
`engram_d_tag` returns the 64-hex `d` tag that [NIP-AE](nips/NIP-AE.md#addressing)
derives from the identity's NIP-44 conversation key with `peer` and the slug.
The provider MUST answer `invalid_request` for a slug outside NIP-AE's grammar,
so the method is not a general keyed-hash oracle. Engram content uses
`nip44_encrypt` and `nip44_decrypt` with the same peer, so no method exposes the
conversation key itself.
The provider never returns the private key or a NIP-44 conversation key. A
caller feature that needs either one, rather than an operation built on it,
needs an explicit key.
`pair` is described under [Pairing](#pairing).
## Pairing
`pair` is the only method accepted without a token. The provider MUST:
1. Show the human an approval that names the client and the relay, and the
calling process when the provider can identify it. 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](#discovery)) |
| `unavailable` | 503 | The desktop cannot act now, for example signed out or its sign-in session lapsed | Fail with the message |
| `unsupported_version` | 400 | See [Versions](#versions) | Retry with a common version |
| `unknown_method` | 400 | The provider does not implement the method | Degrade (see below) |
| `invalid_request` | 400 | Malformed envelope or params. For `nip44_decrypt`, also a payload that is not valid ciphertext for this identity | Fail; for `nip44_decrypt`, treat the payload as invalid (NIP-AE filters it) |
Callers MUST decide on `error`, not on the HTTP status, and MUST treat an
unrecognized code as `denied`.
`sign_event`, `nip44_decrypt` and `pair` can be held for up to 120 seconds, so
callers MUST allow at least 150 seconds for them. Other methods SHOULD answer
within a few seconds.
## Versions
The protocol version is a positive integer. This document defines version `1`.
- A caller sends the highest version it supports in `Buzz-Credential-Provider`.
- A provider that does not support that version answers `unsupported_version`
with a `versions` field listing the versions it supports, for example
`"versions":[1,2]`. The caller retries with the highest version both support,
or treats the provider as not applicable if there is none.
- A provider MUST keep supporting every version that a CLI it bundles, or a CLI
released in the previous six months, speaks.
Additive changes do not change the version: new methods, new optional fields,
new error codes (treated as `denied` by older callers), and new kinds in the
policy table. Removing or changing the meaning of a method, field or code does.
When a provider answers `unknown_method`, a caller degrades rather than fails
where it can: no `auth_tag` means the caller sends no `auth` tag, and no
`federated_identity` means it sends no NIP-FI evidence.
## Platforms
Version 1 supports macOS and Linux. On Windows the desktop MUST NOT set
`BUZZ_CREDENTIAL_PROVIDER` or write advertisements, and callers MUST skip the
desktop link, because loopback has no same-user check there. A named pipe with
an access control list is the planned Windows transport.
## Security considerations
- **Loopback cannot tell processes apart.** Tokens, not the transport,
identify callers. The same-user check stops a token reaching another user's
process; it does not stop another process of the same user, which already has
the user's files.
- **Evidence leaves the desktop.** The caller talks to the relay directly, so
`federated_identity` hands it the assertion. NIP-FI's
[assertion–key binding](nips/NIP-FI.md#assertionkey-binding) means a leaked
assertion is useless without the identity's key.
- **Tokens in the environment.** Child processes of an agent inherit
`BUZZ_CREDENTIAL_PROVIDER` and act as that agent. That matches the agent's
existing authority.
- **An agent running as its owner's user can reach the owner's paired tokens.**
The no-fallback rule binds only callers that follow it. An agent that can run
commands as the same OS user can unset `BUZZ_CREDENTIAL_PROVIDER`, or read the
paired tokens from the OS keychain itself, and then act as the owner. On
macOS the keychain item belongs to the `buzz` binary, so it opens with no
prompt. On Linux, Secret Service is readable once it is unlocked. The
same-user check does not help here, because the caller really is the same
user. Where agents should not act as their owner, sandbox them so they cannot
reach the owner's keychain, or run them as a separate OS user with their own
key. The desktop serves only callers running as its own user, so a
separate-user agent cannot use it.
- **Approval fatigue.** The provider prompts only for sensitive operations,
never for every signature, so prompts stay meaningful.