Files
buzz/crypto.md
2026-07-06 14:04:27 -04:00

42 KiB

Buzz Cryptography And Key Review

Reviewed against the current checkout on 2026-06-25.

Executive Summary

Buzz uses familiar primitives: BIP-340 Schnorr signatures over secp256k1 for Nostr events, SHA-256 for event IDs and payload hashes, HMAC-SHA256 and HKDF-SHA256 for keyed derivations, NIP-44 v2 for encrypted Nostr payloads, and random bearer tokens for a smaller set of HTTP and workflow flows. I did not find evidence that the project chose a broken primitive or implemented an obviously broken signature scheme.

The problems are mostly at the composition and lifecycle layer. The highest risk issue is that the mobile NIP-AB pairing target decrypts, deserializes, and retains payload plaintext before the target user confirms the SAS, even though the protocol explicitly makes that confirmation a local confidentiality boundary. The second high-risk issue is that relay admission verifies NIP-OA delegation signatures but does not enforce signed time conditions, while the desktop currently mints managed-agent credentials with no expiry at all. That turns a delegation credential into a durable bearer capability once the key and tag are copied.

The remaining issues are narrower but real: Git Smart HTTP intentionally turns NIP-98 into a short-lived repo-scoped bearer credential; generic NIP-98 body binding is optional even on JSON POST routes; the managed-agent key store does not enforce owner-only file permissions the way the human desktop key store does; mobile pairing cleanup leaves ephemeral secret material referenced after completion; the audit documentation claims HMAC-backed tamper evidence while the implementation is only an unkeyed hash chain; and the mobile hand-written NIP-44 parser accepts non-canonical padding.

This report does not count intended local agent authority as a cryptographic finding. If a user deliberately runs an agent with shell and file access on their machine, that agent is inside that machine's trust boundary. The crypto question is whether keys and credentials have the properties the product and protocols claim once they cross a boundary.

Severity Model

  • Critical: direct key recovery, signature forgery, or broad impersonation across an expected trust boundary with little precondition.
  • High: an explicit cryptographic protocol guarantee is broken, or a credential intended to be bounded becomes materially more durable or replayable.
  • Medium: a real composition, replay, or key-custody weakness that lowers a claimed guarantee but needs a narrower precondition.
  • Low: parser strictness, defense-in-depth, or test coverage gaps that are worth fixing before they become interoperability or maintenance problems.

No Critical findings were identified in the reviewed code.

Findings

High

Severity: High

NIP-AB makes the target user's SAS confirmation a local confidentiality boundary. The protocol allows the target to decrypt early only as much as is necessary to classify a message type. It explicitly says the target must not deserialize, extract, log, persist, or act on the payload field before the transcript is verified and the target user approves the SAS; the safest behavior is to buffer raw ciphertext until then. See crates/buzz-core/src/pairing/NIP-AB.md:316-318 and crates/buzz-core/src/pairing/NIP-AB.md:645-649.

The mobile implementation does more than that. It decrypts and jsonDecodes incoming pairing messages immediately in mobile/lib/features/pairing/pairing_provider.dart:276-333. When the message type is payload, it stores the full plaintext Map<String, dynamic> in _pendingPayload before the user confirms the SAS in mobile/lib/features/pairing/pairing_provider.dart:380-389. The actual nsec is extracted later in mobile/lib/features/pairing/pairing_provider.dart:416-456, but the sensitive plaintext is already resident in application memory and available to any bug, instrumentation, crash capture, or future code path that touches _pendingPayload.

The Rust reference state machine preserves the intended property. It moves the target into AwaitingConfirmation, requires confirm_target_sas() before handle_payload(), returns the secret in a Zeroizing<String>, and zeroizes session material on drop. See crates/buzz-core/src/pairing/session.rs:329-415 and crates/buzz-core/src/pairing/session.rs:740-750.

This is not a theoretical nicety. The protocol is designed so that a target device can receive an encrypted payload before the human has accepted the channel, while still withholding the secret from the local application until that final approval. The mobile code removes that distinction.

Recommended change: keep the incoming payload event or ciphertext opaque until confirmSas() succeeds, then decrypt and deserialize exactly once. If early message classification is unavoidable, parse only enough to distinguish payload from other message types and do not retain any decoded payload fields. Use mutable byte buffers for secret plaintext where practical so cleanup can do more than drop references.

C-02. Relay admission drops signed NIP-OA time bounds and managed agents mint non-expiring delegation credentials

Severity: High

NIP-OA auth tags are signed delegation credentials. The SDK verifies the tag signature and returns the owner pubkey, but it only syntax-checks conditions; it does not evaluate them. See crates/buzz-sdk/src/nip_oa.rs:33-109 and crates/buzz-sdk/src/nip_oa.rs:139-239. The NIP-OA documentation is clear that empty conditions impose no constraints and that verifiers must evaluate every condition. See docs/nips/NIP-OA.md:13-17, docs/nips/NIP-OA.md:43-71, and docs/nips/NIP-OA.md:93-103.

The relay admission path treats a valid auth tag signature plus owner membership as sufficient delegated membership. It does not evaluate created_at< or created_at> conditions when resolving the effective member for NIP-42 authentication. See crates/buzz-relay/src/api/mod.rs:54-158 and crates/buzz-relay/src/handlers/auth.rs:68-223. NIP-AA requires those time conditions to be evaluated during AUTH admission, in addition to the normal NIP-42 freshness check. See docs/nips/NIP-AA.md:71-125 and docs/nips/NIP-AA.md:143-167.

There is one path that does enforce time conditions: the NIP-IA request-borne identity archive path in crates/buzz-relay/src/handlers/identity_archive.rs:245-347. Git signing also evaluates NIP-OA conditions against its signed envelope timestamp in crates/git-sign-nostr/src/lib.rs:600-629, crates/git-sign-nostr/src/lib.rs:1034-1068, and crates/git-sign-nostr/src/lib.rs:1264-1356. That makes the relay admission gap look like an omitted semantic check, not a deliberate protocol-wide rule.

The current desktop issuance path makes the problem worse by generating managed agent auth tags with an empty condition string in desktop/src-tauri/src/commands/agents.rs:407-420. Deleting the local agent record only stops the process and removes local state; it does not revoke a credential that has already been copied elsewhere. See desktop/src-tauri/src/commands/agents.rs:1045-1097. crates/buzz-db/src/archived_identities.rs:1-6 also states that archive state is a relay-local UI visibility hint, not an authorization revocation mechanism.

The result is that a copied managed-agent private key plus auth tag remains usable as long as the owner remains a relay member, even if a future caller believes it issued a time-bounded tag. kind= conditions are intentionally not admission restrictions under NIP-AA, so the missing property is specifically time-bound admission and credential retirement, not generic event-kind restriction.

Recommended change: add a single NIP-AA admission verifier that accepts the AUTH event timestamp and evaluates all admission-relevant NIP-OA conditions after NIP-42 freshness verification. Make every relay admission caller use it. Issue managed-agent credentials with an explicit short lifetime or add an independent relay-side revocation/rotation mechanism keyed by the delegated agent identity. Deleting or retiring an agent should revoke future admission, not just remove the desktop process record.

Medium

C-03. Git Smart HTTP uses NIP-98 as a replayable repo-scoped bearer credential

Severity: Medium

The Git Smart HTTP path intentionally relaxes NIP-98 request binding so that one credential works across the multiple HTTP requests Git emits for a repository. The relay strips a request down to the repo root URL, does not bind the credential to the actual HTTP method, passes body=None, and intentionally does not deduplicate event IDs. See crates/buzz-relay/src/api/git/transport.rs:111-175. The credential helper matches that behavior by minting a repo-root auth event in crates/git-credential-nostr/src/lib.rs:182-231.

This means a captured credential is a short-lived bearer token for the same repository, not a proof for one exact request. Within the verifier freshness window, the holder can replay it against clone, fetch, or push endpoints for that repository, subject to the original account's repo authorization and the pre-receive push policy. The code comments acknowledge the need for this tradeoff, but the actual scope is broader than an operation-specific signature.

This is not a broken signature scheme. It is a deliberate reduction from "signed request" to "signed short-lived repo session". That can be a reasonable engineering choice, but it should be treated and documented as a bearer credential because its compromise behavior is different.

Recommended change: either document the Git token as an explicit short-lived repo-scoped bearer credential, or add a protocol layer that binds the credential to a smaller session scope and operation set. If the current design remains, avoid describing it as method or service bound when the verifier actually accepts the repo-root token across multiple Git endpoints.

C-04. Bridge NIP-98 body integrity is optional on body-carrying JSON routes

Severity: Medium

The generic NIP-98 verifier checks a SHA-256 payload tag only when both a payload tag and a request body are present. A body-bearing request with no payload tag is accepted. See crates/buzz-auth/src/nip98.rs:34-130; the test that codifies this behavior is crates/buzz-auth/src/nip98.rs:267-273.

The HTTP bridge routes for /events, /query, and /count all pass request bodies through this verifier and use a replay cache. See crates/buzz-relay/src/api/bridge.rs:23-105, crates/buzz-relay/src/api/bridge.rs:167-234, crates/buzz-relay/src/api/bridge.rs:241-325, and crates/buzz-relay/src/api/bridge.rs:497-615. First-party desktop, ACP, and CLI callers do the stronger thing and include both a payload hash and a nonce. See desktop/src-tauri/src/relay.rs:94-136, crates/buzz-acp/src/relay.rs:202-248, crates/buzz-acp/src/relay.rs:311-336, and crates/buzz-cli/src/client.rs:85-117.

The replay cache limits repeated use, but it cannot make the first accepted body be the body the signer intended if the auth event itself does not commit to a payload hash. A captured no-payload auth event can be paired with a different body before the intended request wins the race. The Git path needs weaker body binding for streaming protocol reasons; JSON bridge routes do not.

Recommended change: split NIP-98 verification into explicit modes. Require a payload tag for non-streaming body-carrying routes such as /events, /query, and /count, and keep the Git exception local to the Git transport code rather than making optional body integrity the generic verifier default.

C-05. Managed-agent key store does not enforce owner-only file permissions

Severity: Medium

Managed agent records contain the raw agent nsec and auth tag in plaintext. See desktop/src-tauri/src/managed_agents/types.rs:85-99 and desktop/src-tauri/src/commands/agents.rs:377-385, desktop/src-tauri/src/commands/agents.rs:516-522. The store writes JSON with std::fs::write followed by rename in desktop/src-tauri/src/managed_agents/storage.rs:11-70; it does not explicitly set or validate owner-only permissions.

The desktop human identity store is stricter. It uses an atomic write path and sets Unix mode 0600 for the key file in desktop/src-tauri/src/app_state.rs:127-234. The managed-agent store therefore has a weaker custody guarantee for credentials that are still capable of relay admission and event signing.

Actual exposure depends on the app data directory mode and the user's umask, so this is not a claim that every install is immediately world-readable. The problem is that the code does not enforce the property at the file that matters.

Recommended change: give the managed-agent store the same atomic owner-only write and load-time permission validation used for the desktop human key. For longer-lived deployments, consider moving both human and agent private keys into an OS key store rather than relying only on plaintext files protected by filesystem ACLs.

C-06. Mobile pairing cleanup leaves ephemeral secret material live after completion and abort

Severity: Medium

The NIP-AB specification requires ephemeral private keys, the session secret, and decrypted plaintext to be zeroed after completion, abort, or timeout. See crates/buzz-core/src/pairing/NIP-AB.md:389 and crates/buzz-core/src/pairing/NIP-AB.md:555-562. The Rust implementation does that for its session and QR objects in crates/buzz-core/src/pairing/session.rs:740-750 and crates/buzz-core/src/pairing/qr.rs:34-57.

The mobile provider's _cleanup() only cancels timers and subscriptions, clears status flags, and drops _pendingPayload. It leaves _ephemeralPrivkey, _ephemeralPubkey, _sessionSecret, _sourcePubkey, _sessionId, _sasInput, and _conversationKey referenced on the notifier. See mobile/lib/features/pairing/pairing_provider.dart:119-143.

Dart strings and ordinary immutable objects are not ideal secret containers, so true zeroization may require a different representation. Even then, the current code can at least shorten lifetime by clearing references and avoiding plaintext objects that survive past the session state machine.

Recommended change: clear every session secret field on all terminal paths and move secret-bearing values toward mutable byte buffers or platform-backed containers that can be overwritten before release. Pair this with tests that assert terminal transitions drop all retained secret state.

C-07. Audit documentation claims HMAC-backed tamper evidence but implementation is an unkeyed hash chain

Severity: Medium

SECURITY.md:67-72 describes the audit log as HMAC chained and suitable for strong tamper evidence. The implementation computes a plain SHA-256 digest over the entry fields plus the previous hash in crates/buzz-audit/src/hash.rs:1-77. crates/buzz-audit/src/service.rs:19-193 serializes writes and verifies the stored chain, but there is no secret key and no external anchor.

An unkeyed hash chain detects accidental corruption and some incomplete edits. It does not stop an attacker with write access to the audit table from rewriting a suffix, recomputing every following hash, and leaving a self-consistent chain. A keyed MAC would raise the bar for a database-only attacker if the MAC key lived outside the database. An external signed anchor or append-only transparency system would be needed for stronger guarantees against a compromised relay host.

Recommended change: either narrow the documentation to the actual property, or implement a keyed chain with the key stored outside Postgres and anchor chain heads externally. The threat model should say whether the intended adversary is accidental corruption, database-only compromise, or relay-host compromise.

Low

C-08. Mobile NIP-44 accepts non-canonical padding and lacks direct vector coverage

Severity: Low

The mobile client carries a hand-written NIP-44 v2 implementation in mobile/lib/shared/crypto/nip44.dart:12-176. Its _unpad() routine checks that the declared length is non-zero and fits within the decrypted buffer, but it does not verify that the total padded length matches the canonical NIP-44 padding calculation or that trailing padding bytes are zero. See mobile/lib/shared/crypto/nip44.dart:127-134.

Because the ciphertext is MAC authenticated, this is not a practical plaintext forgery or key-recovery issue by itself. It is a parser strictness divergence: the mobile client can accept authenticated plaintext encodings that stricter implementations would reject. That creates avoidable cross-language behavior differences in the code that carries identity-transfer secrets.

The current mobile tests exercise pairing behavior, but I did not find direct official NIP-44 cross-language vector coverage for this implementation. Pairing tests live in mobile/test/features/pairing/pairing_provider_test.dart; the only direct NIP-44 use found in tests was mobile/test/features/channels/agent_activity/observer_subscription_test.dart.

Recommended change: enforce canonical padding in _unpad() and add official NIP-44 v2 vectors plus Rust-to-Dart interoperability cases for encryption, decryption, padding boundaries, and malformed authenticated payloads.

Primitive Inventory

Primitive Where It Is Used Assessment
BIP-340 Schnorr over secp256k1 Nostr event signatures, relay-signed events, Git NIP-GS envelopes, pairing ephemeral events Appropriate primitive. The main risk is private-key custody and semantic verification around the signed object, not signature forgery.
SHA-256 Nostr event IDs, NIP-98 payload hashes, media content hashes, approval/API token hashing, audit chain, Git object and envelope hashing Appropriate hash. Its use in the audit chain is not a substitute for a keyed MAC or external anchor.
HMAC-SHA256 NIP-44 authentication, NIP-AB derivations via HKDF, Git hook authentication, engram opaque tags, proxy shadow-key derivation Appropriate when the key is well protected and domain separation is explicit. The Git hook and engram uses are among the better composed parts of the codebase.
HKDF-SHA256 NIP-44 and NIP-AB key derivation Appropriate. Pairing derivations are domain separated and reviewed below.
ChaCha20 in NIP-44 v2 Encrypted Nostr application payloads, pairing, reminders, observers, read state, engrams Appropriate as part of the NIP-44 v2 construction. The significant question is when plaintext is exposed and what metadata remains public.
UUID v4 bearer values Workflow webhooks, workflow approvals, proxy invites, some API-facing tokens Roughly 122 random bits after UUID version and variant bits, which is adequate against guessing. These values are bearer secrets and need short lifetime, hashing where possible, and careful transport.
TLS / WSS / HTTPS Transport confidentiality for normal messages, audio, Git, API tokens, and webhook secrets External trust boundary, not application-layer cryptography. Ordinary channel messages and huddle audio rely on relay and transport confidentiality unless separately wrapped in NIP-44.

Encoding mechanisms such as bech32, hex, base64, and JSON canonicalization are not cryptographic protections by themselves. Their security relevance here is whether they preserve an unambiguous signed or MACed representation.

Key And Secret Inventory

Material Generation And Storage Used For Review Notes
Desktop human Nostr private key Created and stored by desktop/src-tauri/src/app_state.rs:127-234 in an atomic 0600 file User event signing, NIP-42, NIP-98, NIP-44, pairing source payload Plaintext at rest behind OS file permissions. Better hardened than the managed-agent store.
Mobile human Nostr private key Stored inside workspace JSON through FlutterSecureStorage in mobile/lib/shared/workspace/workspace_storage.dart:1-95, mobile/lib/shared/workspace/workspace.dart:6-71, and mobile/lib/shared/auth/auth_provider.dart:16-134 User event signing, NIP-42, self-encryption, imported pairing identity Better at-rest posture than a plain file, but runtime code necessarily handles plaintext key material.
Managed-agent private key Freshly generated in desktop/src-tauri/src/commands/agents.rs:377-385, stored in agent JSON record at desktop/src-tauri/src/managed_agents/types.rs:85-99, injected into child env by desktop/src-tauri/src/managed_agents/runtime.rs:1549-1561 Agent Nostr identity, relay auth, event signing Durable local credential. Store permissions are not explicitly hardened; copied key plus auth tag remains powerful.
NIP-OA auth tag Signed by owner in desktop/src-tauri/src/commands/agents.rs:407-420; passed to agents in desktop/src-tauri/src/managed_agents/runtime.rs:1648-1656 Delegated owner context for relay membership and agent attribution Signature is verified, but relay admission does not enforce time conditions. Current desktop issuance uses empty conditions.
Relay Nostr private key Loaded or derived during startup in crates/buzz-relay/src/main.rs:83-105 and crates/buzz-relay/src/main.rs:204-225 Relay identity, NIP-11 identity, relay-signed state and lifecycle events Production membership mode needs a stable key. Development fallback is deterministic scalar-1, which is suitable only for local development.
NIP-44 conversation keys Derived per sender/recipient pair by Rust nostr code and by mobile mobile/lib/shared/crypto/nip44.dart:12-176 Engrams, observer frames, reminders, read state, pairing payloads Content confidentiality only. Tags, timing, sender/recipient relationships, and other public metadata remain visible.
NIP-AB ephemeral private keys and session secret Generated for each pairing session in crates/buzz-core/src/pairing/session.rs:84-109 and QR handling in crates/buzz-core/src/pairing/qr.rs:34-57; mobile keeps equivalents in mobile/lib/features/pairing/pairing_provider.dart:132-143 ECDH, SAS derivation, one-time identity transfer Rust zeroizes on drop. Mobile currently retains references after cleanup and exposes payload plaintext too early.
Git hook HMAC secret Loaded or generated by relay config in crates/buzz-relay/src/config.rs:320-350 Authenticate relay-to-hook push policy context Canonical length-prefixed HMAC construction is sound. Configured minimum is 32 hex characters, which is 16 bytes and still adequate, though wording should be precise.
Workflow webhook secret UUID v4 bearer generated in crates/buzz-relay/src/webhook_secret.rs:22-90 Invoke workflow webhook endpoint Stored as a bearer secret in workflow state, stripped from responses, constant-time compared. Query-string transport in crates/buzz-relay/src/api/bridge.rs:831-973 can leak through logs and referrers more easily than header transport.
Workflow approval token UUID v4 raw token generated in crates/buzz-workflow/src/executor.rs:714-721, SHA-256 hash stored via crates/buzz-db/src/workflow.rs:27-33 and crates/buzz-db/src/workflow.rs:605-755 Approve one pending workflow step Good pattern: raw value is not stored and DB update is written to avoid approval races.
API token Hashed and expiry/revocation tracked in crates/buzz-db/src/api_token.rs:1-290 Media/API bearer authentication Better than storing raw bearer values. Actual security depends on issuance, transport, and revocation behavior at callers.
Proxy server salt and derived shadow keys Server salt feeds HMAC-SHA256(salt, external_pubkey) in crates/buzz-proxy/src/shadow_keys.rs:1-117 Deterministic local shadow identities for NIP-28 compatibility Salt compromise lets an attacker derive every shadow key for known external pubkeys. Salt rotation changes every shadow identity.
Infrastructure secrets Read from relay config for Postgres, Redis, S3, Typesense, and similar services in crates/buzz-relay/src/config.rs Service authentication These are ordinary service credentials rather than Nostr keys, but relay compromise exposes them and therefore bypasses many higher-level crypto guarantees.

Flow Review

Nostr Event Signatures And Relay Identity

The event signing core is conventional and easy to reason about. Nostr event IDs are recomputed and signatures are verified in crates/buzz-core/src/verification.rs:8-31. The relay ingest path rejects events that do not verify, and ordinary WebSocket event submission also checks that an event's pubkey matches the authenticated connection except for explicit protocol cases such as gift wraps and proxy behavior. This is the right base property: if an event is accepted as authored by a Nostr pubkey, the relay has verified a Schnorr signature for that pubkey.

The relay key itself is more than an implementation detail. It signs relay-authored state and lifecycle events and becomes the identity clients see through NIP-11. Membership mode requires a stable relay key in crates/buzz-relay/src/main.rs:83-105. Production token mode also refuses to start without a configured key, while development mode falls back to a deterministic scalar-1 key in crates/buzz-relay/src/main.rs:204-225. crates/buzz-relay/src/nip11.rs:106-171 only advertises a stable relay identity and NIP-43 capability when the configuration supports it.

The configuration comment in crates/buzz-relay/src/config.rs:49-51 says an absent relay key generates a fresh key, but the runtime behavior is now different: development gets a deterministic key and production panics. That is not a crypto vulnerability, but stale comments around identity material are a good way to create operational mistakes.

NIP-42 WebSocket Authentication

The main relay NIP-42 implementation is one of the stronger auth surfaces. It generates 32 random challenge bytes, requires an exact challenge match, binds the AUTH event to the relay URL, verifies signature and kind, and enforces a freshness window of about 60 seconds. See crates/buzz-auth/src/nip42.rs:35-85. That gives a client proof of key possession and prevents a captured AUTH event from remaining useful for long.

The proxy NIP-42 implementation is looser. It uses a UUID v4 challenge, accepts a 10-minute freshness window, and treats relay-tag mismatch as nonfatal in crates/buzz-proxy/src/server.rs:173-313. A UUID v4 challenge still has enough entropy for guessing resistance, but the proxy has a weaker domain-binding and replay posture than the primary relay. That may be acceptable for its compatibility role, but it should be an explicit choice rather than an accidental divergence.

NIP-98 HTTP Authentication

The first-party desktop, ACP, and CLI clients generally create well-bound NIP-98 requests: they sign URL, method, a payload hash, and a nonce. See desktop/src-tauri/src/relay.rs:94-136, crates/buzz-acp/src/relay.rs:202-248, crates/buzz-acp/src/relay.rs:311-336, and crates/buzz-cli/src/client.rs:85-117. That is the right shape for JSON API requests.

The generic verifier is intentionally more permissive because Git Smart HTTP cannot conveniently sign each streamed body. That exception has leaked into the default verifier contract: payload binding is optional even when a caller does have a body. C-04 is the resulting problem. The web helper in web/src/shared/lib/nip98.ts:1-33 also omits a payload hash, but the reviewed use is the Git client in web/src/features/repos/git-client.ts, where it fits the weaker Git path rather than the JSON bridge.

NIP-OA Delegation And Agent Identity

NIP-OA is a signed capability system layered on top of Nostr identity. The owner signs an auth tag naming an agent pubkey and conditions. The agent then proves possession of its own key while presenting the owner's delegation. The cryptographic binding itself is straightforward: without the owner's signature, an arbitrary agent cannot claim owner context.

The difficult part is semantic verification. verify_auth_tag() in the SDK answers "did the owner sign this tag for this agent?" It does not answer "is this credential still valid for this admission at this time?" Several call sites use it only as an owner-binding primitive, including ACP identity resolution in crates/buzz-acp/src/lib.rs:86-112, crates/buzz-acp/src/lib.rs:159-285, and crates/buzz-acp/src/lib.rs:1102-1143, plus desktop conversion and relay helper logic in desktop/src-tauri/src/nostr_convert.rs:60-83 and desktop/src-tauri/src/relay.rs:317-380.

That is fine when the caller only needs attribution. It is not fine for relay admission, where NIP-AA gives time conditions authorization meaning. The lack of one centralized semantic verifier is what allowed C-02 to exist. The design would be easier to audit if the code had separate names for "verify delegation signature" and "authorize delegated action under conditions".

NIP-44 Application Encryption

Buzz uses NIP-44 in several places, and the Rust paths are generally careful. Engrams derive an opaque d tag using HMAC with domain separation, encrypt content with a NIP-44 conversation key, enforce a strict envelope, and rederive the expected tag after decryption. See crates/buzz-core/src/engram.rs:1-154 and crates/buzz-core/src/engram.rs:436-599. The relay validates engram envelopes before allowing replaceable heads to be overwritten in crates/buzz-relay/src/handlers/ingest.rs:822-957 and crates/buzz-relay/src/handlers/ingest.rs:1474-1477.

Observer frames use NIP-44 v2 with size bounds and plaintext zeroization in crates/buzz-core/src/observer.rs:1-108. The relay routes only the public envelope and excludes sensitive kinds from search in crates/buzz-relay/src/handlers/event.rs:188-200, crates/buzz-relay/src/handlers/event.rs:314-329, and crates/buzz-relay/src/handlers/event.rs:826-885.

NIP-ER reminders and NIP-RS read state are intentionally self-encrypted. The reminder protocol keeps due-time metadata public so the relay can schedule delivery, while hiding the reminder body. See docs/nips/NIP-ER.md:9-13, docs/nips/NIP-ER.md:38-40, docs/nips/NIP-ER.md:46-77, docs/nips/NIP-ER.md:104-105, and docs/nips/NIP-ER.md:135-201. Read state uses self-encryption and random slot IDs as described in docs/nips/NIP-RS.md:9-17 and docs/nips/NIP-RS.md:39-109; mobile applies that pattern in mobile/lib/features/channels/read_state/read_state_manager.dart.

Gift wraps are opaque NIP-17 events whose wrapper author is ephemeral. The relay therefore cannot require wrapper pubkey to match the authenticated connection in the normal way. It still p-gates them and excludes them from search. See crates/buzz-core/src/kind.rs:59-60, crates/buzz-relay/src/handlers/ingest.rs:1146-1219, crates/buzz-relay/src/handlers/event.rs:188-200, and crates/buzz-relay/src/handlers/event.rs:287-301.

The common caveat across all NIP-44 use is that content encryption is not metadata encryption. The relay still sees event kind, timing, size, tags needed for routing, and often the relationship between sender and recipient. That is normal for Nostr-style encrypted events, but it matters when evaluating privacy claims.

NIP-AB Pairing

The Rust NIP-AB implementation is the strongest cryptographic subsystem in the repository. It uses fresh ephemeral secp256k1 keypairs, a 32-byte session secret, HKDF-SHA256 with explicit domain separation, constant-time comparison, NIP-44 v2 payload encryption, strict state transitions, timeout handling, recipient p-tag validation, duplicate suppression, and zeroization. See crates/buzz-core/src/pairing/crypto.rs:1-135, crates/buzz-core/src/pairing/session.rs:84-109, crates/buzz-core/src/pairing/session.rs:113-283, crates/buzz-core/src/pairing/session.rs:288-428, and crates/buzz-core/src/pairing/session.rs:432-750.

The QR parser validates key and secret lengths, rejects all-zero secret material, validates relay URLs and versioning, and zeroizes sensitive fields on drop. See crates/buzz-core/src/pairing/qr.rs:34-57, crates/buzz-core/src/pairing/qr.rs:83-96, and crates/buzz-core/src/pairing/qr.rs:110-225. The sidecar pairing relay is deliberately unauthenticated and history-free, but it validates event signatures, freshness, deduplication, and bounds while only routing opaque NIP-44 ciphertext. See crates/buzz-pair-relay/src/lib.rs:1-27, crates/buzz-pair-relay/src/lib.rs:60-91, and crates/buzz-pair-relay/src/lib.rs:323-591.

The protocol's hard limit is human SAS comparison. A QR observer can race the real target, but the user should detect that because the SAS values differ. That assumption and the 120-second QR exposure window are documented in crates/buzz-core/src/pairing/NIP-AB.md:538-605. The mobile implementation weakens the local half of that design through C-01 and C-06.

Desktop pairing sends a raw nsec inside the encrypted payload in desktop/src-tauri/src/commands/pairing.rs:35-60 and desktop/src-tauri/src/commands/pairing.rs:79-182. The NIP permits that, though it recommends NIP-49 ncryptsec for defense in depth in crates/buzz-core/src/pairing/NIP-AB.md:338-369. This is not a finding because the transport channel is intended to protect the payload, but encrypting the transferred key at rest before it leaves the source would reduce the impact of a later target-storage mistake.

Git Commit Signing And Git Transport

git-sign-nostr is unusually careful code. It separates its trust limits in crates/git-sign-nostr/src/lib.rs:1-72, uses Zeroizing for key material, removes key environment variables after loading in crates/git-sign-nostr/src/lib.rs:404-464, and opens key files with O_NOFOLLOW, O_NONBLOCK, owner checks, and mode checks in crates/git-sign-nostr/src/lib.rs:783-898. Its NIP-GS hash includes a domain separator, timestamp, auth-tag binding, and payload in crates/git-sign-nostr/src/lib.rs:900-932; its JSON envelope parser is strict and canonical in crates/git-sign-nostr/src/lib.rs:936-958 and crates/git-sign-nostr/src/lib.rs:1410-1519.

The Git signer also separates cryptographic verification from advisory trust decisions and actually evaluates NIP-OA conditions. See crates/git-sign-nostr/src/lib.rs:600-629, crates/git-sign-nostr/src/lib.rs:1034-1080, and crates/git-sign-nostr/src/lib.rs:1264-1356. That is the right pattern for delegated signatures.

The credential helper is less hardened around key-file loading. It checks file metadata and mode but does not use the same anti-symlink and owner-race protections as the signer. See crates/git-credential-nostr/src/lib.rs:30-74. That is a key hygiene inconsistency, though the more important transport issue is C-03.

The relay-to-hook push policy authentication is well composed. It uses a canonical length-prefixed payload, HMAC-SHA256, constant-time verification, and a 30-second TTL in crates/buzz-relay/src/api/git/policy.rs:1-235 and crates/buzz-relay/src/api/git/policy.rs:386-409. The shell hook mirrors that format and fails closed in crates/buzz-relay/src/api/git/hook.rs:1-176. The relay also signs ref-state events in crates/buzz-relay/src/api/git/manifest_event.rs:59-117.

Media Upload Authentication

The Blossom-style media path is stronger than generic NIP-98 for body integrity. crates/buzz-media/src/auth.rs:1-135 verifies signature, kind, verb, expiry, freshness, server tags, and a mandatory x content hash. The upload handlers compute SHA-256 and verify it after buffering or streaming in crates/buzz-media/src/upload.rs:25-115 and crates/buzz-media/src/upload.rs:211-404. The relay API requires X-SHA-256, matches it against the signed x tag, enforces token signer-owner binding, and checks membership in crates/buzz-relay/src/api/media.rs:27-122 and crates/buzz-relay/src/api/media.rs:567-665.

That is the right model for large uploads: the signed authorization commits to the content hash, and the receiver recomputes the hash over the bytes actually stored.

Bearer Tokens, Webhooks, And Approval Secrets

Not every secret in Buzz is a signing key. Workflow webhook secrets, approval tokens, API tokens, and proxy invite tokens are bearer credentials. Their security depends on entropy, transport, storage, and lifetime rather than signature verification.

Webhook secrets are UUID v4 values, are stripped from normal workflow responses, and are compared in constant time in crates/buzz-relay/src/webhook_secret.rs:22-90. The endpoint accepts them either through X-Webhook-Secret or a ?secret= query parameter in crates/buzz-relay/src/api/bridge.rs:831-973. Header transport is materially better because query strings are more likely to be copied into access logs, browser history, monitoring traces, and referrer-bearing URLs.

Approval tokens follow a better storage pattern: the executor creates a random raw value, while the database stores only a SHA-256 hash and consumes it with a race-safe update. See crates/buzz-workflow/src/executor.rs:714-721, crates/buzz-db/src/workflow.rs:27-33, and crates/buzz-db/src/workflow.rs:605-755. API tokens also have hashed storage, expiry, and revocation fields in crates/buzz-db/src/api_token.rs:1-290.

Proxy invite tokens are UUID v4 bearer values stored raw in memory and consumed by lookup in crates/buzz-proxy/src/invite.rs:8-60, crates/buzz-proxy/src/invite_store.rs:10-70, and crates/buzz-proxy/src/server.rs:807-910. That is adequate for short-lived in-memory invites, but they should be treated as secrets in logs and metrics.

Proxy Shadow Keys

The proxy deterministically derives a local shadow identity from an external pubkey using HMAC-SHA256(server_salt, external_pubkey_bytes) in crates/buzz-proxy/src/shadow_keys.rs:1-117. This is a reasonable deterministic derivation if the salt is high entropy and remains secret. It also creates a single high-value secret: anyone who gets the salt can derive every shadow private key for every known external pubkey. Salt rotation changes every derived identity, so rotation is operationally expensive.

That construction is not inherently wrong, but the salt should be handled like a master key, not like a casual configuration value.

Audit Chain

The audit chain is useful for detecting accidental breaks and some partial tampering, but it is not cryptographically equivalent to a keyed append-only log. C-07 covers the mismatch between documentation and implementation. The important design question is who the audit system is intended to detect: application bugs, database-only attackers, or a compromised relay operator. Those are different threat models and require different cryptographic anchors.

Huddle Audio

Huddle audio is not application-layer encrypted. The relay receives and forwards audio frames whose body is treated as opaque Opus data in crates/buzz-relay/src/audio/mod.rs:1-10 and crates/buzz-relay/src/audio/wire.rs:1-19, crates/buzz-relay/src/audio/wire.rs:56-85. Authentication uses NIP-42, and room lifecycle events are relay-signed in crates/buzz-relay/src/audio/handler.rs:1-12, crates/buzz-relay/src/audio/handler.rs:93-145, and crates/buzz-relay/src/audio/handler.rs:719-785.

"Opaque to the relay code" is not the same as "encrypted from the relay". Audio confidentiality relies on the relay host and transport security unless another layer is added above the current wire format.

Trust Boundaries And Non-Findings

The desktop renderer is inside the human account-key trust boundary. Tauri commands expose event signing, get_nsec, import, and self-NIP-44 operations in desktop/src-tauri/src/commands/identity.rs:67-90, desktop/src-tauri/src/commands/identity.rs:151-197, and desktop/src-tauri/src/commands/identity.rs:199-244. A renderer compromise is therefore a key compromise. That may be an acceptable product boundary, but it should not be confused with a hardware-backed or isolated signer design.

Managed agents are also intentionally inside the local machine trust boundary. The runtime injects BUZZ_PRIVATE_KEY, BUZZ_AUTH_TAG, and NOSTR_PRIVATE_KEY into child processes in desktop/src-tauri/src/managed_agents/runtime.rs:1549-1561, desktop/src-tauri/src/managed_agents/runtime.rs:1648-1656, and desktop/src-tauri/src/managed_agents/runtime.rs:1682-1687. The environment filtering in desktop/src-tauri/src/managed_agents/env_vars.rs:58-145 is useful defense in depth, but an agent that is deliberately allowed to run local tools is not a secret-isolated principal.

Ordinary messages are signed, not end-to-end encrypted. The relay, its storage, and its operators can see plaintext unless a specific feature wraps content in NIP-44. Huddle audio has the same property. TLS protects transport hops; it does not remove the relay from the confidentiality boundary.

Encrypted Nostr events still expose routing metadata. NIP-44 hides content, but the relay can often observe timing, size, event kind, and tags. Any privacy claim should be phrased as content confidentiality, not traffic-analysis resistance.

Some relay-signed state is trusted because it came from the configured relay, not because every client re-verifies every relay signature. The NIP-DV docs state this explicitly for desktop query behavior in docs/nips/NIP-DV.md:118-128. That is a reasonable trust model for a single configured workspace relay, but it is different from an offline-verifiable transparency model.

  1. Fix mobile NIP-AB payload handling so target consent actually gates payload plaintext exposure, then clear all pairing secret state on every terminal path.
  2. Centralize NIP-OA/NIP-AA semantic authorization and enforce signed time conditions during relay admission; add real managed-agent credential retirement.
  3. Make NIP-98 payload binding mandatory for JSON bridge routes and keep the Git weakening explicit and local.
  4. Harden managed-agent key storage to the same permission standard as the desktop human identity key.
  5. Decide what audit tamper-evidence threat model is intended and align either the implementation or the documentation.
  6. Add strict mobile NIP-44 vector tests and canonical padding checks.