aboutsummaryrefslogtreecommitdiffstats
path: root/lib/legacynsec.ts
blob: 0d0a2e6ac5b2d221d8faa3896eb211f836e39169 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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;
};