diff --git a/scratch.txt b/scratch.txt deleted file mode 100644 index 920f497..0000000 --- a/scratch.txt +++ /dev/null @@ -1,7 +0,0 @@ - -npub -> publicKey - -exports.createInvitedNpub = createPublicKeyInvite; -exports.isNpubInvited = isPublicKeyInvited; - -invitedNpubService -> PublicKeyInvitedService \ No newline at end of file diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..3c6cd27 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,34 @@ +class ChallengedUsedError extends Error { + constructor(message) { + super(message); + this.name = "ChallengeUsedError"; + } +} + +class InvalidSignatureError extends Error { + constructor(message) { + super(message); + this.name = "InvalidSignatureError"; + } +} + +class AppInvitedUsedError extends Error { + constructor(message) { + super(message); + this.name = "AppInvitedUsedError"; + } +} + +class NotFoundError extends Error { + constructor(message) { + super(message); + this.name = "AppInvitedUsedError"; + } +} + +module.exports = { + ChallengedUsedError, + InvalidSignatureError, + AppInvitedUsedError, + NotFoundError +}; \ No newline at end of file diff --git a/src/models/SignUpChallengeCompleted.js b/src/models/SignUpChallengeCompleted.js new file mode 100644 index 0000000..0c4fa4c --- /dev/null +++ b/src/models/SignUpChallengeCompleted.js @@ -0,0 +1,31 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../database'); + +const SignUpChallengeCompleted = sequelize.define('SignUpChallengeCompleted', { + uuid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + primaryKey: true + }, + nostr_challenge_completed_uuid: { + type: DataTypes.UUID, + allowNull: false, + }, + app_invite_uuid: { + type: DataTypes.UUID, + allowNull: false + }, + public_key: { + type: DataTypes.STRING, + allowNull: false + }, + created_at: { + type: DataTypes.DATE, + allowNull: false + } +}, { + tableName: 'sign_up_challenge_completed' +}); + +module.exports = SignUpChallengeCompleted; \ No newline at end of file diff --git a/src/models/SignUpChallengeCreated.js b/src/models/SignUpChallengeCreated.js new file mode 100644 index 0000000..1b07436 --- /dev/null +++ b/src/models/SignUpChallengeCreated.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../database'); + +const SignUpChallengeCreated = sequelize.define('SignUpChallengeCreated', { + uuid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + primaryKey: true + }, + nostr_challenge_uuid: { + type: DataTypes.UUID, + allowNull: false + }, + app_invite_uuid: { + type: DataTypes.UUID, + allowNull: false + }, + created_at: { + type: DataTypes.DATE, + allowNull: false + } +}, { + tableName: 'sign_up_challenge_created' +}); + +module.exports = SignUpChallengeCreated; \ No newline at end of file diff --git a/src/public/javascript/invite.js b/src/public/javascript/invite.js index bbeebd7..c857cfe 100644 --- a/src/public/javascript/invite.js +++ b/src/public/javascript/invite.js @@ -1,25 +1,40 @@ async function acceptInvite() { - const publicKey = await window.nostr.getPublicKey(); + + // check if there is nostr extension + if (!window.nostr) { + console.log("No Nostr extension found."); + return { success: false, error: "No Nostr extension detected." }; + } + try { - const response = await fetch('/api/sign-public-key-up', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - publicKey - }) + const challengeResponse = await fetch("/api/signup/nostr-challenge"); + if (!challengeResponse.ok) throw new Error("Failed to fetch challenge"); + const { challenge } = await challengeResponse.json(); + + const pubkey = await window.nostr.getPublicKey(); + + const event = { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [["challenge", challenge]], + content: "Sign this challenge to authenticate", + pubkey: pubkey + }; + + const signedEvent = await window.nostr.signEvent(event); + + const verifyResponse = await fetch("/api/signup/nostr-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(signedEvent), }); - if (response.ok) { - const data = await response.json(); - console.log('invited-npub record created successfully:', data); - } else { - const error = await response.json(); - console.error('Failed to create invited-npub record:', error); + if (verifyResponse.status === 200) { + } - } catch (error) { - console.error('An error occurred:', error); - } + + + + } catch (error) { } } \ No newline at end of file diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index a8eddf1..b817037 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -5,6 +5,9 @@ const crypto = require("crypto"); const invitesService = require('../services/invitesService'); const sessionService = require('../services/sessionService'); const nostrService = require('../services/nostrService'); +const { error } = require('console'); +const { TimeoutError } = require('sequelize'); +const errors = require('../errors'); const router = express.Router(); @@ -58,54 +61,40 @@ router.post('/sign-public-key-up', async (req, res) => { }); -router.get('/nostr-challenge', async (req, res) => { - const nostrChallenge = await nostrService.createNostrChallenge(); - res.json({ 'challenge': nostrChallenge.challenge }); +router.get('/signup/nostr-challenge', async (req, res) => { + const inviteUuid = req.cookies.inviteUuid; + + const signUpChallenge = await invitesService.createSignUpChallenge( + inviteUuid + ) + + const relatedNostrChallenge = await nostrService.getNostrChallenge( + signUpChallenge.nostr_challenge_uuid + ) + + res.status(200).json({ 'challenge': relatedNostrChallenge.challenge }); }); -router.post("/nostr-verify", async (req, res) => { +router.post("/signup/nostr-verify", async (req, res) => { const signedEvent = req.body; - if (!signedEvent || !signedEvent.tags) { - return res.status(400).json({ success: false, error: "Invalid event format" }); + try { + console.log(`Starting nostr-verify with event: ${signedEvent}`); + const completedSignUpChallenge = await invitesService.verifySignUpChallenge(signedEvent); + console.log(`Finished nostr-verify`); + } catch (error) { + if (error instanceof TimeoutError) { + console.error('The challenge is outdated.'); + } + if (error instanceof errors.ChallengedUsedError) { + console.error('The challenge was already used, request a new one.'); + } + if (error instanceof errors.InvalidSignatureError) { + console.error('Signature is not valid.') + } } - - 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" }); - } - - if (!invitesService.isPublicKeyInvited(signedEvent.pubkey)) { - return res.status(400).json( - { - success: false, - error: "Valid signature, but npub is not invited to app." - } - ) - } - - await nostrService.completeNostrChallenge( - challenge, - signedEvent - ) - - return res.json({ success: true, signedEvent }); + return res.status(200).json({ success: true }); }); module.exports = router; diff --git a/src/services/invitesService.js b/src/services/invitesService.js index 14635ee..f333dba 100644 --- a/src/services/invitesService.js +++ b/src/services/invitesService.js @@ -1,7 +1,15 @@ const uuid = require('uuid'); +const nostrService = require('./nostrService'); const AppInviteCreated = require('../models/AppInviteCreated'); const PublicKeySignedUp = require('../models/PublicKeySignedUp'); +const SignUpChallengeCreated = require('../models/SignUpChallengeCreated'); +const SignUpChallengeCompleted = require('../models/SignUpChallengeCompleted'); + + +const errors = require('../errors'); +const NostrChallengeCompleted = require('../models/NostrChallengeCompleted'); +const { sortEvents } = require('nostr-tools'); async function appInviteExists(inviteUuid) { const invite = await AppInviteCreated.findOne({ where: { uuid: inviteUuid } }); @@ -16,14 +24,14 @@ async function getAppInvite(inviteUuid) { return invite; } -async function isAppInviteSpent(inviteUuid) { - const invitedNpub = await PublicKeySignedUp.findOne({ +async function isAppInviteSpent(appInviteUuid) { + const signUpChallengeCompleted = await SignUpChallengeCompleted.findOne({ where: { - app_invite_uuid: inviteUuid + app_invite_uuid: appInviteUuid } }) - if (invitedNpub) { + if (signUpChallengeCompleted) { return true; } return false; @@ -38,6 +46,79 @@ async function createAppInvite(inviterNpub) { ); } +async function createSignUpChallenge(appInviteUuid) { + + if (!(await appInviteExists(appInviteUuid))) { + throw new errors.NotFoundError("Invite doesn't exist.") + } + + if (await isAppInviteSpent(appInviteUuid)) { + throw new errors.AppInvitedUsedError("Invite has already been used.") + } + + const nostrChallenge = await nostrService.createNostrChallenge() + + return await SignUpChallengeCreated.create({ + 'uuid': uuid.v7(), + nostr_challenge_uuid: nostrChallenge.uuid, + app_invite_uuid: appInviteUuid, + created_at: new Date().toISOString() + } + ) +} + +async function hasSignUpChallengeBeenCompleted(nostrChallengeCompletedUuid) { + const signUpChallengeCompleted = await SignUpChallengeCompleted.findOne( + { + where: + { + + } + } + ) +} + +async function verifySignUpChallenge(signedEvent) { + + const challengeTag = signedEvent.tags.find(tag => tag[0] === "challenge"); + const challenge = challengeTag[1]; + + const nostrChallenge = await nostrService.getNostrChallenge( + null, challenge + ); + + console.log(`Found this nostr challenge: ${nostrChallenge}`); + + const signUpChallenge = await SignUpChallengeCreated.findOne({ + where: { + nostr_challenge_uuid: nostrChallenge.uuid + } + }) + + console.log(`Found this signup challenge: ${signUpChallenge}`); + + if (await nostrService.hasNostrChallengeBeenCompleted(challenge)) { + throw new errors.ChallengedUsedError("This challenge has already been used."); + } + console.log(`I'm gonna verify the nostr challenge`); + const completedNostrChallenge = await nostrService.verifyNostrChallenge(signedEvent); + console.log(`Verified the NostrChallenge: ${completedNostrChallenge}`); + + const completedSignUpChallenge = await SignUpChallengeCompleted.create( + { + 'uuid': uuid.v7(), + nostr_challenge_completed_uuid: completedNostrChallenge.uuid, + app_invite_uuid: signUpChallenge.app_invite_uuid, + public_key: completedNostrChallenge.public_key, + created_at: new Date().toISOString() + } + ); + console.log(`Verified the SignUpChallenge: ${completedSignUpChallenge}`); + + + return completedSignUpChallenge; +} + async function signUpPublicKey(inviteUuid, publicKey) { if (await isAppInviteSpent(inviteUuid)) { @@ -68,6 +149,8 @@ module.exports = { getAppInvite, isAppInviteSpent, createAppInvite, + createSignUpChallenge, + verifySignUpChallenge, signUpPublicKey, isPublicKeySignedUp }; \ No newline at end of file diff --git a/src/services/nostrService.js b/src/services/nostrService.js index c4e44a0..e970e54 100644 --- a/src/services/nostrService.js +++ b/src/services/nostrService.js @@ -1,12 +1,13 @@ const uuid = require("uuid"); const crypto = require("crypto"); -const { Op } = require('sequelize'); +const { Op, TimeoutError } = require('sequelize'); +const { verifyEvent } = require("nostr-tools"); const NostrChallengeCreated = require('../models/NostrChallengeCreated'); const NostrChallengeCompleted = require("../models/NostrChallengeCompleted"); const constants = require('../constants'); - +const errors = require('../errors'); async function createNostrChallenge() { @@ -24,6 +25,59 @@ async function createNostrChallenge() { return nostrChallenge; } +async function getNostrChallenge(nostrChallengeUuid = null, challenge = null) { + + if (nostrChallengeUuid) { + return await NostrChallengeCreated.findOne({ + where: { + 'uuid': nostrChallengeUuid + } + }) + } + + if (challenge) { + return await NostrChallengeCreated.findOne({ + where: { + challenge + } + }) + } + + throw Error('You need to pass a uuid or a challenge.') + +} + +async function verifyNostrChallenge(signedEvent) { + const challengeTag = signedEvent.tags.find(tag => tag[0] === "challenge"); + const challenge = challengeTag[1]; + + console.log("Checking if fresh") + if (!(await isNostrChallengeFresh(challenge))) { + throw TimeoutError("Challenge expired, request new one."); + } + + console.log("Checking if completed") + if (await hasNostrChallengeBeenCompleted(challenge)) { + throw new errors.ChallengedUsedError("Challenge already used, request new one."); + } + + console.log("Checking if valid") + const isSignatureValid = verifyEvent(signedEvent); + if (!isSignatureValid) { + throw new errors.InvalidSignatureError("Signature is not valid."); + } + + console.log("Persisting") + return await NostrChallengeCompleted.create({ + 'uuid': uuid.v7(), + challenge: challenge, + signed_event: signedEvent, + public_key: signedEvent.pubkey, + created_at: new Date().toISOString() + } + ); +} + async function isNostrChallengeFresh(challengeString) { const nostrChallenge = await NostrChallengeCreated.findOne({ where: { @@ -55,23 +109,11 @@ async function hasNostrChallengeBeenCompleted(challengeString) { return false; } -async function completeNostrChallenge( - challenge, - signedEvent -) { - await NostrChallengeCompleted.create({ - 'uuid': uuid.v7(), - challenge: challenge, - signed_event: signedEvent, - public_key: signedEvent.pubkey, - created_at: new Date().toISOString() - } - ); - return; -} - -exports.createNostrChallenge = createNostrChallenge; -exports.isNostrChallengeFresh = isNostrChallengeFresh; -exports.hasNostrChallengeBeenCompleted = hasNostrChallengeBeenCompleted; -exports.completeNostrChallenge = completeNostrChallenge; \ No newline at end of file +module.exports = { + createNostrChallenge, + getNostrChallenge, + verifyNostrChallenge, + isNostrChallengeFresh, + hasNostrChallengeBeenCompleted +}; \ No newline at end of file