diff --git a/AGENTS.md b/AGENTS.md index fbfbb5b..ef9b1d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,8 +66,8 @@ js/ │ ├── listings.js # Listings Service (Filter) │ ├── categories.js # Kategorien Service (Baum, Übersetzungen, Cache) │ ├── locations.js # Standorte Service (Geo-Suche) -│ ├── conversations.js # Zero-Knowledge Chat (E2E verschlüsselt) -│ ├── crypto.js # NaCl Encryption +│ ├── conversations.js # Zero-Knowledge Chat (E2E, per-listing keys) +│ ├── crypto.js # NaCl Encryption + per-listing keypair management │ ├── currency.js # XMR/Fiat Umrechnung │ ├── pow-captcha.js # Proof-of-Work Captcha (Challenge/Verify) │ ├── btcpay.js # BTCPay Server Integration (Invoice, Checkout, Webhook) diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md index 665b3ae..d3bbf0f 100644 --- a/docs/DIRECTUS-SCHEMA.md +++ b/docs/DIRECTUS-SCHEMA.md @@ -43,6 +43,7 @@ Haupttabelle für alle Anzeigen. | `views` | integer | Aufrufzähler | | `expires_at` | datetime | Ablaufdatum | | `monero_address` | string | XMR-Adresse für Zahlung | +| `contact_public_key` | text | NaCl Public Key für E2E-Chat (pro Listing) | | `date_created` | datetime | Erstellungsdatum | | `date_updated` | datetime | Änderungsdatum | | `user_created` | UUID | Ersteller (FK → directus_users) | @@ -232,6 +233,7 @@ Meldungen von Anzeigen. - `category`, `condition`, `location` - `shipping`, `shipping_cost` - `monero_address` +- `contact_public_key` - `images` - `views` (geschützt durch Flow) diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js index 1386c7e..76ad3a7 100644 --- a/js/components/chat-widget.js +++ b/js/components/chat-widget.js @@ -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 */`
@@ -141,9 +149,7 @@ class ChatWidget extends HTMLElement {
- ${pending - ? /* html */`

${t('chat.pending')}

` - : this.renderMessagesHtml()} + ${this.renderMessagesHtml()}
${this.renderDealSection()} @@ -152,11 +158,10 @@ class ChatWidget extends HTMLElement { -
diff --git a/js/components/pages/page-messages.js b/js/components/pages/page-messages.js index 3ced1d8..753d461 100644 --- a/js/components/pages/page-messages.js +++ b/js/components/pages/page-messages.js @@ -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 */`
💬
@@ -139,24 +131,10 @@ class PageMessages extends HTMLElement { ` } - let html = '' - - if (this.pendingConversations.length > 0) { - html += /* html */`

${t('messages.pendingRequests')}

` - html += this.pendingConversations.map(conv => this.renderConversationItem(conv, true)).join('') - } - - if (this.conversations.length > 0) { - if (this.pendingConversations.length > 0) { - html += /* html */`

${t('messages.activeChats')}

` - } - 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 */` - +
${imageUrl ? `` @@ -175,9 +153,7 @@ class PageMessages extends HTMLElement {

${escapeHTML(title)}

${dateStr}

- ${isPending - ? `${t('messages.new')}` - : `
`} +
` } diff --git a/js/services/conversations.js b/js/services/conversations.js index a0e9598..c7981cf 100644 --- a/js/services/conversations.js +++ b/js/services/conversations.js @@ -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) { diff --git a/js/services/crypto.js b/js/services/crypto.js index 82ba6fa..c859932 100644 --- a/js/services/crypto.js +++ b/js/services/crypto.js @@ -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), diff --git a/js/services/directus/listings.js b/js/services/directus/listings.js index c929124..181ffb7 100644 --- a/js/services/directus/listings.js +++ b/js/services/directus/listings.js @@ -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 = {}) {