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(); + } + }); +});