feat: Implement comprehensive Nostr identity and social features

## Core Identity Management
- Add secure key generation and import functionality
- Implement AES-GCM encryption with PBKDF2 key derivation
- Create password-protected identity storage
- Add browser-compatible crypto utilities (no Buffer dependency)

## User Interface
- Build identity management dialog with tabs for setup and profile
- Add navbar integration with user dropdown and mobile support
- Create password unlock dialog for encrypted identities
- Integrate vue-sonner for toast notifications

## Nostr Protocol Integration
- Implement event creation (notes, reactions, profiles, contacts)
- Add reply thread detection and engagement metrics
- Create social interaction composables for publishing
- Support multi-relay publishing with failure handling
- Add profile fetching and caching system

## Security Features
- Web Crypto API with 100k PBKDF2 iterations
- Secure random salt and IV generation
- Automatic password prompts for encrypted storage
- Legacy support for unencrypted identities

## Technical Improvements
- Replace all Buffer usage with browser-native APIs
- Add comprehensive error handling and validation
- Implement reactive state management with Vue composables
- Create reusable crypto utility functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-07-02 16:25:20 +02:00
parent d3e8b23c86
commit ee7eb461c4
12 changed files with 1777 additions and 21 deletions

View file

@ -0,0 +1,134 @@
/**
* Secure encryption utilities using Web Crypto API
* Provides AES-GCM encryption for sensitive data storage
*/
export interface EncryptedData {
encryptedData: string
iv: string
salt: string
}
export class SecureStorage {
private static readonly ALGORITHM = 'AES-GCM'
private static readonly KEY_LENGTH = 256
private static readonly IV_LENGTH = 12
private static readonly SALT_LENGTH = 16
private static readonly ITERATIONS = 100000
/**
* Derive encryption key from password using PBKDF2
*/
private static async deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder()
const passwordData = encoder.encode(password)
// Import password as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordData,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
// Derive AES key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: this.ITERATIONS,
hash: 'SHA-256'
},
keyMaterial,
{
name: this.ALGORITHM,
length: this.KEY_LENGTH
},
false,
['encrypt', 'decrypt']
)
}
/**
* Encrypt data with password
*/
static async encrypt(data: string, password: string): Promise<EncryptedData> {
try {
const encoder = new TextEncoder()
const dataBytes = encoder.encode(data)
// Generate random salt and IV
const salt = crypto.getRandomValues(new Uint8Array(this.SALT_LENGTH))
const iv = crypto.getRandomValues(new Uint8Array(this.IV_LENGTH))
// Derive encryption key
const key = await this.deriveKey(password, salt)
// Encrypt the data
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: this.ALGORITHM,
iv
},
key,
dataBytes
)
// Convert to base64 for storage
return {
encryptedData: btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))),
iv: btoa(String.fromCharCode(...iv)),
salt: btoa(String.fromCharCode(...salt))
}
} catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* Decrypt data with password
*/
static async decrypt(encryptedData: EncryptedData, password: string): Promise<string> {
try {
// Convert from base64
const encrypted = new Uint8Array(
atob(encryptedData.encryptedData).split('').map(c => c.charCodeAt(0))
)
const iv = new Uint8Array(
atob(encryptedData.iv).split('').map(c => c.charCodeAt(0))
)
const salt = new Uint8Array(
atob(encryptedData.salt).split('').map(c => c.charCodeAt(0))
)
// Derive decryption key
const key = await this.deriveKey(password, salt)
// Decrypt the data
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: this.ALGORITHM,
iv
},
key,
encrypted
)
// Convert back to string
const decoder = new TextDecoder()
return decoder.decode(decryptedBuffer)
} catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Invalid password or corrupted data'}`)
}
}
/**
* Check if Web Crypto API is available
*/
static isSupported(): boolean {
return typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof crypto.getRandomValues !== 'undefined'
}
}

View file

@ -1,4 +1,5 @@
import { SimplePool, type Filter, type Event } from 'nostr-tools'
import { SimplePool, type Filter, type Event, type UnsignedEvent } from 'nostr-tools'
import { extractReactions, extractReplyCounts, getReplyInfo, EventKinds } from './events'
export interface NostrClientConfig {
relays: string[]
@ -6,8 +7,12 @@ export interface NostrClientConfig {
export interface NostrNote extends Event {
// Add any additional note-specific fields we want to track
replyCount?: number
reactionCount?: number
replyCount: number
reactionCount: number
reactions: { [reaction: string]: number }
isReply: boolean
replyTo?: string
mentions: string[]
}
export class NostrClient {
@ -51,23 +56,146 @@ export class NostrClient {
async fetchNotes(options: {
limit?: number
since?: number // Unix timestamp in seconds
authors?: string[]
includeReplies?: boolean
} = {}): Promise<NostrNote[]> {
const { limit = 20, since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000) } = options
const {
limit = 20,
since = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000),
authors,
includeReplies = false
} = options
const filters: Filter[] = [
{
kinds: [EventKinds.TEXT_NOTE],
since,
limit,
...(authors && { authors })
}
]
// Also fetch reactions and replies for engagement data
const engagementFilters: Filter[] = [
{
kinds: [EventKinds.REACTION, EventKinds.TEXT_NOTE],
since,
limit: limit * 5 // Get more for engagement calculation
}
]
try {
// Get events from all relays
const [noteEvents, engagementEvents] = await Promise.all([
Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], filters)
} catch (error) {
console.warn(`Failed to fetch notes from relay ${relay}:`, error)
return []
}
})
),
Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], engagementFilters)
} catch (error) {
console.warn(`Failed to fetch engagement from relay ${relay}:`, error)
return []
}
})
)
])
// Flatten and deduplicate events by ID
const uniqueNotes = Array.from(
new Map(
noteEvents.flat().map(event => [event.id, event])
).values()
)
const allEngagementEvents = engagementEvents.flat()
// Extract engagement data
const reactions = extractReactions(allEngagementEvents)
const replyCounts = extractReplyCounts(allEngagementEvents)
// Process notes with engagement data
let processedNotes = uniqueNotes
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
const eventReactions = reactions.get(event.id) || {}
const reactionCount = Object.values(eventReactions).reduce((sum, count) => sum + count, 0)
return {
...event,
replyCount: replyCounts.get(event.id) || 0,
reactionCount,
reactions: eventReactions,
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
}
})
// Filter out replies if not requested
if (!includeReplies) {
processedNotes = processedNotes.filter(note => !note.isReply)
}
return processedNotes
.sort((a: NostrNote, b: NostrNote) => b.created_at - a.created_at) // Sort by newest first
.slice(0, limit) // Apply limit after processing
} catch (error) {
console.error('Failed to fetch notes:', error)
throw error
}
}
/**
* Publish an event to all connected relays
*/
async publishEvent(event: UnsignedEvent): Promise<void> {
if (!this._isConnected) {
throw new Error('Not connected to any relays')
}
try {
const results = await Promise.allSettled(
this.relays.map(relay => this.pool.publish([relay], event))
)
const failures = results.filter(result => result.status === 'rejected')
if (failures.length === results.length) {
throw new Error('Failed to publish to any relay')
}
console.log(`Published event ${event.id} to ${results.length - failures.length}/${results.length} relays`)
} catch (error) {
console.error('Failed to publish event:', error)
throw error
}
}
/**
* Fetch replies to a specific note
*/
async fetchReplies(noteId: string, limit: number = 50): Promise<NostrNote[]> {
const filter: Filter = {
kinds: [1], // Regular notes
since,
kinds: [EventKinds.TEXT_NOTE],
'#e': [noteId],
limit
}
try {
// Get events from all relays
const events = await Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], filter)
} catch (error) {
console.warn(`Failed to fetch from relay ${relay}:`, error)
console.warn(`Failed to fetch replies from relay ${relay}:`, error)
return []
}
})
@ -81,14 +209,70 @@ export class NostrClient {
)
return uniqueEvents
.sort((a: Event, b: Event) => b.created_at - a.created_at) // Sort by newest first
.map((event: Event): NostrNote => ({
...event,
replyCount: 0, // We'll implement this later
reactionCount: 0 // We'll implement this later
}))
.sort((a: Event, b: Event) => a.created_at - b.created_at) // Chronological order for replies
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
return {
...event,
replyCount: 0, // Individual replies don't need reply counts in this context
reactionCount: 0, // We could add this later
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
}
})
} catch (error) {
console.error('Failed to fetch notes:', error)
console.error('Failed to fetch replies:', error)
throw error
}
}
/**
* Fetch user profiles
*/
async fetchProfiles(pubkeys: string[]): Promise<Map<string, any>> {
if (pubkeys.length === 0) return new Map()
const filter: Filter = {
kinds: [EventKinds.PROFILE_METADATA],
authors: pubkeys
}
try {
const events = await Promise.all(
this.relays.map(async (relay) => {
try {
return await this.pool.querySync([relay], filter)
} catch (error) {
console.warn(`Failed to fetch profiles from relay ${relay}:`, error)
return []
}
})
)
const profiles = new Map<string, any>()
const allEvents = events.flat()
// Get the latest profile for each pubkey
pubkeys.forEach(pubkey => {
const userEvents = allEvents
.filter(event => event.pubkey === pubkey)
.sort((a, b) => b.created_at - a.created_at)
if (userEvents.length > 0) {
try {
const profile = JSON.parse(userEvents[0].content)
profiles.set(pubkey, profile)
} catch (error) {
console.warn(`Failed to parse profile for ${pubkey}:`, error)
}
}
})
return profiles
} catch (error) {
console.error('Failed to fetch profiles:', error)
throw error
}
}
@ -96,7 +280,7 @@ export class NostrClient {
// Subscribe to new notes in real-time
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
const filters = [{
kinds: [1],
kinds: [EventKinds.TEXT_NOTE],
since: Math.floor(Date.now() / 1000)
}]
@ -107,10 +291,15 @@ export class NostrClient {
filters,
{
onevent: (event: Event) => {
const replyInfo = getReplyInfo(event)
onNote({
...event,
replyCount: 0,
reactionCount: 0
reactionCount: 0,
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
})
}
}

156
src/lib/nostr/events.ts Normal file
View file

@ -0,0 +1,156 @@
import { finalizeEvent, type EventTemplate, type UnsignedEvent } from 'nostr-tools'
import type { NostrIdentity } from './identity'
import { hexToBytes } from '@/lib/utils/crypto'
/**
* Nostr event kinds
*/
export const EventKinds = {
TEXT_NOTE: 1,
REACTION: 7,
ZAP: 9735,
PROFILE_METADATA: 0,
CONTACT_LIST: 3,
REPOST: 6,
DELETE: 5
} as const
/**
* Create a text note event
*/
export function createTextNote(content: string, identity: NostrIdentity, replyTo?: string): UnsignedEvent {
const eventTemplate: EventTemplate = {
kind: EventKinds.TEXT_NOTE,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content
}
// Add reply tags if this is a reply
if (replyTo) {
eventTemplate.tags.push(['e', replyTo, '', 'reply'])
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a reaction event (like/dislike)
*/
export function createReaction(
targetEventId: string,
targetAuthor: string,
reaction: string,
identity: NostrIdentity
): UnsignedEvent {
const eventTemplate: EventTemplate = {
kind: EventKinds.REACTION,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', targetEventId],
['p', targetAuthor]
],
content: reaction
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a profile metadata event
*/
export function createProfileMetadata(profile: Record<string, any>, identity: NostrIdentity): UnsignedEvent {
const eventTemplate: EventTemplate = {
kind: EventKinds.PROFILE_METADATA,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify(profile)
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a contact list event (following)
*/
export function createContactList(contacts: string[], identity: NostrIdentity): UnsignedEvent {
const eventTemplate: EventTemplate = {
kind: EventKinds.CONTACT_LIST,
created_at: Math.floor(Date.now() / 1000),
tags: contacts.map(pubkey => ['p', pubkey]),
content: ''
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a delete event
*/
export function createDeleteEvent(eventIds: string[], identity: NostrIdentity, reason?: string): UnsignedEvent {
const eventTemplate: EventTemplate = {
kind: EventKinds.DELETE,
created_at: Math.floor(Date.now() / 1000),
tags: eventIds.map(id => ['e', id]),
content: reason || ''
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Extract reply information from event tags
*/
export function getReplyInfo(event: any): { isReply: boolean; replyTo?: string; mentions: string[] } {
const eTags = event.tags.filter((tag: string[]) => tag[0] === 'e')
const pTags = event.tags.filter((tag: string[]) => tag[0] === 'p')
// Find reply target (last e tag with "reply" marker or last e tag)
const replyTag = eTags.find((tag: string[]) => tag[3] === 'reply') || eTags[eTags.length - 1]
return {
isReply: !!replyTag,
replyTo: replyTag?.[1],
mentions: pTags.map((tag: string[]) => tag[1])
}
}
/**
* Extract reactions from events
*/
export function extractReactions(events: any[]): Map<string, { [reaction: string]: number }> {
const reactions = new Map<string, { [reaction: string]: number }>()
events
.filter(event => event.kind === EventKinds.REACTION)
.forEach(event => {
const targetEventId = event.tags.find((tag: string[]) => tag[0] === 'e')?.[1]
if (!targetEventId) return
const reaction = event.content || '👍'
const current = reactions.get(targetEventId) || {}
current[reaction] = (current[reaction] || 0) + 1
reactions.set(targetEventId, current)
})
return reactions
}
/**
* Extract reply counts from events
*/
export function extractReplyCounts(events: any[]): Map<string, number> {
const replyCounts = new Map<string, number>()
events
.filter(event => event.kind === EventKinds.TEXT_NOTE)
.forEach(event => {
const replyInfo = getReplyInfo(event)
if (replyInfo.isReply && replyInfo.replyTo) {
const current = replyCounts.get(replyInfo.replyTo) || 0
replyCounts.set(replyInfo.replyTo, current + 1)
}
})
return replyCounts
}

210
src/lib/nostr/identity.ts Normal file
View file

@ -0,0 +1,210 @@
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import { SecureStorage, type EncryptedData } from '@/lib/crypto/encryption'
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
export interface NostrIdentity {
privateKey: string
publicKey: string
npub: string
nsec: string
}
export interface NostrProfile {
name?: string
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string // Lightning address
}
export class IdentityManager {
private static readonly STORAGE_KEY = 'nostr_identity'
private static readonly PROFILE_KEY = 'nostr_profile'
/**
* Generate a new Nostr identity
*/
static generateIdentity(): NostrIdentity {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
return {
privateKey: bytesToHex(privateKey),
publicKey,
npub: nip19.npubEncode(publicKey),
nsec: nip19.nsecEncode(privateKey)
}
}
/**
* Import identity from private key (hex or nsec format)
*/
static importIdentity(privateKeyInput: string): NostrIdentity {
let privateKeyBytes: Uint8Array
try {
// Try to decode as nsec first
if (privateKeyInput.startsWith('nsec')) {
const decoded = nip19.decode(privateKeyInput)
if (decoded.type === 'nsec') {
privateKeyBytes = decoded.data
} else {
throw new Error('Invalid nsec format')
}
} else {
// Try as hex string
if (privateKeyInput.length !== 64) {
throw new Error('Private key must be 64 hex characters')
}
privateKeyBytes = hexToBytes(privateKeyInput)
}
if (privateKeyBytes.length !== 32) {
throw new Error('Private key must be 32 bytes')
}
const publicKey = getPublicKey(privateKeyBytes)
return {
privateKey: bytesToHex(privateKeyBytes),
publicKey,
npub: nip19.npubEncode(publicKey),
nsec: nip19.nsecEncode(privateKeyBytes)
}
} catch (error) {
throw new Error(`Failed to import identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* Save identity to localStorage (encrypted)
*/
static async saveIdentity(identity: NostrIdentity, password?: string): Promise<void> {
try {
let dataToStore: any = identity
if (password) {
if (!SecureStorage.isSupported()) {
throw new Error('Secure encryption is not supported in this browser')
}
// Encrypt sensitive data using Web Crypto API
const sensitiveData = JSON.stringify({
privateKey: identity.privateKey,
nsec: identity.nsec
})
const encryptedData = await SecureStorage.encrypt(sensitiveData, password)
dataToStore = {
publicKey: identity.publicKey,
npub: identity.npub,
encrypted: true,
encryptedData
}
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(dataToStore))
} catch (error) {
throw new Error(`Failed to save identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* Load identity from localStorage
*/
static async loadIdentity(password?: string): Promise<NostrIdentity | null> {
try {
const stored = localStorage.getItem(this.STORAGE_KEY)
if (!stored) return null
const storedData = JSON.parse(stored)
if (storedData.encrypted && password) {
if (!SecureStorage.isSupported()) {
throw new Error('Secure encryption is not supported in this browser')
}
// Decrypt sensitive data
const decryptedSensitiveData = await SecureStorage.decrypt(storedData.encryptedData, password)
const { privateKey, nsec } = JSON.parse(decryptedSensitiveData)
return {
privateKey,
publicKey: storedData.publicKey,
npub: storedData.npub,
nsec
}
} else if (storedData.encrypted && !password) {
throw new Error('Password required to decrypt stored identity')
}
// Non-encrypted identity (legacy support)
const identity = storedData as NostrIdentity
// Validate the loaded identity
if (!identity.privateKey || !identity.publicKey) {
throw new Error('Invalid stored identity')
}
return identity
} catch (error) {
console.error('Failed to load identity:', error)
throw error
}
}
/**
* Clear stored identity
*/
static clearIdentity(): void {
localStorage.removeItem(this.STORAGE_KEY)
localStorage.removeItem(this.PROFILE_KEY)
}
/**
* Check if identity exists in storage
*/
static hasStoredIdentity(): boolean {
return !!localStorage.getItem(this.STORAGE_KEY)
}
/**
* Check if stored identity is encrypted
*/
static isStoredIdentityEncrypted(): boolean {
try {
const stored = localStorage.getItem(this.STORAGE_KEY)
if (!stored) return false
const storedData = JSON.parse(stored)
return !!storedData.encrypted
} catch {
return false
}
}
/**
* Save user profile
*/
static saveProfile(profile: NostrProfile): void {
localStorage.setItem(this.PROFILE_KEY, JSON.stringify(profile))
}
/**
* Load user profile
*/
static loadProfile(): NostrProfile | null {
try {
const stored = localStorage.getItem(this.PROFILE_KEY)
return stored ? JSON.parse(stored) : null
} catch (error) {
console.error('Failed to load profile:', error)
return null
}
}
}

View file

@ -13,3 +13,6 @@ export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref
? updaterOrValue(ref.value)
: updaterOrValue
}
// Toast function using vue-sonner
export { toast } from 'vue-sonner'

42
src/lib/utils/crypto.ts Normal file
View file

@ -0,0 +1,42 @@
/**
* Crypto utility functions for browser environments
*/
/**
* Convert hex string to Uint8Array
*/
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error('Hex string must have even length')
}
const bytes = hex.match(/.{2}/g)?.map(byte => parseInt(byte, 16))
if (!bytes) {
throw new Error('Invalid hex string')
}
return new Uint8Array(bytes)
}
/**
* Convert Uint8Array to hex string
*/
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Convert string to Uint8Array
*/
export function stringToBytes(str: string): Uint8Array {
return new TextEncoder().encode(str)
}
/**
* Convert Uint8Array to string
*/
export function bytesToString(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes)
}