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.
This commit is contained in:
ekzyis 2023-08-10 07:10:05 +02:00
parent 4fe1d416de
commit bb2212d51e
14 changed files with 81 additions and 49 deletions

View File

@ -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=

View File

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

View File

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

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

View File

@ -24,6 +24,7 @@ export default gql`
confirmedAt: Date
satsReceived: Int
nostr: JSONObject
hmac: String
}
type Withdrawl {

View File

@ -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,

View File

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

View File

@ -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 <insert action> but the action did not work.'
return (
@ -69,6 +69,10 @@ const Contacts = ({ invoiceHash }) => {
<div className='w-100'>
<CopyInput type='text' placeholder={invoiceHash} readOnly noForm />
</div>
<span>Payment HMAC</span>
<div className='w-100'>
<CopyInput type='text' placeholder={invoiceHmac} readOnly noForm />
</div>
<div className='d-flex flex-row justify-content-center'>
<a
href={`mailto:kk@stacker.news?subject=${subject}&body=${body}`} className='nav-link p-0 d-inline-flex'
@ -102,7 +106,7 @@ const Contacts = ({ invoiceHash }) => {
)
}
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 }) => {
<InvoiceStatus variant='failed' status={errorStatus} />
{errorCount === 1
? <div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
: <Contacts invoiceHash={hash} />}
: <Contacts invoiceHash={hash} invoiceHmac={hmac} />}
</>
)
: 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) => {
<ActionInvoice
id={id}
hash={hash}
onConfirmation={onConfirmation(onClose)}
hmac={hmac}
onConfirmation={onConfirmation(onClose, hmac)}
successVerb='received'
errorCount={errorCount}
repeat={repeat}
@ -191,7 +197,8 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
<ActionInvoice
id={invoice.id}
hash={invoice.hash}
onConfirmation={onConfirmation(onClose)}
hmac={invoice.hmac}
onConfirmation={onConfirmation(onClose, invoice.hmac)}
successVerb='received'
/>
), { keepOpen: true }
@ -213,7 +220,7 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
<FundError
onClose={onClose}
amount={amount}
onPayment={async (_, invoiceHash) => { await fn(amount, ...args, invoiceHash) }}
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }}
/>
)
})

View File

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

View File

@ -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'

View File

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

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, $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) {

View File

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

View File

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