Files
wownerogue/test/smirkNip98_ultra.test.js
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

367 lines
14 KiB
JavaScript

/**
* 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');
const appRequire = require('./helpers/appRequire');
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 = appRequire('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, token) {
const user = { id: 77, socket_id: socketId, anon_token: token };
const challenges = new Map([[
challengeValue,
{ id: 1, socket_id: socketId, user_id: user.id, used: false }
]]);
const query = async (sql, params = []) => {
const s = sql.replace(/\s+/g, ' ').trim();
if (/SELECT id, socket_id, anon_token FROM users/i.test(s)) {
return params[0] === user.socket_id && params[1] === user.anon_token
? { rows: [user] }
: { rows: [] };
}
if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
const [ch, userId, sock] = params;
const row = challenges.get(ch);
if (row && row.user_id === userId && 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 id = \$1 AND socket_id = \$2 AND anon_token = \$3 FOR UPDATE/i.test(s)) {
return params[0] === user.id && params[1] === user.socket_id && params[2] === user.anon_token
? { rows: [{ id: user.id }] }
: { rows: [] };
}
if (/SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key/i.test(s)) {
return { rows: [] }; // not linked elsewhere
}
if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
return { rows: [{ id: user.id }] };
}
return { rows: [] };
};
return {
challenges,
query,
withTransaction: async (fn) => fn({ query }),
};
}
function startServer(db) {
const app = express();
app.use(express.json());
app.use(createAuthRoutes({
db,
sessionManager: {
generateSecureToken: () => 'unused-test-token',
disconnectUserSessions: jest.fn()
}
}));
// 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, token) {
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
return { status: res.status, json };
}
// The route binds the signed event to this deployment's host (HOSTED_BY when configured), so the
// route tests must declare the host their events are signed for.
const prevHostedBy = process.env.HOSTED_BY;
beforeEach(() => { process.env.HOSTED_BY = `https://${HOST}`; });
afterEach(() => {
if (prevHostedBy === undefined) delete process.env.HOSTED_BY;
else process.env.HOSTED_BY = prevHostedBy;
});
test('a valid NIP-98 event links once, then the identical replay is rejected', async () => {
const sk = generateSecretKey();
const socketId = 'sock-xyz-1';
const token = 'route-session-token';
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, token);
const server = await startServer(db);
try {
const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
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 }, token);
expect(second.status).toBe(400);
expect(second.json.error).toBeTruthy();
} finally {
server.close();
}
});
// The takeover this binding exists to stop: an event the victim's wallet signed for ANOTHER site
// (any origin whose path ends in /api/auth/smirk/verify) must not authenticate here. Linking
// ADOPTS the account that already owns the pubkey and hands back its session, so accepting a
// borrowed event is full account takeover, not just a sloppy audience check.
test('an event signed for a different host is rejected even with a valid challenge', async () => {
const sk = generateSecretKey();
const socketId = 'sock-xyz-2';
const token = 'route-session-token-2';
const challenge = 'route-nonce-cross-host';
const ev = buildEvent(sk, {
created_at: Math.floor(Date.now() / 1000),
challenge,
u: `https://evil.example${PATH_SUFFIX}`
});
const db = mockDb(challenge, socketId, token);
const server = await startServer(db);
try {
const res = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
expect(res.status).toBe(400);
expect(res.json).not.toMatchObject({ linked: true });
} finally {
server.close();
}
});
});