From fd8510d59f32b36e8c64f8d84ea9ec55b99e9af0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 20 Jul 2023 16:55:28 +0200 Subject: [PATCH] Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. --- api/resolvers/item.js | 34 +++++++++++++++++----------------- api/typeDefs/item.js | 10 +++++----- api/typeDefs/wallet.js | 1 + components/discussion-form.js | 8 ++++---- components/item-act.js | 4 ++-- components/link-form.js | 8 ++++---- components/poll-form.js | 8 ++++---- components/reply.js | 8 ++++---- components/upvote.js | 4 ++-- fragments/wallet.js | 1 + lib/anonymous.js | 9 +++++---- 11 files changed, 49 insertions(+), 46 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 94b99463..ff356d6c 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -572,7 +572,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) } }, upsertDiscussion: async (parent, args, { me, models }) => { @@ -583,7 +583,7 @@ export default { if (id) { return await updateItem(parent, { id, data }, { me, models }) } else { - return await createItem(parent, data, { me, models, invoiceId: args.invoiceId }) + return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash }) } }, upsertBounty: async (parent, args, { me, models }) => { @@ -598,13 +598,13 @@ export default { } }, upsertPoll: async (parent, { id, ...data }, { me, models }) => { - const { sub, forward, boost, title, text, options, invoiceId } = data + const { sub, forward, boost, title, text, options, invoiceHash } = data let author = me const trx = [] - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, ANON_POST_FEE) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, ANON_POST_FEE) author = invoice.user - trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } if (!author) { @@ -689,7 +689,7 @@ export default { }, createComment: async (parent, data, { me, models }) => { await ssValidate(commentSchema, data) - const item = await createItem(parent, data, { me, models, invoiceId: data.invoiceId }) + const item = await createItem(parent, data, { me, models, invoiceHash: data.invoiceHash }) // fetch user to get up-to-date name const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -722,17 +722,17 @@ export default { return id }, - act: async (parent, { id, sats, invoiceId }, { me, models }) => { + act: async (parent, { id, sats, invoiceHash }, { me, models }) => { // need to make sure we are logged in - if (!me && !invoiceId) { + if (!me && !invoiceHash) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) let user = me - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, sats) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, sats) user = invoice.user } @@ -748,8 +748,8 @@ export default { const calls = [ models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` ] - if (!me && invoiceId) { - calls.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + if (!me && invoiceHash) { + calls.push(models.invoice.delete({ where: { hash: invoiceHash } })) } const [{ item_act: vote }] = await serialize(models, ...calls) @@ -1075,13 +1075,13 @@ 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, invoiceId }) => { +const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash }) => { let author = me const trx = [] - if (!me && invoiceId) { - const invoice = await checkInvoice(models, invoiceId, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) + if (!me && invoiceHash) { + const invoice = await checkInvoice(models, invoiceHash, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) author = invoice.user - trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + trx.push(models.invoice.delete({ where: { hash: invoiceHash } })) } if (!author) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 86e5d99e..0ca7f821 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, invoiceId: ID): Item! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceId: 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! 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, invoiceId: ID): Item! - createComment(text: String!, parentId: ID!, invoiceId: ID): 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! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! + act(id: ID!, sats: Int, invoiceHash: String): ItemActResult! pollVote(id: ID!): ID! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index dc894e9b..698e3695 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -17,6 +17,7 @@ export default gql` type Invoice { id: ID! createdAt: Date! + hash: String! bolt11: String! expiresAt: Date! cancelled: Boolean! diff --git a/components/discussion-form.js b/components/discussion-form.js index 3e43418e..600b50bd 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -30,17 +30,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, $invoiceId: ID) { - upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + 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) { id } }` ) const submitUpsertDiscussion = useCallback( - async (_, boost, values, invoiceId) => { + async (_, boost, values, invoiceHash) => { const { error } = await upsertDiscussion({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceId } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/item-act.js b/components/item-act.js index 77fe0a01..e5323fdf 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, invoiceId) => { + async (amount, invoiceHash) => { if (!me) { const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') @@ -57,7 +57,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { variables: { id: itemId, sats: Number(amount), - invoiceId + invoiceHash } }) await strike() diff --git a/components/link-form.js b/components/link-form.js index 33c7bc47..03465f16 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -68,17 +68,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, $invoiceId: ID) { - upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + 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) { id } }` ) const submitUpsertLink = useCallback( - async (_, boost, title, values, invoiceId) => { + async (_, boost, title, values, invoiceHash) => { const { error } = await upsertLink({ - variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceId, ...values } + variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, ...values } }) if (error) { throw new Error({ message: error.toString() }) diff --git a/components/poll-form.js b/components/poll-form.js index 3cc6c706..d719def0 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, $invoiceId: ID) { + $options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, invoiceId: $invoiceId) { + options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) { id } }` ) const submitUpsertPoll = useCallback( - async (_, boost, title, options, values, invoiceId) => { + async (_, boost, title, options, values, invoiceHash) => { const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const { error } = await upsertPoll({ variables: { @@ -40,7 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: title.trim(), options: optionsFiltered, ...values, - invoiceId + invoiceHash } }) if (error) { diff --git a/components/reply.js b/components/reply.js index 58745653..6011fef0 100644 --- a/components/reply.js +++ b/components/reply.js @@ -47,8 +47,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold const [createComment] = useMutation( gql` ${COMMENTS} - mutation createComment($text: String!, $parentId: ID!, $invoiceId: ID) { - createComment(text: $text, parentId: $parentId, invoiceId: $invoiceId) { + mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String) { + createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash) { ...CommentFields comments { ...CommentsRecursive @@ -93,8 +93,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold ) const submitComment = useCallback( - async (_, values, parentId, resetForm, invoiceId) => { - const { error } = await createComment({ variables: { ...values, parentId, invoiceId } }) + async (_, values, parentId, resetForm, invoiceHash) => { + const { error } = await createComment({ variables: { ...values, parentId, invoiceHash } }) if (error) { throw new Error({ message: error.toString() }) } diff --git a/components/upvote.js b/components/upvote.js index 0fe7e73a..c19572fc 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -108,8 +108,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } const [act] = useMutation( gql` - mutation act($id: ID!, $sats: Int!, $invoiceId: ID) { - act(id: $id, sats: $sats, invoiceId: $invoiceId) { + mutation act($id: ID!, $sats: Int!, $invoiceHash: String) { + act(id: $id, sats: $sats, invoiceHash: $invoiceHash) { sats } }`, { diff --git a/fragments/wallet.js b/fragments/wallet.js index 62f55d1c..db0cae7f 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -5,6 +5,7 @@ export const INVOICE = gql` query Invoice($id: ID!) { invoice(id: $id) { id + hash bolt11 satsReceived cancelled diff --git a/lib/anonymous.js b/lib/anonymous.js index f47a5a66..b7705869 100644 --- a/lib/anonymous.js +++ b/lib/anonymous.js @@ -30,6 +30,7 @@ export const useAnonymous = (fn) => { mutation createInvoice($amount: Int!) { createInvoice(amount: $amount) { id + hash } }`) const showModal = useShowModal() @@ -42,9 +43,9 @@ export const useAnonymous = (fn) => { { + async ({ satsReceived }) => { setTimeout(async () => { - await fn(satsReceived, ...fnArgs, id) + await fn(satsReceived, ...fnArgs, invoice.hash) onClose() }, 2000) } @@ -63,9 +64,9 @@ export const useAnonymous = (fn) => { return anonFn } -export const checkInvoice = async (models, invoiceId, fee) => { +export const checkInvoice = async (models, invoiceHash, fee) => { const invoice = await models.invoice.findUnique({ - where: { id: Number(invoiceId) }, + where: { hash: invoiceHash }, include: { user: true }