From bdbb2449e818d078fc5209825859792b4edcee10 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 3 Aug 2026 15:47:52 +0530 Subject: [PATCH 1/4] fix(admin): print nsec alongside hex from generate-key buzz-admin was only emitting a hex secret, so operators following the bootstrap hint could not paste into desktop onboarding (#2815). Signed-off-by: Taksh --- crates/buzz-admin/src/main.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4f..cd8e2925903 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -28,7 +28,7 @@ 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 nostr::{EventBuilder, Keys, Kind, Tag, ToBech32}; use tracing::warn; #[derive(Parser)] @@ -131,9 +131,17 @@ 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!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity."); + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| anyhow::anyhow!("encode nsec: {e}"))?; + println!("Public key (hex): {}", keys.public_key().to_hex()); + println!("Public key (npub): {}", keys.public_key().to_bech32()?); + println!("Secret key (hex): {}", keys.secret_key().display_secret()); + println!("Secret key (nsec): {nsec}"); + println!( + "\nSet BUZZ_PRIVATE_KEY to the hex or nsec secret. Desktop onboarding accepts both." + ); Ok(0) } Command::Migrate => { From 2e6aacad9f307d06249f600d20a7228d50ed674d Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 3 Aug 2026 15:47:52 +0530 Subject: [PATCH 2/4] fix(desktop): accept 64-char hex secrets in key import Match Keys::parse / BUZZ_PRIVATE_KEY so buzz-admin generate-key output enables Next instead of sitting on 'waiting for nsec1' (#2815, #3880). Signed-off-by: Taksh --- .../features/onboarding/lib/keyImportInput.ts | 6 ++- .../onboarding/ui/MembershipDenied.tsx | 4 +- .../onboarding/ui/NostrKeyImportForm.tsx | 6 +-- desktop/src/shared/lib/nostrUtils.ts | 48 ++++++++++++++++--- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts index 0f6fc609ed7..fb96611fe1a 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.ts +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -11,7 +11,10 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils"; -export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; +export type KeyImportKind = "nsec" | "ncryptsec" | "hex" | "unknown"; + +/** 32-byte secret as 64 hex chars — `buzz-admin generate-key` / BUZZ_PRIVATE_KEY. */ +const HEX_SECRET_REGEX = /^[0-9a-fA-F]{64}$/; const NCRYPTSEC_HRP = "ncryptsec"; const NIP49_VERSION = 2; @@ -72,6 +75,7 @@ export function classifyKeyImportInput(input: string): KeyImportKind { // case routes there too and fails in Rust with the accurate error. if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; if (trimmed.startsWith("nsec1")) return "nsec"; + if (HEX_SECRET_REGEX.test(trimmed)) return "hex"; return "unknown"; } diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index 3de6857d31b..583b7ad98bd 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -65,7 +65,7 @@ export function MembershipDenied({ const handleImportKey = React.useCallback(async () => { if (!previewNpub) { setImportError( - "That doesn't look like a valid nsec. Paste an nsec1 key.", + "That doesn't look like a valid key. Paste an nsec1… or 64-char hex secret.", ); return; } @@ -186,7 +186,7 @@ export function MembershipDenied({ setNsecInput(event.target.value); setImportError(null); }} - placeholder="nsec1..." + placeholder="nsec1… or hex secret" spellCheck={false} type="password" value={nsecInput} diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b4..505eb3b5d45 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -151,7 +151,7 @@ export function NostrKeyImportForm({ ? "Enter the password for this key backup." : isEncryptedInput ? "That doesn't look like a complete ncryptsec backup." - : "That doesn't look like a valid nsec. Paste an nsec1 key.", + : "That doesn't look like a valid key. Paste an nsec1… or 64-char hex secret.", ); return; } @@ -273,7 +273,7 @@ export function NostrKeyImportForm({ setNsecInput(event.target.value); setImportError(null); }} - placeholder="nsec1..." + placeholder="nsec1… or hex" ref={inputRef} spellCheck={false} type="password" @@ -478,7 +478,7 @@ export function NostrKeyImportForm({

{isEncryptedInput ? "Waiting for a complete ncryptsec backup" - : "Waiting for a valid nsec1 key"} + : "Waiting for a valid nsec1… or hex key"}

) : null} diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index d98c6ee8cfb..634a59ecf78 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -1,4 +1,5 @@ -import { decode, npubEncode } from "nostr-tools/nip19"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19"; import { getPublicKey } from "nostr-tools/pure"; /** @@ -24,6 +25,8 @@ export function safeNpub(pubkey: string): string | null { } const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; +/** 32-byte secret as 64 hex chars — what `buzz-admin generate-key` prints. */ +const HEX_SECRET_REGEX = /^[0-9a-fA-F]{64}$/; /** * Parse user-entered public key input — either a 64-character hex pubkey or @@ -52,20 +55,51 @@ export function parsePubkeyInput(input: string): string | null { } /** - * Decode a bech32 nsec string and derive the matching npub. Returns null if - * the input is not a syntactically valid `nsec1…` (does NOT throw — this is - * intended for live form validation where the user is mid-typing). + * Normalize a pasted private key to bech32 `nsec1…`. + * + * Accepts either `nsec1…` or a 64-char hex secret (what `buzz-admin + * generate-key` prints / `BUZZ_PRIVATE_KEY` accepts). Returns null for + * anything else — does not throw; intended for live form validation. + */ +export function normalizePrivateKeyToNsec(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.startsWith("nsec1")) { + try { + const decoded = decode(trimmed); + if (decoded.type !== "nsec") { + return null; + } + return trimmed; + } catch { + return null; + } + } + if (HEX_SECRET_REGEX.test(trimmed)) { + try { + return nsecEncode(hexToBytes(trimmed.toLowerCase())); + } catch { + return null; + } + } + return null; +} + +/** + * Decode a private key (bech32 `nsec1…` or 64-char hex) and derive the + * matching npub. Returns null if the input is not a syntactically valid + * secret (does NOT throw — this is intended for live form validation where + * the user is mid-typing). * * The input is trimmed first; surrounding whitespace from copy-paste or a * dropped `.key` file is tolerated. */ export function nsecToNpub(nsec: string): string | null { - const trimmed = nsec.trim(); - if (!trimmed.startsWith("nsec1")) { + const normalized = normalizePrivateKeyToNsec(nsec); + if (!normalized) { return null; } try { - const decoded = decode(trimmed); + const decoded = decode(normalized); if (decoded.type !== "nsec") { return null; } From b680811809b7cb4a61eda2f6316ea8c3d1b2add1 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 3 Aug 2026 15:47:52 +0530 Subject: [PATCH 3/4] test(desktop): cover hex private-key import gating Lock buzz-admin hex secrets through classify/submit and npub preview. Signed-off-by: Taksh --- .../onboarding/lib/keyImportInput.test.mjs | 10 ++++ desktop/src/shared/lib/nostrUtils.test.mjs | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 desktop/src/shared/lib/nostrUtils.test.mjs diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs index bc0bb4b4d7d..6c1671e6246 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -23,12 +23,22 @@ const VALID_NSEC = nsecEncode(generateSecretKey()); test("classify_by_hrp_with_whitespace_tolerance", () => { assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput("a".repeat(64)), "hex"); assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); assert.equal(classifyKeyImportInput(""), "unknown"); // nsec must not be shadowed by the longer HRP check. assert.equal(classifyKeyImportInput("nsec1"), "nsec"); }); +test("submit_gating_hex_secret_from_buzz_admin", () => { + const sk = generateSecretKey(); + const hex = Buffer.from(sk).toString("hex"); + assert.equal(classifyKeyImportInput(hex), "hex"); + assert.equal(keyImportSubmitEnabled(hex, ""), true); + assert.equal(keyImportSubmitEnabled(hex.toUpperCase(), ""), true); + assert.equal(keyImportSubmitEnabled("ab".repeat(31), ""), false); // 62 chars +}); + test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => { // Bech32 permits an all-uppercase encoding; it must route to the // encrypted path (matching Rust) and be submit-plausible. diff --git a/desktop/src/shared/lib/nostrUtils.test.mjs b/desktop/src/shared/lib/nostrUtils.test.mjs new file mode 100644 index 00000000000..c3cba693d2e --- /dev/null +++ b/desktop/src/shared/lib/nostrUtils.test.mjs @@ -0,0 +1,55 @@ +/** + * Pure-logic tests for private-key normalization (nsec vs buzz-admin hex). + */ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { hexToBytes } from "@noble/hashes/utils.js"; +import { nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; + +import { + normalizePrivateKeyToNsec, + nsecToNpub, + pubkeyToNpub, +} from "./nostrUtils.ts"; + +const SECRET_HEX = + "0000000000000000000000000000000000000000000000000000000000000001"; +const SECRET_NSEC = nsecEncode(hexToBytes(SECRET_HEX)); +const EXPECTED_NPUB = pubkeyToNpub(getPublicKey(hexToBytes(SECRET_HEX))); + +describe("normalizePrivateKeyToNsec", () => { + test("accepts nsec1 bech32", () => { + assert.equal(normalizePrivateKeyToNsec(` ${SECRET_NSEC}\n`), SECRET_NSEC); + }); + + test("accepts 64-char hex (buzz-admin generate-key output)", () => { + assert.equal(normalizePrivateKeyToNsec(SECRET_HEX), SECRET_NSEC); + assert.equal( + normalizePrivateKeyToNsec(SECRET_HEX.toUpperCase()), + SECRET_NSEC, + ); + }); + + test("rejects garbage", () => { + assert.equal(normalizePrivateKeyToNsec("nsec1notvalid"), null); + assert.equal(normalizePrivateKeyToNsec("00"), null); + assert.equal(normalizePrivateKeyToNsec("npub1whatever"), null); + }); +}); + +describe("nsecToNpub", () => { + test("derives npub from nsec and hex secrets", () => { + assert.equal(nsecToNpub(SECRET_NSEC), EXPECTED_NPUB); + assert.equal(nsecToNpub(SECRET_HEX), EXPECTED_NPUB); + const random = generateSecretKey(); + const hex = Buffer.from(random).toString("hex"); + assert.equal(nsecToNpub(hex), nsecToNpub(nsecEncode(random))); + }); + + test("returns null for incomplete input", () => { + assert.equal(nsecToNpub("nsec1"), null); + assert.equal(nsecToNpub("00"), null); + }); +}); From 73a03712d1a12291d2b5f2a5f3ca3714818b9edf Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 3 Aug 2026 19:26:08 +0530 Subject: [PATCH 4/4] fix(desktop): share case-insensitive HEX_64_REGEX for key import Align pubkey/secret hex tolerance and stop duplicating the pattern in keyImportInput. Signed-off-by: Taksh --- desktop/src/features/onboarding/lib/keyImportInput.ts | 7 ++----- desktop/src/shared/lib/nostrUtils.ts | 9 ++++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts index fb96611fe1a..3a7a594c0c5 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.ts +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -9,13 +9,10 @@ * when the form can safely switch modes. */ -import { nsecToNpub } from "@/shared/lib/nostrUtils"; +import { HEX_64_REGEX, nsecToNpub } from "@/shared/lib/nostrUtils"; export type KeyImportKind = "nsec" | "ncryptsec" | "hex" | "unknown"; -/** 32-byte secret as 64 hex chars — `buzz-admin generate-key` / BUZZ_PRIVATE_KEY. */ -const HEX_SECRET_REGEX = /^[0-9a-fA-F]{64}$/; - const NCRYPTSEC_HRP = "ncryptsec"; const NIP49_VERSION = 2; const NIP49_PAYLOAD_BYTES = 91; @@ -75,7 +72,7 @@ export function classifyKeyImportInput(input: string): KeyImportKind { // case routes there too and fails in Rust with the accurate error. if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; if (trimmed.startsWith("nsec1")) return "nsec"; - if (HEX_SECRET_REGEX.test(trimmed)) return "hex"; + if (HEX_64_REGEX.test(trimmed)) return "hex"; return "unknown"; } diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index 634a59ecf78..e6f7b823177 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -24,9 +24,8 @@ export function safeNpub(pubkey: string): string | null { } } -const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; -/** 32-byte secret as 64 hex chars — what `buzz-admin generate-key` prints. */ -const HEX_SECRET_REGEX = /^[0-9a-fA-F]{64}$/; +/** 32-byte key material as 64 hex chars (pubkey or secret; case-insensitive). */ +export const HEX_64_REGEX = /^[0-9a-fA-F]{64}$/; /** * Parse user-entered public key input — either a 64-character hex pubkey or @@ -38,7 +37,7 @@ const HEX_SECRET_REGEX = /^[0-9a-fA-F]{64}$/; */ export function parsePubkeyInput(input: string): string | null { const trimmed = input.trim().toLowerCase(); - if (HEX_PUBKEY_REGEX.test(trimmed)) { + if (HEX_64_REGEX.test(trimmed)) { return trimmed; } if (trimmed.startsWith("npub1")) { @@ -74,7 +73,7 @@ export function normalizePrivateKeyToNsec(input: string): string | null { return null; } } - if (HEX_SECRET_REGEX.test(trimmed)) { + if (HEX_64_REGEX.test(trimmed)) { try { return nsecEncode(hexToBytes(trimmed.toLowerCase())); } catch {