Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -131,9 +131,17 @@ async fn run(cli: Cli) -> Result<i32> {
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 => {
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/onboarding/lib/keyImportInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/features/onboarding/lib/keyImportInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
* 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" | "unknown";
export type KeyImportKind = "nsec" | "ncryptsec" | "hex" | "unknown";

const NCRYPTSEC_HRP = "ncryptsec";
const NIP49_VERSION = 2;
Expand Down Expand Up @@ -72,6 +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_64_REGEX.test(trimmed)) return "hex";
return "unknown";
}

Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/onboarding/ui/MembershipDenied.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -478,7 +478,7 @@ export function NostrKeyImportForm({
<p className="text-sm text-muted-foreground">
{isEncryptedInput
? "Waiting for a complete ncryptsec backup"
: "Waiting for a valid nsec1 key"}
: "Waiting for a valid nsec1… or hex key"}
</p>
) : null}

Expand Down
55 changes: 55 additions & 0 deletions desktop/src/shared/lib/nostrUtils.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 42 additions & 9 deletions desktop/src/shared/lib/nostrUtils.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -23,7 +24,8 @@ export function safeNpub(pubkey: string): string | null {
}
}

const HEX_PUBKEY_REGEX = /^[0-9a-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
Expand All @@ -35,7 +37,7 @@ const HEX_PUBKEY_REGEX = /^[0-9a-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")) {
Expand All @@ -52,20 +54,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_64_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;
}
Expand Down