diff options
| author | Adam Soltys <[email protected]> | 2026-10-08 12:15:15 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-08 12:15:15 -0700 |
| commit | 5df9bc39ac48fdf1b19b00b9b457f9ebc1ee157c (patch) | |
| tree | c8899d20fceed99af4eec492b34be1e5e8f76709 | |
| parent | 621cb7b6ae6655db60971f248d2e799dbb8c4b3d (diff) | |
| download | coinos-server-5df9bc39ac48fdf1b19b00b9b457f9ebc1ee157c.tar.gz coinos-server-5df9bc39ac48fdf1b19b00b9b457f9ebc1ee157c.zip | |
users: keep the encrypted nostr key and 2FA seed away from bearer tokens
/me and user emits return a hasnsec flag instead of the encrypted key, which
is now only sent with a password login or registration. /otpsecret refuses
once 2FA is enabled, and disabling 2FA rotates the seed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | lib/whitelist.ts | 2 | ||||
| -rw-r--r-- | routes/users.ts | 17 |
2 files changed, 15 insertions, 4 deletions
diff --git a/lib/whitelist.ts b/lib/whitelist.ts index d0acb0c4..f305f516 100644 --- a/lib/whitelist.ts +++ b/lib/whitelist.ts @@ -14,6 +14,7 @@ export default [ "fresh", "followers", "follows", + "hasnsec", "haspin", "hasprinter", "id", @@ -28,7 +29,6 @@ export default [ "nip5", "notify", "npub", - "nsec", "payments", "picture", "profile", diff --git a/routes/users.ts b/routes/users.ts index ab899978..711ad07f 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -101,6 +101,10 @@ export default { const { user } = req; try { user.balance = await g(`balance:${user.id}`); + // 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.prompt = !!user.prompt; if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey); @@ -197,7 +201,7 @@ export default { l("registered new user", user.username); - res.send({ ...pick(user, whitelist), sk: user.sk, token }); + res.send({ ...pick(user, whitelist), nsec: user.nsec, sk: user.sk, token }); } catch (e) { err("problem registering", e.message); res.code(500).send(e.message); @@ -214,7 +218,10 @@ export default { return res.code(401).send("2fa required"); } + // Fresh seed on disable so a seed that leaked while 2FA was on is dead + // when it's re-enabled. user.twofa = false; + user.otpsecret = authenticator.generateSecret(); await s(`user:${id}`, user); emit(username, "user", pick(user, whitelist)); emit(username, "otpsecret", user.otpsecret); @@ -484,7 +491,7 @@ export default { secure: true, sameSite: "lax", }); - user = pick(user, whitelist); + user = { ...pick(user, whitelist), hasnsec: !!user.nsec, nsec: user.nsec }; res.send({ user, token }); } catch (e) { err("login error", e.message, req.socket.remoteAddress); @@ -552,7 +559,7 @@ export default { secure: true, sameSite: "lax", }); - user = pick(user, whitelist); + user = { ...pick(user, whitelist), hasnsec: !!user.nsec }; res.send({ user, token }); } catch (e) { err("nostr login error", e.message, req.socket.remoteAddress); @@ -618,6 +625,10 @@ export default { async otpsecret(req, res) { try { await requirePin(req); + // Only needed to enrol. Once 2FA is on, the seed never leaves the + // server again — otherwise a stolen token on a PIN-less account is + // enough to mint valid codes. + if (req.user.twofa) fail("2FA is already enabled"); const { otpsecret, username } = req.user; res.send({ secret: otpsecret, username }); } catch (e) { |
