mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## Summary
Relays that enforce federated identity (NIP-FI) reject every protected
`buzz` CLI request with `401`. The CLI never sends
`Nostr-Federated-Identity`. This PR gives the CLI a credential chain,
lets it present NIP-FI evidence, and lets it take its identity from Buzz
desktop. Relays without NIP-FI behave exactly as before.
**Credential chain** (`crates/buzz-cli/src/credentials.rs`)
- Raw `Keys` are replaced by `Credentials`, which provide the public
key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth
tag. NIP-FI evidence comes through `evidence(relay)`.
- The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first
link. A later link can add a signer that holds no local key (a desktop
provider, for example) without reshaping callers.
- Operations that need key material rather than a signature are
purpose-bound methods instead of a key accessor:
- `engram_d_tag` (the NIP-AE HMAC over the conversation key)
- the relay-scoped GIF customer id (random and fixed for the process
when there is no local key)
- `buzz mem` now encrypts, decrypts and derives the `d` tag through the
provider. Invalid ciphertext skips an entry; a signer failure aborts, so
an unavailable credential never reads as empty memory.
`buzz_core::engram` gains the composable steps behind it: `encode_body`,
`event_builder`, `validate_envelope` and `check_d_tag`. `build_event`
and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and
desktop callers don't move.
- Agent-management drafts (`agent_management.rs`) encrypt and sign
through the provider.
**Evidence on every protected request**
(`crates/buzz-cli/src/client.rs`)
- A single `authorize` helper gets the NIP-FI assertion first, then
signs the proof just before sending, so a slow mint cannot age it past
NIP-98's 60 s window. It sets these on every protected route:
`Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and
`Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`,
`/events` (stored and moderation), `/upload`, `/media/upload`,
`/media/*`, `get_authed`, `post_json_authed` and
`post_json_once_authed`.
- Exempt routes (`get_public`) are unchanged.
- The WebSocket publish sends the header on the upgrade via
`connect_authenticated_with_headers`. NIP-42 AUTH signs through the
signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers
compile unchanged).
- An upgrade denial is an HTTP response under NIP-FI.
`WsClientError::upgrade_rejection` returns its status and body, which
map like any other relay error, so a `403` still says whether evidence
or authorization was refused. A denial after NIP-42 (such as a key
mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason
and the CLI reports it as an `auth_error`.
- The WebSocket publish caps each network phase as a whole, writes
included: connect, AUTH challenge (with any Pongs), AUTH send plus OK,
EVENT send plus OK, and a best-effort close. NIP-42 signing between
phases is bounded by the signer. The relay drops a socket not signed in
within 5 s, so a sign-in that is throttled or comes back after the
socket closed reconnects and signs the new challenge, at most 3
connections. Each connection fetches current NIP-FI evidence for its
upgrade, and a throttled sign-in is waited out between connections, so a
dead challenge is never signed. EVENT is signed before connecting and
sent once, after AUTH succeeds. Running out is `unavailable` (exit 2,
`retryable:true`).
- Relay traffic follows no redirects: reqwest strips `Authorization`
across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a
3xx is returned as a relay error. Credential header values are marked
sensitive.
**Headless evidence source**
(`crates/buzz-cli/src/federated_identity.rs`)
- Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session:
a human session for your key, not an agent grant) and
`BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both
are env only: command lines are visible to other local users.
- The CLI requests an assertion and the adapter mints it, through `POST
{adapter}/v1/identity/assertions` with a NIP-98 proof in
`Nostr-Authorization` (NIP-FA §Relay assertion).
- It checks the returned pubkey and expiry, parses the assertion as a
compact JWS (three base64url segments, JSON header with `alg`, JSON
payload), and requires its `exp` to equal `expires_at`. It caches one
assertion per process, bound to the relay when credentials are resolved,
and re-mints at the earlier of half-life and 30 s before expiry; the
desktop link caches and refreshes its assertion by the same rule.
Concurrent callers share one mint. An HTTP proof is signed again, after
current evidence, when the assertion changed while it was signed or the
evidence check aged it 30 s or more, up to 3 rounds, then fails as
`unavailable` (retryable).
- Compared with the separate rules before: a 300 s assertion now
refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion
at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s
instead of fetched on every lookup. Desktop assertions now need a
readable JWS header with `alg` and a non-empty signature segment. An
unsupported headless relay URL now fails when credentials are resolved.
- The token goes only to the adapter origin (https, or http on
loopback), never follows a redirect, and never appears in errors or
`Debug`.
- A mint retries network errors, `429` and any `5xx` up to three
attempts with jittered backoff (or the adapter's `Retry-After` on
`429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on
every path (reads, post-once, moderation, stored events, WebSocket). It
then fails as `CliError::Adapter` (`adapter_error`, exit 2,
`retryable:true`). Relay retry loops never repeat it. A stored write
keeps `delivery_unknown` when any earlier attempt may have been
delivered, so a later adapter failure never invites a re-sign. Relay
mutation retries are otherwise unchanged.
- An explicit port 80 relay URL (read numerically from the authority, on
any scheme) is a usage error: `wss://host` means port 443, and NIP-FA
has no relay on port 80.
- A refusal is never retried: a non-transient status with an unreadable
or truncated body is still a refusal.
- Each NIP-FA denial code maps to an `auth_error` with a specific
message.
- These settings belong to the explicit key: without one they are not
read, and when the desktop link wins a warning names them.
- If an enforcing relay answers `401` and no assertion was sent, the CLI
returns an `auth_error` that names both settings, or, for a desktop
user, says to sign in to the community in Buzz. A desktop user's `403`
hint no longer mentions `BUZZ_AUTH_TAG`.
**What got simpler**
- About ten request sites built `Authorization` and `x-auth-tag`
themselves. Five copies of the 401/403 error-mapping block are now one
`relay_error`.
- The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a
three-case `Proof` enum consumed by `authorize`.
- `BuzzClient::keys()` (raw secret access from every command) is
removed. Commands use `public_key()`, `sign_event` or a purpose-bound
credentials method.
**Desktop credential provider**
(`crates/buzz-cli/src/desktop_provider.rs`,
`docs/local-credential-provider.md`)
Buzz desktop is a credential provider in the chain, after an explicit
key:
1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for
the agents it launches). It decides the identity: when it is revoked,
not bound to the relay or unreachable, the command fails. A paired
token, which acts as the human, is never tried. That keeps a
well-behaved CLI from acting as the owner by accident; it is not a
boundary against an agent that can run its own commands as the owner's
OS user, and the contract says so with the mitigations.
2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS
native keychain, Linux Secret Service; no file fallback). A desktop that
answers `denied` or `unavailable` fails the command; only an unreachable
or unconfirmed advertisement is skipped. Revoked entries are forgotten.
New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`.
The help no longer marks `BUZZ_PRIVATE_KEY` as required.
The CLI talks to the desktop over the loopback contract in
`docs/local-credential-provider.md` (NIP-46-shaped envelopes,
`Buzz-Credential-Provider` version header, same-user check on the
listening socket for paired and inherited tokens, via `/usr/sbin/lsof`
on macOS, given 5 s; a check that runs out of time is a retryable
`unavailable`, a wrong owner is refused). Every response's `id` must
match its request, error envelopes included. Paired tokens live only in
the keychain, survive desktop restarts, and are harness-neutral for
agents.
- **Relay binding:** tokens bind the whole relay URL (`http`/`https`
read as `ws`/`wss`, lowercase host, no default port, `/` for an empty
path; path and query count). The CLI sends that URL in `pair` and
`federated_identity`; NIP-FA audience canonicalization is the desktop's.
- **Rate limits:** a `rate_limited` answer refuses before acting, so the
CLI re-sends after `retry_after` (capped at 30 s), up to four times,
except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A
re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom
`expiration` moved with it); content events are re-sent exactly. If it
is still throttled, the command fails as `rate_limited` (exit 2,
`retryable:true`), not `auth_error`, whether the throttled call was
NIP-98, event signing, NIP-42 or encryption. Transport failures are
never re-sent, and relay commands are never replayed. A `buzz mem` list
that is throttled partway through completes.
- **Memory:** a `nip44_decrypt` answered with `invalid_request` is
invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering);
any other provider failure aborts.
- **Keychain changes:** each change reloads the map under a short
`flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to
`0700`, lock opened with `O_NOFOLLOW`) and changes only its own
instance; the lock is never held across the approval wait. An unreadable
entry is refused, never overwritten; `unpair` with no instance clears
it.
### Related
Related:
[block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the
desktop side of the provider contract)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
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>
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
425 lines
20 KiB
Markdown
425 lines
20 KiB
Markdown
# 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>]` | JSON: token and identity |
|
||
|
||
`describe` returns:
|
||
|
||
```json
|
||
{
|
||
"instance": "<instance>",
|
||
"pubkey": "<hex>",
|
||
"role": "agent",
|
||
"owner_pubkey": "<hex>",
|
||
"relays": ["wss://community.example"],
|
||
"methods": ["get_public_key", "sign_event", "..."]
|
||
}
|
||
```
|
||
|
||
- `role` is `agent` or `owner`. `owner_pubkey` is present only for `agent`.
|
||
- A caller MUST NOT use the provider for a relay that is not in `relays`. It
|
||
treats the provider as not applicable to that relay: with a paired token it
|
||
moves on to its next source; with an inherited token it fails (see
|
||
[Discovery](#discovery)).
|
||
|
||
`federated_identity` returns a current [NIP-FI](nips/NIP-FI.md) assertion
|
||
whose `nostr_pubkey` is the token's identity and whose audience is the given
|
||
relay. It returns an empty string when the relay does not advertise NIP-FI or
|
||
the desktop has no enterprise login for it, so a caller sends no evidence on
|
||
such relays. It answers `denied` for a relay that is not bound to the token,
|
||
and `unavailable` when the relay requires NIP-FI but the desktop's session for
|
||
it has lapsed. A caller SHOULD cache the assertion until shortly before its
|
||
`exp` and MUST NOT write it to disk.
|
||
|
||
`auth_tag` returns the agent's NIP-OA `auth` tag as a JSON array, for example
|
||
`["auth","<owner>","<conditions>","<sig>"]`, or an empty string for an `owner`
|
||
token.
|
||
|
||
`engram_d_tag` returns the 64-hex `d` tag that [NIP-AE](nips/NIP-AE.md#addressing)
|
||
derives from the identity's NIP-44 conversation key with `peer` and the slug.
|
||
The provider MUST answer `invalid_request` for a slug outside NIP-AE's grammar,
|
||
so the method is not a general keyed-hash oracle. Engram content uses
|
||
`nip44_encrypt` and `nip44_decrypt` with the same peer, so no method exposes the
|
||
conversation key itself.
|
||
|
||
The provider never returns the private key or a NIP-44 conversation key. A
|
||
caller feature that needs either one, rather than an operation built on it,
|
||
needs an explicit key.
|
||
|
||
`pair` is described under [Pairing](#pairing).
|
||
|
||
## Pairing
|
||
|
||
`pair` is the only method accepted without a token. The provider MUST:
|
||
|
||
1. Show the human an approval that names the client and the relay, and the
|
||
calling process when the provider can identify it.
|
||
2. Hold the request open until the human decides, for at most 120 seconds.
|
||
3. On approval, answer with
|
||
`{"token":"<token>","instance":"<instance>","pubkey":"<hex>","relays":["<relay>"]}`.
|
||
The identity is the human's own key for that relay.
|
||
4. On refusal, answer `denied`. On timeout, answer `approval_required`.
|
||
|
||
The provider MUST rate-limit `pair` so that a local process cannot flood the
|
||
human with approvals. A caller MUST treat a new pairing for an instance as
|
||
replacing its old token.
|
||
|
||
## Operation policy
|
||
|
||
The provider classifies each `sign_event` request before signing. Callers MUST
|
||
NOT rely on the policy for safety; it exists so that a token holder, such as a
|
||
prompt-injected agent, cannot do more than the identity's ordinary work without
|
||
a person noticing.
|
||
|
||
### Auth proofs
|
||
|
||
These kinds are accepted only when bound to one of the token's relays, and only
|
||
when fresh: `created_at` within 60 seconds of the provider's clock.
|
||
|
||
| Kind | Requirement |
|
||
|---|---|
|
||
| `22242` NIP-42 AUTH | Exactly one `relay` tag, equal to a bound relay after URL normalization, and exactly one `challenge` tag |
|
||
| `27235` NIP-98 HTTP auth | Exactly one `u` tag whose origin is a bound relay's origin (`wss`→`https`, `ws`→`http`), and exactly one `method` tag |
|
||
| `24242` Blossom auth | Exactly one `server` tag equal to a bound relay's host, a `t` tag of `upload` or `get`, and an `expiration` no later than `created_at + 60` |
|
||
|
||
A NIP-98 event for any other origin is denied. This rules out using the desktop
|
||
to sign in to other services, including a NIP-FA adapter: a caller using the
|
||
provider gets NIP-FI evidence from `federated_identity` and never mints its own.
|
||
|
||
### Ordinary content
|
||
|
||
Kinds an identity uses for everyday work are signed without a prompt: messages
|
||
and their edits, reactions, threads and forum posts, typing and presence, read
|
||
state and user status, the identity's own lists (mute, pin, bookmark, emoji,
|
||
follow sets), canvases, artifacts, git patches, issues and status events, DM
|
||
open and hide, archive requests, workflow triggers, jobs, agent memory
|
||
(`30174`) and observer frames. The provider keeps the exact table, keyed by the
|
||
constants in `crates/buzz-core/src/kind.rs`.
|
||
|
||
### Sensitive operations
|
||
|
||
These kinds change who the identity is, what it controls or what others see
|
||
about it. For each request the provider MUST either get a person's approval,
|
||
holding the request open for at most 120 seconds, or refuse it with `denied`.
|
||
If it asks and gets no decision, it answers `approval_required`; on refusal,
|
||
`denied`.
|
||
|
||
| Category | Kinds |
|
||
|---|---|
|
||
| Profile and relays | `0` profile, `10002` relay list, `10100` agent profile |
|
||
| Social graph | `3` contact list |
|
||
| Deletion | `5` deletion, `9005` group delete event, `45011` artifact removal |
|
||
| Membership and admin | `9000`–`9009` group admin, `9021`/`9022` join and leave, `28936` NIP-43 leave, `41011` DM add member |
|
||
| Moderation | `9040`–`9044` |
|
||
| Agent and team management | `30175` persona, `30176` team, `30177` managed agent, `30179` private managed agent |
|
||
| Workflow authority | `30620` workflow definition, `46030`/`46031` approval grant and deny |
|
||
| Repositories and projects | `30617` repository announcement, `30618` repository state, `30621` project |
|
||
|
||
The provider MAY remember an approval for a short, visible period, such as
|
||
repeated deletions in one session, provided the human can see and revoke it.
|
||
|
||
### Unknown kinds
|
||
|
||
Any kind not in the provider's table is denied, with a `message` naming the
|
||
kind. A caller that needs it can use an explicit key instead. Adding a kind to
|
||
the table is an additive provider change and does not change the version.
|
||
|
||
### Encryption
|
||
|
||
`nip44_encrypt` and `nip44_decrypt` act only as the token's identity, so an
|
||
agent token can read only what was encrypted to the agent. They are subject to
|
||
the token's rate limits. The provider MAY require approval for decryption with a
|
||
peer other than the identity itself on an `owner` token.
|
||
|
||
## Limits
|
||
|
||
- The provider SHOULD rate-limit each token per method and answer
|
||
`rate_limited` with `retry_after` in seconds.
|
||
- Tokens SHOULD expire. An inherited token lives no longer than the process it
|
||
was issued to. A paired token SHOULD expire after a period of disuse that the
|
||
human can see.
|
||
|
||
## Revocation
|
||
|
||
The provider MUST revoke tokens when:
|
||
|
||
- the human revokes the caller in the desktop,
|
||
- the owner signs out or the desktop's account changes, which revokes every
|
||
token,
|
||
- an agent is deleted, which revokes that agent's tokens, or
|
||
- a token expires.
|
||
|
||
A revoked, expired or unknown token gets `revoked`. A caller holding a revoked
|
||
paired token SHOULD delete it and tell the human to pair again.
|
||
|
||
## Errors
|
||
|
||
| Code | HTTP | Meaning | Caller behavior |
|
||
|---|---|---|---|
|
||
| `approval_required` | 403 | A person must approve and did not decide in time | Tell the human to approve in Buzz, then retry |
|
||
| `denied` | 403 | Policy or the human refused | Fail; do not retry |
|
||
| `rate_limited` | 429 | Too many requests | Wait `retry_after` seconds |
|
||
| `revoked` | 401 | Token revoked, expired or unknown | Drop a paired token and try the next paired one; with an inherited token, fail (see [Discovery](#discovery)) |
|
||
| `unavailable` | 503 | The desktop cannot act now, for example signed out or its sign-in session lapsed | Fail with the message |
|
||
| `unsupported_version` | 400 | See [Versions](#versions) | Retry with a common version |
|
||
| `unknown_method` | 400 | The provider does not implement the method | Degrade (see below) |
|
||
| `invalid_request` | 400 | Malformed envelope or params. For `nip44_decrypt`, also a payload that is not valid ciphertext for this identity | Fail; for `nip44_decrypt`, treat the payload as invalid (NIP-AE filters it) |
|
||
|
||
Callers MUST decide on `error`, not on the HTTP status, and MUST treat an
|
||
unrecognized code as `denied`.
|
||
|
||
`sign_event`, `nip44_decrypt` and `pair` can be held for up to 120 seconds, so
|
||
callers MUST allow at least 150 seconds for them. Other methods SHOULD answer
|
||
within a few seconds.
|
||
|
||
## Versions
|
||
|
||
The protocol version is a positive integer. This document defines version `1`.
|
||
|
||
- A caller sends the highest version it supports in `Buzz-Credential-Provider`.
|
||
- A provider that does not support that version answers `unsupported_version`
|
||
with a `versions` field listing the versions it supports, for example
|
||
`"versions":[1,2]`. The caller retries with the highest version both support,
|
||
or treats the provider as not applicable if there is none.
|
||
- A provider MUST keep supporting every version that a CLI it bundles, or a CLI
|
||
released in the previous six months, speaks.
|
||
|
||
Additive changes do not change the version: new methods, new optional fields,
|
||
new error codes (treated as `denied` by older callers), and new kinds in the
|
||
policy table. Removing or changing the meaning of a method, field or code does.
|
||
|
||
When a provider answers `unknown_method`, a caller degrades rather than fails
|
||
where it can: no `auth_tag` means the caller sends no `auth` tag, and no
|
||
`federated_identity` means it sends no NIP-FI evidence.
|
||
|
||
## Platforms
|
||
|
||
Version 1 supports macOS and Linux. On Windows the desktop MUST NOT set
|
||
`BUZZ_CREDENTIAL_PROVIDER` or write advertisements, and callers MUST skip the
|
||
desktop link, because loopback has no same-user check there. A named pipe with
|
||
an access control list is the planned Windows transport.
|
||
|
||
## Security considerations
|
||
|
||
- **Loopback cannot tell processes apart.** Tokens, not the transport,
|
||
identify callers. The same-user check stops a token reaching another user's
|
||
process; it does not stop another process of the same user, which already has
|
||
the user's files.
|
||
- **Evidence leaves the desktop.** The caller talks to the relay directly, so
|
||
`federated_identity` hands it the assertion. NIP-FI's
|
||
[assertion–key binding](nips/NIP-FI.md#assertionkey-binding) means a leaked
|
||
assertion is useless without the identity's key.
|
||
- **Tokens in the environment.** Child processes of an agent inherit
|
||
`BUZZ_CREDENTIAL_PROVIDER` and act as that agent. That matches the agent's
|
||
existing authority.
|
||
- **An agent running as its owner's user can reach the owner's paired tokens.**
|
||
The no-fallback rule binds only callers that follow it. An agent that can run
|
||
commands as the same OS user can unset `BUZZ_CREDENTIAL_PROVIDER`, or read the
|
||
paired tokens from the OS keychain itself, and then act as the owner. On
|
||
macOS the keychain item belongs to the `buzz` binary, so it opens with no
|
||
prompt. On Linux, Secret Service is readable once it is unlocked. The
|
||
same-user check does not help here, because the caller really is the same
|
||
user. Where agents should not act as their owner, sandbox them so they cannot
|
||
reach the owner's keychain, or run them as a separate OS user with their own
|
||
key. The desktop serves only callers running as its own user, so a
|
||
separate-user agent cannot use it.
|
||
- **Approval fatigue.** The provider prompts only for sensitive operations,
|
||
never for every signature, so prompts stay meaningful.
|