mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-26 17:48:32 +01:00
feat(auth): NIP-98 nostr sign-in for Smirk; fix SHA256 fallback + /status BOLA
Adds schnorr (BIP-340) NIP-98 verification (kind 27235) as the preferred Smirk login, with the legacy Ed25519 wallet-message path kept as a feature-detected fallback for wallets without signNostrEvent. New src/utils/nip98.js verifies securely: rebuilds a clean event, independently recomputes getEventHash and compares to event.id (defeats nostr-tools' verify-memo footgun), then schnorr-verifies; enforces kind, created_at freshness, and exactly-one u/method/challenge tags bound to the server nonce.
auth.js: /verify branches on body.event (NIP-98) vs {publicKey,signature,challenge} (legacy). Challenge consume is now atomic single-use (UPDATE ... WHERE used=FALSE AND expires_at>NOW() RETURNING) before verification. Removed the deprecated SHA256(challenge) pre-hash fallback (past its removal date). /status is now session-gated (X-Session-Token vs users.anon_token; 401/403 on mismatch) — closes the unauthenticated per-socketId disclosure. Client smirkAuth.js: feature-detects signNostrEvent, signs the NIP-98 event, falls back to legacy; checkStatus() now sends the session token.
Adds nostr-tools ^2.23.9 (works under CJS in prod; test loader falls back to its self-contained bundle under Jest). New adversarial suite test/smirkNip98_ultra.test.js (13 tests: tamper/expiry/wrong-tag/bad-sig/memo-bypass/replay). Suite 47/330 green.
This commit is contained in:
Generated
+105
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+148
-71
@@ -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];
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user