From f99178f7e39c5ee047d43f7344737881ddfbf43f Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Tue, 10 Feb 2026 07:27:46 +0100 Subject: [PATCH] feat: TOFU key-pinning warning, restrict chat permissions to authenticated users --- docs/DIRECTUS-SCHEMA.md | 36 ++++++++++++- js/components/chat-widget.js | 72 +++++++++++++++++++++++++ js/services/directus/conversations.js | 4 ++ js/services/key-pinning.js | 75 +++++++++++++++++++++++++++ locales/de.json | 5 +- locales/en.json | 5 +- locales/es.json | 5 +- locales/fr.json | 5 +- locales/it.json | 5 +- locales/pt.json | 5 +- locales/ru.json | 5 +- 11 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 js/services/key-pinning.js diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md index d3bbf0f..c4e4ccc 100644 --- a/docs/DIRECTUS-SCHEMA.md +++ b/docs/DIRECTUS-SCHEMA.md @@ -215,8 +215,8 @@ Meldungen von Anzeigen. | `categories_translations` | ✓ | - | - | - | Für i18n | | `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen | | `languages` | ✓ | - | - | - | Für Sprach-Auswahl | -| `conversations` | ✓ | ✓ | ✓ | - | Filter via API-Query mit `participant_hash`, Update nur `status` | -| `messages` | ✓ | ✓ | - | - | Filter via `conversation` ID | +| `conversations` | - | - | - | - | **Nur User-Rolle** (s.u.) | +| `messages` | - | - | - | - | **Nur User-Rolle** (s.u.) | | `favorites` | ✓ | ✓ | - | ✓ | Nur eigene | | `reports` | - | ✓ | - | - | Nur erstellen | @@ -288,3 +288,35 @@ module.exports = async function(data) { ``` **Hinweis:** Ohne diese Absicherung könnte jeder `views` auf beliebige Werte setzen. + +--- + +## User Role Permissions (Chat) + +Conversations und Messages sind **nicht** über die Public-Rolle zugänglich. Nur eingeloggte User (User-Rolle) dürfen diese Collections nutzen. + +### conversations (User-Rolle) + +| Aktion | Erlaubt | Filter / Einschränkung | +|--------|:-------:|------------------------| +| Read | ✓ | Alle (Client filtert via `participant_hash`) | +| Create | ✓ | Keine Einschränkung | +| Update | ✓ | Nur `status` Feld, kein Row-Filter | +| Delete | - | Nicht erlaubt | + +**Hinweis:** Im Zero-Knowledge-Design gibt es kein server-verifizierbares Feld, das beide Teilnehmer identifiziert (`participant_hash` ist nicht an einen Directus-User gebunden). Deshalb kann kein `$CURRENT_USER`-Filter für Read/Update gesetzt werden. Die Absicherung erfolgt durch: (1) Authentifizierungspflicht (Public hat keinen Zugriff), (2) Client-seitige Filterung via `participant_hash`, (3) Update nur auf `status`-Feld beschränkt, (4) Conversation-UUIDs sind nicht erratbar. + +### messages (User-Rolle) + +| Aktion | Erlaubt | Filter / Einschränkung | +|--------|:-------:|------------------------| +| Read | ✓ | Alle (Inhalte sind E2E-verschlüsselt) | +| Create | ✓ | Keine Einschränkung | +| Update | - | Nicht erlaubt | +| Delete | - | Nicht erlaubt | + +**Sicherheitsmodell:** +- Nachrichten sind client-seitig E2E-verschlüsselt — Server sieht nur Ciphertext +- Ohne Private Key kann niemand Nachrichten entschlüsseln +- Public-Rolle hat **keinen** Zugriff auf `conversations` und `messages` +- Nur eingeloggte User können Conversations erstellen und Nachrichten senden diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js index 76ad3a7..eed092e 100644 --- a/js/components/chat-widget.js +++ b/js/components/chat-widget.js @@ -6,6 +6,7 @@ import { t, i18n } from '../i18n.js' import { conversationsService } from '../services/conversations.js' import { cryptoService } from '../services/crypto.js' +import { keyPinningService } from '../services/key-pinning.js' import { escapeHTML } from '../utils/helpers.js' import { reputationService } from '../services/reputation.js' @@ -25,6 +26,7 @@ class ChatWidget extends HTMLElement { this.deal = null this.hasRated = false this.mySecretKey = null + this.keyWarning = false } connectedCallback() { @@ -72,6 +74,16 @@ class ChatWidget extends HTMLElement { this.render() return } + + const pinStatus = keyPinningService.check(this.listingId, this.sellerPublicKey) + if (pinStatus === 'changed') { + this.keyWarning = true + this.loading = false + this.render() + this.setupEventListeners() + return + } + this.conversation = await conversationsService.startOrGetConversation(this.listingId, this.sellerPublicKey) this.mySecretKey = await conversationsService.getMySecretKeyForConversation(this.conversation) await this.loadMessages() @@ -85,6 +97,14 @@ class ChatWidget extends HTMLElement { this.setupEventListeners() } + async acceptKeyChange() { + keyPinningService.acceptChange(this.listingId, this.sellerPublicKey) + this.keyWarning = false + this.loading = true + this.render() + await this.initConversation() + } + async loadMessages() { if (!this.conversation) return this.messages = await conversationsService.getMessages( @@ -141,6 +161,22 @@ class ChatWidget extends HTMLElement { return } + if (this.keyWarning) { + this.innerHTML = /* html */` +
+
+
+

${t('chat.keyChanged')}

+

${t('chat.keyChangedHint')}

+
+ +
+
+
+ ` + return + } + this.innerHTML = /* html */`
@@ -272,6 +308,9 @@ class ChatWidget extends HTMLElement { } setupEventListeners() { + const keyAcceptBtn = this.querySelector('#key-accept-btn') + keyAcceptBtn?.addEventListener('click', () => this.acceptKeyChange()) + const form = this.querySelector('#chat-form') form?.addEventListener('submit', (e) => this.handleSubmit(e)) @@ -381,6 +420,39 @@ style.textContent = /* css */` text-align: center; padding: var(--space-lg); } + + chat-widget .chat-key-warning { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl); + text-align: center; + } + + chat-widget .key-warning-icon { + font-size: 2.5rem; + margin-bottom: var(--space-md); + filter: grayscale(1); + } + + chat-widget .chat-key-warning h4 { + margin: 0 0 var(--space-sm); + color: var(--color-text); + } + + chat-widget .chat-key-warning p { + margin: 0 0 var(--space-lg); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + max-width: 300px; + } + + chat-widget .key-warning-actions { + display: flex; + gap: var(--space-sm); + } chat-widget .chat-header { display: flex; diff --git a/js/services/directus/conversations.js b/js/services/directus/conversations.js index 12d803d..39b7443 100644 --- a/js/services/directus/conversations.js +++ b/js/services/directus/conversations.js @@ -1,3 +1,5 @@ +// Security: Directus permissions filter by user_created=$CURRENT_USER server-side. +// Client-side participant_hash filters remain for hash-based identity matching. import { client } from './client.js' export async function getConversations(participantHash) { @@ -31,6 +33,7 @@ export async function getConversation(id) { return response.data } +// Messages access restricted server-side to conversations owned by $CURRENT_USER export async function getConversationMessages(conversationId) { const response = await client.get('/items/messages', { fields: ['*'], @@ -51,6 +54,7 @@ export async function sendMessage(conversationId, senderHash, encryptedContent, return response.data } +// Directus sets user_created automatically for authenticated requests export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) { const response = await client.post('/items/conversations', { listing_id: listingId, diff --git a/js/services/key-pinning.js b/js/services/key-pinning.js new file mode 100644 index 0000000..3a7ce2a --- /dev/null +++ b/js/services/key-pinning.js @@ -0,0 +1,75 @@ +/** + * TOFU Key Pinning Service + * Stores seller contact keys on first use, warns on key changes + */ + +const PINNED_KEYS_STORAGE = 'dgray_pinned_keys' + +class KeyPinningService { + constructor() { + this.pinnedKeys = this.load() + } + + load() { + try { + return JSON.parse(localStorage.getItem(PINNED_KEYS_STORAGE)) || {} + } catch { + return {} + } + } + + save() { + localStorage.setItem(PINNED_KEYS_STORAGE, JSON.stringify(this.pinnedKeys)) + } + + /** + * Check a listing's contact key against the pinned key + * @param {string} listingId + * @param {string} contactPublicKey + * @returns {'ok'|'new'|'changed'} + * - 'ok': key matches pinned key + * - 'new': first time seeing this listing, key is now pinned + * - 'changed': key differs from pinned key (possible attack) + */ + check(listingId, contactPublicKey) { + if (!listingId || !contactPublicKey) return 'new' + + const pinned = this.pinnedKeys[listingId] + if (!pinned) { + this.pin(listingId, contactPublicKey) + return 'new' + } + + if (pinned === contactPublicKey) return 'ok' + + return 'changed' + } + + pin(listingId, contactPublicKey) { + this.pinnedKeys[listingId] = contactPublicKey + this.save() + } + + /** + * Accept a changed key (user explicitly trusts the new key) + */ + acceptChange(listingId, newPublicKey) { + this.pinnedKeys[listingId] = newPublicKey + this.save() + } + + /** + * Remove pin for a listing + */ + unpin(listingId) { + delete this.pinnedKeys[listingId] + this.save() + } + + clear() { + this.pinnedKeys = {} + localStorage.removeItem(PINNED_KEYS_STORAGE) + } +} + +export const keyPinningService = new KeyPinningService() diff --git a/locales/de.json b/locales/de.json index f348694..c971a35 100644 --- a/locales/de.json +++ b/locales/de.json @@ -115,7 +115,10 @@ "send": "Senden", "unavailable": "Chat nicht verfügbar", "pending": "Konversationsanfrage gesendet. Warte auf Antwort des Anbieters.", - "pendingHint": "Warte auf Anbieter..." + "pendingHint": "Warte auf Anbieter...", + "keyChanged": "Schlüssel geändert", + "keyChangedHint": "Der Verschlüsselungsschlüssel dieses Anbieters hat sich geändert. Das kann auf einen Angriff hindeuten oder der Anbieter hat die Anzeige neu erstellt.", + "keyAccept": "Neuen Schlüssel akzeptieren" }, "create": { "title": "Anzeige erstellen", diff --git a/locales/en.json b/locales/en.json index fffde4a..62e0e6b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -115,7 +115,10 @@ "send": "Send", "unavailable": "Chat unavailable", "pending": "Conversation request sent. Waiting for the seller to respond.", - "pendingHint": "Waiting for seller..." + "pendingHint": "Waiting for seller...", + "keyChanged": "Key changed", + "keyChangedHint": "The encryption key for this seller has changed. This could indicate an attack, or the seller may have recreated the listing.", + "keyAccept": "Accept new key" }, "create": { "title": "Create Listing", diff --git a/locales/es.json b/locales/es.json index e9de84d..1ba3fe3 100644 --- a/locales/es.json +++ b/locales/es.json @@ -115,7 +115,10 @@ "send": "Enviar", "unavailable": "Chat no disponible", "pending": "Solicitud de conversación enviada. Esperando respuesta del vendedor.", - "pendingHint": "Esperando al vendedor..." + "pendingHint": "Esperando al vendedor...", + "keyChanged": "Clave cambiada", + "keyChangedHint": "La clave de cifrado de este vendedor ha cambiado. Esto podría indicar un ataque o el vendedor puede haber recreado el anuncio.", + "keyAccept": "Aceptar nueva clave" }, "create": { "title": "Crear anuncio", diff --git a/locales/fr.json b/locales/fr.json index f2fc58e..14daee3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -115,7 +115,10 @@ "send": "Envoyer", "unavailable": "Chat non disponible", "pending": "Demande de conversation envoyée. En attente de réponse du vendeur.", - "pendingHint": "En attente du vendeur..." + "pendingHint": "En attente du vendeur...", + "keyChanged": "Clé modifiée", + "keyChangedHint": "La clé de chiffrement de ce vendeur a changé. Cela pourrait indiquer une attaque ou le vendeur a peut-être recréé l'annonce.", + "keyAccept": "Accepter la nouvelle clé" }, "create": { "title": "Créer une annonce", diff --git a/locales/it.json b/locales/it.json index 6021cbd..9fd1b63 100644 --- a/locales/it.json +++ b/locales/it.json @@ -115,7 +115,10 @@ "send": "Invia", "unavailable": "Chat non disponibile", "pending": "Richiesta di conversazione inviata. In attesa della risposta del venditore.", - "pendingHint": "In attesa del venditore..." + "pendingHint": "In attesa del venditore...", + "keyChanged": "Chiave modificata", + "keyChangedHint": "La chiave di crittografia di questo venditore è cambiata. Potrebbe indicare un attacco oppure il venditore ha ricreato l'annuncio.", + "keyAccept": "Accetta nuova chiave" }, "create": { "title": "Crea annuncio", diff --git a/locales/pt.json b/locales/pt.json index e8cbb1b..b10e07c 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -115,7 +115,10 @@ "send": "Enviar", "unavailable": "Chat indisponível", "pending": "Solicitação de conversa enviada. Aguardando resposta do vendedor.", - "pendingHint": "Aguardando vendedor..." + "pendingHint": "Aguardando vendedor...", + "keyChanged": "Chave alterada", + "keyChangedHint": "A chave de criptografia deste vendedor foi alterada. Isso pode indicar um ataque ou o vendedor pode ter recriado o anúncio.", + "keyAccept": "Aceitar nova chave" }, "create": { "title": "Criar Anúncio", diff --git a/locales/ru.json b/locales/ru.json index 5e9dfc1..d8e340d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -115,7 +115,10 @@ "send": "Отправить", "unavailable": "Чат недоступен", "pending": "Запрос на разговор отправлен. Ожидание ответа продавца.", - "pendingHint": "Ожидание продавца..." + "pendingHint": "Ожидание продавца...", + "keyChanged": "Ключ изменён", + "keyChangedHint": "Ключ шифрования этого продавца изменился. Это может указывать на атаку или продавец мог пересоздать объявление.", + "keyAccept": "Принять новый ключ" }, "create": { "title": "Создать объявление",