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:
parent
d3e8b23c86
commit
ee7eb461c4
12 changed files with 1777 additions and 21 deletions
210
src/lib/nostr/identity.ts
Normal file
210
src/lib/nostr/identity.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue