diff --git a/.env.sample b/.env.sample index cab864f6..38da8e7f 100644 --- a/.env.sample +++ b/.env.sample @@ -47,6 +47,7 @@ PUBLIC_URL=http://localhost:3000 LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02@xhlmkj7mfrl6ejnczfwl2vqik3xim6wzmurc2vlyfoqw2sasaocgpuad.onion:9735 NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"} +INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c91 # imgproxy NEXT_PUBLIC_IMGPROXY_URL= diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 8467f811..57673785 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -17,6 +17,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' +import { createHmac } from './wallet' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -37,9 +38,17 @@ export async function commentFilterClause (me, models) { return clause } -async function checkInvoice (models, invoiceHash, fee) { +async function checkInvoice (models, hash, hmac, fee) { + if (!hmac) { + throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } }) + } + const hmac2 = createHmac(hash) + if (hmac !== hmac2) { + throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } }) + } + const invoice = await models.invoice.findUnique({ - where: { hash: invoiceHash }, + where: { hash }, include: { user: true } @@ -590,7 +599,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) } }, upsertDiscussion: async (parent, args, { me, models }) => { @@ -601,7 +610,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac }) } }, upsertBounty: async (parent, args, { me, models }) => { @@ -616,11 +625,11 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options, invoiceHash } = data + const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data let author = me const trx = [] if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, ANON_POST_FEE) + const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE) author = invoice.user trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } @@ -707,7 +716,7 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash }) + const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac }) // fetch user to get up-to-date name const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -740,7 +749,7 @@ export default { return id }, - act: async (parent, { id, sats, invoiceHash }, { me, models }) => { + act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => { // need to make sure we are logged in if (!me && !invoiceHash) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) @@ -749,8 +758,9 @@ export default { await ssValidate(amountSchema, { amount: sats }) let user = me + let invoice if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, sats) + invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats) user = invoice.user } @@ -766,8 +776,8 @@ export default { const calls = [ models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` ] - if (!me && invoiceHash) { - calls.push(models.invoice.delete({ where: { hash: invoiceHash } })) + if (invoice) { + calls.push(models.invoice.delete({ where: { hash: invoice.hash } })) } const [{ item_act: vote }] = await serialize(models, ...calls) @@ -1093,11 +1103,11 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo return item } -const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash }) => { +const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => { let author = me const trx = [] if (!me && invoiceHash) { - const invoice = await checkInvoice(models, invoiceHash, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index f1b57003..d9cc5fa1 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,6 @@ import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' import { GraphQLError } from 'graphql' +import crypto from 'crypto' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import lnpr from 'bolt11' @@ -40,6 +41,11 @@ export async function getInvoice (parent, { id }, { me, models }) { return inv } +export function createHmac (hash) { + const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') + return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') +} + export default { Query: { invoice: getInvoice, @@ -220,7 +226,12 @@ export default { models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) - return inv + // the HMAC is only returned during invoice creation + // this makes sure that only the person who created this invoice + // has access to the HMAC + const hmac = createHmac(inv.hash) + + return { ...inv, hmac } } catch (error) { console.log(error) throw error diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 0ca7f821..5852d529 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,16 +26,16 @@ export default gql` bookmarkItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item - upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String): Item! + upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! + upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String): Item! - createComment(text: String!, parentId: ID!, invoiceHash: String): Item! + upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! + createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int, invoiceHash: String): ItemActResult! + act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult! pollVote(id: ID!): ID! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 3cc3a9f6..2bde43e0 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -24,6 +24,7 @@ export default gql` confirmedAt: Date satsReceived: Int nostr: JSONObject + hmac: String } type Withdrawl { diff --git a/components/bounty-form.js b/components/bounty-form.js index 96dd86d7..666869e7 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -53,7 +53,7 @@ export function BountyForm ({ const submitUpsertBounty = useCallback( // we ignore the invoice since only stackers can post bounties - async (_, boost, bounty, values, __) => { + async (_, boost, bounty, values, ...__) => { const { error } = await upsertBounty({ variables: { sub: item?.subName || sub?.name, diff --git a/components/discussion-form.js b/components/discussion-form.js index 19be6057..769380a5 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -29,17 +29,17 @@ export function DiscussionForm ({ // const me = useMe() const [upsertDiscussion] = useMutation( gql` - mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { + upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertDiscussion = useCallback( - async (_, boost, values, invoiceHash) => { + async (_, boost, values, invoiceHash, invoiceHmac) => { const { error } = await upsertDiscussion({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash, invoiceHmac } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/invoice.js b/components/invoice.js index d2f4bca5..678db5e9 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -60,7 +60,7 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) { ) } -const Contacts = ({ invoiceHash }) => { +const Contacts = ({ invoiceHash, invoiceHmac }) => { const subject = `Support request for payment hash: ${invoiceHash}` const body = 'Hi, I successfully paid for but the action did not work.' return ( @@ -69,6 +69,10 @@ const Contacts = ({ invoiceHash }) => {
+ Payment HMAC +
+ +
{ ) } -const ActionInvoice = ({ id, hash, errorCount, repeat, ...props }) => { +const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => { const { data, loading, error } = useQuery(INVOICE, { pollInterval: 1000, variables: { id } @@ -130,7 +134,7 @@ const ActionInvoice = ({ id, hash, errorCount, repeat, ...props }) => { {errorCount === 1 ?
- : } + : } ) : null} @@ -149,6 +153,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { createInvoice(amount: $amount) { id hash + hmac } }`) const showModal = useShowModal() @@ -157,11 +162,11 @@ export const useInvoiceable = (fn, options = defaultOptions) => { // fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice let errorCount = 0 const onConfirmation = useCallback( - onClose => { + (onClose, hmac) => { return async ({ id, satsReceived, hash }) => { await sleep(2000) const repeat = () => - fn(satsReceived, ...fnArgs, hash) + fn(satsReceived, ...fnArgs, hash, hmac) .then(onClose) .catch((error) => { console.error(error) @@ -171,7 +176,8 @@ export const useInvoiceable = (fn, options = defaultOptions) => { { ), { keepOpen: true } @@ -213,7 +220,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => { { await fn(amount, ...args, invoiceHash) }} + onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }} /> ) }) diff --git a/components/item-act.js b/components/item-act.js index 860ae017..6c5bc4ec 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -47,7 +47,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { }, [onClose, itemId]) const submitAct = useCallback( - async (amount, invoiceHash) => { + async (amount, invoiceHash, invoiceHmac) => { if (!me) { const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') @@ -57,7 +57,8 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { variables: { id: itemId, sats: Number(amount), - invoiceHash + invoiceHash, + invoiceHmac } }) await strike() diff --git a/components/job-form.js b/components/job-form.js index 21d9990c..8a8027f1 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -53,7 +53,7 @@ export default function JobForm ({ item, sub }) { const submitUpsertJob = useCallback( // we ignore the invoice since only stackers can post jobs - async (_, maxBid, stop, start, values, __) => { + async (_, maxBid, stop, start, values, ...__) => { let status if (start) { status = 'ACTIVE' diff --git a/components/link-form.js b/components/link-form.js index 65eae961..b9fa5912 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -67,17 +67,17 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const [upsertLink] = useMutation( gql` - mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { + upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertLink = useCallback( - async (_, boost, title, values, invoiceHash) => { + async (_, boost, title, values, invoiceHash, invoiceHmac) => { const { error } = await upsertLink({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, ...values } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, invoiceHmac, ...values } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/poll-form.js b/components/poll-form.js index 402fb9ab..91022c1c 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -21,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) { const [upsertPoll] = useMutation( gql` mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String) { + $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { + options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { id } }` ) const submitUpsertPoll = useCallback( - async (_, boost, title, options, values, invoiceHash) => { + async (_, boost, title, options, values, invoiceHash, invoiceHmac) => { const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { @@ -40,7 +40,8 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: title.trim(), options: optionsFiltered, ...values, - invoiceHash + invoiceHash, + invoiceHmac } }) if (error) { diff --git a/components/reply.js b/components/reply.js index e748f8b1..e52d8971 100644 --- a/components/reply.js +++ b/components/reply.js @@ -46,8 +46,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [createComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String) { - createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash) { + mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) { + createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { ...CommentFields comments { ...CommentsRecursive @@ -92,8 +92,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold ) const submitComment = useCallback( - async (_, values, parentId, resetForm, invoiceHash) => { - const { error } = await createComment({ variables: { ...values, parentId, invoiceHash } }) + async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => { + const { error } = await createComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } }) if (error) { throw new Error({ message: error.toString() }) } diff --git a/components/upvote.js b/components/upvote.js index 9965a98a..5347eb20 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -110,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const [act] = useMutation( gql` - mutation act($id: ID!, $sats: Int!, $invoiceHash: String) { - act(id: $id, sats: $sats, invoiceHash: $invoiceHash) { + mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) { + act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { sats } }`, {