diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 13:06:47 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-29 16:40:36 -0700 |
| commit | bdeda0fa35a0c0d1a59621f95143e09ce8def8b9 (patch) | |
| tree | 4e34e8cd1e06c4c40873422f9ba4df58a2481868 /lib/legacynsec.ts | |
| parent | abb0f094aa51155cec4561b4999f6739df47f23e (diff) | |
| download | coinos-server-bdeda0fa35a0c0d1a59621f95143e09ce8def8b9.tar.gz coinos-server-bdeda0fa35a0c0d1a59621f95143e09ce8def8b9.zip | |
users: open pre-NIP-49 nostr keys with the account password
Older accounts keep their nostr key as a password-encrypted seed
(cipher + salt) rather than a NIP-49 nsec, so the reveal and signing
paths treated them as having no key. On a verified password, /login and
/nsec now convert it to a NIP-49 nsec, and hasnsec counts it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'lib/legacynsec.ts')
| -rw-r--r-- | lib/legacynsec.ts | 62 |
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; +}; |
