feat: per-listing E2E keypairs, eliminate insecure pending chat flow

This commit is contained in:
2026-02-10 07:14:42 +01:00
parent 16e73a61ab
commit 531c32140a
9 changed files with 193 additions and 185 deletions

View File

@@ -11,7 +11,7 @@ import { reputationService } from '../services/reputation.js'
class ChatWidget extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'recipient-name']
return ['listing-id', 'recipient-name', 'seller-public-key']
}
constructor() {
@@ -24,11 +24,13 @@ class ChatWidget extends HTMLElement {
this._initialized = false
this.deal = null
this.hasRated = false
this.mySecretKey = null
}
connectedCallback() {
this.listingId = this.getAttribute('listing-id')
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
this.sellerPublicKey = this.getAttribute('seller-public-key')
this.render()
}
@@ -64,7 +66,14 @@ class ChatWidget extends HTMLElement {
async initConversation() {
try {
this.conversation = await conversationsService.startOrFindByListing(this.listingId)
if (!this.sellerPublicKey) {
this.error = 'no-seller-key'
this.loading = false
this.render()
return
}
this.conversation = await conversationsService.startOrGetConversation(this.listingId, this.sellerPublicKey)
this.mySecretKey = await conversationsService.getMySecretKeyForConversation(this.conversation)
await this.loadMessages()
await this.loadDealState()
} catch (e) {
@@ -80,7 +89,8 @@ class ChatWidget extends HTMLElement {
if (!this.conversation) return
this.messages = await conversationsService.getMessages(
this.conversation.id,
this.conversation.otherPublicKey
this.conversation.otherPublicKey,
this.mySecretKey
)
}
@@ -131,8 +141,6 @@ class ChatWidget extends HTMLElement {
return
}
const pending = this.conversation && !this.conversation.otherPublicKey
this.innerHTML = /* html */`
<div class="chat-widget">
<div class="chat-header">
@@ -141,9 +149,7 @@ class ChatWidget extends HTMLElement {
</div>
<div class="chat-messages" id="chat-messages">
${pending
? /* html */`<div class="chat-empty"><p>${t('chat.pending')}</p></div>`
: this.renderMessagesHtml()}
${this.renderMessagesHtml()}
</div>
${this.renderDealSection()}
@@ -152,11 +158,10 @@ class ChatWidget extends HTMLElement {
<input
type="text"
id="message-input"
placeholder="${pending ? t('chat.pendingHint') : t('chat.placeholder')}"
placeholder="${t('chat.placeholder')}"
autocomplete="off"
${pending ? 'disabled' : ''}
>
<button type="submit" class="btn btn-primary" ${pending ? 'disabled' : ''}>
<button type="submit" class="btn btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
@@ -294,7 +299,9 @@ class ChatWidget extends HTMLElement {
await conversationsService.sendMessage(
this.conversation.id,
this.conversation.otherPublicKey,
text
text,
'text',
this.mySecretKey
)
await this.refreshMessages()

View File

@@ -6,6 +6,7 @@ import { listingsService } from '../../services/listings.js'
import { categoriesService } from '../../services/categories.js'
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
import { createInvoice, openCheckout, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js'
import { cryptoService } from '../../services/crypto.js'
import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js'
import '../pow-captcha.js'
@@ -620,6 +621,13 @@ class PageCreate extends HTMLElement {
}
await directus.updateListing(this.editId, listingData)
// Generate contact keypair for old listings that don't have one
if (!this.editListing?.contact_public_key) {
const contactPublicKey = await cryptoService.generateListingKeyPair(this.editId)
await directus.updateListing(this.editId, { contact_public_key: contactPublicKey })
}
router.navigate(`/listing/${this.editId}`)
} else {
// Save as draft first, then trigger payment
@@ -639,6 +647,10 @@ class PageCreate extends HTMLElement {
this.clearDraft()
if (listing?.id) {
// Generate per-listing E2E keypair
const contactPublicKey = await cryptoService.generateListingKeyPair(listing.id)
await directus.updateListing(listing.id, { contact_public_key: contactPublicKey })
await this.startPayment(listing.id, formElements.currency)
} else {
router.navigate('/')

View File

@@ -1,7 +1,6 @@
import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import { auth } from '../../services/auth.js'
import { conversationsService } from '../../services/conversations.js'
import { favoritesService } from '../../services/favorites.js'
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js'
@@ -20,7 +19,6 @@ class PageListing extends HTMLElement {
this.isFavorite = false
this.rates = null
this.isOwner = false
this.hasPendingChats = false
this.sellerReputation = null
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
}
@@ -62,11 +60,6 @@ class PageListing extends HTMLElement {
}
}
// Check for pending chats if owner
if (this.isOwner) {
this.hasPendingChats = await this.checkPendingChats()
}
// Load other listings from same seller
if (this.listing?.user_created) {
await this.loadSellerListings()
@@ -149,23 +142,6 @@ class PageListing extends HTMLElement {
}
}
async checkPendingChats() {
try {
const response = await directus.get('/items/conversations', {
fields: ['id'],
filter: {
listing_id: { _eq: this.listingId },
status: { _eq: 'pending' },
participant_hash_2: { _null: true }
},
limit: 1
})
return (response.data?.length || 0) > 0
} catch {
return false
}
}
async loadSellerListings() {
try {
const response = await directus.getListings({
@@ -394,14 +370,7 @@ class PageListing extends HTMLElement {
${t('listing.edit')}
</a>
${this.hasPendingChats ? /* html */`
<button class="btn btn-outline btn-lg sidebar-btn" id="contact-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
${t('listing.newMessages')}
</button>
` : ''}
<div class="sidebar-actions">
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
@@ -539,6 +508,7 @@ class PageListing extends HTMLElement {
<chat-widget
listing-id="${this.listing?.id || ''}"
recipient-name="${t('listing.anonymousSeller')}"
seller-public-key="${this.listing?.contact_public_key || ''}"
></chat-widget>
</div>

View File

@@ -8,7 +8,6 @@ class PageMessages extends HTMLElement {
constructor() {
super()
this.conversations = []
this.pendingConversations = []
this.loading = true
this.error = null
this.isLoggedIn = false
@@ -50,15 +49,8 @@ class PageMessages extends HTMLElement {
return
}
const userHash = await auth.hashString(user.id)
const [conversations, pending] = await Promise.all([
directus.getConversations(userHash),
conversationsService.getPendingForMyListings().catch(() => [])
])
const conversations = await conversationsService.getMyConversations()
this.conversations = conversations || []
this.pendingConversations = pending.filter(
p => !this.conversations.some(c => c.id === p.id)
)
this.loading = false
this.updateContent()
} catch (err) {
@@ -128,7 +120,7 @@ class PageMessages extends HTMLElement {
`
}
if (this.conversations.length === 0 && this.pendingConversations.length === 0) {
if (this.conversations.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">💬</div>
@@ -139,24 +131,10 @@ class PageMessages extends HTMLElement {
`
}
let html = ''
if (this.pendingConversations.length > 0) {
html += /* html */`<h2 class="section-title">${t('messages.pendingRequests')}</h2>`
html += this.pendingConversations.map(conv => this.renderConversationItem(conv, true)).join('')
}
if (this.conversations.length > 0) {
if (this.pendingConversations.length > 0) {
html += /* html */`<h2 class="section-title">${t('messages.activeChats')}</h2>`
}
html += this.conversations.map(conv => this.renderConversationItem(conv, false)).join('')
}
return html
return this.conversations.map(conv => this.renderConversationItem(conv)).join('')
}
renderConversationItem(conv, isPending) {
renderConversationItem(conv) {
const listing = typeof conv.listing_id === 'object' ? conv.listing_id : null
const listingId = listing?.id || conv.listing_id
const imageId = listing?.images?.[0]?.directus_files_id?.id
@@ -165,7 +143,7 @@ class PageMessages extends HTMLElement {
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
return /* html */`
<a href="#/listing/${listingId}" class="conversation-item ${isPending ? 'conversation-pending' : ''}" data-conv-id="${conv.id}">
<a href="#/listing/${listingId}" class="conversation-item" data-conv-id="${conv.id}">
<div class="conversation-image">
${imageUrl
? `<img src="${imageUrl}" alt="" loading="lazy">`
@@ -175,9 +153,7 @@ class PageMessages extends HTMLElement {
<h3 class="conversation-title">${escapeHTML(title)}</h3>
<p class="conversation-date">${dateStr}</p>
</div>
${isPending
? `<span class="conversation-badge">${t('messages.new')}</span>`
: `<div class="conversation-arrow">→</div>`}
<div class="conversation-arrow">→</div>
</a>
`
}