web-app/src/composables/useNostrChat.ts
padreug aa3509d807 feat: Improve Nostr chat encryption with enhanced key validation and error handling
- Add validation for the hex format of private and public keys before encryption, ensuring they contain only valid characters.
- Implement error handling during the encryption process to log failures and provide clearer error messages.
- Refactor the encryption logic to improve reliability and security in the message encryption workflow.
2025-08-10 10:50:14 +02:00

509 lines
No EOL
14 KiB
TypeScript

import { ref, computed, readonly } from 'vue'
import { SimplePool, nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from '@/lib/utils/crypto'
import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config'
// Types
export interface ChatMessage {
id: string
content: string
created_at: number
sent: boolean
pubkey: string
}
export interface NostrRelayConfig {
url: string
read?: boolean
write?: boolean
}
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
const getRelays = (): NostrRelayConfig[] => {
const configuredRelays = config.nostr.relays
if (!configuredRelays || configuredRelays.length === 0) {
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
}
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
}
export function useNostrChat() {
// State
const isConnected = ref(false)
const messages = ref<Map<string, ChatMessage[]>>(new Map())
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
const pool = ref<SimplePool | null>(null)
const processedMessageIds = ref(new Set<string>())
// Callback for when messages change
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
// Computed
const isLoggedIn = computed(() => !!currentUser.value)
// Initialize Nostr pool
const initializePool = () => {
if (!pool.value) {
pool.value = new SimplePool()
}
}
// Connect to relays
const connectToRelay = async (url: string): Promise<any> => {
try {
initializePool()
const relay = pool.value!.ensureRelay(url)
console.log(`Connected to relay: ${url}`)
return relay
} catch (error) {
console.error(`Failed to connect to ${url}:`, error)
return null
}
}
// Connect to all relays
const connect = async () => {
try {
// Get current user from LNBits
await loadCurrentUser()
if (!currentUser.value) {
console.warn('No user logged in - chat functionality will be limited')
// Don't throw error, just continue without user data
// The chat will still work for viewing messages, but sending will fail
}
// Initialize pool
initializePool()
// Connect to relays
const relayConfigs = getRelays()
const relays = await Promise.all(
relayConfigs.map(relay => connectToRelay(relay.url))
)
const connectedRelays = relays.filter(relay => relay !== null)
isConnected.value = connectedRelays.length > 0
console.log(`Connected to ${connectedRelays.length} relays`)
} catch (error) {
console.error('Failed to connect:', error)
// Don't throw error, just log it and continue
// This allows the chat to still work for viewing messages
}
}
// Disconnect from relays
const disconnect = () => {
if (pool.value) {
const relayConfigs = getRelays()
pool.value.close(relayConfigs.map(r => r.url))
pool.value = null
}
isConnected.value = false
messages.value.clear()
processedMessageIds.value.clear()
}
// Load current user from LNBits
const loadCurrentUser = async () => {
try {
// Get current user from LNBits API using the auth endpoint
const authToken = getAuthToken()
if (!authToken) {
throw new Error('No authentication token found')
}
const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006'
const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/me`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
})
console.log('API Response status:', response.status)
console.log('API Response headers:', response.headers)
const responseText = await response.text()
console.log('API Response text:', responseText)
if (response.ok) {
try {
const user = JSON.parse(responseText)
currentUser.value = {
pubkey: user.pubkey,
prvkey: user.prvkey
}
} catch (parseError) {
console.error('JSON Parse Error:', parseError)
console.error('Response was:', responseText)
throw new Error('Invalid JSON response from API')
}
} else {
console.error('API Error:', response.status, responseText)
throw new Error(`Failed to load current user: ${response.status}`)
}
} catch (error) {
console.error('Failed to load current user:', error)
throw error
}
}
// Subscribe to messages from a specific peer
const subscribeToPeer = async (peerPubkey: string) => {
if (!currentUser.value) {
console.warn('No user logged in - cannot subscribe to peer messages')
return null
}
// Check if we have a pool and are connected
if (!pool.value) {
console.warn('No pool available - initializing...')
initializePool()
}
if (!isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!pool.value) {
throw new Error('Failed to initialize Nostr pool')
}
const myPubkey = currentUser.value.pubkey
// First, load historical messages
await loadHistoricalMessages(peerPubkey, myPubkey)
// Then subscribe to new messages
const relayConfigs = getRelays()
console.log('Subscribing to new messages for peer:', peerPubkey, 'with filters:', [
{
kinds: [4],
authors: [peerPubkey],
'#p': [myPubkey]
},
{
kinds: [4],
authors: [myPubkey],
'#p': [peerPubkey]
}
])
const sub = pool.value.subscribeMany(
relayConfigs.map(r => r.url),
[
{
kinds: [4],
authors: [peerPubkey],
'#p': [myPubkey]
},
{
kinds: [4],
authors: [myPubkey],
'#p': [peerPubkey]
}
],
{
onevent(event) {
console.log('Received live event:', event.id, 'author:', event.pubkey)
handleIncomingMessage(event, peerPubkey)
}
}
)
return sub
}
// Load historical messages for a peer
const loadHistoricalMessages = async (peerPubkey: string, myPubkey: string) => {
console.log('Loading historical messages for peer:', peerPubkey)
console.log('My pubkey:', myPubkey)
const relayConfigs = getRelays()
console.log('Using relays:', relayConfigs.map(r => r.url))
const filters = [
{
kinds: [4],
authors: [peerPubkey],
'#p': [myPubkey]
},
{
kinds: [4],
authors: [myPubkey],
'#p': [peerPubkey]
}
]
console.log('Historical query filters:', filters)
const historicalSub = pool.value!.subscribeMany(
relayConfigs.map(r => r.url),
filters,
{
onevent(event) {
console.log('Received historical event:', {
id: event.id,
author: event.pubkey,
isSentByMe: event.pubkey === myPubkey,
contentLength: event.content.length
})
handleIncomingMessage(event, peerPubkey)
},
oneose() {
console.log('Historical query completed for peer:', peerPubkey)
}
}
)
// Wait a bit for historical messages to load
await new Promise(resolve => setTimeout(resolve, 3000))
historicalSub.close()
console.log('Historical query closed for peer:', peerPubkey)
}
// Handle incoming message
const handleIncomingMessage = async (event: any, peerPubkey: string) => {
if (processedMessageIds.value.has(event.id)) {
return
}
processedMessageIds.value.add(event.id)
console.log('Handling incoming message:', {
eventId: event.id,
eventPubkey: event.pubkey,
myPubkey: currentUser.value!.pubkey,
peerPubkey,
isSentByMe: event.pubkey === currentUser.value!.pubkey
})
try {
// Decrypt the message
// For NIP-04 direct messages, always use peerPubkey as the second argument
// This is the public key of the other party in the conversation
const isSentByMe = event.pubkey === currentUser.value!.pubkey
console.log('Decrypting message:', {
eventId: event.id,
isSentByMe,
eventPubkey: event.pubkey,
myPubkey: currentUser.value!.pubkey,
peerPubkey,
contentLength: event.content.length
})
const decryptedContent = await nip04.decrypt(
currentUser.value!.prvkey,
peerPubkey, // Always use peerPubkey for shared secret derivation
event.content
)
console.log('Successfully decrypted message:', {
eventId: event.id,
contentLength: decryptedContent.length,
contentPreview: decryptedContent.substring(0, 50) + '...'
})
const message: ChatMessage = {
id: event.id,
content: decryptedContent,
created_at: event.created_at,
sent: event.pubkey === currentUser.value!.pubkey,
pubkey: event.pubkey
}
// Add message to the appropriate conversation
// Always use peerPubkey as the conversation key for both sent and received messages
const conversationKey = peerPubkey
console.log('Storing message with conversation key:', conversationKey)
if (!messages.value.has(conversationKey)) {
messages.value.set(conversationKey, [])
}
if (!messages.value.has(conversationKey)) {
messages.value.set(conversationKey, [])
}
messages.value.get(conversationKey)!.push(message)
// Sort messages by timestamp
messages.value.get(conversationKey)!.sort((a, b) => a.created_at - b.created_at)
// Force reactivity by triggering a change
messages.value = new Map(messages.value)
// Trigger callback if set
if (onMessageAdded.value) {
onMessageAdded.value(conversationKey)
}
console.log('Messages for conversation:', messages.value.get(conversationKey)?.map(m => ({
id: m.id,
sent: m.sent,
content: m.content.substring(0, 30) + '...',
timestamp: m.created_at
})))
} catch (error) {
console.error('Failed to decrypt message:', error)
}
}
// Send message to a peer
const sendMessage = async (peerPubkey: string, content: string) => {
if (!currentUser.value) {
throw new Error('No user logged in - please authenticate first')
}
// Check if we have a pool and are connected
if (!pool.value) {
console.warn('No pool available - initializing...')
initializePool()
}
if (!isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!pool.value) {
throw new Error('Failed to initialize Nostr pool')
}
try {
// Validate keys before encryption
if (!currentUser.value.prvkey || !peerPubkey) {
throw new Error('Missing private key or peer public key')
}
// Ensure keys are in correct hex format (64 characters for private key, 64 characters for public key)
const privateKey = currentUser.value.prvkey.startsWith('0x')
? currentUser.value.prvkey.slice(2)
: currentUser.value.prvkey
const publicKey = peerPubkey.startsWith('0x')
? peerPubkey.slice(2)
: peerPubkey
if (privateKey.length !== 64) {
throw new Error(`Invalid private key length: ${privateKey.length} (expected 64)`)
}
if (publicKey.length !== 64) {
throw new Error(`Invalid public key length: ${publicKey.length} (expected 64)`)
}
// Validate hex format
const hexRegex = /^[0-9a-fA-F]+$/
if (!hexRegex.test(privateKey)) {
throw new Error(`Invalid private key format: contains non-hex characters`)
}
if (!hexRegex.test(publicKey)) {
throw new Error(`Invalid public key format: contains non-hex characters`)
}
// Encrypt the message
let encryptedContent: string
try {
encryptedContent = await nip04.encrypt(
privateKey,
publicKey,
content
)
} catch (encryptError) {
console.error('Encryption failed:', encryptError)
throw new Error(`Encryption failed: ${encryptError instanceof Error ? encryptError.message : String(encryptError)}`)
}
// Create the event template
const eventTemplate: EventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', peerPubkey]],
content: encryptedContent
}
// Finalize the event (sign it)
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
// Publish to relays
const relayConfigs = getRelays()
const publishPromises = relayConfigs.map(relay => {
return pool.value!.publish([relay.url], event)
})
await Promise.all(publishPromises)
// Add message to local state
const message: ChatMessage = {
id: event.id,
content,
created_at: event.created_at,
sent: true,
pubkey: currentUser.value.pubkey
}
// Add to processed IDs to prevent duplicate processing
processedMessageIds.value.add(event.id)
if (!messages.value.has(peerPubkey)) {
messages.value.set(peerPubkey, [])
}
messages.value.get(peerPubkey)!.push(message)
// Sort messages by timestamp
messages.value.get(peerPubkey)!.sort((a, b) => a.created_at - b.created_at)
// Force reactivity by triggering a change
messages.value = new Map(messages.value)
// Trigger callback if set
if (onMessageAdded.value) {
onMessageAdded.value(peerPubkey)
}
} catch (error) {
console.error('Failed to send message:', error)
throw error
}
}
// Get messages for a specific peer
const getMessages = (peerPubkey: string): ChatMessage[] => {
return messages.value.get(peerPubkey) || []
}
// Clear messages for a specific peer
const clearMessages = (peerPubkey: string) => {
messages.value.delete(peerPubkey)
}
return {
// State
isConnected: readonly(isConnected),
messages: readonly(messages),
isLoggedIn: readonly(isLoggedIn),
// Methods
connect,
disconnect,
subscribeToPeer,
sendMessage,
getMessages,
clearMessages,
loadCurrentUser,
onMessageAdded
}
}