From 4b78fec8533562b5041a89177580da5c78c662e8 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 22:20:10 -0400 Subject: [PATCH] feat(chat): per-player nostr identity (Phase 2) + premium subscription unlocks cosmetic tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — a signed-in player can post global chat under their OWN Smirk npub instead of the shared bridge account: - src/utils/verifyChatEvent.js: verify a client-signed chat event with the same defenses as verifyNip98Event (rebuild clean event, recompute id, verify schnorr, freshness) PLUS the impersonation guard — the event's pubkey MUST equal the session's authenticated npub (users.smirk_public_key from NIP-98 login). - ChatProvider.relaySignedEvent (base: local delivery only) + NostrChatProvider override (local delivery + publish the PRE-SIGNED event to the relays, no re-signing, echo-deduped) so it carries the player's npub. - chatHandler.handleSignedChatMessage: rate-limit + resolve the session npub + verify + escape + relay. socketHandlers wires the event. Serves lobby + tavern (both render the global broadcast). - html/js/smirkChatSign.js + tavern send wire: feature-detected client helper — signs via window.smirk.signNostrEvent, falls back to the unsigned send when Smirk isn't connected, so it's inert (and safe) until a page has a wallet. Premium -> cosmetics (the P1 catalog already had the hook): - entitlements.levelForUser now takes the HIGHER of premium_level and an active-subscription tier, so a wowne.ro premium sub sets the entitlement tier and unlocks tile/customization packs at/below it — one sub, chat perks + cosmetics. Buying credits still never joins the premium ladder. Behavior-preserving: no subscription_tier => unchanged; chat_signed only fires when a client opts in. The client signing path needs browser verification against a connected Smirk wallet (like the NIP-98 login). Tests: +13 (verify incl. impersonation/tamper/channel/freshness, relay+echo-dedupe, sub-tier unlock). Suite 56 suites / 407 tests green. --- html/js/smirkChatSign.js | 31 +++++++ html/tavern.html | 13 ++- src/multiplayer/entitlements.js | 13 ++- src/network/chat/ChatProvider.js | 10 ++ src/network/chat/NostrChatProvider.js | 13 +++ src/network/chatHandler.js | 62 +++++++++++++ src/network/socketHandlers.js | 2 + src/utils/verifyChatEvent.js | 90 ++++++++++++++++++ test/perPlayerSigning_ultra.test.js | 127 ++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 html/js/smirkChatSign.js create mode 100644 src/utils/verifyChatEvent.js create mode 100644 test/perPlayerSigning_ultra.test.js diff --git a/html/js/smirkChatSign.js b/html/js/smirkChatSign.js new file mode 100644 index 0000000..3bc525c --- /dev/null +++ b/html/js/smirkChatSign.js @@ -0,0 +1,31 @@ +(function (root) { + 'use strict'; + // Per-player nostr identity (Phase 2). Sign a global-chat message with the player's OWN Smirk + // npub (window.smirk.signNostrEvent) and emit it as `chat_signed`; the server verifies it's + // signed by this session's authenticated npub, then relays it to nostr under that npub. + // + // Returns a Promise: true if it signed + emitted, false if Smirk signing is + // unavailable or declined — in which case the caller falls back to the normal unsigned send. + // Feature-detected end to end, so it's a no-op (always false) until the page has a connected + // Smirk wallet; wiring it into a send site is therefore always safe. + function smirkChatSign(socket, text, tag) { + try { + var smirk = root.smirk; + if (!socket || !smirk || typeof smirk.signNostrEvent !== 'function') return Promise.resolve(false); + var tmpl = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [['t', tag || 'wowngeon-global']], + content: String(text) + }; + return Promise.resolve(smirk.signNostrEvent(tmpl)).then(function (signed) { + if (!signed || !signed.id || !signed.sig || !signed.pubkey) return false; + socket.emit('chat_signed', { event: signed }); + return true; + }).catch(function () { return false; }); + } catch (e) { + return Promise.resolve(false); + } + } + root.smirkChatSign = smirkChatSign; +})(typeof window !== 'undefined' ? window : this); diff --git a/html/tavern.html b/html/tavern.html index 9353214..ccb64ef 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -5,6 +5,7 @@ Tavern - Wownerogue + @@ -369,7 +370,17 @@ chatForm.addEventListener('submit', function (e) { e.preventDefault(); var text = chatInput.value; - if (text && text.trim()) socket.emit('tavern_chat', { text: text }); + if (text && text.trim()) { + // Try to post under the player's OWN Smirk npub (per-player identity); fall back to + // the normal unsigned send if Smirk signing isn't available/declined. + if (window.smirkChatSign) { + window.smirkChatSign(socket, text.trim(), 'wowngeon-global').then(function (signed) { + if (!signed) socket.emit('tavern_chat', { text: text }); + }); + } else { + socket.emit('tavern_chat', { text: text }); + } + } chatInput.value = ''; }); diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js index 1efcffc..6d48f80 100644 --- a/src/multiplayer/entitlements.js +++ b/src/multiplayer/entitlements.js @@ -70,10 +70,17 @@ function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) { } // Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not. +// The tier is the HIGHEST of the stored premium_level and any active-subscription tier: a live +// wowne.ro premium sub sets subscription_tier (e.g. 'premium') and thereby unlocks the cosmetic +// packs at/below that tier — one subscription drives both chat perks and tile/customization unlocks. +// (The subscription_tier field is populated by the user loader from a subscription check; absent = +// unchanged legacy behavior.) function levelForUser(user = {}) { - const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase(); - if (raw && tierForLevel(raw) > 0) return raw; - return 'free'; + const candidates = [user.premium_level, user.premiumLevel, user.subscription_tier, user.subscriptionTier] + .map(v => String(v || '').trim().toLowerCase()) + .filter(l => tierForLevel(l) > 0); + if (!candidates.length) return 'free'; + return candidates.reduce((a, b) => (tierForLevel(b) > tierForLevel(a) ? b : a)); } // A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime diff --git a/src/network/chat/ChatProvider.js b/src/network/chat/ChatProvider.js index 0adcdde..7a521f6 100644 --- a/src/network/chat/ChatProvider.js +++ b/src/network/chat/ChatProvider.js @@ -38,6 +38,16 @@ class ChatProvider { return []; } + /** + * Deliver a message whose nostr event was signed by the CLIENT (per-player identity, Phase 2). + * The base/local provider has no relay, so it just delivers the message in-game — the signed + * `event` is ignored. NostrChatProvider overrides this to ALSO publish the pre-signed event. + * @param {object} req { event, scope, username, text, ts, socketId, userId } + */ + async relaySignedEvent({ event, ...msg } = {}) { // eslint-disable-line no-unused-vars + return this.publish({ scope: 'global', ...msg }); + } + // eslint-disable-next-line no-empty-function async shutdown() {} } diff --git a/src/network/chat/NostrChatProvider.js b/src/network/chat/NostrChatProvider.js index d8d472b..6ac7297 100644 --- a/src/network/chat/NostrChatProvider.js +++ b/src/network/chat/NostrChatProvider.js @@ -129,6 +129,19 @@ class NostrChatProvider extends ChatProvider { return true; } + // Relay a CLIENT-signed event (per-player identity, Phase 2). Delivers the message in-game + // (escaped text, exactly like a normal global message) and publishes the pre-signed event to + // the relays WITHOUT re-signing — so it carries the player's OWN npub, not the bridge. The + // signed id is marked seen so the relay round-trip doesn't re-deliver it. + async relaySignedEvent({ event, username, text, ts, socketId, userId } = {}) { + await this.local.publish({ scope: 'global', username, text, ts, socketId, userId }); + if (this._globalEnabled() && event && event.id) { + this._markSeen(event.id); + try { await this.transport.publish(event); } + catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] signed relay failed:', err.message); } + } + } + async getHistory(opts) { return this.local.getHistory(opts); } diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index 6c05542..541f9a4 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -7,6 +7,8 @@ const ChatHistoryManager = require('./chatHistoryManager'); const SocketChatProvider = require('./chat/SocketChatProvider'); const { buildChatProvider } = require('./chat'); const { clientIp, stableId } = require('./rateLimitContext'); +const { verifyChatEvent } = require('../utils/verifyChatEvent'); +const { escapeChatText } = require('../utils/escapeChat'); class ChatHandler { constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter, db }) { @@ -124,6 +126,66 @@ class ChatHandler { } } + /** + * Handle a CLIENT-signed global chat event (Phase 2 per-player nostr identity). The client signs + * with the player's OWN Smirk npub (window.smirk.signNostrEvent) and sends the finished event; + * the server verifies it's authentic AND signed by THIS session's authenticated npub + * (users.smirk_public_key from NIP-98 login), moderates, then relays it — the provider publishes + * the pre-signed event to nostr under the player's npub (not the bridge). Serves lobby + tavern: + * it delivers to global, which both render. + */ + async handleSignedChatMessage(socket, payload) { + try { + const rlId = stableId(socket, this.gameModeManager?.sessionManager); + const rlIp = clientIp(socket); + const rl = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp); + if (!rl.allowed) { + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please slow down before your next message.'); + return; + } + await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp); + + const event = payload && payload.event; + if (!event || typeof event !== 'object') return; + + // The session's authenticated npub is the ONLY key this player may post under. + const db = this.gameModeManager?.db; + const session = this.gameModeManager?.sessionManager?.sessions?.get(socket.id); + const userId = session ? session.id : null; + if (!db || !userId) { + this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.'); + return; + } + const row = (await db.query('SELECT smirk_public_key, display_name, username FROM users WHERE id = $1', [userId])).rows[0]; + const npub = row && row.smirk_public_key; + if (!npub) { + this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.'); + return; + } + + const verdict = verifyChatEvent(event, { + expectedPubkey: npub, + channelTag: process.env.NOSTR_CHAT_TAG || 'wowngeon-global', + maxLen: 280 + }); + if (!verdict.ok) { + if (this.debugManager.CONSOLE_LOGGING) console.warn('[chat] signed event rejected:', verdict.reason); + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Your signed message could not be verified.'); + return; + } + + const username = row.display_name || row.username || String(npub).slice(0, 8); + const text = escapeChatText(verdict.content, 200); + + // Delivers in-game (escaped) + relays the client-signed event to nostr under the player's npub. + await this.chatProvider.relaySignedEvent({ + event, scope: 'global', username, text, ts: Date.now(), socketId: socket.id, userId + }); + } catch (err) { + console.error('Signed chat handler error:', err.message); + } + } + /** * Get chat statistics */ diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 68dd749..ccc8332 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -438,6 +438,8 @@ class SocketHandlers { handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket), handleStatsRequest: (socket) => this.handleStatsRequest(socket) })); + // Phase 2: a client-signed global chat event (posts under the player's own Smirk npub). + socket.on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload)); socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData)); socket.on('disconnect', () => this.handleDisconnect(socket)); socket.on('debug_ping', (data) => this.handleDebugPing(socket, data)); diff --git a/src/utils/verifyChatEvent.js b/src/utils/verifyChatEvent.js new file mode 100644 index 0000000..08469c2 --- /dev/null +++ b/src/utils/verifyChatEvent.js @@ -0,0 +1,90 @@ +const { loadNostrTools } = require('./nostrLoader'); + +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 + +function fail(reason) { return { ok: false, reason }; } + +/** + * Verify a client-signed global-chat event before the server relays it to nostr (Phase 2 per-player + * signing). Same defense posture as verifyNip98Event: rebuild a clean event from primitives, + * INDEPENDENTLY recompute the id, verify the schnorr signature, check freshness — and, critically, + * bind the author to the session's authenticated npub so a player can only ever post as THEMSELVES. + * + * @param {object} event signed nostr event { kind, created_at, pubkey, id, sig, content, tags } + * @param {object} opts + * @param {string} opts.expectedPubkey 64-hex x-only key the session is authenticated as (from NIP-98 login) — required + * @param {string} [opts.channelTag] 't' tag the event must carry (default 'wowngeon-global') + * @param {number} [opts.kind] expected kind (default 1) + * @param {number} [opts.maxLen] max content length (default 280) + * @param {number} [opts.now] unix seconds (default now) + * @param {number} [opts.maxSkewSec] freshness window (default 120) + * @returns {{ ok: boolean, reason?: string, pubkey?: string, content?: string }} + */ +function verifyChatEvent(event, opts = {}) { + const { + expectedPubkey, + channelTag = 'wowngeon-global', + kind = 1, + maxLen = 280, + now = Math.floor(Date.now() / 1000), + maxSkewSec = 120 + } = opts; + + if (!expectedPubkey || !HEX_64.test(String(expectedPubkey).toLowerCase())) return fail('no-expected-pubkey'); + + // Shape/type validation. + 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.toLowerCase())) return fail('bad-pubkey'); + if (typeof event.id !== 'string' || !HEX_64.test(event.id.toLowerCase())) return fail('bad-id'); + if (typeof event.sig !== 'string' || !HEX_128.test(event.sig.toLowerCase())) 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 t of event.tags) { + if (!Array.isArray(t) || t.some((v) => typeof v !== 'string')) return fail('malformed-tag'); + } + + if (event.kind !== kind) return fail('wrong-kind'); + if (event.content.length > maxLen) return fail('too-long'); + + // Author binding — the ONE that stops impersonation: the event must be signed by the npub this + // session authenticated as (users.smirk_public_key from NIP-98 login). A valid signature by + // SOME other key is not enough. + if (event.pubkey.toLowerCase() !== String(expectedPubkey).toLowerCase()) return fail('pubkey-mismatch'); + + // Rebuild a clean event from primitives only (strips any stamped verified-symbol / extras). + const clean = { + kind: event.kind, + created_at: event.created_at, + pubkey: event.pubkey.toLowerCase(), + id: event.id.toLowerCase(), + sig: event.sig.toLowerCase(), + content: event.content, + tags: event.tags.map((t) => t.slice()) + }; + + // Independently recompute the id — defeats content/tag tampering regardless of memoization. + let recomputed; + try { recomputed = getEventHash(clean); } catch (_) { return fail('hash-error'); } + if (recomputed !== clean.id) return fail('id-mismatch'); + + // BIP-340 schnorr signature. + let sigOk = false; + try { sigOk = verifyEvent(clean) === true; } catch (_) { return fail('sig-verify-threw'); } + if (!sigOk) return fail('bad-signature'); + + // Freshness — reject replayed-late / skewed events. + if (Math.abs(now - Number(clean.created_at)) > maxSkewSec) return fail('expired'); + + // Must target OUR channel tag, so the server can't be used to relay arbitrary events. + const tTags = clean.tags.filter((t) => t[0] === 't').map((t) => t[1]); + if (!tTags.includes(channelTag)) return fail('wrong-channel'); + + return { ok: true, pubkey: clean.pubkey, content: clean.content }; +} + +module.exports = { verifyChatEvent }; diff --git a/test/perPlayerSigning_ultra.test.js b/test/perPlayerSigning_ultra.test.js new file mode 100644 index 0000000..5c9cb37 --- /dev/null +++ b/test/perPlayerSigning_ultra.test.js @@ -0,0 +1,127 @@ +/** + * Phase 2 — per-player nostr identity: verify client-signed chat events, relay them under the + * player's own npub, and let a premium subscription unlock cosmetic tiers. + */ +const { verifyChatEvent } = require('../src/utils/verifyChatEvent'); +const { loadNostrTools } = require('../src/utils/nostrLoader'); +const NostrChatProvider = require('../src/network/chat/NostrChatProvider'); +const ChatProvider = require('../src/network/chat/ChatProvider'); +const Entitlements = require('../src/multiplayer/entitlements'); + +const tools = loadNostrTools(); +function signedEvent({ sk, content = 'gm', tag = 'wowngeon-global', kind = 1, created_at } = {}) { + return tools.finalizeEvent({ + kind, + created_at: created_at || Math.floor(Date.now() / 1000), + tags: [['t', tag]], + content + }, sk); +} + +describe('verifyChatEvent', () => { + const sk = tools.generateSecretKey(); + const pub = tools.getPublicKey(sk); + + test('accepts an authentic event signed by the expected npub', () => { + const v = verifyChatEvent(signedEvent({ sk, content: 'hello world' }), { expectedPubkey: pub }); + expect(v.ok).toBe(true); + expect(v.pubkey).toBe(pub); + expect(v.content).toBe('hello world'); + }); + + test('rejects a valid event signed by a DIFFERENT key (impersonation)', () => { + const otherPub = tools.getPublicKey(tools.generateSecretKey()); + const v = verifyChatEvent(signedEvent({ sk }), { expectedPubkey: otherPub }); + expect(v.ok).toBe(false); + expect(v.reason).toBe('pubkey-mismatch'); + }); + + test('rejects a tampered event (content changed after signing)', () => { + const ev = signedEvent({ sk, content: 'original' }); + ev.content = 'tampered'; + expect(verifyChatEvent(ev, { expectedPubkey: pub }).reason).toBe('id-mismatch'); + }); + + test('rejects the wrong channel tag', () => { + const v = verifyChatEvent(signedEvent({ sk, tag: 'some-other-room' }), { expectedPubkey: pub }); + expect(v.reason).toBe('wrong-channel'); + }); + + test('rejects a stale event', () => { + const v = verifyChatEvent(signedEvent({ sk, created_at: Math.floor(Date.now() / 1000) - 9999 }), { expectedPubkey: pub }); + expect(v.reason).toBe('expired'); + }); + + test('rejects wrong kind and over-length content', () => { + expect(verifyChatEvent(signedEvent({ sk, kind: 4 }), { expectedPubkey: pub }).reason).toBe('wrong-kind'); + expect(verifyChatEvent(signedEvent({ sk, content: 'x'.repeat(400) }), { expectedPubkey: pub, maxLen: 280 }).reason).toBe('too-long'); + }); + + test('requires an expected pubkey', () => { + expect(verifyChatEvent(signedEvent({ sk }), {}).reason).toBe('no-expected-pubkey'); + }); +}); + +describe('relaySignedEvent', () => { + function fakeLocal() { + return { initialize: jest.fn().mockResolvedValue(), publish: jest.fn().mockResolvedValue(), getHistory: jest.fn(), shutdown: jest.fn() }; + } + function fakeTransport() { + return { publish: jest.fn().mockResolvedValue(), subscribe: jest.fn(function (f, cb) { this.onEvent = cb; }), close: jest.fn() }; + } + + test('NostrChatProvider delivers in-game AND relays the pre-signed event without re-signing', async () => { + const local = fakeLocal(), transport = fakeTransport(); + const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => { throw new Error('should not sign'); } } }); + const event = { id: 'evt-42', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] }; + await p.relaySignedEvent({ event, username: 'alice', text: 'gm', ts: 1700000000000 }); + + expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'alice', text: 'gm' })); + expect(transport.publish).toHaveBeenCalledWith(event); // the client-signed event, verbatim + }); + + test('the relayed event echoing back from the relay is deduped (not re-delivered)', async () => { + const local = fakeLocal(), transport = fakeTransport(); + const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => ({}) } }); + await p.initialize(); + const event = { id: 'evt-echo', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] }; + await p.relaySignedEvent({ event, username: 'alice', text: 'gm' }); + local.publish.mockClear(); + transport.onEvent(event); // relay echoes our own published event back + expect(local.publish).not.toHaveBeenCalled(); // markSeen dropped it + }); + + test('the base ChatProvider relaySignedEvent just delivers locally (no relay)', async () => { + const base = new ChatProvider(); + base.publish = jest.fn().mockResolvedValue(); + await base.relaySignedEvent({ event: { id: 'x' }, username: 'a', text: 'hi' }); + expect(base.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'a', text: 'hi' })); + expect(base.publish.mock.calls[0][0].event).toBeUndefined(); // signed event not forwarded to a non-relay provider + }); +}); + +describe('premium subscription unlocks cosmetic tiers', () => { + test('an active premium sub sets the tier and unlocks tier-1 packs — no purchase needed', () => { + const free = Entitlements.snapshotForUser({ credits: 0, total_credits_purchased: 0 }); + expect(free.tier).toBe(0); + expect(free.packs['generated-skins']).toBe(false); // tier-1 pack locked for free + + const premium = Entitlements.snapshotForUser({ subscription_tier: 'premium', total_credits_purchased: 0 }); + expect(premium.level).toBe('premium'); + expect(premium.tier).toBe(2); + expect(premium.packs['generated-skins']).toBe(true); // tier-1 pack unlocked by the sub + expect(premium.premium).toBe(true); + }); + + test('buying credits still does NOT put you on the premium ladder', () => { + const snap = Entitlements.snapshotForUser({ subscription_tier: 'credits', total_credits_purchased: 50 }); + expect(snap.level).toBe('free'); + expect(snap.tier).toBe(0); + }); + + test('the higher of stored level and subscription tier wins', () => { + const snap = Entitlements.snapshotForUser({ premium_level: 'supporter', subscription_tier: 'operator' }); + expect(snap.level).toBe('operator'); + expect(snap.tier).toBe(3); + }); +});