From 19667807bb68c994dba7c2d28192d0dae433e0ee Mon Sep 17 00:00:00 2001 From: counterweight Date: Wed, 12 Feb 2025 00:44:17 +0100 Subject: [PATCH] nostr verification working --- package-lock.json | 119 ++++++++++++++++++++++++++ package.json | 1 + src/constants.js | 4 +- src/models/NostrChallengeCompleted.js | 27 ++++++ src/models/NostrChallengeCreated.js | 4 + src/public/javascript/index.js | 7 +- src/routes/apiRoutes.js | 38 +++++++- src/services/nostrService.js | 65 +++++++++++++- 8 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 src/models/NostrChallengeCompleted.js diff --git a/package-lock.json b/package-lock.json index 135b59d..41c8b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.17.1", + "nostr-tools": "^2.10.4", "pg": "^8.13.1", "sequelize": "^6.37.5", "sqlite3": "^5.1.7", @@ -26,6 +27,47 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -50,6 +92,53 @@ "node": ">=10" } }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1577,6 +1666,36 @@ "node": ">=6" } }, + "node_modules/nostr-tools": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "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==", + "optional": true + }, "node_modules/npmlog": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", diff --git a/package.json b/package.json index a7f9f34..20d5a81 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.17.1", + "nostr-tools": "^2.10.4", "pg": "^8.13.1", "sequelize": "^6.37.5", "sqlite3": "^5.1.7", diff --git a/src/constants.js b/src/constants.js index f296e0b..6cf88fe 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,5 +1,7 @@ const DEFAULT_SESSION_DURATION_SECONDS = 60 * 60 * 24 * 30; +const DEFAULT_NOSTR_CHALLENGE_DURATION_SECONDS = 60 * 60 * 24 * 30; module.exports = { - DEFAULT_SESSION_DURATION_SECONDS + DEFAULT_SESSION_DURATION_SECONDS, + DEFAULT_NOSTR_CHALLENGE_DURATION_SECONDS } \ No newline at end of file diff --git a/src/models/NostrChallengeCompleted.js b/src/models/NostrChallengeCompleted.js new file mode 100644 index 0000000..063aa9d --- /dev/null +++ b/src/models/NostrChallengeCompleted.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../database'); + +const NostrChallengeCompleted = sequelize.define('NostrChallengeCompleted', { + uuid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + primaryKey: true + }, + challenge: { + type: DataTypes.STRING, + allowNull: false + }, + signed_event: { + type: DataTypes.JSONB, + allowNull: false + }, + created_at: { + type: DataTypes.DATE, + allowNull: false + } +}, { + tableName: 'nostr_challenge_completed' +}); + +module.exports = NostrChallengeCompleted; \ No newline at end of file diff --git a/src/models/NostrChallengeCreated.js b/src/models/NostrChallengeCreated.js index b6571f3..2ae5f79 100644 --- a/src/models/NostrChallengeCreated.js +++ b/src/models/NostrChallengeCreated.js @@ -12,6 +12,10 @@ const NostrChallengeCreated = sequelize.define('NostrChallengeCreated', { type: DataTypes.STRING, allowNull: false }, + expires_at: { + type: DataTypes.DATE, + allowNull: false + }, created_at: { type: DataTypes.DATE, allowNull: false diff --git a/src/public/javascript/index.js b/src/public/javascript/index.js index 5bd4670..4a0a2ce 100644 --- a/src/public/javascript/index.js +++ b/src/public/javascript/index.js @@ -9,9 +9,7 @@ async function login() { if (!challengeResponse.ok) throw new Error("Failed to fetch challenge"); const { challenge } = await challengeResponse.json(); - console.log(`Received challenge: ${challenge}`); - } catch (error) { } - /* const pubkey = await window.nostr.getPublicKey(); + const pubkey = await window.nostr.getPublicKey(); const event = { kind: 22242, @@ -29,6 +27,9 @@ async function login() { body: JSON.stringify(signedEvent), }); + } catch (error) { } + /* + if (!verifyResponse.ok) throw new Error("Verification failed"); const verifyResult = await verifyResponse.json(); diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index a87a45a..04f5519 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -1,5 +1,5 @@ const express = require('express'); -//const { generatePrivateKey, getPublicKey, verifySignature } = require("nostr-tools"); +const { getPublicKey, verifyEvent } = require("nostr-tools"); const crypto = require("crypto"); const appInviteService = require('../services/appInviteService'); @@ -64,4 +64,40 @@ router.get('/nostr-challenge', async (req, res) => { res.json({ 'challenge': nostrChallenge.challenge }); }); + +router.post("/nostr-verify", async (req, res) => { + const signedEvent = req.body; + + if (!signedEvent || !signedEvent.tags) { + return res.status(400).json({ success: false, error: "Invalid event format" }); + } + + const challengeTag = signedEvent.tags.find(tag => tag[0] === "challenge"); + if (!challengeTag) { + return res.status(400).json({ success: false, error: "No challenge tag found" }); + } + + const challenge = challengeTag[1]; + + if (!(await nostrService.isNostrChallengeFresh(challenge))) { + return res.status(410).json({ success: false, error: "Challenge expired, request new one." }) + } + + if (await nostrService.hasNostrChallengeBeenCompleted(challenge)) { + return res.status(410).json({ success: false, error: "Challenge already used, request new one." }) + } + + const isSignatureValid = verifyEvent(signedEvent); + if (!isSignatureValid) { + return res.status(400).json({ success: false, error: "Invalid signature" }); + } + + await nostrService.completeNostrChallenge( + challenge, + signedEvent + ) + + return res.json({ success: true, signedEvent }); +}); + module.exports = router; diff --git a/src/services/nostrService.js b/src/services/nostrService.js index 110027d..fe51b52 100644 --- a/src/services/nostrService.js +++ b/src/services/nostrService.js @@ -1,15 +1,76 @@ const uuid = require("uuid"); const crypto = require("crypto"); +const { Op } = require('sequelize'); const NostrChallengeCreated = require('../models/NostrChallengeCreated'); +const NostrChallengeCompleted = require("../models/NostrChallengeCompleted"); + +const constants = require('../constants'); + async function createNostrChallenge() { + + const currentTimestamp = new Date(); + const expiryTimestamp = new Date(currentTimestamp.getTime()); + expiryTimestamp.setSeconds(expiryTimestamp.getSeconds() + constants.DEFAULT_NOSTR_CHALLENGE_DURATION_SECONDS); + const nostrChallenge = await NostrChallengeCreated.create({ 'uuid': uuid.v7(), challenge: crypto.randomBytes(32).toString("hex"), - created_at: new Date().toISOString() + expires_at: expiryTimestamp.toISOString(), + created_at: currentTimestamp.toISOString() }); + return nostrChallenge; } -exports.createNostrChallenge = createNostrChallenge; \ No newline at end of file +async function isNostrChallengeFresh(challengeString) { + const nostrChallenge = await NostrChallengeCreated.findOne({ + where: { + challenge: challengeString, + expires_at: { + [Op.gt]: new Date() + } + } + }); + + if (nostrChallenge) { + return true; + } + return false; +} + +async function hasNostrChallengeBeenCompleted(challengeString) { + const completedNostrChallenge = await NostrChallengeCompleted.findOne( + { + where: { + challenge: challengeString + } + } + ); + + if (completedNostrChallenge) { + return true; + } + return false; +} + +async function completeNostrChallenge( + challenge, + signedEvent +) { + await NostrChallengeCompleted.create({ + 'uuid': uuid.v7(), + challenge: challenge, + signed_event: signedEvent, + created_at: new Date().toISOString() + } + ); + + return; +} + +exports.createNostrChallenge = createNostrChallenge; +exports.isNostrChallengeFresh = isNostrChallengeFresh; +exports.hasNostrChallengeBeenCompleted = hasNostrChallengeBeenCompleted; +exports.completeNostrChallenge = completeNostrChallenge; \ No newline at end of file