fix: namespace crypto storage per account, add chat polling, fix notification flow and dark theme issues

This commit is contained in:
2026-02-11 11:21:39 +01:00
parent 53673b4650
commit 227791e8f9
11 changed files with 188 additions and 51 deletions

View File

@@ -40,6 +40,19 @@ class ChatWidget extends HTMLElement {
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
this._stopPolling()
}
_startPolling() {
this._stopPolling()
this._pollTimer = setInterval(() => this.refreshMessages(), 5000)
}
_stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer)
this._pollTimer = null
}
}
async activate() {
@@ -66,6 +79,8 @@ class ChatWidget extends HTMLElement {
this.unsubscribe = conversationsService.subscribe(() => this.refreshMessages())
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
if (this.conversation) this._startPolling()
}
async initConversation() {
@@ -130,9 +145,12 @@ class ChatWidget extends HTMLElement {
}
async refreshMessages() {
const prevCount = this.messages.length
await this.loadMessages()
this.renderMessages()
this.scrollToBottom()
if (this.messages.length !== prevCount) {
this.renderMessages()
this.scrollToBottom()
}
}
async loadDealState() {

View File

@@ -255,8 +255,8 @@ style.textContent = /* css */`
}
listing-card .payment-published {
background: var(--color-accent);
color: var(--color-accent-text, #fff);
background: var(--color-success);
color: var(--color-success-text, #fff);
}
listing-card .payment-expired {

View File

@@ -1299,11 +1299,13 @@ style.textContent = /* css */`
font-size: var(--font-size-xs);
word-break: break-all;
line-height: 1.4;
color: var(--color-text);
}
page-listing .btn-copy {
padding: var(--space-sm);
flex-shrink: 0;
color: var(--color-text);
}
page-listing .btn-copy.copied {

View File

@@ -5,6 +5,7 @@
import { directus } from './directus.js'
import { cryptoService } from './crypto.js'
import { auth } from './auth.js'
class ConversationsService {
constructor() {
@@ -140,7 +141,8 @@ class ConversationsService {
let text = '[Encrypted]'
try {
text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey)
const decrypted = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey, mySecretKey)
text = decrypted || '[Decryption failed]'
} catch (e) {
text = '[Decryption failed]'
}
@@ -203,12 +205,14 @@ class ConversationsService {
return this.getConversation(existing.id)
}
const user = await auth.getUser()
const newConv = await directus.startConversation(
listingId,
myHash,
sellerHash,
myPublicKey,
sellerPublicKey
sellerPublicKey,
user?.id
)
return this.getConversation(newConv.id)

View File

@@ -4,11 +4,12 @@
*
* Secret keys are encrypted at rest using AES-GCM with a key
* derived from the user's UUID via PBKDF2.
*
* Storage keys are namespaced per account (UUID hash prefix)
* to support multiple accounts in the same browser.
*/
const STORAGE_KEY = 'kashilo_keypair'
const SALT_KEY = 'kashilo_keypair_salt'
const LISTING_KEYS_STORAGE = 'kashilo_listing_keys'
const STORAGE_PREFIX = 'kashilo_'
class CryptoService {
constructor() {
@@ -16,6 +17,7 @@ class CryptoService {
this.naclUtil = null
this.keyPair = null
this.wrappingKey = null
this._storageKeys = null
this.ready = this.init()
}
@@ -43,13 +45,33 @@ class CryptoService {
})
}
async _uuidPrefix(uuid) {
const data = new TextEncoder().encode(uuid)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).slice(0, 4)
.map(b => b.toString(16).padStart(2, '0')).join('')
}
async _getStorageKeys(uuid) {
if (this._storageKeys) return this._storageKeys
const prefix = await this._uuidPrefix(uuid)
this._storageKeys = {
keypair: `${STORAGE_PREFIX}kp_${prefix}`,
salt: `${STORAGE_PREFIX}salt_${prefix}`,
listingKeys: `${STORAGE_PREFIX}lk_${prefix}`
}
return this._storageKeys
}
async deriveKey(uuid) {
const enc = new TextEncoder()
let salt = localStorage.getItem(SALT_KEY)
const keys = await this._getStorageKeys(uuid)
let salt = localStorage.getItem(keys.salt)
if (!salt) {
const saltBytes = crypto.getRandomValues(new Uint8Array(16))
salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('')
localStorage.setItem(SALT_KEY, salt)
localStorage.setItem(keys.salt, salt)
}
const saltBytes = new Uint8Array(salt.match(/.{2}/g).map(h => parseInt(h, 16)))
@@ -92,10 +114,11 @@ class CryptoService {
async unlock(uuid) {
await this.ready
const keys = await this._getStorageKeys(uuid)
const wrappingKey = await this.deriveKey(uuid)
this.wrappingKey = wrappingKey
const stored = localStorage.getItem(STORAGE_KEY)
const stored = localStorage.getItem(keys.keypair)
if (stored) {
try {
const parsed = JSON.parse(stored)
@@ -119,10 +142,61 @@ class CryptoService {
}
} catch (e) {
console.warn('Failed to load keypair, generating new one')
localStorage.removeItem(keys.listingKeys)
}
}
await this.generateKeyPair(wrappingKey)
const migrated = await this._migrateOldStorage(uuid, wrappingKey, keys)
if (!migrated) await this.generateKeyPair(wrappingKey)
}
async _migrateOldStorage(uuid, wrappingKey, keys) {
const oldKeypair = localStorage.getItem('kashilo_keypair')
const oldSalt = localStorage.getItem('kashilo_keypair_salt')
if (!oldKeypair || !oldSalt) return false
try {
const oldSaltBytes = new Uint8Array(oldSalt.match(/.{2}/g).map(h => parseInt(h, 16)))
const enc = new TextEncoder()
const baseKey = await crypto.subtle.importKey(
'raw', enc.encode(uuid), 'PBKDF2', false, ['deriveKey']
)
const oldWrappingKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: oldSaltBytes, iterations: 100000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
const parsed = JSON.parse(oldKeypair)
if (parsed.ct && parsed.iv) {
const data = await this.decryptFromStorage(parsed, oldWrappingKey)
this.keyPair = {
publicKey: this.naclUtil.decodeBase64(data.publicKey),
secretKey: this.naclUtil.decodeBase64(data.secretKey)
}
await this.saveKeyPair(wrappingKey)
const oldListing = localStorage.getItem('kashilo_listing_keys')
if (oldListing) {
try {
const listingData = await this.decryptFromStorage(JSON.parse(oldListing), oldWrappingKey)
const encrypted = await this.encryptForStorage(listingData, wrappingKey)
localStorage.setItem(keys.listingKeys, JSON.stringify(encrypted))
localStorage.removeItem('kashilo_listing_keys')
} catch {}
}
localStorage.removeItem('kashilo_keypair')
localStorage.removeItem('kashilo_keypair_salt')
console.debug('[crypto] migrated old storage to namespaced keys')
return true
}
} catch (e) {
console.debug('[crypto] old storage migration failed (different account)')
}
return false
}
async generateKeyPair(wrappingKey) {
@@ -136,20 +210,27 @@ class CryptoService {
secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey)
}
const encrypted = await this.encryptForStorage(data, wrappingKey)
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted))
localStorage.setItem(this._storageKeys.keypair, JSON.stringify(encrypted))
}
lock() {
this.keyPair = null
this.wrappingKey = null
this._storageKeys = null
}
destroyKeyPair() {
this.keyPair = null
this.wrappingKey = null
localStorage.removeItem(STORAGE_KEY)
localStorage.removeItem(SALT_KEY)
localStorage.removeItem(LISTING_KEYS_STORAGE)
if (this._storageKeys) {
localStorage.removeItem(this._storageKeys.keypair)
localStorage.removeItem(this._storageKeys.salt)
localStorage.removeItem(this._storageKeys.listingKeys)
}
localStorage.removeItem('kashilo_keypair')
localStorage.removeItem('kashilo_keypair_salt')
localStorage.removeItem('kashilo_listing_keys')
this._storageKeys = null
}
getPublicKey() {
@@ -158,8 +239,8 @@ class CryptoService {
}
async getListingKeysStore() {
if (!this.wrappingKey) return {}
const stored = localStorage.getItem(LISTING_KEYS_STORAGE)
if (!this.wrappingKey || !this._storageKeys) return {}
const stored = localStorage.getItem(this._storageKeys.listingKeys)
if (!stored) return {}
try {
const parsed = JSON.parse(stored)
@@ -173,7 +254,7 @@ class CryptoService {
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))
localStorage.setItem(this._storageKeys.listingKeys, JSON.stringify(encrypted))
}
async generateListingKeyPair(listingId) {

View File

@@ -54,16 +54,17 @@ 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', {
export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2, buyerUserId) {
const payload = {
listing_id: listingId,
participant_hash_1: participantHash1,
participant_hash_2: participantHash2,
public_key_1: publicKey1,
public_key_2: publicKey2,
status: 'active'
})
}
if (buyerUserId) payload.buyer_user = buyerUserId
const response = await client.post('/items/conversations', payload)
return response.data
}

View File

@@ -3,6 +3,7 @@
*/
import { directus } from './directus.js'
import { client } from './directus/client.js'
import { auth } from './auth.js'
class NotificationsService {
@@ -68,8 +69,11 @@ class NotificationsService {
document.removeEventListener('visibilitychange', this._onVisibility)
}
_onVisibility = () => {
_onVisibility = async () => {
if (document.visibilityState === 'visible') {
if (client._refreshPromise) {
await client._refreshPromise
}
this.refresh()
}
}

View File

@@ -69,13 +69,17 @@ export async function verifySolution(solution) {
})
clearTimeout(timeout)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
if (!response.ok) {
const errData = await response.json().catch(() => ({}))
throw new Error(errData.error || `HTTP ${response.status}`)
}
const data = await response.json()
return { ok: data.ok === true, token: data.captcha_token || null }
} catch {
} catch (e) {
clearTimeout(timeout)
return { ok: false, token: null }
console.warn('[pow] verify failed:', e.message)
return { ok: false, token: null, error: e.message }
}
}