feat: per-listing E2E keypairs, eliminate insecure pending chat flow
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -30,17 +30,69 @@ class ConversationsService {
|
||||
async getMyConversations() {
|
||||
const participantHash = await this.getParticipantHash()
|
||||
const conversations = await directus.getConversations(participantHash)
|
||||
|
||||
const listingKeyHashes = await this.getMyListingKeyHashes()
|
||||
const allHashes = new Set([participantHash, ...listingKeyHashes])
|
||||
|
||||
const sellerConvs = await this.getSellerConversations(listingKeyHashes)
|
||||
const allConvIds = new Set(conversations.map(c => c.id))
|
||||
for (const conv of sellerConvs) {
|
||||
if (!allConvIds.has(conv.id)) {
|
||||
conversations.push(conv)
|
||||
allConvIds.add(conv.id)
|
||||
}
|
||||
}
|
||||
|
||||
return conversations.map(conv => ({
|
||||
...conv,
|
||||
isParticipant1: conv.participant_hash_1 === participantHash,
|
||||
otherParticipantHash: conv.participant_hash_1 === participantHash
|
||||
? conv.participant_hash_2
|
||||
: conv.participant_hash_1,
|
||||
otherPublicKey: conv.participant_hash_1 === participantHash
|
||||
? conv.public_key_2
|
||||
: conv.public_key_1
|
||||
}))
|
||||
return conversations.map(conv => {
|
||||
const isP1 = allHashes.has(conv.participant_hash_1)
|
||||
return {
|
||||
...conv,
|
||||
isParticipant1: isP1,
|
||||
otherParticipantHash: isP1
|
||||
? conv.participant_hash_2
|
||||
: conv.participant_hash_1,
|
||||
otherPublicKey: isP1
|
||||
? conv.public_key_2
|
||||
: conv.public_key_1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getMyListingKeyHashes() {
|
||||
if (this._listingKeyHashesCache) return this._listingKeyHashesCache
|
||||
|
||||
const store = await cryptoService.getListingKeysStore()
|
||||
const hashes = []
|
||||
for (const [, secretKeyB64] of Object.entries(store)) {
|
||||
const sk = cryptoService.naclUtil.decodeBase64(secretKeyB64)
|
||||
const kp = cryptoService.nacl.box.keyPair.fromSecretKey(sk)
|
||||
const pubB64 = cryptoService.naclUtil.encodeBase64(kp.publicKey)
|
||||
const hash = await this.hashPublicKey(pubB64)
|
||||
hashes.push(hash)
|
||||
}
|
||||
this._listingKeyHashesCache = hashes
|
||||
setTimeout(() => { this._listingKeyHashesCache = null }, 30000)
|
||||
return hashes
|
||||
}
|
||||
|
||||
async getSellerConversations(listingKeyHashes) {
|
||||
if (listingKeyHashes.length === 0) return []
|
||||
const response = await directus.get('/items/conversations', {
|
||||
fields: [
|
||||
'*',
|
||||
'listing_id.id',
|
||||
'listing_id.title',
|
||||
'listing_id.status',
|
||||
'listing_id.images.directus_files_id.id'
|
||||
],
|
||||
filter: {
|
||||
_or: listingKeyHashes.map(h => ({
|
||||
participant_hash_2: { _eq: h }
|
||||
}))
|
||||
},
|
||||
sort: ['-date_updated']
|
||||
})
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
async getConversation(id) {
|
||||
@@ -48,36 +100,41 @@ class ConversationsService {
|
||||
if (!conversation) return null
|
||||
|
||||
const participantHash = await this.getParticipantHash()
|
||||
const listingKeyHashes = await this.getMyListingKeyHashes()
|
||||
const allHashes = new Set([participantHash, ...listingKeyHashes])
|
||||
const isP1 = allHashes.has(conversation.participant_hash_1)
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
isParticipant1: conversation.participant_hash_1 === participantHash,
|
||||
otherParticipantHash: conversation.participant_hash_1 === participantHash
|
||||
isParticipant1: isP1,
|
||||
otherParticipantHash: isP1
|
||||
? conversation.participant_hash_2
|
||||
: conversation.participant_hash_1,
|
||||
otherPublicKey: conversation.participant_hash_1 === participantHash
|
||||
otherPublicKey: isP1
|
||||
? conversation.public_key_2
|
||||
: conversation.public_key_1,
|
||||
myPublicKey: conversation.participant_hash_1 === participantHash
|
||||
myPublicKey: isP1
|
||||
? conversation.public_key_1
|
||||
: conversation.public_key_2
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(conversationId, otherPublicKey) {
|
||||
async getMessages(conversationId, otherPublicKey, mySecretKey) {
|
||||
await cryptoService.ready
|
||||
|
||||
if (!otherPublicKey) return []
|
||||
|
||||
const messages = await directus.getConversationMessages(conversationId)
|
||||
const myHash = await this.getParticipantHash()
|
||||
const listingKeyHashes = await this.getMyListingKeyHashes()
|
||||
const allMyHashes = new Set([myHash, ...listingKeyHashes])
|
||||
|
||||
return messages.map(msg => {
|
||||
const isOwn = msg.sender_hash === myHash
|
||||
const isOwn = allMyHashes.has(msg.sender_hash)
|
||||
let text = '[Encrypted]'
|
||||
|
||||
try {
|
||||
text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey)
|
||||
text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey)
|
||||
} catch (e) {
|
||||
text = '[Decryption failed]'
|
||||
}
|
||||
@@ -92,13 +149,22 @@ class ConversationsService {
|
||||
})
|
||||
}
|
||||
|
||||
async sendMessage(conversationId, otherPublicKey, plainText, type = 'text') {
|
||||
async sendMessage(conversationId, otherPublicKey, plainText, type = 'text', mySecretKey) {
|
||||
await cryptoService.ready
|
||||
|
||||
if (!otherPublicKey) throw new Error('Cannot send: other party has not joined yet')
|
||||
if (!otherPublicKey) throw new Error('Cannot send: recipient key missing')
|
||||
|
||||
const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey)
|
||||
const senderHash = await this.getParticipantHash()
|
||||
const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey, mySecretKey)
|
||||
|
||||
let senderHash
|
||||
if (mySecretKey) {
|
||||
const sk = cryptoService.naclUtil.decodeBase64(mySecretKey)
|
||||
const kp = cryptoService.nacl.box.keyPair.fromSecretKey(sk)
|
||||
const pubB64 = cryptoService.naclUtil.encodeBase64(kp.publicKey)
|
||||
senderHash = await this.hashPublicKey(pubB64)
|
||||
} else {
|
||||
senderHash = await this.getParticipantHash()
|
||||
}
|
||||
|
||||
const message = await directus.sendMessage(
|
||||
conversationId,
|
||||
@@ -142,91 +208,17 @@ class ConversationsService {
|
||||
return this.getConversation(newConv.id)
|
||||
}
|
||||
|
||||
async startOrFindByListing(listingId) {
|
||||
await cryptoService.ready
|
||||
|
||||
const myHash = await this.getParticipantHash()
|
||||
const myPublicKey = cryptoService.getPublicKey()
|
||||
|
||||
const response = await directus.get('/items/conversations', {
|
||||
filter: {
|
||||
listing_id: { _eq: listingId },
|
||||
_or: [
|
||||
{ participant_hash_1: { _eq: myHash } },
|
||||
{ participant_hash_2: { _eq: myHash } }
|
||||
]
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
const existing = response.data?.[0]
|
||||
if (existing) {
|
||||
return this.getConversation(existing.id)
|
||||
}
|
||||
|
||||
const pendingResponse = await directus.get('/items/conversations', {
|
||||
filter: {
|
||||
listing_id: { _eq: listingId },
|
||||
status: { _eq: 'pending' },
|
||||
participant_hash_2: { _null: true },
|
||||
participant_hash_1: { _neq: myHash }
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
const pending = pendingResponse.data?.[0]
|
||||
if (pending) {
|
||||
return this.joinConversation(pending.id)
|
||||
}
|
||||
|
||||
const newConv = await directus.post('/items/conversations', {
|
||||
listing_id: listingId,
|
||||
participant_hash_1: myHash,
|
||||
participant_hash_2: null,
|
||||
public_key_1: myPublicKey,
|
||||
public_key_2: null,
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
return this.getConversation(newConv.data?.id || newConv.id)
|
||||
async startOrFindByListing(listingId, sellerPublicKey) {
|
||||
return this.startOrGetConversation(listingId, sellerPublicKey)
|
||||
}
|
||||
|
||||
async joinConversation(conversationId) {
|
||||
await cryptoService.ready
|
||||
|
||||
const myHash = await this.getParticipantHash()
|
||||
const myPublicKey = cryptoService.getPublicKey()
|
||||
|
||||
await directus.patch(`/items/conversations/${conversationId}`, {
|
||||
participant_hash_2: myHash,
|
||||
public_key_2: myPublicKey,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
return this.getConversation(conversationId)
|
||||
}
|
||||
|
||||
async getPendingForMyListings() {
|
||||
const myListings = await directus.getMyListings()
|
||||
if (!myListings || myListings.length === 0) return []
|
||||
|
||||
const listingIds = myListings.map(l => l.id)
|
||||
const listingMap = new Map(myListings.map(l => [l.id, l]))
|
||||
|
||||
const response = await directus.get('/items/conversations', {
|
||||
fields: ['*'],
|
||||
filter: {
|
||||
status: { _eq: 'pending' },
|
||||
participant_hash_2: { _null: true },
|
||||
listing_id: { _in: listingIds }
|
||||
},
|
||||
sort: ['-date_created']
|
||||
})
|
||||
|
||||
return (response.data || []).map(conv => {
|
||||
const lid = typeof conv.listing_id === 'object' ? conv.listing_id?.id : conv.listing_id
|
||||
return { ...conv, listing_id: listingMap.get(lid) || { id: lid } }
|
||||
})
|
||||
async getMySecretKeyForConversation(conversation) {
|
||||
if (!conversation?.listing_id) return null
|
||||
const listingId = typeof conversation.listing_id === 'object'
|
||||
? conversation.listing_id.id
|
||||
: conversation.listing_id
|
||||
const listingKey = await cryptoService.getListingSecretKey(listingId)
|
||||
return listingKey
|
||||
}
|
||||
|
||||
async closeConversation(id) {
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
|
||||
const STORAGE_KEY = 'dgray_keypair'
|
||||
const SALT_KEY = 'dgray_keypair_salt'
|
||||
const LISTING_KEYS_STORAGE = 'dgray_listing_keys'
|
||||
|
||||
class CryptoService {
|
||||
constructor() {
|
||||
this.nacl = null
|
||||
this.naclUtil = null
|
||||
this.keyPair = null
|
||||
this.wrappingKey = null
|
||||
this.ready = this.init()
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ class CryptoService {
|
||||
async unlock(uuid) {
|
||||
await this.ready
|
||||
const wrappingKey = await this.deriveKey(uuid)
|
||||
this.wrappingKey = wrappingKey
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
@@ -138,12 +141,15 @@ class CryptoService {
|
||||
|
||||
lock() {
|
||||
this.keyPair = null
|
||||
this.wrappingKey = null
|
||||
}
|
||||
|
||||
destroyKeyPair() {
|
||||
this.keyPair = null
|
||||
this.wrappingKey = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(SALT_KEY)
|
||||
localStorage.removeItem(LISTING_KEYS_STORAGE)
|
||||
}
|
||||
|
||||
getPublicKey() {
|
||||
@@ -151,17 +157,55 @@ class CryptoService {
|
||||
return this.naclUtil.encodeBase64(this.keyPair.publicKey)
|
||||
}
|
||||
|
||||
async getListingKeysStore() {
|
||||
if (!this.wrappingKey) return {}
|
||||
const stored = localStorage.getItem(LISTING_KEYS_STORAGE)
|
||||
if (!stored) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(stored)
|
||||
return await this.decryptFromStorage(parsed, this.wrappingKey)
|
||||
} catch (e) {
|
||||
console.warn('Failed to load listing keys store', e)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async saveListingKeysStore(store) {
|
||||
if (!this.wrappingKey) throw new Error('Not unlocked')
|
||||
const encrypted = await this.encryptForStorage(store, this.wrappingKey)
|
||||
localStorage.setItem(LISTING_KEYS_STORAGE, JSON.stringify(encrypted))
|
||||
}
|
||||
|
||||
async generateListingKeyPair(listingId) {
|
||||
if (!this.wrappingKey) throw new Error('Not unlocked')
|
||||
await this.ready
|
||||
const kp = this.nacl.box.keyPair()
|
||||
const store = await this.getListingKeysStore()
|
||||
store[listingId] = this.naclUtil.encodeBase64(kp.secretKey)
|
||||
await this.saveListingKeysStore(store)
|
||||
return this.naclUtil.encodeBase64(kp.publicKey)
|
||||
}
|
||||
|
||||
async getListingSecretKey(listingId) {
|
||||
const store = await this.getListingKeysStore()
|
||||
return store[listingId] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message for a recipient
|
||||
* @param {string} message - Plain text message
|
||||
* @param {string} recipientPublicKey - Base64 encoded public key
|
||||
* @param {string} [senderSecretKey] - Optional base64 secret key (e.g. listing-specific)
|
||||
* @returns {object} - { nonce, ciphertext } both base64 encoded
|
||||
*/
|
||||
encrypt(message, recipientPublicKey) {
|
||||
encrypt(message, recipientPublicKey, senderSecretKey) {
|
||||
const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength)
|
||||
const messageUint8 = this.naclUtil.decodeUTF8(message)
|
||||
const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey)
|
||||
const sharedKey = this.nacl.box.before(recipientKey, this.keyPair.secretKey)
|
||||
const sk = senderSecretKey
|
||||
? this.naclUtil.decodeBase64(senderSecretKey)
|
||||
: this.keyPair.secretKey
|
||||
const sharedKey = this.nacl.box.before(recipientKey, sk)
|
||||
|
||||
const encrypted = this.nacl.secretbox(messageUint8, nonce, sharedKey)
|
||||
|
||||
@@ -176,12 +220,16 @@ class CryptoService {
|
||||
* @param {string} ciphertext - Base64 encoded ciphertext
|
||||
* @param {string} nonce - Base64 encoded nonce
|
||||
* @param {string} otherPublicKey - Base64 encoded public key of the other party
|
||||
* @param {string} [mySecretKey] - Optional base64 secret key (e.g. listing-specific)
|
||||
* @returns {string|null} - Decrypted message or null if failed
|
||||
*/
|
||||
decrypt(ciphertext, nonce, otherPublicKey) {
|
||||
decrypt(ciphertext, nonce, otherPublicKey, mySecretKey) {
|
||||
try {
|
||||
const otherKey = this.naclUtil.decodeBase64(otherPublicKey)
|
||||
const sharedKey = this.nacl.box.before(otherKey, this.keyPair.secretKey)
|
||||
const sk = mySecretKey
|
||||
? this.naclUtil.decodeBase64(mySecretKey)
|
||||
: this.keyPair.secretKey
|
||||
const sharedKey = this.nacl.box.before(otherKey, sk)
|
||||
|
||||
const decrypted = this.nacl.secretbox.open(
|
||||
this.naclUtil.decodeBase64(ciphertext),
|
||||
|
||||
@@ -48,7 +48,8 @@ const DETAIL_FIELDS = [
|
||||
'location.id',
|
||||
'location.name',
|
||||
'location.postal_code',
|
||||
'location.country'
|
||||
'location.country',
|
||||
'contact_public_key'
|
||||
]
|
||||
|
||||
export async function getListings(options = {}) {
|
||||
|
||||
Reference in New Issue
Block a user