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) { if (id) {
return await updateItem(parent, { id, data }, { me, models }) return await updateItem(parent, { id, data }, { me, models })
} else { } 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 }) => { upsertDiscussion: async (parent, args, { me, models }) => {
@ -583,7 +583,7 @@ export default {
if (id) { if (id) {
return await updateItem(parent, { id, data }, { me, models }) return await updateItem(parent, { id, data }, { me, models })
} else { } 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 }) => { upsertBounty: async (parent, args, { me, models }) => {
@ -598,13 +598,13 @@ export default {
} }
}, },
upsertPoll: async (parent, { id, ...data }, { me, models }) => { 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 let author = me
const trx = [] const trx = []
if (!me && invoiceId) { if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceId, ANON_POST_FEE) const invoice = await checkInvoice(models, invoiceHash, ANON_POST_FEE)
author = invoice.user author = invoice.user
trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
} }
if (!author) { if (!author) {
@ -689,7 +689,7 @@ export default {
}, },
createComment: async (parent, data, { me, models }) => { createComment: async (parent, data, { me, models }) => {
await ssValidate(commentSchema, data) 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 // fetch user to get up-to-date name
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
@ -722,17 +722,17 @@ export default {
return id 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 // 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' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
} }
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
let user = me let user = me
if (!me && invoiceId) { if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceId, sats) const invoice = await checkInvoice(models, invoiceHash, sats)
user = invoice.user user = invoice.user
} }
@ -748,8 +748,8 @@ export default {
const calls = [ const calls = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
] ]
if (!me && invoiceId) { if (!me && invoiceHash) {
calls.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) calls.push(models.invoice.delete({ where: { hash: invoiceHash } }))
} }
const [{ item_act: vote }] = await serialize(models, ...calls) 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 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 let author = me
const trx = [] const trx = []
if (!me && invoiceId) { if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceId, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) const invoice = await checkInvoice(models, invoiceHash, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
author = invoice.user author = invoice.user
trx.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
} }
if (!author) { if (!author) {

View File

@ -26,16 +26,16 @@ export default gql`
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: 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, invoiceId: ID): 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! 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, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! 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! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String): Item!
createComment(text: String!, parentId: ID!, invoiceId: ID): Item! createComment(text: String!, parentId: ID!, invoiceHash: String): Item!
updateComment(id: ID!, text: String!): Item! updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean! dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! act(id: ID!, sats: Int, invoiceHash: String): ItemActResult!
pollVote(id: ID!): ID! pollVote(id: ID!): ID!
} }

View File

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

View File

@ -30,17 +30,17 @@ export function DiscussionForm ({
// const me = useMe() // const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceId: ID) { 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, invoiceId: $invoiceId) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) {
id id
} }
}` }`
) )
const submitUpsertDiscussion = useCallback( const submitUpsertDiscussion = useCallback(
async (_, boost, values, invoiceId) => { async (_, boost, values, invoiceHash) => {
const { error } = await upsertDiscussion({ 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) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })

View File

@ -47,7 +47,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
}, [onClose, itemId]) }, [onClose, itemId])
const submitAct = useCallback( const submitAct = useCallback(
async (amount, invoiceId) => { async (amount, invoiceHash) => {
if (!me) { if (!me) {
const storageKey = `TIP-item:${itemId}` const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
@ -57,7 +57,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
variables: { variables: {
id: itemId, id: itemId,
sats: Number(amount), sats: Number(amount),
invoiceId invoiceHash
} }
}) })
await strike() await strike()

View File

@ -68,17 +68,17 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const [upsertLink] = useMutation( const [upsertLink] = useMutation(
gql` gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceId: ID) { 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, invoiceId: $invoiceId) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash) {
id id
} }
}` }`
) )
const submitUpsertLink = useCallback( const submitUpsertLink = useCallback(
async (_, boost, title, values, invoiceId) => { async (_, boost, title, values, invoiceHash) => {
const { error } = await upsertLink({ 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) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })

View File

@ -21,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, 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, 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 id
} }
}` }`
) )
const submitUpsertPoll = useCallback( 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 optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({ const { error } = await upsertPoll({
variables: { variables: {
@ -40,7 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: title.trim(), title: title.trim(),
options: optionsFiltered, options: optionsFiltered,
...values, ...values,
invoiceId invoiceHash
} }
}) })
if (error) { if (error) {

View File

@ -47,8 +47,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
const [createComment] = useMutation( const [createComment] = useMutation(
gql` gql`
${COMMENTS} ${COMMENTS}
mutation createComment($text: String!, $parentId: ID!, $invoiceId: ID) { mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String) {
createComment(text: $text, parentId: $parentId, invoiceId: $invoiceId) { createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash) {
...CommentFields ...CommentFields
comments { comments {
...CommentsRecursive ...CommentsRecursive
@ -93,8 +93,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
) )
const submitComment = useCallback( const submitComment = useCallback(
async (_, values, parentId, resetForm, invoiceId) => { async (_, values, parentId, resetForm, invoiceHash) => {
const { error } = await createComment({ variables: { ...values, parentId, invoiceId } }) const { error } = await createComment({ variables: { ...values, parentId, invoiceHash } })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }

View File

@ -108,8 +108,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
const [act] = useMutation( const [act] = useMutation(
gql` gql`
mutation act($id: ID!, $sats: Int!, $invoiceId: ID) { mutation act($id: ID!, $sats: Int!, $invoiceHash: String) {
act(id: $id, sats: $sats, invoiceId: $invoiceId) { act(id: $id, sats: $sats, invoiceHash: $invoiceHash) {
sats sats
} }
}`, { }`, {

View File

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

View File

@ -30,6 +30,7 @@ export const useAnonymous = (fn) => {
mutation createInvoice($amount: Int!) { mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount) { createInvoice(amount: $amount) {
id id
hash
} }
}`) }`)
const showModal = useShowModal() const showModal = useShowModal()
@ -42,9 +43,9 @@ export const useAnonymous = (fn) => {
<Invoice <Invoice
id={invoice.id} id={invoice.id}
onConfirmation={ onConfirmation={
async ({ id, satsReceived }) => { async ({ satsReceived }) => {
setTimeout(async () => { setTimeout(async () => {
await fn(satsReceived, ...fnArgs, id) await fn(satsReceived, ...fnArgs, invoice.hash)
onClose() onClose()
}, 2000) }, 2000)
} }
@ -63,9 +64,9 @@ export const useAnonymous = (fn) => {
return anonFn return anonFn
} }
export const checkInvoice = async (models, invoiceId, fee) => { export const checkInvoice = async (models, invoiceHash, fee) => {
const invoice = await models.invoice.findUnique({ const invoice = await models.invoice.findUnique({
where: { id: Number(invoiceId) }, where: { hash: invoiceHash },
include: { include: {
user: true user: true
} }