/** * NIP-49 — password-encrypted private keys (`ncryptsec1…`) * ───────────────────────────────────────────────────────────────────────────── * A raw `nsec` in a text file is the worst artefact to hand a new user. NIP-49 * wraps the key with scrypt + XChaCha20-Poly1305 under a password, so the blob * is safe to store in cloud storage, email, or a password manager — and it's a * portable login credential in its own right. * * Thin wrapper around `nostr-tools/nip49` (verified: round-trips, and a wrong * password is rejected by the AEAD, not by luck). */ import * as nip49 from 'nostr-tools/nip49'; import { nip19 } from 'nostr-tools'; export const NCRYPTSEC_PREFIX = 'ncryptsec1'; /** scrypt work factor. 16 ≈ 2^16 rounds — the usual NIP-49 default. */ const LOG_N = 16; /** Encrypt a bech32 `nsec` into a password-protected `ncryptsec`. */ export function encryptNsec(nsec: string, password: string): string { if (!password) throw new Error('Choose a password first.'); const decoded = nip19.decode(nsec.trim()); if (decoded.type !== 'nsec') throw new Error('That is not a valid nsec.'); return nip49.encrypt(decoded.data, password, LOG_N); } /** Decrypt a `ncryptsec` back to a bech32 `nsec`. Throws on a wrong password. */ export function decryptNcryptsec(ncryptsec: string, password: string): string { const value = ncryptsec.trim(); if (!value.startsWith(NCRYPTSEC_PREFIX)) { throw new Error('That doesn’t look like an encrypted key (it should start with ncryptsec1).'); } try { const secret = nip49.decrypt(value, password); return nip19.nsecEncode(secret); } catch { // The AEAD fails on a wrong password or a damaged blob; we can't tell which. throw new Error('Wrong password — or the encrypted key is damaged.'); } } /** Cheap shape check so the UI can switch mode without a decode attempt. */ export function isNcryptsec(value: string): boolean { return value.trim().startsWith(NCRYPTSEC_PREFIX); }