// Nostr/secp256k1 primary keys. Schnorr (BIP-340), bech32 nsec/npub. // Mirrors Rust's NostrSecret. Signatures are deterministic (auxRand = zeros) // to match the Rust `sign_schnorr_no_aux_rand` path. import { schnorr } from "@noble/curves/secp256k1"; import { bech32 } from "@scure/base"; import { bytesToHex } from "@noble/hashes/utils"; import { Identity, validateNpub } from "./identity.js"; const ZERO_AUX = new Uint8Array(32); export class NostrSecret { private readonly secretKey: Uint8Array; // 32 bytes private constructor(secretKey: Uint8Array) { if (secretKey.length !== 32) { throw new Error(`secret key must be 32 bytes, got ${secretKey.length}`); } this.secretKey = secretKey; } static generate(): NostrSecret { return new NostrSecret(schnorr.utils.randomPrivateKey()); } static fromNsec(nsec: string): NostrSecret { const decoded = bech32.decode(nsec as `${string}1${string}`, 1023); if (decoded.prefix !== "nsec") { throw new Error(`expected nsec bech32, got hrp=${decoded.prefix}`); } const bytes = bech32.fromWords(decoded.words); return new NostrSecret(Uint8Array.from(bytes)); } /** Lowercase 32-byte x-only public key, hex-encoded. */ pubkeyHex(): string { return bytesToHex(schnorr.getPublicKey(this.secretKey)); } nsec(): string { return bech32.encode("nsec", bech32.toWords(this.secretKey), 1023); } npub(): string { const pubkey = schnorr.getPublicKey(this.secretKey); return bech32.encode("npub", bech32.toWords(pubkey), 1023); } identity(): Identity { return Identity.parse(`nostr:${this.npub()}`); } /** * Sign a digest with deterministic Schnorr (zero auxRand) to match Rust's * `sign_schnorr_no_aux_rand`. Returns a 64-byte BIP-340 signature. */ signDigest(digest: Uint8Array): Uint8Array { if (digest.length !== 32) { throw new Error(`digest must be 32 bytes, got ${digest.length}`); } return schnorr.sign(digest, this.secretKey, ZERO_AUX); } } /** Lowercase 32-byte x-only public key (hex) for a `nostr:npub1...` identity. */ export function nostrPubkeyHex(identity: Identity): string { if (identity.scheme !== "nostr") { throw new Error(`expected nostr: identity, got: ${identity}`); } const decoded = bech32.decode(identity.id as `${string}1${string}`, 1023); if (decoded.prefix !== "npub") { throw new Error(`expected npub bech32, got hrp=${decoded.prefix}`); } return bytesToHex(Uint8Array.from(bech32.fromWords(decoded.words))); } /** Verify a Schnorr signature over a 32-byte digest. */ export function verifySchnorr( signature: Uint8Array, digest: Uint8Array, pubkeyHex: string, ): boolean { // Normalize hex → bytes via noble's utility (accepts hex strings or bytes). return schnorr.verify(signature, digest, pubkeyHex); } /** Decode an npub to its raw x-only pubkey bytes. */ export function npubToPubkeyBytes(npub: string): Uint8Array { validateNpub(npub); const decoded = bech32.decode(npub as `${string}1${string}`, 1023); return Uint8Array.from(bech32.fromWords(decoded.words)); }