feat: TOFU key-pinning warning, restrict chat permissions to authenticated users
This commit is contained in:
@@ -215,8 +215,8 @@ Meldungen von Anzeigen.
|
|||||||
| `categories_translations` | ✓ | - | - | - | Für i18n |
|
| `categories_translations` | ✓ | - | - | - | Für i18n |
|
||||||
| `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen |
|
| `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen |
|
||||||
| `languages` | ✓ | - | - | - | Für Sprach-Auswahl |
|
| `languages` | ✓ | - | - | - | Für Sprach-Auswahl |
|
||||||
| `conversations` | ✓ | ✓ | ✓ | - | Filter via API-Query mit `participant_hash`, Update nur `status` |
|
| `conversations` | - | - | - | - | **Nur User-Rolle** (s.u.) |
|
||||||
| `messages` | ✓ | ✓ | - | - | Filter via `conversation` ID |
|
| `messages` | - | - | - | - | **Nur User-Rolle** (s.u.) |
|
||||||
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
||||||
| `reports` | - | ✓ | - | - | Nur erstellen |
|
| `reports` | - | ✓ | - | - | Nur erstellen |
|
||||||
|
|
||||||
@@ -288,3 +288,35 @@ module.exports = async function(data) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Hinweis:** Ohne diese Absicherung könnte jeder `views` auf beliebige Werte setzen.
|
**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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { t, i18n } from '../i18n.js'
|
import { t, i18n } from '../i18n.js'
|
||||||
import { conversationsService } from '../services/conversations.js'
|
import { conversationsService } from '../services/conversations.js'
|
||||||
import { cryptoService } from '../services/crypto.js'
|
import { cryptoService } from '../services/crypto.js'
|
||||||
|
import { keyPinningService } from '../services/key-pinning.js'
|
||||||
import { escapeHTML } from '../utils/helpers.js'
|
import { escapeHTML } from '../utils/helpers.js'
|
||||||
import { reputationService } from '../services/reputation.js'
|
import { reputationService } from '../services/reputation.js'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class ChatWidget extends HTMLElement {
|
|||||||
this.deal = null
|
this.deal = null
|
||||||
this.hasRated = false
|
this.hasRated = false
|
||||||
this.mySecretKey = null
|
this.mySecretKey = null
|
||||||
|
this.keyWarning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -72,6 +74,16 @@ class ChatWidget extends HTMLElement {
|
|||||||
this.render()
|
this.render()
|
||||||
return
|
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.conversation = await conversationsService.startOrGetConversation(this.listingId, this.sellerPublicKey)
|
||||||
this.mySecretKey = await conversationsService.getMySecretKeyForConversation(this.conversation)
|
this.mySecretKey = await conversationsService.getMySecretKeyForConversation(this.conversation)
|
||||||
await this.loadMessages()
|
await this.loadMessages()
|
||||||
@@ -85,6 +97,14 @@ class ChatWidget extends HTMLElement {
|
|||||||
this.setupEventListeners()
|
this.setupEventListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async acceptKeyChange() {
|
||||||
|
keyPinningService.acceptChange(this.listingId, this.sellerPublicKey)
|
||||||
|
this.keyWarning = false
|
||||||
|
this.loading = true
|
||||||
|
this.render()
|
||||||
|
await this.initConversation()
|
||||||
|
}
|
||||||
|
|
||||||
async loadMessages() {
|
async loadMessages() {
|
||||||
if (!this.conversation) return
|
if (!this.conversation) return
|
||||||
this.messages = await conversationsService.getMessages(
|
this.messages = await conversationsService.getMessages(
|
||||||
@@ -141,6 +161,22 @@ class ChatWidget extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.keyWarning) {
|
||||||
|
this.innerHTML = /* html */`
|
||||||
|
<div class="chat-widget">
|
||||||
|
<div class="chat-key-warning">
|
||||||
|
<div class="key-warning-icon">⚠</div>
|
||||||
|
<h4>${t('chat.keyChanged')}</h4>
|
||||||
|
<p>${t('chat.keyChangedHint')}</p>
|
||||||
|
<div class="key-warning-actions">
|
||||||
|
<button class="btn btn-outline" id="key-accept-btn">${t('chat.keyAccept')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.innerHTML = /* html */`
|
this.innerHTML = /* html */`
|
||||||
<div class="chat-widget">
|
<div class="chat-widget">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
@@ -272,6 +308,9 @@ class ChatWidget extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
|
const keyAcceptBtn = this.querySelector('#key-accept-btn')
|
||||||
|
keyAcceptBtn?.addEventListener('click', () => this.acceptKeyChange())
|
||||||
|
|
||||||
const form = this.querySelector('#chat-form')
|
const form = this.querySelector('#chat-form')
|
||||||
form?.addEventListener('submit', (e) => this.handleSubmit(e))
|
form?.addEventListener('submit', (e) => this.handleSubmit(e))
|
||||||
|
|
||||||
@@ -382,6 +421,39 @@ style.textContent = /* css */`
|
|||||||
padding: var(--space-lg);
|
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 {
|
chat-widget .chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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'
|
import { client } from './client.js'
|
||||||
|
|
||||||
export async function getConversations(participantHash) {
|
export async function getConversations(participantHash) {
|
||||||
@@ -31,6 +33,7 @@ export async function getConversation(id) {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Messages access restricted server-side to conversations owned by $CURRENT_USER
|
||||||
export async function getConversationMessages(conversationId) {
|
export async function getConversationMessages(conversationId) {
|
||||||
const response = await client.get('/items/messages', {
|
const response = await client.get('/items/messages', {
|
||||||
fields: ['*'],
|
fields: ['*'],
|
||||||
@@ -51,6 +54,7 @@ export async function sendMessage(conversationId, senderHash, encryptedContent,
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directus sets user_created automatically for authenticated requests
|
||||||
export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) {
|
export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) {
|
||||||
const response = await client.post('/items/conversations', {
|
const response = await client.post('/items/conversations', {
|
||||||
listing_id: listingId,
|
listing_id: listingId,
|
||||||
|
|||||||
75
js/services/key-pinning.js
Normal file
75
js/services/key-pinning.js
Normal file
@@ -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()
|
||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"unavailable": "Chat nicht verfügbar",
|
"unavailable": "Chat nicht verfügbar",
|
||||||
"pending": "Konversationsanfrage gesendet. Warte auf Antwort des Anbieters.",
|
"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": {
|
"create": {
|
||||||
"title": "Anzeige erstellen",
|
"title": "Anzeige erstellen",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Send",
|
"send": "Send",
|
||||||
"unavailable": "Chat unavailable",
|
"unavailable": "Chat unavailable",
|
||||||
"pending": "Conversation request sent. Waiting for the seller to respond.",
|
"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": {
|
"create": {
|
||||||
"title": "Create Listing",
|
"title": "Create Listing",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"unavailable": "Chat no disponible",
|
"unavailable": "Chat no disponible",
|
||||||
"pending": "Solicitud de conversación enviada. Esperando respuesta del vendedor.",
|
"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": {
|
"create": {
|
||||||
"title": "Crear anuncio",
|
"title": "Crear anuncio",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
"unavailable": "Chat non disponible",
|
"unavailable": "Chat non disponible",
|
||||||
"pending": "Demande de conversation envoyée. En attente de réponse du vendeur.",
|
"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": {
|
"create": {
|
||||||
"title": "Créer une annonce",
|
"title": "Créer une annonce",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Invia",
|
"send": "Invia",
|
||||||
"unavailable": "Chat non disponibile",
|
"unavailable": "Chat non disponibile",
|
||||||
"pending": "Richiesta di conversazione inviata. In attesa della risposta del venditore.",
|
"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": {
|
"create": {
|
||||||
"title": "Crea annuncio",
|
"title": "Crea annuncio",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"unavailable": "Chat indisponível",
|
"unavailable": "Chat indisponível",
|
||||||
"pending": "Solicitação de conversa enviada. Aguardando resposta do vendedor.",
|
"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": {
|
"create": {
|
||||||
"title": "Criar Anúncio",
|
"title": "Criar Anúncio",
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
"send": "Отправить",
|
"send": "Отправить",
|
||||||
"unavailable": "Чат недоступен",
|
"unavailable": "Чат недоступен",
|
||||||
"pending": "Запрос на разговор отправлен. Ожидание ответа продавца.",
|
"pending": "Запрос на разговор отправлен. Ожидание ответа продавца.",
|
||||||
"pendingHint": "Ожидание продавца..."
|
"pendingHint": "Ожидание продавца...",
|
||||||
|
"keyChanged": "Ключ изменён",
|
||||||
|
"keyChangedHint": "Ключ шифрования этого продавца изменился. Это может указывать на атаку или продавец мог пересоздать объявление.",
|
||||||
|
"keyAccept": "Принять новый ключ"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Создать объявление",
|
"title": "Создать объявление",
|
||||||
|
|||||||
Reference in New Issue
Block a user