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
134
src/lib/crypto/encryption.ts
Normal file
134
src/lib/crypto/encryption.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
156
src/lib/nostr/events.ts
Normal 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
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
42
src/lib/utils/crypto.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue