You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
400 lines
13 KiB
400 lines
13 KiB
/** |
|
* Unified authentication handler |
|
*/ |
|
|
|
import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js'; |
|
import { signEventWithNsec, getPublicKeyFromNsec } from '../auth/nsec-signer.js'; |
|
import { |
|
signEventWithAnonymous, |
|
generateAnonymousKey, |
|
getStoredAnonymousKey |
|
} from '../auth/anonymous-signer.js'; |
|
import { decryptPrivateKey, encryptPrivateKey } from '../security/key-management.js'; |
|
import { sessionManager, type AuthMethod } from '../auth/session-manager.js'; |
|
import { fetchRelayLists, fetchProfile } from '../user-data.js'; |
|
import { nostrClient } from './nostr-client.js'; |
|
import { relayManager } from './relay-manager.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
|
|
// Mute list and blocked relays management |
|
const muteList: Set<string> = new Set(); |
|
const blockedRelays: Set<string> = new Set(); |
|
|
|
/** |
|
* Authenticate with NIP-07 |
|
*/ |
|
export async function authenticateWithNIP07(): Promise<string> { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
|
|
sessionManager.setSession({ |
|
pubkey, |
|
method: 'nip07', |
|
signer: signEventWithNIP07, |
|
createdAt: Date.now() |
|
}, {}); // No metadata needed for NIP-07 |
|
|
|
// Fetch user relay lists and mute list in background with timeout |
|
// Don't block login if relays are slow or unavailable |
|
Promise.race([ |
|
loadUserPreferences(pubkey), |
|
new Promise<void>((resolve) => setTimeout(resolve, 5000)) // 5 second timeout |
|
]).catch(() => { |
|
// Silently fail - preference loading errors shouldn't break login |
|
}); |
|
|
|
// Fetch and cache user's own profile (background-update if already cached) |
|
fetchProfile(pubkey).catch(() => { |
|
// Silently fail - profile fetch errors shouldn't break login |
|
}); |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Authenticate with existing stored nsec key (password only) |
|
* NEVER logs the password - it is sensitive |
|
*/ |
|
export async function authenticateWithStoredNsec( |
|
pubkey: string, |
|
password: string |
|
): Promise<string> { |
|
// Verify the key exists and password is correct by trying to decrypt |
|
const { getNsecKey } = await import('../cache/nsec-key-store.js'); |
|
const decryptedNsec = await getNsecKey(pubkey, password); |
|
if (!decryptedNsec) { |
|
throw new Error('Invalid password or key not found'); |
|
} |
|
|
|
// Create session with signer that uses stored encrypted key |
|
// Store password in memory (never persisted) so signing works without re-prompting |
|
sessionManager.setSession({ |
|
pubkey, |
|
method: 'nsec', |
|
password, // Store in memory for signing - never persisted to localStorage |
|
signer: async (event) => { |
|
// Retrieve stored ncryptsec directly (no need to decrypt and re-encrypt) |
|
const session = sessionManager.getSession(); |
|
if (!session || !session.password) { |
|
throw new Error('Session password not available'); |
|
} |
|
const { getNcryptsec } = await import('../cache/nsec-key-store.js'); |
|
const ncryptsec = await getNcryptsec(pubkey); |
|
if (!ncryptsec) { |
|
throw new Error('Stored nsec key not found'); |
|
} |
|
// Use stored ncryptsec directly - it's already encrypted |
|
return signEventWithNsec(event, ncryptsec, session.password); |
|
}, |
|
createdAt: Date.now() |
|
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted) |
|
|
|
await loadUserPreferences(pubkey); |
|
|
|
// Fetch and cache user's own profile (background-update if already cached) |
|
fetchProfile(pubkey).catch(() => { |
|
// Silently fail - profile fetch errors shouldn't break login |
|
}); |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Authenticate with nsec (new key - will be stored) |
|
* NEVER logs the nsec, password, or ncryptsec - they are sensitive |
|
*/ |
|
export async function authenticateWithNsec( |
|
nsec: string, |
|
password: string |
|
): Promise<string> { |
|
// Derive public key from private key - NEVER log the nsec |
|
const pubkey = await getPublicKeyFromNsec(nsec); |
|
|
|
// Encrypt and store the nsec key in IndexedDB |
|
// NEVER log the nsec or password |
|
const { storeNsecKey } = await import('../cache/nsec-key-store.js'); |
|
await storeNsecKey(nsec, password, pubkey); |
|
|
|
// Verify the key was stored correctly by trying to retrieve it |
|
// This ensures the key is committed to IndexedDB before we proceed |
|
const { getNsecKey } = await import('../cache/nsec-key-store.js'); |
|
try { |
|
const verifyNsec = await getNsecKey(pubkey, password); |
|
if (!verifyNsec) { |
|
throw new Error('Failed to retrieve stored nsec key'); |
|
} |
|
// Compare hex strings (case-insensitive) to handle any case differences |
|
const nsecLower = nsec.toLowerCase().trim(); |
|
const verifyLower = verifyNsec.toLowerCase().trim(); |
|
if (nsecLower !== verifyLower) { |
|
throw new Error('Stored nsec key does not match original - key may be corrupted'); |
|
} |
|
} catch (error) { |
|
// If verification fails, provide helpful error |
|
if (error instanceof Error) { |
|
throw error; |
|
} |
|
throw new Error('Failed to verify stored nsec key - please try again'); |
|
} |
|
|
|
// Create session with signer that uses stored encrypted key |
|
// Store password in memory (never persisted) so signing works without re-prompting |
|
// The signer will retrieve and decrypt when needed, but never log the key |
|
sessionManager.setSession({ |
|
pubkey, |
|
method: 'nsec', |
|
password, // Store in memory for signing - never persisted to localStorage |
|
signer: async (event) => { |
|
// Retrieve and decrypt key when signing - never log it |
|
const session = sessionManager.getSession(); |
|
if (!session || !session.password) { |
|
throw new Error('Session password not available'); |
|
} |
|
const { getNcryptsec } = await import('../cache/nsec-key-store.js'); |
|
try { |
|
// Get stored ncryptsec directly (no need to decrypt and re-encrypt) |
|
const ncryptsec = await getNcryptsec(pubkey); |
|
if (!ncryptsec) { |
|
throw new Error('Stored nsec key not found'); |
|
} |
|
// Use stored ncryptsec directly - it's already encrypted |
|
return signEventWithNsec(event, ncryptsec, session.password); |
|
} catch (error) { |
|
// Provide better error message without exposing sensitive data |
|
if (error instanceof Error) { |
|
// Re-throw with more context if it's a decryption error |
|
if (error.message.includes('decrypt') || error.message.includes('password')) { |
|
throw new Error('Failed to decrypt stored nsec key. Please log in again with your password.'); |
|
} |
|
throw error; |
|
} |
|
throw new Error('Failed to retrieve nsec key for signing'); |
|
} |
|
}, |
|
createdAt: Date.now() |
|
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted) |
|
|
|
await loadUserPreferences(pubkey); |
|
|
|
// Fetch and cache user's own profile (background-update if already cached) |
|
fetchProfile(pubkey).catch(() => { |
|
// Silently fail - profile fetch errors shouldn't break login |
|
}); |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Authenticate with existing stored anonymous key (password only) |
|
* NEVER logs the password - it is sensitive |
|
*/ |
|
export async function authenticateWithStoredAnonymous( |
|
pubkey: string, |
|
password: string |
|
): Promise<string> { |
|
// Verify the key exists and password is correct by trying to decrypt |
|
const decryptedNsec = await getStoredAnonymousKey(pubkey, password); |
|
if (!decryptedNsec) { |
|
throw new Error('Invalid password or key not found'); |
|
} |
|
|
|
// Store password in memory (never persisted) so signing works without re-prompting |
|
// Create session with signer that retrieves and decrypts when needed |
|
sessionManager.setSession({ |
|
pubkey, |
|
method: 'anonymous', |
|
password, // Store in memory for signing - never persisted to localStorage |
|
signer: async (event) => { |
|
// Retrieve and decrypt key when signing - never log it |
|
const session = sessionManager.getSession(); |
|
if (!session || !session.password) { |
|
throw new Error('Session password not available'); |
|
} |
|
return signEventWithAnonymous(event, pubkey, session.password); |
|
}, |
|
createdAt: Date.now() |
|
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted) |
|
|
|
await loadUserPreferences(pubkey); |
|
|
|
// Fetch and cache user's own profile (background-update if already cached) |
|
fetchProfile(pubkey).catch(() => { |
|
// Silently fail - profile fetch errors shouldn't break login |
|
}); |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Authenticate as anonymous (new key - will be generated and stored) |
|
* Generates a new key, encrypts it, and stores it in IndexedDB |
|
* NEVER logs the generated nsec or password |
|
*/ |
|
export async function authenticateAsAnonymous(password: string): Promise<string> { |
|
// Generate new anonymous key - never log the nsec |
|
const { pubkey, nsec } = await generateAnonymousKey(password); |
|
|
|
// Key is already stored encrypted in IndexedDB by generateAnonymousKey |
|
// Store password in memory (never persisted) so signing works without re-prompting |
|
// Create session with signer that retrieves and decrypts when needed |
|
sessionManager.setSession({ |
|
pubkey, |
|
method: 'anonymous', |
|
password, // Store in memory for signing - never persisted to localStorage |
|
signer: async (event) => { |
|
// Retrieve and decrypt key when signing - never log it |
|
const session = sessionManager.getSession(); |
|
if (!session || !session.password) { |
|
throw new Error('Session password not available'); |
|
} |
|
return signEventWithAnonymous(event, pubkey, session.password); |
|
}, |
|
createdAt: Date.now() |
|
}, { pubkey }); // Store pubkey in metadata for restoration (password never persisted) |
|
|
|
await loadUserPreferences(pubkey); |
|
|
|
// Fetch and cache user's own profile (background-update if already cached) |
|
fetchProfile(pubkey).catch(() => { |
|
// Silently fail - profile fetch errors shouldn't break login |
|
}); |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Load user preferences (relay lists, mute list, blocked relays) |
|
*/ |
|
async function loadUserPreferences(pubkey: string): Promise<void> { |
|
try { |
|
// Fetch relay lists and load into relay manager with timeout |
|
await Promise.race([ |
|
relayManager.loadUserPreferences(pubkey), |
|
new Promise<void>((_, reject) => |
|
setTimeout(() => reject(new Error('Relay list fetch timeout')), 5000) |
|
) |
|
]); |
|
} catch (error) { |
|
// If relay list fetch fails, continue with default relays |
|
console.debug('[auth-handler] Failed to load user relay preferences, using defaults'); |
|
} |
|
|
|
try { |
|
// Fetch mute list (kind 10000) with timeout |
|
const muteEvents = await Promise.race([ |
|
nostrClient.fetchEvents( |
|
[{ kinds: [KIND.MUTE_LIST], authors: [pubkey], limit: 1 }], |
|
relayManager.getProfileReadRelays(), |
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
), |
|
new Promise<NostrEvent[]>((resolve) => |
|
setTimeout(() => resolve([]), 5000) |
|
) |
|
]); |
|
|
|
if (muteEvents.length > 0) { |
|
const mutedPubkeys = muteEvents[0].tags |
|
.filter((t) => t[0] === 'p') |
|
.map((t) => t[1]) |
|
.filter(Boolean) as string[]; |
|
muteList.clear(); |
|
mutedPubkeys.forEach(pk => muteList.add(pk)); |
|
} |
|
} catch (error) { |
|
// Silently fail - mute list fetch errors shouldn't break login |
|
console.debug('[auth-handler] Failed to load mute list'); |
|
} |
|
|
|
try { |
|
// Fetch blocked relays (kind 10006) with timeout |
|
const blockedRelayEvents = await Promise.race([ |
|
nostrClient.fetchEvents( |
|
[{ kinds: [KIND.BLOCKED_RELAYS], authors: [pubkey], limit: 1 }], |
|
relayManager.getProfileReadRelays(), |
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
), |
|
new Promise<NostrEvent[]>((resolve) => |
|
setTimeout(() => resolve([]), 5000) |
|
) |
|
]); |
|
|
|
if (blockedRelayEvents.length > 0) { |
|
const blocked = blockedRelayEvents[0].tags |
|
.filter((t) => t[0] === 'relay') |
|
.map((t) => t[1]) |
|
.filter(Boolean) as string[]; |
|
blockedRelays.clear(); |
|
blocked.forEach(r => blockedRelays.add(r)); |
|
|
|
// Update relay manager with blocked relays |
|
relayManager.updateBlockedRelays(blockedRelays); |
|
} |
|
} catch (error) { |
|
// Silently fail - blocked relay fetch errors shouldn't break login |
|
console.debug('[auth-handler] Failed to load blocked relays'); |
|
} |
|
} |
|
|
|
/** |
|
* Sign HTTP auth (NIP-98) for authenticated HTTP requests |
|
* Returns Authorization header value: "Nostr <base64-encoded-event>" |
|
*/ |
|
export async function signHttpAuth( |
|
url: string, |
|
method: string, |
|
description: string = '' |
|
): Promise<string> { |
|
const event = await sessionManager.signEvent({ |
|
kind: KIND.HTTP_AUTH, |
|
pubkey: sessionManager.getCurrentPubkey()!, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['u', url], |
|
['method', method] |
|
], |
|
content: description |
|
}); |
|
|
|
// Base64 encode the event JSON and return as "Nostr <base64>" |
|
const eventJson = JSON.stringify(event); |
|
const base64 = btoa(eventJson); |
|
return `Nostr ${base64}`; |
|
} |
|
|
|
/** |
|
* Sign and publish event |
|
*/ |
|
export async function signAndPublish( |
|
event: Omit<NostrEvent, 'sig' | 'id'>, |
|
relays?: string[] |
|
): Promise<{ |
|
success: string[]; |
|
failed: Array<{ relay: string; error: string }>; |
|
}> { |
|
const signed = await sessionManager.signEvent(event); |
|
return nostrClient.publish(signed, { relays }); |
|
} |
|
|
|
/** |
|
* Logout |
|
*/ |
|
export function logout(): void { |
|
sessionManager.clearSession(); |
|
muteList.clear(); |
|
blockedRelays.clear(); |
|
relayManager.clearUserPreferences(); |
|
} |
|
|
|
/** |
|
* Get mute list |
|
*/ |
|
export function getMuteList(): Set<string> { |
|
return muteList; |
|
} |
|
|
|
/** |
|
* Get blocked relays |
|
*/ |
|
export function getBlockedRelays(): Set<string> { |
|
return blockedRelays; |
|
}
|
|
|