diff options
| -rw-r--r-- | lib/legacynsec.ts | 62 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | routes/users.ts | 20 |
3 files changed, 78 insertions, 5 deletions
diff --git a/lib/legacynsec.ts b/lib/legacynsec.ts new file mode 100644 index 00000000..0d0a2e6a --- /dev/null +++ b/lib/legacynsec.ts @@ -0,0 +1,62 @@ +import { warn } from "$lib/logging"; +import { bech32m, hex } from "@scure/base"; +import { entropyToMnemonic } from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english.js"; +import { getPublicKey } from "nostr-tools"; +import { privateKeyFromSeedWords } from "nostr-tools/nip06"; +import { encrypt as nip49encrypt } from "nostr-tools/nip49"; + +// Accounts created before the switch to NIP-49 (Dec 2024) hold their nostr key +// as `cipher` + `salt`: BIP-39 entropy, AES-GCM encrypted under +// PBKDF2-SHA256(password, salt, 100k) with an all-zero 16-byte IV, bech32m +// encoded with the "en" prefix. The key is the NIP-06 derivation of the +// mnemonic. Returns the secret key, or undefined if the password doesn't open +// it (e.g. it was changed after the UI stopped re-encrypting the cipher). +const decryptLegacy = async ({ cipher, salt }, password: string) => { + try { + const key = await crypto.subtle.deriveKey( + { name: "PBKDF2", salt: hex.decode(salt), iterations: 100000, hash: "SHA-256" }, + await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ), + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + + const entropy = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: new Uint8Array(16) }, + key, + Uint8Array.from(bech32m.fromWords(bech32m.decode(cipher, 180).words)), + ); + + return privateKeyFromSeedWords( + entropyToMnemonic(new Uint8Array(entropy), wordlist), + ); + } catch (e) {} +}; + +// Given a verified password, convert a legacy cipher into a NIP-49 nsec on the +// user record so the normal reveal/sign paths work. Only adopts the key if it +// still matches the account's pubkey. Returns true if the user was changed. +export const upgradeLegacyNsec = async (user, password: string) => { + if (user.nsec || !user.cipher || !user.salt || !password) return false; + + const sk = await decryptLegacy(user, password); + if (!sk) { + warn("legacy nsec: password doesn't open cipher", user.username); + return false; + } + + if (getPublicKey(sk) !== user.pubkey) { + warn("legacy nsec: cipher key doesn't match pubkey", user.username); + return false; + } + + user.nsec = nip49encrypt(sk, password); + return true; +}; diff --git a/package.json b/package.json index 01a3a5e4..734a21bf 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@fastify/websocket": "^11.3.0", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", + "@scure/bip39": "^2.0.1", "bech32": "^2.0.0", "buffer-crc32": "^0.2.13", "clightning-client": "github:asoltys/lightning-client-js#master", diff --git a/routes/users.ts b/routes/users.ts index 684f361b..fce74843 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -5,6 +5,7 @@ import config from "$config"; import { requirePin } from "$lib/auth"; import { db, g, gf, s } from "$lib/db"; import { err, l, warn } from "$lib/logging"; +import { upgradeLegacyNsec } from "$lib/legacynsec"; import { mail, templates } from "$lib/mail"; import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; import register from "$lib/register"; @@ -104,7 +105,7 @@ export default { // The password-encrypted nostr key is only handed out by /login, after // the password (and 2FA) check. A bearer token alone mustn't be enough // to fetch it and brute-force or decrypt it offline. - user.hasnsec = !!user.nsec; + user.hasnsec = !!(user.nsec || user.cipher); user.prompt = !!user.prompt; if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey); @@ -461,13 +462,17 @@ export default { user.password.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", 10, ); + let changed = false; if (cost < 12) { user.password = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 12, }); - await s(`user:${user.id}`, user); + changed = true; } + + if (await upgradeLegacyNsec(user, password)) changed = true; + if (changed) await s(`user:${user.id}`, user); } // Evicted accounts can't use a token anyway (see lib/auth.ts), so don't @@ -487,7 +492,7 @@ export default { secure: true, sameSite: "lax", }); - user = { ...pick(user, whitelist), hasnsec: !!user.nsec, nsec: user.nsec }; + user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher), nsec: user.nsec }; res.send({ user, token }); } catch (e) { err("login error", e.message, req.socket.remoteAddress); @@ -555,7 +560,7 @@ export default { secure: true, sameSite: "lax", }); - user = { ...pick(user, whitelist), hasnsec: !!user.nsec }; + user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher) }; res.send({ user, token }); } catch (e) { err("nostr login error", e.message, req.socket.remoteAddress); @@ -622,7 +627,7 @@ export default { const ipFailKey = `ip:${ip}:login:fail`; try { - if (!user.nsec) fail("nsec not available"); + if (!user.nsec && !user.cipher) fail("nsec not available"); if ((await g(ipFailKey)) > 20) return res.code(429).send({}); let verified = false; @@ -637,6 +642,9 @@ export default { return res.code(401).send("invalid password"); } + if (await upgradeLegacyNsec(user, password)) await s(`user:${user.id}`, user); + if (!user.nsec) fail("nsec could not be decrypted"); + res.send({ nsec: user.nsec }); } catch (e) { bail(res, e.message); @@ -889,6 +897,8 @@ export default { user.pin = null; user.nsec = null; + user.cipher = null; + user.salt = null; user.password = await Bun.password.hash(password, { algorithm: "bcrypt", |
