aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/legacynsec.ts62
-rw-r--r--package.json1
-rw-r--r--routes/users.ts20
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",