feat: TOFU key-pinning warning, restrict chat permissions to authenticated users
This commit is contained in:
@@ -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 */`
|
||||
<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 */`
|
||||
<div class="chat-widget">
|
||||
<div class="chat-header">
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user