diff --git a/.env.example b/.env.example index 0f7bbba6f13..4946b06c00f 100644 --- a/.env.example +++ b/.env.example @@ -53,7 +53,7 @@ BUZZ_BIND_ADDR=0.0.0.0:3000 RELAY_URL=ws://localhost:3000 # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. -# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> +# BUZZ_RELAY_PRIVATE_KEY=nsec1… # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). @@ -137,11 +137,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # are optional unless noted; defaults are shown in comments. # # Quick start: -# BUZZ_PRIVATE_KEY= BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp +# BUZZ_PRIVATE_KEY=nsec1… BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp # ── Identity & auth ────────────────────────────────────────────────────────── -# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay. -# BUZZ_PRIVATE_KEY=<32-byte hex or nsec1… private key> +# Nostr private key in NIP-19 nsec form. REQUIRED — identifies the agent on the relay. +# BUZZ_PRIVATE_KEY=nsec1… # Relay WebSocket URL the harness connects to. # Note: the relay itself uses RELAY_URL (above); this is the ACP harness's diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 892082d96c6..e83597b3a17 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -523,7 +523,7 @@ steps: - id: page if: "str_contains(trigger_text, 'production')" action: request_approval - from: "{{trigger.author}}" + from: "{{trigger.author | npub}}" message: "Page on-call?" ``` @@ -536,16 +536,16 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg | Action | Description | |--------|-------------| | `send_message` | Post to the workflow's channel (or override channel) | -| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) | +| `send_dm` | Direct message to a user (`npub`; legacy hex remains accepted) | | `set_channel_topic` | Update channel topic | | `add_reaction` | React to the trigger message | | `call_webhook` | HTTP POST to external URL (SSRF-protected, redirects disabled, 1 MiB response cap) | | `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) | | `delay` | Pause execution (max 300 seconds) | -**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text. +**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. For compatibility with persisted definitions, bare `{{trigger.author}}` retains its protocol-hex result. Use `{{trigger.author | npub}}` for canonical human-facing output; the filter is idempotent for npub input. Single-pass resolution (not recursive). Unknown variables are left as literal text. -**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking. +**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation is converted to underscores (`trigger.text` → `trigger_text`). `trigger_author` remains protocol hex, matching bare template substitution. Portable trigger JSON serializes the author as npub and normalizes it back to internal hex when a persisted run is resumed. Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. A 100ms timeout prevents adversarial expressions from blocking. **Concurrency:** `Arc` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing. @@ -681,7 +681,7 @@ Subcommands: | Subcommand | Purpose | |------------|---------| -| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`); accepts npub or hex; publishes kind:13534 roster | +| `add-member` | Add an `npub` to the relay membership list (`--pubkey`, `--role`); publishes kind:13534 roster | | `remove-member` | Remove a pubkey from the relay membership list (`--pubkey`, optional `--role` guard); publishes kind:13534 roster | | `list-members` | List all relay members | | `generate-key` | Generate a new Nostr keypair (for bootstrapping) | diff --git a/Cargo.lock b/Cargo.lock index eaea5b35a8b..2db5233a149 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,6 +881,7 @@ dependencies = [ "tokio", "tracing", "url", + "zeroize", ] [[package]] @@ -996,6 +997,7 @@ dependencies = [ "tokio", "url", "uuid", + "zeroize", ] [[package]] @@ -1891,6 +1893,7 @@ name = "countdown-bot" version = "0.1.0" dependencies = [ "anyhow", + "buzz-core", "buzz-sdk", "futures-util", "nostr 0.44.7", diff --git a/Justfile b/Justfile index 2e62599dacf..48c537f6700 100644 --- a/Justfile +++ b/Justfile @@ -342,11 +342,12 @@ mesh-dev-fresh: set -euo pipefail ./scripts/dev-reset.sh --yes ./scripts/setup-desktop-test-data.sh - export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03" + # Canonical human/config forms; relay internals normalize these to protocol hex. + export BUZZ_PRIVATE_KEY="nsec18kawhtd4mlth0le9zj0wyvxeq7s448sjjj6qhqcxv8n9hdp0dspsaqmxsm" export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true export BUZZ_ALLOW_NIP_OA_AUTH=true - export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34" - export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001" + export RELAY_OWNER_PUBKEY="npub1uh4udnd40xlpzt3ndnp3ndvcnd9mdtc30ph2jrd7226lprt5rv6q28qswd" + export BUZZ_RELAY_PRIVATE_KEY="nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl" export BUZZ_RECONCILE_CHANNELS=true export BUZZ_RESET_WEBVIEW_STATE=1 exec just mesh=1 dev diff --git a/NOSTR.md b/NOSTR.md index cce70f2f77f..867490b0a23 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -223,9 +223,8 @@ Use `buzz-admin` — the operator CLI shipped in the relay image — to manage r In a Docker Compose deployment, use `run.sh`: ```bash -# Add a member (accepts bech32 npub or 64-char hex; default role: member) +# Add a member by npub (default role: member) ./run.sh add-member npub1abc... -./run.sh add-member <64-char-hex-pubkey> ./run.sh add-member npub1abc... --role admin # Remove a member @@ -262,7 +261,7 @@ docker compose exec relay buzz-admin list-members |----------|-------| | `DATABASE_URL` | Postgres connection string | | `REDIS_URL` | Redis connection string | -| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events | +| `BUZZ_RELAY_PRIVATE_KEY` | NIP-19 `nsec` — required to sign kind:13534 events | ### NIP-43 Admin Events (WebSocket) @@ -335,7 +334,7 @@ but only admins/owners can set it. Full spec: | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth | -| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) | +| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | NIP-19 `nsec` for relay signing (discovery events, system messages) | | `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections | --- diff --git a/TESTING.md b/TESTING.md index 7c107da5754..329afe7bc40 100644 --- a/TESTING.md +++ b/TESTING.md @@ -290,7 +290,7 @@ CLI-side, only two matter for testing: | Variable | Default | Notes | |-------------------------|--------------------------|-------| | `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises | -| `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex | +| `BUZZ_PRIVATE_KEY` | — (**required**) | NIP-19 `nsec1…` | | `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON | --- diff --git a/admin-web/package.json b/admin-web/package.json index 3f3eeebd875..272ff4c72f0 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@vitejs/plugin-react": "^6.0.0", + "nostr-tools": "^2.23.3", "vite": "^8.0.0", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index f39ecc33c50..ff30971cf51 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { ApiFailure, request } from "./api"; +import { formatNpub, truncatePubkey } from "./pubkey"; import type { FeedbackDetail, FeedbackSummary, @@ -114,7 +115,10 @@ function Reports() { {report.reportType} {report.communityHost} - {report.targetKind}: {short(report.target)} + {report.targetKind}:{" "} + {report.targetKind === "pubkey" + ? truncatePubkey(report.targetNpub ?? report.target) + : short(report.target)}
@@ -166,11 +170,17 @@ function ReportDetail({ id }: { id: string }) {
Reporter
- {report.reporterPubkey} + + {formatNpub(report.reporterNpub ?? report.reporterPubkey)} +
Target
- {report.target} + + {report.targetKind === "pubkey" + ? formatNpub(report.targetNpub ?? report.target) + : report.target} +
{report.targetKind === "event" ? ( <> @@ -184,7 +194,12 @@ function ReportDetail({ id }: { id: string }) {

{report.message.content}

Author - {report.message.authorPubkey} + + {formatNpub( + report.message.authorNpub ?? + report.message.authorPubkey, + )} + Created
@@ -328,7 +343,11 @@ function FeedbackList() { {item.bodySummary} {item.communityHost} - {short(item.submitterPubkey)} + + {truncatePubkey( + item.submitterNpub ?? item.submitterPubkey, + )} +
@@ -411,7 +430,8 @@ function FeedbackResults({ item.bodySummary, item.communityHost, item.category ?? "uncategorized", - item.submitterPubkey, + item.submitterNpub ?? item.submitterPubkey, + formatNpub(item.submitterNpub ?? item.submitterPubkey), ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); return { communities, filtered }; @@ -469,7 +489,11 @@ function FeedbackDetailView({ id }: { id: string }) { ) : null}
Submitted by
- {feedback.submitterPubkey} + + {formatNpub( + feedback.submitterNpub ?? feedback.submitterPubkey, + )} +
Event
diff --git a/admin-web/src/pubkey.test.ts b/admin-web/src/pubkey.test.ts new file mode 100644 index 00000000000..c7542b13584 --- /dev/null +++ b/admin-web/src/pubkey.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { nip19 } from "nostr-tools"; +import { formatNpub, truncatePubkey } from "./pubkey"; + +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; + +describe("public-key display", () => { + it("uses npub for full and compact displays", () => { + expect(formatNpub(HEX)).toBe(NPUB); + expect(formatNpub(NPUB)).toBe(NPUB); + expect(truncatePubkey(HEX)).toBe("npub1a2d56…5qusyp60"); + }); + + it("fails closed instead of echoing malformed API values", () => { + expect(formatNpub("invalid-pubkey")).toBe("Invalid public key"); + expect(formatNpub("a".repeat(63))).toBe("Invalid public key"); + }); + + it("rejects public-key bytes that cannot lift to secp256k1", () => { + const invalidPoint = "ff".repeat(32); + expect(formatNpub(invalidPoint)).toBe("Invalid public key"); + expect(formatNpub(nip19.npubEncode(invalidPoint))).toBe( + "Invalid public key", + ); + }); +}); diff --git a/admin-web/src/pubkey.ts b/admin-web/src/pubkey.ts new file mode 100644 index 00000000000..59fe728c5ce --- /dev/null +++ b/admin-web/src/pubkey.ts @@ -0,0 +1,61 @@ +import { nip19 } from "nostr-tools"; + +const HEX_PUBKEY = /^[0-9a-fA-F]{64}$/; +const INVALID_PUBLIC_KEY = "Invalid public key"; +const SECP256K1_FIELD = BigInt( + "0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", +); + +function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint { + let result = 1n; + let factor = base % modulus; + let remaining = exponent; + while (remaining > 0n) { + if (remaining & 1n) result = (result * factor) % modulus; + factor = (factor * factor) % modulus; + remaining >>= 1n; + } + return result; +} + +/** BIP-340 public keys are x coordinates that must lift to secp256k1. */ +function isValidXOnlyPublicKey(hex: string): boolean { + if (!HEX_PUBKEY.test(hex)) return false; + const x = BigInt(`0x${hex}`); + if (x >= SECP256K1_FIELD) return false; + const ySquared = (x * x * x + 7n) % SECP256K1_FIELD; + const y = modPow(ySquared, (SECP256K1_FIELD + 1n) >> 2n, SECP256K1_FIELD); + return (y * y) % SECP256K1_FIELD === ySquared; +} + +/** Format a Nostr public key for people without changing protocol storage. */ +export function formatNpub(pubkey: string): string { + const value = pubkey.trim(); + + if (HEX_PUBKEY.test(value)) { + const normalized = value.toLowerCase(); + return isValidXOnlyPublicKey(normalized) + ? nip19.npubEncode(normalized) + : INVALID_PUBLIC_KEY; + } + + try { + const decoded = nip19.decode(value); + if ( + decoded.type === "npub" && + typeof decoded.data === "string" && + isValidXOnlyPublicKey(decoded.data) + ) { + return nip19.npubEncode(decoded.data.toLowerCase()); + } + } catch { + // Fall through to a stable sentinel; never echo a malformed raw identity. + } + + return INVALID_PUBLIC_KEY; +} + +export function truncatePubkey(pubkey: string): string { + const npub = formatNpub(pubkey); + return npub.length > 20 ? `${npub.slice(0, 10)}…${npub.slice(-8)}` : npub; +} diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 6c108377589..0eff5a55461 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -2,18 +2,25 @@ export interface Report { id: string; communityId: string; communityHost: string; + /** Legacy protocol-hex identity. Prefer reporterNpub when present. */ reporterPubkey: string; + reporterNpub?: string; targetKind: "event" | "pubkey" | "blob"; target: string; + targetNpub?: string; channelId?: string; reportType: string; note?: string; status: string; + resolvedBy?: string | null; + resolvedByNpub?: string | null; createdAt: string; } export interface ReportedMessage { + /** Legacy protocol-hex identity. Prefer authorNpub when present. */ authorPubkey: string; + authorNpub?: string; content: string; createdAt: string; deletedAt: string | null; @@ -27,7 +34,9 @@ export interface FeedbackSummary { id: string; communityId: string; communityHost: string; + /** Legacy protocol-hex identity. Prefer submitterNpub when present. */ submitterPubkey: string; + submitterNpub?: string; category?: string; bodySummary: string; receivedAt: string; @@ -38,7 +47,9 @@ export interface FeedbackDetail { communityId: string; communityHost: string; eventId: string; + /** Legacy protocol-hex identity. Prefer submitterNpub when present. */ submitterPubkey: string; + submitterNpub?: string; category?: string; body: string; tags: string[][]; diff --git a/admin-web/tests/routes.spec.ts b/admin-web/tests/routes.spec.ts index 3c965dd2d85..347d6861db1 100644 --- a/admin-web/tests/routes.spec.ts +++ b/admin-web/tests/routes.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from "@playwright/test"; +const LEGACY_HEX = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const CANONICAL_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; + test.beforeEach(async ({ page }) => { await page.route("**/api/admin/v1/**", async (route) => { await route.fulfill({ contentType: "application/json", body: "[]" }); @@ -76,7 +81,8 @@ test("event report detail renders the reported message content", async ({ status: "open", createdAt: "2026-07-17T17:30:00Z", message: { - authorPubkey: "31".repeat(32), + authorPubkey: LEGACY_HEX, + authorNpub: CANONICAL_NPUB, content: "This is the complete reported message.\nIt preserves lines.", createdAt: "2026-07-17T17:25:00Z", @@ -90,7 +96,8 @@ test("event report detail renders the reported message content", async ({ await expect( page.getByText("This is the complete reported message.", { exact: false }), ).toBeVisible(); - await expect(page.getByText("31".repeat(32))).toBeVisible(); + await expect(page.getByText(CANONICAL_NPUB)).toBeVisible(); + await expect(page.getByText(LEGACY_HEX)).toHaveCount(0); await expect( page.getByText("Message content is unavailable", { exact: false }), ).toHaveCount(0); diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py index 711b877ab48..bfcba3e6755 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py @@ -3,7 +3,7 @@ Gated by BUZZ_TESTBED_LIVE=1 with stack coordinates in the environment: BUZZ_TESTBED_RELAY_HTTP (default http://localhost:3000) BUZZ_TESTBED_RELAY_WS (default ws://host.docker.internal:3000) - BUZZ_TESTBED_OWNER_KEY relay owner secret key (hex) + BUZZ_TESTBED_OWNER_KEY relay owner secret key (nsec) BUZZ_TESTBED_PG_DSN benchmark Postgres DSN """ diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..2d65771dd4b 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -31,7 +31,7 @@ Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use ` cargo run -p buzz-admin -- generate-key ``` -This prints a public and secret key pair as hex. **Save the secret key immediately — it is not stored and cannot be recovered.** Set `BUZZ_PRIVATE_KEY` to the secret key to act as this identity. +This prints the public key as `npub` and the secret key as `nsec`. **Save the secret key immediately — it is not stored and cannot be recovered.** Set `BUZZ_PRIVATE_KEY` to the `nsec` to act as this identity. Then register the agent's public key as a relay member so it can read and publish: @@ -136,7 +136,7 @@ Controls which authors' events the harness forwards to the agent. Events from di | Flag | Env Var | Default | Description | |------|---------|---------|-------------| | `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `anyone`, `nobody`. | -| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required when mode is `allowlist`). Owner is always implicitly included. | +| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated `npub` identities (required when mode is `allowlist`). Owner is always implicitly included. | **Modes:** @@ -169,7 +169,7 @@ buzz-acp # Respond to a team of three users (owner always included automatically) buzz-acp --respond-to allowlist \ - --respond-to-allowlist "abc123...64hex,def456...64hex,789abc...64hex" + --respond-to-allowlist ",," # Respond to anyone (open agent) buzz-acp --respond-to anyone diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f113..3542b20f9e9 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -49,7 +49,7 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat - Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. -- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. +- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. - Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8a..d3be725cc8c 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -13,6 +13,10 @@ use thiserror::Error; use url::Url; use uuid::Uuid; +use buzz_core::nostr_identity::{ + parse_public_key_compat, parse_secret_key_compat, public_key_to_npub, +}; + use crate::filter::SubscriptionRule; /// Default idle timeout (seconds) when neither `--idle-timeout` nor the @@ -169,7 +173,7 @@ impl std::fmt::Display for PermissionMode { /// This is a standalone `Parser` (not a subcommand variant) because the /// `models` path must bypass `Config::from_cli()` entirely — no relay, /// no private key, no harness setup. -#[derive(Debug, Parser)] +#[derive(Parser)] #[command( name = "buzz-acp models", about = "Query available models from the configured agent" @@ -240,10 +244,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, + /// Nostr private key in nsec form (legacy hex remains accepted). #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] pub private_key: String, - /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. + /// Agent owner npub. Used for --respond-to=owner-only gate. #[arg(long, env = "BUZZ_ACP_AGENT_OWNER")] pub agent_owner: Option, @@ -453,7 +458,7 @@ pub struct CliArgs { )] pub respond_to: RespondTo, - /// Comma-separated 64-char hex pubkeys for allowlist mode. + /// Comma-separated npubs for allowlist mode. /// Owner pubkey is always implicitly included. #[arg(long, env = "BUZZ_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')] pub respond_to_allowlist: Option>, @@ -643,18 +648,16 @@ pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> format!("{agent}{SESSION_TITLE_SEPARATOR}#{channel}") } -/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. +/// Validate and deduplicate allowlist entries, normalizing to protocol hex. fn validate_allowlist(entries: &[String]) -> Result, ConfigError> { let mut validated = HashSet::new(); for entry in entries { - let trimmed = entry.trim().to_ascii_lowercase(); - if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(ConfigError::ConfigFile(format!( - "invalid pubkey in --respond-to-allowlist: '{entry}' \ - (must be exactly 64 hex characters)" - ))); - } - validated.insert(trimmed); + let (public_key, _) = parse_public_key_compat(entry).map_err(|_| { + ConfigError::ConfigFile( + "invalid pubkey in --respond-to-allowlist (expected an npub)".to_string(), + ) + })?; + validated.insert(public_key.to_hex()); } Ok(validated) } @@ -851,13 +854,17 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { - let keys = Keys::parse(&args.private_key)?; + let parsed_key = parse_secret_key_compat(&args.private_key); // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. args.private_key .replace_range(.., &"0".repeat(args.private_key.len())); args.private_key.clear(); + let (secret_key, _) = parsed_key.map_err(|_| { + ConfigError::ConfigFile("BUZZ_PRIVATE_KEY must be a valid nsec".to_string()) + })?; + let keys = Keys::new(secret_key); let system_prompt = if let Some(text) = args.system_prompt { Some(text) @@ -1028,6 +1035,18 @@ impl Config { HashSet::new() }; + let agent_owner = args + .agent_owner + .as_deref() + .map(|value| { + parse_public_key_compat(value) + .map(|(public_key, _)| public_key.to_hex()) + .map_err(|_| { + ConfigError::ConfigFile("--agent-owner must be a valid npub".to_string()) + }) + }) + .transpose()?; + // Validate respond_to against the allowed set. let allowed_respond_to = if let Some(raw) = args.allowed_respond_to { // Validate each entry is a known RespondTo mode. @@ -1119,7 +1138,7 @@ impl Config { exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, - agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), + agent_owner, no_base_prompt: args.no_base_prompt, base_prompt_content, }; @@ -1145,7 +1164,8 @@ impl Config { format!( "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, - self.keys.public_key().to_hex(), + public_key_to_npub(&self.keys.public_key()) + .unwrap_or_else(|_| "".to_string()), self.agent_command, self.agent_args.join(" "), self.mcp_command, @@ -2510,14 +2530,21 @@ channels = "ALL" #[test] fn test_validate_allowlist_valid_entries() { - let entries = vec!["ab".repeat(32), "cd".repeat(32)]; + let first = Keys::generate().public_key(); + let second = Keys::generate().public_key(); + let entries = vec![ + public_key_to_npub(&first).unwrap(), + public_key_to_npub(&second).unwrap(), + ]; let result = validate_allowlist(&entries).unwrap(); assert_eq!(result.len(), 2); + assert!(result.contains(&first.to_hex())); + assert!(result.contains(&second.to_hex())); } #[test] fn test_validate_allowlist_deduplicates() { - let pk = "ab".repeat(32); + let pk = public_key_to_npub(&Keys::generate().public_key()).unwrap(); let entries = vec![pk.clone(), pk.clone(), pk]; let result = validate_allowlist(&entries).unwrap(); assert_eq!(result.len(), 1); @@ -2525,53 +2552,42 @@ channels = "ALL" #[test] fn test_validate_allowlist_normalizes_case() { - let upper = "AB".repeat(32); - let lower = "ab".repeat(32); - let entries = vec![upper, lower]; + let lower = Keys::generate().public_key().to_hex(); + let upper = lower.to_ascii_uppercase(); + let entries = vec![upper, lower.clone()]; let result = validate_allowlist(&entries).unwrap(); assert_eq!(result.len(), 1); - assert!(result.contains(&"ab".repeat(32))); + assert!(result.contains(&lower)); } #[test] fn test_validate_allowlist_trims_whitespace() { - let entries = vec![format!(" {} ", "ab".repeat(32))]; + let public_key = Keys::generate().public_key(); + let entries = vec![format!(" {} ", public_key_to_npub(&public_key).unwrap())]; let result = validate_allowlist(&entries).unwrap(); assert_eq!(result.len(), 1); - assert!(result.contains(&"ab".repeat(32))); + assert!(result.contains(&public_key.to_hex())); } #[test] fn test_validate_allowlist_rejects_short() { let entries = vec!["abcd".to_string()]; let err = validate_allowlist(&entries).unwrap_err(); - assert!( - err.to_string() - .contains("must be exactly 64 hex characters"), - "got: {err}" - ); + assert!(err.to_string().contains("expected an npub"), "got: {err}"); } #[test] fn test_validate_allowlist_rejects_non_hex() { let entries = vec!["zz".repeat(32)]; let err = validate_allowlist(&entries).unwrap_err(); - assert!( - err.to_string() - .contains("must be exactly 64 hex characters"), - "got: {err}" - ); + assert!(err.to_string().contains("expected an npub"), "got: {err}"); } #[test] fn test_validate_allowlist_rejects_too_long() { let entries = vec!["ab".repeat(33)]; // 66 chars let err = validate_allowlist(&entries).unwrap_err(); - assert!( - err.to_string() - .contains("must be exactly 64 hex characters"), - "got: {err}" - ); + assert!(err.to_string().contains("expected an npub"), "got: {err}"); } #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7bb..7b96ead554b 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -24,6 +24,7 @@ use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, }; +use buzz_core::nostr_identity::{canonical_npub_or_invalid, public_key_to_npub_or_invalid}; use buzz_core::observer::{ decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY, OBSERVER_MAX_PLAINTEXT_LEN, @@ -104,7 +105,7 @@ fn emit_runtime_lifecycle( None, &observer::ObserverContext::default(), serde_json::json!({ - "pubkey": pubkey, + "pubkey": canonical_npub_or_invalid(pubkey), "relayUrl": relay_url, "startNonce": start_nonce, "lifecycle": lifecycle, @@ -128,7 +129,10 @@ fn resolve_agent_owner(config: &Config) -> Option { match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag, &agent_pk) { Ok(owner_pk) => { let owner_hex = owner_pk.to_hex().to_ascii_lowercase(); - tracing::info!("owner resolved from BUZZ_AUTH_TAG: {owner_hex}"); + tracing::info!( + "owner resolved from BUZZ_AUTH_TAG: {}", + public_key_to_npub_or_invalid(&owner_pk) + ); return Some(owner_hex); } Err(e) => { @@ -349,11 +353,15 @@ async fn check_sibling_via_profile( let tag_json = serde_json::to_string(tag).unwrap_or_default(); match buzz_sdk::nip_oa::verify_auth_tag(&tag_json, &agent_pk) { Ok(_) => { - tracing::debug!(author, expected_owner, "sibling verified via NIP-OA"); + tracing::debug!( + author = %public_key_to_npub_or_invalid(&agent_pk), + expected_owner = %canonical_npub_or_invalid(expected_owner), + "sibling verified via NIP-OA" + ); return true; } Err(e) => { - tracing::debug!(author, "NIP-OA auth tag verification failed: {e}"); + tracing::debug!(author = %public_key_to_npub_or_invalid(&agent_pk), "NIP-OA auth tag verification failed: {e}"); } } } @@ -1081,8 +1089,8 @@ fn handle_relay_observer_control_event( // Defense-in-depth: verify the sender is the resolved owner. if event.pubkey.to_hex() != owner_pubkey_hex { tracing::warn!( - sender = %event.pubkey, - expected = %owner_pubkey_hex, + sender = %public_key_to_npub_or_invalid(&event.pubkey), + expected = %canonical_npub_or_invalid(owner_pubkey_hex), "observer control frame from non-owner — dropping" ); return; @@ -1849,7 +1857,7 @@ async fn tokio_main() -> Result<()> { // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. let startup_owner: Option = resolve_agent_owner(&config); if let Some(ref owner) = startup_owner { - tracing::info!("agent owner: {owner}"); + tracing::info!("agent owner: {}", canonical_npub_or_invalid(owner)); } else { tracing::info!("no agent owner configured"); } @@ -2576,7 +2584,7 @@ async fn tokio_main() -> Result<()> { if buzz_event.event.pubkey.to_hex() == *owner { tracing::info!( channel_id = %buzz_event.channel_id, - sender = %buzz_event.event.pubkey.to_hex(), + sender = %public_key_to_npub_or_invalid(&buzz_event.event.pubkey), "shutdown command from owner — exiting gracefully" ); let _ = shutdown_tx.send(()); @@ -2696,7 +2704,7 @@ async fn tokio_main() -> Result<()> { if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), + author = %public_key_to_npub_or_invalid(&buzz_event.event.pubkey), mode = %config.respond_to, is_dm, "inbound author gate — dropping event" @@ -4277,7 +4285,7 @@ mod agent_draft_prompt_tests { #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("--mention ")); + assert!(prompt.contains("--mention ")); assert!(prompt.contains("every presentation-only name that should notify")); assert!( prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only") diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index dabee13afd5..efd752d1ccd 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -13,12 +13,13 @@ //! still queue normally. //! - **Queue** — all events accumulate; batched on the next flush cycle. -use nostr::{Event, ToBech32}; +use nostr::Event; use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant}; use uuid::Uuid; use crate::config::DedupMode; +use buzz_core::nostr_identity::{parse_public_key_compat, public_key_to_npub}; /// Maximum events queued per channel before oldest events are dropped. const MAX_PENDING_PER_CHANNEL: usize = 500; @@ -1101,15 +1102,18 @@ fn resolve_prompt_label( } fn format_prompt_actor(pubkey: &str, profile_lookup: Option<&PromptProfileLookup>) -> String { + let npub = parse_public_key_compat(pubkey) + .and_then(|(public_key, _)| public_key_to_npub(&public_key)) + .unwrap_or_else(|_| "".to_string()); match resolve_prompt_label(pubkey, profile_lookup) { - Some(label) => format!("{label} ({pubkey})"), - None => pubkey.to_string(), + Some(label) => format!("{label} ({npub})"), + None => npub, } } /// Format the per-event `[Event]` block for a single [`BatchEvent`]. /// -/// Includes: event_id, channel (name + UUID), kind, sender (hex + npub), +/// Includes: event_id, channel (name + UUID), kind, sender (npub), /// time, content, all tags (never stripped), and parsed structural fields. /// /// Reused by the goose-native steer path (lib.rs mode-gate) to render the @@ -1123,7 +1127,8 @@ pub(crate) fn format_event_block( profile_lookup: Option<&PromptProfileLookup>, ) -> String { let hex = be.event.pubkey.to_hex(); - let npub = be.event.pubkey.to_bech32().unwrap_or_else(|_| hex.clone()); + let npub = + public_key_to_npub(&be.event.pubkey).unwrap_or_else(|_| "".to_string()); let time = chrono::DateTime::from_timestamp(be.event.created_at.as_secs() as i64, 0) .map(|dt| dt.to_rfc3339()) @@ -1145,8 +1150,8 @@ pub(crate) fn format_event_block( Time: {time}\n\ Content: {}", match resolve_prompt_label(&hex, profile_lookup) { - Some(label) => format!("{label} (npub: {npub}, hex: {hex})"), - None => format!("{npub} (hex: {hex})"), + Some(label) => format!("{label} ({npub})"), + None => npub, }, be.event.content, ); @@ -1765,7 +1770,7 @@ pub(crate) fn native_steer_framing() -> (&'static str, &'static str) { #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind, Timestamp}; + use nostr::{EventBuilder, Keys, Kind, Timestamp, ToBech32}; use std::time::Duration; /// Build a test event with the given content and kind. @@ -3422,13 +3427,11 @@ mod tests { #[test] fn test_format_prompt_with_profiles_prefers_display_names() { let ch = Uuid::new_v4(); - let event = make_event_with_tags( - "hello there", - vec![vec![ - "p".into(), - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), - ]], - ); + let mentioned = nostr::Keys::generate().public_key(); + let mentioned_hex = mentioned.to_hex(); + let mentioned_npub = mentioned.to_bech32().unwrap(); + let event = + make_event_with_tags("hello there", vec![vec!["p".into(), mentioned_hex.clone()]]); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { channel_id: ch, @@ -3460,7 +3463,7 @@ mod tests { }, ), ( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + mentioned_hex, PromptProfile { display_name: Some("Rick".into()), nip05_handle: None, @@ -3479,10 +3482,8 @@ mod tests { ) .join("\n\n"); - assert!(prompt.contains("From: Wes (npub:")); - assert!(prompt.contains( - "mentions=[Rick (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)]" - )); + assert!(prompt.contains("From: Wes (npub1")); + assert!(prompt.contains(&format!("mentions=[Rick ({mentioned_npub})]"))); assert!(prompt.contains("[1] Wes (")); } @@ -3847,7 +3848,7 @@ mod tests { } #[test] - fn test_format_event_block_includes_hex_and_npub() { + fn test_format_event_block_uses_npub_for_sender() { let ch = Uuid::new_v4(); let event = make_event("test"); let hex = event.pubkey.to_hex(); @@ -3865,9 +3866,10 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!( - prompt.contains(&format!("From: {npub} (hex: {hex})")), - "prompt should contain both npub and hex" + prompt.contains(&format!("From: {npub}")), + "prompt should contain the sender npub" ); + assert!(!prompt.contains(&format!("From: {npub} (hex: {hex})"))); } #[test] diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea46..5c65db8e379 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -39,6 +39,7 @@ use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED, }; +use buzz_core::nostr_identity::{parse_public_key_compat, public_key_to_npub}; use nostr::EventId; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -198,8 +199,9 @@ impl RequirementPayload { pub(crate) struct SetupPayload { /// Human-readable agent display name (for the nudge message). pub agent_name: String, - /// Hex-encoded agent pubkey. Carried so the desktop card can open - /// the Edit Agent dialog for this agent directly from the nudge. + /// Canonical npub. Carried so the desktop card can open the Edit Agent + /// dialog for this agent directly from the nudge. The environment parser + /// accepts legacy hex payloads and immediately normalizes them. pub agent_pubkey: String, /// Surface-discriminated list of missing requirements. pub requirements: Vec, @@ -228,8 +230,17 @@ impl SetupPayload { Some(v) if !v.is_empty() => v, _ => return Ok(None), }; - let payload = serde_json::from_str::(&raw) + let mut payload = serde_json::from_str::(&raw) .map_err(|e| anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: {e}"))?; + let (public_key, _) = parse_public_key_compat(&payload.agent_pubkey).map_err(|_| { + anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: agent_pubkey must be a valid npub") + })?; + public_key.xonly().map_err(|_| { + anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: agent_pubkey must be a valid npub") + })?; + payload.agent_pubkey = public_key_to_npub(&public_key).map_err(|_| { + anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: failed to encode agent npub") + })?; Ok(Some(payload)) } @@ -651,6 +662,14 @@ async fn publish_setup_nudge( mod tests { use super::*; + const SETUP_AGENT_HEX: &str = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + fn setup_agent_npub() -> String { + let (public_key, _) = parse_public_key_compat(SETUP_AGENT_HEX).unwrap(); + public_key_to_npub(&public_key).unwrap() + } + #[test] fn setup_payload_from_raw_returns_none_when_absent() { // None → Ok(None): normal startup, no setup payload. @@ -674,25 +693,58 @@ mod tests { #[test] fn setup_payload_deserializes_correctly() { - let json = r#"{ + let json = format!( + r#"{{ "agent_name": "Fizz", - "agent_pubkey": "aabbccddeeff0011", + "agent_pubkey": "{SETUP_AGENT_HEX}", "requirements": [ - {"surface": "normalized_field", "field": "provider"}, - {"surface": "env_key", "key": "ANTHROPIC_API_KEY"} + {{"surface": "normalized_field", "field": "provider"}}, + {{"surface": "env_key", "key": "ANTHROPIC_API_KEY"}} ] - }"#; - let payload: SetupPayload = serde_json::from_str(json).unwrap(); + }}"# + ); + let payload = SetupPayload::from_raw_env_value(Some(json)) + .unwrap() + .unwrap(); assert_eq!(payload.agent_name, "Fizz"); + assert_eq!(payload.agent_pubkey, setup_agent_npub()); assert_eq!(payload.requirements.len(), 2); } + #[test] + fn setup_payload_legacy_hex_is_normalized_in_user_visible_sentinel() { + let raw = format!( + r#"{{"agent_name":"Fizz","agent_pubkey":"{SETUP_AGENT_HEX}","requirements":[]}}"# + ); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .unwrap(); + let body = payload.nudge_body(); + + assert_eq!(payload.agent_pubkey, setup_agent_npub()); + assert!(body.contains(&setup_agent_npub())); + assert!(!body.contains(SETUP_AGENT_HEX)); + } + + #[test] + fn setup_payload_rejects_invalid_pubkey_without_echoing_it() { + let invalid = "not-a-public-key"; + let raw = + format!(r#"{{"agent_name":"Fizz","agent_pubkey":"{invalid}","requirements":[]}}"#); + let error = SetupPayload::from_raw_env_value(Some(raw)).unwrap_err(); + + assert!(error.to_string().contains("valid npub")); + assert!(!error.to_string().contains(invalid)); + } + #[test] fn setup_payload_deserializes_git_bash_requirement() { - let payload: SetupPayload = serde_json::from_str( - r#"{"agent_name":"Buzz Agent","agent_pubkey":"test","requirements":[{"surface":"git_bash"}]}"#, - ) - .unwrap(); + let raw = format!( + r#"{{"agent_name":"Buzz Agent","agent_pubkey":"{SETUP_AGENT_HEX}","requirements":[{{"surface":"git_bash"}}]}}"# + ); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .unwrap(); assert!(matches!( payload.requirements.as_slice(), [RequirementPayload::GitBash] @@ -703,7 +755,7 @@ mod tests { fn nudge_body_names_all_requirements() { let payload = SetupPayload { agent_name: "Fizz".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![ RequirementPayload::NormalizedField { field: "provider".to_string(), @@ -729,7 +781,7 @@ mod tests { fn nudge_body_codex_copy_does_not_mention_openai_api_key() { let payload = SetupPayload { agent_name: "Codex".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![RequirementPayload::CliLogin { probe_args: vec![ "codex".to_string(), @@ -761,7 +813,7 @@ mod tests { ] { let payload = SetupPayload { agent_name: "Codex".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![RequirementPayload::CliLogin { probe_args: vec!["codex".to_string()], setup_copy: "run `codex login`".to_string(), @@ -784,7 +836,7 @@ mod tests { fn nudge_body_git_bash_copy_points_to_agent_runtimes() { let payload = SetupPayload { agent_name: "Buzz Agent".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![RequirementPayload::GitBash], }; let body = payload.nudge_body(); @@ -802,7 +854,7 @@ mod tests { fn nudge_body_empty_requirements_falls_back_to_generic() { let payload = SetupPayload { agent_name: "Fizz".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![], }; let body = payload.nudge_body(); @@ -826,7 +878,7 @@ mod tests { // Edit Agent (which cannot fix an external config file). let payload = SetupPayload { agent_name: "Codex".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![make_cli_config_invalid( "codex", "unknown variant `ultra` for field `model_reasoning_effort`", @@ -853,7 +905,7 @@ mod tests { // Footer must address both sides. let payload = SetupPayload { agent_name: "Codex".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![ RequirementPayload::EnvKey { key: "SOME_API_KEY".to_string(), @@ -877,7 +929,7 @@ mod tests { // Pure Buzz-managed requirements → original "Open Edit Agent" footer unchanged. let payload = SetupPayload { agent_name: "Fizz".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![RequirementPayload::EnvKey { key: "ANTHROPIC_API_KEY".to_string(), }], @@ -897,7 +949,7 @@ mod tests { // can detect and strip it before rendering the ConfigNudgeCard. let payload = SetupPayload { agent_name: "Fizz".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![RequirementPayload::EnvKey { key: "ANTHROPIC_API_KEY".to_string(), }], @@ -919,7 +971,7 @@ mod tests { // equivalent SetupPayload (same agent_name and requirements). let payload = SetupPayload { agent_name: "Atlas".to_string(), - agent_pubkey: "ddeeff00".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![ RequirementPayload::NormalizedField { field: "model".to_string(), @@ -962,7 +1014,7 @@ mod tests { // replacement, so all prior `body.contains(...)` invariants hold. let payload = SetupPayload { agent_name: "Fizz".to_string(), - agent_pubkey: "test".to_string(), + agent_pubkey: setup_agent_npub(), requirements: vec![ RequirementPayload::NormalizedField { field: "provider".to_string(), @@ -1073,7 +1125,7 @@ mod tests { // Simulate the JSON desktop's runtime.rs emits — a full SetupPayload // with one cli_login requirement carrying the given availability state. format!( - r#"{{"agent_name":"TestAgent","agent_pubkey":"aa","requirements":[{{"surface":"cli_login","probe_args":["claude"],"setup_copy":"run claude login","availability":"{availability}"}}]}}"# + r#"{{"agent_name":"TestAgent","agent_pubkey":"{SETUP_AGENT_HEX}","requirements":[{{"surface":"cli_login","probe_args":["claude"],"setup_copy":"run claude login","availability":"{availability}"}}]}}"# ) } diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index 00c2804cbcf..314fb1c8e80 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -35,4 +35,5 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" tracing = { workspace = true } sqlx = { workspace = true } url = { workspace = true } +zeroize = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 580d5865913..d1099dd9124 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -26,12 +26,16 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; +use buzz_core::nostr_identity::{ + parse_public_key_compat, parse_secret_key_compat, public_key_to_npub, secret_key_to_nsec, +}; use buzz_core::tenant::{relay_url_authority, TenantContext}; use buzz_db::{Db, DbConfig}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; use tracing::warn; +use zeroize::Zeroizing; #[derive(Parser)] #[command(name = "buzz-admin", about = "Buzz instance administration")] @@ -44,11 +48,11 @@ struct Cli { enum Command { /// Add a pubkey to the relay membership list. /// - /// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row, + /// Accepts an npub (legacy hex remains accepted). After inserting the DB row, /// publishes a kind:13534 membership roster via Redis so live clients see /// the updated list immediately. AddMember { - /// Nostr public key — bech32 npub or 64-char hex. + /// Nostr public key in npub form. #[arg(long)] pubkey: String, @@ -59,11 +63,11 @@ enum Command { }, /// Remove a pubkey from the relay membership list. /// - /// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row, + /// Accepts an npub (legacy hex remains accepted). After removing the DB row, /// publishes a kind:13534 membership roster via Redis. Cannot remove the /// relay owner — change RELAY_OWNER_PUBKEY config instead. RemoveMember { - /// Nostr public key — bech32 npub or 64-char hex. + /// Nostr public key in npub form. #[arg(long)] pubkey: String, @@ -94,7 +98,7 @@ enum Command { /// have Nostr discovery events. This command creates them so pure-nostr /// clients can see those channels. Idempotent — safe to run multiple times. ReconcileChannels { - /// Relay private key (hex) for signing events. Falls back to + /// Relay private key (nsec) for signing events. Falls back to /// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates /// an ephemeral key (events will be unverifiable after restart). #[arg(long)] @@ -138,8 +142,9 @@ async fn run(cli: Cli) -> Result { match cli.command { Command::GenerateKey => { let keys = Keys::generate(); - println!("Public key: {}", keys.public_key().to_hex()); - println!("Secret key: {}", keys.secret_key().display_secret()); + println!("Public key: {}", public_key_to_npub(&keys.public_key())?); + let nsec = Zeroizing::new(secret_key_to_nsec(keys.secret_key())?); + println!("Secret key: {}", nsec.as_str()); println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity."); Ok(0) } @@ -176,6 +181,7 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { return Ok(1); } }; + let pubkey_npub = npub_from_protocol_hex(&pubkey_hex)?; let (db, pubsub, relay_keypair) = connect_member_services().await?; @@ -184,8 +190,8 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { .add_relay_member(tenant.community(), &pubkey_hex, &role, None) .await { - Ok(true) => println!("added {pubkey_hex} as {role}"), - Ok(false) => println!("already a member: {pubkey_hex} (no change)"), + Ok(true) => println!("added {pubkey_npub} as {role}"), + Ok(false) => println!("already a member: {pubkey_npub} (no change)"), Err(e) => { eprintln!("error: DB write failed: {e}"); return Ok(5); @@ -214,6 +220,7 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R return Ok(1); } }; + let pubkey_npub = npub_from_protocol_hex(&pubkey_hex)?; let (db, pubsub, relay_keypair) = connect_member_services().await?; @@ -228,21 +235,21 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R }; match result { - Ok(RemoveResult::Removed) => println!("removed {pubkey_hex}"), + Ok(RemoveResult::Removed) => println!("removed {pubkey_npub}"), Ok(RemoveResult::NotFound) => { - eprintln!("error: member not found: {pubkey_hex}"); + eprintln!("error: member not found: {pubkey_npub}"); return Ok(2); } Ok(RemoveResult::IsOwner) => { eprintln!( - "error: cannot remove relay owner: {pubkey_hex}\n\ + "error: cannot remove relay owner: {pubkey_npub}\n\ To change the owner, update RELAY_OWNER_PUBKEY and restart." ); return Ok(3); } Ok(RemoveResult::RoleMismatch) => { let role_str = role_filter.as_deref().unwrap_or("(unknown)"); - eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'"); + eprintln!("error: role mismatch — {pubkey_npub} is not currently '{role_str}'"); return Ok(4); } Err(e) => { @@ -261,10 +268,30 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R async fn cmd_list_product_feedback(limit: u16) -> Result { let db = connect_db().await?; let feedback = db.list_product_feedback(i64::from(limit)).await?; - println!("{}", serde_json::to_string_pretty(&feedback)?); + let mut output = serde_json::to_value(feedback)?; + add_product_feedback_npubs(&mut output)?; + println!("{}", serde_json::to_string_pretty(&output)?); Ok(0) } +/// Preserve the established machine-JSON `submitter_pubkey` hex field while +/// adding the canonical identity for current operator consumers. +fn add_product_feedback_npubs(output: &mut serde_json::Value) -> Result<()> { + if let Some(items) = output.as_array_mut() { + for item in items { + if let Some(pubkey) = item + .get("submitter_pubkey") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + { + item["submitter_npub"] = + serde_json::Value::String(npub_from_protocol_hex(&pubkey)?); + } + } + } + Ok(()) +} + async fn cmd_list_members() -> Result { let db = connect_db().await?; let tenant = resolve_admin_tenant(&db).await?; @@ -281,10 +308,16 @@ async fn cmd_list_members() -> Result { ); println!("{}", "-".repeat(160)); for m in &members { - let added_by = m.added_by.as_deref().unwrap_or("-"); + let pubkey = npub_from_protocol_hex(&m.pubkey)?; + let added_by = m + .added_by + .as_deref() + .map(npub_from_protocol_hex) + .transpose()? + .unwrap_or_else(|| "-".to_string()); println!( "{:<66} {:<8} {:<66} {}", - m.pubkey, + pubkey, m.role, added_by, m.created_at.format("%Y-%m-%dT%H:%M:%SZ") @@ -309,9 +342,15 @@ fn validate_role(role: &str) -> std::result::Result<(), String> { /// Parse a bech32 npub or 64-char hex pubkey into lowercase hex. fn parse_pubkey_hex(input: &str) -> std::result::Result { - nostr::PublicKey::parse(input) - .map(|pk| pk.to_hex()) - .map_err(|e| format!("invalid pubkey '{input}': {e}")) + parse_public_key_compat(input) + .map(|(public_key, _)| public_key.to_hex()) + .map_err(|_| "invalid pubkey: expected an npub".to_string()) +} + +fn npub_from_protocol_hex(input: &str) -> Result { + let public_key = nostr::PublicKey::from_hex(input) + .map_err(|_| anyhow::anyhow!("stored pubkey is invalid"))?; + Ok(public_key_to_npub(&public_key)?) } /// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`. @@ -398,13 +437,15 @@ async fn connect_member_services() -> Result<(Db, Arc, Keys)> { let db = connect_db().await?; let relay_keypair = { - let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| { + let raw = Zeroizing::new(std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| { anyhow::anyhow!( "BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\ The relay must have a stable signing key to publish kind:13534 events." ) - })?; - Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? + })?); + let (secret_key, _) = parse_secret_key_compat(&raw) + .map_err(|_| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: expected an nsec"))?; + Keys::new(secret_key) }; let redis_url = @@ -474,14 +515,17 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { // Resolve relay signing key: arg > env > ephemeral let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) { - Some(key_hex) => { - Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))? + Some(raw_key) => { + let raw_key = Zeroizing::new(raw_key); + let (secret_key, _) = parse_secret_key_compat(&raw_key) + .map_err(|_| anyhow::anyhow!("invalid relay key: expected an nsec"))?; + Keys::new(secret_key) } None => { let k = Keys::generate(); eprintln!( "Warning: no relay key provided — using ephemeral key {}", - k.public_key().to_hex() + public_key_to_npub(&k.public_key())? ); eprintln!("Events signed with this key won't be verifiable after this run."); eprintln!("Pass --relay-key or set BUZZ_RELAY_PRIVATE_KEY for production use."); @@ -590,3 +634,25 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn product_feedback_json_adds_npub_without_repurposing_legacy_field() { + let keys = Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = public_key_to_npub(&keys.public_key()).expect("npub"); + let mut output = serde_json::json!([{ + "event_id": "11".repeat(32), + "submitter_pubkey": hex + }]); + + add_product_feedback_npubs(&mut output).expect("project feedback"); + + assert_eq!(output[0]["submitter_pubkey"], hex); + assert_eq!(output[0]["submitter_npub"], npub); + assert_eq!(output[0]["event_id"], "11".repeat(32)); + } +} diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd4..f43869ca6b2 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -44,6 +44,7 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } +zeroize = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d21..b3eadbe99a4 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -35,7 +35,7 @@ buzz messages send --channel --content - < message.md # read body from buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages search --query "architecture" -buzz messages search --author --since +buzz messages search --author --since buzz messages edit --event --content "Updated text" buzz messages delete --event @@ -54,15 +54,15 @@ buzz reactions get --event # Users & Presence buzz users get # your own profile -buzz users get --pubkey # single user -buzz users get --pubkey --pubkey # batch (max 200) +buzz users get --pubkey # single user +buzz users get --pubkey --pubkey # batch (max 200) buzz users get --name Honey --owner me # exact-name lookup in your managed agents buzz users set-presence --status online buzz users set-status --text "heads down on the CLI" --emoji "🚀" buzz users set-status --clear # remove your status # DMs -buzz dms open --pubkey +buzz dms open --pubkey buzz dms list # Workflows diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 568249d0829..b51fd367c55 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::{read_or_stdin, validate_hex64}; +use crate::validate::{format_npub, normalize_pubkey, read_or_stdin}; use crate::{AgentsCmd, RespondToArg}; pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { @@ -92,7 +92,9 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli content, admin, } => { - validate_hex64(&target_pubkey)?; + let target_pubkey = normalize_pubkey(&target_pubkey)?; + let target_npub = format_npub(&target_pubkey)?; + let replaced_by = replaced_by.as_deref().map(normalize_pubkey).transpose()?; let signer_hex = client.keys().public_key().to_hex(); let auth = resolve_auth( client, @@ -119,7 +121,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli "ok": true, "event_id": event_id, "action": "archive", - "target": target_pubkey, + "target": target_npub, }) ); Ok(()) @@ -131,7 +133,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli content, admin, } => { - validate_hex64(&target_pubkey)?; + let target_pubkey = normalize_pubkey(&target_pubkey)?; + let target_npub = format_npub(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); let auth = resolve_auth( client, @@ -157,7 +160,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli "ok": true, "event_id": event_id, "action": "unarchive", - "target": target_pubkey, + "target": target_npub, }) ); Ok(()) @@ -208,10 +211,16 @@ impl AuthFailure { fn message(&self) -> String { match self { AuthFailure::NoProfile(target) => { - format!("no kind:0 profile found for target {target}") + format!( + "no kind:0 profile found for target {}", + auth_failure_identity(target) + ) } AuthFailure::NoTagsArray(target) => { - format!("target {target} kind:0 has no tags array") + format!( + "target {} kind:0 has no tags array", + auth_failure_identity(target) + ) } AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(), AuthFailure::AmbiguousAuthTag(n) => format!( @@ -223,19 +232,27 @@ impl AuthFailure { AuthFailure::NonStringElement => { "sole \"auth\" tag contains a non-string element".to_owned() } - AuthFailure::InvalidOwnerHex(v) => { - format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}") + AuthFailure::InvalidOwnerHex(_) => { + "sole \"auth\" tag owner field is not a valid public identity ()" + .to_owned() } AuthFailure::InvalidSigHex => { "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned() } AuthFailure::OwnerMismatch(actual) => { - format!("sole \"auth\" tag names owner {actual} which does not match your key") + format!( + "sole \"auth\" tag names owner {} which does not match your key", + auth_failure_identity(actual) + ) } } } } +fn auth_failure_identity(pubkey_hex: &str) -> String { + format_npub(pubkey_hex).unwrap_or_else(|_| "".to_owned()) +} + /// Single classifier: either extract the auth tag or return the typed reason /// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for /// the existing tests that assert on `Option`. @@ -396,9 +413,9 @@ fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Optio /// the case the relay published `self` in. fn normalize_relay_self_hex(self_hex: &str) -> Result { if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(CliError::Other(format!( - "relay 'self' field is not a valid 64-hex pubkey: {self_hex}" - ))); + return Err(CliError::Other( + "relay 'self' field is not a valid protocol public key".to_owned(), + )); } Ok(self_hex.to_ascii_lowercase()) } @@ -443,7 +460,9 @@ pub(crate) async fn fetch_archived_snapshot(client: &BuzzClient) -> Result Result Result<(), CliError> { let archived = fetch_archived_snapshot(client).await?; - println!("{}", json!({"archived": archived})); + println!("{}", archived_output(&archived)?); Ok(()) } +fn archived_output(archived_hex: &[String]) -> Result { + let archived = archived_hex + .iter() + .map(|pubkey| format_npub(pubkey)) + .collect::, _>>()?; + Ok(json!({"archived": archived})) +} + /// Pure verification of a kind:13535 archived-identities event. /// /// Returns the list of valid hex64 pubkeys from `p` tags on success, or a @@ -476,10 +503,11 @@ fn verify_archived_event<'a>( } if event.pubkey.to_hex() != relay_self_hex { + let event_author = format_npub(&event.pubkey.to_hex())?; + let relay_self = + format_npub(relay_self_hex).unwrap_or_else(|_| "".to_owned()); return Err(CliError::Other(format!( - "archived-identities event author {} does not match relay self {}", - event.pubkey.to_hex(), - relay_self_hex + "archived-identities event author {event_author} does not match relay self {relay_self}" ))); } @@ -733,8 +761,11 @@ mod tests { let tags = vec![json!(["auth", bad_owner, "", hex128('a')])]; assert_eq!( classify_owner_auth_tag(&tags, &bad_owner), - Err(AuthFailure::InvalidOwnerHex(bad_owner)) + Err(AuthFailure::InvalidOwnerHex(bad_owner.clone())) ); + let message = AuthFailure::InvalidOwnerHex(bad_owner.clone()).message(); + assert!(message.contains("")); + assert!(!message.contains(&bad_owner)); } #[test] @@ -752,20 +783,18 @@ mod tests { fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() { // Case 4: structurally valid tag but owner ≠ signer. The failure must // carry the actual owner so resolve_auth can print it in the warning. - let actual_owner = hex64('a'); - let signer = hex64('b'); + let actual_owner = Keys::generate().public_key().to_hex(); + let signer = Keys::generate().public_key().to_hex(); let sig = hex128('c'); let tags = vec![json!(["auth", actual_owner, "conditions", sig])]; assert_eq!( classify_owner_auth_tag(&tags, &signer), Err(AuthFailure::OwnerMismatch(actual_owner.clone())) ); - // Message must include the actual owner for actionability. + // Message must include the canonical npub without exposing raw hex. let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message(); - assert!( - msg.contains(&actual_owner), - "OwnerMismatch message must include actual owner, got: {msg}" - ); + assert!(msg.contains(&format_npub(&actual_owner).expect("owner formats"))); + assert!(!msg.contains(&actual_owner)); } // --- (c2) extract_auth: profile-level failure taxonomy --- @@ -820,20 +849,18 @@ mod tests { #[test] fn extract_auth_owner_mismatch_returns_owner_mismatch_failure() { - let actual_owner = hex64('a'); - let signer = hex64('b'); + let actual_owner = Keys::generate().public_key().to_hex(); + let signer = Keys::generate().public_key().to_hex(); let sig = hex128('c'); let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); assert_eq!( extract_auth(Some(&profile), &hex64('t'), &signer), Err(AuthFailure::OwnerMismatch(actual_owner.clone())) ); - // Message must include the actual owner for actionability. + // Message must include the canonical npub without exposing raw hex. let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message(); - assert!( - msg.contains(&actual_owner), - "OwnerMismatch message must include actual owner, got: {msg}" - ); + assert!(msg.contains(&format_npub(&actual_owner).expect("owner formats"))); + assert!(!msg.contains(&actual_owner)); } // --- (c3) resolve_auth: production async resolver via counted test server --- @@ -1154,6 +1181,25 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn archived_output_emits_npub_identities_only() { + let first = Keys::generate().public_key().to_hex(); + let second = Keys::generate().public_key().to_hex(); + + let output = archived_output(&[first.clone(), second.clone()]).expect("output formats"); + + assert!(output["archived"][0] + .as_str() + .expect("first identity") + .starts_with("npub1")); + assert!(output["archived"][1] + .as_str() + .expect("second identity") + .starts_with("npub1")); + assert!(!output.to_string().contains(&first)); + assert!(!output.to_string().contains(&second)); + } + #[test] fn archived_state3_wrong_kind_errors() { let keys = Keys::generate(); @@ -1169,13 +1215,17 @@ mod tests { #[test] fn archived_state3_wrong_author_errors() { let event_keys = Keys::generate(); - let other_self = hex64('f'); + let other_self = Keys::generate().public_key().to_hex(); let event = build_archived_event(&event_keys, KIND_IA_ARCHIVED_LIST as u16, &[], true); let err = verify_archived_event(&event, &other_self).unwrap_err(); + let message = err.to_string(); assert!( - err.to_string().contains("does not match relay self"), + message.contains("does not match relay self"), "error should name author mismatch: {err}" ); + assert!(message.contains("npub1")); + assert!(!message.contains(&event.pubkey.to_hex())); + assert!(!message.contains(&other_self)); } #[test] diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 5cc745d7b94..fb9c077770d 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -11,7 +11,7 @@ use crate::client::{ use crate::commands::agents::fetch_archived_snapshot; use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{format_npub, normalize_pubkey, parse_uuid, read_or_stdin, validate_uuid}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -22,6 +22,55 @@ fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { }) } +fn format_identity_field(value: &mut serde_json::Value, field: &str) -> Result<(), CliError> { + let pubkey = value + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CliError::Other(format!("relay response missing '{field}' public key")))?; + value[field] = serde_json::Value::String(format_npub(pubkey)?); + Ok(()) +} + +fn format_member_identities( + members: Vec, +) -> Result, CliError> { + members + .into_iter() + .map(|mut member| { + format_identity_field(&mut member, "pubkey")?; + Ok(member) + }) + .collect() +} + +fn format_archived_exclusions( + exclusions: &[ArchivedExclusion], +) -> Result, CliError> { + exclusions + .iter() + .map(|exclusion| { + Ok(serde_json::json!({ + "persona_id": exclusion.persona_id, + "pubkey": format_npub(&exclusion.pubkey)?, + })) + }) + .collect() +} + +fn format_resolved_agent_identities(agents: &[ResolvedAgent]) -> Result, CliError> { + agents + .iter() + .map(|agent| format_npub(&agent.pubkey)) + .collect() +} + +fn extract_channel_detail(e: &serde_json::Value) -> Result { + let mut normalized = extract_channel_metadata(e); + normalized["pubkey"] = e.get("pubkey").cloned().unwrap_or(serde_json::Value::Null); + format_identity_field(&mut normalized, "pubkey")?; + Ok(normalized) +} + pub async fn cmd_list_channels( client: &BuzzClient, visibility: Option<&str>, @@ -231,9 +280,7 @@ pub async fn cmd_get_channel(client: &BuzzClient, channel_id: &str) -> Result<() let resp = client.query(&filter).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); if let Some(e) = events.first() { - let mut normalized = extract_channel_metadata(e); - normalized["pubkey"] = - serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")); + let normalized = extract_channel_detail(e)?; println!("{normalized}"); } else { println!("null"); @@ -253,7 +300,7 @@ pub async fn cmd_list_channel_members( }); let resp = client.query(&filter).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - let members = events.first().map(extract_p_tags).unwrap_or_default(); + let members = format_member_identities(events.first().map(extract_p_tags).unwrap_or_default())?; let output = serde_json::to_string(&members).unwrap_or_default(); println!("{output}"); Ok(()) @@ -487,7 +534,10 @@ fn apply_cardinality_rule( [] => skipped.push(slug.clone()), [one] => agents.push((*one).clone()), many => { - let candidates: Vec<&str> = many.iter().map(|a| a.pubkey.as_str()).collect(); + let candidates = many + .iter() + .map(|agent| format_npub(&agent.pubkey)) + .collect::, _>>()?; return Err(CliError::Usage(format!( "persona '{slug}' has {} live instances for this owner ({}); \ pass a template with a single instance per persona, or resolve \ @@ -695,6 +745,10 @@ pub async fn cmd_create_channel_from_template( .unwrap_or_else(|| client.keys().public_key().to_hex()); let resolved = build_roster_resolution(client, &owner, &template.agents).await?; + // Validate and format every public identity before channel-creation side + // effects. Protocol builders below continue to receive canonical hex. + let agent_npubs = format_resolved_agent_identities(&resolved.agents)?; + let archived_excluded = format_archived_exclusions(&resolved.archived_excluded)?; let channel_uuid = Uuid::new_v4(); let vis = match visibility { @@ -742,7 +796,7 @@ pub async fn cmd_create_channel_from_template( // here would race each other for no benefit. let mut members_added: Vec = Vec::new(); let mut member_failures: Vec = Vec::new(); - for agent in &resolved.agents { + for (agent, agent_npub) in resolved.agents.iter().zip(agent_npubs) { let outcome: Result<(), CliError> = async { let builder = buzz_sdk::build_add_member( channel_uuid, @@ -758,11 +812,11 @@ pub async fn cmd_create_channel_from_template( match outcome { Ok(()) => members_added.push(serde_json::json!({ "persona_id": agent.persona_id, - "pubkey": agent.pubkey, + "pubkey": agent_npub, })), Err(e) => member_failures.push(serde_json::json!({ "persona_id": agent.persona_id, - "pubkey": agent.pubkey, + "pubkey": agent_npub, "error": e.to_string(), })), } @@ -780,6 +834,7 @@ pub async fn cmd_create_channel_from_template( canvas_applied, members_added, member_failures, + archived_excluded, &resolved, ); println!("{report}"); @@ -799,6 +854,7 @@ fn build_template_report( canvas_applied: bool, members_added: Vec, member_failures: Vec, + archived_excluded: Vec, resolved: &RosterResolution, ) -> serde_json::Value { let mut report = serde_json::json!({ @@ -808,7 +864,7 @@ fn build_template_report( "canvas_applied": canvas_applied, "members_added": members_added, "skipped": resolved.skipped, - "archived_excluded": resolved.archived_excluded, + "archived_excluded": archived_excluded, "member_failures": member_failures, }); if let Some(warning) = &resolved.archive_state_warning { @@ -972,7 +1028,7 @@ pub async fn cmd_add_channel_member( pubkey: &str, role: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; let typed_role = match role { @@ -988,7 +1044,7 @@ pub async fn cmd_add_channel_member( ))) } }; - let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role) + let builder = buzz_sdk::build_add_member(channel_uuid, &pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; let event = client.sign_event(builder)?; @@ -1002,10 +1058,10 @@ pub async fn cmd_remove_channel_member( channel_id: &str, pubkey: &str, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; - let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey) + let builder = buzz_sdk::build_remove_member(channel_uuid, &pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; let event = client.sign_event(builder)?; @@ -1192,12 +1248,14 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu #[cfg(test)] mod tests { use super::{ - apply_cardinality_rule, build_template_report, cmd_set_add_policy, - finalize_roster_resolution, name_matches, resolve_roster_with_archive_filter, + apply_cardinality_rule, build_template_report, cmd_set_add_policy, extract_channel_detail, + finalize_roster_resolution, format_archived_exclusions, format_member_identities, + format_resolved_agent_identities, name_matches, resolve_roster_with_archive_filter, validate_ttl_seconds, validate_update_channel_fields, ArchivedExclusion, ChannelSummary, ResolvedAgent, RosterResolution, SkippedSlug, }; use crate::client::BuzzClient; + use crate::validate::format_npub; use crate::CliError; use serde_json::json; @@ -1205,6 +1263,56 @@ mod tests { json!({ "tags": tags }) } + fn test_identity() -> (String, String) { + let hex = nostr::Keys::generate().public_key().to_hex(); + let npub = crate::validate::format_npub(&hex).expect("test public key formats"); + (hex, npub) + } + + #[test] + fn channel_detail_and_member_projections_emit_npub_only() { + let (hex, npub) = test_identity(); + let detail = extract_channel_detail(&json!({ + "pubkey": hex.clone(), + "created_at": 1, + "tags": [["d", "channel-1"], ["name", "general"]], + })) + .expect("channel detail formats"); + assert_eq!(detail["pubkey"], npub); + assert!(!detail.to_string().contains(&hex)); + + let members = format_member_identities(vec![json!({ + "pubkey": hex.clone(), + "role": "bot", + })]) + .expect("member list formats"); + assert_eq!(members[0]["pubkey"], npub); + assert!(!serde_json::to_string(&members).unwrap().contains(&hex)); + } + + #[test] + fn archived_template_report_identities_emit_npub_only() { + let (hex, npub) = test_identity(); + let agents = vec![ResolvedAgent { + persona_id: "builtin:fizz".into(), + pubkey: hex.clone(), + }]; + let displayed_agents = + format_resolved_agent_identities(&agents).expect("agent identities format"); + assert_eq!(displayed_agents, vec![npub.clone()]); + assert!(!serde_json::to_string(&displayed_agents) + .unwrap() + .contains(&hex)); + + let formatted = format_archived_exclusions(&[ArchivedExclusion { + persona_id: "builtin:fizz".into(), + pubkey: hex.clone(), + }]) + .expect("archived exclusions format"); + assert_eq!(formatted[0]["pubkey"], npub); + assert!(!serde_json::to_string(&formatted).unwrap().contains(&hex)); + } + #[test] fn from_event_extracts_known_tags() { let ev = event(json!([ @@ -1441,16 +1549,22 @@ mod tests { #[test] fn cardinality_multiple_instances_is_hard_error_listing_candidates() { let slugs = vec!["builtin:fizz".to_string()]; + let first = nostr::Keys::generate().public_key().to_hex(); + let second = nostr::Keys::generate().public_key().to_hex(); + let first_npub = format_npub(&first).unwrap(); + let second_npub = format_npub(&second).unwrap(); let found = vec![ - agent("builtin:fizz", &"a".repeat(64)), - agent("builtin:fizz", &"b".repeat(64)), + agent("builtin:fizz", &first), + agent("builtin:fizz", &second), ]; let err = apply_cardinality_rule(&slugs, &found).unwrap_err(); assert!(matches!(err, CliError::Usage(_))); let msg = err.to_string(); assert!(msg.contains("builtin:fizz")); - assert!(msg.contains(&"a".repeat(64))); - assert!(msg.contains(&"b".repeat(64))); + assert!(msg.contains(&first_npub)); + assert!(msg.contains(&second_npub)); + assert!(!msg.contains(&first)); + assert!(!msg.contains(&second)); } #[test] @@ -1464,10 +1578,12 @@ mod tests { "builtin:fizz".to_string(), "builtin:duplicated".to_string(), ]; + let first_duplicate = nostr::Keys::generate().public_key().to_hex(); + let second_duplicate = nostr::Keys::generate().public_key().to_hex(); let found = vec![ agent("builtin:fizz", &"a".repeat(64)), - agent("builtin:duplicated", &"b".repeat(64)), - agent("builtin:duplicated", &"c".repeat(64)), + agent("builtin:duplicated", &first_duplicate), + agent("builtin:duplicated", &second_duplicate), ]; let err = apply_cardinality_rule(&slugs, &found).unwrap_err(); assert!(err.to_string().contains("builtin:duplicated")); @@ -1593,9 +1709,11 @@ mod tests { // but the trust warning rides along in the error detail — never a // fake success report on stdout. let slugs = vec!["builtin:fizz".to_string()]; + let first = nostr::Keys::generate().public_key().to_hex(); + let second = nostr::Keys::generate().public_key().to_hex(); let found = vec![ - agent("builtin:fizz", &"a".repeat(64)), - agent("builtin:fizz", &"b".repeat(64)), + agent("builtin:fizz", &first), + agent("builtin:fizz", &second), ]; let archived_err = CliError::Other("query failure".into()); let err = resolve_roster_with_archive_filter(&slugs, found, Err(archived_err)) @@ -1671,6 +1789,7 @@ mod tests { false, members_added, member_failures, + Vec::new(), &resolution, ); assert_eq!( @@ -1688,9 +1807,11 @@ mod tests { // this path by construction — the function returns `Err` before // `cmd_create_channel_from_template` ever reaches `build_template_report`. let slugs = vec!["builtin:fizz".to_string()]; + let first = nostr::Keys::generate().public_key().to_hex(); + let second = nostr::Keys::generate().public_key().to_hex(); let found = vec![ - agent("builtin:fizz", &"a".repeat(64)), - agent("builtin:fizz", &"b".repeat(64)), + agent("builtin:fizz", &first), + agent("builtin:fizz", &second), ]; let archived_err = CliError::Other("query failure".into()); let mut sink: Vec = Vec::new(); @@ -1734,6 +1855,7 @@ mod tests { false, members_added, member_failures, + Vec::new(), &resolution, ); assert!( diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e4118270..563ff17aa91 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::{parse_uuid, sdk_err, validate_hex64}; +use crate::validate::{format_npub, normalize_pubkey, parse_uuid, sdk_err}; /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { @@ -27,7 +27,10 @@ pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), .filter_map(|tag| { let arr = tag.as_array()?; if arr.first()?.as_str()? == "p" { - arr.get(1)?.as_str().map(|s| s.to_string()) + arr.get(1)?.as_str().map(|s| { + format_npub(s) + .unwrap_or_else(|_| "".to_string()) + }) } else { None } @@ -52,9 +55,10 @@ pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into())); } - for pk in pubkeys { - validate_hex64(pk)?; - } + let pubkeys = pubkeys + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let dm_id = Uuid::new_v4().to_string(); let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect(); @@ -115,9 +119,9 @@ pub async fn cmd_add_dm_member( pubkey: &str, ) -> Result<(), CliError> { let channel_uuid = parse_uuid(channel_id)?; - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; - let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?; + let builder = buzz_sdk::build_dm_add_member(channel_uuid, &pubkey).map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 91c64a3915c..bec721522cd 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,7 +1,7 @@ use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; -use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; +use crate::validate::{normalize_pubkey, read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; pub async fn cmd_create_issue( @@ -13,17 +13,21 @@ pub async fn cmd_create_issue( labels: &[String], to: &[String], ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let body = read_or_stdin(content)?; + let recipients = to + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let meta = GitIssueMeta { labels: labels.to_vec(), - recipients: to.to_vec(), + recipients, }; let repo = GitRepoCoord { - owner: repo_owner.to_string(), + owner: repo_owner.clone(), id: repo_id.to_string(), }; @@ -35,7 +39,7 @@ pub async fn cmd_create_issue( let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in // a chat message — agents announce issues with it (see base_prompt.md). - let link = crate::links::issue_link(&event_id, repo_owner, repo_id); + let link = crate::links::issue_link(&event_id, &repo_owner, repo_id)?; crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -59,7 +63,7 @@ pub async fn cmd_list_issues( label: Option<&str>, limit: Option, ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let a_value = format!("30617:{repo_owner}:{repo_id}"); @@ -69,7 +73,7 @@ pub async fn cmd_list_issues( }); if let Some(pk) = author { - validate_hex64(pk)?; + let pk = normalize_pubkey(pk)?; filter["authors"] = serde_json::json!([pk]); } if let Some(l) = label { @@ -104,10 +108,10 @@ pub async fn cmd_issue_status( let repo = match (repo_owner, repo_id) { (Some(owner), Some(id)) => { - validate_hex64(owner)?; + let owner = normalize_pubkey(owner)?; validate_repo_id(id)?; Some(GitRepoCoord { - owner: owner.to_string(), + owner, id: id.to_string(), }) } @@ -127,9 +131,9 @@ pub async fn cmd_issue_status( recipients.push(repo.owner.clone()); } for recipient in to { - validate_hex64(recipient)?; - if !recipients.contains(recipient) { - recipients.push(recipient.clone()); + let recipient = normalize_pubkey(recipient)?; + if !recipients.contains(&recipient) { + recipients.push(recipient); } } diff --git a/crates/buzz-cli/src/commands/mem.rs b/crates/buzz-cli/src/commands/mem.rs index eb15921bd4c..e3e94dbabf4 100644 --- a/crates/buzz-cli/src/commands/mem.rs +++ b/crates/buzz-cli/src/commands/mem.rs @@ -27,13 +27,15 @@ use nostr::PublicKey; use crate::client::BuzzClient; use crate::error::CliError; +use crate::validate::normalize_pubkey; /// Resolve the agent's owner pubkey: explicit `--owner` flag wins, otherwise /// fall back to the NIP-OA `auth_tag` (which carries owner pubkey in slot 1). fn resolve_owner(client: &BuzzClient, owner_flag: Option<&str>) -> Result { if let Some(s) = owner_flag { - return PublicKey::from_hex(s) - .map_err(|e| CliError::Usage(format!("--owner must be a 64-hex pubkey: {e}"))); + let owner = normalize_pubkey(s)?; + return PublicKey::from_hex(&owner) + .map_err(|e| CliError::Usage(format!("--owner must be an npub: {e}"))); } let tag = client.auth_tag_owner_hex().ok_or_else(|| { CliError::Usage( @@ -62,8 +64,9 @@ fn resolve_reader( "--owner and --agent are mutually exclusive for read commands".into(), )); } - let agent = PublicKey::from_hex(agent) - .map_err(|e| CliError::Usage(format!("--agent must be a 64-hex pubkey: {e}")))?; + let agent = normalize_pubkey(agent)?; + let agent = PublicKey::from_hex(&agent) + .map_err(|e| CliError::Usage(format!("--agent must be an npub: {e}")))?; if agent == client.keys().public_key() { return Err(CliError::Usage( "--agent must differ from the CLI identity; omit --agent for agent-side reads" diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..bb6c53c5446 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -5,8 +5,9 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ - infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, - validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, + format_npub, infer_language, normalize_pubkey, parse_event_id, parse_uuid, read_or_stdin, + reject_secret_key_input, truncate_diff, validate_content_size, validate_hex64, validate_uuid, + MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -134,14 +135,22 @@ fn resolve_names_to_pubkeys( [] if has_explicit_mentions => {} [] => { return Err(CliError::Usage(format!( - "mention '@{name}' does not match a current channel member; retry with --mention " + "mention '@{name}' does not match a current channel member; retry with --mention " ))) } _ if has_explicit_mentions => {} candidates => { + let candidates = candidates + .iter() + .map(|pubkey| match format_npub(pubkey) { + Ok(npub) => npub, + Err(_) => "".to_string(), + }) + .collect::>() + .join(", "); return Err(CliError::Usage(format!( - "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", - candidates.join(", ") + "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", + candidates ))) } } @@ -228,9 +237,8 @@ async fn resolve_content_mentions( fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { let mut normalized = Vec::new(); for value in values { - let pubkey = PublicKey::parse(value.trim()) - .map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?; - let hex = pubkey.to_hex(); + let hex = normalize_pubkey(value) + .map_err(|_| CliError::Usage("invalid --mention: expected a valid npub".into()))?; if !normalized.contains(&hex) { normalized.push(hex); } @@ -275,6 +283,10 @@ fn missing_members(mentions: &[String], members: &[String]) -> Vec { .collect() } +fn format_pubkeys_for_output(pubkeys: &[String]) -> Result, CliError> { + pubkeys.iter().map(|pubkey| format_npub(pubkey)).collect() +} + fn event_mention_pubkeys(event: &nostr::Event) -> Vec { event .tags @@ -476,20 +488,19 @@ pub async fn cmd_search( /// Resolve an `--author` value to a 64-char hex pubkey. /// -/// Accepts, in order of precedence: 64-char hex (validated), an `npub1…` -/// bech32 key, or a display name resolved via NIP-50 profile search. A name +/// Accepts, in order of precedence: an `npub1…` key (or compatibility-only +/// hex), or a display name resolved via NIP-50 profile search. A name /// must match exactly one user (case-insensitive, on `display_name` or /// `name`) — ambiguity is an error listing the candidates rather than a /// silent mix of authors. async fn resolve_author(client: &BuzzClient, author: &str) -> Result { let author = author.trim(); - if author.len() == 64 && author.chars().all(|c| c.is_ascii_hexdigit()) { - return Ok(author.to_ascii_lowercase()); + reject_secret_key_input(author)?; + if let Ok(pubkey) = normalize_pubkey(author) { + return Ok(pubkey); } - if author.starts_with("npub1") { - return nostr::PublicKey::parse(author) - .map(|pk| pk.to_hex()) - .map_err(|_| CliError::Usage(format!("invalid npub: {author}"))); + if author.starts_with("npub1") || author.starts_with("nostr:npub1") { + return Err(CliError::Usage("invalid npub".to_string())); } // Display name → NIP-50 search on kind:0, exact case-insensitive match. @@ -503,7 +514,7 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result Err(CliError::Usage(format!( - "no user found with name '{author}' — pass a hex pubkey or npub instead" + "no user found with name '{author}' — pass an npub instead" ))), 1 => Ok(matches.remove(0).0), _ => { @@ -512,13 +523,16 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result = matches[..shown] .iter() - .map(|(pk, name)| format!("{name} ({pk})")) + .map(|(pk, name)| match format_npub(pk) { + Ok(npub) => format!("{name} ({npub})"), + Err(_) => format!("{name} ()"), + }) .collect(); if matches.len() > shown { listing.push(format!("… and {} more", matches.len() - shown)); } Err(CliError::Usage(format!( - "name '{author}' is ambiguous — matches: {}. Pass a pubkey instead", + "name '{author}' is ambiguous — matches: {}. Pass an npub instead", listing.join(", ") ))) } @@ -600,11 +614,12 @@ pub async fn cmd_send_message( let missing = missing_members(&mention_pubkeys, &member_pubkeys); if !missing.is_empty() { + let missing = format_pubkeys_for_output(&missing)?; return Err(CliError::Usage( serde_json::json!({ - "message": "mentioned pubkeys are not channel members; add them explicitly before retrying", + "message": "mentioned npubs are not channel members; add them explicitly before retrying", "missing_member_pubkeys": missing, - "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), + "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), }) .to_string(), )); @@ -679,6 +694,7 @@ pub async fn cmd_send_message( let event = client.sign_event(builder)?; let emitted_mentions = event_mention_pubkeys(&event); + let emitted_mentions = format_pubkeys_for_output(&emitted_mentions)?; let resp = client.submit_event(event).await?; let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) .unwrap_or_else(|_| serde_json::json!({ "response": resp })); @@ -993,10 +1009,12 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, + event_mention_pubkeys, find_root_from_tags, format_pubkeys_for_output, + match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_author, resolve_names_to_pubkeys, }; + use crate::validate::format_npub; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1235,6 +1253,12 @@ mod tests { vec![PK_VALID_A] ); assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err()); + let mistaken_secret = nostr::Keys::generate().secret_key().to_bech32().unwrap(); + let error = normalize_explicit_mentions(std::slice::from_ref(&mistaken_secret)) + .unwrap_err() + .to_string(); + assert!(!error.contains(&mistaken_secret)); + assert!(error.contains("expected a valid npub")); } #[test] @@ -1260,8 +1284,14 @@ mod tests { Vec::::new() ); let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err(); - assert!(error.to_string().contains(PK_VALID_A)); - assert!(error.to_string().contains(PK_VALID_B)); + assert!(error + .to_string() + .contains(&format_npub(PK_VALID_A).unwrap())); + assert!(error + .to_string() + .contains(&format_npub(PK_VALID_B).unwrap())); + assert!(!error.to_string().contains(PK_VALID_A)); + assert!(!error.to_string().contains(PK_VALID_B)); } #[test] @@ -1301,6 +1331,30 @@ mod tests { ); } + #[test] + fn mention_output_lists_emit_npub_not_hex() { + let displayed = format_pubkeys_for_output(&[PK_VALID_A.into(), PK_VALID_B.into()]) + .expect("mention identities format"); + assert_eq!(displayed[0], format_npub(PK_VALID_A).unwrap()); + assert_eq!(displayed[1], format_npub(PK_VALID_B).unwrap()); + let serialized = serde_json::to_string(&displayed).unwrap(); + assert!(!serialized.contains(PK_VALID_A)); + assert!(!serialized.contains(PK_VALID_B)); + } + + #[test] + fn unresolved_mention_guidance_requests_npub() { + let error = resolve_names_to_pubkeys( + &["unknown".into()], + &std::collections::HashMap::new(), + false, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("--mention ")); + assert!(!error.contains("")); + } + #[test] fn mention_evidence_comes_from_signed_event_tags() { use nostr::{EventBuilder, Keys, Tag}; @@ -1372,4 +1426,27 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[tokio::test] + async fn author_secret_shapes_are_rejected_before_relay_search() { + let client = crate::client::BuzzClient::new( + "http://127.0.0.1:9".to_string(), + nostr::Keys::generate(), + None, + None, + ) + .expect("test client builds"); + + for supplied in [ + "nsec1secret-shaped-input", + "nostr:nsec1secret-shaped-input", + "NOSTR:NSEC1SECRET-SHAPED-INPUT", + ] { + let error = resolve_author(&client, supplied) + .await + .expect_err("secret input must fail locally"); + assert!(matches!(&error, crate::error::CliError::Usage(_))); + assert!(!error.to_string().contains(supplied)); + } + } } diff --git a/crates/buzz-cli/src/commands/moderation.rs b/crates/buzz-cli/src/commands/moderation.rs index c53aecaf852..9d0a11c12f3 100644 --- a/crates/buzz-cli/src/commands/moderation.rs +++ b/crates/buzz-cli/src/commands/moderation.rs @@ -15,12 +15,74 @@ //! carry no channel scope. use nostr::Timestamp; +use serde_json::{Map, Value}; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{format_npub, normalize_pubkey, validate_hex64}; use crate::{ModerationCmd, OutputFormat}; +/// Project a dual-field moderation response onto the compact human/agent-facing +/// npub contract. JSON output stays byte-for-byte relay-compatible for scripts; +/// compact output prefers additive npub fields and removes only their legacy +/// compatibility duplicates. Event IDs and blob hashes stay hex. +fn project_moderation_response(raw: &str) -> Result { + let mut value: Value = serde_json::from_str(raw) + .map_err(|error| CliError::Other(format!("invalid moderation response: {error}")))?; + let rows = value + .as_array_mut() + .ok_or_else(|| CliError::Other("invalid moderation response: expected an array".into()))?; + for row in rows { + let object = row.as_object_mut().ok_or_else(|| { + CliError::Other("invalid moderation response: expected object rows".into()) + })?; + project_moderation_row(object); + } + serde_json::to_string(&value).map_err(|error| { + CliError::Other(format!("moderation response serialization failed: {error}")) + }) +} + +fn render_moderation_response(raw: &str, format: &OutputFormat) -> Result { + match format { + OutputFormat::Json => Ok(raw.to_string()), + OutputFormat::Compact => project_moderation_response(raw), + } +} + +fn prefer_npub(object: &mut Map, legacy: &str, canonical: &str) { + if !object.contains_key(legacy) { + return; + } + let canonical_value = object.remove(canonical); + let identity = canonical_value + .filter(|value| !value.is_null()) + .or_else(|| object.get(legacy).cloned()); + match identity { + Some(Value::String(value)) => { + let npub = format_npub(&value).unwrap_or_else(|_| "".to_string()); + object.insert(legacy.to_string(), Value::String(npub)); + } + Some(value) if object.contains_key(legacy) => { + object.insert(legacy.to_string(), value); + } + _ => {} + } +} + +fn project_moderation_row(object: &mut Map) { + prefer_npub(object, "reporter_pubkey", "reporter_npub"); + prefer_npub(object, "resolved_by", "resolved_by_npub"); + prefer_npub(object, "actor_pubkey", "actor_npub"); + prefer_npub(object, "target_pubkey", "target_npub"); + prefer_npub(object, "pubkey", "npub"); + if object.get("target_kind").and_then(Value::as_str) == Some("pubkey") { + prefer_npub(object, "target", "target_npub"); + } else { + object.remove("target_npub"); + } +} + /// Resolve `--expires-in ` / `--expires-at ` into an absolute /// unix-seconds expiry. At most one may be set (enforced by clap). fn resolve_expiry(expires_in: Option, expires_at: Option) -> Option { @@ -38,9 +100,9 @@ async fn cmd_ban( expires_at: Option, reason: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let expiry = resolve_expiry(expires_in, expires_at); - let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason) + let builder = buzz_sdk::build_moderation_ban(&pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -49,8 +111,8 @@ async fn cmd_ban( } async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { - validate_hex64(pubkey)?; - let builder = buzz_sdk::build_moderation_unban(pubkey) + let pubkey = normalize_pubkey(pubkey)?; + let builder = buzz_sdk::build_moderation_unban(&pubkey) .map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -65,10 +127,10 @@ async fn cmd_timeout( expires_at: Option, reason: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let expiry = resolve_expiry(expires_in, expires_at) .ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?; - let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason) + let builder = buzz_sdk::build_moderation_timeout(&pubkey, expiry, reason) .map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -77,8 +139,8 @@ async fn cmd_timeout( } async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { - validate_hex64(pubkey)?; - let builder = buzz_sdk::build_moderation_untimeout(pubkey) + let pubkey = normalize_pubkey(pubkey)?; + let builder = buzz_sdk::build_moderation_untimeout(&pubkey) .map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -106,38 +168,100 @@ async fn cmd_reports( client: &BuzzClient, status: Option<&str>, limit: i64, + format: &OutputFormat, ) -> Result<(), CliError> { let mut path = format!("/moderation/reports?limit={limit}"); if let Some(s) = status { path.push_str(&format!("&status={s}")); } let resp = client.get_authed(&path).await?; - println!("{resp}"); + println!("{}", render_moderation_response(&resp, format)?); Ok(()) } -async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> { +async fn cmd_restricted(client: &BuzzClient, format: &OutputFormat) -> Result<(), CliError> { let resp = client.get_authed("/moderation/restricted").await?; - println!("{resp}"); + println!("{}", render_moderation_response(&resp, format)?); Ok(()) } -async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> { +async fn cmd_audit(client: &BuzzClient, limit: i64, format: &OutputFormat) -> Result<(), CliError> { let resp = client .get_authed(&format!("/moderation/audit?limit={limit}")) .await?; - println!("{resp}"); + println!("{}", render_moderation_response(&resp, format)?); Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + const HEX: &str = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; + const NPUB: &str = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; + + #[test] + fn compact_read_projection_prefers_npub_and_omits_legacy_identity_hex() { + let raw = serde_json::json!([{ + "report_event_id": "11".repeat(32), + "reporter_pubkey": HEX, + "reporter_npub": NPUB, + "target_kind": "pubkey", + "target": HEX, + "target_npub": NPUB, + "resolved_by": HEX, + "resolved_by_npub": NPUB + }]) + .to_string(); + + let projected = + render_moderation_response(&raw, &OutputFormat::Compact).expect("project response"); + let value: Value = serde_json::from_str(&projected).expect("parse projection"); + assert_eq!(value[0]["reporter_pubkey"], NPUB); + assert_eq!(value[0]["target"], NPUB); + assert_eq!(value[0]["resolved_by"], NPUB); + assert_eq!(value[0]["report_event_id"], "11".repeat(32)); + assert!(!projected.contains(HEX)); + assert!(value[0].get("reporter_npub").is_none()); + assert!(value[0].get("target_npub").is_none()); + } + + #[test] + fn compact_read_projection_canonicalizes_identity_fields_from_older_relays() { + let raw = serde_json::json!([{ + "reporter_pubkey": HEX, + "target_kind": "event", + "target": "11".repeat(32) + }]) + .to_string(); + + let projected = + render_moderation_response(&raw, &OutputFormat::Compact).expect("project response"); + let value: Value = serde_json::from_str(&projected).expect("parse projection"); + assert_eq!(value[0]["reporter_pubkey"], NPUB); + assert_eq!(value[0]["target"], "11".repeat(32)); + assert!(!projected.contains(HEX)); + } + + #[test] + fn json_read_preserves_relay_dual_field_bytes() { + let raw = + format!("[ {{ \"reporter_pubkey\": \"{HEX}\", \"reporter_npub\": \"{NPUB}\" }} ]"); + + let rendered = + render_moderation_response(&raw, &OutputFormat::Json).expect("render response"); + assert_eq!(rendered, raw); + } +} + pub async fn dispatch( cmd: ModerationCmd, client: &BuzzClient, - _format: &OutputFormat, + format: &OutputFormat, ) -> Result<(), CliError> { match cmd { ModerationCmd::Reports { status, limit } => { - cmd_reports(client, status.as_deref(), limit).await + cmd_reports(client, status.as_deref(), limit, format).await } ModerationCmd::Resolve { report, @@ -159,7 +283,7 @@ pub async fn dispatch( reason, } => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await, ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await, - ModerationCmd::Restricted => cmd_restricted(client).await, - ModerationCmd::Audit { limit } => cmd_audit(client, limit).await, + ModerationCmd::Restricted => cmd_restricted(client, format).await, + ModerationCmd::Audit { limit } => cmd_audit(client, limit, format).await, } } diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be11..ecfda3f5445 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -32,7 +32,7 @@ use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp, ToBech32}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{format_npub, normalize_pubkey, reject_secret_key_input}; /// NIP-23 long-form content kind. pub const KIND_LONG_FORM: u16 = 30023; @@ -198,17 +198,21 @@ pub async fn fetch_by_slug(client: &BuzzClient, slug: &str) -> Result /// /// Accepts: /// - `"me"` → the CLI's own keypair. -/// - 64-hex pubkey → parsed directly. +/// - npub (or compatibility-only hex) → parsed directly. /// - anything else → treated as a petname / display name, searched against /// kind:0 profiles. Exact-one match required; ambiguity is a hard error. pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result { if author_flag == "me" { return Ok(client.keys().public_key()); } - if validate_hex64(author_flag).is_ok() { - return PublicKey::from_hex(author_flag) + reject_secret_key_input(author_flag)?; + if let Ok(pubkey_hex) = normalize_pubkey(author_flag) { + return PublicKey::from_hex(&pubkey_hex) .map_err(|e| CliError::Usage(format!("invalid pubkey: {e}"))); } + if author_flag.starts_with("npub1") || author_flag.starts_with("nostr:npub1") { + return Err(CliError::Usage("invalid npub".to_string())); + } // Petname lookup: NIP-50 search on kind:0, then exact-name filter. let filter = serde_json::json!({ "kinds": [0], @@ -234,11 +238,11 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result Err(CliError::Usage(format!( - "no user found with display_name {author_flag:?}; pass a 64-hex pubkey or \"me\"" + "no user found with display_name {author_flag:?}; pass an npub or \"me\"" ))), 1 => Ok(matches[0].pubkey), n => Err(CliError::Usage(format!( - "{n} users match display_name {author_flag:?}; disambiguate with --author " + "{n} users match display_name {author_flag:?}; disambiguate with --author " ))), } } @@ -275,7 +279,7 @@ pub fn coord_for(author: &PublicKey, slug: &str) -> nostr::nips::nip01::Coordina /// Format a list of candidate notes for the "ambiguous slug" error path. /// One line per candidate; sorted newest-first. Designed so the user can -/// paste a pubkey into a follow-up `--author ` invocation. +/// paste an npub into a follow-up `--author ` invocation. pub fn format_note_candidates(snapshots: &[NoteSnapshot]) -> String { let mut rows: Vec<&NoteSnapshot> = snapshots.iter().collect(); rows.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); @@ -286,12 +290,9 @@ pub fn format_note_candidates(snapshots: &[NoteSnapshot]) -> String { } else { s.title.as_str() }; - out.push_str(&format!( - " {} {} {}\n", - s.pubkey.to_hex(), - s.updated_at, - title - )); + let npub = + format_npub(&s.pubkey.to_hex()).unwrap_or_else(|_| "".to_string()); + out.push_str(&format!(" {npub} {} {title}\n", s.updated_at)); } out } @@ -321,7 +322,7 @@ impl TryFrom<&NoteSnapshot> for NoteOutput { .map_err(|e| CliError::Other(format!("failed to encode naddr: {e}")))?; Ok(Self { id: snapshot.id.to_hex(), - pubkey: snapshot.pubkey.to_hex(), + pubkey: format_npub(&snapshot.pubkey.to_hex())?, naddr, coordinate: coordinate.to_string(), slug: snapshot.slug.clone(), @@ -633,8 +634,9 @@ pub async fn cmd_get( if let Some(author_flag) = author { let author_pk = resolve_author(client, author_flag).await?; let coord = coord_for(&author_pk, &slug); + let author_npub = format_npub(&author_pk.to_hex())?; let event = fetch_by_coord(client, &coord).await?.ok_or_else(|| { - CliError::NotFound(format!("note not found: {}/{}", author_pk.to_hex(), slug)) + CliError::NotFound(format!("note not found: {author_npub}/{slug}")) })?; snapshot_from_event(&event)? } else { @@ -648,7 +650,7 @@ pub async fn cmd_get( snapshots.remove(0) } else { return Err(CliError::Usage(format!( - "note name {slug:?} is ambiguous; pass --author or --latest\n{}", + "note name {slug:?} is ambiguous; pass --author or --latest\n{}", format_note_candidates(&snapshots) ))); } @@ -719,10 +721,10 @@ pub async fn cmd_rm(client: &BuzzClient, slug: &str) -> Result<(), CliError> { // want a clear "nothing to delete" signal rather than emitting a kind:5 // for a coordinate that was never published. let me = client.keys().public_key(); + let me_npub = format_npub(&me.to_hex())?; if fetch_own_note(client, slug).await?.is_none() { return Err(CliError::NotFound(format!( - "no note {slug:?} found for you ({}); nothing to delete", - me.to_hex() + "no note {slug:?} found for you ({me_npub}); nothing to delete" ))); } @@ -979,6 +981,10 @@ mod tests { assert_eq!(lines.len(), 2); assert!(lines[0].contains("newer")); assert!(lines[1].contains("older")); + assert!(lines[0].contains("npub1")); + assert!(lines[1].contains("npub1")); + assert!(!out.contains(&keys_a.public_key().to_hex())); + assert!(!out.contains(&keys_b.public_key().to_hex())); } #[test] @@ -1327,4 +1333,27 @@ mod tests { Err(CliError::Usage(m)) if m.contains("mutually exclusive") )); } + + #[tokio::test] + async fn author_secret_shapes_are_rejected_before_relay_search() { + let client = crate::client::BuzzClient::new( + "http://127.0.0.1:9".to_string(), + Keys::generate(), + None, + None, + ) + .expect("test client builds"); + + for supplied in [ + "nsec1secret-shaped-input", + "nostr:nsec1secret-shaped-input", + "NSEC1SECRET-SHAPED-INPUT", + ] { + let error = resolve_author(&client, supplied) + .await + .expect_err("secret input must fail locally"); + assert!(matches!(&error, CliError::Usage(_))); + assert!(!error.to_string().contains(supplied)); + } + } } diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 413934a3c11..0d0291d5f03 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -2,7 +2,8 @@ use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ - read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, + normalize_pubkey, read_file_or_stdin, read_or_stdin, reject_secret_key_input, sdk_err, + validate_hex64, validate_repo_id, }; use buzz_sdk::{GitAppliedPatchRef, GitPatchMeta, GitRepoCoord, GitStatus, GitStatusMeta}; @@ -22,9 +23,13 @@ pub async fn cmd_send_patch( commit_pgp_sig: Option<&str>, committer: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let content = read_file_or_stdin(patch)?; + let recipients = to + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let committer = match committer { Some(spec) => Some(parse_committer(spec)?), @@ -33,7 +38,7 @@ pub async fn cmd_send_patch( let meta = GitPatchMeta { euc: euc.map(str::to_string), - recipients: to.to_vec(), + recipients, reply_to: reply_to.map(str::to_string), root, root_revision, @@ -44,7 +49,7 @@ pub async fn cmd_send_patch( }; let repo = GitRepoCoord { - owner: repo_owner.to_string(), + owner: repo_owner, id: repo_id.to_string(), }; @@ -90,7 +95,7 @@ pub async fn cmd_list_patches( author: Option<&str>, limit: Option, ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let a_value = format!("30617:{repo_owner}:{repo_id}"); @@ -100,7 +105,7 @@ pub async fn cmd_list_patches( }); if let Some(pk) = author { - validate_hex64(pk)?; + let pk = normalize_pubkey(pk)?; filter["authors"] = serde_json::json!([pk]); } if let Some(n) = limit { @@ -136,10 +141,10 @@ pub async fn cmd_patch_status( let repo = match (repo_owner, repo_id) { (Some(owner), Some(id)) => { - validate_hex64(owner)?; + let owner = normalize_pubkey(owner)?; validate_repo_id(id)?; Some(GitRepoCoord { - owner: owner.to_string(), + owner, id: id.to_string(), }) } @@ -160,15 +165,15 @@ pub async fn cmd_patch_status( recipients.push(repo.owner.clone()); } for recipient in to { - validate_hex64(recipient)?; - if !recipients.contains(recipient) { - recipients.push(recipient.clone()); + let recipient = normalize_pubkey(recipient)?; + if !recipients.contains(&recipient) { + recipients.push(recipient); } } let applied_patches = q .iter() - .map(|spec| GitAppliedPatchRef::parse(spec).map_err(sdk_err)) + .map(|spec| parse_applied_patch_ref(spec)) .collect::, _>>()?; let meta = GitStatusMeta { @@ -190,6 +195,29 @@ pub async fn cmd_patch_status( Ok(()) } +fn parse_applied_patch_ref(spec: &str) -> Result { + if let Some((_, trailing_hint)) = spec.rsplit_once(':') { + reject_secret_key_input(trailing_hint)?; + } + let parsed = GitAppliedPatchRef::parse(spec).map_err(sdk_err)?; + if parsed.pubkey.is_some() { + return Ok(parsed); + } + + let Some((prefix, candidate)) = spec.rsplit_once(':') else { + return Ok(parsed); + }; + let Some((_, relay_hint)) = prefix.split_once(':') else { + return Ok(parsed); + }; + if relay_hint.is_empty() || !candidate.starts_with("npub1") { + return Ok(parsed); + } + + let author_hex = normalize_pubkey(candidate)?; + GitAppliedPatchRef::parse(&format!("{prefix}:{author_hex}")).map_err(sdk_err) +} + /// Parse the CLI's status word into a `GitStatus`. `merged` and `resolved` /// are accepted as synonyms for the same underlying kind (1631) — NIP-34 /// uses "applied/merged" for patches and "resolved" for issues, but it's one @@ -323,4 +351,49 @@ mod tests { let err = parse_status("merge").unwrap_err(); assert!(matches!(err, CliError::Usage(_))); } + + #[test] + fn applied_patch_ref_normalizes_npub_author_hint_to_protocol_hex() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = crate::validate::format_npub(&hex).expect("public key formats"); + let event_id = "a".repeat(64); + + let parsed = + parse_applied_patch_ref(&format!("{event_id}:wss://relay.example/path:{npub}")) + .expect("npub hint parses"); + + assert_eq!(parsed.id, event_id); + assert_eq!(parsed.relay.as_deref(), Some("wss://relay.example/path")); + assert_eq!(parsed.pubkey.as_deref(), Some(hex.as_str())); + } + + #[test] + fn applied_patch_ref_keeps_legacy_hex_author_hint_compatible() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + let event_id = "b".repeat(64); + + let parsed = parse_applied_patch_ref(&format!("{event_id}:wss://relay.example/path:{hex}")) + .expect("legacy hex hint parses"); + + assert_eq!(parsed.pubkey.as_deref(), Some(hex.as_str())); + } + + #[test] + fn applied_patch_ref_rejects_secret_shaped_author_hint_without_echo() { + let event_id = "c".repeat(64); + for secret in [ + "nsec1secret-shaped", + "nostr:nsec1secret-shaped", + "NSEC1SECRET-SHAPED", + "NOSTR:NSEC1SECRET-SHAPED", + ] { + let supplied = format!("{event_id}:wss://relay.example/path:{secret}"); + let error = parse_applied_patch_ref(&supplied) + .expect_err("secret author hint must be rejected"); + assert!(!error.to_string().contains(secret)); + assert!(!error.to_string().contains(&supplied)); + } + } } diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 74c580a6d6a..828fd625e99 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -2,7 +2,7 @@ use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ - read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, + normalize_pubkey, read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, }; use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta}; @@ -35,17 +35,21 @@ pub async fn cmd_open_pr( channel: Option<&str>, revision_of: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let content = read_optional_body(body, body_file)?; + let recipients = to + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let repo = GitRepoCoord { - owner: repo_owner.to_string(), + owner: repo_owner.clone(), id: repo_id.to_string(), }; let meta = GitPullRequestMeta { euc: euc.map(str::to_string), - recipients: to.to_vec(), + recipients, channel_id: channel.map(str::to_string), subject: subject.to_string(), labels: labels.to_vec(), @@ -64,7 +68,7 @@ pub async fn cmd_open_pr( let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in // a chat message — agents announce PRs with it (see base_prompt.md). - let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id); + let link = crate::links::pull_request_link(&event_id, &repo_owner, repo_id)?; crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -84,21 +88,25 @@ pub async fn cmd_update_pr( euc: Option<&str>, to: &[String], ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; validate_hex64(pr)?; - validate_hex64(pr_author)?; + let pr_author = normalize_pubkey(pr_author)?; let content = read_optional_body(body, body_file)?; + let recipients = to + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let repo = GitRepoCoord { - owner: repo_owner.to_string(), + owner: repo_owner, id: repo_id.to_string(), }; let meta = GitPrUpdateMeta { euc: euc.map(str::to_string), - recipients: to.to_vec(), + recipients, pr_event: pr.to_string(), - pr_author: pr_author.to_string(), + pr_author, commit: commit.to_string(), clone_urls: clone_urls.to_vec(), merge_base: merge_base.map(str::to_string), @@ -132,7 +140,7 @@ pub async fn cmd_list_prs( label: Option<&str>, limit: Option, ) -> Result<(), CliError> { - validate_hex64(repo_owner)?; + let repo_owner = normalize_pubkey(repo_owner)?; validate_repo_id(repo_id)?; let a_value = format!("30617:{repo_owner}:{repo_id}"); @@ -142,7 +150,7 @@ pub async fn cmd_list_prs( }); if let Some(pk) = author { - validate_hex64(pk)?; + let pk = normalize_pubkey(pk)?; filter["authors"] = serde_json::json!([pk]); } if let Some(l) = label { @@ -176,10 +184,10 @@ pub async fn cmd_pr_status( let repo = match (repo_owner, repo_id) { (Some(owner), Some(id)) => { - validate_hex64(owner)?; + let owner = normalize_pubkey(owner)?; validate_repo_id(id)?; Some(GitRepoCoord { - owner: owner.to_string(), + owner, id: id.to_string(), }) } @@ -198,9 +206,9 @@ pub async fn cmd_pr_status( recipients.push(repo.owner.clone()); } for recipient in to { - validate_hex64(recipient)?; - if !recipients.contains(recipient) { - recipients.push(recipient.clone()); + let recipient = normalize_pubkey(recipient)?; + if !recipients.contains(&recipient) { + recipients.push(recipient); } } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index e6798dbfc44..fd688d9697a 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -24,6 +24,7 @@ use nostr::{Event, EventBuilder, Tag, Timestamp}; use crate::client::BuzzClient; use crate::commands::parse_write_response; use crate::error::CliError; +use crate::validate::{format_npub, normalize_pubkey, reject_secret_key_input}; // ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── @@ -40,18 +41,52 @@ fn is_bare_repo_id(s: &str) -> bool { /// Expand a CLI `--repo` argument into a full `30617::` coordinate. /// /// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey. -/// Full form (`30617::`): used verbatim. +/// Full form (`30617::`): normalized to protocol hex. fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result { + // A secret-shaped value can otherwise satisfy the bare repo-id grammar + // and be published as a d-tag. Reject it before selecting either form. + reject_secret_key_input(s)?; if is_bare_repo_id(s) { // Bare form: expand to full coordinate with caller as owner. let full = format!("30617:{caller_pubkey}:{s}"); ProjectMemberCoord::parse_full(&full) .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) } else { - // Full form: must be parseable as a complete coordinate. - ProjectMemberCoord::parse_full(s) - .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + // Full form: normalize the human-facing owner before crossing the + // NIP-33/NIP-34 protocol boundary. The repo d-tag may contain colons, + // so split only the first two separators. + let mut parts = s.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let repo_d = parts.next(); + if kind != Some("30617") || owner.is_none() || repo_d.is_none_or(str::is_empty) { + return Err(CliError::Usage( + "invalid repo coordinate; expected 30617::".to_string(), + )); + } + let owner = owner.unwrap_or_default(); + reject_secret_key_input(owner)?; + let owner_hex = normalize_pubkey(owner).map_err(|_| { + CliError::Usage("repo coordinate owner must be a valid npub".to_string()) + })?; + let normalized = format!("30617:{owner_hex}:{}", repo_d.unwrap_or_default()); + ProjectMemberCoord::parse_full(&normalized).map_err(|_| { + CliError::Usage("invalid repo coordinate; expected 30617::".to_string()) + }) + } +} + +fn project_member_display(member: &ProjectMemberCoord) -> Result { + let mut parts = member.coord.splitn(3, ':'); + let kind = parts.next().unwrap_or_default(); + let owner = parts.next().unwrap_or_default(); + let repo_d = parts.next().unwrap_or_default(); + if kind != "30617" || repo_d.is_empty() { + return Err(CliError::Other( + "project member has an invalid protocol coordinate".to_string(), + )); } + Ok(format!("30617:{}:{repo_d}", format_npub(owner)?)) } // ── Head-fetch helper ───────────────────────────────────────────────────────── @@ -73,10 +108,7 @@ async fn fetch_project( owner: Option<&str>, ) -> Result, CliError> { let pubkey = match owner { - Some(pk) => { - crate::validate::validate_hex64(pk)?; - pk.to_string() - } + Some(pk) => crate::validate::normalize_pubkey(pk)?, None => client.keys().public_key().to_hex(), }; let filter = serde_json::json!({ @@ -174,9 +206,9 @@ pub async fn cmd_create( let mut seen = std::collections::HashSet::new(); for m in &members { if !seen.insert(m.coord.clone()) { + let member = project_member_display(m)?; return Err(CliError::Usage(format!( - "duplicate --repo coordinate in this invocation: {:?}", - m.coord + "duplicate --repo coordinate in this invocation: {member:?}" ))); } } @@ -211,19 +243,30 @@ pub async fn cmd_create( } /// `buzz projects get` +fn project_output(event: &Event) -> Result { + Ok(serde_json::json!({ + "event_id": event.id.to_hex(), + "pubkey": format_npub(&event.pubkey.to_hex())?, + "created_at": event.created_at.as_secs(), + "kind": event.kind.as_u16(), + "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + "content": event.content, + })) +} + +fn project_owner_display(owner: Option<&str>) -> Result { + match owner { + Some(owner) => format_npub(&crate::validate::normalize_pubkey(owner)?), + None => Ok("current identity".to_string()), + } +} + pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> { validate_project_slug(slug)?; let resp = match fetch_project(client, slug, owner).await? { - Some(event) => serde_json::json!({ - "event_id": event.id.to_hex(), - "pubkey": event.pubkey.to_hex(), - "created_at": event.created_at.as_secs(), - "kind": event.kind.as_u16(), - "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), - "content": event.content, - }), + Some(event) => project_output(&event)?, None => { - let owner_desc = owner.unwrap_or("current identity"); + let owner_desc = project_owner_display(owner)?; return Err(CliError::NotFound(format!( "project {slug:?} not found for {owner_desc}" ))); @@ -240,10 +283,7 @@ pub async fn cmd_list( limit: Option, ) -> Result<(), CliError> { let pubkey = match owner { - Some(pk) => { - crate::validate::validate_hex64(pk)?; - pk.to_string() - } + Some(pk) => crate::validate::normalize_pubkey(pk)?, None => client.keys().public_key().to_hex(), }; let mut filter = serde_json::json!({ @@ -277,9 +317,9 @@ pub async fn cmd_add_repo( let mut seen = std::collections::HashSet::new(); for m in &new_members { if !seen.insert(m.coord.clone()) { + let member = project_member_display(m)?; return Err(CliError::Usage(format!( - "duplicate --repo coordinate in this invocation: {:?}", - m.coord + "duplicate --repo coordinate in this invocation: {member:?}" ))); } } @@ -353,9 +393,9 @@ pub async fn cmd_remove_repo( .collect(); for m in &to_remove { if !existing_coords.contains(m.coord.as_str()) { + let member = project_member_display(m)?; return Err(CliError::NotFound(format!( - "project {slug:?} does not contain member {:?}", - m.coord + "project {slug:?} does not contain member {member:?}" ))); } } @@ -619,8 +659,8 @@ mod tests { // ── Coordinate expansion ────────────────────────────────────────────────── - const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OWNER_HEX: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4"; + const OWNER_B_HEX: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; #[test] fn expand_repo_coord_bare_expands_with_caller_pubkey() { @@ -628,6 +668,40 @@ mod tests { assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo")); } + #[test] + fn project_output_emits_author_as_npub_without_changing_protocol_tags() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + let coordinate = format!("30617:{hex}:repo"); + let event = EventBuilder::new(nostr::Kind::Custom(KIND_PROJECT as u16), "") + .tags([ + Tag::parse(["d", "project"]).unwrap(), + Tag::parse(["a", &coordinate]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let output = project_output(&event).expect("project output formats"); + assert!(output["pubkey"].as_str().unwrap().starts_with("npub1")); + assert_ne!(output["pubkey"], hex); + assert_eq!(output["tags"][1][1], coordinate); + assert!(output["tags"].to_string().contains(&hex)); + } + + #[test] + fn project_owner_display_normalizes_legacy_hex_for_errors() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + + let displayed = project_owner_display(Some(&hex)).expect("legacy public key formats"); + assert!(displayed.starts_with("npub1")); + assert!(!displayed.contains(&hex)); + assert_eq!( + project_owner_display(None).expect("current identity sentinel formats"), + "current identity" + ); + } + #[test] fn expand_repo_coord_full_passes_through() { let full = format!("30617:{OWNER_HEX}:some-repo"); @@ -644,9 +718,59 @@ mod tests { } #[test] - fn expand_repo_coord_rejects_uppercase_owner() { - let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"; - assert!(expand_repo_coord(upper, OWNER_HEX).is_err()); + fn expand_repo_coord_accepts_npub_owner_and_normalizes_to_protocol_hex() { + let npub = format_npub(OWNER_HEX).expect("owner formats"); + let coord = expand_repo_coord(&format!("30617:{npub}:buzz"), OWNER_B_HEX) + .expect("npub coordinate parses"); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:buzz")); + } + + #[test] + fn expand_repo_coord_normalizes_legacy_uppercase_owner() { + let upper = OWNER_HEX.to_ascii_uppercase(); + let coord = expand_repo_coord(&format!("30617:{upper}:buzz"), OWNER_B_HEX) + .expect("legacy uppercase owner parses"); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:buzz")); + } + + #[test] + fn expand_repo_coord_rejects_secret_or_invalid_owner_without_echo() { + for owner in [ + "nsec1secret-shaped-input", + "NSEC1SECRET-SHAPED-INPUT", + "not-a-public-key", + ] { + let supplied = format!("30617:{owner}:buzz"); + let error = expand_repo_coord(&supplied, OWNER_B_HEX) + .expect_err("non-public owner must be rejected"); + let message = error.to_string(); + assert!(!message.contains(owner)); + assert!(!message.contains(&supplied)); + } + } + + #[test] + fn expand_repo_coord_rejects_bare_secret_shape_without_echo() { + for supplied in [ + "nsec1secretshapedrepo", + "NSEC1SECRETSHAPEDREPO", + "nostr:nsec1secretshapedrepo", + "NOSTR:NSEC1SECRETSHAPEDREPO", + ] { + let error = expand_repo_coord(supplied, OWNER_B_HEX) + .expect_err("secret-shaped bare repo must be rejected"); + assert!(!error.to_string().contains(supplied)); + } + } + + #[test] + fn project_member_display_uses_npub_without_changing_internal_coordinate() { + let member = expand_repo_coord(&format!("30617:{OWNER_HEX}:buzz"), OWNER_B_HEX) + .expect("coordinate parses"); + let display = project_member_display(&member).expect("coordinate displays"); + assert!(display.contains("npub1")); + assert!(!display.contains(OWNER_HEX)); + assert_eq!(member.coord, format!("30617:{OWNER_HEX}:buzz")); } #[test] @@ -1162,11 +1286,13 @@ mod tests { matches!(err, CliError::Usage(_)), "expected CliError::Usage for duplicate repo, got {err:?}" ); - // Error message must name the duplicate coordinate. + // Error message names the duplicate using its human-facing owner. + let message = err.to_string(); assert!( - format!("{err}").contains("buzz"), + message.contains("buzz") && message.contains("npub1"), "Usage message must name the duplicate coordinate, got {err:?}" ); + assert!(!message.contains(OWNER_HEX)); } /// Supplying the same coordinate twice in one add-repo call must return Usage @@ -1182,6 +1308,9 @@ mod tests { matches!(err, CliError::Usage(_)), "expected CliError::Usage for duplicate repo on add-repo, got {err:?}" ); + let message = err.to_string(); + assert!(message.contains("npub1")); + assert!(!message.contains(OWNER_HEX)); } // ── create collision guard ──────────────────────────────────────────────── diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d301312..47902691aae 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -4,7 +4,43 @@ use nostr::EventId; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{format_npub, validate_hex64}; + +fn normalize_reactions(events: &[serde_json::Value]) -> Result { + let mut groups: HashMap> = HashMap::new(); + for event in events { + let emoji = event + .get("content") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("+") + .to_string(); + let pubkey = event + .get("pubkey") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CliError::Other("relay response missing reaction author".into()))?; + groups.entry(emoji).or_default().push(format_npub(pubkey)?); + } + + let mut reactions: Vec = groups + .into_iter() + .map(|(emoji, pubkeys)| { + serde_json::json!({ + "emoji": emoji, + "count": pubkeys.len(), + "pubkeys": pubkeys, + }) + }) + .collect(); + reactions.sort_by(|a, b| { + a.get("emoji") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or("")) + }); + + Ok(serde_json::json!({ "reactions": reactions })) +} pub async fn cmd_add_reaction( client: &BuzzClient, @@ -86,44 +122,33 @@ pub async fn cmd_get_reactions(client: &BuzzClient, event_id: &str) -> Result<() let resp = client.query(&filter).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - let mut groups: HashMap> = HashMap::new(); - for e in &events { - let emoji = e - .get("content") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("+") - .to_string(); - let pubkey = e - .get("pubkey") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - groups.entry(emoji).or_default().push(pubkey); - } - - let mut reactions: Vec = groups - .into_iter() - .map(|(emoji, pubkeys)| { - serde_json::json!({ - "emoji": emoji, - "count": pubkeys.len(), - "pubkeys": pubkeys, - }) - }) - .collect(); - reactions.sort_by(|a, b| { - a.get("emoji") - .and_then(|v| v.as_str()) - .unwrap_or("") - .cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or("")) - }); - - let output = serde_json::json!({ "reactions": reactions }); + let output = normalize_reactions(&events)?; println!("{}", serde_json::to_string(&output).unwrap_or_default()); Ok(()) } +#[cfg(test)] +mod tests { + use super::normalize_reactions; + use serde_json::json; + + #[test] + fn reaction_author_lists_emit_npub_not_hex() { + let hex = nostr::Keys::generate().public_key().to_hex(); + let output = normalize_reactions(&[json!({ + "content": "+", + "pubkey": hex.clone(), + })]) + .expect("reactions format"); + let displayed = output["reactions"][0]["pubkeys"][0] + .as_str() + .expect("string public key"); + assert!(displayed.starts_with("npub1")); + assert_ne!(displayed, hex); + assert!(!output.to_string().contains(&hex)); + } +} + pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::ReactionsCmd; match cmd { diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index e54b95ef20e..db4c116e3ef 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -265,7 +265,7 @@ pub async fn cmd_create_repo( let resp = client.submit_event(event).await?; // `link` renders as a rich preview card in Buzz Desktop when included in // a chat message — agents announce repos with it (see base_prompt.md). - let link = crate::links::repo_link(&owner, repo_id); + let link = crate::links::repo_link(&owner, repo_id)?; crate::client::print_create_response(&resp, "link", &link); Ok(()) } @@ -285,7 +285,7 @@ pub async fn cmd_get_repo( // If owner specified, filter by author pubkey; otherwise return any match. // Note: without --owner, multiple repos with the same name (different owners) may be returned. if let Some(pk) = owner { - crate::validate::validate_hex64(pk)?; + let pk = crate::validate::normalize_pubkey(pk)?; filter["authors"] = serde_json::json!([pk]); } @@ -301,10 +301,7 @@ pub async fn cmd_list_repos( ) -> Result<(), CliError> { // Default to self if no owner specified. let pubkey = match owner { - Some(pk) => { - crate::validate::validate_hex64(pk)?; - pk.to_string() - } + Some(pk) => crate::validate::normalize_pubkey(pk)?, None => client.keys().public_key().to_hex(), }; diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 89c028a8ae3..d25195a0240 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::{parse_event_id, validate_hex64}; +use crate::validate::{normalize_pubkey, parse_event_id, validate_hex64}; /// A single contact entry (CLI-local, not from buzz-sdk). #[derive(Debug, Deserialize)] @@ -47,14 +47,20 @@ pub async fn cmd_set_contact_list( let entries: Vec = serde_json::from_str(contacts_json) .map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?; - let contacts: Vec<(&str, Option<&str>, Option<&str>)> = entries + let normalized_entries = entries .iter() .map(|c| { - ( - c.pubkey.as_str(), - c.relay_url.as_deref(), - c.petname.as_deref(), - ) + Ok(( + normalize_pubkey(&c.pubkey)?, + c.relay_url.clone(), + c.petname.clone(), + )) + }) + .collect::, CliError>>()?; + let contacts: Vec<(&str, Option<&str>, Option<&str>)> = normalized_entries + .iter() + .map(|(pubkey, relay_url, petname)| { + (pubkey.as_str(), relay_url.as_deref(), petname.as_deref()) }) .collect(); @@ -87,7 +93,7 @@ pub async fn cmd_get_user_notes( before: Option, before_id: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; if let Some(bid) = before_id { validate_hex64(bid)?; } @@ -113,7 +119,7 @@ pub async fn cmd_get_user_notes( /// Get a user's contact list (kind:3) by pubkey. pub async fn cmd_get_contact_list(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; let filter = serde_json::json!({ "kinds": [3], "authors": [pubkey], @@ -187,7 +193,7 @@ pub async fn cmd_get_list( kind: u32, d_tag: Option<&str>, ) -> Result<(), CliError> { - validate_hex64(pubkey)?; + let pubkey = normalize_pubkey(pubkey)?; validate_social_list_kind(kind)?; if !is_parameterized_social_list_kind(kind) && d_tag.is_some() { return Err(CliError::Usage(format!( diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 7c15d285a0d..b87b9ad45f1 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -3,10 +3,16 @@ use nostr::PublicKey; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{format_npub, normalize_pubkey}; // TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder +fn public_identity_output(pubkey: Option<&str>) -> String { + pubkey + .and_then(|value| format_npub(value).ok()) + .unwrap_or_else(|| "".to_string()) +} + /// Get user profiles (kind:0 metadata events). /// /// - 0 pubkeys, no name → query our own profile @@ -32,18 +38,19 @@ pub async fn cmd_get_users( return Err(CliError::Usage("--owner requires --name".into())); } - for pk in pubkeys { - validate_hex64(pk)?; - } if pubkeys.len() > 200 { return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); } + let normalized_pubkeys = pubkeys + .iter() + .map(|pubkey| normalize_pubkey(pubkey)) + .collect::, _>>()?; let my_pk = client.keys().public_key().to_hex(); - let authors: Vec<&str> = if pubkeys.is_empty() { + let authors: Vec<&str> = if normalized_pubkeys.is_empty() { vec![my_pk.as_str()] } else { - pubkeys.iter().map(|s| s.as_str()).collect() + normalized_pubkeys.iter().map(|s| s.as_str()).collect() }; let filter = serde_json::json!({ @@ -61,7 +68,9 @@ pub async fn cmd_get_users( if let Some(obj) = profile.as_object_mut() { obj.insert( "pubkey".to_string(), - serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")), + serde_json::json!(public_identity_output( + e.get("pubkey").and_then(|v| v.as_str()) + )), ); } Some(profile) @@ -96,11 +105,9 @@ fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result Vec Vec { +) -> Result, CliError> { + let owner_npub = format_npub(owner)?; pubkeys .iter() .map(|pubkey| { @@ -268,16 +275,22 @@ fn owner_scoped_profiles( .map(|event| owner_verification(event, owner)) .unwrap_or("missing_profile") }; - profile.insert("pubkey".to_string(), serde_json::json!(pubkey)); + profile.insert( + "pubkey".to_string(), + serde_json::json!(public_identity_output(Some(pubkey))), + ); profile.insert("verification".to_string(), serde_json::json!(verification)); profile.insert( "owned_by_me".to_string(), serde_json::json!(verification == "verified" && owner == effective_owner), ); if verification == "verified" { - profile.insert("owner_pubkey".to_string(), serde_json::json!(owner)); + profile.insert( + "owner_pubkey".to_string(), + serde_json::json!(owner_npub.clone()), + ); } - serde_json::Value::Object(profile) + Ok(serde_json::Value::Object(profile)) }) .collect() } @@ -317,7 +330,7 @@ async fn search_by_name( serde_json::from_str(&raw) .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))? }; - owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client)) + owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client))? } else { let filter = serde_json::json!({ "kinds": [0], @@ -454,14 +467,12 @@ async fn fetch_current_profile( /// Get presence status for users — query kind:40902 presence snapshot events. pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result<(), CliError> { - let pubkeys: Vec<&str> = pubkeys_csv + let pubkeys = pubkeys_csv .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) - .collect(); - for pk in &pubkeys { - validate_hex64(pk)?; - } + .map(normalize_pubkey) + .collect::, _>>()?; let filter = serde_json::json!({ "kinds": [40902], @@ -474,7 +485,8 @@ pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result< .iter() .map(|e| { serde_json::json!({ - "pubkey": presence_subject(e), + "pubkey": format_npub(presence_subject(e)) + .unwrap_or_else(|_| "".to_string()), "status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), "updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), }) @@ -574,8 +586,8 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification, - presence_subject, + name_search_profiles, owned_agent_pubkeys_from_events, owner_scoped_profiles, + owner_verification, presence_subject, public_identity_output, }; use nostr::Keys; use serde_json::json; @@ -602,6 +614,29 @@ mod tests { assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty()); } + #[test] + fn public_profile_identity_projection_emits_npub_and_never_raw_hex() { + let keys = Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = public_identity_output(Some(&hex)); + assert!(npub.starts_with("npub1")); + assert_ne!(npub, hex); + + let profiles = name_search_profiles( + &[json!({ + "pubkey": hex.clone(), + "content": r#"{"display_name":"Honey"}"#, + })], + "honey", + ); + assert_eq!(profiles[0]["pubkey"], npub); + assert!(!profiles[0].to_string().contains(&hex)); + assert_eq!( + public_identity_output(Some("malformed")), + "" + ); + } + fn profile_event(agent_keys: &Keys, auth_tags: Vec) -> serde_json::Value { json!({ "pubkey": agent_keys.public_key().to_hex(), @@ -728,19 +763,28 @@ mod tests { &pubkeys, &owner_keys.public_key().to_hex(), &owner_keys.public_key().to_hex(), - ); + ) + .expect("owner identities format"); assert_eq!(profiles[0]["display_name"], "Renamed Honey"); assert_eq!(profiles[0]["verification"], "verified"); assert_eq!(profiles[0]["owned_by_me"], true); assert_eq!( profiles[0]["owner_pubkey"], - owner_keys.public_key().to_hex() + crate::validate::format_npub(&owner_keys.public_key().to_hex()).unwrap() + ); + assert_eq!( + profiles[0]["pubkey"], + crate::validate::format_npub(&agent_keys.public_key().to_hex()).unwrap() ); + assert!(!profiles[0] + .to_string() + .contains(&agent_keys.public_key().to_hex())); assert_eq!(profiles[1]["verification"], "missing_profile"); assert_eq!(profiles[1]["owned_by_me"], false); assert!(profiles[1].get("owner_pubkey").is_none()); assert_eq!(profiles[2]["verification"], "invalid_agent_pubkey"); + assert_eq!(profiles[2]["pubkey"], ""); assert_eq!(profiles[2]["owned_by_me"], false); assert!(profiles[2].get("owner_pubkey").is_none()); } diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 2786d2c5088..bf2aa099857 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -5,10 +5,23 @@ use crate::client::{ BuzzClient, }; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid}; +use crate::validate::{format_npub, parse_uuid, read_or_stdin, sdk_err, validate_uuid}; // TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions +fn normalize_workflow(event: &serde_json::Value) -> Result { + let author = event + .get("pubkey") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CliError::Other("relay response missing workflow author".into()))?; + Ok(serde_json::json!({ + "workflow_id": extract_d_tag(event), + "content": event.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": event.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + "pubkey": format_npub(author)?, + })) +} + /// List workflows in a channel — query kind:30620 workflow definition events. pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; @@ -20,15 +33,8 @@ pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); let workflows: Vec = events .iter() - .map(|e| { - serde_json::json!({ - "workflow_id": extract_d_tag(e), - "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), - "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), - "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), - }) - }) - .collect(); + .map(normalize_workflow) + .collect::>()?; let output = serde_json::to_string(&workflows).unwrap_or_default(); println!("{output}"); Ok(()) @@ -44,12 +50,7 @@ pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result< let resp = client.query(&filter).await?; let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); if let Some(e) = events.first() { - let normalized = serde_json::json!({ - "workflow_id": extract_d_tag(e), - "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), - "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), - "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), - }); + let normalized = normalize_workflow(e)?; println!("{normalized}"); } else { println!("null"); @@ -241,3 +242,25 @@ pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<( } } } + +#[cfg(test)] +mod tests { + use super::normalize_workflow; + use serde_json::json; + + #[test] + fn normalized_workflow_author_is_npub_not_hex() { + let hex = nostr::Keys::generate().public_key().to_hex(); + let workflow = normalize_workflow(&json!({ + "pubkey": hex.clone(), + "created_at": 1, + "content": "name: test", + "tags": [["d", "workflow-1"]], + })) + .expect("workflow formats"); + let displayed = workflow["pubkey"].as_str().expect("string public key"); + assert!(displayed.starts_with("npub1")); + assert_ne!(displayed, hex); + assert!(!workflow.to_string().contains(&hex)); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3893c5b6425..98cee514416 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -5,11 +5,13 @@ mod error; mod links; mod validate; +use buzz_core::nostr_identity::{parse_secret_key_compat, public_key_to_npub}; use clap::{Parser, Subcommand}; use client::BuzzClient; use error::CliError; use nostr::Keys; use uuid::Uuid; +use zeroize::Zeroizing; /// Run the Buzz CLI from raw arguments (including `argv[0]`). /// @@ -69,7 +71,7 @@ Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] - BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] + BUZZ_PRIVATE_KEY Nostr private key (nsec) [required] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] The 'pack' subcommand runs locally and does not require a relay connection. @@ -82,7 +84,7 @@ struct Cli { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")] relay: String, - /// Nostr private key (hex or nsec). This is the CLI's identity. + /// Nostr private key in nsec form (legacy hex remains accepted). #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, @@ -309,16 +311,16 @@ running under BUZZ_AUTH_TAG signs as itself, so it can only ever satisfy \ the self path (target == signer) — not the owner-of-agent path for another \ identity.\n\n\ Examples:\n \ -buzz agents archive --reason retired\n \ -buzz agents archive --reason bot-rebuilt --replaced-by " +buzz agents archive --reason retired\n \ +buzz agents archive --reason bot-rebuilt --replaced-by " )] Archive { - /// Target identity pubkey (hex) + /// Target identity npub target_pubkey: String, /// Machine-readable reason code, max 64 UTF-8 bytes #[arg(long)] reason: Option, - /// Rotation pointer pubkey (hex); must differ from the target + /// Rotation pointer npub; must differ from the target #[arg(long)] replaced_by: Option, /// Optional human-readable note (not parsed for authorization) @@ -337,10 +339,10 @@ buzz agents archive --reason bot-rebuilt --replaced-by " extraction failure, then exits with an error if still unresolvable. Use --admin to bypass \ for relay-admin callers.\n\n\ Examples:\n \ -buzz agents unarchive --reason returned" +buzz agents unarchive --reason returned" )] Unarchive { - /// Target identity pubkey (hex) + /// Target identity npub target_pubkey: String, /// Machine-readable reason code, max 64 UTF-8 bytes #[arg(long)] @@ -392,7 +394,7 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, - /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. + /// Npub to mention (repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. Legacy hex remains accepted. #[arg(long = "mention")] mentions: Vec, }, @@ -503,7 +505,7 @@ pub enum MessagesCmd { /// Search query string (optional when --author is given) #[arg(long)] query: Option, - /// Filter by author: 64-char hex pubkey, npub, or display name + /// Filter by author: npub or display name #[arg(long)] author: Option, /// Unix timestamp — return messages after this time @@ -681,7 +683,7 @@ pub enum ChannelsCmd { /// Channel UUID #[arg(long)] channel: String, - /// Member pubkey (64-char hex) + /// Member npub #[arg(long)] pubkey: String, /// Member role (owner, admin, member, guest, bot) @@ -694,7 +696,7 @@ pub enum ChannelsCmd { /// Channel UUID #[arg(long)] channel: String, - /// Member pubkey (64-char hex) + /// Member npub #[arg(long)] pubkey: String, }, @@ -809,7 +811,7 @@ pub enum DmsCmd { }, /// Open a new direct message with one or more users Open { - /// User pubkey(s) to DM (64-char hex, 1-8) + /// User npub(s) to DM (1-8) #[arg(long = "pubkey")] pubkeys: Vec, }, @@ -818,7 +820,7 @@ pub enum DmsCmd { /// DM conversation UUID #[arg(long)] channel: String, - /// User pubkey to add (64-char hex) + /// User npub to add #[arg(long)] pubkey: String, }, @@ -834,13 +836,13 @@ pub enum DmsCmd { pub enum UsersCmd { /// Look up user profiles by pubkey or name Get { - /// User pubkey(s) to look up (64-char hex). Omit for your own profile + /// User npub(s) to look up. Omit for your own profile #[arg(long = "pubkey")] pubkeys: Vec, /// Search by display name (case-insensitive substring match) #[arg(long = "name")] name: Option, - /// Scope an exact-name agent lookup to its owner (`me`, hex, or npub) + /// Scope an exact-name agent lookup to its owner (`me` or npub; legacy hex remains accepted) #[arg(long = "owner", requires = "name")] owner: Option, }, @@ -862,7 +864,7 @@ pub enum UsersCmd { }, /// Get presence status for users Presence { - /// Comma-separated pubkeys (64-char hex) + /// Comma-separated npubs #[arg(long)] pubkeys: String, }, @@ -998,7 +1000,7 @@ pub enum SocialCmd { /// Set your contact list (NIP-02 kind:3) #[command(name = "set-contacts")] SetContactList { - /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] + /// JSON array of contacts: [{"pubkey":"npub1...","relay_url":"...","petname":"..."}] #[arg(long)] contacts: String, }, @@ -1012,7 +1014,7 @@ pub enum SocialCmd { /// Get recent notes published by a user #[command(name = "notes")] GetUserNotes { - /// 64-char hex pubkey of the author. + /// Author npub. #[arg(long)] pubkey: String, /// Maximum number of notes to return (default 50, max 100). @@ -1028,7 +1030,7 @@ pub enum SocialCmd { /// Get a user's contact list #[command(name = "contacts")] GetContactList { - /// 64-char hex pubkey. + /// Author npub. #[arg(long)] pubkey: String, }, @@ -1048,7 +1050,7 @@ pub enum SocialCmd { /// Get NIP-51/NIP-65 social lists or sets by author and kind. #[command(name = "list")] GetList { - /// 64-char hex pubkey of the author. + /// Author npub. #[arg(long)] pubkey: String, /// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003. @@ -1103,7 +1105,7 @@ pub enum NotesCmd { /// Slug to look up across authors. Mutually exclusive with `--naddr`. #[arg(long)] name: Option, - /// Disambiguate `--name` to a specific author (hex pubkey, display name, or `me`). + /// Disambiguate `--name` to a specific author (npub, display name, or `me`). #[arg(long)] author: Option, /// On an ambiguous `--name` (multiple authors), pick the most recently updated note @@ -1116,7 +1118,7 @@ pub enum NotesCmd { }, /// List notes. Defaults to your own. Ls { - /// Hex pubkey, display name, `me`, or `all`. + /// Npub, display name, `me`, or `all`. #[arg(long, default_value = "me")] author: Option, /// Filter by NIP-23 `t` tag. @@ -1172,13 +1174,13 @@ pub enum ReposCmd { /// Repository identifier (d-tag) #[arg(long)] id: String, - /// Owner pubkey (64-char hex). Omit to match any owner. + /// Owner npub. Omit to match any owner. #[arg(long)] owner: Option, }, /// List repository announcements List { - /// Owner pubkey (64-char hex). Omit for your repos. + /// Owner npub. Omit for your repos. #[arg(long)] owner: Option, /// Maximum number of results @@ -1283,7 +1285,7 @@ pub enum ProjectsCmd { /// Project identifier (slug), up to 1024 bytes slug: String, /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full - /// `30617::` for cross-owner or colon-bearing repo ids. + /// `30617::` for cross-owner or colon-bearing repo ids. /// At least one --repo is required. #[arg(long = "repo", required = true)] repo: Vec, @@ -1304,13 +1306,13 @@ pub enum ProjectsCmd { Get { /// Project slug slug: String, - /// Owner pubkey (64-char hex). Defaults to the current identity. + /// Owner npub. Defaults to the current identity. #[arg(long)] owner: Option, }, /// List projects List { - /// Owner pubkey (64-char hex). Defaults to the current identity. + /// Owner npub. Defaults to the current identity. #[arg(long)] owner: Option, /// Maximum number of results @@ -1322,7 +1324,7 @@ pub enum ProjectsCmd { AddRepo { /// Project slug slug: String, - /// Member repository coordinate (bare id or full `30617::`) + /// Member repository coordinate (bare id or full `30617::`) #[arg(long = "repo", required = true)] repo: Vec, }, @@ -1331,7 +1333,7 @@ pub enum ProjectsCmd { RemoveRepo { /// Project slug slug: String, - /// Member repository coordinate to remove (bare id or full `30617::`) + /// Member repository coordinate to remove (bare id or full `30617::`) #[arg(long = "repo", required = true)] repo: Vec, }, @@ -1376,10 +1378,10 @@ pub enum ProjectsCmd { pub enum PatchesCmd { /// Send a git patch (NIP-34 kind:1617) #[command( - after_help = "Examples:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner --repo-id myrepo --patch-file 0001-fix.patch --reply-to " + after_help = "Examples:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner --repo-id myrepo --patch-file 0001-fix.patch --reply-to " )] Send { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) @@ -1391,7 +1393,7 @@ pub enum PatchesCmd { /// Earliest-unique-commit of the repo #[arg(long)] euc: Option, - /// Additional recipient pubkey(s) — can be specified multiple times + /// Additional recipient npub(s) — can be specified multiple times #[arg(long = "to")] to: Vec, /// Previous patch event id (series) or original root (revision) @@ -1424,13 +1426,13 @@ pub enum PatchesCmd { }, /// List patches for a repo List { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) #[arg(long)] repo_id: String, - /// Filter by patch author pubkey + /// Filter by patch author npub #[arg(long)] author: Option, /// Maximum number of results @@ -1448,7 +1450,7 @@ pub enum PatchesCmd { /// Markdown context for the status change ('-' to read from stdin) #[arg(long)] content: Option, - /// Repo owner pubkey — requires --repo-id + /// Repo owner npub — requires --repo-id #[arg(long, requires = "repo_id")] repo_owner: Option, /// Repo identifier (d-tag) — requires --repo-owner @@ -1460,13 +1462,13 @@ pub enum PatchesCmd { /// Root id of the revision that was accepted (status=merged only) #[arg(long)] revision: Option, - /// Additional recipient pubkey(s) for the status event (besides the + /// Additional recipient npub(s) for the status event (besides the /// repo owner, which is tagged automatically when --repo-owner is /// given) — e.g. root/revision author. Can be specified multiple times. #[arg(long = "to")] to: Vec, /// Applied patch event id — can be specified multiple times (status=merged only). - /// Accepts ``, `:`, or `::`. + /// Accepts ``, `:`, or `::`. #[arg(long = "q")] q: Vec, /// Merge commit id (status=merged only) @@ -1482,10 +1484,10 @@ pub enum PatchesCmd { pub enum PrCmd { /// Open a git pull request (NIP-34 kind:1618) #[command( - after_help = "Examples:\n buzz pr open --repo-owner --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner --repo-id myrepo --pr --pr-author --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo" + after_help = "Examples:\n buzz pr open --repo-owner --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner --repo-id myrepo --pr --pr-author --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo" )] Open { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) @@ -1518,7 +1520,7 @@ pub enum PrCmd { /// Label — can be specified multiple times #[arg(long = "label")] label: Vec, - /// Additional recipient pubkey(s) — can be specified multiple times + /// Additional recipient npub(s) — can be specified multiple times #[arg(long = "to")] to: Vec, /// Channel where this pull request originated (NIP-29 h-tag) @@ -1530,7 +1532,7 @@ pub enum PrCmd { }, /// Update a git pull request tip (NIP-34 kind:1619) Update { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) @@ -1539,7 +1541,7 @@ pub enum PrCmd { /// Pull request event id being updated #[arg(long)] pr: String, - /// Pull request author's pubkey + /// Pull request author's npub (legacy hex remains accepted) #[arg(long)] pr_author: String, /// Updated tip commit of the PR branch @@ -1560,7 +1562,7 @@ pub enum PrCmd { /// Earliest-unique-commit of the repo #[arg(long)] euc: Option, - /// Additional recipient pubkey(s) — can be specified multiple times + /// Additional recipient npub(s) — can be specified multiple times #[arg(long = "to")] to: Vec, }, @@ -1572,13 +1574,13 @@ pub enum PrCmd { }, /// List PRs for a repo List { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) #[arg(long)] repo_id: String, - /// Filter by PR author pubkey + /// Filter by PR author npub (legacy hex remains accepted) #[arg(long)] author: Option, /// Filter by label @@ -1602,7 +1604,7 @@ pub enum PrCmd { /// Path to markdown context for the status change, or '-' to read from stdin. #[arg(long, conflicts_with = "body")] body_file: Option, - /// Repo owner pubkey — requires --repo-id + /// Repo owner npub — requires --repo-id #[arg(long, requires = "repo_id")] repo_owner: Option, /// Repo identifier (d-tag) — requires --repo-owner @@ -1611,7 +1613,7 @@ pub enum PrCmd { /// Earliest-unique-commit of the repo #[arg(long)] euc: Option, - /// Additional recipient pubkey(s) for the status event (besides the + /// Additional recipient npub(s) for the status event (besides the /// repo owner, which is tagged automatically when --repo-owner is /// given) — e.g. PR author/reviewers. Can be specified multiple times. #[arg(long = "to")] @@ -1626,7 +1628,7 @@ pub enum PrCmd { pub enum IssuesCmd { /// Create a git issue (NIP-34 kind:1621) Create { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) @@ -1641,7 +1643,7 @@ pub enum IssuesCmd { /// Label — can be specified multiple times #[arg(long = "label")] label: Vec, - /// Additional recipient pubkey(s) — can be specified multiple times + /// Additional recipient npub(s) — can be specified multiple times #[arg(long = "to")] to: Vec, }, @@ -1653,13 +1655,13 @@ pub enum IssuesCmd { }, /// List issues for a repo List { - /// Repo owner pubkey (64-char hex) + /// Repo owner npub #[arg(long)] repo_owner: String, /// Repo identifier (d-tag) #[arg(long)] repo_id: String, - /// Filter by issue author pubkey + /// Filter by issue author npub (legacy hex remains accepted) #[arg(long)] author: Option, /// Filter by label @@ -1680,7 +1682,7 @@ pub enum IssuesCmd { /// Markdown context for the status change ('-' to read from stdin) #[arg(long)] content: Option, - /// Repo owner pubkey — requires --repo-id + /// Repo owner npub — requires --repo-id #[arg(long, requires = "repo_id")] repo_owner: Option, /// Repo identifier (d-tag) — requires --repo-owner @@ -1689,7 +1691,7 @@ pub enum IssuesCmd { /// Earliest-unique-commit of the repo #[arg(long)] euc: Option, - /// Additional recipient pubkey(s) for the status event (besides the + /// Additional recipient npub(s) for the status event (besides the /// repo owner, which is tagged automatically when --repo-owner is /// given) — e.g. the issue author. Can be specified multiple times. #[arg(long = "to")] @@ -1724,10 +1726,10 @@ pub enum MediaCmd { pub enum MemCmd { /// List non-tombstoned memory entries Ls { - /// Owner pubkey (hex). Overrides BUZZ_AUTH_TAG. + /// Owner npub. Overrides BUZZ_AUTH_TAG. #[arg(long)] owner: Option, - /// Agent pubkey (hex) to read as this key's owner. + /// Agent npub to read as this key's owner. #[arg(long)] agent: Option, /// Emit JSON instead of tab-delimited lines. @@ -1739,7 +1741,7 @@ pub enum MemCmd { slug: String, #[arg(long)] owner: Option, - /// Agent pubkey (hex) to read as this key's owner. + /// Agent npub to read as this key's owner. #[arg(long)] agent: Option, }, @@ -1748,7 +1750,7 @@ pub enum MemCmd { slug: String, #[arg(long)] owner: Option, - /// Agent pubkey (hex) to read as this key's owner. + /// Agent npub to read as this key's owner. #[arg(long)] agent: Option, }, @@ -1857,10 +1859,10 @@ pub enum ModerationCmd { }, /// Ban a member from the community (kind 9040) #[command( - after_help = "Examples:\n buzz moderation ban --pubkey \n buzz moderation ban --pubkey --expires-in 604800 --reason \"repeated spam\"" + after_help = "Examples:\n buzz moderation ban --pubkey \n buzz moderation ban --pubkey --expires-in 604800 --reason \"repeated spam\"" )] Ban { - /// Target member pubkey (hex) + /// Target member npub #[arg(long)] pubkey: String, /// Ban duration in seconds from now (omit for a permanent ban) @@ -1875,16 +1877,16 @@ pub enum ModerationCmd { }, /// Lift a member's ban (kind 9041) Unban { - /// Target member pubkey (hex) + /// Target member npub #[arg(long)] pubkey: String, }, /// Time out a member — a write-block, not a disconnect (kind 9042) #[command( - after_help = "Examples:\n buzz moderation timeout --pubkey --expires-in 3600\n buzz moderation timeout --pubkey --expires-at 1783500000 --reason \"cool off\"" + after_help = "Examples:\n buzz moderation timeout --pubkey --expires-in 3600\n buzz moderation timeout --pubkey --expires-at 1783500000 --reason \"cool off\"" )] Timeout { - /// Target member pubkey (hex) + /// Target member npub #[arg(long)] pubkey: String, /// Timeout duration in seconds from now @@ -1899,7 +1901,7 @@ pub enum ModerationCmd { }, /// Clear a member's timeout early (kind 9043) Untimeout { - /// Target member pubkey (hex) + /// Target member npub #[arg(long)] pubkey: String, }, @@ -1961,11 +1963,12 @@ async fn run(cli: Cli) -> Result<(), CliError> { // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { + let private_key = Zeroizing::new(cli.private_key.ok_or_else(|| { CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into()) - })?; - let keys = Keys::parse(&private_key_str) - .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; + })?); + let (secret_key, _) = parse_secret_key_compat(&private_key) + .map_err(|_| CliError::Key("invalid BUZZ_PRIVATE_KEY: expected an nsec".to_string()))?; + let keys = Keys::new(secret_key); // NIP-OA: parse and verify the auth tag if provided. // @@ -1979,10 +1982,13 @@ async fn run(cli: Cli) -> Result<(), CliError> { let json = normalize_auth_tag_input(input); let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; + let current_identity = public_key_to_npub(&keys.public_key()).map_err(|e| { + CliError::Auth(format!("failed to format current identity as npub: {e}")) + })?; buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", - keys.public_key().to_hex() + current_identity )) })?; // Canonical wire form derives from the parsed-and-verified tag diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 043bdc48b05..37abb000852 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -6,22 +6,34 @@ //! stay format-compatible (see `golden_format_matches_desktop` below and //! the mirror test in `entityLink.test.mjs`). //! -//! Callers are expected to validate inputs first (`validate_hex64`, -//! `validate_repo_id`); the identifier charsets need no URL encoding. +//! Callers normalize public keys to protocol hex before reaching these +//! builders; links expose the human-facing owner as npub. + +use crate::error::CliError; +use crate::validate::format_npub; /// Build a `buzz://repo` link for a repository announcement (kind 30617). -pub fn repo_link(owner: &str, repo_id: &str) -> String { - format!("buzz://repo?owner={owner}&d={repo_id}") +pub fn repo_link(owner: &str, repo_id: &str) -> Result { + Ok(format!( + "buzz://repo?owner={}&d={repo_id}", + format_npub(owner)? + )) } /// Build a `buzz://pr` link for a pull request event (kind 1618). -pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { - format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") +pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> Result { + Ok(format!( + "buzz://pr?id={event_id}&owner={}&d={repo_id}", + format_npub(owner)? + )) } /// Build a `buzz://issue` link for an issue event (kind 1621). -pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { - format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}") +pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> Result { + Ok(format!( + "buzz://issue?id={event_id}&owner={}&d={repo_id}", + format_npub(owner)? + )) } #[cfg(test)] @@ -35,17 +47,18 @@ mod tests { // ("builders emit the canonical cross-language link format"). #[test] fn golden_format_matches_desktop() { + let owner_npub = format_npub(OWNER).unwrap(); assert_eq!( - pull_request_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + pull_request_link(EVENT_ID, OWNER, "buzz-world").unwrap(), + format!("buzz://pr?id={EVENT_ID}&owner={owner_npub}&d=buzz-world") ); assert_eq!( - issue_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + issue_link(EVENT_ID, OWNER, "buzz-world").unwrap(), + format!("buzz://issue?id={EVENT_ID}&owner={owner_npub}&d=buzz-world") ); assert_eq!( - repo_link(OWNER, "buzz-world"), - format!("buzz://repo?owner={OWNER}&d=buzz-world") + repo_link(OWNER, "buzz-world").unwrap(), + format!("buzz://repo?owner={owner_npub}&d=buzz-world") ); } } diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..2dd02448d3f 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -1,4 +1,5 @@ use crate::error::CliError; +use buzz_core::nostr_identity::{parse_public_key_compat, public_key_to_npub}; /// Maximum content size in bytes (64 KiB). pub const MAX_CONTENT_BYTES: usize = 65_536; @@ -21,20 +22,52 @@ pub fn parse_uuid(s: &str) -> Result { /// Validate UUID string. Returns CliError::Usage on failure. pub fn validate_uuid(s: &str) -> Result<(), CliError> { - uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?; + uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage("invalid UUID".to_string()))?; Ok(()) } /// Validate 64-character lowercase hex string (event_id, pubkey). pub fn validate_hex64(s: &str) -> Result<(), CliError> { if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(CliError::Usage(format!( - "must be a 64-character hex string: {s}" - ))); + return Err(CliError::Usage( + "must be a 64-character hexadecimal value".to_string(), + )); } Ok(()) } +/// Reject input that is shaped like a NIP-19 secret before a dual key/name +/// argument can fall through to a relay-backed name search. +/// +/// The diagnostic deliberately never includes the supplied secret. +pub fn reject_secret_key_input(s: &str) -> Result<(), CliError> { + let normalized = s.trim().to_ascii_lowercase(); + let nip19 = normalized + .strip_prefix("nostr:") + .unwrap_or(normalized.as_str()); + if nip19.starts_with("nsec1") { + return Err(CliError::Usage( + "secret keys cannot be used as public identities".to_string(), + )); + } + Ok(()) +} + +/// Parse a human-facing npub (or compatibility-only hex) to protocol hex. +pub fn normalize_pubkey(s: &str) -> Result { + parse_public_key_compat(s) + .map(|(public_key, _)| public_key.to_hex()) + .map_err(|_| CliError::Usage("expected a valid npub".to_string())) +} + +/// Convert a protocol-native public key to canonical human-facing npub. +pub fn format_npub(s: &str) -> Result { + let (public_key, _) = parse_public_key_compat(s) + .map_err(|_| CliError::Other("relay returned an invalid public key".to_string()))?; + public_key_to_npub(&public_key) + .map_err(|_| CliError::Other("failed to encode public key as npub".to_string())) +} + /// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. pub fn validate_repo_id(s: &str) -> Result<(), CliError> { if s.is_empty() || s.len() > 64 { @@ -210,8 +243,10 @@ mod tests { #[test] fn validate_uuid_malformed() { - let err = validate_uuid("not-a-uuid").unwrap_err(); - assert!(matches!(err, CliError::Usage(_))); + let supplied = "not-a-uuid"; + let err = validate_uuid(supplied).unwrap_err(); + assert!(matches!(&err, CliError::Usage(_))); + assert!(!err.to_string().contains(supplied)); } #[test] @@ -220,6 +255,14 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } + #[test] + fn validate_uuid_never_echoes_secret_shaped_input() { + for supplied in ["nsec1secret-shaped", "NOSTR:NSEC1SECRET-SHAPED"] { + let error = validate_uuid(supplied).expect_err("secret shape is not a UUID"); + assert!(!error.to_string().contains(supplied)); + } + } + // --- validate_hex64 --- #[test] @@ -253,7 +296,39 @@ mod tests { let mut hex = "a".repeat(63); hex.push('z'); // 'z' is not a hex digit let err = validate_hex64(&hex).unwrap_err(); - assert!(matches!(err, CliError::Usage(_))); + assert!(matches!(&err, CliError::Usage(_))); + assert!(!err.to_string().contains(&hex)); + } + + #[test] + fn validate_hex64_error_never_echoes_input() { + let supplied = "private-looking-but-not-hex"; + let error = validate_hex64(supplied).expect_err("input must be rejected"); + assert!(!error.to_string().contains(supplied)); + } + + #[test] + fn secret_shaped_public_identity_input_is_rejected_without_echo() { + for supplied in [ + "nsec1not-a-real-secret", + "nostr:nsec1not-a-real-secret", + "NSEC1NOT-A-REAL-SECRET", + "NOSTR:NSEC1NOT-A-REAL-SECRET", + ] { + let error = reject_secret_key_input(supplied).expect_err("secret shape must fail"); + assert!(!error.to_string().contains(supplied)); + } + assert!(reject_secret_key_input("npub1public-shape").is_ok()); + assert!(reject_secret_key_input("Aaron").is_ok()); + } + + #[test] + fn normalize_pubkey_accepts_npub_and_legacy_hex() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = public_key_to_npub(&keys.public_key()).unwrap(); + assert_eq!(normalize_pubkey(&npub).unwrap(), hex); + assert_eq!(normalize_pubkey(&hex).unwrap(), hex); } // --- validate_content_size --- diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83e..5b475590de0 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -26,6 +26,8 @@ pub mod invite; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// Human-facing Nostr identity parsing and NIP-19 formatting helpers. +pub mod nostr_identity; /// Agent observer frame helpers. pub mod observer; /// NIP-AB device pairing — crypto primitives, message types, and errors. diff --git a/crates/buzz-core/src/nostr_identity.rs b/crates/buzz-core/src/nostr_identity.rs new file mode 100644 index 00000000000..60709068b83 --- /dev/null +++ b/crates/buzz-core/src/nostr_identity.rs @@ -0,0 +1,292 @@ +//! NIP-19 identity helpers for human and configuration boundaries. +//! +//! Signed Nostr events, tags, filters, and database rows remain protocol-native +//! hex. These helpers are for the boundary immediately before or after those +//! protocol values: CLI arguments, environment variables, logs, and custom +//! JSON APIs. + +use nostr::{FromBech32, PublicKey, SecretKey, ToBech32}; +use thiserror::Error; + +/// Encoding used by an accepted key input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyInputEncoding { + /// Canonical NIP-19 bech32 (`npub1…` or `nsec1…`). + Nip19, + /// Compatibility-only 64-character hexadecimal input. + LegacyHex, +} + +/// Error returned when a human-facing Nostr key cannot be parsed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum IdentityError { + /// A public key was neither a valid npub nor a valid legacy hex key. + #[error("expected a valid npub")] + InvalidPublicKey, + /// A private key was neither a valid nsec nor a valid legacy hex key. + #[error("expected a valid nsec")] + InvalidSecretKey, + /// A validated public key could not be encoded as NIP-19. + #[error("failed to encode npub")] + PublicKeyEncoding, + /// A validated secret key could not be encoded as NIP-19. + #[error("failed to encode nsec")] + SecretKeyEncoding, +} + +/// Stable placeholder for a malformed public identity at a diagnostic boundary. +/// +/// Logs and custom human-facing projections must never fall back to echoing the +/// original value, because that would reintroduce raw hex (or arbitrary input). +pub const INVALID_PUBLIC_KEY_DISPLAY: &str = ""; + +fn strip_nostr_scheme(input: &str) -> &str { + input + .get(..6) + .filter(|prefix| prefix.eq_ignore_ascii_case("nostr:")) + .map_or(input, |_| &input[6..]) +} + +fn has_mixed_ascii_case(input: &str) -> bool { + input + .chars() + .any(|character| character.is_ascii_lowercase()) + && input + .chars() + .any(|character| character.is_ascii_uppercase()) +} + +/// Parse a canonical npub or a compatibility-only 64-character hex public key. +/// +/// The returned [`PublicKey`] should be converted to hex only at a Nostr +/// protocol or database boundary. +pub fn parse_public_key_compat( + input: &str, +) -> Result<(PublicKey, KeyInputEncoding), IdentityError> { + let input = input.trim(); + let input = strip_nostr_scheme(input); + if input + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("npub1")) + { + if has_mixed_ascii_case(input) { + return Err(IdentityError::InvalidPublicKey); + } + let canonical = input.to_ascii_lowercase(); + let key = + PublicKey::from_bech32(&canonical).map_err(|_| IdentityError::InvalidPublicKey)?; + key.xonly().map_err(|_| IdentityError::InvalidPublicKey)?; + return Ok((key, KeyInputEncoding::Nip19)); + } + if input.len() == 64 && input.chars().all(|character| character.is_ascii_hexdigit()) { + let key = PublicKey::from_hex(input).map_err(|_| IdentityError::InvalidPublicKey)?; + key.xonly().map_err(|_| IdentityError::InvalidPublicKey)?; + return Ok((key, KeyInputEncoding::LegacyHex)); + } + Err(IdentityError::InvalidPublicKey) +} + +/// Parse a canonical nsec or a compatibility-only 64-character hex secret key. +/// +/// Errors deliberately never include the supplied secret. +pub fn parse_secret_key_compat( + input: &str, +) -> Result<(SecretKey, KeyInputEncoding), IdentityError> { + let input = input.trim(); + let input = strip_nostr_scheme(input); + if input + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("nsec1")) + { + if has_mixed_ascii_case(input) { + return Err(IdentityError::InvalidSecretKey); + } + let canonical = input.to_ascii_lowercase(); + return SecretKey::from_bech32(&canonical) + .map(|key| (key, KeyInputEncoding::Nip19)) + .map_err(|_| IdentityError::InvalidSecretKey); + } + if input.len() == 64 && input.chars().all(|character| character.is_ascii_hexdigit()) { + return SecretKey::from_hex(input) + .map(|key| (key, KeyInputEncoding::LegacyHex)) + .map_err(|_| IdentityError::InvalidSecretKey); + } + Err(IdentityError::InvalidSecretKey) +} + +/// Format a validated public key in canonical human-facing npub form. +pub fn public_key_to_npub(public_key: &PublicKey) -> Result { + public_key + .to_bech32() + .map_err(|_| IdentityError::PublicKeyEncoding) +} + +/// Normalize an npub (or compatibility-only legacy hex public key) to npub. +pub fn canonical_npub(input: &str) -> Result { + let (public_key, _) = parse_public_key_compat(input)?; + public_key_to_npub(&public_key) +} + +/// Format a string public identity for logs without ever falling back to hex. +pub fn canonical_npub_or_invalid(input: &str) -> String { + canonical_npub(input).unwrap_or_else(|_| INVALID_PUBLIC_KEY_DISPLAY.to_string()) +} + +/// Format a validated public identity for logs without ever falling back to hex. +pub fn public_key_to_npub_or_invalid(public_key: &PublicKey) -> String { + public_key_to_npub(public_key).unwrap_or_else(|_| INVALID_PUBLIC_KEY_DISPLAY.to_string()) +} + +/// Format protocol/database public-key bytes as npub. +pub fn public_key_bytes_to_npub(bytes: &[u8]) -> Result { + let public_key = PublicKey::from_slice(bytes).map_err(|_| IdentityError::InvalidPublicKey)?; + public_key + .xonly() + .map_err(|_| IdentityError::InvalidPublicKey)?; + public_key_to_npub(&public_key) +} + +/// Format protocol/database public-key bytes for logs without a raw fallback. +pub fn public_key_bytes_to_npub_or_invalid(bytes: &[u8]) -> String { + public_key_bytes_to_npub(bytes).unwrap_or_else(|_| INVALID_PUBLIC_KEY_DISPLAY.to_string()) +} + +/// Export a validated secret key in canonical nsec form. +/// +/// Call this only at an explicit secret export or protected persistence +/// boundary. An nsec is an encoding, not encryption, and must never be logged. +pub fn secret_key_to_nsec(secret_key: &SecretKey) -> Result { + secret_key + .to_bech32() + .map_err(|_| IdentityError::SecretKeyEncoding) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + #[test] + fn public_key_compat_normalizes_npub_and_hex() { + let keys = Keys::generate(); + let npub = public_key_to_npub(&keys.public_key()).unwrap(); + let hex = keys.public_key().to_hex(); + + let (from_npub, npub_encoding) = parse_public_key_compat(&npub).unwrap(); + let (from_hex, hex_encoding) = parse_public_key_compat(&hex.to_ascii_uppercase()).unwrap(); + + assert_eq!(from_npub, keys.public_key()); + assert_eq!(from_hex, keys.public_key()); + assert_eq!(npub_encoding, KeyInputEncoding::Nip19); + assert_eq!(hex_encoding, KeyInputEncoding::LegacyHex); + assert_eq!( + parse_public_key_compat(&format!("nostr:{npub}")).unwrap().0, + keys.public_key() + ); + assert_eq!( + parse_public_key_compat(&npub.to_ascii_uppercase()) + .unwrap() + .0, + keys.public_key() + ); + assert_eq!( + parse_public_key_compat(&format!("NOSTR:{}", npub.to_ascii_uppercase())) + .unwrap() + .0, + keys.public_key() + ); + let mixed_case = format!("N{}", &npub[1..]); + assert_eq!( + parse_public_key_compat(&mixed_case), + Err(IdentityError::InvalidPublicKey) + ); + } + + #[test] + fn public_key_compat_rejects_wrong_hrp_and_malformed_hex() { + let keys = Keys::generate(); + let nsec = secret_key_to_nsec(keys.secret_key()).unwrap(); + assert_eq!( + parse_public_key_compat(&nsec), + Err(IdentityError::InvalidPublicKey) + ); + assert_eq!( + parse_public_key_compat(&"f".repeat(63)), + Err(IdentityError::InvalidPublicKey) + ); + + // nostr's PublicKey parser decodes arbitrary 32-byte values; require + // that the bytes are an actual secp256k1 x-only public key too. + let invalid_point_hex = "ff".repeat(32); + let invalid_point_npub = PublicKey::from_hex(&invalid_point_hex) + .unwrap() + .to_bech32() + .unwrap(); + assert_eq!( + parse_public_key_compat(&invalid_point_hex), + Err(IdentityError::InvalidPublicKey) + ); + assert_eq!( + parse_public_key_compat(&invalid_point_npub), + Err(IdentityError::InvalidPublicKey) + ); + } + + #[test] + fn canonical_display_normalizes_legacy_hex_and_never_echoes_invalid_input() { + let keys = Keys::generate(); + let hex = keys.public_key().to_hex(); + let expected = public_key_to_npub(&keys.public_key()).unwrap(); + + assert_eq!(canonical_npub(&hex).unwrap(), expected); + assert_eq!(canonical_npub(&expected).unwrap(), expected); + assert_eq!(canonical_npub_or_invalid(&hex), expected); + assert_eq!(public_key_to_npub_or_invalid(&keys.public_key()), expected); + assert_eq!( + public_key_bytes_to_npub_or_invalid(keys.public_key().as_bytes()), + expected + ); + + let invalid = "012345-not-a-public-key"; + let displayed = canonical_npub_or_invalid(invalid); + assert_eq!(displayed, INVALID_PUBLIC_KEY_DISPLAY); + assert!(!displayed.contains(invalid)); + } + + #[test] + fn secret_key_compat_normalizes_nsec_and_hex_without_echoing_errors() { + let keys = Keys::generate(); + let nsec = secret_key_to_nsec(keys.secret_key()).unwrap(); + let hex = keys.secret_key().to_secret_hex(); + + let (from_nsec, nsec_encoding) = parse_secret_key_compat(&nsec).unwrap(); + let (from_hex, hex_encoding) = parse_secret_key_compat(&hex).unwrap(); + + assert_eq!(from_nsec, *keys.secret_key()); + assert_eq!(from_hex, *keys.secret_key()); + assert_eq!(nsec_encoding, KeyInputEncoding::Nip19); + assert_eq!(hex_encoding, KeyInputEncoding::LegacyHex); + assert_eq!( + parse_secret_key_compat(&nsec.to_ascii_uppercase()) + .unwrap() + .0, + *keys.secret_key() + ); + assert_eq!( + parse_secret_key_compat(&format!("NOSTR:{}", nsec.to_ascii_uppercase())) + .unwrap() + .0, + *keys.secret_key() + ); + let mixed_case = format!("N{}", &nsec[1..]); + assert_eq!( + parse_secret_key_compat(&mixed_case), + Err(IdentityError::InvalidSecretKey) + ); + + let secret = "not-a-secret"; + let error = parse_secret_key_compat(secret).unwrap_err().to_string(); + assert!(!error.contains(secret)); + assert_eq!(error, "expected a valid nsec"); + } +} diff --git a/crates/buzz-core/src/pairing/qr.rs b/crates/buzz-core/src/pairing/qr.rs index c6784dc17c0..7fb57eebe13 100644 --- a/crates/buzz-core/src/pairing/qr.rs +++ b/crates/buzz-core/src/pairing/qr.rs @@ -127,13 +127,13 @@ pub fn decode_qr(uri: &str) -> Result { // Validate pubkey: must be exactly 64 lowercase hex chars (NIP-AB §QR Code Format). if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(is_lowercase_hex) { - return Err(PairingError::InvalidQr(format!( - "pubkey must be 64 lowercase hex chars, got {:?}", - pubkey_hex - ))); + return Err(PairingError::InvalidQr( + "pubkey must be 64 lowercase hex chars".into(), + )); } - let source_pubkey = PublicKey::from_hex(pubkey_hex) - .map_err(|e| PairingError::InvalidQr(format!("invalid pubkey: {e}")))?; + let source_pubkey = PublicKey::from_hex(pubkey_hex).map_err(|_| { + PairingError::InvalidQr("pubkey is not a valid secp256k1 x-only public key".into()) + })?; // Parse query parameters. let mut secret_hex: Option<&str> = None; @@ -164,10 +164,9 @@ pub fn decode_qr(uri: &str) -> Result { .ok_or_else(|| PairingError::InvalidQr("missing 'secret' query parameter".into()))?; if secret_str.len() != 64 || !secret_str.chars().all(is_lowercase_hex) { - return Err(PairingError::InvalidQr(format!( - "secret must be 64 lowercase hex chars, got {:?}", - secret_str - ))); + return Err(PairingError::InvalidQr( + "secret must be 64 lowercase hex chars".into(), + )); } let secret_bytes = hex::decode(secret_str) .map_err(|e| PairingError::InvalidQr(format!("invalid secret hex: {e}")))?; @@ -355,6 +354,23 @@ mod tests { assert!(matches!(err, PairingError::InvalidQr(_))); } + #[test] + fn malformed_ephemeral_pubkey_error_does_not_echo_raw_input() { + let bad_pubkey = "not-a-public-key-containing-private-input"; + let secret = hex::encode([0xab; 32]); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}", + bad_pubkey, secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + assert!( + !err.to_string().contains(bad_pubkey), + "pairing errors must not echo the malformed ephemeral public key" + ); + } + // 6. Reject invalid hex in secret #[test] fn reject_invalid_secret_hex() { @@ -584,5 +600,9 @@ mod tests { matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")), "expected lowercase rejection for secret, got {err:?}" ); + assert!( + !err.to_string().contains(&secret_upper), + "pairing errors must not echo the session secret" + ); } } diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs index cccf0e6eca1..179b2d6990a 100644 --- a/crates/buzz-dev-mcp/src/shim.rs +++ b/crates/buzz-dev-mcp/src/shim.rs @@ -1,4 +1,4 @@ -use nostr::ToBech32; +use buzz_core::nostr_identity::{parse_secret_key_compat, public_key_to_npub, secret_key_to_nsec}; use std::path::{Path, PathBuf}; use tempfile::TempDir; use zeroize::Zeroize; @@ -88,24 +88,43 @@ fn write_keyfile(shim_dir: &Path, raw: &str) -> Option { if raw.is_empty() { return None; } - let keys = match nostr::Keys::parse(raw) { - Ok(k) => k, - Err(e) => { + let (secret_key, _) = match parse_secret_key_compat(raw) { + Ok(parsed) => parsed, + Err(_) => { eprintln!( - "buzz-dev-mcp: warning: NOSTR_PRIVATE_KEY is set but invalid ({e}); \ + "buzz-dev-mcp: warning: NOSTR_PRIVATE_KEY is set but invalid; \ git auth/signing will be disabled" ); return None; } }; + let keys = nostr::Keys::new(secret_key); let pubkey_hex = keys.public_key().to_hex(); - let npub = keys - .public_key() - .to_bech32() - .unwrap_or_else(|_| pubkey_hex.clone()); + let npub = match public_key_to_npub(&keys.public_key()) { + Ok(npub) => npub, + Err(_) => { + eprintln!( + "buzz-dev-mcp: warning: failed to encode public identity as npub; \ + git auth/signing will be disabled" + ); + return None; + } + }; + let mut nsec = match secret_key_to_nsec(keys.secret_key()) { + Ok(nsec) => nsec, + Err(_) => { + eprintln!( + "buzz-dev-mcp: warning: failed to encode private identity as nsec; \ + git auth/signing will be disabled" + ); + return None; + } + }; let keyfile = shim_dir.join(".nostr-key"); - if write_keyfile_atomic(&keyfile, raw.as_bytes()).is_err() { + let write_result = write_keyfile_atomic(&keyfile, nsec.as_bytes()); + nsec.zeroize(); + if write_result.is_err() { eprintln!( "buzz-dev-mcp: warning: failed to write nostr keyfile; git auth/signing disabled" ); @@ -693,3 +712,35 @@ mod git_user_name_tests { } } } + +#[cfg(test)] +mod keyfile_tests { + use super::write_keyfile; + use buzz_core::nostr_identity::secret_key_to_nsec; + + #[test] + fn write_keyfile_normalizes_legacy_secret_hex_to_nsec() { + let keys = nostr::Keys::generate(); + let legacy_hex = keys.secret_key().to_secret_hex(); + let expected_nsec = secret_key_to_nsec(keys.secret_key()).expect("secret key formats"); + let dir = tempfile::tempdir().expect("tempdir created"); + + let info = write_keyfile(dir.path(), &legacy_hex).expect("keyfile written"); + let persisted = std::fs::read_to_string(&info.keyfile_path).expect("keyfile readable"); + + assert_eq!(persisted, expected_nsec); + assert!(persisted.starts_with("nsec1")); + assert!(!persisted.contains(&legacy_hex)); + assert!(info.npub.starts_with("npub1")); + assert!(!info.npub.contains(&info.pubkey_hex)); + } + + #[test] + fn write_keyfile_rejects_invalid_secret_without_persisting_it() { + let invalid = "not-a-secret-value"; + let dir = tempfile::tempdir().expect("tempdir created"); + + assert!(write_keyfile(dir.path(), invalid).is_none()); + assert!(!dir.path().join(".nostr-key").exists()); + } +} diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index 441338ab127..54ea2a2ce71 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -13,6 +13,7 @@ use crate::paths::resolve_path; use crate::shell::SharedState; use base64::Engine; +use buzz_core::nostr_identity::{parse_secret_key_compat, KeyInputEncoding}; use image::{ codecs::{jpeg::JpegEncoder, png::PngEncoder}, DynamicImage, ExtendedColorType, ImageEncoder, ImageReader, Limits, @@ -26,6 +27,7 @@ use serde::Deserialize; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; +use zeroize::Zeroize; /// Hard cap on bytes we will read from disk / URL / data: URL. pub(crate) const MAX_SOURCE_BYTES: usize = 20 * 1024 * 1024; @@ -296,14 +298,20 @@ fn relay_media_get_auth(url: &reqwest::Url) -> Option { if !is_relay_media_url(url, &relay) { return None; } - let key = std::env::var("BUZZ_PRIVATE_KEY").ok()?; - let keys = match nostr::Keys::parse(&key) { - Ok(k) => k, - Err(e) => { - tracing::warn!("BUZZ_PRIVATE_KEY invalid; fetching relay media unauthenticated: {e}"); + let mut key = std::env::var("BUZZ_PRIVATE_KEY").ok()?; + let parsed = parse_secret_key_compat(&key); + key.zeroize(); + let (secret_key, encoding) = match parsed { + Ok(parsed) => parsed, + Err(_) => { + tracing::warn!("BUZZ_PRIVATE_KEY invalid; fetching relay media unauthenticated"); return None; } }; + if encoding == KeyInputEncoding::LegacyHex { + tracing::warn!("BUZZ_PRIVATE_KEY uses legacy secret hex; configure an nsec instead"); + } + let keys = nostr::Keys::new(secret_key); let authority = server_authority(url)?; match sign_media_get_auth(&keys, &authority) { Ok(header) => Some(header), diff --git a/crates/buzz-pairing-cli/src/main.rs b/crates/buzz-pairing-cli/src/main.rs index 1eb9d215f92..007736e9feb 100644 --- a/crates/buzz-pairing-cli/src/main.rs +++ b/crates/buzz-pairing-cli/src/main.rs @@ -16,6 +16,7 @@ use std::io::{self, BufRead, Write}; use std::time::Duration; use buzz_core::kind::KIND_PAIRING; +use buzz_core::nostr_identity::{parse_secret_key_compat, secret_key_to_nsec}; use buzz_core::pairing::session::PairingSession; use buzz_core::pairing::{ crypto::{derive_sas, derive_session_id, derive_transcript_hash, format_sas}, @@ -576,14 +577,17 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { /// Resolve the payload to send. /// -/// If `nsec` is provided, parse it as bech32 and return the raw nsec string. -/// Otherwise generate a fresh test key and return its nsec. +/// If a key is provided, normalize it to canonical nsec before transfer. +/// Legacy hex remains readable for compatibility but is never emitted. fn resolve_payload(nsec: Option) -> Result<(Zeroizing, PayloadType), CliError> { match nsec { Some(s) => { - // Validate it parses as a secret key. - let _sk = SecretKey::parse(&s).map_err(|e| CliError::InvalidNsec(e.to_string()))?; - Ok((Zeroizing::new(s), PayloadType::Nsec)) + let raw = Zeroizing::new(s); + let (secret_key, _) = parse_secret_key_compat(&raw) + .map_err(|_| CliError::InvalidNsec("expected a valid nsec".to_string()))?; + let canonical = secret_key_to_nsec(&secret_key) + .map_err(|_| CliError::InvalidNsec("failed to encode nsec".to_string()))?; + Ok((Zeroizing::new(canonical), PayloadType::Nsec)) } None => { let keys = Keys::generate(); @@ -597,6 +601,31 @@ fn resolve_payload(nsec: Option) -> Result<(Zeroizing, PayloadTy } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_normalizes_legacy_secret_hex_to_nsec() { + let keys = Keys::generate(); + let legacy_hex = keys.secret_key().to_secret_hex(); + + let (payload, payload_type) = resolve_payload(Some(legacy_hex.clone())).unwrap(); + + assert_eq!(payload_type, PayloadType::Nsec); + assert_eq!(payload.as_str(), keys.secret_key().to_bech32().unwrap()); + assert_ne!(payload.as_str(), legacy_hex); + } + + #[test] + fn payload_errors_do_not_echo_secret_input() { + let supplied = "not-a-valid-secret"; + let error = resolve_payload(Some(supplied.to_string())).unwrap_err(); + + assert!(!error.to_string().contains(supplied)); + } +} + /// Read a single line from stdin (trims trailing newline). fn read_line() -> Result { let stdin = io::stdin(); diff --git a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs index 7544ca09eae..3cf1b61c435 100644 --- a/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs +++ b/crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs @@ -58,6 +58,7 @@ use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +use buzz_core::nostr_identity::{public_key_to_npub, secret_key_to_nsec}; use buzz_test_client::BuzzTestClient; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use mesh_llm_host_runtime::crypto::{load_keystore, save_keystore, OwnerKeypair}; @@ -708,14 +709,12 @@ fn orchestrate() -> anyhow::Result<()> { // MEMBERSHIP: A and B become relay members via buzz-admin (publishes the // kind:13534 roster snapshot). C is deliberately not added. for (label, keys) in [("A", &member_a), ("B", &member_b)] { + let npub = public_key_to_npub(&keys.public_key())?; let status = Command::new(&admin) - .args(["add-member", "--pubkey", &keys.public_key().to_hex()]) + .args(["add-member", "--pubkey", &npub]) .status()?; anyhow::ensure!(status.success(), "buzz-admin add-member {label} failed"); - eprintln!( - "[lifecycle] member {label} added: {}", - keys.public_key().to_hex() - ); + eprintln!("[lifecycle] member {label} added: {npub}"); } // Isolated HOMEs (mesh-llm keeps node identity under ~/.mesh-llm), with @@ -733,14 +732,14 @@ fn orchestrate() -> anyhow::Result<()> { }; let exe = std::env::current_exe()?; - let secret_hex = |keys: &Keys| format!("{}", keys.secret_key().display_secret()); + let secret_nsec = |keys: &Keys| secret_key_to_nsec(keys.secret_key()); // SERVE child (member A). eprintln!("[lifecycle] starting SERVE member (relay-derived allowlist)..."); let mut serve_child = Command::new(&exe) .env("MESH_ROLE", "serve") .env("MESH_SMOKE_MODEL", &model) - .env("BUZZ_MEMBER_NSEC", secret_hex(&member_a)) + .env("BUZZ_MEMBER_NSEC", secret_nsec(&member_a)?) .env("MESH_OWNER_KEY", &serve_key) .env("MESH_EXPECTED_OWNERS", &expected_owners) .env("HOME", role_home("serve")?) @@ -765,7 +764,7 @@ fn orchestrate() -> anyhow::Result<()> { eprintln!("[lifecycle] starting CLIENT member (relay-driven join)..."); let mut client_child = Command::new(&exe) .env("MESH_ROLE", "client") - .env("BUZZ_MEMBER_NSEC", secret_hex(&member_b)) + .env("BUZZ_MEMBER_NSEC", secret_nsec(&member_b)?) .env("MESH_OWNER_KEY", &client_key) .env("HOME", role_home("client")?) .env("MESH_LLM_NATIVE_RUNTIME_CACHE_DIR", &native_cache) @@ -821,7 +820,7 @@ fn orchestrate() -> anyhow::Result<()> { eprintln!("[lifecycle] starting STRANGER (non-member, leaked endpoint)..."); let mut stranger_child = Command::new(&exe) .env("MESH_ROLE", "stranger") - .env("BUZZ_MEMBER_NSEC", secret_hex(&stranger)) + .env("BUZZ_MEMBER_NSEC", secret_nsec(&stranger)?) .env("MESH_OWNER_KEY", &stranger_key) .env("MESH_LEAKED_ENDPOINT", &endpoint) .env("HOME", role_home("stranger")?) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0a..32375db1d4f 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -20,6 +20,11 @@ use serde::{Deserialize, Serialize}; use tower_http::limit::RequestBodyLimitLayer; use uuid::Uuid; +use buzz_core::nostr_identity::canonical_npub_or_invalid; +use buzz_db::admin_moderation::{ + AdminFeedback, AdminReport, AdminReportDetail, AdminReportedMessage, +}; + pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap) -> bool { auth::is_admin_host(state, headers) } @@ -94,7 +99,7 @@ async fn reports( State(state): State>, headers: HeaderMap, Query(query): Query, -) -> Result>, ApiError> { +) -> Result>, ApiError> { authorize(&state, &headers)?; validate( query.status.as_deref(), @@ -119,30 +124,142 @@ async fn reports( limit(query.limit)?, ) .await?; - Ok(Json(items)) + Ok(Json( + items.into_iter().map(AdminReportResponse::from).collect(), + )) } async fn report_detail( State(state): State>, headers: HeaderMap, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { authorize(&state, &headers)?; state .db .admin_get_report(id) .await? + .map(AdminReportDetailResponse::from) .map(Json) .ok_or_else(ApiError::not_found) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminReportResponse { + id: Uuid, + community_id: Uuid, + community_host: String, + report_event_id: String, + /// Legacy protocol-hex field retained for existing dashboard clients. + reporter_pubkey: String, + /// Canonical public identity for new clients. + reporter_npub: String, + target_kind: String, + /// Legacy target value. Pubkey targets remain protocol hex; event and blob + /// targets retain their existing encodings. + target: String, + /// Canonical identity when `target_kind` is `pubkey`. + #[serde(skip_serializing_if = "Option::is_none")] + target_npub: Option, + channel_id: Option, + report_type: String, + note: Option, + status: String, + /// Legacy protocol-hex resolver retained for existing clients. + resolved_by: Option, + /// Canonical resolver identity for new clients. + #[serde(skip_serializing_if = "Option::is_none")] + resolved_by_npub: Option, + resolved_at: Option>, + action_id: Option, + created_at: DateTime, +} + +impl From for AdminReportResponse { + fn from(report: AdminReport) -> Self { + let target_npub = if report.target_kind == "pubkey" { + Some(canonical_npub_or_invalid(&report.target)) + } else { + None + }; + let reporter_npub = canonical_npub_or_invalid(&report.reporter_pubkey); + let resolved_by_npub = report.resolved_by.as_deref().map(canonical_npub_or_invalid); + Self { + id: report.id, + community_id: report.community_id, + community_host: report.community_host, + report_event_id: report.report_event_id, + reporter_pubkey: report.reporter_pubkey, + reporter_npub, + target_kind: report.target_kind, + target: report.target, + target_npub, + channel_id: report.channel_id, + report_type: report.report_type, + note: report.note, + status: report.status, + resolved_by: report.resolved_by, + resolved_by_npub, + resolved_at: report.resolved_at, + action_id: report.action_id, + created_at: report.created_at, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminReportedMessageResponse { + /// Legacy protocol-hex field retained for existing dashboard clients. + author_pubkey: String, + /// Canonical author identity for new clients. + author_npub: String, + content: String, + created_at: DateTime, + deleted_at: Option>, +} + +impl From for AdminReportedMessageResponse { + fn from(message: AdminReportedMessage) -> Self { + let author_npub = canonical_npub_or_invalid(&message.author_pubkey); + Self { + author_pubkey: message.author_pubkey, + author_npub, + content: message.content, + created_at: message.created_at, + deleted_at: message.deleted_at, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminReportDetailResponse { + #[serde(flatten)] + report: AdminReportResponse, + message: Option, +} + +impl From for AdminReportDetailResponse { + fn from(detail: AdminReportDetail) -> Self { + Self { + report: detail.report.into(), + message: detail.message.map(Into::into), + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct FeedbackSummary { id: Uuid, community_id: Uuid, community_host: String, + /// Legacy protocol-hex field retained for existing dashboard clients. submitter_pubkey: String, + /// Canonical submitter identity for new clients. + submitter_npub: String, category: Option, body_summary: String, received_at: DateTime, @@ -160,11 +277,13 @@ async fn feedback( .into_iter() .map(|item| { let body_summary = summarize_body(&item.body, &item.tags); + let submitter_npub = canonical_npub_or_invalid(&item.submitter_pubkey); FeedbackSummary { id: item.id, community_id: item.community_id, community_host: item.community_host, submitter_pubkey: item.submitter_pubkey, + submitter_npub, category: item.category, body_summary, received_at: item.received_at, @@ -178,16 +297,54 @@ async fn feedback_detail( State(state): State>, headers: HeaderMap, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { authorize(&state, &headers)?; state .db .admin_get_feedback(id) .await? + .map(AdminFeedbackResponse::from) .map(Json) .ok_or_else(ApiError::not_found) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminFeedbackResponse { + id: Uuid, + community_id: Uuid, + community_host: String, + event_id: String, + /// Legacy protocol-hex field retained for existing dashboard clients. + submitter_pubkey: String, + /// Canonical submitter identity for new clients. + submitter_npub: String, + category: Option, + body: String, + tags: serde_json::Value, + event_created_at: DateTime, + received_at: DateTime, +} + +impl From for AdminFeedbackResponse { + fn from(feedback: AdminFeedback) -> Self { + let submitter_npub = canonical_npub_or_invalid(&feedback.submitter_pubkey); + Self { + id: feedback.id, + community_id: feedback.community_id, + community_host: feedback.community_host, + event_id: feedback.event_id, + submitter_pubkey: feedback.submitter_pubkey, + submitter_npub, + category: feedback.category, + body: feedback.body, + tags: feedback.tags, + event_created_at: feedback.event_created_at, + received_at: feedback.received_at, + } + } +} + async fn feedback_attachment( State(state): State>, headers: HeaderMap, @@ -544,4 +701,138 @@ mod tests { assert!(!is_sha256(&HASH[..63])); assert!(!is_sha256(&format!("{HASH}.png"))); } + + #[test] + fn admin_report_projection_adds_npub_without_changing_legacy_fields() { + let keys = nostr::Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let expected_npub = + buzz_core::nostr_identity::public_key_to_npub(&keys.public_key()).expect("npub"); + let now = Utc::now(); + let report = AdminReport { + id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + community_host: "community.example".to_string(), + report_event_id: HASH.to_string(), + reporter_pubkey: pubkey_hex.clone(), + target_kind: "pubkey".to_string(), + target: pubkey_hex.clone(), + channel_id: Some(Uuid::new_v4()), + report_type: "spam".to_string(), + note: Some("report note".to_string()), + status: "resolved".to_string(), + resolved_by: Some(pubkey_hex.clone()), + resolved_at: Some(now), + action_id: Some(Uuid::new_v4()), + created_at: now, + }; + + let value = serde_json::to_value(AdminReportResponse::from(report.clone())) + .expect("serialize report response"); + assert_eq!(value["reporterPubkey"], pubkey_hex); + assert_eq!(value["reporterNpub"], expected_npub); + assert_eq!(value["target"], pubkey_hex); + assert_eq!(value["targetNpub"], expected_npub); + assert_eq!(value["resolvedBy"], pubkey_hex); + assert_eq!(value["resolvedByNpub"], expected_npub); + assert_eq!(value["reportEventId"], HASH); + + let event_target = AdminReportResponse::from(AdminReport { + target_kind: "event".to_string(), + target: HASH.to_string(), + ..report + }); + assert_eq!(event_target.target, HASH); + } + + #[test] + fn admin_detail_and_feedback_add_npub_without_changing_event_data() { + let keys = nostr::Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let expected_npub = + buzz_core::nostr_identity::public_key_to_npub(&keys.public_key()).expect("npub"); + let now = Utc::now(); + let report = AdminReport { + id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + community_host: "community.example".to_string(), + report_event_id: HASH.to_string(), + reporter_pubkey: pubkey_hex.clone(), + target_kind: "event".to_string(), + target: HASH.to_string(), + channel_id: None, + report_type: "spam".to_string(), + note: None, + status: "open".to_string(), + resolved_by: None, + resolved_at: None, + action_id: None, + created_at: now, + }; + let detail = AdminReportDetailResponse::from(AdminReportDetail { + report, + message: Some(AdminReportedMessage { + author_pubkey: pubkey_hex.clone(), + content: "message".to_string(), + created_at: now, + deleted_at: None, + }), + }); + let detail_value = serde_json::to_value(detail).expect("serialize report detail response"); + assert_eq!(detail_value["message"]["authorPubkey"], pubkey_hex); + assert_eq!(detail_value["message"]["authorNpub"], expected_npub); + assert_eq!(detail_value["target"], HASH); + + let tags = serde_json::json!([["e", HASH]]); + let feedback = AdminFeedbackResponse::from(AdminFeedback { + id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + community_host: "community.example".to_string(), + event_id: HASH.to_string(), + submitter_pubkey: pubkey_hex.clone(), + category: Some("bug".to_string()), + body: "feedback".to_string(), + tags: tags.clone(), + event_created_at: now, + received_at: now, + }); + let feedback_value = serde_json::to_value(feedback).expect("serialize feedback response"); + assert_eq!(feedback_value["submitterPubkey"], pubkey_hex); + assert_eq!(feedback_value["submitterNpub"], expected_npub); + assert_eq!(feedback_value["eventId"], HASH); + assert_eq!(feedback_value["tags"], tags); + } + + #[test] + fn admin_identity_projection_fails_closed_for_an_invalid_curve_point() { + let now = Utc::now(); + let response = AdminReportResponse::from(AdminReport { + id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + community_host: "community.example".to_string(), + report_event_id: HASH.to_string(), + reporter_pubkey: "ff".repeat(32), + target_kind: "pubkey".to_string(), + target: "ff".repeat(32), + channel_id: None, + report_type: "spam".to_string(), + note: None, + status: "open".to_string(), + resolved_by: None, + resolved_at: None, + action_id: None, + created_at: now, + }); + + assert_eq!( + response.reporter_npub, + buzz_core::nostr_identity::INVALID_PUBLIC_KEY_DISPLAY + ); + assert_eq!( + response.target_npub.as_deref(), + Some(buzz_core::nostr_identity::INVALID_PUBLIC_KEY_DISPLAY) + ); + assert_eq!(response.reporter_pubkey, "ff".repeat(32)); + assert_eq!(response.target, "ff".repeat(32)); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index dfce484494a..42b0219c8dc 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -14,7 +14,12 @@ use base64::Engine; use serde_json::Value; use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; -use buzz_core::TenantContext; +use buzz_core::{ + nostr_identity::{ + parse_public_key_compat, public_key_bytes_to_npub_or_invalid, public_key_to_npub_or_invalid, + }, + TenantContext, +}; use crate::handlers::ingest::{IngestAuth, IngestError}; use crate::state::AppState; @@ -116,9 +121,15 @@ pub(crate) fn verify_bridge_auth_with_options( // Dev-mode fallback: X-Pubkey header (only when require_auth_token is false) if !require_auth_token { - if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) { - let pubkey = nostr::PublicKey::from_hex(hex_val) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; + if let Some(value) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) { + let pubkey = parse_public_key_compat(value) + .map(|(public_key, _)| public_key) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid X-Pubkey: expected an npub", + ) + })?; // Zero event ID — no replay detection needed for dev mode return Ok((pubkey, [0u8; 32])); } @@ -644,7 +655,7 @@ pub async fn submit_event( Some(&body), state.config.require_auth_token, )?; - let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = public_key_to_npub_or_invalid(&pubkey); // Everything after auth — admission, replay, membership, parse, ingest — // runs inside the helper. The thin wrapper here owns the single terminal @@ -656,7 +667,7 @@ pub async fn submit_event( match &outcome { SubmitOutcome::Ok { accepted, kind, .. } => { tracing::info!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/events", status = 200u16, accepted, @@ -671,7 +682,7 @@ pub async fn submit_event( .. } => { tracing::warn!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/events", status = 400u16, accepted = false, @@ -683,7 +694,7 @@ pub async fn submit_event( } SubmitOutcome::Rejected { kind, reason, .. } => { tracing::warn!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/events", status = 400u16, accepted = false, @@ -694,7 +705,7 @@ pub async fn submit_event( } SubmitOutcome::Err { status, .. } => { tracing::warn!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/events", status = status.as_u16(), accepted = false, @@ -915,7 +926,7 @@ pub async fn query_events( Some(&body), state.config.require_auth_token, )?; - let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = public_key_to_npub_or_invalid(&pubkey); // Admission, replay, membership, and filter execution all run inside the // helper. The single terminal attribution line fires here from the Result @@ -926,7 +937,7 @@ pub async fn query_events( match &result { Ok(Json(Value::Array(events))) => { tracing::info!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/query", status = 200u16, result_count = events.len(), @@ -934,11 +945,11 @@ pub async fn query_events( ); } Ok(_) => { - tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request"); + tracing::info!(pubkey = %pubkey_npub, route = "/query", status = 200u16, "HTTP bridge request"); } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/query", status = status.as_u16(), "HTTP bridge request" @@ -1358,7 +1369,7 @@ pub async fn count_events( Some(&body), state.config.require_auth_token, )?; - let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = public_key_to_npub_or_invalid(&pubkey); // Admission, replay, membership, and count execution all run inside the // helper. The single terminal attribution line fires here from the Result @@ -1370,7 +1381,7 @@ pub async fn count_events( Ok(Json(value)) => { let count = value.get("count").and_then(Value::as_u64); tracing::info!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/count", status = 200u16, result_count = count, @@ -1379,7 +1390,7 @@ pub async fn count_events( } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, route = "/count", status = status.as_u16(), "HTTP bridge request" @@ -2117,6 +2128,11 @@ async fn authorize_moderation_read( /// Cap on rows returned by a single moderation read. const MODERATION_READ_LIMIT: i64 = 500; +// Moderation response compatibility: established identity fields retain +// lowercase protocol hex for existing HTTP/CLI consumers. Additive npub fields +// are canonical and preferred by current clients. Event IDs and blob hashes +// remain hex because they are not public identities. + /// Optional `?status=` and `?limit=` query for moderation reads. #[derive(serde::Deserialize, Default)] pub struct ModerationReadQuery { @@ -2191,22 +2207,31 @@ pub async fn moderation_restricted( } fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { - let (target_kind, target) = match &r.target { - buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id)), - buzz_db::moderation::ReportTarget::Pubkey(pk) => ("pubkey", hex::encode(pk)), - buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha)), + let (target_kind, target, target_npub) = match &r.target { + buzz_db::moderation::ReportTarget::Event(id) => ("event", hex::encode(id), None), + buzz_db::moderation::ReportTarget::Pubkey(pk) => ( + "pubkey", + hex::encode(pk), + Some(public_key_bytes_to_npub_or_invalid(pk)), + ), + buzz_db::moderation::ReportTarget::Blob(sha) => ("blob", hex::encode(sha), None), }; serde_json::json!({ "id": r.id, "report_event_id": hex::encode(&r.report_event_id), + // Existing response fields retain protocol hex. Additive `*_npub` + // fields are the canonical identity surface for current clients. "reporter_pubkey": hex::encode(&r.reporter_pubkey), + "reporter_npub": public_key_bytes_to_npub_or_invalid(&r.reporter_pubkey), "target_kind": target_kind, "target": target, + "target_npub": target_npub, "channel_id": r.channel_id, "report_type": r.report_type, "note": r.note, "status": r.status, "resolved_by": r.resolved_by.as_ref().map(hex::encode), + "resolved_by_npub": r.resolved_by.as_ref().map(|value| public_key_bytes_to_npub_or_invalid(value)), "resolved_at": r.resolved_at, "action_id": r.action_id, "created_at": r.created_at, @@ -2217,8 +2242,10 @@ fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value { serde_json::json!({ "id": a.id, "actor_pubkey": hex::encode(&a.actor_pubkey), + "actor_npub": public_key_bytes_to_npub_or_invalid(&a.actor_pubkey), "action": a.action, "target_pubkey": a.target_pubkey.as_ref().map(hex::encode), + "target_npub": a.target_pubkey.as_ref().map(|value| public_key_bytes_to_npub_or_invalid(value)), "target_event_id": a.target_event_id.as_ref().map(hex::encode), "channel_id": a.channel_id, "reason_code": a.reason_code, @@ -2232,12 +2259,14 @@ fn action_json(a: &buzz_db::moderation::ActionRecord) -> Value { fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { serde_json::json!({ "pubkey": hex::encode(&b.pubkey), + "npub": public_key_bytes_to_npub_or_invalid(&b.pubkey), "banned": b.banned, "ban_expires_at": b.ban_expires_at, "ban_reason": b.ban_reason, "muted_until": b.muted_until, "mute_reason": b.mute_reason, "actor_pubkey": hex::encode(&b.actor_pubkey), + "actor_npub": public_key_bytes_to_npub_or_invalid(&b.actor_pubkey), "updated_at": b.updated_at, }) } @@ -2255,6 +2284,101 @@ mod tests { .expect("create redis pool") } + #[test] + fn moderation_projections_add_npub_without_changing_legacy_hex() { + let identity = Keys::generate().public_key().to_bytes().to_vec(); + let identity_hex = hex::encode(&identity); + let now = chrono::Utc::now(); + + let report = buzz_db::moderation::ReportRecord { + id: uuid::Uuid::new_v4(), + report_event_id: vec![0x11; 32], + reporter_pubkey: identity.clone(), + target: buzz_db::moderation::ReportTarget::Pubkey(identity.clone()), + channel_id: None, + report_type: "spam".into(), + note: None, + status: "resolved".into(), + resolved_by: Some(identity.clone()), + resolved_at: Some(now), + action_id: None, + created_at: now, + }; + let report = report_json(&report); + for field in ["reporter_pubkey", "target", "resolved_by"] { + assert_eq!(report[field], identity_hex); + } + for field in ["reporter_npub", "target_npub", "resolved_by_npub"] { + assert!(report[field].as_str().unwrap().starts_with("npub1")); + } + + let action = buzz_db::moderation::ActionRecord { + id: uuid::Uuid::new_v4(), + actor_pubkey: identity.clone(), + action: "ban".into(), + target_pubkey: Some(identity.clone()), + target_event_id: Some(vec![0x22; 32]), + channel_id: None, + reason_code: None, + public_reason: None, + private_reason: None, + matched_principal: Some("self".into()), + created_at: now, + }; + let action = action_json(&action); + assert_eq!(action["actor_pubkey"], identity_hex); + assert_eq!(action["target_pubkey"], identity_hex); + assert!(action["actor_npub"].as_str().unwrap().starts_with("npub1")); + assert!(action["target_npub"].as_str().unwrap().starts_with("npub1")); + assert_eq!(action["target_event_id"], hex::encode(vec![0x22; 32])); + + let ban = buzz_db::moderation::BanRecord { + pubkey: identity.clone(), + banned: true, + ban_expires_at: None, + ban_reason: None, + muted_until: None, + mute_reason: None, + actor_pubkey: identity, + updated_at: now, + }; + let ban = ban_json(&ban); + assert_eq!(ban["pubkey"], identity_hex); + assert_eq!(ban["actor_pubkey"], identity_hex); + assert!(ban["npub"].as_str().unwrap().starts_with("npub1")); + assert!(ban["actor_npub"].as_str().unwrap().starts_with("npub1")); + } + + #[test] + fn moderation_additive_npub_fields_fail_closed_for_invalid_curve_points() { + let invalid = vec![0xff; 32]; + let report = report_json(&buzz_db::moderation::ReportRecord { + id: uuid::Uuid::new_v4(), + report_event_id: vec![0x11; 32], + reporter_pubkey: invalid.clone(), + target: buzz_db::moderation::ReportTarget::Pubkey(invalid.clone()), + channel_id: None, + report_type: "spam".into(), + note: None, + status: "open".into(), + resolved_by: Some(invalid), + resolved_at: None, + action_id: None, + created_at: chrono::Utc::now(), + }); + + let invalid_hex = "ff".repeat(32); + for field in ["reporter_pubkey", "target", "resolved_by"] { + assert_eq!(report[field], invalid_hex); + } + for field in ["reporter_npub", "target_npub", "resolved_by_npub"] { + assert_eq!( + report[field], + buzz_core::nostr_identity::INVALID_PUBLIC_KEY_DISPLAY + ); + } + } + fn fresh_tenant(host: &str) -> TenantContext { TenantContext::resolved( buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()), @@ -3705,9 +3829,12 @@ mod tests { "expected exactly 1 attribution line for invalid-JSON arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), + log.contains(&buzz_core::nostr_identity::canonical_npub_or_invalid( + &pubkey_hex + )), "attribution line must carry the pubkey;\nlog:\n{log}" ); + assert!(!log.contains(&pubkey_hex)); } /// T3b — exactly-once invariant, post-parse IngestError::Rejected arm (relay-only kind). @@ -3765,8 +3892,11 @@ mod tests { "expected exactly 1 attribution line for IngestError::Rejected arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), + log.contains(&buzz_core::nostr_identity::canonical_npub_or_invalid( + &pubkey_hex + )), "attribution line must carry the pubkey;\nlog:\n{log}" ); + assert!(!log.contains(&pubkey_hex)); } } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 3b2241046a3..3875611a527 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -37,7 +37,10 @@ use super::hydrate::{ }; use super::manifest_event::{build_ref_state_event, RefStateInputs}; use crate::state::AppState; -use buzz_core::TenantContext; +use buzz_core::{ + nostr_identity::{canonical_npub_or_invalid, public_key_to_npub_or_invalid}, + TenantContext, +}; /// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`). const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); @@ -220,7 +223,7 @@ impl axum::extract::FromRequestParts> for GitAuth { .await .is_err() { - warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); + warn!(pubkey = %public_key_to_npub_or_invalid(&pubkey), "git: relay membership denied"); return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } @@ -263,8 +266,8 @@ async fn deny_banned_git_principal( enforce_git_ban_cascade(&agent, owner_state.as_ref()).map_err(|status| { warn!( - pubkey = %pubkey.to_hex(), - owner = ?owner.map(|owner| owner.to_hex()), + pubkey = %public_key_to_npub_or_invalid(pubkey), + owner = ?owner.as_ref().map(public_key_to_npub_or_invalid), "git: community ban denied request" ); (status, "blocked: banned from this community").into_response() @@ -284,7 +287,7 @@ async fn git_restriction_state( db.moderation_restriction_state(community, pubkey.as_bytes()) .await .map_err(|error| { - warn!(pubkey = %pubkey.to_hex(), error = %error, "git: ban lookup failed closed"); + warn!(pubkey = %public_key_to_npub_or_invalid(pubkey), error = %error, "git: ban lookup failed closed"); (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() }) } @@ -427,7 +430,7 @@ fn acquire_git_permit( /// paths share. Below-pointer failure ⇒ 5xx; pointer-absent is signalled /// via `Ok(None)` from [`hydrate_for_read`] and never reaches this fn. fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Response { - error!(error = %err, owner = %owner, repo = %repo, "hydrate failed"); + error!(error = %err, owner = %canonical_npub_or_invalid(owner), repo = %repo, "hydrate failed"); if matches!(err, HydrateError::ResourceLimit(_)) { return ( StatusCode::PAYLOAD_TOO_LARGE, @@ -1819,6 +1822,7 @@ async fn finalize_push_inner( #[cfg(not(test))] let _ = hooks; + let owner_npub = canonical_npub_or_invalid(&ctx.owner); // The push fence, part 0 — **a rejected push publishes nothing.** // // `ctx.pack.ok` is false when git aborted the ref updates: either the @@ -1839,7 +1843,7 @@ async fn finalize_push_inner( // hook's decline message; only the publish side effects are suppressed. if !ctx.pack.ok { warn!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo_id, "receive-pack exited non-zero (e.g. pre-receive hook decline); \ skipping CAS publish and kind:30618 — no state published" @@ -1861,7 +1865,7 @@ async fn finalize_push_inner( { Ok(guard) => guard, Err(error) => { - warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence"); + warn!(owner = %owner_npub, repo = %ctx.repo, %error, "push rejected by community deletion fence"); return ( StatusCode::SERVICE_UNAVAILABLE, "community writes are fenced", @@ -1871,7 +1875,7 @@ async fn finalize_push_inner( }; if let Err(error) = serving_write.verify().await { - warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease"); + warn!(owner = %owner_npub, repo = %ctx.repo, %error, "push lost community serving lease"); return ( StatusCode::SERVICE_UNAVAILABLE, "community write lease lost", @@ -1904,7 +1908,7 @@ async fn finalize_push_inner( .. }) => { warn!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo, winner = %winner_manifest_key, "push lost CAS race; tempdir dropped, returning 409" @@ -1921,7 +1925,7 @@ async fn finalize_push_inner( // empty head, malformed parent). Pre-CAS — no pointer was // written. warn!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo, error = %e, "push rejected: manifest validation failed" @@ -1934,7 +1938,7 @@ async fn finalize_push_inner( } Err(CasError::ResourceLimit(e)) => { warn!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo, error = %e, "push rejected: repo exceeds relay resource limits" @@ -1952,7 +1956,7 @@ async fn finalize_push_inner( // winner-fetch, the winner is already installed and the // loser's data is unrelated). error!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo, error = %e, "push failed pre-response" @@ -1961,7 +1965,7 @@ async fn finalize_push_inner( } }, Err(error) => { - warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease during CAS publish"); + warn!(owner = %owner_npub, repo = %ctx.repo, %error, "push lost community serving lease during CAS publish"); return ( StatusCode::SERVICE_UNAVAILABLE, "community write lease lost", @@ -2038,7 +2042,7 @@ async fn finalize_push_inner( ) .await; info!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo_id, manifest = %success.manifest_key, "kind:30618 published (derived after CAS)" @@ -2047,7 +2051,7 @@ async fn finalize_push_inner( } Ok((_, false)) => { info!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo_id, "kind:30618 deduplicated by relay db" ); @@ -2066,7 +2070,7 @@ async fn finalize_push_inner( // acquisition cannot overtake the pointer CAS, durable 30618 insert, or // local fan-out attempt; only now may the lease be released. if let Err(error) = serving_write.finish().await { - warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "failed to release community serving lease after push publication"); + warn!(owner = %owner_npub, repo = %ctx.repo, %error, "failed to release community serving lease after push publication"); return ( StatusCode::SERVICE_UNAVAILABLE, "community write lease lost during publication", @@ -2075,7 +2079,7 @@ async fn finalize_push_inner( } if let Err(error) = publication_result { error!( - owner = %ctx.owner, + owner = %owner_npub, repo = %ctx.repo_id, manifest = %success.manifest_key, %error, diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b1..d8510e37b3f 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -36,7 +36,11 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { use axum::{http::StatusCode, response::Json}; - use buzz_core::{tenant::CommunityId, TenantContext}; + use buzz_core::{ + nostr_identity::{canonical_npub_or_invalid, public_key_to_npub_or_invalid}, + tenant::CommunityId, + TenantContext, + }; use tracing::{debug, info}; use crate::state::AppState; @@ -69,6 +73,7 @@ pub mod relay_members { } let pubkey_hex = hex::encode(pubkey_bytes); + let agent_npub = canonical_npub_or_invalid(&pubkey_hex); let is_member = state .db .is_relay_member(community, &pubkey_hex) @@ -93,15 +98,15 @@ pub mod relay_members { .map_err(|e| format!("relay membership check (owner) failed: {e}"))?; if owner_is_member { debug!( - agent = %pubkey_hex, - owner = %owner_hex, + agent = %agent_npub, + owner = %public_key_to_npub_or_invalid(&owner_pubkey), "NIP-OA membership granted via owner" ); return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } Err(e) => { - info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); + info!(agent = %agent_npub, "NIP-OA auth tag invalid: {e}"); } } } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874c..db9e4e69f0f 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -3,6 +3,11 @@ //! These routes are outside the Nostr event data plane. They still use NIP-98 //! request signing and replay protection, but they do not run through event //! ingest, relay membership, channel scoping, storage, or fan-out. +//! +//! Response identity fields are additive for compatibility. Fields whose +//! existing names contain `pubkey` (plus `previous_owner`) retain lowercase +//! protocol hex; adjacent `*_npub` fields are canonical and preferred by new +//! clients. Inputs accept npub and legacy hex during migration. use std::sync::Arc; @@ -15,10 +20,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; -use buzz_core::{CommunityId, TenantContext}; +use buzz_core::{nostr_identity::canonical_npub_or_invalid, CommunityId, TenantContext}; use crate::handlers::community_provisioning::{ - normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, + normalize_candidate_host, parse_pubkey_to_hex, ProvisionCommunityRequest, }; use crate::state::AppState; @@ -36,6 +41,15 @@ pub struct CommunityAvailabilityQuery { host: String, } +fn owned_communities_response(owner_pubkey: String, communities: Vec) -> Value { + let owner_npub = canonical_npub_or_invalid(&owner_pubkey); + serde_json::json!({ + "owner_pubkey": owner_pubkey, + "owner_npub": owner_npub, + "communities": communities, + }) +} + #[derive(Debug, Deserialize)] struct TransferCommunityRequest { community_id: String, @@ -46,10 +60,35 @@ struct TransferCommunityRequest { #[derive(Debug, Serialize)] struct TransferCommunityResponse { community_id: String, + /// Legacy protocol-hex field retained for existing operator clients. new_owner_pubkey: String, + /// Canonical public identity for new operator clients. + new_owner_npub: String, status: &'static str, + /// Legacy protocol-hex field retained for existing operator clients. + #[serde(skip_serializing_if = "Option::is_none")] + previous_owner: Option, + /// Canonical public identity for new operator clients. #[serde(skip_serializing_if = "Option::is_none")] + previous_owner_npub: Option, +} + +fn transfer_community_response( + community_id: String, + new_owner_pubkey: String, + status: &'static str, previous_owner: Option, +) -> TransferCommunityResponse { + let new_owner_npub = canonical_npub_or_invalid(&new_owner_pubkey); + let previous_owner_npub = previous_owner.as_deref().map(canonical_npub_or_invalid); + TransferCommunityResponse { + community_id, + new_owner_pubkey, + new_owner_npub, + status, + previous_owner, + previous_owner_npub, + } } const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; @@ -140,7 +179,7 @@ async fn check_operator_replay( /// `RELAY_OPERATOR_PUBKEYS`, body: /// /// ```json -/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } /// ``` /// /// The request is authenticated against `RELAY_OPERATOR_API_ORIGIN` and does @@ -222,10 +261,10 @@ pub async fn archive_community( "the deployment community cannot be archived", )); } - let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + let owner = parse_pubkey_to_hex(&request.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, - "invalid owner_pubkey: expected 64-char hex pubkey", + "invalid owner_pubkey: expected an npub", ) })?; let record = state @@ -277,10 +316,10 @@ pub async fn unarchive_community( })?; let normalized_host = normalize_candidate_host(&request.host) .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; - let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { + let owner = parse_pubkey_to_hex(&request.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, - "invalid owner_pubkey: expected 64-char hex pubkey", + "invalid owner_pubkey: expected an npub", ) })?; let record = state @@ -315,10 +354,10 @@ pub async fn list_owned_communities( ) .await?; - let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| { + let owner_pubkey = parse_pubkey_to_hex(&query.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, - "invalid owner_pubkey: expected 64-char hex pubkey", + "invalid owner_pubkey: expected an npub", ) })?; @@ -328,15 +367,18 @@ pub async fn list_owned_communities( .await .map_err(|e| internal_error(&format!("list owned communities: {e}")))?; - Ok(Json(serde_json::json!({ - "owner_pubkey": owner_pubkey, - "communities": rows.into_iter().map(|row| serde_json::json!({ - "community_id": row.id.to_string(), - "host": row.host, - "created_at": row.created_at, - "archived_at": row.archived_at, - })).collect::>(), - }))) + let communities = rows + .into_iter() + .map(|row| { + serde_json::json!({ + "community_id": row.id.to_string(), + "host": row.host, + "created_at": row.created_at, + "archived_at": row.archived_at, + }) + }) + .collect(); + Ok(Json(owned_communities_response(owner_pubkey, communities))) } /// Transfer ownership of a community to a new owner pubkey. @@ -345,7 +387,7 @@ pub async fn list_owned_communities( /// `RELAY_OPERATOR_PUBKEYS`, body: /// /// ```json -/// { "community_id": "", "new_owner_pubkey": "" } +/// { "community_id": "", "new_owner_pubkey": "" } /// ``` /// /// The previous owner is demoted to `member` (not `admin`). The transfer is @@ -380,18 +422,18 @@ pub async fn transfer_community( ) })?; - let new_owner_pubkey = validate_pubkey_hex(&request.new_owner_pubkey).ok_or_else(|| { + let new_owner_pubkey = parse_pubkey_to_hex(&request.new_owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, - "invalid new_owner_pubkey: expected 64-char hex pubkey", + "invalid new_owner_pubkey: expected an npub", ) })?; let expected_owner_pubkey = - validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(|| { + parse_pubkey_to_hex(&request.expected_owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, - "invalid expected_owner_pubkey: expected 64-char hex pubkey", + "invalid expected_owner_pubkey: expected an npub", ) })?; @@ -451,12 +493,12 @@ pub async fn transfer_community( } } - let response = TransferCommunityResponse { - community_id: request.community_id, + let response = transfer_community_response( + request.community_id, new_owner_pubkey, status, previous_owner, - }; + ); Ok(Json(serde_json::to_value(response).map_err(|e| { tracing::error!("failed to serialize transfer-community response: {e}"); @@ -501,6 +543,8 @@ pub async fn community_availability( mod tests { use std::sync::Arc; + use super::{owned_communities_response, transfer_community_response}; + use axum::{ body::{to_bytes, Body}, http::{header, Request, StatusCode}, @@ -512,7 +556,9 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; - use buzz_core::{kind::KIND_NIP43_MEMBERSHIP_LIST, CommunityId}; + use buzz_core::{ + kind::KIND_NIP43_MEMBERSHIP_LIST, nostr_identity::public_key_to_npub, CommunityId, + }; use buzz_db::event::EventQuery; use crate::router::build_router; @@ -536,6 +582,55 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 const INGRESS_HOST: &str = "operator-ingress.example"; + #[test] + fn operator_responses_preserve_legacy_hex_and_add_npub() { + let owner = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let owner_npub = public_key_to_npub(&owner.public_key()).expect("encode owner npub"); + + let communities = owned_communities_response( + owner_hex.clone(), + vec![serde_json::json!({ "id": "community" })], + ); + assert_eq!(communities["owner_pubkey"], owner_hex); + assert_eq!(communities["owner_npub"], owner_npub); + + let transfer = serde_json::to_value(transfer_community_response( + "community".to_string(), + owner.public_key().to_hex(), + "transferred", + Some(owner.public_key().to_hex()), + )) + .expect("serialize transfer response"); + assert_eq!(transfer["new_owner_pubkey"], owner.public_key().to_hex()); + assert_eq!( + transfer["new_owner_npub"], + public_key_to_npub(&owner.public_key()).expect("encode new owner npub") + ); + assert_eq!(transfer["previous_owner"], owner.public_key().to_hex()); + assert_eq!( + transfer["previous_owner_npub"], + public_key_to_npub(&owner.public_key()).expect("encode previous owner npub") + ); + } + + #[test] + fn operator_additive_npub_fails_closed_without_changing_legacy_hex() { + let invalid_hex = "ff".repeat(32); + let response = serde_json::to_value(transfer_community_response( + "community".to_string(), + invalid_hex.clone(), + "transferred", + Some(invalid_hex.clone()), + )) + .expect("serialize transfer response"); + + assert_eq!(response["new_owner_pubkey"], invalid_hex); + assert_eq!(response["new_owner_npub"], ""); + assert_eq!(response["previous_owner"], "ff".repeat(32)); + assert_eq!(response["previous_owner_npub"], ""); + } + fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { let mut tags = vec![ Tag::parse(["u", url]).expect("u tag"), @@ -658,7 +753,8 @@ mod tests { ) -> axum::response::Response { let body = serde_json::json!({ "host": host, - "initial_owner_pubkey": owner.public_key().to_hex(), + "initial_owner_pubkey": public_key_to_npub(&owner.public_key()) + .expect("encode owner npub"), "create_only": true, }) .to_string(); @@ -810,8 +906,8 @@ mod tests { let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { return; }; - let owner_hex = owner.public_key().to_hex(); - let query = format!("owner_pubkey={owner_hex}"); + let owner_npub = public_key_to_npub(&owner.public_key()).expect("encode owner npub"); + let query = format!("owner_pubkey={owner_npub}"); let url = format!("http://{INGRESS_HOST}/operator/communities?{query}"); let auth = nip98_auth_header(&operator, &url, "GET", None); @@ -829,10 +925,15 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; + let owner_hex = owner.public_key().to_hex(); assert_eq!( json.get("owner_pubkey").and_then(Value::as_str), Some(owner_hex.as_str()) ); + assert_eq!( + json.get("owner_npub").and_then(Value::as_str), + Some(owner_npub.as_str()) + ); } #[tokio::test] @@ -1135,11 +1236,15 @@ mod tests { let community_id = community.id.to_string(); let initial_owner_hex = initial_owner.public_key().to_hex(); let new_owner_hex = new_owner.public_key().to_hex(); + let initial_owner_npub = + public_key_to_npub(&initial_owner.public_key()).expect("encode initial owner npub"); + let new_owner_npub = + public_key_to_npub(&new_owner.public_key()).expect("encode new owner npub"); let transfer_body = serde_json::json!({ "community_id": community_id, - "new_owner_pubkey": new_owner_hex, - "expected_owner_pubkey": initial_owner_hex, + "new_owner_pubkey": new_owner_npub, + "expected_owner_pubkey": initial_owner_npub, }) .to_string(); let response = signed_operator_request( @@ -1161,10 +1266,18 @@ mod tests { json.get("new_owner_pubkey").and_then(Value::as_str), Some(new_owner_hex.as_str()) ); + assert_eq!( + json.get("new_owner_npub").and_then(Value::as_str), + Some(new_owner_npub.as_str()) + ); assert_eq!( json.get("previous_owner").and_then(Value::as_str), Some(initial_owner_hex.as_str()) ); + assert_eq!( + json.get("previous_owner_npub").and_then(Value::as_str), + Some(initial_owner_npub.as_str()) + ); // New owner is owner. assert_eq!( diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 4c158eab0c4..cce568802b9 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -31,7 +31,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::generate_challenge; -use buzz_core::tenant::TenantContext; +use buzz_core::{nostr_identity::public_key_to_npub_or_invalid, tenant::TenantContext}; use buzz_db::channel::MemberRole; use buzz_core::StoredEvent; @@ -243,6 +243,7 @@ async fn handle_active_audio_connection( let pubkey = auth_ctx.pubkey; let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = public_key_to_npub_or_invalid(&pubkey); let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; @@ -255,7 +256,7 @@ async fn handle_active_audio_connection( .await .is_err() { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied"); + warn!(channel_id = %channel_id, pubkey = %pubkey_npub, "audio: relay membership denied"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type": "error", "message": "restricted: not a relay member"}) @@ -278,7 +279,7 @@ async fn handle_active_audio_connection( { Ok(parent_id) => parent_id, Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}"); + warn!(channel_id = %channel_id, pubkey = %pubkey_npub, "audio membership denied: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type":"error","message":"not a member"}) @@ -340,7 +341,7 @@ async fn handle_active_audio_connection( Err(e) => { warn!( channel_id = %channel_id, - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, "huddle join rejected by fence: {e}" ); let _ = ws_send @@ -362,7 +363,7 @@ async fn handle_active_audio_connection( if !state.config.huddle_audio_available { debug!( channel_id = %channel_id, - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, "huddle audio unavailable under horizontal scaling — rejecting join" ); let _ = ws_send @@ -422,7 +423,7 @@ async fn handle_active_audio_connection( if requested_version == 0 || requested_version > CURRENT_PROTOCOL_VERSION { warn!( channel_id = %channel_id, - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, requested_version, current = CURRENT_PROTOCOL_VERSION, "audio: client requested unsupported protocol version" @@ -478,7 +479,7 @@ async fn handle_active_audio_connection( remote_fence = Some(Arc::clone(&mesh.audio_fence)); } Err(crate::audio::join::DialError::Rejected(reason)) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner rejected registration: {reason:?}"); + warn!(channel_id = %channel_id, pubkey = %pubkey_npub, "huddle owner rejected registration: {reason:?}"); let _ = ws_send .send(WsMessage::Text( remote_rejection_ws_error(&reason).to_string().into(), @@ -490,7 +491,7 @@ async fn handle_active_audio_connection( return; } Err(crate::audio::join::DialError::Mesh(e)) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner registration failed: {e}"); + warn!(channel_id = %channel_id, pubkey = %pubkey_npub, "huddle owner registration failed: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({ @@ -538,7 +539,7 @@ async fn handle_active_audio_connection( return; } Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { - info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); + info!(channel_id = %channel_id, pubkey = %pubkey_npub, pinned, requested, "audio: protocol version mismatch — upgrade required"); let _ = ws_send.send(WsMessage::Text(serde_json::json!({ "type": "error", "code": "upgrade_required", "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), @@ -555,7 +556,7 @@ async fn handle_active_audio_connection( info!( channel_id = %channel_id, - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, peer_index, "audio peer joined" ); @@ -890,7 +891,7 @@ async fn handle_active_audio_connection( info!( channel_id = %channel_id, - pubkey = %pubkey_hex, + pubkey = %pubkey_npub, "audio peer left" ); } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..ba3a23b078a 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -7,6 +7,10 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use tracing::warn; +use buzz_core::nostr_identity::{ + parse_public_key_compat, parse_secret_key_compat, secret_key_to_nsec, KeyInputEncoding, +}; + /// Default maximum inbound WebSocket frame size in bytes. /// /// Must comfortably exceed accepted event content sizes after Nostr JSON and @@ -51,7 +55,7 @@ pub struct JoinPolicyConfig { pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; /// Relay runtime configuration, loaded from environment variables. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Config { /// Address the relay HTTP/WebSocket server binds to. pub bind_addr: SocketAddr, @@ -122,7 +126,7 @@ pub struct Config { /// If empty, permissive CORS is used (dev mode). /// Example: "tauri://localhost,http://localhost:3000" pub cors_origins: Vec, - /// Optional hex-encoded private key for the relay's signing keypair. + /// Optional canonical nsec private key for the relay's signing keypair. /// If absent, a fresh keypair is generated at startup. pub relay_private_key: Option, /// Optional Unix Domain Socket path. When set, the relay also listens on this @@ -178,7 +182,7 @@ pub struct Config { /// streams are accepted, logged, and closed (no session consumer yet). pub mesh_demo_echo: bool, - /// Optional hex-encoded pubkey of the relay owner. + /// Optional relay-owner npub, normalized to protocol hex after parsing. /// When set, this pubkey is automatically bootstrapped into `relay_members` /// with the `owner` role on first startup. pub relay_owner_pubkey: Option, @@ -199,8 +203,8 @@ pub struct Config { /// initial owners, but hold no implicit tenant membership row. /// Empty (the default) disables community provisioning entirely — fail closed. /// - /// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char - /// hex pubkeys. Invalid entries are rejected at startup (config error), not + /// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of npubs. + /// Invalid entries are rejected at startup (config error), not /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, @@ -632,25 +636,23 @@ impl Config { // config that may be shared across multiple services (e.g., ACP agent). let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY") .ok() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .and_then(|s| { - // Must be exactly 64 lowercase hex characters (32-byte pubkey). - let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()); - if valid { - Some(s) - } else { - warn!( - "RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — ignoring. \ - Got: {s:?}" - ); + .filter(|value| !value.trim().is_empty()) + .and_then(|value| match parse_public_key_compat(&value) { + Ok((key, encoding)) => { + if encoding == KeyInputEncoding::LegacyHex { + warn!("RELAY_OWNER_PUBKEY uses legacy hex; use the canonical npub form"); + } + Some(key.to_hex()) + } + Err(_) => { + warn!("RELAY_OWNER_PUBKEY is not a valid npub — ignoring"); None } }); // Note: intentionally not prefixed with BUZZ_ — same relay-identity - // config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex - // pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid + // config family as RELAY_OWNER_PUBKEY. Comma-separated npubs. Unlike + // RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid // entry here is a hard config error: silently dropping an operator // pubkey would silently disable provisioning for that operator. let relay_operator_api_origin = std::env::var("RELAY_OPERATOR_API_ORIGIN") @@ -663,18 +665,21 @@ impl Config { Ok(raw) => { let mut pubkeys = Vec::new(); for entry in raw.split(',') { - let entry = entry.trim().to_lowercase(); + let entry = entry.trim(); if entry.is_empty() { continue; } - let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit()); - if !valid { - return Err(ConfigError::InvalidValue(format!( - "RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}" - ))); + let (key, encoding) = parse_public_key_compat(entry).map_err(|_| { + ConfigError::InvalidValue( + "RELAY_OPERATOR_PUBKEYS contains an invalid npub".to_string(), + ) + })?; + if encoding == KeyInputEncoding::LegacyHex { + warn!("RELAY_OPERATOR_PUBKEYS contains legacy hex; use canonical npubs"); } - if !pubkeys.contains(&entry) { - pubkeys.push(entry); + let key = key.to_hex(); + if !pubkeys.contains(&key) { + pubkeys.push(key); } } pubkeys @@ -706,7 +711,24 @@ impl Config { .filter(|s| !s.is_empty()) .collect(); - let relay_private_key = std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok(); + let relay_private_key = std::env::var("BUZZ_RELAY_PRIVATE_KEY") + .ok() + .map(|value| { + let (secret_key, encoding) = parse_secret_key_compat(&value).map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_RELAY_PRIVATE_KEY must be a valid nsec".to_string(), + ) + })?; + if encoding == KeyInputEncoding::LegacyHex { + warn!("BUZZ_RELAY_PRIVATE_KEY uses legacy hex; store the canonical nsec form"); + } + secret_key_to_nsec(&secret_key).map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_RELAY_PRIVATE_KEY could not be encoded as nsec".to_string(), + ) + }) + }) + .transpose()?; let uds_path = std::env::var("BUZZ_UDS_PATH") .ok() @@ -1369,7 +1391,8 @@ mod tests { message.contains("BUZZ_REPLICA_READ_MAX_AGE_MS"), "the error must name the replacement env var, got: {message}" ), - other => panic!("old env name must hard-fail startup, got {other:?}"), + Ok(_) => panic!("old env name must hard-fail startup, got Ok"), + Err(error) => panic!("old env name must hard-fail startup, got {error}"), } } @@ -1512,9 +1535,13 @@ mod tests { #[test] fn relay_operator_pubkeys_parse_dedupe_and_normalize() { let _guard = ENV_MUTEX.lock().unwrap(); + let first = nostr::Keys::generate().public_key(); + let second = nostr::Keys::generate().public_key(); + let first_npub = buzz_core::nostr_identity::public_key_to_npub(&first).unwrap(); + let second_npub = buzz_core::nostr_identity::public_key_to_npub(&second).unwrap(); std::env::set_var( "RELAY_OPERATOR_PUBKEYS", - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + format!("{first_npub},{second_npub},{first_npub}"), ); std::env::set_var( "RELAY_OPERATOR_API_ORIGIN", @@ -1526,13 +1553,29 @@ mod tests { assert_eq!( config.relay_operator_pubkeys, - vec![ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), - ] + vec![first.to_hex(), second.to_hex()] ); } + #[test] + fn relay_identity_env_accepts_nip19_and_normalizes_protocol_values() { + let _guard = ENV_MUTEX.lock().unwrap(); + let owner = nostr::Keys::generate(); + let relay = nostr::Keys::generate(); + let owner_npub = + buzz_core::nostr_identity::public_key_to_npub(&owner.public_key()).unwrap(); + let relay_nsec = buzz_core::nostr_identity::secret_key_to_nsec(relay.secret_key()).unwrap(); + std::env::set_var("RELAY_OWNER_PUBKEY", &owner_npub); + std::env::set_var("BUZZ_RELAY_PRIVATE_KEY", &relay_nsec); + + let config = Config::from_env().expect("config"); + + std::env::remove_var("RELAY_OWNER_PUBKEY"); + std::env::remove_var("BUZZ_RELAY_PRIVATE_KEY"); + assert_eq!(config.relay_owner_pubkey, Some(owner.public_key().to_hex())); + assert_eq!(config.relay_private_key, Some(relay_nsec)); + } + #[test] fn relay_operator_pubkeys_invalid_entry_is_error() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -1549,9 +1592,10 @@ mod tests { #[test] fn relay_operator_pubkeys_require_api_origin() { let _guard = ENV_MUTEX.lock().unwrap(); + let operator = nostr::Keys::generate().public_key(); std::env::set_var( "RELAY_OPERATOR_PUBKEYS", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + buzz_core::nostr_identity::public_key_to_npub(&operator).unwrap(), ); std::env::remove_var("RELAY_OPERATOR_API_ORIGIN"); let result = Config::from_env(); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e0..dd00b97f6a7 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use axum::extract::ws::Message as WsMessage; +use buzz_core::nostr_identity::public_key_to_npub_or_invalid; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -90,6 +91,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + let pubkey_npub = public_key_to_npub_or_invalid(&pubkey); // Community ban gate (NIP-42 seam). Runs immediately after auth // verification succeeds and before the allowlist and relay-membership @@ -124,7 +126,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, pubkey = %pubkey_npub, error = %e, "ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -146,7 +148,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, owner = %owner.to_hex(), error = %e, + warn!(conn_id = %conn_id, owner = %public_key_to_npub_or_invalid(&owner), error = %e, "owner ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -166,7 +168,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: }; if let Some((metric_reason, deny_reason)) = denial { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); + warn!(conn_id = %conn_id, pubkey = %pubkey_npub, reason = deny_reason, "principal denied at ban seam"); metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -194,13 +196,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(v) => v, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, pubkey = %pubkey_npub, error = %e, "allowlist DB lookup failed, denying (fail-closed)"); false } }; if !allowed { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); + warn!(conn_id = %conn_id, pubkey = %pubkey_npub, "pubkey not in allowlist"); metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -224,7 +226,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(owner) => owner, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?e, "not a relay member"); + warn!(conn_id = %conn_id, pubkey = %pubkey_npub, error = ?e, "not a relay member"); metrics::counter!("buzz_auth_failures_total", "reason" => "not_relay_member") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -267,14 +269,14 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } else { warn!( conn_id = %conn_id, - agent = %pubkey.to_hex(), - nip_oa_owner = %owner.to_hex(), + agent = %pubkey_npub, + nip_oa_owner = %public_key_to_npub_or_invalid(&owner), "NIP-OA owner could not be materialized" ); } } - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + info!(conn_id = %conn_id, pubkey = %pubkey_npub, "NIP-42 auth successful"); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state .conn_manager diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index abb9bb20665..6c9bb3b86cc 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -18,6 +18,7 @@ use tracing::warn; use uuid::Uuid; use buzz_core::kind::*; +use buzz_core::nostr_identity::public_key_to_npub_or_invalid; use buzz_core::tenant::{CommunityId, TenantContext}; use buzz_datastore_tracing::datastore_span; use buzz_db::workflow::{ApprovalStatus, RunStatus}; @@ -1233,7 +1234,7 @@ async fn handle_approval_deny( // 6. Cancel the workflow run (post-commit, async) let community_id = tenant.community(); let run_id = approval.run_id; - let pubkey_hex = self_hex.clone(); + let denial_message = approval_denial_message(auth.pubkey()); let db = state.db.clone(); tokio::spawn(async move { @@ -1253,7 +1254,6 @@ async fn handle_approval_deny( return; } - let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}"); if let Err(e) = db .update_workflow_run( community_id, @@ -1261,7 +1261,7 @@ async fn handle_approval_deny( RunStatus::Cancelled, run.current_step, &run.execution_trace, - Some(&cancel_msg), + Some(&denial_message), ) .await { @@ -1283,6 +1283,13 @@ async fn handle_approval_deny( }) } +fn approval_denial_message(pubkey: &nostr::PublicKey) -> String { + format!( + "workflow cancelled: approval denied by {}", + public_key_to_npub_or_invalid(pubkey) + ) +} + /// Resume a suspended workflow run after an approval gate has been granted. async fn resume_workflow_after_approval( engine: Arc, @@ -1376,3 +1383,19 @@ async fn resume_workflow_after_approval( .finalize_run(community_id, run_id, result, existing_trace) .await; } + +#[cfg(test)] +mod identity_output_tests { + use super::approval_denial_message; + + #[test] + fn approval_denial_message_uses_npub_not_hex() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + + let message = approval_denial_message(&keys.public_key()); + + assert!(message.contains("npub1")); + assert!(!message.contains(&hex)); + } +} diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea0..0a8f912cd6c 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -18,12 +18,19 @@ //! ## Request shape //! //! ```json -//! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +//! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } //! ``` //! //! `initial_owner_pubkey` is optional. When present for an existing community, //! it rotates that community owner through the same bootstrap path used by //! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. +//! +//! ## Response identity compatibility +//! +//! Operator responses preserve the original `owner_pubkey` hex field for +//! existing automation and add `owner_npub` for new consumers. New clients +//! should read `owner_npub`; the legacy field remains protocol hex during the +//! compatibility window and must not be repurposed in place. use std::sync::Arc; @@ -34,6 +41,9 @@ use buzz_core::tenant::{normalize_host, TenantContext}; use url::{Host, Url}; use crate::state::AppState; +use buzz_core::nostr_identity::{ + canonical_npub_or_invalid, parse_public_key_compat, public_key_to_npub, +}; /// Maximum accepted authority length. Matches `communities.host VARCHAR(255)`. const MAX_HOST_LEN: usize = 255; @@ -63,15 +73,36 @@ pub struct ProvisionCommunityResponse { /// `created` when the host row was inserted, `existed` when it was already /// present and the request converged idempotently. pub status: &'static str, - /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. + /// Legacy protocol-hex owner retained for response compatibility. + /// + /// New consumers should read [`Self::owner_npub`]. #[serde(skip_serializing_if = "Option::is_none")] pub owner_pubkey: Option, + /// Canonical npub owner for human-facing and new client use. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_npub: Option, +} + +fn provision_community_response( + community_id: String, + host: String, + status: &'static str, + owner_pubkey: Option, +) -> ProvisionCommunityResponse { + let owner_npub = owner_pubkey.as_deref().map(canonical_npub_or_invalid); + ProvisionCommunityResponse { + community_id, + host, + status, + owner_pubkey, + owner_npub, + } } -pub(crate) fn validate_pubkey_hex(value: &str) -> Option { - let normalized = value.to_ascii_lowercase(); - (normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit())) - .then_some(normalized) +pub(crate) fn parse_pubkey_to_hex(value: &str) -> Option { + parse_public_key_compat(value) + .ok() + .map(|(public_key, _)| public_key.to_hex()) } /// Validate a normalized host authority value for a community. @@ -252,6 +283,8 @@ pub async fn provision_community( request: ProvisionCommunityRequest, ) -> Result { let operator_hex = operator_pubkey.to_hex(); + let operator_npub = public_key_to_npub(operator_pubkey) + .map_err(|_| "failed to encode operator npub".to_string())?; // Operator gate. Deliberately NOT a relay_members lookup: provisioning // authority spans tenants and lives in deployment config only. Empty @@ -271,9 +304,8 @@ pub async fn provision_community( .initial_owner_pubkey .as_deref() .map(|value| { - validate_pubkey_hex(value).ok_or_else(|| { - "invalid initial_owner_pubkey: expected 64-char hex pubkey".to_string() - }) + parse_pubkey_to_hex(value) + .ok_or_else(|| "invalid initial_owner_pubkey: expected an npub".to_string()) }) .transpose()?; @@ -281,6 +313,7 @@ pub async fn provision_community( let owner_hex = initial_owner.as_deref().ok_or_else(|| { "initial_owner_pubkey is required when create_only is true".to_string() })?; + let owner_npub = canonical_npub_or_invalid(owner_hex); let record = match state .db .create_community_with_owner(&request.host, owner_hex) @@ -300,19 +333,19 @@ pub async fn provision_community( }; info!( - operator = %operator_hex, + operator = %operator_npub, community = %record.id, host = %record.host, - owner = %owner_hex, + owner = %owner_npub, "community created via operator endpoint" ); publish_membership_snapshot_if_required(state, record.id, &record.host).await; - return Ok(ProvisionCommunityResponse { - community_id: record.id.to_string(), - host: record.host, - status: "created", - owner_pubkey: initial_owner, - }); + return Ok(provision_community_response( + record.id.to_string(), + record.host, + "created", + Some(owner_hex.to_string()), + )); } // Legacy convergence mode remains available to deployment operators and @@ -333,27 +366,71 @@ pub async fn provision_community( publish_membership_snapshot_if_required(state, record.id, &record.host).await; } + let owner_npub = initial_owner.as_deref().map(canonical_npub_or_invalid); info!( - operator = %operator_hex, + operator = %operator_npub, community = %record.id, host = %record.host, - owner = initial_owner.as_deref().unwrap_or(""), + owner = owner_npub.as_deref().unwrap_or(""), created = record.created, "community provisioned via operator endpoint" ); - Ok(ProvisionCommunityResponse { - community_id: record.id.to_string(), - host: record.host, - status: if record.created { "created" } else { "existed" }, - owner_pubkey: initial_owner, - }) + Ok(provision_community_response( + record.id.to_string(), + record.host, + if record.created { "created" } else { "existed" }, + initial_owner, + )) } #[cfg(test)] mod tests { use super::*; + #[test] + fn public_key_boundary_accepts_npub_and_emits_npub() { + let keys = nostr::Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = public_key_to_npub(&keys.public_key()).unwrap(); + + assert_eq!(parse_pubkey_to_hex(&npub), Some(hex.clone())); + assert_eq!(parse_pubkey_to_hex(&hex), Some(hex.clone())); + assert_eq!(parse_pubkey_to_hex("not-a-pubkey"), None); + } + + #[test] + fn provision_response_preserves_legacy_hex_and_adds_npub() { + let owner = nostr::Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let owner_npub = public_key_to_npub(&owner.public_key()).expect("encode owner npub"); + let response = serde_json::to_value(provision_community_response( + "community".to_string(), + "community.example".to_string(), + "created", + Some(owner_hex.clone()), + )) + .expect("serialize provisioning response"); + + assert_eq!(response["owner_pubkey"], owner_hex); + assert_eq!(response["owner_npub"], owner_npub); + } + + #[test] + fn provision_additive_npub_fails_closed_without_changing_legacy_hex() { + let invalid_hex = "ff".repeat(32); + let response = serde_json::to_value(provision_community_response( + "community".to_string(), + "community.example".to_string(), + "created", + Some(invalid_hex.clone()), + )) + .expect("serialize provisioning response"); + + assert_eq!(response["owner_pubkey"], invalid_hex); + assert_eq!(response["owner_npub"], ""); + } + #[test] fn host_valid_bare_domain() { assert!(validate_host("acme.communities.buzz.xyz").is_ok()); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..7ce04de1f28 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -10,6 +10,7 @@ use buzz_core::kind::{ event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; +use buzz_core::nostr_identity::public_key_to_npub_or_invalid; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY, @@ -1090,8 +1091,8 @@ async fn handle_agent_observer_event( let stored_event = StoredEvent::new(event.clone(), None); debug!( event_id = %event_id_hex, - agent = %route.agent.to_hex(), - owner = %route.owner.to_hex(), + agent = %public_key_to_npub_or_invalid(&route.agent), + owner = %public_key_to_npub_or_invalid(&route.owner), direction = ?route.direction, "Agent observer fan-out" ); diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483fe..9c9855697b0 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -10,6 +10,7 @@ use nostr::{Event, PublicKey}; use tracing::{info, warn}; use buzz_core::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_PROFILE}; +use buzz_core::nostr_identity::{canonical_npub_or_invalid, public_key_to_npub_or_invalid}; use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; use buzz_db::EventQuery; @@ -89,8 +90,8 @@ pub async fn handle_identity_archive_event( }; info!( - actor = %actor_hex, - target = %target_hex, + actor = %public_key_to_npub_or_invalid(&event.pubkey), + target = %canonical_npub_or_invalid(&target_hex), consent = consent_path.as_str(), changed, kind, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91e..a6f56b6e0d2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -36,6 +36,7 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; +use buzz_core::nostr_identity::public_key_to_npub_or_invalid; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -2424,7 +2425,7 @@ async fn ingest_event_inner( warn!(error = %e, "failed to publish NIP-43 membership list"); } - info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request"); + info!(pubkey = %public_key_to_npub_or_invalid(&event.pubkey), "relay member left via NIP-43 leave request"); return Ok(IngestResult { event_id: event_id_hex, diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index c769ac3cb92..db09a633288 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -62,6 +62,7 @@ use buzz_core::kind::{ KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, }; +use buzz_core::nostr_identity::public_key_bytes_to_npub_or_invalid; use buzz_core::tenant::TenantContext; use chrono::{DateTime, TimeZone, Utc}; use nostr::Event; @@ -218,7 +219,7 @@ async fn handle_ban( info!(error = %e, "ban notice DM delivery failed (ban still enforced)"); } - info!(target = %hex::encode(&target), "community ban applied"); + info!(target = %public_key_bytes_to_npub_or_invalid(&target), "community ban applied"); Ok(()) } @@ -254,7 +255,7 @@ async fn handle_unban( insert_audit(state, tenant, actor, "unban", Some(&target), None, None).await?; - info!(target = %hex::encode(&target), "community ban lifted"); + info!(target = %public_key_bytes_to_npub_or_invalid(&target), "community ban lifted"); Ok(()) } @@ -321,7 +322,7 @@ async fn handle_timeout( info!(error = %e, "timeout notice DM delivery failed (timeout still enforced)"); } - info!(target = %hex::encode(&target), "community timeout applied"); + info!(target = %public_key_bytes_to_npub_or_invalid(&target), "community timeout applied"); Ok(()) } @@ -357,7 +358,7 @@ async fn handle_untimeout( insert_audit(state, tenant, actor, "untimeout", Some(&target), None, None).await?; - info!(target = %hex::encode(&target), "community timeout cleared"); + info!(target = %public_key_bytes_to_npub_or_invalid(&target), "community timeout cleared"); Ok(()) } diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516d..3e2d46bb9bc 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -21,6 +21,7 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; +use buzz_core::nostr_identity::{canonical_npub_or_invalid, public_key_to_npub_or_invalid}; use buzz_core::tenant::TenantContext; use buzz_db::relay_members::RemoveResult; @@ -227,6 +228,7 @@ async fn execute_relay_admin_command( ) -> Result<(), String> { let kind = event.kind.as_u16() as u32; let sender_hex = event.pubkey.to_hex(); + let sender_npub = public_key_to_npub_or_invalid(&event.pubkey); // This mirrors the NIP-42 auth event freshness check and prevents replay // of captured admin commands. The window is intentionally tight — admin @@ -282,7 +284,7 @@ async fn execute_relay_admin_command( // publishes no announcement event (unlike 9030/9031), so this warn // is the only durable attribution of who changed the icon. warn!( - sender = %sender_hex, + sender = %sender_npub, "workspace profile change admitted without a roster role (open relay, no steward)" ); } @@ -300,13 +302,14 @@ async fn execute_relay_admin_command( .await .map_err(|e| format!("failed to store workspace icon: {e}"))?; - info!(sender = %sender_hex, icon_len = icon.len(), "workspace profile updated"); + info!(sender = %sender_npub, icon_len = icon.len(), "workspace profile updated"); return Ok(()); } let target_hex = extract_p_tag_hex(event) .ok_or_else(|| "missing or invalid p tag".to_string())? .to_ascii_lowercase(); + let target_npub = canonical_npub_or_invalid(&target_hex); match kind { // kind:9030 — Add relay member @@ -340,8 +343,8 @@ async fn execute_relay_admin_command( .map_err(|e| format!("database error: {e}"))?; info!( - sender = %sender_hex, - target = %target_hex, + sender = %sender_npub, + target = %target_npub, role = %role, was_inserted, "relay member add attempted" @@ -397,7 +400,7 @@ async fn execute_relay_admin_command( return Err("cannot remove the relay owner".to_string()); } RemoveResult::NotFound => { - return Err(format!("member not found: {target_hex}")); + return Err(format!("member not found: {target_npub}")); } RemoveResult::RoleMismatch => { return Err("actor not authorized: admins can only remove members".to_string()); @@ -405,8 +408,8 @@ async fn execute_relay_admin_command( } info!( - sender = %sender_hex, - target = %target_hex, + sender = %sender_npub, + target = %target_npub, "relay member removed" ); @@ -459,13 +462,13 @@ async fn execute_relay_admin_command( return Err(if exists.is_some() { "cannot change the relay owner's role".to_string() } else { - format!("member not found: {target_hex}") + format!("member not found: {target_npub}") }); } info!( - sender = %sender_hex, - target = %target_hex, + sender = %sender_npub, + target = %target_npub, new_role = %new_role, "relay member role changed" ); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 88a9f0c731c..174549411f6 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -13,6 +13,9 @@ use buzz_core::kind::{ KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; +use buzz_core::nostr_identity::{ + canonical_npub_or_invalid, public_key_bytes_to_npub_or_invalid, public_key_to_npub_or_invalid, +}; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -78,7 +81,7 @@ async fn disable_departed_member_workflows( Ok(n) => { tracing::info!( channel = %channel_id, - owner = %hex::encode(target_pubkey), + owner = %public_key_bytes_to_npub_or_invalid(target_pubkey), disabled = n, "Disabled departed member's workflows" ); @@ -89,7 +92,7 @@ async fn disable_departed_member_workflows( Err(e) => { warn!( channel = %channel_id, - owner = %hex::encode(target_pubkey), + owner = %public_key_bytes_to_npub_or_invalid(target_pubkey), error = %e, "Failed to disable departed member's workflows — per-fire authority gate still denies" ); @@ -905,6 +908,7 @@ pub async fn emit_membership_notification( notification_kind: u32, ) -> anyhow::Result<()> { let target_hex = hex::encode(target_pubkey); + let target_npub = public_key_bytes_to_npub_or_invalid(target_pubkey); let actor_hex = hex::encode(actor_pubkey); let channel_id_str = channel_id.to_string(); @@ -960,7 +964,7 @@ pub async fn emit_membership_notification( .invalidate(&(tenant.community(), stored.event.id.to_bytes())); warn!( channel = %channel_id, - target = %target_hex, + target = %target_npub, kind = notification_kind, "membership notification Redis publish failed: {e}" ); @@ -973,7 +977,7 @@ pub async fn emit_membership_notification( info!( channel = %channel_id, - target = %target_hex, + target = %target_npub, kind = notification_kind, "membership notification emitted" ); @@ -1187,7 +1191,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(pubkey = %public_key_bytes_to_npub_or_invalid(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); Ok(()) } @@ -1260,7 +1264,7 @@ async fn handle_kind0_profile( if let Err(ref e) = result { let msg = format!("{e}"); if msg.contains("duplicate key value") || msg.contains("23505") { - warn!(pubkey = %hex::encode(&pubkey_bytes), + warn!(pubkey = %public_key_bytes_to_npub_or_invalid(&pubkey_bytes), "kind:0 NIP-05 handle contested, syncing profile without it"); state .db @@ -1278,7 +1282,7 @@ async fn handle_kind0_profile( } } - info!(pubkey = %hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); + info!(pubkey = %public_key_bytes_to_npub_or_invalid(&pubkey_bytes), "kind:0 profile synced to users table"); Ok(()) } @@ -1352,7 +1356,7 @@ async fn handle_put_user( warn!(channel = %channel_id, error = %e, "membership notification emission failed"); } - info!(channel = %channel_id, target = %target_hex, "NIP-29 PUT_USER processed"); + info!(channel = %channel_id, target = %public_key_bytes_to_npub_or_invalid(&target_pubkey), "NIP-29 PUT_USER processed"); Ok(()) } @@ -2666,7 +2670,7 @@ async fn handle_git_repo_announcement( info!( repo_id = %repo_id, - owner = %owner_hex, + owner = %public_key_to_npub_or_invalid(&event.pubkey), reserved = reserved_by_this_attempt, "kind:30617 repo announced (name reserved, manifest pointer ensured)" ); @@ -2690,7 +2694,7 @@ async fn handle_git_repo_announcement( // "repo now exists" event, but clone/push still works. warn!( repo_id = %repo_id, - owner = %owner_hex, + owner = %public_key_to_npub_or_invalid(&event.pubkey), error = %e, "failed to emit initial kind:30618 ref state (non-fatal)" ); @@ -2775,8 +2779,9 @@ async fn seed_manifest_pointer( .map_err(|e| anyhow::anyhow!("pointer body not utf-8: {e}"))? .trim(); if existing != digest { + let owner_npub = canonical_npub_or_invalid(owner_hex); return Err(anyhow::anyhow!( - "repo '{repo_id}' for owner {owner_hex} already has a non-empty pointer \ + "repo '{repo_id}' for owner {owner_npub} already has a non-empty pointer \ ({existing}); refusing to overwrite via announce" )); } @@ -3017,8 +3022,8 @@ async fn publish_nip43_delta( .await; info!( - target = %target_pubkey_hex, - relay = %relay_pubkey_hex, + target = %canonical_npub_or_invalid(target_pubkey_hex), + relay = %canonical_npub_or_invalid(&relay_pubkey_hex), "NIP-43 {label} event published" ); Ok(()) @@ -3236,7 +3241,7 @@ pub async fn publish_dm_visibility_snapshot( } info!( - viewer = %viewer_hex, + viewer = %public_key_bytes_to_npub_or_invalid(viewer), hidden_count = hidden.len(), "NIP-DV DM visibility snapshot published" ); @@ -3297,8 +3302,8 @@ async fn publish_nipia_delta( dispatch_persistent_event(tenant, state, &stored, kind, &relay_pubkey_hex, None).await; info!( - target = %target_pubkey_hex, - relay = %relay_pubkey_hex, + target = %canonical_npub_or_invalid(target_pubkey_hex), + relay = %canonical_npub_or_invalid(&relay_pubkey_hex), kind, consent = %consent_path, "NIP-IA delta event published" diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3584e1849d1..1c1a564c8d8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -8,11 +8,19 @@ use tracing_subscriber::{fmt, prelude::*, EnvFilter}; fn log_env_filter(rust_log: Option<&str>) -> EnvFilter { EnvFilter::new(rust_log.unwrap_or("buzz_relay=info")) } + +fn relay_owner_npub(owner_pubkey: &str) -> anyhow::Result { + let owner = nostr::PublicKey::from_hex(owner_pubkey).map_err(|error| { + anyhow::anyhow!("invalid relay owner public key after validation: {error}") + })?; + public_key_to_npub(&owner) + .map_err(|error| anyhow::anyhow!("failed to encode relay owner npub: {error}")) +} use uuid::Uuid; use buzz_audit::AuditService; use buzz_auth::AuthService; -use buzz_core::CommunityId; +use buzz_core::{nostr_identity::public_key_to_npub, CommunityId}; use buzz_db::{Db, DbConfig}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; @@ -234,7 +242,7 @@ async fn main() -> anyhow::Result<()> { if config.require_relay_membership && config.relay_owner_pubkey.is_none() { error!( "BUZZ_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set or invalid. \ - Set RELAY_OWNER_PUBKEY to a valid 64-char hex pubkey." + Set RELAY_OWNER_PUBKEY to a valid npub." ); return Err(anyhow::anyhow!( "RELAY_OWNER_PUBKEY required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true" @@ -324,8 +332,9 @@ async fn main() -> anyhow::Result<()> { if let (Some(community), Some(owner_pubkey)) = (deployment_community, config.relay_owner_pubkey.as_ref()) { + let owner_npub = relay_owner_npub(owner_pubkey)?; match db.bootstrap_owner(community, owner_pubkey).await { - Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"), + Ok(()) => info!(pubkey = %owner_npub, "Relay owner bootstrapped"), Err(e) => { if config.require_relay_membership { // Membership enforcement is on — a missing owner means no one @@ -422,9 +431,9 @@ async fn main() -> anyhow::Result<()> { let workflow_config = buzz_workflow::WorkflowConfig::default(); let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config)); - let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex) - .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? + let relay_keypair = if let Some(nsec) = &config.relay_private_key { + nostr::Keys::parse(nsec) + .map_err(|_| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY after validation"))? } else if !config.require_auth_token { // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) // replace correctly across restarts. Without this, each restart generates a new pubkey @@ -432,8 +441,10 @@ async fn main() -> anyhow::Result<()> { const DEV_RELAY_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); + let relay_npub = public_key_to_npub(&keys.public_key()) + .map_err(|error| anyhow::anyhow!("failed to encode relay npub: {error}"))?; tracing::warn!( - pubkey = %keys.public_key().to_hex(), + pubkey = %relay_npub, "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ Set BUZZ_RELAY_PRIVATE_KEY for production." ); @@ -1120,10 +1131,19 @@ async fn main() -> anyhow::Result<()> { #[cfg(test)] mod env_filter_tests { - use super::log_env_filter; + use super::{log_env_filter, relay_owner_npub}; use buzz_relay::telemetry::otel_env_filter; use tracing_subscriber::prelude::*; + #[test] + fn relay_owner_log_identity_is_npub_not_hex() { + let hex = nostr::Keys::generate().public_key().to_hex(); + let displayed = relay_owner_npub(&hex).expect("relay owner formats"); + assert!(displayed.starts_with("npub1")); + assert_ne!(displayed, hex); + assert!(!displayed.contains(&hex)); + } + #[test] fn unset_enables_datastore_only_for_otel_filter() { let logs = tracing_subscriber::registry().with(log_env_filter(None)); diff --git a/crates/buzz-sdk/examples/compute_auth_tag.rs b/crates/buzz-sdk/examples/compute_auth_tag.rs index b2bc5a5e612..de3c981a25d 100644 --- a/crates/buzz-sdk/examples/compute_auth_tag.rs +++ b/crates/buzz-sdk/examples/compute_auth_tag.rs @@ -1,29 +1,54 @@ //! Compute a NIP-OA auth tag for an agent keypair. //! //! Usage: -//! cargo run --release --example compute_auth_tag -- [conditions] +//! cargo run --release --example compute_auth_tag -- [conditions] //! //! Prints the JSON auth tag to stdout. +use buzz_core::nostr_identity::{ + parse_public_key_compat, parse_secret_key_compat, KeyInputEncoding, +}; use buzz_sdk::nip_oa; -use nostr::{Keys, PublicKey}; +use nostr::Keys; fn main() { let args: Vec = std::env::args().collect(); if args.len() < 3 { - eprintln!( - "Usage: {} [conditions]", - args[0] - ); + eprintln!("Usage: {} [conditions]", args[0]); std::process::exit(1); } - let owner_keys = Keys::parse(&args[1]).expect("invalid owner secret key"); - let agent_pubkey = PublicKey::from_hex(&args[2]).expect("invalid agent pubkey hex"); + let (owner_secret, owner_encoding) = match parse_secret_key_compat(&args[1]) { + Ok(value) => value, + Err(_) => { + eprintln!("invalid owner nsec"); + std::process::exit(1); + } + }; + let (agent_pubkey, agent_encoding) = match parse_public_key_compat(&args[2]) { + Ok(value) => value, + Err(_) => { + eprintln!("invalid agent npub"); + std::process::exit(1); + } + }; + if owner_encoding == KeyInputEncoding::LegacyHex { + eprintln!("warning: legacy owner secret hex is deprecated; use nsec"); + } + if agent_encoding == KeyInputEncoding::LegacyHex { + eprintln!("warning: legacy agent public-key hex is deprecated; use npub"); + } + + let owner_keys = Keys::new(owner_secret); let conditions = args.get(3).map(|s| s.as_str()).unwrap_or(""); - let tag_json = nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions) - .expect("failed to compute auth tag"); + let tag_json = match nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions) { + Ok(value) => value, + Err(_) => { + eprintln!("failed to compute auth tag"); + std::process::exit(1); + } + }; println!("{tag_json}"); } diff --git a/crates/buzz-test-client/src/bin/mention.rs b/crates/buzz-test-client/src/bin/mention.rs index 61fc704656f..67cd0787ab2 100644 --- a/crates/buzz-test-client/src/bin/mention.rs +++ b/crates/buzz-test-client/src/bin/mention.rs @@ -1,6 +1,7 @@ -//! Send an @mention event to a Buzz channel targeting a specific pubkey. -//! Usage: mention +//! Send an @mention event to a Buzz channel targeting a specific npub. +//! Usage: mention +use buzz_core::nostr_identity::{parse_public_key_compat, public_key_to_npub}; use buzz_test_client::BuzzTestClient; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -10,21 +11,21 @@ async fn main() -> anyhow::Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); let args: Vec = std::env::args().collect(); if args.len() < 4 { - eprintln!("Usage: mention "); + eprintln!("Usage: mention "); std::process::exit(1); } let channel_id = &args[1]; - let target_pubkey = &args[2]; + let target_pubkey = parse_public_key_compat(&args[2])?.0.to_hex(); let message = args[3..].join(" "); let url = std::env::var("BUZZ_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into()); let keys = Keys::generate(); - println!("Sender pubkey: {}", keys.public_key().to_hex()); + println!("Sender pubkey: {}", public_key_to_npub(&keys.public_key())?); let mut client = BuzzTestClient::connect(&url, &keys).await?; let h_tag = Tag::parse(["h", channel_id])?; - let p_tag = Tag::parse(["p", target_pubkey])?; + let p_tag = Tag::parse(["p", &target_pubkey])?; let event = EventBuilder::new(Kind::Custom(9), message) .tags([h_tag, p_tag]) .sign_with_keys(&keys)?; diff --git a/crates/buzz-test-client/src/bin/wamp_bench.rs b/crates/buzz-test-client/src/bin/wamp_bench.rs index b1bcad21044..f11cb934654 100644 --- a/crates/buzz-test-client/src/bin/wamp_bench.rs +++ b/crates/buzz-test-client/src/bin/wamp_bench.rs @@ -8,10 +8,11 @@ //! (milliseconds, f64) per line to `latency_out`. //! //! Usage: wamp-bench -//! Env: BUZZ_RELAY_URL (default ws://localhost:3000), BENCH_PRIVATE_KEY (hex) +//! Env: BUZZ_RELAY_URL (default ws://localhost:3000), BENCH_PRIVATE_KEY (nsec) use std::time::{Duration, Instant}; +use buzz_core::nostr_identity::parse_secret_key_compat; use buzz_test_client::BuzzTestClient; use nostr::Keys; use tokio::time::MissedTickBehavior; @@ -34,7 +35,11 @@ async fn main() -> anyhow::Result<()> { let url = std::env::var("BUZZ_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into()); let keys = match std::env::var("BENCH_PRIVATE_KEY") { - Ok(hex) => Keys::parse(&hex)?, + Ok(secret) => { + let (secret_key, _) = parse_secret_key_compat(&secret) + .map_err(|_| anyhow::anyhow!("BENCH_PRIVATE_KEY must be a valid nsec"))?; + Keys::new(secret_key) + } Err(_) => anyhow::bail!("BENCH_PRIVATE_KEY is required (channel member secret key)"), }; diff --git a/crates/buzz-test-client/src/main.rs b/crates/buzz-test-client/src/main.rs index 858f795c83c..d4a2f187d31 100644 --- a/crates/buzz-test-client/src/main.rs +++ b/crates/buzz-test-client/src/main.rs @@ -27,6 +27,7 @@ use std::time::Duration; +use buzz_core::nostr_identity::{parse_secret_key_compat, public_key_to_npub}; use buzz_test_client::{BuzzTestClient, RelayMessage}; use nostr::{Filter, Keys}; @@ -48,10 +49,18 @@ async fn main() { let kind = opts.kind.unwrap_or(9); let keys = match std::env::var("BUZZ_PRIVATE_KEY") { - Ok(sk) => Keys::parse(&sk).expect("invalid BUZZ_PRIVATE_KEY"), + Ok(secret) => match parse_secret_key_compat(&secret) { + Ok((secret_key, _)) => Keys::new(secret_key), + Err(_) => { + eprintln!("invalid BUZZ_PRIVATE_KEY: expected an nsec"); + std::process::exit(1); + } + }, Err(_) => Keys::generate(), }; - println!("Using pubkey: {}", keys.public_key()); + let npub = + public_key_to_npub(&keys.public_key()).unwrap_or_else(|_| "".to_string()); + println!("Using pubkey: {npub}"); if opts.subscribe { run_subscribe(url, &keys, channel, kind).await; diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dffa4927168..ed1a8d35a68 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -11,9 +11,10 @@ use std::collections::HashMap; +use buzz_core::nostr_identity::{canonical_npub, parse_public_key_compat}; use buzz_core::tenant::CommunityId; use evalexpr::HashMapContext; -use nostr::ToBech32; +use serde::{Deserialize, Deserializer, Serializer}; use serde_json::Value as JsonValue; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -27,7 +28,8 @@ use crate::WorkflowEngine; pub struct TriggerContext { /// Message content (message_posted trigger). pub text: String, - /// Pubkey of the event author (hex string). + /// Pubkey of the event author (protocol hex internally, npub when serialized). + #[serde(with = "trigger_author_serde")] pub author: String, /// Channel UUID as string. pub channel_id: String, @@ -41,6 +43,40 @@ pub struct TriggerContext { pub webhook_fields: HashMap, } +/// Portable trigger contexts use npub while runtime authorization and condition +/// evaluation continue to use protocol-native lowercase hex. +mod trigger_author_serde { + use super::*; + use serde::de::Error as _; + use serde::ser::Error as _; + + pub fn serialize(author: &str, serializer: S) -> Result + where + S: Serializer, + { + if author.is_empty() { + return serializer.serialize_str(""); + } + + let npub = canonical_npub(author).map_err(S::Error::custom)?; + serializer.serialize_str(&npub) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let author = String::deserialize(deserializer)?; + if author.is_empty() { + return Ok(author); + } + + parse_public_key_compat(&author) + .map(|(public_key, _)| public_key.to_hex()) + .map_err(D::Error::custom) + } +} + impl TriggerContext { /// Look up a trigger field by name. /// @@ -63,8 +99,12 @@ impl TriggerContext { /// /// Supports filters: /// - `| truncate(N)` — truncate to N characters -/// - `| npub` — encode a hex pubkey as its full bech32 `npub` (non-pubkey -/// values pass through unchanged); `truncate_pubkey` is a legacy alias +/// - `| npub` — render a public key as canonical npub; this is idempotent for +/// npub input and `truncate_pubkey` remains as a legacy alias +/// +/// For compatibility with persisted workflow definitions, bare +/// `{{trigger.author}}` continues to render as protocol hex. New definitions +/// should use `{{trigger.author | npub}}` when they need a human-facing key. /// /// Unknown `{{keys}}` are left as literal text (no error, no substitution). pub fn resolve_template( @@ -189,10 +229,7 @@ fn apply_filter(value: String, filter: &str) -> Result { // `npub` (alias `truncate_pubkey`): full bech32 npub — truncated prefixes are grindable. if filter == "npub" || filter == "truncate_pubkey" { - if let Ok(pk) = nostr::PublicKey::from_hex(&value) { - return Ok(pk.to_bech32().unwrap_or(value)); - } - return Ok(value); + return Ok(canonical_npub(&value).unwrap_or(value)); } Err(WorkflowError::TemplateError(format!( @@ -1258,10 +1295,13 @@ mod tests { use super::*; use serde_json::json; + const AUTHOR_HEX: &str = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f"; + const AUTHOR_NPUB: &str = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux"; + fn make_trigger() -> TriggerContext { TriggerContext { text: "P1 incident in production".to_owned(), - author: "abc123def456".to_owned(), + author: AUTHOR_HEX.to_owned(), channel_id: "channel-uuid-here".to_owned(), timestamp: "1700000000".to_owned(), emoji: "fire".to_owned(), @@ -1281,7 +1321,7 @@ mod tests { fn resolve_trigger_author() { let ctx = make_trigger(); let out = resolve_template("By {{trigger.author}}", &ctx, &HashMap::new()).unwrap(); - assert_eq!(out, "By abc123def456"); + assert_eq!(out, format!("By {AUTHOR_HEX}")); } #[test] @@ -1312,28 +1352,22 @@ mod tests { #[test] fn resolve_npub_filter_encodes_hex_pubkey() { let mut ctx = make_trigger(); - ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); + ctx.author = AUTHOR_HEX.to_owned(); let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); - assert_eq!( - out, - "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" - ); + assert_eq!(out, AUTHOR_NPUB); } #[test] fn resolve_truncate_pubkey_is_alias_for_npub() { let mut ctx = make_trigger(); - ctx.author = "e17e5abf7b1dbd363f0ed6fbda2455609727b2555428dea251388c542cd2f03f".to_owned(); + ctx.author = AUTHOR_HEX.to_owned(); let out = resolve_template( "{{trigger.author | truncate_pubkey}}", &ctx, &HashMap::new(), ) .unwrap(); - assert_eq!( - out, - "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux" - ); + assert_eq!(out, AUTHOR_NPUB); } #[test] @@ -1352,7 +1386,7 @@ mod tests { &HashMap::new(), ) .unwrap(); - assert_eq!(out, "abc123def456 said: P1 incident in production"); + assert_eq!(out, format!("{AUTHOR_HEX} said: P1 incident in production")); } #[test] @@ -1584,8 +1618,7 @@ mod tests { } #[test] - fn resolve_pubkey_filter_non_pubkey_passes_through() { - // Values that are not valid hex pubkeys are returned unchanged. + fn resolve_npub_filter_non_pubkey_passes_through() { let mut ctx = make_trigger(); ctx.author = "short".to_owned(); let out = resolve_template( @@ -1599,11 +1632,11 @@ mod tests { #[test] fn resolve_npub_filter_passes_npub_through() { - // Already-encoded npubs are not valid hex, so they pass through intact. + // Compatibility callers that already hold an npub remain idempotent. let mut ctx = make_trigger(); - ctx.author = "npub1u9l940mmrk7nv0cw6maa5fz4vztj0vj42s5dagj38zx9gtxj7qls94fpux".to_owned(); + ctx.author = AUTHOR_NPUB.to_owned(); let out = resolve_template("{{trigger.author | npub}}", &ctx, &HashMap::new()).unwrap(); - assert_eq!(out, ctx.author); + assert_eq!(out, AUTHOR_NPUB); } #[test] @@ -1631,7 +1664,7 @@ mod tests { let ctx = make_trigger(); let out = resolve_template("{{trigger.author}}{{trigger.emoji}}", &ctx, &HashMap::new()).unwrap(); - assert_eq!(out, "abc123def456fire"); + assert_eq!(out, format!("{AUTHOR_HEX}fire")); } #[tokio::test] @@ -1726,9 +1759,9 @@ mod tests { #[tokio::test] async fn condition_author_field() { - let ctx = make_trigger(); // author = "abc123def456" + let ctx = make_trigger(); let result = evaluate_condition( - "str_starts_with(trigger_author, \"abc\")", + "str_starts_with(trigger_author, \"e17e\")", &ctx, &HashMap::new(), ) @@ -1804,7 +1837,7 @@ mod tests { fn trigger_context_get_field_known_fields() { let ctx = make_trigger(); assert_eq!(ctx.get_field("text"), Some("P1 incident in production")); - assert_eq!(ctx.get_field("author"), Some("abc123def456")); + assert_eq!(ctx.get_field("author"), Some(AUTHOR_HEX)); assert_eq!(ctx.get_field("channel_id"), Some("channel-uuid-here")); assert_eq!(ctx.get_field("timestamp"), Some("1700000000")); assert_eq!(ctx.get_field("emoji"), Some("fire")); @@ -1838,6 +1871,70 @@ mod tests { assert!(ctx.webhook_fields.is_empty()); } + #[test] + fn persisted_legacy_trigger_context_keeps_template_contract_after_upgrade() { + let legacy_persisted = json!({ + "text": "hello", + "author": AUTHOR_HEX, + "channel_id": "", + "timestamp": "", + "emoji": "", + "message_id": "", + "webhook_fields": {}, + }); + + let context: TriggerContext = serde_json::from_value(legacy_persisted).unwrap(); + assert_eq!(context.author, AUTHOR_HEX); + let rendered = resolve_template( + "{{trigger.author}}|{{trigger.author | npub}}", + &context, + &HashMap::new(), + ) + .unwrap(); + assert_eq!(rendered, format!("{AUTHOR_HEX}|{AUTHOR_NPUB}")); + + // New writes stay portable/canonical, but loading that upgraded row + // must reconstruct the internal hex value so a later approval resume + // does not change templates authored under the legacy contract. + let upgraded_persisted = serde_json::to_value(&context).unwrap(); + assert_eq!(upgraded_persisted["author"], AUTHOR_NPUB); + + let resumed: TriggerContext = serde_json::from_value(upgraded_persisted).unwrap(); + let resumed_rendered = resolve_template( + "{{trigger.author}}|{{trigger.author | npub}}", + &resumed, + &HashMap::new(), + ) + .unwrap(); + assert_eq!(resumed_rendered, format!("{AUTHOR_HEX}|{AUTHOR_NPUB}")); + } + + #[test] + fn trigger_context_npub_deserializes_to_internal_hex() { + let value = json!({ + "text": "hello", + "author": AUTHOR_NPUB, + "channel_id": "", + "timestamp": "", + "emoji": "", + "message_id": "", + "webhook_fields": {}, + }); + + let context: TriggerContext = serde_json::from_value(value).unwrap(); + assert_eq!(context.author, AUTHOR_HEX); + } + + #[test] + fn trigger_context_empty_author_sentinel_round_trips() { + let context = TriggerContext::default(); + let serialized = serde_json::to_value(&context).unwrap(); + assert_eq!(serialized["author"], ""); + + let deserialized: TriggerContext = serde_json::from_value(serialized).unwrap(); + assert!(deserialized.author.is_empty()); + } + #[test] fn send_message_uses_bound_workflow_channel_by_default() { let workflow_channel_id = Uuid::new_v4(); diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index e1422211690..950af30ad5e 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -946,7 +946,8 @@ async fn should_fire_workflow( /// Build a [`executor::TriggerContext`] from a [`buzz_core::StoredEvent`]. /// /// - `text` — event content (message body or reaction emoji character) -/// - `author` — pubkey hex string +/// - `author` — protocol hex internally and in legacy bare templates; serialized +/// as canonical npub for portable run snapshots /// - `channel_id` — channel UUID as string (empty if no channel scope) /// - `timestamp` — Unix timestamp as string /// - `emoji` — for `KIND_REACTION` events, the content is the emoji; otherwise empty diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48b3..5e7cfe63b1a 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -100,7 +100,8 @@ pub enum ActionDef { }, /// Send a direct message to a user. SendDm { - /// Recipient — pubkey hex or `{{trigger.author}}`. + /// Recipient — canonical npub, or legacy `{{trigger.author}}` protocol + /// hex. Prefer `{{trigger.author | npub}}` in new definitions. to: String, /// Message text (supports template variables). text: String, diff --git a/crates/git-sign-nostr/README.md b/crates/git-sign-nostr/README.md index 908682fd7fe..2f7bcb357ab 100644 --- a/crates/git-sign-nostr/README.md +++ b/crates/git-sign-nostr/README.md @@ -11,10 +11,10 @@ git config gpg.format x509 git config gpg.x509.program /path/to/git-sign-nostr git config commit.gpgsign true git config tag.gpgsign true -git config user.signingkey +git config user.signingkey # Set the private key (env var) -export NOSTR_PRIVATE_KEY= +export NOSTR_PRIVATE_KEY= # Optional: NIP-OA owner attestation export BUZZ_AUTH_TAG='["auth","","",""]' @@ -32,7 +32,7 @@ git verify-commit HEAD 2. `BUZZ_PRIVATE_KEY` environment variable 3. Keyfile at path from `git config nostr.keyfile` -Keys may be hex (64 chars) or NIP-19 bech32 (`nsec1...`). +Store public identities as NIP-19 `npub` and private keys as `nsec`. ## How It Works diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 86989676604..1bf53912a47 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -19,7 +19,7 @@ helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ --set redis.enabled=true \ --set minio.enabled=true \ --set relayUrl=wss://buzz.example.com \ - --set ownerPubkey=<64-char-hex-pubkey> + --set ownerPubkey= ``` This brings up **everything in-cluster** — Postgres, Redis, and MinIO (with @@ -46,7 +46,7 @@ See: | Key | What | When required | |---|---|---| | `relayUrl` | Public `wss://` URL clients connect to | Always | -| `ownerPubkey` | 64-char lowercase hex Nostr pubkey of the relay operator | When `relay.requireRelayMembership=true` (default) | +| `ownerPubkey` | NIP-19 `npub` of the relay operator | When `relay.requireRelayMembership=true` (default) | | `secrets.existingSecret` | Name of pre-created Secret | Production / GitOps | | `externalPostgresql.url` / `externalRedis.url` / `s3.endpoint` | External service URLs | Production — when the matching bundled service is disabled (the default) | diff --git a/deploy/charts/buzz/ci/quickstart-values.yaml b/deploy/charts/buzz/ci/quickstart-values.yaml index 4dcf6bcd21b..1e10623772a 100644 --- a/deploy/charts/buzz/ci/quickstart-values.yaml +++ b/deploy/charts/buzz/ci/quickstart-values.yaml @@ -10,7 +10,7 @@ redis: minio: enabled: true relayUrl: wss://buzz.test.local -ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001" +ownerPubkey: "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60" relay: # Don't enforce membership in CI — we're testing the chart renders and the # Pod starts, not relay business logic. diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml index a29a6919b72..1c7b7d2838d 100644 --- a/deploy/charts/buzz/examples/argocd-app.yaml +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -28,7 +28,7 @@ spec: releaseName: buzz values: | relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" # replace + ownerPubkey: "REPLACE_WITH_NPUB" replicaCount: 3 secrets: diff --git a/deploy/charts/buzz/examples/flux-helmrelease.yaml b/deploy/charts/buzz/examples/flux-helmrelease.yaml index 09a6bfeb6a0..303820a693b 100644 --- a/deploy/charts/buzz/examples/flux-helmrelease.yaml +++ b/deploy/charts/buzz/examples/flux-helmrelease.yaml @@ -31,7 +31,7 @@ spec: name: buzz values: relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" # replace + ownerPubkey: "REPLACE_WITH_NPUB" replicaCount: 3 secrets: diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d486..7d5adae774c 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -5,7 +5,7 @@ # and leaves the rest as Pod env vars marked `optional: true`. # # Keys consumed by the relay (all optional unless required by relay config): -# BUZZ_RELAY_PRIVATE_KEY — 64-char hex; relay identity (do NOT rotate) +# BUZZ_RELAY_PRIVATE_KEY — nsec; relay identity (do NOT rotate) # BUZZ_GIT_HOOK_HMAC_SECRET — 32+ chars; required when replicaCount > 1 # DATABASE_URL — postgres://... # READ_DATABASE_URL — postgres://... (optional read-replica; omit to disable read routing) @@ -19,7 +19,7 @@ metadata: namespace: buzz type: Opaque stringData: - BUZZ_RELAY_PRIVATE_KEY: "REPLACE_WITH_64_HEX" + BUZZ_RELAY_PRIVATE_KEY: "REPLACE_WITH_NSEC" BUZZ_GIT_HOOK_HMAC_SECRET: "REPLACE_WITH_RANDOM_64_CHARS" DATABASE_URL: "postgres://buzz:REPLACE@postgres.buzz.svc.cluster.local:5432/buzz?sslmode=require" REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index aa7f7ac13cf..b88e5c5f278 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -49,14 +49,16 @@ surface at template time regardless of which manifest helm renders first. {{/* Owner pubkey required when requireRelayMembership */}} {{- if .Values.relay.requireRelayMembership -}} {{- if not .Values.ownerPubkey -}} - {{- fail "ownerPubkey is required when relay.requireRelayMembership=true. Set ownerPubkey to the 64-char lowercase hex Nostr pubkey of the relay operator, or set relay.requireRelayMembership=false for an open relay." -}} + {{- fail "ownerPubkey is required when relay.requireRelayMembership=true. Set ownerPubkey to the NIP-19 npub of the relay operator, or set relay.requireRelayMembership=false for an open relay." -}} {{- end -}} {{- end -}} {{/* ownerPubkey format check */}} {{- if .Values.ownerPubkey -}} - {{- if not (regexMatch "^[0-9a-f]{64}$" .Values.ownerPubkey) -}} - {{- fail (printf "ownerPubkey must be 64 lowercase hex characters (got %d chars; must match ^[0-9a-f]{64}$)." (len .Values.ownerPubkey)) -}} + {{- $isNpub := regexMatch "^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$" .Values.ownerPubkey -}} + {{- $isLegacyHex := regexMatch "^[0-9a-f]{64}$" .Values.ownerPubkey -}} + {{- if not (or $isNpub $isLegacyHex) -}} + {{- fail (printf "ownerPubkey must be a NIP-19 npub (got %d chars)." (len .Values.ownerPubkey)) -}} {{- end -}} {{- end -}} diff --git a/deploy/charts/buzz/templates/secret-chart.yaml b/deploy/charts/buzz/templates/secret-chart.yaml index 49569bf432a..af940001f82 100644 --- a/deploy/charts/buzz/templates/secret-chart.yaml +++ b/deploy/charts/buzz/templates/secret-chart.yaml @@ -23,7 +23,10 @@ metadata: type: Opaque data: - {{- /* Relay private key (relay identity; rotation = identity change) */}} + {{- /* Relay private key (relay identity; rotation = identity change). + User-supplied values are canonical nsec. The chart-generated fallback + is machine-only legacy hex because Helm has no bech32 encoder; relay + config normalizes it immediately and never displays it. */}} {{- if .Values.secrets.relayPrivateKey }} BUZZ_RELAY_PRIVATE_KEY: {{ .Values.secrets.relayPrivateKey | b64enc | quote }} {{- else if (index $existingData "BUZZ_RELAY_PRIVATE_KEY") }} diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index a5a0050a866..472fb072602 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -5,7 +5,7 @@ tests: - it: fails when relayUrl is missing set: relayUrl: "" - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + ownerPubkey: "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60" externalPostgresql.url: postgres://u:p@h:5432/d asserts: - failedTemplate: @@ -20,7 +20,7 @@ tests: - failedTemplate: errorPattern: "ownerPubkey is required when relay.requireRelayMembership=true" - - it: fails when ownerPubkey is not 64 lowercase hex (schema-level) + - it: fails when ownerPubkey is neither npub nor a legacy key (schema-level) set: relayUrl: wss://buzz.example.com ownerPubkey: "NOTAHEX" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 94d369c8903..f711d937b33 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -39,15 +39,19 @@ }, "ownerPubkey": { "type": "string", - "pattern": "^([0-9a-f]{64})?$", - "description": "64-char lowercase hex Nostr pubkey of the relay operator. Required when relay.requireRelayMembership=true." + "pattern": "^$|^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$|^[0-9a-f]{64}$", + "description": "NIP-19 npub of the relay operator. Required when relay.requireRelayMembership=true. Legacy lowercase hex is accepted for upgrade compatibility." }, "secrets": { "type": "object", "additionalProperties": false, "properties": { "existingSecret": { "type": "string", "description": "Name of an externally managed Secret. Production / GitOps path." }, - "relayPrivateKey": { "type": "string" }, + "relayPrivateKey": { + "type": "string", + "pattern": "^$|^nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}$|^[0-9a-fA-F]{64}$", + "description": "Relay identity in NIP-19 nsec form. Legacy hex is accepted for upgrade compatibility." + }, "gitHookHmacSecret": { "type": "string" } } }, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index ca3403a633f..769ec564277 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -76,7 +76,7 @@ relayUrl: "" mediaBaseUrl: "" # ── Owner ──────────────────────────────────────────────────────────────────── -# 64-char lowercase hex Nostr pubkey of the relay operator. Required when +# NIP-19 npub of the relay operator. Required when # relay.requireRelayMembership=true (the production default). ownerPubkey: "" @@ -86,7 +86,7 @@ ownerPubkey: "" # Secret falls back to chart-side autogen (only effective at first install). # # Expected keys (all optional unless required by relay config): -# BUZZ_RELAY_PRIVATE_KEY — 64-char hex; relay identity (rotation = identity change) +# BUZZ_RELAY_PRIVATE_KEY — nsec; relay identity (rotation = identity change) # BUZZ_GIT_HOOK_HMAC_SECRET — 32+ chars; required when replicaCount > 1 # DATABASE_URL — full Postgres URL (preferred over externalPostgresql.url) # READ_DATABASE_URL — optional Postgres read-replica URL; omit to keep all reads on the writer diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab97..365ca169aee 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -20,11 +20,11 @@ BUZZ_AUTO_MIGRATE=true BUZZ_GIT_CONFORMANCE_PROBE=true RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info -# Owner identity. Set to a 64-character hex Nostr pubkey. -RELAY_OWNER_PUBKEY=CHANGE_ME_OWNER_PUBKEY_HEX +# Owner identity in NIP-19 npub form. +RELAY_OWNER_PUBKEY=CHANGE_ME_OWNER_NPUB # Stable secrets. Generate once, keep in .env, and back up securely. -BUZZ_RELAY_PRIVATE_KEY=CHANGE_ME_64_HEX_PRIVATE_KEY +BUZZ_RELAY_PRIVATE_KEY=CHANGE_ME_RELAY_NSEC BUZZ_GIT_HOOK_HMAC_SECRET=CHANGE_ME_RANDOM_64_HEX POSTGRES_DB=buzz POSTGRES_USER=buzz diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15d..6f59047cd96 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -30,8 +30,8 @@ keypair. - Default `BUZZ_IMAGE` tracks `ghcr.io/block/buzz:main` for early testing. Pin it to `ghcr.io/block/buzz:sha-<7>` or a semver release tag for production once available. - Keep `BUZZ_RELAY_PRIVATE_KEY`, `BUZZ_GIT_HOOK_HMAC_SECRET`, database/Redis, and S3 secrets stable across restarts. -- `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; it must be a - 64-character hex Nostr pubkey when closed relay mode is enabled. +- `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; set it to + the operator's NIP-19 `npub` when closed relay mode is enabled. - `BUZZ_AUTO_MIGRATE` is opt-in. Set `BUZZ_AUTO_MIGRATE=true` or run `buzz-admin migrate` before starting the relay when bootstrapping a fresh database. Auto-migration requires an image that includes embedded SQLx diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5d..b1d0b72b962 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -87,10 +87,10 @@ case "${1:-help}" in backup_hint ;; add-member) - docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member [--role member|admin]}" "${@:3}" + docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member [--role member|admin]}" "${@:3}" ;; remove-member) - docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member [--role member|admin]}" "${@:3}" + docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member [--role member|admin]}" "${@:3}" ;; list-members) docker compose exec relay /usr/local/bin/buzz-admin list-members @@ -110,9 +110,9 @@ Commands: config Render merged compose config backup-hint Print the production backup checklist - add-member [--role member|admin] + add-member [--role member|admin] Add a relay member (default role: member) - remove-member [--role member|admin] + remove-member [--role member|admin] Remove a relay member list-members List all relay members diff --git a/deploy/local/quickstart-ha-values.yaml b/deploy/local/quickstart-ha-values.yaml index 540435cf540..12275dc20fc 100644 --- a/deploy/local/quickstart-ha-values.yaml +++ b/deploy/local/quickstart-ha-values.yaml @@ -23,7 +23,7 @@ redis: minio: enabled: true relayUrl: wss://buzz.test.local -ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000001" +ownerPubkey: "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60" relay: # Testbed, not business-logic validation — same rationale as CI quickstart. requireRelayMembership: false diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14a..4523e9005e7 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -13,6 +13,9 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_display::{ + identity_from_env, identity_npub_for_log, identity_npub_for_log_str, +}; pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; @@ -135,28 +138,6 @@ pub struct AppState { pub pending_owned_channels: Mutex>, } -/// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the -/// env var was present and valid and MUST win over any persisted/keyring key -/// (the dev/CI/harness override). `None` means absent or malformed — callers -/// fall through to persisted resolution. A malformed value is logged and -/// treated as absent rather than left on an ephemeral identity. -fn identity_from_env() -> Option { - match std::env::var("BUZZ_PRIVATE_KEY") { - Ok(nsec) => match Keys::parse(nsec.trim()) { - Ok(keys) => Some(keys), - Err(error) => { - eprintln!("buzz-desktop: invalid BUZZ_PRIVATE_KEY: {error}"); - None - } - }, - Err(std::env::VarError::NotUnicode(_)) => { - eprintln!("buzz-desktop: BUZZ_PRIVATE_KEY contains invalid UTF-8"); - None - } - Err(std::env::VarError::NotPresent) => None, - } -} - /// Build the no-redirect HTTP client used for authenticated relay media /// fetches (download / copy). /// @@ -185,7 +166,7 @@ pub fn build_app_state() -> AppState { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", - keys.public_key().to_hex() + identity_npub_for_log(&keys.public_key()) ); (keys, IdentityStorage::Environment) } @@ -478,7 +459,7 @@ fn resolve_identity_with_store( Ok(keyring_keys) => { eprintln!( "buzz-desktop: persisted identity pubkey {}", - keyring_keys.public_key().to_hex() + identity_npub_for_log(&keyring_keys.public_key()) ); // Check for a leftover identity.key. If it holds a // DIFFERENT pubkey, the user imported that key after @@ -494,7 +475,7 @@ fn resolve_identity_with_store( eprintln!( "buzz-desktop: identity.key differs from keyring; \ adopting imported key {}", - file_keys.public_key().to_hex() + identity_npub_for_log(&file_keys.public_key()) ); // Delegate the store→read-back-verify→marker→delete // sequence to `persist_identity_to_keyring`, which owns @@ -605,7 +586,7 @@ fn resolve_identity_with_store( eprintln!( "buzz-desktop: identity lost — keyring was empty despite migration marker; \ using ephemeral key {}, awaiting user re-import", - ephemeral.public_key().to_hex() + identity_npub_for_log(&ephemeral.public_key()) ); return Ok(ResolvedIdentity { keys: ephemeral, @@ -634,7 +615,7 @@ fn resolve_identity_with_store( "buzz-desktop: keyring unreachable but migration marker present; \ booting keyring-locked recovery with ephemeral key {} — \ unlock the keyring and relaunch", - ephemeral.public_key().to_hex() + identity_npub_for_log(&ephemeral.public_key()) ); return Ok(ResolvedIdentity { keys: ephemeral, @@ -694,7 +675,7 @@ fn recover_from_keyring( "buzz-desktop: identity lost — keyring had corrupt data and no valid identity.key \ backup; prior identity (migration marker present) is unrecoverable; \ using ephemeral key {}, awaiting user re-import", - ephemeral.public_key().to_hex() + identity_npub_for_log(&ephemeral.public_key()) ); return Ok(ResolvedIdentity { keys: ephemeral, @@ -722,7 +703,7 @@ fn load_file_or_generate( Ok(keys) => { eprintln!( "buzz-desktop: persisted identity pubkey {}", - keys.public_key().to_hex() + identity_npub_for_log(&keys.public_key()) ); return Ok(keys); } @@ -733,7 +714,7 @@ fn load_file_or_generate( save_key_file(legacy_path, &keys)?; eprintln!( "buzz-desktop: generated and saved identity pubkey {}", - keys.public_key().to_hex() + identity_npub_for_log(&keys.public_key()) ); Ok(keys) } @@ -949,7 +930,7 @@ fn generate_and_persist( } eprintln!( "buzz-desktop: generated and saved identity pubkey {}", - keys.public_key().to_hex() + identity_npub_for_log(&keys.public_key()) ); Ok((keys, storage)) } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2df..c9f21576c85 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,7 +1,5 @@ -use tauri::State; - use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str as npub, AppState}, managed_agents::{ command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, @@ -10,6 +8,7 @@ use crate::{ nostr_convert, relay::query_relay, }; +use tauri::State; mod post_install_verification; @@ -553,20 +552,20 @@ async fn restart_single_agent_after_install( save_managed_agents(&app_for_stop, &records)?; } - // Re-verify eligibility under lock. + let who = npub(&pubkey_owned); let record = records .iter() .find(|r| r.pubkey == pubkey_owned) - .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + .ok_or_else(|| format!("agent {who} not found"))?; if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey_owned} is no longer a local agent")); + return Err(format!("agent {who} is no longer a local agent")); } let runtime_keys = crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); if runtime_keys.is_empty() { return Err(format!( - "agent {pubkey_owned} no longer has a live pair runtime after sync" + "agent {who} no longer has a live pair runtime after sync" )); } @@ -578,7 +577,7 @@ async fn restart_single_agent_after_install( known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); if !runtime_matches { return Err(format!( - "agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock" + "agent {who} runtime no longer matches {runtime_id_owned} under lock" )); } @@ -589,7 +588,7 @@ async fn restart_single_agent_after_install( .unwrap_or(false); if !setup_mode { return Err(format!( - "agent {pubkey_owned} is not in setup mode under lock — skipping" + "agent {who} is not in setup mode under lock — skipping" )); } @@ -597,7 +596,7 @@ async fn restart_single_agent_after_install( let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { return Err(format!( - "agent {pubkey_owned} readiness is still NotReady after install — not bouncing" + "agent {who} readiness is still NotReady after install — not bouncing" )); } @@ -610,15 +609,16 @@ async fn restart_single_agent_after_install( }) .await; + let who = npub(pubkey); let runtime_keys = match stop_result { Ok(Ok(runtime_keys)) => runtime_keys, Ok(Err(e)) => { - eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {pubkey}: {e}"); + eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {who}: {e}"); return InstallRestartOutcome::Skipped; } Err(e) => { eprintln!( - "buzz-desktop: install_acp_runtime: spawn_blocking failed for stop of {pubkey}: {e}" + "buzz-desktop: install_acp_runtime: spawn_blocking failed for stop of {who}: {e}" ); return InstallRestartOutcome::Skipped; } @@ -631,17 +631,17 @@ async fn restart_single_agent_after_install( { Ok(_) => { eprintln!( - "buzz-desktop: install_acp_runtime: restarted setup-mode agent {pubkey} after install" + "buzz-desktop: install_acp_runtime: restarted setup-mode agent {who} after install" ); InstallRestartOutcome::Restarted } Err(e) => { eprintln!( - "buzz-desktop: install_acp_runtime: failed to start {pubkey} after install: {e}" + "buzz-desktop: install_acp_runtime: failed to start {who} after install: {e}" ); if let Err(save_err) = persist_last_error_on_install(app, pubkey, &e) { eprintln!( - "buzz-desktop: install_acp_runtime: failed to persist last_error for {pubkey}: {save_err}" + "buzz-desktop: install_acp_runtime: failed to persist last_error for {who}: {save_err}" ); } InstallRestartOutcome::FailedAfterStop diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 183f27dba12..7922fd25c12 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -14,9 +14,8 @@ use super::agent_models_env::{ effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider, }; use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; - use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str as npub, AppState}, managed_agents::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, @@ -61,7 +60,7 @@ pub async fn get_agent_models( let record = records .iter() .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {} not found", npub(&pubkey)))?; let resolved = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; @@ -162,7 +161,8 @@ pub async fn get_agent_models( /// contract spawn and summary rows honor. fn model_discovery_error(pubkey: &str, error: &str) -> String { format!( - "cannot discover models for {pubkey}: {}", + "cannot discover models for {}: {}", + npub(pubkey), crate::managed_agents::user_facing_harness_error(error) ) } @@ -831,7 +831,7 @@ pub async fn update_managed_agent( let record = records .iter() .find(|r| r.pubkey == input.pubkey) - .ok_or_else(|| format!("agent {} not found", input.pubkey))?; + .ok_or_else(|| format!("agent {} not found", npub(&input.pubkey)))?; // Publish the edit to the relay. After-save, inside the lock, before // any .await. The retention upsert hashes the opt-IN projection, so an diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 79dd7263c61..bd4af7e92cc 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -598,15 +598,21 @@ fn databricks_passive_auth_error_has_reachable_create_flow_guidance() { fn model_discovery_error_converts_dangling_sentinel_to_sentence() { // get_agent_models is a user-facing surface: a dangling harness must // render as a sentence, never as the raw DANGLING_HARNESS_ID: sentinel. + let pubkey = Keys::generate().public_key().to_hex(); + let pubkey_display = npub(&pubkey); let raw = format!("{}doomed", crate::managed_agents::DANGLING_HARNESS_PREFIX); - let msg = model_discovery_error("agent-pk", &raw); - assert!(msg.contains("cannot discover models for agent-pk")); + let msg = model_discovery_error(&pubkey, &raw); + assert!(msg.contains(&format!("cannot discover models for {pubkey_display}"))); + assert!(!msg.contains(&pubkey)); assert!(msg.contains("\"doomed\"") && msg.contains("deleted")); assert!(!msg.contains(crate::managed_agents::DANGLING_HARNESS_PREFIX)); // Non-dangling errors pass through untouched. - let plain = model_discovery_error("agent-pk", "plain failure"); - assert_eq!(plain, "cannot discover models for agent-pk: plain failure"); + let plain = model_discovery_error(&pubkey, "plain failure"); + assert_eq!( + plain, + format!("cannot discover models for {pubkey_display}: plain failure") + ); } // --------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 2317930c1ef..9675b94547d 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -2,7 +2,7 @@ use std::sync::atomic::Ordering; use tauri::{AppHandle, Manager, State}; use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str, AppState}, managed_agents::{ build_managed_agent_summary, current_instance_id, find_managed_agent_mut, load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, @@ -55,7 +55,7 @@ pub async fn set_managed_agent_start_on_app_launch( let record = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {} not found", identity_npub_for_log_str(&pubkey)))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, @@ -106,7 +106,7 @@ pub async fn set_managed_agent_auto_restart( let record = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {} not found", identity_npub_for_log_str(&pubkey)))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 2745b3cd22b..74e63e0d658 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -1,7 +1,7 @@ use tauri::AppHandle; use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str, AppState}, managed_agents::{ load_managed_agents, save_managed_agents, try_regenerate_nest, ManagedAgentRecord, }, @@ -47,14 +47,17 @@ fn restore_agent_update( pubkey: &str, rollback: AgentUpdateRollback, ) -> Result<(), String> { + let pubkey_display = identity_npub_for_log_str(pubkey); let current = records .iter_mut() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found while rolling back failed rename"))?; + .ok_or_else(|| { + format!("agent {pubkey_display} not found while rolling back failed rename") + })?; if !same_configuration(current, &rollback.attempted_record) { return Err(format!( - "agent {pubkey} changed again before the failed rename could be rolled back" + "agent {pubkey_display} changed again before the failed rename could be rolled back" )); } @@ -78,6 +81,7 @@ pub(super) fn rollback_failed_agent_update( pubkey: &str, rollback: AgentUpdateRollback, ) -> Result<(), String> { + let pubkey_display = identity_npub_for_log_str(pubkey); { let _store_guard = state .managed_agents_store_lock @@ -89,7 +93,9 @@ pub(super) fn rollback_failed_agent_update( let restored = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found after failed rename rollback"))?; + .ok_or_else(|| { + format!("agent {pubkey_display} not found after failed rename rollback") + })?; super::agents::retain_managed_agent_pending(app, state, restored); } try_regenerate_nest(app); @@ -164,6 +170,8 @@ mod tests { .expect_err("a concurrent update must not be overwritten"); assert!(error.contains("changed again")); + assert!(error.contains("")); + assert!(!error.contains("abcd1234")); assert_eq!(records[0].name, "Newest name"); assert_eq!(records[0].updated_at, "newer"); } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 453bb81fb0c..d05da9f0363 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -4,7 +4,7 @@ use tauri::{AppHandle, State}; use super::managed_agent_definition::validate_create_definition; use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str as npub, AppState}, managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, @@ -32,7 +32,6 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { /// body after `save_managed_agents`, NEVER across an `.await`: it acquires /// `state.keys` and a retention-db connection, both `std::sync` guards, and /// drops them before returning. -/// /// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the /// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is /// `30177::`. The event content is the opt-IN @@ -272,6 +271,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( pubkey: &str, relay_urls: &[String], ) -> Result { + let who = npub(pubkey); let record_snapshot = { let _store_guard = state .managed_agents_store_lock @@ -280,10 +280,10 @@ pub(super) async fn start_local_agent_pairs_with_preflight( load_managed_agents(app)? .into_iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))? + .ok_or_else(|| format!("agent {who} not found"))? }; if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); + return Err(format!("agent {who} is not a local agent")); } let personas_for_preflight = load_personas(app).unwrap_or_default(); let global_for_preflight = @@ -346,7 +346,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( let record = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {who} not found"))?; build_managed_agent_summary( app, record, @@ -363,6 +363,7 @@ pub(super) async fn start_local_agent_with_preflight( owner_hex: &str, allow_fresh_create_start: bool, ) -> Result { + let who = npub(pubkey); let record_snapshot = { let _store_guard = state .managed_agents_store_lock @@ -373,11 +374,11 @@ pub(super) async fn start_local_agent_with_preflight( .iter() .find(|record| record.pubkey == pubkey) .cloned() - .ok_or_else(|| format!("agent {pubkey} not found"))? + .ok_or_else(|| format!("agent {who} not found"))? }; if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); + return Err(format!("agent {who} is not a local agent")); } // Preflight against the same resolution spawn uses — `resolve_effective_config` @@ -408,7 +409,7 @@ pub(super) async fn start_local_agent_with_preflight( .map_err(|e| e.to_string())?; let record = find_managed_agent_mut(&mut records, pubkey)?; if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is no longer a local agent")); + return Err(format!("agent {who} is no longer a local agent")); } // Re-snapshot the persona onto the record at every spawn so the agent always // starts with the current persona config (system_prompt, model, provider, @@ -439,7 +440,7 @@ pub(super) async fn start_local_agent_with_preflight( let record = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {who} not found"))?; build_managed_agent_summary( app, record, @@ -496,7 +497,7 @@ async fn deploy_to_provider( let rec = records .iter_mut() .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {} not found", npub(pubkey)))?; match deploy_result { Ok(backend_agent_id) => { @@ -632,7 +633,7 @@ pub async fn create_managed_agent( let keys = Keys::generate(); let pubkey = keys.public_key().to_hex(); if records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} already exists")); + return Err(format!("agent {} already exists", npub(&pubkey))); } let private_key_nsec = keys .secret_key() @@ -700,7 +701,7 @@ pub async fn create_managed_agent( // Guard against a duplicate pubkey appearing between phase 1 and phase 3 // (extremely unlikely but safe to check). if records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} already exists")); + return Err(format!("agent {} already exists", npub(&pubkey))); } // Provider config was already validated in Pre-Phase 2; cache the discovered binary path for deploy_to_provider. let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend { @@ -1068,6 +1069,7 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + let who = npub(&pubkey); // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; @@ -1168,7 +1170,7 @@ pub async fn start_managed_agent( let record = records .iter() .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {who} not found"))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, @@ -1179,7 +1181,7 @@ pub async fn start_managed_agent( ) } StartTarget::Provider { backend, .. } => Err(format!( - "agent {pubkey} has unsupported backend kind: {backend:?}" + "agent {who} has unsupported backend kind: {backend:?}" )), }; @@ -1197,14 +1199,13 @@ pub async fn start_managed_agent( let reconcile_app = app.clone(); tauri::async_runtime::spawn(async move { use tauri::Manager; + let who = npub(&reconcile_pubkey); let state = reconcile_app.state::(); if let Err(e) = reconcile_agent_profile(&state, &reconcile_app, &reconcile_pubkey, &reconcile_data) .await { - eprintln!( - "buzz-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}" - ); + eprintln!("buzz-desktop: profile reconciliation failed for agent {who}: {e}"); } }); } @@ -1256,7 +1257,7 @@ pub async fn stop_managed_agent( let record = records .iter() .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + .ok_or_else(|| format!("agent {} not found", npub(&pubkey)))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, @@ -1328,7 +1329,7 @@ pub async fn delete_managed_agent( let initial_len = records.len(); records.retain(|record| record.pubkey != pubkey); if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); + return Err(format!("agent {} not found", npub(&pubkey))); } save_managed_agents(&app, &records)?; // Remove the agent's nsec from the keyring after the record is gone. diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9c..781842dff81 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use tauri::AppHandle; use crate::{ - app_state::AppState, + app_state::{identity_npub_for_log_str, AppState}, managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, record_agent_command, @@ -284,19 +284,20 @@ async fn restart_local_agent_on_config_change( } // Re-check eligibility under lock with current record state. + let pubkey_display = identity_npub_for_log_str(&pubkey_owned); let record = records .iter() .find(|r| r.pubkey == pubkey_owned) - .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + .ok_or_else(|| format!("agent {pubkey_display} not found"))?; if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey_owned} is no longer a local agent")); + return Err(format!("agent {pubkey_display} is no longer a local agent")); } let runtime_keys = crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); if runtime_keys.is_empty() { return Err(format!( - "agent {pubkey_owned} no longer has a live pair runtime after sync" + "agent {pubkey_display} no longer has a live pair runtime after sync" )); } @@ -318,7 +319,7 @@ async fn restart_local_agent_on_config_change( let env_changed = old_ready && old_effective.env != new_effective.env; if !should_restart_on_config_change(old_ready, new_ready, env_changed) { return Err(format!( - "agent {pubkey_owned} restart condition no longer valid under lock" + "agent {pubkey_display} restart condition no longer valid under lock" )); } @@ -331,15 +332,18 @@ async fn restart_local_agent_on_config_change( }) .await; + let pubkey_display = identity_npub_for_log_str(pubkey); let runtime_keys = match stop_result { Ok(Ok(runtime_keys)) => runtime_keys, Ok(Err(e)) => { - eprintln!("buzz-desktop: set_global_agent_config: skipping restart of {pubkey}: {e}"); + eprintln!( + "buzz-desktop: set_global_agent_config: skipping restart of {pubkey_display}: {e}" + ); return RestartOutcome::Skipped; } Err(e) => { eprintln!( - "buzz-desktop: set_global_agent_config: spawn_blocking failed for stop of {pubkey}: {e}" + "buzz-desktop: set_global_agent_config: spawn_blocking failed for stop of {pubkey_display}: {e}" ); return RestartOutcome::Skipped; } @@ -353,17 +357,17 @@ async fn restart_local_agent_on_config_change( { Ok(_) => { eprintln!( - "buzz-desktop: set_global_agent_config: restarted agent {pubkey} with updated config" + "buzz-desktop: set_global_agent_config: restarted agent {pubkey_display} with updated config" ); RestartOutcome::Restarted } Err(e) => { eprintln!( - "buzz-desktop: set_global_agent_config: failed to start {pubkey} after restart: {e}" + "buzz-desktop: set_global_agent_config: failed to start {pubkey_display} after restart: {e}" ); if let Err(save_err) = persist_last_error(app, pubkey, &e) { eprintln!( - "buzz-desktop: set_global_agent_config: failed to persist last_error for {pubkey}: {save_err}" + "buzz-desktop: set_global_agent_config: failed to persist last_error for {pubkey_display}: {save_err}" ); } RestartOutcome::FailedAfterStop diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e7..fbadbe6dfa6 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -28,7 +28,9 @@ fn truncated_display_name(pubkey: &PublicKey) -> Result { pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; let pubkey = keys.public_key(); - let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = pubkey + .to_bech32() + .map_err(|error| format!("encode identity npub: {error}"))?; let display_name = truncated_display_name(&pubkey)?; let lost = state .identity_lost @@ -41,7 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result .load(std::sync::atomic::Ordering::Acquire); Ok(IdentityInfo { - pubkey: pubkey_hex, + pubkey: pubkey_npub, display_name, storage: state.identity_storage().as_str().to_string(), lost, @@ -257,7 +259,6 @@ pub async fn create_ncryptsec_backup( #[serde(rename_all = "camelCase")] pub struct BackupVerification { pub pubkey: String, - pub npub: String, pub matches_current_identity: bool, } @@ -270,8 +271,7 @@ fn verify_ncryptsec_backup_inner( let pubkey = keys.public_key(); let current = state.signing_keys()?.public_key(); Ok(BackupVerification { - pubkey: pubkey.to_hex(), - npub: pubkey + pubkey: pubkey .to_bech32() .map_err(|e| format!("encode backup identity: {e}"))?, matches_current_identity: pubkey == current, @@ -370,13 +370,15 @@ pub async fn import_identity( crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) })?; - let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; + let pubkey_npub = pubkey + .to_bech32() + .map_err(|error| format!("encode imported identity npub: {error}"))?; - eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex); + eprintln!("buzz-desktop: imported identity pubkey {pubkey_npub}"); Ok(IdentityInfo { - pubkey: pubkey_hex, + pubkey: pubkey_npub, display_name, storage: storage.as_str().to_string(), lost: false, @@ -505,11 +507,13 @@ pub async fn persist_current_identity( .store(false, std::sync::atomic::Ordering::Release); let pubkey = keys.public_key(); - let pubkey_hex = pubkey.to_hex(); + let pubkey_npub = pubkey + .to_bech32() + .map_err(|error| format!("encode identity npub: {error}"))?; let display_name = truncated_display_name(&pubkey)?; Ok(IdentityInfo { - pubkey: pubkey_hex, + pubkey: pubkey_npub, display_name, storage: storage.as_str().to_string(), lost: false, diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs index c36af66879a..e2c8dd3a659 100644 --- a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -14,9 +14,8 @@ fn verification_returns_only_public_identity_and_match_status() { let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); assert_eq!( result.pubkey, - state.keys.lock().unwrap().public_key().to_hex() + state.keys.lock().unwrap().public_key().to_bech32().unwrap() ); - assert!(result.npub.starts_with("npub1")); assert!(result.matches_current_identity); } @@ -26,7 +25,7 @@ fn verification_reports_valid_backup_for_a_different_identity() { let other = Keys::generate(); let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap(); let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); - assert_eq!(result.pubkey, other.public_key().to_hex()); + assert_eq!(result.pubkey, other.public_key().to_bech32().unwrap()); assert!(!result.matches_current_identity); } diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c1..9dbf4defa61 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -138,17 +138,8 @@ async fn start_pairing_session( if mode == PairingMode::SendIdentity { let keys = state.signing_keys()?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|e| format!("encode nsec: {e}"))?; - let payload_json = serde_json::json!({ - "relayUrl": http_url, - "pubkey": keys.public_key().to_hex(), - "nsec": nsec, - }); *pairing.payload.lock().map_err(|e| e.to_string())? = - Some(Zeroizing::new(payload_json.to_string())); + Some(identity_pairing_payload(&http_url, &keys)?); } { @@ -178,6 +169,28 @@ async fn start_pairing_session( Ok(qr_uri) } +fn identity_pairing_payload( + relay_url: &str, + keys: &nostr::Keys, +) -> Result, String> { + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let npub = keys + .public_key() + .to_bech32() + .map_err(|e| format!("encode npub: {e}"))?; + Ok(Zeroizing::new( + serde_json::json!({ + "relayUrl": relay_url, + "npub": npub, + "nsec": nsec, + }) + .to_string(), + )) +} + /// User confirmed the SAS codes match. Sends sas-confirm + payload. #[tauri::command] pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<(), String> { diff --git a/desktop/src-tauri/src/commands/pairing_generation_tests.rs b/desktop/src-tauri/src/commands/pairing_generation_tests.rs index 8a2291ae86f..e2fc6b5d554 100644 --- a/desktop/src-tauri/src/commands/pairing_generation_tests.rs +++ b/desktop/src-tauri/src/commands/pairing_generation_tests.rs @@ -2,12 +2,28 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; +use nostr::ToBech32; + use super::{ - clear_pairing_session_if_current, commit_recovery_if_current, invalidate_pairing_generation, - recovery_result_after_completion, validate_recovery_payload_type, PairingHandle, - PairingSession, PayloadType, + clear_pairing_session_if_current, commit_recovery_if_current, identity_pairing_payload, + invalidate_pairing_generation, recovery_result_after_completion, + validate_recovery_payload_type, PairingHandle, PairingSession, PayloadType, }; +#[test] +fn identity_transfer_payload_uses_npub_and_nsec_only() { + let keys = nostr::Keys::generate(); + let payload = identity_pairing_payload("https://relay.example", &keys).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + + assert_eq!(parsed["relayUrl"], "https://relay.example"); + assert_eq!(parsed["npub"], keys.public_key().to_bech32().unwrap()); + assert_eq!(parsed["nsec"], keys.secret_key().to_bech32().unwrap()); + assert!(parsed.get("pubkey").is_none()); + assert!(!payload.contains(&keys.public_key().to_hex())); + assert!(!payload.contains(&keys.secret_key().to_secret_hex())); +} + #[tokio::test] async fn overlapping_starts_are_serialized() { let pairing = Arc::new(PairingHandle::new()); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39ac..9ad37d688bb 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,4 +1,4 @@ -use nostr::Keys; +use nostr::{Keys, ToBech32}; use serde::{Deserialize, Serialize}; use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter, Manager, State}; @@ -85,7 +85,10 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result, fallback: &str) -> String { @@ -270,7 +268,8 @@ pub async fn start_huddle( match submit_event(add_builder, &state).await { Ok(_) => successful_agents.push(pubkey.clone()), Err(e) => { - eprintln!("buzz-desktop: huddle add_member failed for {pubkey}: {e}"); + let who = crate::app_state::identity_npub_for_log_str(pubkey); + eprintln!("buzz-desktop: huddle add_member failed for {who}: {e}"); // Intentionally not added — policy rejected this agent. } } @@ -572,7 +571,8 @@ async fn remove_huddle_agents(ephemeral_channel_id: &str, state: &AppState) { continue; }; if let Err(e) = submit_event(remove_builder, state).await { - eprintln!("buzz-desktop: remove huddle agent {pubkey} failed: {e}"); + let who = crate::app_state::identity_npub_for_log_str(&pubkey); + eprintln!("buzz-desktop: remove huddle agent {who} failed: {e}"); } } } diff --git a/desktop/src-tauri/src/identity_display.rs b/desktop/src-tauri/src/identity_display.rs new file mode 100644 index 00000000000..3983a4956e7 --- /dev/null +++ b/desktop/src-tauri/src/identity_display.rs @@ -0,0 +1,64 @@ +use nostr::{Keys, PublicKey}; + +pub(crate) fn identity_npub_for_log(pubkey: &PublicKey) -> String { + buzz_core_pkg::nostr_identity::public_key_to_npub(pubkey) + .unwrap_or_else(|error| format!("")) +} + +/// Render an internal hex or canonical npub identity for diagnostics and UI errors. +/// Invalid values become a non-key sentinel instead of leaking the input. +pub(crate) fn identity_npub_for_log_str(pubkey: &str) -> String { + buzz_core_pkg::nostr_identity::parse_public_key_compat(pubkey) + .map(|(pubkey, _)| identity_npub_for_log(&pubkey)) + .unwrap_or_else(|_| "".to_string()) +} + +/// Read the explicit environment identity, accepting secret hex only for migration. +pub(crate) fn identity_from_env() -> Option { + match std::env::var("BUZZ_PRIVATE_KEY") { + Ok(nsec) => match buzz_core_pkg::nostr_identity::parse_secret_key_compat(nsec.trim()) { + Ok((secret_key, encoding)) => { + if encoding == buzz_core_pkg::nostr_identity::KeyInputEncoding::LegacyHex { + eprintln!( + "buzz-desktop: BUZZ_PRIVATE_KEY uses legacy secret hex; store the canonical nsec form" + ); + } + Some(Keys::new(secret_key)) + } + Err(_) => { + eprintln!("buzz-desktop: invalid BUZZ_PRIVATE_KEY; expected a canonical nsec"); + None + } + }, + Err(std::env::VarError::NotUnicode(_)) => { + eprintln!("buzz-desktop: BUZZ_PRIVATE_KEY contains invalid UTF-8"); + None + } + Err(std::env::VarError::NotPresent) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_log_display_uses_npub() { + let pubkey = Keys::generate().public_key(); + let display = identity_npub_for_log(&pubkey); + assert!(display.starts_with("npub1")); + assert_ne!(display, pubkey.to_hex()); + } + + #[test] + fn identity_string_display_normalizes_hex_and_redacts_invalid_values() { + let pubkey = Keys::generate().public_key(); + let expected = identity_npub_for_log(&pubkey); + assert_eq!(identity_npub_for_log_str(&pubkey.to_hex()), expected); + assert_eq!(identity_npub_for_log_str(&expected), expected); + assert_eq!( + identity_npub_for_log_str("not-a-public-key"), + "" + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..86361ed76f9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_display; mod identity_storage; mod initial_window; mod key_backup; @@ -98,8 +99,7 @@ pub fn run() { { Ok(runtime) => { tauri::async_runtime::set(runtime.handle().clone()); - // Keep the runtime alive for the process lifetime; dropping it - // would shut down the workers Tauri now depends on. + // Keep the runtime alive; dropping it would shut down Tauri's workers. std::mem::forget(runtime); eprintln!( "buzz-mesh: installed tokio runtime with {} MiB worker stacks", diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 5b51c522551..f083c8e11bf 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -41,8 +41,11 @@ //! placed into `AgentSnapshotDefinition`) and asserted by unit tests. use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_core_pkg::nostr_identity::{parse_public_key_compat, public_key_to_npub}; +use nostr::PublicKey; use png::{BitDepth, ColorType, Decoder, Encoder}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashSet; use std::io::Cursor; use crate::managed_agents::types::ManagedAgentRecord; @@ -107,9 +110,18 @@ pub struct AgentSnapshotDefinition { pub parallelism: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub respond_to: Option, - /// Allowlist entries. These are flagged during import — they come from the - /// source environment and are meaningless on the importer's relay. - #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Allowlist entries. Portable artifacts serialize these as canonical + /// npubs. Deserialization accepts v1's legacy hex representation and + /// normalizes both encodings to the protocol-native lowercase hex used by + /// managed-agent records. Entries are still flagged during import because + /// they come from the source environment and may be meaningless on the + /// importer's relay. + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_respond_to_allowlist", + deserialize_with = "deserialize_respond_to_allowlist" + )] pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub name_pool: Vec, @@ -173,6 +185,56 @@ pub struct AgentSnapshot { pub memory: AgentSnapshotMemory, } +/// Parse a portable allowlist member and return the protocol-native public +/// key. Snapshot v1 readers accept both canonical npub and the legacy hex +/// representation so existing cards remain importable. +fn parse_snapshot_allowlist_pubkey(value: &str) -> Result { + let (public_key, _) = parse_public_key_compat(value) + .map_err(|_| "invalid public key in snapshot respondToAllowlist".to_string())?; + public_key + .xonly() + .map_err(|_| "invalid public key in snapshot respondToAllowlist".to_string())?; + Ok(public_key) +} + +/// Normalize a portable allowlist to the lowercase hex form consumed by +/// Nostr authorization and event-filter internals. Duplicate identities are +/// removed while preserving their first-seen order. +fn normalize_respond_to_allowlist(input: &[String]) -> Result, String> { + let mut seen = HashSet::new(); + let mut normalized = Vec::with_capacity(input.len()); + for entry in input { + let hex = parse_snapshot_allowlist_pubkey(entry)?.to_hex(); + if seen.insert(hex.clone()) { + normalized.push(hex); + } + } + Ok(normalized) +} + +fn serialize_respond_to_allowlist(input: &[String], serializer: S) -> Result +where + S: Serializer, +{ + let canonical = input + .iter() + .map(|entry| { + let public_key = + parse_snapshot_allowlist_pubkey(entry).map_err(serde::ser::Error::custom)?; + public_key_to_npub(&public_key).map_err(serde::ser::Error::custom) + }) + .collect::, _>>()?; + canonical.serialize(serializer) +} + +fn deserialize_respond_to_allowlist<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let input = Vec::::deserialize(deserializer)?; + normalize_respond_to_allowlist(&input).map_err(serde::de::Error::custom) +} + // ── Builder / encoder ──────────────────────────────────────────────────────── /// Materialize a snapshot manifest from a `ManagedAgentRecord`. @@ -412,6 +474,7 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> .unwrap_or_default(), ) .map_err(|error| format!("Snapshot definition is unsafe: {error}"))?; + normalize_respond_to_allowlist(&snapshot.definition.respond_to_allowlist)?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073d..7848e55d173 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -22,6 +22,7 @@ //! partial plaintext or crypto details. use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX; +use buzz_core_pkg::nostr_identity::{parse_public_key_compat, public_key_to_npub}; use nostr::nips::nip44::{self, Version}; use nostr::{Keys, PublicKey, SecretKey}; use serde::{Deserialize, Serialize}; @@ -72,10 +73,12 @@ pub struct LockedSnapshotEnvelope { pub struct LockedEncryption { /// Always [`LOCKED_SCHEME`]. pub scheme: String, - /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor - /// knows which counterparty to pair with. + /// Owner identity pubkey (canonical npub). Plaintext so a decryptor knows + /// which counterparty to pair with. Version 1 readers also accept the + /// legacy 64-character lowercase hex encoding. pub owner_pubkey: String, - /// Agent instance pubkey (64 lowercase hex). + /// Agent instance pubkey (canonical npub). Version 1 readers also accept + /// the legacy 64-character lowercase hex encoding. pub agent_pubkey: String, /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON. pub ciphertext: String, @@ -100,24 +103,33 @@ struct FormatProbe { // ── Validation ──────────────────────────────────────────────────────────────── -/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a -/// valid x-only pubkey. Lowercase is required so string comparisons against -/// record pubkeys (always `to_hex()` output) stay sound. Curve validation is -/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers -/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would -/// otherwise pass structurally and fail only at decrypt time. +/// Parse a canonical npub or the legacy v1 lowercase-hex representation as a +/// valid x-only pubkey. Curve validation is explicit: nostr's hex parser only +/// decodes 32 bytes and defers lift-x validation to `xonly()`, so a non-point +/// like `"f" * 64` would otherwise pass structurally and fail only at decrypt +/// time. pub(crate) fn parse_canonical_pubkey(field: &str, value: &str) -> Result { - if value.len() != 64 - || !value + let is_canonical_npub = value.starts_with("npub1") && value == value.to_ascii_lowercase(); + let is_legacy_hex = value.len() == 64 + && value .chars() - .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) - { + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)); + if !is_canonical_npub && !is_legacy_hex { return Err(format!( - "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)." + "Locked card envelope has a malformed {field} (expected a canonical npub or legacy 64-character lowercase hex key)." )); } - let pubkey = PublicKey::from_hex(value) + let (pubkey, _) = parse_public_key_compat(value) .map_err(|_| format!("Locked card envelope has an invalid {field}."))?; + if is_canonical_npub + && public_key_to_npub(&pubkey) + .map_err(|_| format!("Locked card envelope has an invalid {field}."))? + != value + { + return Err(format!( + "Locked card envelope has a malformed {field} (npub is not canonical)." + )); + } pubkey .xonly() .map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?; @@ -185,9 +197,15 @@ pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result { if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES { return Err("Locked card envelope exceeds the maximum size.".to_string()); } - let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) + let mut envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) .map_err(|e| format!("Invalid locked card envelope: {e}"))?; - validate_envelope(&envelope)?; + let (owner, agent) = validate_envelope(&envelope)?; + // Read legacy v1 hex endpoints, but never let a successfully + // imported portable envelope escape or reserialize in hex. + envelope.encryption.owner_pubkey = public_key_to_npub(&owner) + .map_err(|_| "Locked card envelope has an invalid ownerPubkey.".to_string())?; + envelope.encryption.agent_pubkey = public_key_to_npub(&agent) + .map_err(|_| "Locked card envelope has an invalid agentPubkey.".to_string())?; Ok(ChunkPayload::Locked(envelope)) } Some(other) => Err(format!("Unsupported snapshot format: {other:?}")), @@ -226,13 +244,18 @@ pub fn encrypt_snapshot_envelope( ) .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + let owner_npub = public_key_to_npub(&owner_keys.public_key()) + .map_err(|e| format!("Failed to encode owner npub: {e}"))?; + let agent_npub = public_key_to_npub(agent_pubkey) + .map_err(|e| format!("Failed to encode agent npub: {e}"))?; + Ok(LockedSnapshotEnvelope { format: LOCKED_FORMAT.to_string(), version: LOCKED_VERSION, encryption: LockedEncryption { scheme: LOCKED_SCHEME.to_string(), - owner_pubkey: owner_keys.public_key().to_hex(), - agent_pubkey: agent_pubkey.to_hex(), + owner_pubkey: owner_npub, + agent_pubkey: agent_npub, ciphertext, }, }) @@ -273,16 +296,17 @@ pub fn resolve_unlock_secret( owner_keys: Option<&Keys>, records: &[ManagedAgentRecord], ) -> Option { + let (owner_pubkey, agent_pubkey) = validate_envelope(envelope).ok()?; if let Some(keys) = owner_keys { - if keys.public_key().to_hex() == envelope.encryption.owner_pubkey { + if keys.public_key() == owner_pubkey { return Some(keys.secret_key().clone()); } } - let record = records - .iter() - .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?; + let record = records.iter().find(|record| { + PublicKey::from_hex(&record.pubkey).is_ok_and(|record_pubkey| record_pubkey == agent_pubkey) + })?; let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?; - if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey { + if agent_keys.public_key() != agent_pubkey { return None; } Some(agent_keys.secret_key().clone()) @@ -443,6 +467,52 @@ mod tests { assert_eq!(decoded, sample_snapshot()); } + #[test] + fn envelope_export_uses_npub_endpoints_without_hex_keys() { + let (env, owner, agent) = locked_envelope(); + let json = serde_json::to_string(&env).unwrap(); + + assert!(env.encryption.owner_pubkey.starts_with("npub1")); + assert!(env.encryption.agent_pubkey.starts_with("npub1")); + assert!(!json.contains(&owner.public_key().to_hex())); + assert!(!json.contains(&agent.public_key().to_hex())); + } + + #[test] + fn legacy_v1_hex_endpoints_still_validate_and_decrypt() { + let (mut env, owner, agent) = locked_envelope(); + env.encryption.owner_pubkey = owner.public_key().to_hex(); + env.encryption.agent_pubkey = agent.public_key().to_hex(); + + assert_eq!( + validate_envelope(&env).unwrap(), + (owner.public_key(), agent.public_key()) + ); + assert_eq!( + decrypt_envelope(&env, owner.secret_key()).unwrap(), + sample_snapshot() + ); + } + + #[test] + fn legacy_v1_hex_endpoints_normalize_when_parsed_for_reexport() { + let (mut env, owner, agent) = locked_envelope(); + let owner_hex = owner.public_key().to_hex(); + let agent_hex = agent.public_key().to_hex(); + env.encryption.owner_pubkey = owner_hex.clone(); + env.encryption.agent_pubkey = agent_hex.clone(); + + let bytes = serde_json::to_vec(&env).unwrap(); + let ChunkPayload::Locked(parsed) = parse_chunk_payload(&bytes).unwrap() else { + panic!("expected locked envelope"); + }; + let reexported = serde_json::to_string(&parsed).unwrap(); + assert!(parsed.encryption.owner_pubkey.starts_with("npub1")); + assert!(parsed.encryption.agent_pubkey.starts_with("npub1")); + assert!(!reexported.contains(&owner_hex)); + assert!(!reexported.contains(&agent_hex)); + } + #[test] fn unrelated_key_fails_closed_with_refusal_only() { let (env, _owner, _agent) = locked_envelope(); @@ -487,7 +557,9 @@ mod tests { // derive the wrong conversation key — the NIP-44 MAC fails and only // the refusal surfaces. let (mut env, owner, _agent) = locked_envelope(); - env.encryption.agent_pubkey = Keys::generate().public_key().to_hex(); + env.encryption.agent_pubkey = + buzz_core_pkg::nostr_identity::public_key_to_npub(&Keys::generate().public_key()) + .unwrap(); let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); assert_eq!(err, LOCKED_CARD_REFUSAL); } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e59..344a0f26530 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -7,6 +7,9 @@ use super::*; use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; use std::collections::BTreeMap; +const ALLOWLIST_MEMBER_HEX: &str = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + /// Build a minimal `ManagedAgentRecord` for testing. Only the fields /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { @@ -69,7 +72,7 @@ fn minimal_record() -> ManagedAgentRecord { source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear definition_respond_to: Some("allowlist".to_string()), catalog_source: None, - definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_respond_to_allowlist: vec![ALLOWLIST_MEMBER_HEX.to_string()], definition_parallelism: Some(4), relay_mesh: None, } @@ -86,6 +89,48 @@ fn json_round_trip_config_only() { assert_eq!(parsed, snapshot); } +#[test] +fn json_export_uses_npub_allowlist_without_member_hex() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let json = String::from_utf8(bytes.clone()).unwrap(); + let public_key = nostr::PublicKey::from_hex(ALLOWLIST_MEMBER_HEX).unwrap(); + let npub = buzz_core_pkg::nostr_identity::public_key_to_npub(&public_key).unwrap(); + + assert!(json.contains(&npub), "portable artifact must contain npub"); + assert!( + !json.contains(ALLOWLIST_MEMBER_HEX), + "portable artifact must not contain the allowlist member's hex key" + ); + + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!( + parsed.definition.respond_to_allowlist, + vec![ALLOWLIST_MEMBER_HEX.to_string()], + "import must normalize npub to protocol-native hex" + ); +} + +#[test] +fn json_import_accepts_legacy_hex_allowlist_and_normalizes_case() { + let mut value = serde_json::to_value(build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + None, + )) + .unwrap(); + value["definition"]["respondToAllowlist"] = + serde_json::json!([ALLOWLIST_MEMBER_HEX.to_ascii_uppercase()]); + let parsed = decode_snapshot_json(value.to_string().as_bytes()).unwrap(); + + assert_eq!( + parsed.definition.respond_to_allowlist, + vec![ALLOWLIST_MEMBER_HEX.to_string()] + ); +} + #[test] fn json_round_trip_with_memory() { let record = minimal_record(); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ccd3709c0..811278fdb37 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -34,6 +34,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +mod setup_payload; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6fd..08d9a2098a3 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -31,7 +31,7 @@ fn init_nest_dir_prod_sets_buzz() { #[test] fn nest_skill_contains_safe_mention_workflow() { - assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify")); assert!(BUZZ_CLI_SKILL_MD .contains("permits unresolved or ambiguous `@Name` text as presentation-only")); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d4..940d49102b7 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -11,7 +11,7 @@ version: 1 ## Environment -`BUZZ_PRIVATE_KEY` is set by the harness at runtime or by the developer's environment. If missing, tell the user to set it (hex or nsec format). Never read or echo the value. +`BUZZ_PRIVATE_KEY` is set by the harness at runtime or by the developer's environment. It is a canonical `nsec`; legacy hex is accepted only as a migration input. If missing, tell the user to set an `nsec`. Never read or echo the value. `BUZZ_RELAY_URL` defaults to `http://localhost:3000`. In development, the user may need to set this to a staging or production relay URL. @@ -43,7 +43,7 @@ Run `buzz agents draft-update --help` for optional runtime, provider, model, ren ## Git Repositories -Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `/git//` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id --clone /git//`, then `git remote add origin ` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol. +Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it. Human-facing identity uses `npub`; the owner segment in the Git transport URL remains the 64-character hex protocol coordinate required by the relay. Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `/git//` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id --clone /git//`, then `git remote add origin ` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol. Manage your repository's enforced branch and tag rules with `repos protect list|set|remove`. Ref patterns must use full Git names such as `refs/heads/main` or `refs/tags/*`; supported rules are `--push owner|admin|member`, `--no-force-push`, `--no-delete`, and `--require-patch`. `protect set` replaces the complete rule for that exact pattern, so omitted constraints are removed. Protection updates preserve every unrelated metadata tag and return exit code 5 when a newer NIP-33 head wins a concurrent write. @@ -51,7 +51,7 @@ Manage your repository's enforced branch and tag rules with `repos protect list| Output varies by command group — `--help` shows flags but not response shapes. -**Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`). +**Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Human-facing identity fields use `npub`; event IDs, tags, filters, and other raw Nostr protocol fields remain hex. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`). **Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. @@ -87,16 +87,16 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. +**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. ```bash buzz messages send --channel \ - --content "@Alice check this" --mention + --content "@Alice check this" --mention ``` ## DM Management -`dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `. +`dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `. ## Channel Policies @@ -127,7 +127,7 @@ buzz messages send --channel \ 4. **`dms open` returns `dm_id`** — use this value as `--channel` for subsequent `messages send/get` commands on that DM. 5. **Content max 65,536 bytes** (exit 1 if exceeded). Diffs auto-truncate at 61,440 bytes at a hunk boundary. 6. **`users get` always returns an array** — even for a single pubkey lookup. Never expect a bare object. -7. **All `mem` subcommands accept `--owner `** — for querying or writing memories owned by a different pubkey in multi-agent scenarios. Defaults to the owner from `BUZZ_AUTH_TAG`. +7. **All `mem` subcommands accept `--owner `** — for querying or writing memories owned by a different pubkey in multi-agent scenarios. Defaults to the owner from `BUZZ_AUTH_TAG`. 8. **`mem rm` cannot delete `core`** — use `mem set core ''` instead. ## Forum Posts diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec60..742b95c04b3 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -3,7 +3,7 @@ use super::{ save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; -use crate::app_state::AppState; +use crate::app_state::{identity_npub_for_log_str, AppState}; use crate::util; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; @@ -66,7 +66,7 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { eprintln!( "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", - record.pubkey + identity_npub_for_log_str(&record.pubkey) ); continue; }; @@ -464,7 +464,10 @@ pub async fn restore_managed_agents_on_launch( crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) .await { - eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); + eprintln!( + "buzz-desktop: profile reconciliation failed for agent {}: {e}", + identity_npub_for_log_str(&pubkey) + ); } }); } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..65ffe42cfaa 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -64,6 +64,7 @@ use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; // Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). mod lifecycle; +use super::setup_payload::serialize_setup_payload; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; @@ -562,7 +563,8 @@ pub fn spawn_agent_child( // when desktop has computed NotReady — the desktop is the sole readiness // source and buzz-acp only transports the payload. // - // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: + // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp. Identity + // is canonical npub at this app-defined boundary (never record hex): // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } // // `spawned_setup_mode` is captured outside the block so it can be stamped @@ -623,16 +625,11 @@ pub fn spawn_agent_child( }), }) .collect(); - let payload = serde_json::json!({ - "agent_name": record.name, - "agent_pubkey": record.pubkey, - "requirements": reqs, - }); - match serde_json::to_string(&payload) { + match serialize_setup_payload(&record.name, &record.pubkey, reqs) { Ok(json) => Some(json), Err(e) => { eprintln!( - "buzz-desktop: failed to serialize setup payload for {}: {e}", + "buzz-desktop: refused setup payload for {}: {e}", record.name ); None diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b19..522aa3359f0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -81,7 +81,12 @@ fn observer_lifecycle_key( outer_pubkey: &str, payload: &super::ManagedAgentRuntimeLifecycleObserverPayload, ) -> Result { - if !outer_pubkey.eq_ignore_ascii_case(&payload.pubkey) { + let (outer_pubkey, _) = buzz_core_pkg::nostr_identity::parse_public_key_compat(outer_pubkey) + .map_err(|_| "observer signer is not a valid public key".to_string())?; + let (payload_pubkey, _) = + buzz_core_pkg::nostr_identity::parse_public_key_compat(&payload.pubkey) + .map_err(|_| "lifecycle payload pubkey is not a valid public key".to_string())?; + if outer_pubkey != payload_pubkey { return Err("observer signer does not match lifecycle payload pubkey".into()); } if matches!( @@ -96,7 +101,7 @@ fn observer_lifecycle_key( if payload.lifecycle != ManagedAgentRuntimeLifecycle::Failed && payload.error.is_some() { return Err("lifecycle error is only valid for failed".into()); } - ManagedAgentRuntimeKey::new(payload.pubkey.clone(), &payload.relay_url) + ManagedAgentRuntimeKey::new(payload_pubkey.to_hex(), &payload.relay_url) } #[tauri::command] @@ -680,6 +685,20 @@ mod tests { assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); } + #[test] + fn observer_lifecycle_accepts_npub_payload_for_hex_signer() { + let mut ready = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + let signer_hex = ready.pubkey.clone(); + ready.pubkey = buzz_core_pkg::nostr_identity::canonical_npub(&signer_hex).unwrap(); + + let key = observer_lifecycle_key(&signer_hex, &ready).unwrap(); + assert_eq!(key.pubkey, signer_hex); + } + #[test] fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { let ready = payload( diff --git a/desktop/src-tauri/src/managed_agents/setup_payload.rs b/desktop/src-tauri/src/managed_agents/setup_payload.rs new file mode 100644 index 00000000000..5940c8dbfd0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/setup_payload.rs @@ -0,0 +1,47 @@ +use buzz_core_pkg::nostr_identity::{parse_public_key_compat, public_key_to_npub}; + +/// Serialize the app-defined setup payload with a canonical npub identity. +/// Records remain protocol hex internally; legacy npub records are accepted defensively. +pub(super) fn serialize_setup_payload( + agent_name: &str, + agent_pubkey: &str, + requirements: Vec, +) -> Result { + let (public_key, _) = parse_public_key_compat(agent_pubkey) + .map_err(|_| "agent record has an invalid public key".to_string())?; + let agent_npub = + public_key_to_npub(&public_key).map_err(|_| "failed to encode agent npub".to_string())?; + serde_json::to_string(&serde_json::json!({ + "agent_name": agent_name, + "agent_pubkey": agent_npub, + "requirements": requirements, + })) + .map_err(|error| format!("failed to serialize setup payload: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const AGENT_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn serialization_emits_npub_without_record_hex() { + let json = serialize_setup_payload( + "Fizz", + AGENT_HEX, + vec![serde_json::json!({"surface": "env_key", "key": "TOKEN"})], + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(value["agent_pubkey"].as_str().unwrap().starts_with("npub1")); + assert!(!json.contains(AGENT_HEX)); + } + + #[test] + fn serialization_rejects_invalid_record_key_without_echoing_it() { + let error = serialize_setup_payload("Fizz", "not-a-public-key", vec![]).unwrap_err(); + assert!(error.contains("invalid public key")); + assert!(!error.contains("not-a-public-key")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea8..754f9a37cbe 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -7,7 +7,7 @@ use std::{ use tauri::{AppHandle, Manager}; -use crate::app_state::keyring_service; +use crate::app_state::{identity_npub_for_log_str, keyring_service}; use crate::managed_agents::{ ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, }; @@ -209,7 +209,7 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key Err(e) => { eprintln!( "buzz-desktop: keyring write for agent {} failed ({e}), keeping inline", - record.pubkey + identity_npub_for_log_str(&record.pubkey) ); KeyMigration::KeptInline } @@ -330,7 +330,7 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) Ok(None) => { eprintln!( "buzz-desktop: agent {} has no key in JSON or keyring", - record.pubkey + identity_npub_for_log_str(&record.pubkey) ); } // Outage, NOT absence: the key may exist in the keyring but is @@ -340,7 +340,7 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) eprintln!( "buzz-desktop: agent {} key unavailable — keyring read failed ({e}); \ agent will be refused until the keyring is reachable", - record.pubkey + identity_npub_for_log_str(&record.pubkey) ); } } @@ -591,7 +591,10 @@ pub(crate) fn try_delete_agent_key(pubkey: &str) -> Result<(), String> { /// is deleted so its secret does not linger in the OS store. pub fn delete_agent_key(pubkey: &str) { if let Err(e) = try_delete_agent_key(pubkey) { - eprintln!("buzz-desktop: failed to delete agent {pubkey} key from keyring: {e}"); + eprintln!( + "buzz-desktop: failed to delete agent {} key from keyring: {e}", + identity_npub_for_log_str(pubkey) + ); } } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 768b2ad7db3..e3a4ccf37bb 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Deserializer, Serialize}; #[derive(Serialize)] pub struct IdentityInfo { + /// Canonical npub at the Tauri boundary. Frontend protocol code decodes it + /// to hex once inside `tauriIdentity.ts`. pub pubkey: String, pub display_name: String, /// Durable location of the active identity key. diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a306b..0b952ee631a 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -9,6 +9,7 @@ import { import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; import type { SearchHit } from "@/shared/api/types"; +import { safeNpub } from "@/shared/lib/nostrUtils"; type NavigationBehavior = { replace?: boolean; @@ -80,14 +81,17 @@ export function useAppNavigation() { ); const goProfile = React.useCallback( - (pubkey: string, behavior?: NavigationBehavior) => - commitNavigation( + (pubkey: string, behavior?: NavigationBehavior) => { + const profile = safeNpub(pubkey); + if (!profile) return Promise.resolve(false); + return commitNavigation( { to: "/pulse", - search: { profile: pubkey }, + search: { profile }, }, behavior, - ), + ); + }, [commitNavigation], ); @@ -194,7 +198,7 @@ export function useAppNavigation() { } : {}), ...(options?.agentSession - ? { agentSession: options.agentSession } + ? { agentSession: safeNpub(options.agentSession) ?? undefined } : {}), ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), diff --git a/desktop/src/app/routes/agents.tsx b/desktop/src/app/routes/agents.tsx index b32dc5a9962..4aadb062e2f 100644 --- a/desktop/src/app/routes/agents.tsx +++ b/desktop/src/app/routes/agents.tsx @@ -7,6 +7,7 @@ import { type ProfilePanelTab, type ProfilePanelView, } from "@/features/profile/ui/UserProfilePanelUtils"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type AgentsRouteSearch = { @@ -24,7 +25,10 @@ function validateAgentsSearch( search: Record, ): AgentsRouteSearch { return { - profile: nonEmptyString(search.profile), + profile: + typeof search.profile === "string" + ? (safeNpub(search.profile) ?? undefined) + : undefined, profilePersona: nonEmptyString(search.profilePersona), profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, profileView: parseProfilePanelView(search.profileView) ?? undefined, diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 892eef6a56c..6ce18e79d9a 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -9,6 +9,7 @@ import { } from "@/features/profile/ui/UserProfilePanelUtils"; import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { @@ -35,10 +36,16 @@ function validateChannelSearch( search: Record, ): ChannelRouteSearch { return { - agentSession: nonEmptyString(search.agentSession), + agentSession: + typeof search.agentSession === "string" + ? (safeNpub(search.agentSession) ?? undefined) + : undefined, autoSend: nonEmptyString(search.autoSend), messageId: nonEmptyString(search.messageId), - profile: nonEmptyString(search.profile), + profile: + typeof search.profile === "string" + ? (safeNpub(search.profile) ?? undefined) + : undefined, profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, profileView: parseProfilePanelView(search.profileView) ?? undefined, thread: nonEmptyString(search.thread), diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 82deb4ac733..df5c6be73c9 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -9,6 +9,7 @@ import { WELCOME_CHANNEL_READY_EVENT, } from "@/features/onboarding/welcome"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { safeNpub } from "@/shared/lib/nostrUtils"; type HomeRouteSearch = { item?: string; @@ -24,8 +25,8 @@ function validateHomeSearch(search: Record): HomeRouteSearch { ? search.item : undefined, profile: - typeof search.profile === "string" && search.profile.length > 0 - ? search.profile + typeof search.profile === "string" + ? (safeNpub(search.profile) ?? undefined) : undefined, profileTab: typeof search.profileTab === "string" && search.profileTab.length > 0 diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 49544287487..795813e5892 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; import { usePreviewFeatureWarning } from "@/shared/features"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectDetailScreen = React.lazy(async () => { @@ -21,6 +22,14 @@ export const Route = createFileRoute("/projects/$projectId")({ issueId: typeof search.issueId === "string" ? search.issueId : undefined, repositoryId: typeof search.repositoryId === "string" ? search.repositoryId : undefined, + profile: + typeof search.profile === "string" + ? (safeNpub(search.profile) ?? undefined) + : undefined, + profileTab: + typeof search.profileTab === "string" ? search.profileTab : undefined, + profileView: + typeof search.profileView === "string" ? search.profileView : undefined, }), }); diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index 297265a34fb..1d090cb4f27 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -8,6 +8,7 @@ import { type ProfilePanelView, } from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const PulseScreen = React.lazy(async () => { @@ -26,8 +27,8 @@ function validatePulseSearch( ): PulseRouteSearch { return { profile: - typeof search.profile === "string" && search.profile.length > 0 - ? search.profile + typeof search.profile === "string" + ? (safeNpub(search.profile) ?? undefined) : undefined, profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, profileView: parseProfilePanelView(search.profileView) ?? undefined, diff --git a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs index bbe07f72040..dd100ad1cdd 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.test.mjs +++ b/desktop/src/features/agents/lib/respondToAllowlist.test.mjs @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { npubEncode } from "nostr-tools/nip19"; import { mergeAllowlist, parsePubkeyInput } from "./respondToAllowlist.ts"; -const HEX_A = "a".repeat(64); -const HEX_B = "b".repeat(64); -const HEX_A_UPPER = "A".repeat(64); +const HEX_A = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const HEX_B = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; +const HEX_A_UPPER = HEX_A.toUpperCase(); +const NPUB_A = npubEncode(HEX_A); test("parsePubkeyInput splits on commas, whitespace, and newlines", () => { const input = `${HEX_A}, ${HEX_B}\n${HEX_A_UPPER}`; @@ -25,11 +29,17 @@ test("parsePubkeyInput surfaces invalid entries separately", () => { assert.deepEqual(result.invalid, ["notgood", "z".repeat(64)]); }); -test("parsePubkeyInput rejects npub-style strings (hex only)", () => { - const npub = `npub1${"a".repeat(59)}`; - const result = parsePubkeyInput(npub); +test("parsePubkeyInput accepts canonical npubs and normalizes to protocol hex", () => { + const result = parsePubkeyInput(`${NPUB_A} ${HEX_A}`); + assert.deepEqual(result.valid, [HEX_A]); + assert.deepEqual(result.invalid, []); +}); + +test("parsePubkeyInput rejects npubs with invalid checksums", () => { + const invalidNpub = `${NPUB_A.slice(0, -1)}q`; + const result = parsePubkeyInput(invalidNpub); assert.deepEqual(result.valid, []); - assert.deepEqual(result.invalid, [npub]); + assert.deepEqual(result.invalid, [invalidNpub]); }); test("parsePubkeyInput rejects wrong-length entries", () => { @@ -51,7 +61,7 @@ test("mergeAllowlist preserves existing order and appends new", () => { }); test("mergeAllowlist dedupes case-insensitively", () => { - const merged = mergeAllowlist([HEX_A], [HEX_A_UPPER]); + const merged = mergeAllowlist([NPUB_A], [HEX_A_UPPER]); assert.deepEqual(merged, [HEX_A]); }); diff --git a/desktop/src/features/agents/lib/respondToAllowlist.ts b/desktop/src/features/agents/lib/respondToAllowlist.ts index c376aa1d1af..3f204eaf283 100644 --- a/desktop/src/features/agents/lib/respondToAllowlist.ts +++ b/desktop/src/features/agents/lib/respondToAllowlist.ts @@ -7,7 +7,7 @@ * round-trip, and to normalize input so the Rust validator sees clean data. */ -const HEX_64 = /^[0-9a-f]{64}$/i; +import { parsePubkeyInput as parseSinglePubkeyInput } from "@/shared/lib/nostrUtils"; export type ParsedAllowlist = { /** Successfully parsed entries — lowercase hex, deduplicated, in order. */ @@ -22,8 +22,8 @@ export type ParsedAllowlist = { * pattern used by `ChannelMemberInviteCard` so users have one mental model. * * - Splits on `/[\s,]+/`. - * - Trims and lowercases each entry. - * - Validates each entry is exactly 64 hex chars. + * - Accepts canonical npubs plus legacy hex at this paste boundary. + * - Normalizes valid entries to lowercase hex for Nostr protocol internals. * - Deduplicates while preserving insertion order. */ export function parsePubkeyInput(raw: string): ParsedAllowlist { @@ -33,14 +33,14 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { for (const piece of raw.split(/[\s,]+/)) { const trimmed = piece.trim(); if (trimmed.length === 0) continue; - if (!HEX_64.test(trimmed)) { + const pubkey = parseSinglePubkeyInput(trimmed); + if (!pubkey) { invalid.push(trimmed); continue; } - const lower = trimmed.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - valid.push(lower); + if (!seen.has(pubkey)) { + seen.add(pubkey); + valid.push(pubkey); } } return { valid, invalid }; @@ -51,13 +51,13 @@ export function parsePubkeyInput(raw: string): ParsedAllowlist { * deduplicating without reordering existing entries. */ export function mergeAllowlist(existing: string[], add: string[]): string[] { - const seen = new Set(existing.map((p) => p.toLowerCase())); - const out = [...existing.map((p) => p.toLowerCase())]; - for (const candidate of add) { - const lower = candidate.toLowerCase(); - if (!HEX_64.test(lower) || seen.has(lower)) continue; - seen.add(lower); - out.push(lower); + const seen = new Set(); + const out: string[] = []; + for (const candidate of [...existing, ...add]) { + const pubkey = parseSinglePubkeyInput(candidate); + if (!pubkey || seen.has(pubkey)) continue; + seen.add(pubkey); + out.push(pubkey); } return out; } diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e86180..b56581b8057 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -11,6 +11,7 @@ import { import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; +import { PubKey } from "@/shared/ui/PubKey"; import { Dialog, DialogContent, @@ -18,7 +19,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { CopyButton } from "./CopyButton"; export function AddAgentToChannelDialog({ agent, @@ -181,16 +181,15 @@ export function AddAgentToChannelDialog({
-

- Agent pubkey -

-
- - {agent?.pubkey ?? "No agent selected"} - +

Agent npub

+
{agent ? ( - - ) : null} + + ) : ( + + No agent selected + + )}
diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index 0527565763a..6546480e1db 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -5,6 +5,7 @@ import type { AgentSnapshotImportPreview, AgentSnapshotImportResult, } from "@/features/agents/hooks"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -36,6 +37,28 @@ type AgentSnapshotImportDialogProps = { onOpenChange: (open: boolean) => void; }; +function manifestForDisplay(manifestJson: string): string { + try { + const manifest = JSON.parse(manifestJson) as { + definition?: { respondToAllowlist?: unknown }; + }; + const allowlist = manifest.definition?.respondToAllowlist; + if (Array.isArray(allowlist)) { + manifest.definition = { + ...manifest.definition, + respondToAllowlist: allowlist.map((value) => + typeof value === "string" + ? (safeNpub(value) ?? "Invalid public key") + : "Invalid public key", + ), + }; + } + return JSON.stringify(manifest, null, 2); + } catch { + return "Unable to display snapshot manifest."; + } +} + // ── Component ───────────────────────────────────────────────────────────────── export function AgentSnapshotImportDialog({ @@ -250,7 +273,7 @@ export function PreviewBody({ > {preview.sourceAllowlist.map((pubkey) => (
  • - {pubkey} + {safeNpub(pubkey) ?? "Invalid public key"}
  • ))} @@ -295,7 +318,7 @@ export function PreviewBody({ credentials, and source identity are not part of the snapshot format.

    -          {preview.manifestJson}
    +          {manifestForDisplay(preview.manifestJson)}
             
    diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d8..1ceecf28122 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -20,6 +20,7 @@ import { } from "@/shared/context/ProfilePanelContext"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { parsePubkeyInput, safeNpub } from "@/shared/lib/nostrUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const AgentsView = React.lazy(async () => { @@ -48,7 +49,8 @@ export function AgentsScreen() { const profilePanelView = profilePanelViewFromSearch(values.profileView); const profilePanelTarget = React.useMemo(() => { if (values.profile) { - return { kind: "pubkey", pubkey: values.profile }; + const pubkey = parsePubkeyInput(values.profile); + if (pubkey) return { kind: "pubkey", pubkey }; } if (values.profilePersona) { @@ -68,8 +70,10 @@ export function AgentsScreen() { const handleOpenProfilePanel = React.useCallback( (pubkey: string, options?: ProfilePanelOpenOptions) => { + const profile = safeNpub(pubkey); + if (!profile) return; applyPatch({ - profile: pubkey, + profile, profilePersona: null, profileTab: options?.tab === "info" ? null : (options?.tab ?? null), profileView: null, diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 589d4c8c7ad..4a8bc5c1df9 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -10,6 +10,7 @@ import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { RespondToMode, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { parsePubkeyInput as parseSinglePubkeyInput } from "@/shared/lib/nostrUtils"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -278,8 +279,6 @@ export function CreateAgentRespondToField({ ); } -const HEX_64_RE = /^[0-9a-f]{64}$/i; - function AllowlistPicker({ allowlist, deferredQuery, @@ -325,10 +324,10 @@ function AllowlistPicker({ }) { const isPersona = variant === "persona"; - // Detect if the query is a valid hex pubkey that's not already in the list. - const queryIsHexPubkey = - HEX_64_RE.test(deferredQuery) && - !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase()); + const directPubkey = parseSinglePubkeyInput(deferredQuery); + const queryIsDirectPubkey = + directPubkey !== null && + !allowlist.some((pubkey) => pubkey.toLowerCase() === directPubkey); return (
    ))}
    - ) : queryIsHexPubkey ? ( + ) : queryIsDirectPubkey ? ( {isDirectEntryOpen ? (

    - One per line, or comma/space-separated. 64-char lowercase hex - only — npub decoding is not yet supported here. + One npub per line, or comma/space-separated.