From 030c51375172e0ce021b9006575e7e7625689ab9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 4 Feb 2026 15:47:33 +0100 Subject: [PATCH] implement anon signing and nsec encryption+saving fixed commentform --- README.md | 16 +- README_SETUP.md | 2 +- src/app.css | 12 +- src/app.html | 14 +- src/lib/components/EventMenu.svelte | 4 +- .../content/MarkdownRenderer.svelte | 2 +- src/lib/components/layout/Header.svelte | 4 +- .../preferences/UserPreferences.svelte | 2 +- .../components/write/CreateEventForm.svelte | 655 +++++++++++++++++- src/lib/modules/comments/CommentForm.svelte | 511 +++++++++++++- .../reactions/FeedReactionButtons.svelte | 2 +- .../modules/reactions/ReactionButtons.svelte | 2 +- src/lib/services/auth/anonymous-signer.ts | 10 +- src/lib/services/auth/nsec-signer.ts | 89 ++- src/lib/services/auth/session-manager.ts | 57 +- src/lib/services/cache/anonymous-key-store.ts | 37 +- src/lib/services/cache/nsec-key-store.ts | 131 ++++ src/lib/services/content/opengraph-fetcher.ts | 2 +- src/lib/services/nostr/auth-handler.ts | 195 +++++- src/lib/services/nostr/nostr-client.ts | 13 +- src/lib/services/security/key-management.ts | 104 ++- src/routes/+layout.svelte | 8 +- src/routes/login/+page.svelte | 520 +++++++++++++- src/routes/rss/[pubkey]/+server.ts | 2 +- tailwind.config.js | 3 +- 25 files changed, 2221 insertions(+), 176 deletions(-) create mode 100644 src/lib/services/cache/nsec-key-store.ts diff --git a/README.md b/README.md index bb5cb05..3a34302 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Aitherboard Specification +# aitherboard Specification A decentralized messageboard built on the Nostr protocol. This document defines the complete specification for implementation. @@ -146,7 +146,7 @@ aitherboard/ ["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "reply", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], ["root", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4", "created_at": 1679673300, @@ -170,7 +170,7 @@ aitherboard/ ["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "wss://relay.example.com"], ["k", "1"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4", "created_at": 1679673300, @@ -194,7 +194,7 @@ aitherboard/ ["imeta", "url https://example.com/image1.jpg", "m image/jpeg", "x 1920", "y 1080"], ["imeta", "url https://example.com/video1.mp4", "m video/mp4", "x 1920", "y 1080", "dim 1920x1080x30"], ["file", "https://example.com/document.pdf", "application/pdf", "size 1048576"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "created_at": 1679673265, @@ -224,7 +224,7 @@ aitherboard/ ["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], ["k", "11"], ["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4", "created_at": 1679673300, @@ -253,7 +253,7 @@ aitherboard/ ["p", "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9"], ["e", "9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb"], ["k", "1"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", "created_at": 1679673265, @@ -429,7 +429,7 @@ aitherboard/ "content": "Working on a new project", "tags": [ ["d", "general"], - ["client", "Aitherboard"] + ["client", "aitherboard"] ], "pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb", "created_at": 1699597889, @@ -520,7 +520,7 @@ aitherboard/ ### Event Publishing **REQUIREMENTS** (applies to all published events): -- **NIP-89 client tag**: Add `["client", "Aitherboard"]` tag to all published events when checkbox is selected +- **NIP-89 client tag**: Add `["client", "aitherboard"]` tag to all published events when checkbox is selected - Checkbox "Include client tag." displayed in all publish forms - Checkbox selected by default - Only include client tag if checkbox is selected (not deselected) diff --git a/README_SETUP.md b/README_SETUP.md index c6bddac..a56c29d 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -1,4 +1,4 @@ -# Aitherboard Setup Guide +# aitherboard Setup Guide ## Prerequisites diff --git a/src/app.css b/src/app.css index 1105ec0..5207a78 100644 --- a/src/app.css +++ b/src/app.css @@ -70,6 +70,11 @@ body { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; } +/* Apply monospace font to all elements globally */ +* { + font-family: inherit; +} + /* Secret supercoder vibe - subtle terminal aesthetic */ body::before { content: ''; @@ -148,11 +153,8 @@ img.emoji-inline { } /* Ensure normal Unicode emojis (text characters) are displayed correctly */ -/* Use emoji-friendly fonts and ensure they're not filtered */ -body, .markdown-content, .post-content { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; - /* Normal emojis are text, not images, so no filter should apply */ -} +/* Monospace font is already applied globally via body and * { font-family: inherit } */ +/* Emojis will still display correctly in monospace fonts */ /* Emoji picker buttons - apply grayscale filter to buttons that open the picker */ /* But NOT to emojis inside the picker drawer or rendered content */ diff --git a/src/app.html b/src/app.html index 0126392..05c29e3 100644 --- a/src/app.html +++ b/src/app.html @@ -9,8 +9,8 @@ - Aitherboard - Decentralized Messageboard on Nostr - + aitherboard - Decentralized Messageboard on Nostr + @@ -18,22 +18,22 @@ - + - - + + - + - + diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index f8b71ce..bb20ea0 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -215,7 +215,7 @@ } } - async function shareWithAitherboard() { + async function shareWithaitherboard() { try { const url = `${window.location.origin}/event/${event.id}`; await navigator.clipboard.writeText(url); @@ -328,7 +328,7 @@ - + + + +
{#if onCancel} +
+ + + + + {/if} + + + {#if showPreviewModal} + + {/if} {/if} @@ -277,6 +620,11 @@ min-height: 100px; } + /* Add padding to bottom when buttons are visible to prevent text overlap */ + textarea.has-buttons { + padding-bottom: 2.5rem; + } + textarea:focus { outline: none; border-color: var(--fog-accent, #64748b); @@ -322,4 +670,157 @@ background: var(--fog-dark-highlight, #374151); border-color: var(--fog-dark-accent, #64748b); } + + /* Modal styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal-content { + background: #f8fafc; + border: 1px solid #cbd5e1; + border-radius: 8px; + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow: auto; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + } + + :global(.dark) .modal-content { + background: #1e293b; + border-color: #475569; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #cbd5e1; + } + + :global(.dark) .modal-header { + border-bottom-color: #475569; + } + + .modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } + + .close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #64748b; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + } + + .close-button:hover { + background: #e2e8f0; + color: #1e2937; + } + + :global(.dark) .close-button { + color: #94a3b8; + } + + :global(.dark) .close-button:hover { + background: #334155; + color: #f1f5f9; + } + + .modal-body { + padding: 1rem; + max-height: 60vh; + overflow: auto; + } + + .json-preview { + background: #1e293b; + color: #f1f5f9; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } + + .preview-modal { + max-width: 900px; + } + + .preview-body { + padding: 1.5rem; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid #cbd5e1; + } + + :global(.dark) .modal-footer { + border-top-color: #475569; + } + + .modal-footer button { + padding: 0.5rem 1rem; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + background: #ffffff; + color: #1e2937; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + } + + .modal-footer button:hover { + background: #f1f5f9; + border-color: #94a3b8; + } + + :global(.dark) .modal-footer button { + background: #334155; + border-color: #475569; + color: #f1f5f9; + } + + :global(.dark) .modal-footer button:hover { + background: #475569; + border-color: #64748b; + } + + label[for="comment-file-upload"], + .upload-label { + user-select: none; + } + + .upload-label { + filter: grayscale(100%); + } diff --git a/src/lib/modules/reactions/FeedReactionButtons.svelte b/src/lib/modules/reactions/FeedReactionButtons.svelte index f877078..5543772 100644 --- a/src/lib/modules/reactions/FeedReactionButtons.svelte +++ b/src/lib/modules/reactions/FeedReactionButtons.svelte @@ -483,7 +483,7 @@ ]; if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) { - tags.push(['client', 'Aitherboard']); + tags.push(['client', 'aitherboard']); } const reactionEvent: Omit = { diff --git a/src/lib/modules/reactions/ReactionButtons.svelte b/src/lib/modules/reactions/ReactionButtons.svelte index c604b44..69079bd 100644 --- a/src/lib/modules/reactions/ReactionButtons.svelte +++ b/src/lib/modules/reactions/ReactionButtons.svelte @@ -123,7 +123,7 @@ ]; if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) { - tags.push(['client', 'Aitherboard']); + tags.push(['client', 'aitherboard']); } const reactionEvent: Omit = { diff --git a/src/lib/services/auth/anonymous-signer.ts b/src/lib/services/auth/anonymous-signer.ts index 698559b..658bcab 100644 --- a/src/lib/services/auth/anonymous-signer.ts +++ b/src/lib/services/auth/anonymous-signer.ts @@ -42,15 +42,17 @@ export async function signEventWithAnonymous( pubkey: string, password: string ): Promise { + // Retrieve and decrypt key - NEVER log the nsec or password const nsec = await getStoredAnonymousKey(pubkey, password); if (!nsec) { throw new Error('Anonymous key not found'); } - // For anonymous keys, we need the ncryptsec format - // This is simplified - in practice we'd store ncryptsec and decrypt it - // For now, assume we have the plain nsec after decryption - return signEventWithNsec(event, nsec, password); + // Encrypt to ncryptsec format for signing + // NEVER log the nsec or password + const { encryptPrivateKey } = await import('../security/key-management.js'); + const ncryptsec = await encryptPrivateKey(nsec, password); + return signEventWithNsec(event, ncryptsec, password); } /** diff --git a/src/lib/services/auth/nsec-signer.ts b/src/lib/services/auth/nsec-signer.ts index c25a06d..430d5bc 100644 --- a/src/lib/services/auth/nsec-signer.ts +++ b/src/lib/services/auth/nsec-signer.ts @@ -3,71 +3,62 @@ */ import { decryptPrivateKey } from '../security/key-management.js'; +import { getPublicKey, finalizeEvent } from 'nostr-tools'; import type { NostrEvent } from '../../types/nostr.js'; +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + if (hex.length !== 64) { + throw new Error('Invalid hex string: must be 64 characters (32 bytes)'); + } + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + const hexByte = hex.slice(i * 2, i * 2 + 2); + bytes[i] = parseInt(hexByte, 16); + } + return bytes; +} + /** * Sign event with nsec (private key) - * This is a placeholder - full implementation requires: - * - secp256k1 cryptography library - * - Event ID computation (SHA256) - * - Signature computation + * Uses proper secp256k1 cryptography via nostr-tools finalizeEvent */ export async function signEventWithNsec( event: Omit, ncryptsec: string, password: string ): Promise { - // Decrypt private key + // Decrypt private key - NEVER log the nsec, password, or ncryptsec const nsec = await decryptPrivateKey(ncryptsec, password); - // Compute event ID (SHA256 of serialized event) - const serialized = JSON.stringify([ - 0, - event.pubkey, - event.created_at, - event.kind, - event.tags, - event.content - ]); - - const encoder = new TextEncoder(); - const data = encoder.encode(serialized); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const id = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - - // TEMPORARY: Generate a deterministic signature-like string - // This is NOT a valid secp256k1 signature but has the correct length - // Production code MUST compute actual secp256k1 signature - const sigData = encoder.encode(nsec + id); - const sigHashBuffer = await crypto.subtle.digest('SHA-256', sigData); - const sigHashArray = Array.from(new Uint8Array(sigHashBuffer)); - const sigHash = sigHashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - // Double the hash to get 128 chars (64 * 2) - const sig = (sigHash + sigHash).slice(0, 128); + // Convert hex string to Uint8Array for finalizeEvent + const privkey = hexToBytes(nsec); - return { - ...event, - id, - sig - }; + // Use nostr-tools finalizeEvent to properly sign the event + // This computes the event ID (SHA256) and secp256k1 signature + return finalizeEvent(event, privkey); } /** - * Get public key from private key - * - * TEMPORARY: Uses SHA256 hash of private key to generate a deterministic pubkey. - * This is NOT a valid secp256k1 public key derivation but provides unique pubkeys. - * Production code MUST use proper secp256k1 point multiplication. + * Get public key from private key (hex string) + * Uses proper secp256k1 cryptography via nostr-tools */ export async function getPublicKeyFromNsec(nsec: string): Promise { - // TEMPORARY: Generate deterministic pubkey from private key hash - // This ensures each private key gets a unique (but not cryptographically valid) pubkey - const encoder = new TextEncoder(); - const data = encoder.encode(nsec); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - // Double the hash to get 64 chars (32 * 2) - return (hash + hash).slice(0, 64); + // Convert hex string to Uint8Array + // nsec is a 64-character hex string representing 32 bytes + if (nsec.length !== 64) { + throw new Error('Invalid nsec: must be 64 hex characters (32 bytes)'); + } + + // Convert hex string to bytes + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + const hexByte = nsec.slice(i * 2, i * 2 + 2); + bytes[i] = parseInt(hexByte, 16); + } + + // Use nostr-tools to get the proper public key + return getPublicKey(bytes); } diff --git a/src/lib/services/auth/session-manager.ts b/src/lib/services/auth/session-manager.ts index 2db4596..85f3e44 100644 --- a/src/lib/services/auth/session-manager.ts +++ b/src/lib/services/auth/session-manager.ts @@ -11,6 +11,9 @@ export interface UserSession { method: AuthMethod; signer: (event: Omit) => Promise; createdAt: number; + // Store password in memory for nsec/anonymous sessions (never persisted) + // This allows signing without re-prompting, but password is cleared on logout + password?: string; // Only for 'nsec' and 'anonymous' methods } // Simple store implementation for Svelte reactivity @@ -45,6 +48,7 @@ class SessionManager { this.currentSession = session; this.session.set(session); // Store in localStorage for persistence + // NEVER store password in localStorage - it's only kept in memory if (typeof window !== 'undefined') { const sessionData: any = { pubkey: session.pubkey, @@ -55,6 +59,7 @@ class SessionManager { if (metadata) { sessionData.metadata = metadata; } + // Password is never persisted - only kept in memory localStorage.setItem('aitherboard_session', JSON.stringify(sessionData)); } } @@ -93,8 +98,14 @@ class SessionManager { /** * Clear session + * Also clears password from memory for security */ clearSession(): void { + // Clear password from memory if it exists + if (this.currentSession?.password) { + // Overwrite password in memory (though JS doesn't guarantee this) + this.currentSession.password = ''; + } this.currentSession = null; this.session.set(null); if (typeof window !== 'undefined') { @@ -105,10 +116,16 @@ class SessionManager { /** * Restore session from localStorage * This will attempt to restore the session based on the auth method + * Only restores if there's no active session (to avoid overwriting sessions with passwords) */ async restoreSession(): Promise { if (typeof window === 'undefined') return false; + // Don't restore if there's already an active session (especially one with a password) + if (this.currentSession) { + return true; // Session already exists, consider it restored + } + const stored = localStorage.getItem('aitherboard_session'); if (!stored) return false; @@ -146,26 +163,20 @@ class SessionManager { case 'anonymous': { // For anonymous, we can restore if the encrypted key is stored // The key is stored in IndexedDB, we just need to verify it exists - const { getStoredAnonymousKey } = await import('./anonymous-signer.js'); // We can't restore without password, but we can check if key exists - // For now, we'll just restore the pubkey and let signer fail if password is wrong - // In practice, user would need to re-enter password if (pubkey) { - // Check if key exists in storage try { - // This will fail without password, but we can still restore session - // The signer will need password on first use - const { signEventWithAnonymous } = await import('./anonymous-signer.js'); + // Check if key exists by trying to list keys (we can't decrypt without password) + // For now, restore session but signer will require password this.setSession({ pubkey, method: 'anonymous', - signer: async (event) => { - // This will fail without password - user needs to re-authenticate + signer: async () => { throw new Error('Anonymous session requires password. Please log in again.'); }, createdAt: data.createdAt || Date.now() }); - // Note: This session won't work until user re-authenticates + // Note: This session won't work until user re-authenticates with password return true; } catch { return false; @@ -174,8 +185,30 @@ class SessionManager { return false; } case 'nsec': { - // nsec can't be restored without password (security) - // Clear the stored session + // For nsec, we can restore the session but signing will require password + // The encrypted key is stored in IndexedDB, but we need password to decrypt + // For now, restore session but signer will fail until user re-enters password + if (pubkey) { + try { + const { hasNsecKey } = await import('../cache/nsec-key-store.js'); + const keyExists = await hasNsecKey(pubkey); + if (keyExists) { + // Restore session but signer will require password + this.setSession({ + pubkey, + method: 'nsec', + signer: async () => { + throw new Error('Nsec session requires password. Please log in again.'); + }, + createdAt: data.createdAt || Date.now() + }); + return true; + } + } catch { + return false; + } + } + // Clear the stored session if key doesn't exist localStorage.removeItem('aitherboard_session'); return false; } diff --git a/src/lib/services/cache/anonymous-key-store.ts b/src/lib/services/cache/anonymous-key-store.ts index 550288b..f7353f1 100644 --- a/src/lib/services/cache/anonymous-key-store.ts +++ b/src/lib/services/cache/anonymous-key-store.ts @@ -10,6 +10,7 @@ export interface StoredAnonymousKey { ncryptsec: string; // NIP-49 encrypted key pubkey: string; // Public key for identification created_at: number; + keyType?: 'nsec' | 'anonymous'; // Distinguish between nsec and anonymous keys } /** @@ -26,7 +27,8 @@ export async function storeAnonymousKey( id: pubkey, ncryptsec, pubkey, - created_at: Date.now() + created_at: Date.now(), + keyType: 'anonymous' }; await db.put('keys', stored); } @@ -43,20 +45,45 @@ export async function getAnonymousKey( if (!stored) return null; const key = stored as StoredAnonymousKey; - return decryptPrivateKey(key.ncryptsec, password); + if (!key.ncryptsec || typeof key.ncryptsec !== 'string') { + throw new Error('Stored anonymous key has invalid ncryptsec format - key may be corrupted'); + } + + // Validate ncryptsec format before attempting decryption + if (!key.ncryptsec.startsWith('ncryptsec1')) { + throw new Error('Stored anonymous key has invalid encryption format - key may need to be re-saved'); + } + + // NEVER log the password or ncryptsec - they are sensitive + try { + return await decryptPrivateKey(key.ncryptsec, password); + } catch (error) { + // Provide helpful error without exposing sensitive data + if (error instanceof Error && error.message.includes('Invalid password')) { + throw error; // Re-throw password errors + } + throw new Error('Failed to decrypt stored anonymous key - the key may be corrupted or the password is incorrect'); + } } /** * List all stored anonymous keys (pubkeys only) */ -export async function listAnonymousKeys(): Promise { +export async function listAnonymousKeys(): Promise> { const db = await getDB(); - const keys: string[] = []; + const keys: Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }> = []; const tx = db.transaction('keys', 'readonly'); for await (const cursor of tx.store.iterate()) { const key = cursor.value as StoredAnonymousKey; - keys.push(key.pubkey); + // Only include anonymous keys + if (!key.keyType || key.keyType === 'anonymous') { + keys.push({ + pubkey: key.pubkey, + created_at: key.created_at, + keyType: key.keyType || 'anonymous' + }); + } } await tx.done; diff --git a/src/lib/services/cache/nsec-key-store.ts b/src/lib/services/cache/nsec-key-store.ts new file mode 100644 index 0000000..a97f594 --- /dev/null +++ b/src/lib/services/cache/nsec-key-store.ts @@ -0,0 +1,131 @@ +/** + * Nsec key storage (NIP-49 encrypted) + * Stores encrypted nsec keys in IndexedDB + */ + +import { getDB } from './indexeddb-store.js'; +import { encryptPrivateKey, decryptPrivateKey } from '../security/key-management.js'; + +export interface StoredNsecKey { + id: string; // pubkey + ncryptsec: string; // NIP-49 encrypted key + pubkey: string; // Public key for identification + created_at: number; + keyType?: 'nsec' | 'anonymous'; // Distinguish between nsec and anonymous keys +} + +/** + * Store an nsec key (encrypted) + * NEVER log the nsec or password - they are sensitive + */ +export async function storeNsecKey( + nsec: string, + password: string, + pubkey: string +): Promise { + // Encrypt the private key - never store plaintext + const ncryptsec = await encryptPrivateKey(nsec, password); + + const db = await getDB(); + + // Check if there's an existing key with this pubkey + // If so, verify it matches before overwriting + const existing = await db.get('keys', pubkey); + if (existing) { + const existingKey = existing as StoredNsecKey; + // If the existing key is an nsec key, verify it matches + if (existingKey.keyType === 'nsec' && existingKey.pubkey === pubkey) { + // Key exists and matches - we'll overwrite it + // This is fine, we're updating with the same pubkey + } + } + + const stored: StoredNsecKey = { + id: pubkey, + ncryptsec, + pubkey, + created_at: Date.now(), + keyType: 'nsec' + }; + + // Store encrypted key - never log the nsec or password + await db.put('keys', stored); + + // Note: Verification is done in authenticateWithNsec after storage + // to ensure the key is committed to IndexedDB and can be retrieved +} + +/** + * Retrieve and decrypt an nsec key + * NEVER log the password or decrypted nsec + */ +export async function getNsecKey( + pubkey: string, + password: string +): Promise { + const db = await getDB(); + const stored = await db.get('keys', pubkey); + if (!stored) return null; + + const key = stored as StoredNsecKey; + if (!key.ncryptsec || typeof key.ncryptsec !== 'string') { + throw new Error('Stored nsec key has invalid ncryptsec format - key may be corrupted'); + } + + // Validate ncryptsec format before attempting decryption + if (!key.ncryptsec.startsWith('ncryptsec1')) { + throw new Error('Stored nsec key has invalid encryption format - key may need to be re-saved'); + } + + // Decrypt and return - never log the result + try { + return await decryptPrivateKey(key.ncryptsec, password); + } catch (error) { + // Provide helpful error without exposing sensitive data + if (error instanceof Error && error.message.includes('Invalid password')) { + throw error; // Re-throw password errors + } + throw new Error('Failed to decrypt stored nsec key - the key may be corrupted or the password is incorrect'); + } +} + +/** + * Check if an nsec key exists for a pubkey + */ +export async function hasNsecKey(pubkey: string): Promise { + const db = await getDB(); + const stored = await db.get('keys', pubkey); + return stored !== undefined; +} + +/** + * Delete an nsec key + */ +export async function deleteNsecKey(pubkey: string): Promise { + const db = await getDB(); + await db.delete('keys', pubkey); +} + +/** + * List all stored nsec keys (pubkeys only) + */ +export async function listNsecKeys(): Promise> { + const db = await getDB(); + const keys: Array<{ pubkey: string; created_at: number; keyType?: 'nsec' | 'anonymous' }> = []; + const tx = db.transaction('keys', 'readonly'); + + for await (const cursor of tx.store.iterate()) { + const key = cursor.value as StoredNsecKey; + // Only include nsec keys (not anonymous, which are handled separately) + if (!key.keyType || key.keyType === 'nsec') { + keys.push({ + pubkey: key.pubkey, + created_at: key.created_at, + keyType: key.keyType || 'nsec' + }); + } + } + + await tx.done; + return keys; +} diff --git a/src/lib/services/content/opengraph-fetcher.ts b/src/lib/services/content/opengraph-fetcher.ts index 958d538..48a0842 100644 --- a/src/lib/services/content/opengraph-fetcher.ts +++ b/src/lib/services/content/opengraph-fetcher.ts @@ -35,7 +35,7 @@ export async function fetchOpenGraph(url: string): Promise method: 'GET', headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'User-Agent': 'Mozilla/5.0 (compatible; Aitherboard/1.0)' + 'User-Agent': 'Mozilla/5.0 (compatible; aitherboard/1.0)' }, mode: 'cors', cache: 'no-cache' diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index f1b5138..4282cd0 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -45,25 +45,179 @@ export async function authenticateWithNIP07(): Promise { } /** - * Authenticate with nsec + * Authenticate with existing stored nsec key (password only) + * NEVER logs the password - it is sensitive + */ +export async function authenticateWithStoredNsec( + pubkey: string, + password: string +): Promise { + // 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 and decrypt key when signing - never log it + const session = sessionManager.getSession(); + if (!session || !session.password) { + throw new Error('Session password not available'); + } + const { getNsecKey } = await import('../cache/nsec-key-store.js'); + const decryptedNsec = await getNsecKey(pubkey, session.password); + if (!decryptedNsec) { + throw new Error('Stored nsec key not found'); + } + // Encrypt to ncryptsec format for signing + const { encryptPrivateKey } = await import('../security/key-management.js'); + const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password); + 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( - ncryptsec: string, + nsec: string, password: string ): Promise { - // Decrypt the encrypted private key - const nsec = await decryptPrivateKey(ncryptsec, password); - - // Derive public key from private key + // 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', - signer: async (event) => signEventWithNsec(event, ncryptsec, password), + 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 { getNsecKey } = await import('../cache/nsec-key-store.js'); + try { + const decryptedNsec = await getNsecKey(pubkey, session.password); + if (!decryptedNsec) { + throw new Error('Stored nsec key not found'); + } + // Verify the decrypted nsec matches the expected pubkey + const { getPublicKeyFromNsec } = await import('../auth/nsec-signer.js'); + const derivedPubkey = await getPublicKeyFromNsec(decryptedNsec); + if (derivedPubkey !== pubkey) { + throw new Error('Stored nsec key does not match the expected pubkey - key may be corrupted'); + } + // Encrypt to ncryptsec format for signing + const { encryptPrivateKey } = await import('../security/key-management.js'); + const ncryptsec = await encryptPrivateKey(decryptedNsec, session.password); + 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 { + // Verify the key exists and password is correct by trying to decrypt + const { getStoredAnonymousKey } = await import('../auth/anonymous-signer.js'); + 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) @@ -75,22 +229,37 @@ export async function authenticateWithNsec( } /** - * Authenticate as anonymous + * 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 { + // Generate new anonymous key - never log the nsec const { pubkey, nsec } = await generateAnonymousKey(password); - // Store the key for later use - // In practice, we'd need to store the ncryptsec and decrypt when needed - // For now, this is simplified + // 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) => { - // Simplified - would decrypt and sign - return signEventWithAnonymous(event, pubkey, password); + // 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; diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 5756118..5bfc5d5 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -122,7 +122,13 @@ class NostrClient { // Only log if it's not the "no challenge" error (which is expected for relays that don't require auth) const errorMessage = error instanceof Error ? error.message : String(error); if (!errorMessage.includes('no challenge was received')) { - console.debug(`[nostr-client] Failed to authenticate with relay ${url}:`, errorMessage); + // Log authentication failures but don't expose sensitive data + // Never log passwords, nsec, or ncryptsec values + const safeMessage = errorMessage + .replace(/password/gi, '[password]') + .replace(/nsec/gi, '[nsec]') + .replace(/ncryptsec/gi, '[ncryptsec]'); + console.warn(`[nostr-client] Failed to authenticate with relay ${url}:`, safeMessage); } return false; } @@ -1048,6 +1054,11 @@ class NostrClient { } else if (connectedRelays.length < relays.length * 0.5) { console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); } + + // Log connection status for single relay queries + if (relays.length === 1 && connectedRelays.length === 1) { + console.log(`[nostr-client] Successfully connected to relay ${relays[0]}, fetching events...`); + } // Process relays sequentially with throttling to avoid overload const events: Map = new Map(); diff --git a/src/lib/services/security/key-management.ts b/src/lib/services/security/key-management.ts index b957a9f..2c8a184 100644 --- a/src/lib/services/security/key-management.ts +++ b/src/lib/services/security/key-management.ts @@ -1,57 +1,95 @@ /** * Key management with NIP-49 encryption * All private keys MUST be encrypted before storage + * + * Uses nostr-tools/nip49 for proper NIP-49 compliant encryption/decryption + */ + +import * as nip49 from 'nostr-tools/nip49'; + +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + if (hex.length !== 64) { + throw new Error('Invalid hex string: must be 64 characters (32 bytes)'); + } + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + const hexByte = hex.slice(i * 2, i * 2 + 2); + bytes[i] = parseInt(hexByte, 16); + } + return bytes; +} + +/** + * Convert Uint8Array to hex string */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} /** * Encrypt a private key using NIP-49 (password-based encryption) - * This is a placeholder - full implementation requires: - * - scrypt for key derivation - * - AES-256-GCM for encryption - * - Base64 encoding * - * WARNING: Current implementation stores keys in plaintext format. - * This is insecure and should only be used for development. - * Production code MUST implement proper NIP-49 encryption. + * Implements NIP-49 using nostr-tools: + * - Unicode NFKC normalization of password + * - scrypt key derivation + * - XChaCha20-Poly1305 encryption + * - bech32 encoding as ncryptsec + * + * @param nsec - Private key as hex string (64 characters) + * @param password - Password for encryption (will be NFKC normalized) + * @returns Encrypted private key in ncryptsec bech32 format */ export async function encryptPrivateKey(nsec: string, password: string): Promise { - // TEMPORARY: Store as base64-encoded plaintext with a marker - // This allows the system to function while proper crypto is implemented - // Full NIP-49 implementation would: - // 1. Derive key from password using scrypt - // 2. Generate random salt and nonce - // 3. Encrypt nsec with AES-256-GCM - // 4. Encode as ncryptsec format - const encoded = btoa(JSON.stringify({ nsec, password })); - return `ncryptsec1${encoded}`; + // Convert hex string to Uint8Array (32 bytes) + const privkey = hexToBytes(nsec); + + // Use nostr-tools/nip49 to encrypt (handles NFKC normalization, scrypt, XChaCha20-Poly1305) + // Returns ncryptsec bech32 string + return nip49.encrypt(privkey, password); } /** * Decrypt a private key using NIP-49 * - * WARNING: Current implementation reads plaintext keys. - * This is insecure and should only be used for development. - * Production code MUST implement proper NIP-49 decryption. + * Implements NIP-49 using nostr-tools: + * - Unicode NFKC normalization of password + * - scrypt key derivation + * - XChaCha20-Poly1305 decryption + * + * @param ncryptsec - Encrypted private key in ncryptsec bech32 format + * @param password - Password for decryption (will be NFKC normalized) + * @returns Decrypted private key as hex string (64 characters) */ export async function decryptPrivateKey(ncryptsec: string, password: string): Promise { - // TEMPORARY: Decode from base64 plaintext format - // This allows the system to function while proper crypto is implemented - // Full NIP-49 implementation would: - // 1. Decode ncryptsec format - // 2. Derive key from password using scrypt - // 3. Decrypt with AES-256-GCM - // 4. Return plain nsec + if (!ncryptsec || typeof ncryptsec !== 'string') { + throw new Error('Invalid ncryptsec: must be a non-empty string'); + } if (!ncryptsec.startsWith('ncryptsec1')) { - throw new Error('Invalid ncryptsec format'); + throw new Error('Invalid ncryptsec format: must start with "ncryptsec1"'); } + try { - const decoded = JSON.parse(atob(ncryptsec.slice(11))); - if (decoded.password !== password) { - throw new Error('Invalid password'); - } - return decoded.nsec; + // Use nostr-tools/nip49 to decrypt (handles NFKC normalization, scrypt, XChaCha20-Poly1305) + // Returns Uint8Array (32 bytes) + const privkey = nip49.decrypt(ncryptsec, password); + + // Convert Uint8Array to hex string + return bytesToHex(privkey); } catch (error) { - throw new Error('Failed to decrypt private key: ' + (error instanceof Error ? error.message : 'Unknown error')); + // Provide more specific error messages without exposing sensitive data + if (error instanceof Error) { + // Check for common error types + if (error.message.includes('password') || error.message.includes('decrypt')) { + throw new Error('Invalid password or corrupted encrypted key'); + } + throw new Error('Failed to decrypt private key: ' + error.message); + } + throw new Error('Failed to decrypt private key: Unknown error'); } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 787a0a6..b7d5303 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,10 +3,14 @@ import { sessionManager } from '../lib/services/auth/session-manager.js'; import { onMount } from 'svelte'; - // Restore session on app load + // Restore session on app load (only if no session exists) onMount(async () => { try { - await sessionManager.restoreSession(); + // Only restore if there's no active session + // This prevents overwriting sessions that were just created during login + if (!sessionManager.isLoggedIn()) { + await sessionManager.restoreSession(); + } } catch (error) { console.error('Failed to restore session:', error); } diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index d30d02e..dd7b095 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,16 +1,62 @@

Login

{#if error} -
+
{error}
{/if} -
+ +
+ + +
+ +
+ {#if activeTab === 'nip07'} +
+

+ Login using a Nostr browser extension (Alby, nos2x, etc.) +

+ +
+ {:else if activeTab === 'nsec'} +
+ {#if storedNsecKeys.length > 0 && !showNewNsecForm} + +
+

+ Select a stored key or add a new one: +

+ + +
+ {#each storedNsecKeys as key} + + {/each} +
+ + {#if selectedNsecKey} +
+ + + +
+ {/if} + + +
+ {:else} + +
+ {#if storedNsecKeys.length > 0} + + {/if} +

+ Enter your nsec key. It will be encrypted and stored securely in your browser. +

+
+ + +
+
+ + +
+
+ + +

+ This password encrypts your key. You'll need it each time you sign events. +

+
+ +
+ {/if} +
+ {:else if activeTab === 'anonymous'} +
+ {#if storedAnonymousKeys.length > 0 && !showNewAnonymousForm} +
+

+ Select a stored anonymous key or generate a new one: +

+ + +
+ {#each storedAnonymousKeys as key} + + {/each} +
+ + {#if selectedAnonymousKey} +
+ + + +
+ {/if} -

- Other authentication methods (nsec, anonymous) coming soon... -

+ +
+ {:else} + +
+ {#if storedAnonymousKeys.length > 0} + + {/if} +

+ Generate a new anonymous key. It will be encrypted and stored securely in your browser. +

+
+ + +
+
+ + +
+ +
+ {/if} +
+ {/if}
diff --git a/src/routes/rss/[pubkey]/+server.ts b/src/routes/rss/[pubkey]/+server.ts index e6fddbc..c676670 100644 --- a/src/routes/rss/[pubkey]/+server.ts +++ b/src/routes/rss/[pubkey]/+server.ts @@ -67,7 +67,7 @@ export const GET: RequestHandler = async ({ params, url }) => { ${escapeXml(profileAbout || `Nostr feed for ${profileName}`)} en ${new Date().toUTCString()} - Aitherboard + aitherboard ${profilePicture ? `${escapeXml(profilePicture)}${escapeXml(profileName)}${baseUrl}/profile/${pubkey}` : ''} ${sortedEvents.map(event => { const title = getEventTitle(event); diff --git a/tailwind.config.js b/tailwind.config.js index 2e8a43b..8128994 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -29,7 +29,8 @@ export default { } }, fontFamily: { - sans: ['system-ui', '-apple-system', 'sans-serif'] + sans: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'monospace'], + mono: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'monospace'] } } },