mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
Closes the two findings left open from the pre-launch review. Node redundancy (it was node reachability, not chain health) RPC_ENDPOINTS accepts any number of daemons in preference order; PRIMARY / FALLBACK still work and are appended. The list is deduplicated, which exposes that the stock config pointed both at the SAME host — what looked like redundancy was one node. One logical call now walks every endpoint before failing, instead of needing three consecutive failures to try exactly one alternate. Each node still has to pass the chain-identity check before it may answer, so failover cannot silently serve another chain's data. `failoverActive` is no longer a one-way latch: the preferred node is re-tested every RPC_PREFERRED_RETRY_MS, so recovery no longer requires a restart. healthCheck probes every node. Graceful failure Entries are refused while no node is reachable. A run is measured in blocks and its entry block comes from the last successful poll, so a game admitted during an outage was anchored to an already-past block and killed as a timeout on the first recovered poll — the player lost a run, and in paid mode an entry, to our infrastructure. The gate sits in _runAdmission, ahead of any credit spend, and says so to the player. Wallet: refused, not failed over Two wallet-rpc processes over one wallet file can build transactions from the same outputs and corrupt the cache. A standby wallet is a custody decision for the operator, not something this code may take on its own — daemons are read-only and interchangeable, wallets are neither. So a wallet outage declines paid intake cleanly and leaves credits and free play working. Auth limiter evicts instead of refusing The bounded key map 429'd any NEW ip once full, turning a memory cap into a DoS primitive: a distributed source could hold it at capacity and lock every first-time caller out of Smirk login. It now reclaims expired entries and, if still full, evicts the oldest live windows. Same memory ceiling, still a working limiter. 129 suites / 1026 tests pass.
486 lines
19 KiB
JavaScript
486 lines
19 KiB
JavaScript
/**
|
|
* Smirk wallet auth routes (extracted from index.js).
|
|
* DI via ctx so handler bodies stay identical. ctx: { db }
|
|
*/
|
|
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const asyncHandler = require('../middleware/asyncHandler');
|
|
const { AppError, ValidationError } = require('../utils/errors');
|
|
const { verifyNip98Event } = require('../utils/nip98');
|
|
|
|
const AUTH_WINDOW_MS = 60 * 1000;
|
|
const AUTH_RATE_MAX_KEYS = 10000;
|
|
|
|
/**
|
|
* The host a NIP-98 auth event must be signed for.
|
|
*
|
|
* `HOSTED_BY` is the operator's canonical public origin and is contract-enforced in operated
|
|
* production profiles, so it is the authority whenever it is set — that keeps verification correct
|
|
* behind a proxy and immune to a spoofed Host header. Unconfigured (dev / self-host) deployments
|
|
* fall back to the request's own host, which still binds the event to the origin it was sent to.
|
|
*/
|
|
function expectedAuthHost(req) {
|
|
const configured = typeof process.env.HOSTED_BY === 'string' ? process.env.HOSTED_BY.trim() : '';
|
|
if (configured) {
|
|
try {
|
|
const parsed = new URL(configured);
|
|
if (parsed.host) return parsed.host;
|
|
} catch (_) { /* malformed HOSTED_BY — fall through to the request host */ }
|
|
}
|
|
return (req && (req.get('host') || req.headers?.host)) || null;
|
|
}
|
|
|
|
/**
|
|
* Per-IP auth rate limiter with a bounded key map.
|
|
*
|
|
* The bound must be enforced by EVICTION, never by refusing new keys. Refusing turned the memory
|
|
* cap into a denial-of-service primitive: an attacker sourcing from many addresses could hold the
|
|
* map at capacity and every legitimate first-time caller of /challenge or /verify got a 429, locking
|
|
* the whole population out of Smirk login. Eviction keeps the same memory ceiling while leaving the
|
|
* limiter functional — the worst case is that a very old window's count is forgotten, which grants
|
|
* one extra window to an attacker who was already being counted.
|
|
*/
|
|
function createIpRateLimiter({ max }) {
|
|
const hits = new Map();
|
|
let lastSweep = 0;
|
|
|
|
const sweepExpired = (now) => {
|
|
for (const [key, value] of hits) {
|
|
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
|
|
}
|
|
lastSweep = now;
|
|
};
|
|
|
|
// Discard the oldest live windows in one batch, so the O(n) pass is amortised rather than paid
|
|
// on every request once the map is full.
|
|
const evictOldest = (count) => {
|
|
const oldest = Array.from(hits.entries())
|
|
.sort((left, right) => left[1].windowStart - right[1].windowStart)
|
|
.slice(0, count);
|
|
for (const [key] of oldest) hits.delete(key);
|
|
};
|
|
|
|
return (req, res, next) => {
|
|
const now = Date.now();
|
|
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
|
|
|
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
let entry = hits.get(ip);
|
|
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
|
|
if (!entry && hits.size >= AUTH_RATE_MAX_KEYS) {
|
|
// Sweeping is otherwise lazy (once per window), so a map full of expired entries would
|
|
// stay full. Reclaim first, and only evict live windows if that was not enough.
|
|
sweepExpired(now);
|
|
if (hits.size >= AUTH_RATE_MAX_KEYS) {
|
|
evictOldest(Math.max(1, Math.ceil(AUTH_RATE_MAX_KEYS * 0.1)));
|
|
}
|
|
}
|
|
entry = { count: 0, windowStart: now };
|
|
hits.set(ip, entry);
|
|
}
|
|
|
|
entry.count += 1;
|
|
if (entry.count > max) {
|
|
const retryAfterSeconds = Math.max(1, Math.ceil(
|
|
(entry.windowStart + AUTH_WINDOW_MS - now) / 1000
|
|
));
|
|
res.setHeader('Retry-After', String(retryAfterSeconds));
|
|
return res.status(429).json({ error: 'Too many authentication attempts' });
|
|
}
|
|
return next();
|
|
};
|
|
}
|
|
|
|
module.exports = function createAuthRoutes(ctx) {
|
|
const { db, sessionManager } = ctx;
|
|
if (!db || typeof db.query !== 'function' || typeof db.withTransaction !== 'function') {
|
|
throw new TypeError('Smirk auth requires transactional database access');
|
|
}
|
|
if (!sessionManager
|
|
|| typeof sessionManager.generateSecureToken !== 'function'
|
|
|| typeof sessionManager.disconnectUserSessions !== 'function') {
|
|
throw new TypeError('Smirk auth requires session revocation support');
|
|
}
|
|
const router = express.Router();
|
|
const challengeRateLimit = createIpRateLimiter({ max: 10 });
|
|
const verifyRateLimit = createIpRateLimiter({ max: 20 });
|
|
|
|
function unauthorized(message = 'Session ownership verification failed') {
|
|
return new AppError(message, {
|
|
statusCode: 403,
|
|
code: 'SESSION_OWNERSHIP_REQUIRED',
|
|
safeMessage: 'Session ownership verification failed.'
|
|
});
|
|
}
|
|
|
|
async function resolveSessionOwner(req, socketId) {
|
|
const token = req.get('X-Session-Token');
|
|
if (!token) {
|
|
throw new AppError('Session token required', {
|
|
statusCode: 401,
|
|
code: 'SESSION_TOKEN_REQUIRED',
|
|
safeMessage: 'Session token required.'
|
|
});
|
|
}
|
|
if (typeof token !== 'string' || token.length > 512) throw unauthorized();
|
|
|
|
const result = await db.query(`
|
|
SELECT id, socket_id, anon_token
|
|
FROM users
|
|
WHERE socket_id = $1 AND anon_token = $2
|
|
LIMIT 1
|
|
`, [socketId, token]);
|
|
if (result.rows.length !== 1) throw unauthorized();
|
|
return { userId: result.rows[0].id, socketId, token };
|
|
}
|
|
|
|
/**
|
|
* Atomically consume a challenge nonce, single-use, bound to the authenticated stable user.
|
|
* 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, session) {
|
|
const consumed = await db.query(`
|
|
UPDATE smirk_challenges
|
|
SET used = TRUE
|
|
WHERE challenge = $1
|
|
AND user_id = $2
|
|
AND socket_id = $3
|
|
AND used = FALSE
|
|
AND expires_at > NOW()
|
|
RETURNING id
|
|
`, [challenge, session.userId, session.socketId]);
|
|
return consumed.rows.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Link a proven wallet key to the authenticated stable user. Wallet adoption is one
|
|
* transaction: lock/re-check the presented session, revoke the temporary session,
|
|
* rotate the wallet owner's bearer token, clear the displaced row, and assign the caller
|
|
* socket to exactly one user. Live cached sockets are disconnected after the HTTP response.
|
|
*/
|
|
async function linkSmirkKey(provenKey, session) {
|
|
return db.withTransaction(async (client) => {
|
|
const currentResult = await client.query(`
|
|
SELECT id
|
|
FROM users
|
|
WHERE id = $1 AND socket_id = $2 AND anon_token = $3
|
|
FOR UPDATE
|
|
`, [session.userId, session.socketId, session.token]);
|
|
if (currentResult.rows.length !== 1) throw unauthorized();
|
|
|
|
const existingLink = await client.query(`
|
|
SELECT id, socket_id, payout_address
|
|
FROM users
|
|
WHERE smirk_public_key = $1 AND id != $2
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
`, [provenKey, session.userId]);
|
|
|
|
if (existingLink.rows.length > 0) {
|
|
const owner = existingLink.rows[0];
|
|
const ownerToken = sessionManager.generateSecureToken();
|
|
const revokedTemporaryToken = sessionManager.generateSecureToken();
|
|
|
|
const revoked = await client.query(`
|
|
UPDATE users
|
|
SET socket_id = NULL, anon_token = $1, last_seen = NOW()
|
|
WHERE id = $2 AND socket_id = $3 AND anon_token = $4
|
|
RETURNING id
|
|
`, [revokedTemporaryToken, session.userId, session.socketId, session.token]);
|
|
if (revoked.rows.length !== 1) throw unauthorized();
|
|
|
|
const adopted = await client.query(`
|
|
UPDATE users
|
|
SET socket_id = $1, anon_token = $2, last_seen = NOW()
|
|
WHERE id = $3 AND smirk_public_key = $4
|
|
RETURNING id, payout_address
|
|
`, [session.socketId, ownerToken, owner.id, provenKey]);
|
|
if (adopted.rows.length !== 1) {
|
|
throw new AppError('Wallet account adoption lost its ownership lock', {
|
|
statusCode: 409,
|
|
code: 'WALLET_ADOPTION_CONFLICT',
|
|
safeMessage: 'Wallet sign-in conflicted with another request. Please retry.'
|
|
});
|
|
}
|
|
|
|
const socketOwner = await client.query(`
|
|
SELECT id
|
|
FROM users
|
|
WHERE socket_id = $1
|
|
FOR UPDATE
|
|
`, [session.socketId]);
|
|
if (socketOwner.rows.length !== 1
|
|
|| String(socketOwner.rows[0].id) !== String(owner.id)) {
|
|
throw new AppError('Adopted socket ownership is ambiguous', {
|
|
statusCode: 409,
|
|
code: 'SOCKET_OWNERSHIP_CONFLICT',
|
|
safeMessage: 'Wallet sign-in conflicted with another session. Please retry.'
|
|
});
|
|
}
|
|
|
|
return {
|
|
adopted: true,
|
|
currentUserId: session.userId,
|
|
ownerUserId: owner.id,
|
|
previousOwnerSocketId: owner.socket_id || null,
|
|
sessionToken: ownerToken,
|
|
address: adopted.rows[0].payout_address || null
|
|
};
|
|
}
|
|
|
|
const linked = await client.query(`
|
|
UPDATE users
|
|
SET smirk_public_key = $1
|
|
WHERE id = $2 AND socket_id = $3 AND anon_token = $4
|
|
RETURNING id
|
|
`, [provenKey, session.userId, session.socketId, session.token]);
|
|
if (linked.rows.length !== 1) throw unauthorized();
|
|
return { adopted: false, currentUserId: session.userId };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/smirk/challenge
|
|
* Generate a challenge for Smirk wallet signature verification
|
|
* Body: { socketId: string }
|
|
*/
|
|
router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async (req, res) => {
|
|
const { socketId } = req.body || {};
|
|
|
|
if (!socketId || typeof socketId !== 'string' || socketId.length > 255) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId is required to generate a challenge.'
|
|
});
|
|
}
|
|
|
|
const session = await resolveSessionOwner(req, socketId);
|
|
|
|
// Generate a cryptographically secure challenge
|
|
const challenge = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Store a stable user binding as well as the current socket. The user binding is what
|
|
// prevents a public Tavern/Match socket id from being used to attach an attacker's key.
|
|
const inserted = await db.query(`
|
|
INSERT INTO smirk_challenges (challenge, socket_id, user_id)
|
|
SELECT $1, $2, id
|
|
FROM users
|
|
WHERE id = $3 AND socket_id = $2 AND anon_token = $4
|
|
RETURNING id
|
|
`, [challenge, socketId, session.userId, session.token]);
|
|
if (inserted.rows.length !== 1) throw unauthorized();
|
|
|
|
// Clean up old/expired challenges periodically
|
|
await db.query(`
|
|
DELETE FROM smirk_challenges
|
|
WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour')
|
|
`);
|
|
|
|
res.json({
|
|
challenge,
|
|
expiresIn: 300 // 5 minutes in seconds
|
|
});
|
|
}));
|
|
|
|
/**
|
|
* POST /api/auth/smirk/verify
|
|
* 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', verifyRateLimit, asyncHandler(async (req, res) => {
|
|
const body = req.body || {};
|
|
const { socketId } = body;
|
|
|
|
if (!socketId || typeof socketId !== 'string' || socketId.length > 255) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId is required.'
|
|
});
|
|
}
|
|
const session = await resolveSessionOwner(req, socketId);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, session);
|
|
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);
|
|
// Bind the signed event to THIS deployment's host. The 'u' tag path check alone is not an
|
|
// authentication boundary: a kind:27235 event the same wallet signed for any other site whose
|
|
// path happens to end in /api/auth/smirk/verify would otherwise verify here — and because the
|
|
// link step ADOPTS whatever account already owns that pubkey and returns its session token, a
|
|
// borrowed event is a full account takeover. Prefer the operator's configured HOSTED_BY origin;
|
|
// fall back to the request host only when it is not configured (dev/self-host).
|
|
const result = verifyNip98Event(body.event, {
|
|
challenge,
|
|
expectedPathSuffix: '/api/auth/smirk/verify',
|
|
expectedHost: expectedAuthHost(req),
|
|
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).
|
|
const outcome = await linkSmirkKey(result.pubkey, session);
|
|
if (outcome.adopted) {
|
|
res.json({
|
|
success: true,
|
|
linked: true,
|
|
adopted: true,
|
|
sessionToken: outcome.sessionToken,
|
|
address: outcome.address,
|
|
message: 'Signed in to your wallet-linked account.'
|
|
});
|
|
setImmediate(() => sessionManager.disconnectUserSessions(
|
|
[outcome.currentUserId, outcome.ownerUserId],
|
|
[socketId, outcome.previousOwnerSocketId].filter(Boolean)
|
|
));
|
|
return;
|
|
}
|
|
res.json({ success: true, linked: true, message: 'Smirk wallet linked successfully' });
|
|
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.'
|
|
});
|
|
}
|
|
|
|
// Consume the challenge single-use (atomic) before verifying the signature.
|
|
const consumed = await consumeChallenge(challenge, session);
|
|
if (!consumed) {
|
|
throw new ValidationError('Invalid or expired challenge', {
|
|
safeMessage: 'The challenge is invalid, expired, or has already been used.'
|
|
});
|
|
}
|
|
|
|
// 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');
|
|
const challengeBytes = Buffer.from(challenge, 'utf8');
|
|
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
|
|
} catch (verifyError) {
|
|
console.error('Signature verification error:', verifyError.message);
|
|
throw new ValidationError('Signature verification failed', {
|
|
safeMessage: 'Unable to verify the wallet signature.'
|
|
});
|
|
}
|
|
|
|
if (!isValidSignature) {
|
|
throw new ValidationError('Invalid signature', {
|
|
safeMessage: 'The wallet signature is invalid.'
|
|
});
|
|
}
|
|
|
|
const outcome = await linkSmirkKey(publicKey, session);
|
|
if (outcome.adopted) {
|
|
res.json({
|
|
success: true,
|
|
linked: true,
|
|
adopted: true,
|
|
sessionToken: outcome.sessionToken,
|
|
address: outcome.address,
|
|
message: 'Signed in to your wallet-linked account.'
|
|
});
|
|
setImmediate(() => sessionManager.disconnectUserSessions(
|
|
[outcome.currentUserId, outcome.ownerUserId],
|
|
[socketId, outcome.previousOwnerSocketId].filter(Boolean)
|
|
));
|
|
return;
|
|
}
|
|
res.json({ success: true, linked: true, message: 'Smirk wallet linked successfully' });
|
|
}));
|
|
|
|
/**
|
|
* GET /api/auth/smirk/status
|
|
* Check if a session has a linked Smirk wallet.
|
|
* Query: socketId=string ; Auth: X-Session-Token header.
|
|
*
|
|
* 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;
|
|
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId query parameter is required.'
|
|
});
|
|
}
|
|
|
|
const token = req.get('X-Session-Token');
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Session token required' });
|
|
}
|
|
|
|
// Identify the caller by anon_token ALONE — it is the ownership secret. The previous query also
|
|
// required socket_id = current socket.id, but socket_id is VOLATILE (changes on every refresh /
|
|
// reconnect), so after a refresh the row's stored socket_id no longer matched and this 403'd —
|
|
// which left the client's _isLinked=false and silently disabled Smirk one-click payment. Matching
|
|
// on the unguessable token is the same guarantee (you can only read your OWN state) without the
|
|
// socket_id fragility. (socketId is still required in the query string for API shape.)
|
|
const result = await db.query(`
|
|
SELECT smirk_public_key, payout_address
|
|
FROM users
|
|
WHERE anon_token = $1
|
|
`, [token]);
|
|
|
|
if (result.rows.length === 0) {
|
|
// Unknown/again-rotated token — ownership check failed; disclose nothing.
|
|
return res.status(403).json({ error: 'Session ownership verification failed' });
|
|
}
|
|
|
|
const user = result.rows[0];
|
|
res.json({
|
|
linked: !!user.smirk_public_key,
|
|
hasPayoutAddress: !!user.payout_address
|
|
});
|
|
}));
|
|
|
|
return router;
|
|
};
|