aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-08 12:15:15 -0700
committerAdam Soltys <[email protected]>2026-10-08 12:15:15 -0700
commit5df9bc39ac48fdf1b19b00b9b457f9ebc1ee157c (patch)
treec8899d20fceed99af4eec492b34be1e5e8f76709
parent621cb7b6ae6655db60971f248d2e799dbb8c4b3d (diff)
downloadcoinos-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.ts2
-rw-r--r--routes/users.ts17
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) {