From bb2212d51efdf20a8c4957c367b2c3c52de22ffd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Aug 2023 07:10:05 +0200 Subject: [PATCH] Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. --- .env.sample | 1 + api/resolvers/item.js | 36 ++++++++++++++++++++++------------- api/resolvers/wallet.js | 13 ++++++++++++- api/typeDefs/item.js | 10 +++++----- api/typeDefs/wallet.js | 1 + components/bounty-form.js | 2 +- components/discussion-form.js | 8 ++++---- components/invoice.js | 23 ++++++++++++++-------- components/item-act.js | 5 +++-- components/job-form.js | 2 +- components/link-form.js | 8 ++++---- components/poll-form.js | 9 +++++---- components/reply.js | 8 ++++---- components/upvote.js | 4 ++-- 14 files changed, 81 insertions(+), 49 deletions(-) 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 } }`, {