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

/**
* 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;
}