SkateSpots/src/lib/ncryptsec.ts
SkateSpots 257ce05a25 feat(auth): NIP-49 encrypted keys + NIP-46 nostrconnect QR login
Two Nostr-native auth upgrades (read-only npub sessions were declined).

NIP-49 (src/lib/ncryptsec.ts):
- Login: the Secret Key tab gains an 'Encrypted (NIP-49)' mode — paste
  ncryptsec1… + password -> decrypt -> login.
- Signup: the done step's key card exports a password-protected backup, which
  is safe to keep in cloud storage (unlike a raw nsec).
- Verified in Node: round-trips; wrong password, non-ncryptsec and empty
  password all rejected.

NIP-46 nostrconnect (src/lib/nostrConnect.ts):
- Desktop -> phone pairing via QR. Nostrify implements only the bunker
  direction; nostrconnect is signer-initiated, so the handshake lives here
  (ephemeral client keypair + secret, subscribe kind 24133 #p:[clientPub],
  reply {id,result:secret} signed with the ephemeral key), then hands the
  derived bunker:// URI to login.bunker().
- Try NIP-44 then NIP-04 (the transport moved NIP-04 -> NIP-44; support varies).
- LoginDialog's Bunker tab leads with the QR; bunker:// paste stays a fallback.
- NOT verified against a real signer — needs an on-device Amber check.

Docs: AGENTS.md.
2026-10-01 12:00:08 +07:00

47 lines
2.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}