aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/legacynsec.ts62
1 files changed, 62 insertions, 0 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;
+};