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.
This commit is contained in:
ekzyis 2023-07-20 16:55:28 +02:00
parent 74893b09dd
commit fd8510d59f
11 changed files with 49 additions and 46 deletions

View File

@ -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) {

View File

@ -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!
}

View File

@ -17,6 +17,7 @@ export default gql`
type Invoice {
id: ID!
createdAt: Date!
hash: String!
bolt11: String!
expiresAt: Date!
cancelled: Boolean!

View File

@ -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() })

View File

@ -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()

View File

@ -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() })

View File

@ -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) {

View File

@ -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() })
}

View File

@ -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
}
}`, {

View File

@ -5,6 +5,7 @@ export const INVOICE = gql`
query Invoice($id: ID!) {
invoice(id: $id) {
id
hash
bolt11
satsReceived
cancelled

View File

@ -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) => {
<Invoice
id={invoice.id}
onConfirmation={
async ({ id, satsReceived }) => {
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
}