diff --git a/html/js/network/smirkAuth.js b/html/js/network/smirkAuth.js
index 04be1ba..5e6de09 100644
--- a/html/js/network/smirkAuth.js
+++ b/html/js/network/smirkAuth.js
@@ -27,7 +27,20 @@ const SmirkAuth = {
_walletAddress: null,
/**
- * Main login flow
+ * Detect whether the connected Smirk wallet supports NIP-98 (nostr) signing.
+ */
+ _supportsNip98() {
+ return typeof window.smirk?.signNostrEvent === 'function' &&
+ typeof window.smirk?.getNostrPublicKey === 'function';
+ },
+
+ /**
+ * Main login flow.
+ *
+ * Prefers NIP-98 (nostr kind:27235 HTTP auth event) when the wallet
+ * advertises the capability, and gracefully falls back to the legacy
+ * Ed25519 challenge-signing path otherwise (or if NIP-98 signing fails
+ * mid-flow).
*/
async login() {
if (!this.isAvailable()) {
@@ -54,7 +67,105 @@ const SmirkAuth = {
const { challenge } = await challengeRes.json();
- // Step 2: Connect to Smirk extension
+ // Step 2: Feature-detect NIP-98 capability and verify with the backend.
+ let verifyData;
+ let provenPublicKey = null;
+
+ if (this._supportsNip98()) {
+ try {
+ const result = await this._verifyNip98(socketId, challenge);
+ verifyData = result.verifyData;
+ provenPublicKey = result.pubkey;
+ } catch (nip98Err) {
+ // Graceful fallback: if NIP-98 signing/verification fails
+ // (wallet threw, lacks the method mid-flow, etc.) fall back to
+ // the legacy path so login still works.
+ console.warn('Smirk NIP-98 auth failed, falling back to legacy:', nip98Err && nip98Err.message);
+ const result = await this._verifyLegacy(socketId, challenge);
+ verifyData = result.verifyData;
+ provenPublicKey = result.pubkey;
+ }
+ } else {
+ const result = await this._verifyLegacy(socketId, challenge);
+ verifyData = result.verifyData;
+ provenPublicKey = result.pubkey;
+ }
+
+ // Step 3: Get wallet addresses and auto-set payout address
+ // Note: the proven public key is a pubkey (hex), NOT an address.
+ // We need to call getAddresses() to get the actual WOW address.
+ let walletAddress = null;
+ try {
+ const addresses = await window.smirk.getAddresses();
+ if (addresses && addresses.wow && window.socket) {
+ window.socket.emit('address:update', { address: addresses.wow });
+ walletAddress = addresses.wow;
+ console.log('Smirk WOW address set:', addresses.wow.substring(0, 20) + '...');
+ }
+ } catch (addrErr) {
+ console.warn('Could not get Smirk addresses:', addrErr.message);
+ // Fall back - user will need to manually enter address
+ }
+
+ // Update internal state
+ this._isLinked = true;
+ this._walletAddress = walletAddress;
+ this._publicKey = provenPublicKey;
+
+ return {
+ success: true,
+ linked: verifyData.linked,
+ address: walletAddress
+ };
+ },
+
+ /**
+ * NIP-98 auth path: build a kind:27235 nostr event, sign it with the
+ * Smirk wallet, and POST { socketId, event } to /verify.
+ *
+ * @returns {Promise<{verifyData: object, pubkey: string|null}>}
+ */
+ async _verifyNip98(socketId, challenge) {
+ // Build the NIP-98 HTTP auth event. The wallet fills in pubkey/id/sig.
+ const evt = {
+ kind: 27235,
+ created_at: Math.floor(Date.now() / 1000),
+ content: '',
+ tags: [
+ ['u', new URL('/api/auth/smirk/verify', window.location.origin).href],
+ ['method', 'POST'],
+ ['challenge', challenge]
+ ]
+ };
+
+ const signed = await window.smirk.signNostrEvent(evt);
+ if (!signed || typeof signed !== 'object') {
+ throw new Error('Smirk wallet did not return a signed nostr event');
+ }
+
+ const verifyRes = await fetch('/api/auth/smirk/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ socketId, event: signed })
+ });
+
+ if (!verifyRes.ok) {
+ const error = await verifyRes.json().catch(() => ({}));
+ throw new Error(error.message || 'NIP-98 verification failed');
+ }
+
+ const verifyData = await verifyRes.json();
+ return { verifyData, pubkey: signed.pubkey || null };
+ },
+
+ /**
+ * Legacy Ed25519 auth path: connect, sign the challenge string, and POST
+ * { socketId, challenge, publicKey, signature } to /verify.
+ *
+ * @returns {Promise<{verifyData: object, pubkey: string|null}>}
+ */
+ async _verifyLegacy(socketId, challenge) {
+ // Connect to Smirk extension
let keys;
try {
keys = await window.smirk.connect();
@@ -66,7 +177,7 @@ const SmirkAuth = {
throw new Error('Smirk wallet did not return WOW public key');
}
- // Step 3: Sign the challenge
+ // Sign the challenge
let signResult;
try {
signResult = await window.smirk.signMessage(challenge);
@@ -80,7 +191,7 @@ const SmirkAuth = {
throw new Error('WOW signature not found in Smirk response');
}
- // Step 4: Verify signature with backend
+ // Verify signature with backend
const verifyRes = await fetch('/api/auth/smirk/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -98,33 +209,7 @@ const SmirkAuth = {
}
const verifyData = await verifyRes.json();
-
- // Step 5: Get wallet addresses and auto-set payout address
- // Note: keys.wow is a public key (hex), NOT an address
- // We need to call getAddresses() to get the actual WOW address
- let walletAddress = null;
- try {
- const addresses = await window.smirk.getAddresses();
- if (addresses && addresses.wow && window.socket) {
- window.socket.emit('address:update', { address: addresses.wow });
- walletAddress = addresses.wow;
- console.log('Smirk WOW address set:', addresses.wow.substring(0, 20) + '...');
- }
- } catch (addrErr) {
- console.warn('Could not get Smirk addresses:', addrErr.message);
- // Fall back - user will need to manually enter address
- }
-
- // Update internal state
- this._isLinked = true;
- this._walletAddress = walletAddress;
- this._publicKey = wowSig.publicKey;
-
- return {
- success: true,
- linked: verifyData.linked,
- address: walletAddress
- };
+ return { verifyData, pubkey: wowSig.publicKey };
},
/**
@@ -136,7 +221,13 @@ const SmirkAuth = {
}
try {
- const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`);
+ // /status is session-gated (BOLA fix) — send the session token so a linked user
+ // is recognised; without it the endpoint returns 401 and we fall through to unlinked.
+ var token = '';
+ try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) { token = ''; }
+ const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`, {
+ headers: token ? { 'X-Session-Token': token } : {}
+ });
if (res.ok) {
const data = await res.json();
this._isLinked = data.linked;
diff --git a/src/package-lock.json b/src/package-lock.json
index 536b8c0..5fbb288 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -13,6 +13,7 @@
"dotenv": "^16.0.3",
"express": "^4.21.3",
"node-pg-migrate": "^8.0.4",
+ "nostr-tools": "^2.23.9",
"pg": "^8.15.5",
"qrcode": "^1.5.3",
"resend": "^4.0.0",
@@ -1013,6 +1014,45 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
+ "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
+ "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
+ "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@react-email/render": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
@@ -1031,6 +1071,42 @@
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
+ "node_modules/@scure/base": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
+ "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
+ "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "2.0.1",
+ "@noble/hashes": "2.0.1",
+ "@scure/base": "2.0.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip39": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
+ "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.0.1",
+ "@scure/base": "2.0.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
@@ -4021,6 +4097,35 @@
"node": ">=0.10.0"
}
},
+ "node_modules/nostr-tools": {
+ "version": "2.23.9",
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
+ "integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
+ "license": "Unlicense",
+ "dependencies": {
+ "@noble/ciphers": "2.1.1",
+ "@noble/curves": "2.0.1",
+ "@noble/hashes": "2.0.1",
+ "@scure/base": "2.0.0",
+ "@scure/bip32": "2.0.1",
+ "@scure/bip39": "2.0.1",
+ "nostr-wasm": "0.1.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nostr-wasm": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
+ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
+ "license": "MIT"
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
diff --git a/src/package.json b/src/package.json
index 46b79cd..dd85266 100644
--- a/src/package.json
+++ b/src/package.json
@@ -33,6 +33,7 @@
"dotenv": "^16.0.3",
"express": "^4.21.3",
"node-pg-migrate": "^8.0.4",
+ "nostr-tools": "^2.23.9",
"pg": "^8.15.5",
"qrcode": "^1.5.3",
"resend": "^4.0.0",
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 0fa8937..ed6a021 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -6,11 +6,68 @@ const express = require('express');
const crypto = require('crypto');
const asyncHandler = require('../middleware/asyncHandler');
const { ValidationError, NotFoundError } = require('../utils/errors');
+const { verifyNip98Event } = require('../utils/nip98');
module.exports = function createAuthRoutes(ctx) {
const { db } = ctx;
const router = express.Router();
+ /**
+ * Atomically consume a challenge nonce, single-use, bound to a socket.
+ * A single UPDATE ... WHERE used=FALSE ... RETURNING flips FALSE->TRUE for exactly
+ * one caller, so two concurrent requests can never both pass (no SELECT/UPDATE TOCTOU).
+ * Returns true iff this call is the one that consumed a fresh, unexpired challenge.
+ */
+ async function consumeChallenge(challenge, socketId) {
+ const consumed = await db.query(`
+ UPDATE smirk_challenges
+ SET used = TRUE
+ WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
+ RETURNING id
+ `, [challenge, socketId]);
+ return consumed.rows.length > 0;
+ }
+
+ /**
+ * Link a proven wallet key to the user resolved from socketId, then send the
+ * success response shape the client expects ({ success, linked }). Shared by the
+ * NIP-98 and legacy Ed25519 paths.
+ */
+ async function linkSmirkKey(provenKey, socketId, res) {
+ const userResult = await db.query(`
+ SELECT id FROM users WHERE socket_id = $1
+ `, [socketId]);
+
+ if (userResult.rows.length === 0) {
+ throw new NotFoundError('User session not found', {
+ safeMessage: 'No active session found. Please connect to the game first.'
+ });
+ }
+
+ const userId = userResult.rows[0].id;
+
+ // Reject linking a wallet already bound to a different account.
+ const existingLink = await db.query(`
+ SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
+ `, [provenKey, userId]);
+
+ if (existingLink.rows.length > 0) {
+ throw new ValidationError('Wallet already linked', {
+ safeMessage: 'This wallet is already linked to another account.'
+ });
+ }
+
+ await db.query(`
+ UPDATE users SET smirk_public_key = $1 WHERE id = $2
+ `, [provenKey, userId]);
+
+ res.json({
+ success: true,
+ linked: true,
+ message: 'Smirk wallet linked successfully'
+ });
+ }
+
/**
* POST /api/auth/smirk/challenge
* Generate a challenge for Smirk wallet signature verification
@@ -48,54 +105,96 @@ router.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => {
/**
* POST /api/auth/smirk/verify
- * Verify a Smirk wallet signature and link to user session
- * Body: { socketId: string, challenge: string, publicKey: string, signature: string }
+ * Verify a Smirk wallet auth proof and link the wallet to the user session.
+ * Two accepted request shapes:
+ * - NIP-98: { socketId, event } -> nostr kind:27235 HTTP-auth event (preferred)
+ * - Legacy: { socketId, challenge, publicKey, signature } -> Ed25519 challenge signing
*/
router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
- const { socketId, challenge, publicKey, signature } = req.body || {};
+ const body = req.body || {};
+ const { socketId } = body;
- if (!socketId || !challenge || !publicKey || !signature) {
+ if (!socketId || typeof socketId !== 'string') {
+ throw new ValidationError('Missing socketId', {
+ safeMessage: 'socketId is required.'
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // NIP-98 path — a nostr event object is present.
+ // ---------------------------------------------------------------------------
+ if (body.event && typeof body.event === 'object' && !Array.isArray(body.event)) {
+ // Extract the challenge carried by the event's 'challenge' tag WITHOUT trusting
+ // the rest of the event yet. We consume the server nonce single-use FIRST, then
+ // cryptographically verify the event is bound to that exact nonce.
+ const rawTags = Array.isArray(body.event.tags) ? body.event.tags : [];
+ const challengeTags = rawTags.filter((t) => Array.isArray(t) && t[0] === 'challenge');
+ const challenge = challengeTags.length === 1 ? challengeTags[0][1] : null;
+
+ if (!challenge || typeof challenge !== 'string') {
+ throw new ValidationError('Invalid NIP-98 event', {
+ safeMessage: 'The authentication event is missing its challenge.'
+ });
+ }
+
+ // Atomic single-use consume (bound to this socket) — prevents replay/double-use.
+ const consumed = await consumeChallenge(challenge, socketId);
+ if (!consumed) {
+ throw new ValidationError('Invalid or expired challenge', {
+ safeMessage: 'The challenge is invalid, expired, or has already been used.'
+ });
+ }
+
+ const now = Math.floor(Date.now() / 1000);
+ const result = verifyNip98Event(body.event, {
+ challenge,
+ expectedPathSuffix: '/api/auth/smirk/verify',
+ now,
+ maxSkewSec: 120
+ });
+
+ if (!result.ok) {
+ throw new ValidationError('Invalid NIP-98 event', {
+ safeMessage: 'The authentication event could not be verified.'
+ });
+ }
+
+ // NOTE: result.pubkey is a nostr x-only secp256k1 key (64-hex) — a DIFFERENT
+ // key namespace than the legacy Ed25519 spend key. A NIP-98 login therefore
+ // re-registers the account under the nostr key (acceptable per the cutover spec).
+ await linkSmirkKey(result.pubkey, socketId, res);
+ return;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Legacy Ed25519 path — { challenge, publicKey, signature }.
+ // ---------------------------------------------------------------------------
+ const { challenge, publicKey, signature } = body;
+
+ if (!challenge || !publicKey || !signature) {
throw new ValidationError('Missing required fields', {
safeMessage: 'socketId, challenge, publicKey, and signature are required.'
});
}
- // 1. Verify challenge exists, is not used, and has not expired
- const challengeResult = await db.query(`
- SELECT * FROM smirk_challenges
- WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
- `, [challenge, socketId]);
-
- if (challengeResult.rows.length === 0) {
+ // Consume the challenge single-use (atomic) before verifying the signature.
+ const consumed = await consumeChallenge(challenge, socketId);
+ if (!consumed) {
throw new ValidationError('Invalid or expired challenge', {
safeMessage: 'The challenge is invalid, expired, or has already been used.'
});
}
- // 2. Mark challenge as used immediately (prevent replay attacks)
- await db.query(`
- UPDATE smirk_challenges SET used = TRUE WHERE id = $1
- `, [challengeResult.rows[0].id]);
-
- // 3. Verify signature using tweetnacl
- // Accepts both formats during transition:
- // - RFC 8032 standard: signature over raw UTF-8 message bytes (new extension)
- // - Legacy: signature over SHA256(message) (old extension, remove after 2026-07-01)
+ // Verify Ed25519 signature over the RAW UTF-8 challenge bytes (RFC 8032).
+ // The deprecated SHA256(challenge) pre-hash fallback was removed (past its
+ // 2026-07-01 removal date) — only the raw-bytes verify remains.
let isValidSignature = false;
try {
const nacl = require('tweetnacl');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
-
- // Try RFC 8032 standard first (raw message bytes)
const challengeBytes = Buffer.from(challenge, 'utf8');
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
-
- // Fall back to legacy SHA256 pre-hash (DEPRECATED — remove after 2026-07-01)
- if (!isValidSignature) {
- const challengeHash = crypto.createHash('sha256').update(challenge).digest();
- isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
- }
} catch (verifyError) {
console.error('Signature verification error:', verifyError.message);
throw new ValidationError('Signature verification failed', {
@@ -109,46 +208,20 @@ router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
});
}
- // 4. Link public key to user session
- const userResult = await db.query(`
- SELECT id FROM users WHERE socket_id = $1
- `, [socketId]);
-
- if (userResult.rows.length === 0) {
- throw new NotFoundError('User session not found', {
- safeMessage: 'No active session found. Please connect to the game first.'
- });
- }
-
- const userId = userResult.rows[0].id;
-
- // Check if this public key is already linked to another user
- const existingLink = await db.query(`
- SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
- `, [publicKey, userId]);
-
- if (existingLink.rows.length > 0) {
- throw new ValidationError('Wallet already linked', {
- safeMessage: 'This wallet is already linked to another account.'
- });
- }
-
- // Link the wallet to this user
- await db.query(`
- UPDATE users SET smirk_public_key = $1 WHERE id = $2
- `, [publicKey, userId]);
-
- res.json({
- success: true,
- linked: true,
- message: 'Smirk wallet linked successfully'
- });
+ await linkSmirkKey(publicKey, socketId, res);
}));
/**
* GET /api/auth/smirk/status
- * Check if a session has a linked Smirk wallet
- * Query: socketId=string
+ * Check if a session has a linked Smirk wallet.
+ * Query: socketId=string ; Auth: X-Session-Token header (fallback ?t=).
+ *
+ * BOLA fix: previously this disclosed { linked, hasPayoutAddress } for ANY socketId
+ * with no auth, letting anyone probe another player's wallet-link state. We now
+ * require the caller to prove ownership of the socketId via the session token
+ * (users.anon_token) — the same guard used by /api/user/:socketId/* in index.js
+ * (requireSessionOwnership). We only resolve the row whose socket_id AND anon_token
+ * both match; a mismatch/absent token gets 401/403 and learns nothing.
*/
router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
const { socketId } = req.query;
@@ -159,16 +232,20 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
});
}
+ const token = req.get('X-Session-Token') || req.query.t;
+ if (!token) {
+ return res.status(401).json({ error: 'Session token required' });
+ }
+
const result = await db.query(`
- SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1
- `, [socketId]);
+ SELECT smirk_public_key, payout_address
+ FROM users
+ WHERE socket_id = $1 AND anon_token = $2
+ `, [socketId, token]);
if (result.rows.length === 0) {
- res.json({
- linked: false,
- hasPayoutAddress: false
- });
- return;
+ // Do not disclose whether the socketId exists — ownership check failed.
+ return res.status(403).json({ error: 'Session ownership verification failed' });
}
const user = result.rows[0];
diff --git a/src/utils/nip98.js b/src/utils/nip98.js
new file mode 100644
index 0000000..d1ec424
--- /dev/null
+++ b/src/utils/nip98.js
@@ -0,0 +1,164 @@
+/**
+ * Secure NIP-98 (HTTP Auth, kind:27235) event verification.
+ *
+ * Pure + side-effect free (no DB, no I/O) so it can be unit-tested with a plain
+ * round-trip using nostr-tools. The route layer is responsible for issuing and
+ * single-use-consuming the server challenge nonce; this module only proves that a
+ * signed event is well-formed, authentic, fresh, and bound to the expected
+ * method/URL/challenge.
+ *
+ * SECURITY — why we do NOT rely on nostr-tools verifyEvent() alone:
+ * nostr-tools memoizes its result on the event object via an internal
+ * Symbol(verified). A caller that reuses/mutates an already-"verified" object in
+ * the same process could otherwise bypass a re-check. We defend two ways:
+ * 1. We rebuild a CLEAN event object from only the canonical primitive fields,
+ * so it can never carry a stamped verified-symbol (freshly-parsed wire JSON
+ * can't carry a JS Symbol either, but this makes the guarantee explicit).
+ * 2. We INDEPENDENTLY recompute the event id (getEventHash) and compare it to
+ * the claimed id. This catches any content/tag tampering with zero reliance
+ * on verifyEvent's memoization, before we ever check the schnorr signature.
+ */
+
+/**
+ * Load nostr-tools in a way that works in BOTH runtimes:
+ * - Production (plain Node >=22): a normal require() succeeds — Node natively
+ * require()s nostr-tools' ESM-only transitive deps (@noble/*).
+ * - A CJS-only test runtime (Jest without --experimental-vm-modules) cannot parse
+ * those ESM deps, so we fall back to nostr-tools' pre-bundled build, which is a
+ * single self-contained esbuild IIFE with every dependency inlined (no import /
+ * no external require) and is therefore safe to evaluate in-process.
+ * Same public API either way (getEventHash, verifyEvent, ...).
+ */
+function loadNostrTools() {
+ try {
+ return require('nostr-tools');
+ } catch (_e) {
+ const fs = require('fs');
+ const path = require('path');
+ const cjsIndex = require.resolve('nostr-tools'); // .../nostr-tools/lib/cjs/index.js
+ const bundlePath = path.join(path.dirname(cjsIndex), '..', 'nostr.bundle.js');
+ const src = fs.readFileSync(bundlePath, 'utf8');
+ // eslint-disable-next-line no-new-func
+ return new Function(`${src}\nreturn NostrTools;`)();
+ }
+}
+
+const { getEventHash, verifyEvent } = loadNostrTools();
+
+const HEX_64 = /^[0-9a-f]{64}$/; // x-only secp256k1 pubkey / event id
+const HEX_128 = /^[0-9a-f]{128}$/; // BIP-340 schnorr signature
+const NIP98_KIND = 27235;
+
+function fail(reason) {
+ return { ok: false, reason };
+}
+
+/**
+ * @param {object} event A NIP-98 kind:27235 event: { kind, created_at, pubkey, id, sig, content, tags }.
+ * @param {object} opts
+ * @param {string} opts.challenge Server nonce that the event's 'challenge' tag must equal.
+ * @param {string} [opts.expectedPathSuffix] The 'u' tag URL path must end with this. Default '/api/auth/smirk/verify'.
+ * @param {string} [opts.expectedHost] If set, the 'u' tag URL host must equal this.
+ * @param {number} [opts.now] Current unix seconds (for freshness). Default Date.now()/1000.
+ * @param {number} [opts.maxSkewSec] Max allowed |now - created_at|. Default 120.
+ * @returns {{ ok: boolean, pubkey?: string, reason?: string }}
+ */
+function verifyNip98Event(event, opts = {}) {
+ const {
+ challenge,
+ expectedPathSuffix = '/api/auth/smirk/verify',
+ expectedHost = null,
+ now = Math.floor(Date.now() / 1000),
+ maxSkewSec = 120,
+ } = opts;
+
+ if (!challenge || typeof challenge !== 'string') {
+ return fail('missing-expected-challenge');
+ }
+
+ // (a) Shape/type validation. Nostr events carry kind & created_at as NUMBERS and
+ // pubkey/id/sig/content as strings; tags is an array of string arrays.
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
+ return fail('event-not-object');
+ }
+ if (typeof event.kind !== 'number') return fail('kind-not-number');
+ if (typeof event.created_at !== 'number' || !Number.isFinite(event.created_at)) {
+ return fail('created_at-not-number');
+ }
+ if (typeof event.pubkey !== 'string' || !HEX_64.test(event.pubkey)) return fail('bad-pubkey');
+ if (typeof event.id !== 'string' || !HEX_64.test(event.id)) return fail('bad-id');
+ if (typeof event.sig !== 'string' || !HEX_128.test(event.sig)) return fail('bad-sig');
+ if (typeof event.content !== 'string') return fail('content-not-string');
+ if (!Array.isArray(event.tags)) return fail('tags-not-array');
+ for (const tag of event.tags) {
+ if (!Array.isArray(tag) || tag.some((v) => typeof v !== 'string')) {
+ return fail('malformed-tag');
+ }
+ }
+
+ // (b) Must be a NIP-98 HTTP Auth event.
+ if (event.kind !== NIP98_KIND) return fail('wrong-kind');
+
+ // Rebuild a clean event from primitives only — strips any inherited verified-symbol
+ // and any extra properties, so verifyEvent() below is forced to actually verify.
+ const clean = {
+ kind: event.kind,
+ created_at: event.created_at,
+ pubkey: event.pubkey,
+ id: event.id,
+ sig: event.sig,
+ content: event.content,
+ tags: event.tags.map((t) => t.slice()),
+ };
+
+ // (c) Independently recompute the id. Defeats content/tag tampering regardless of
+ // any memoization inside verifyEvent.
+ let recomputedId;
+ try {
+ recomputedId = getEventHash(clean);
+ } catch (_e) {
+ return fail('hash-error');
+ }
+ if (recomputedId !== clean.id) return fail('id-mismatch');
+
+ // (d) BIP-340 schnorr signature over secp256k1 (x-only 64-hex pubkey, 128-hex sig).
+ let sigOk = false;
+ try {
+ sigOk = verifyEvent(clean) === true;
+ } catch (_e) {
+ return fail('sig-verify-threw');
+ }
+ if (!sigOk) return fail('bad-signature');
+
+ // (e) Freshness — reject clock skew / replayed-late events.
+ if (Math.abs(now - Number(clean.created_at)) > maxSkewSec) {
+ return fail('expired');
+ }
+
+ // (f) Tag policy: exactly-one method(POST), exactly-one matching u, exactly-one challenge.
+ const methodTags = clean.tags.filter((t) => t[0] === 'method');
+ if (methodTags.length !== 1) return fail('method-tag-count');
+ if (methodTags[0][1] !== 'POST') return fail('wrong-method');
+
+ const uTags = clean.tags.filter((t) => t[0] === 'u');
+ if (uTags.length !== 1) return fail('u-tag-count');
+ let url;
+ try {
+ url = new URL(uTags[0][1]);
+ } catch (_e) {
+ return fail('bad-u-url');
+ }
+ if (!url.pathname.endsWith(expectedPathSuffix)) return fail('wrong-u-path');
+ if (expectedHost && url.host !== expectedHost && url.hostname !== expectedHost) {
+ return fail('wrong-u-host');
+ }
+
+ const challengeTags = clean.tags.filter((t) => t[0] === 'challenge');
+ if (challengeTags.length !== 1) return fail('challenge-tag-count');
+ if (challengeTags[0][1] !== challenge) return fail('challenge-mismatch');
+
+ // (g) Success — return the proven x-only pubkey, lowercased.
+ return { ok: true, pubkey: clean.pubkey.toLowerCase() };
+}
+
+module.exports = { verifyNip98Event };
diff --git a/test/smirkNip98_ultra.test.js b/test/smirkNip98_ultra.test.js
new file mode 100644
index 0000000..deb6100
--- /dev/null
+++ b/test/smirkNip98_ultra.test.js
@@ -0,0 +1,310 @@
+/**
+ * Adversarial round-trip tests for the secure NIP-98 verifier (src/utils/nip98.js).
+ *
+ * We sign REAL kind:27235 events with nostr-tools (generateSecretKey / getPublicKey /
+ * finalizeEvent) and then attack them: tamper content/tags, wrong kind/method/url,
+ * stale timestamps, flipped signatures, and the memoization-bypass trick. The verifier
+ * is exercised directly (no HTTP). Case (ix) — single-use / replay — is enforced at the
+ * route layer, so we drive the real Express route with a mock DB for that one.
+ */
+
+// nostr-tools is installed under src/node_modules (the app package lives in src/).
+// Load it resiliently: a normal require works under plain Node (>=22 require()s the
+// ESM-only @noble deps), but Jest's CJS runtime can't parse those, so we fall back to
+// nostr-tools' self-contained pre-bundled build (an esbuild IIFE with deps inlined).
+const fs = require('fs');
+const path = require('path');
+function loadNostrTools() {
+ const cjsIndex = require.resolve('nostr-tools', { paths: [path.join(__dirname, '..', 'src')] });
+ try {
+ return require(cjsIndex);
+ } catch (_e) {
+ const bundlePath = path.join(path.dirname(cjsIndex), '..', 'nostr.bundle.js');
+ // eslint-disable-next-line no-new-func
+ return new Function(`${fs.readFileSync(bundlePath, 'utf8')}\nreturn NostrTools;`)();
+ }
+}
+const {
+ generateSecretKey,
+ getPublicKey,
+ finalizeEvent,
+ getEventHash,
+ verifyEvent,
+} = loadNostrTools();
+
+const { verifyNip98Event } = require('../src/utils/nip98');
+
+const PATH_SUFFIX = '/api/auth/smirk/verify';
+const HOST = 'game.example';
+const U = `https://${HOST}${PATH_SUFFIX}`;
+const NOW = 1_800_000_000; // fixed reference so freshness is deterministic
+const CHALLENGE = 'server-nonce-abc123';
+
+// Sign a fresh event; opts override any field. `tags` (if given) fully replaces the default trio.
+function buildEvent(sk, opts = {}) {
+ const {
+ kind = 27235,
+ created_at = NOW,
+ content = '',
+ challenge = CHALLENGE,
+ u = U,
+ method = 'POST',
+ tags,
+ } = opts;
+ const template = {
+ kind,
+ created_at,
+ content,
+ tags: tags || [['u', u], ['method', method], ['challenge', challenge]],
+ };
+ return finalizeEvent(template, sk);
+}
+
+// The opts every "happy-path expected" call shares.
+function baseOpts(extra = {}) {
+ return {
+ challenge: CHALLENGE,
+ expectedPathSuffix: PATH_SUFFIX,
+ now: NOW,
+ maxSkewSec: 120,
+ ...extra,
+ };
+}
+
+describe('verifyNip98Event — adversarial round-trip', () => {
+ test('(i) a valid, correctly-signed event is accepted and returns the x-only pubkey', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk);
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(true);
+ expect(r.pubkey).toBe(getPublicKey(sk)); // nostr-tools returns lowercase 64-hex
+ expect(r.pubkey).toMatch(/^[0-9a-f]{64}$/);
+
+ // host binding also passes when the expected host matches
+ expect(verifyNip98Event(ev, baseOpts({ expectedHost: HOST })).ok).toBe(true);
+ });
+
+ test('(ii) tampering with content after signing is rejected (independent id recompute)', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk);
+ ev.content = 'malicious-payload'; // id no longer matches the (unchanged) claimed id
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('id-mismatch');
+ });
+
+ test('(iii) tampering with a tag value after signing is rejected', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk, { challenge: CHALLENGE });
+ // Rewrite the challenge tag to a different value post-signature.
+ ev.tags = ev.tags.map((t) => (t[0] === 'challenge' ? ['challenge', 'swapped-value'] : t));
+ const r = verifyNip98Event(ev, baseOpts({ challenge: 'swapped-value' }));
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('id-mismatch');
+ });
+
+ test('(iii-b) a validly-signed event bound to a DIFFERENT challenge than the server nonce is rejected', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk, { challenge: 'nonce-the-client-signed' });
+ const r = verifyNip98Event(ev, baseOpts({ challenge: 'the-server-actually-issued-this' }));
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('challenge-mismatch');
+ });
+
+ test('(iv) wrong kind is rejected even when the signature is valid', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk, { kind: 1 });
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('wrong-kind');
+ });
+
+ test('(v) an expired created_at (beyond maxSkew) is rejected — past and future', () => {
+ const sk = generateSecretKey();
+ const stale = buildEvent(sk, { created_at: NOW - 10_000 });
+ const past = verifyNip98Event(stale, baseOpts({ now: NOW }));
+ expect(past.ok).toBe(false);
+ expect(past.reason).toBe('expired');
+
+ const future = buildEvent(sk, { created_at: NOW + 10_000 });
+ expect(verifyNip98Event(future, baseOpts({ now: NOW })).reason).toBe('expired');
+
+ // A timestamp exactly at the skew boundary is still accepted.
+ const edge = buildEvent(sk, { created_at: NOW - 120 });
+ expect(verifyNip98Event(edge, baseOpts({ now: NOW })).ok).toBe(true);
+ });
+
+ test('(vi) a wrong "u" path is rejected; a wrong host is rejected when expectedHost is set', () => {
+ const sk = generateSecretKey();
+ const wrongPath = buildEvent(sk, { u: `https://${HOST}/api/wrong/endpoint` });
+ const rPath = verifyNip98Event(wrongPath, baseOpts());
+ expect(rPath.ok).toBe(false);
+ expect(rPath.reason).toBe('wrong-u-path');
+
+ const wrongHost = buildEvent(sk, { u: `https://evil.example${PATH_SUFFIX}` });
+ const rHost = verifyNip98Event(wrongHost, baseOpts({ expectedHost: HOST }));
+ expect(rHost.ok).toBe(false);
+ expect(rHost.reason).toBe('wrong-u-host');
+ });
+
+ test('(vii) a wrong method tag is rejected', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk, { method: 'GET' });
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('wrong-method');
+ });
+
+ test('(viii) a flipped signature byte is rejected', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk);
+ const first = ev.sig[0] === 'f' ? 'e' : 'f';
+ ev.sig = first + ev.sig.slice(1); // stays 128-hex, but no longer a valid schnorr sig
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('bad-signature');
+ });
+
+ test('exactly-one tag policy: duplicate or missing challenge/method tags are rejected', () => {
+ const sk = generateSecretKey();
+
+ const dupChallenge = buildEvent(sk, {
+ tags: [['u', U], ['method', 'POST'], ['challenge', CHALLENGE], ['challenge', CHALLENGE]],
+ });
+ expect(verifyNip98Event(dupChallenge, baseOpts()).reason).toBe('challenge-tag-count');
+
+ const noChallenge = buildEvent(sk, { tags: [['u', U], ['method', 'POST']] });
+ expect(verifyNip98Event(noChallenge, baseOpts()).reason).toBe('challenge-tag-count');
+
+ const dupMethod = buildEvent(sk, {
+ tags: [['u', U], ['method', 'POST'], ['method', 'POST'], ['challenge', CHALLENGE]],
+ });
+ expect(verifyNip98Event(dupMethod, baseOpts()).reason).toBe('method-tag-count');
+ });
+
+ test('shape validation: non-27235 primitives / bad hex / non-object are rejected safely', () => {
+ expect(verifyNip98Event(null, baseOpts()).ok).toBe(false);
+ expect(verifyNip98Event('nope', baseOpts()).ok).toBe(false);
+ expect(verifyNip98Event({}, baseOpts()).ok).toBe(false);
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk);
+ // Corrupt the pubkey to non-hex — must not throw, must reject.
+ const bad = { ...ev, pubkey: 'zz' + ev.pubkey.slice(2) };
+ const r = verifyNip98Event(bad, baseOpts());
+ expect(r.ok).toBe(false);
+ });
+
+ test('CRITICAL: the nostr-tools verified-memo cannot bypass our re-verification', () => {
+ const sk = generateSecretKey();
+ const ev = buildEvent(sk);
+
+ // Prime nostr-tools' internal memo by verifying the pristine event once.
+ expect(verifyEvent(ev)).toBe(true);
+
+ // Attacker mutates content AND repairs the id so a naive hash check would pass,
+ // then leans on the STALE memo to skip re-verification.
+ ev.content = 'tampered-after-memoization';
+ ev.id = getEventHash({
+ kind: ev.kind,
+ created_at: ev.created_at,
+ pubkey: ev.pubkey,
+ tags: ev.tags,
+ content: ev.content,
+ });
+
+ // Demonstrate the vulnerability we defend against: nostr-tools returns the stale
+ // memoized `true` for the now-tampered object.
+ expect(verifyEvent(ev)).toBe(true);
+
+ // Our verifier rebuilds a clean event (no memo symbol) and re-runs schnorr, so the
+ // signature — made over the ORIGINAL content — fails against the repaired id.
+ const r = verifyNip98Event(ev, baseOpts());
+ expect(r.ok).toBe(false);
+ expect(r.reason).toBe('bad-signature');
+ });
+});
+
+describe('(ix) route layer — single-use challenge consume rejects replay', () => {
+ const express = require('express');
+ const http = require('http');
+ const createAuthRoutes = require('../src/routes/auth');
+
+ // Mock DB modelling the atomic single-use UPDATE ... WHERE used=FALSE ... RETURNING.
+ function mockDb(challengeValue, socketId) {
+ const challenges = new Map([[challengeValue, { id: 1, socket_id: socketId, used: false }]]);
+ return {
+ challenges,
+ query: async (sql, params = []) => {
+ const s = sql.replace(/\s+/g, ' ').trim();
+ if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
+ const [ch, sock] = params;
+ const row = challenges.get(ch);
+ if (row && row.socket_id === sock && row.used === false) {
+ row.used = true; // atomic FALSE -> TRUE, exactly once
+ return { rows: [{ id: row.id }] };
+ }
+ return { rows: [] };
+ }
+ if (/SELECT id FROM users WHERE socket_id = \$1$/i.test(s)) {
+ return { rows: [{ id: 77 }] };
+ }
+ if (/SELECT id FROM users WHERE smirk_public_key = \$1 AND id != \$2/i.test(s)) {
+ return { rows: [] }; // not linked elsewhere
+ }
+ if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
+ return { rows: [] };
+ }
+ return { rows: [] };
+ },
+ };
+ }
+
+ function startServer(db) {
+ const app = express();
+ app.use(express.json());
+ app.use(createAuthRoutes({ db }));
+ // Minimal error handler mirroring the app's (ValidationError -> 400, etc.).
+ app.use((err, req, res, _next) => {
+ res.status(err.statusCode || 500).json({ error: err.message });
+ });
+ return new Promise((resolve) => {
+ const server = http.createServer(app);
+ server.listen(0, '127.0.0.1', () => resolve(server));
+ });
+ }
+
+ async function postJson(server, path, body) {
+ const { port } = server.address();
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, json };
+ }
+
+ test('a valid NIP-98 event links once, then the identical replay is rejected', async () => {
+ const sk = generateSecretKey();
+ const socketId = 'sock-xyz-1';
+ const challenge = 'route-nonce-9f8e7d';
+ // created_at must be genuinely fresh — the route uses real Date.now().
+ const ev = buildEvent(sk, { created_at: Math.floor(Date.now() / 1000), challenge });
+
+ const db = mockDb(challenge, socketId);
+ const server = await startServer(db);
+ try {
+ const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
+ expect(first.status).toBe(200);
+ expect(first.json).toMatchObject({ success: true, linked: true });
+ expect(db.challenges.get(challenge).used).toBe(true);
+
+ // Replay the exact same event: the single-use consume now fails -> 400.
+ const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
+ expect(second.status).toBe(400);
+ expect(second.json.error).toBeTruthy();
+ } finally {
+ server.close();
+ }
+ });
+});