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.
47 lines
2.1 KiB
TypeScript
47 lines
2.1 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|