LoginModal.svelte raw
1 <script>
2 import { createEventDispatcher, onMount, onDestroy } from "svelte";
3 import { PrivateKeySigner } from "./nostr.js";
4 import { generateSecretKey, getPublicKey } from "nostr-tools/pure";
5 import { nsecEncode, npubEncode, decode as nip19Decode } from "nostr-tools/nip19";
6 import { encryptNsec, decryptNsec, isValidNsec } from "./nsec-crypto.js";
7
8 const dispatch = createEventDispatcher();
9
10 export let showModal = false;
11 export let isDarkTheme = false;
12
13 let activeTab = "extension";
14 let nsecInput = "";
15 let encryptionPassword = "";
16 let confirmPassword = "";
17 let unlockPassword = "";
18 let isLoading = false;
19 let isGenerating = false;
20 let isDeriving = false;
21 let errorMessage = "";
22 let successMessage = "";
23 let generatedNsec = "";
24 let generatedNpub = "";
25 let npubInput = "";
26
27 // Deriving modal timer
28 let derivingElapsed = 0;
29 let derivingStartTime = null;
30 let derivingAnimationFrame = null;
31
32 function startDerivingTimer() {
33 derivingElapsed = 0;
34 derivingStartTime = performance.now();
35 updateDerivingTimer();
36 }
37
38 function updateDerivingTimer() {
39 if (derivingStartTime !== null) {
40 derivingElapsed = (performance.now() - derivingStartTime) / 1000;
41 derivingAnimationFrame = requestAnimationFrame(updateDerivingTimer);
42 }
43 }
44
45 function stopDerivingTimer() {
46 derivingStartTime = null;
47 if (derivingAnimationFrame) {
48 cancelAnimationFrame(derivingAnimationFrame);
49 derivingAnimationFrame = null;
50 }
51 }
52
53 onDestroy(() => {
54 stopDerivingTimer();
55 });
56
57 // Check if there's an encrypted key stored
58 let hasEncryptedKey = false;
59 let storedPubkey = "";
60
61 onMount(() => {
62 checkStoredCredentials();
63 });
64
65 function checkStoredCredentials() {
66 hasEncryptedKey = !!localStorage.getItem("nostr_privkey_encrypted");
67 storedPubkey = localStorage.getItem("nostr_pubkey") || "";
68 }
69
70 // Reset to show the nsec input form
71 function clearStoredCredentials() {
72 localStorage.removeItem("nostr_privkey_encrypted");
73 localStorage.removeItem("nostr_privkey");
74 localStorage.removeItem("nostr_pubkey");
75 localStorage.removeItem("nostr_auth_method");
76 hasEncryptedKey = false;
77 storedPubkey = "";
78 unlockPassword = "";
79 errorMessage = "";
80 successMessage = "";
81 }
82
83 function closeModal() {
84 showModal = false;
85 nsecInput = "";
86 npubInput = "";
87 encryptionPassword = "";
88 confirmPassword = "";
89 unlockPassword = "";
90 errorMessage = "";
91 successMessage = "";
92 generatedNsec = "";
93 generatedNpub = "";
94 dispatch("close");
95 }
96
97 // Re-check stored credentials when modal opens
98 $: if (showModal) {
99 checkStoredCredentials();
100 }
101
102 // Unlock with stored encrypted key
103 async function unlockWithPassword() {
104 isLoading = true;
105 isDeriving = true;
106 startDerivingTimer();
107 errorMessage = "";
108 successMessage = "";
109
110 try {
111 if (!unlockPassword) {
112 throw new Error("Please enter your password");
113 }
114
115 const encryptedData = localStorage.getItem("nostr_privkey_encrypted");
116 if (!encryptedData) {
117 throw new Error("No encrypted key found");
118 }
119
120 // Decrypt the nsec (library validates bech32 checksum)
121 const nsec = await decryptNsec(encryptedData, unlockPassword);
122
123 stopDerivingTimer();
124 isDeriving = false;
125
126 // Create signer and login
127 const signer = PrivateKeySigner.fromKey(nsec);
128 const publicKey = await signer.getPublicKey();
129
130 dispatch("login", {
131 method: "nsec",
132 pubkey: publicKey,
133 privateKey: nsec,
134 signer: signer,
135 });
136
137 closeModal();
138 } catch (error) {
139 stopDerivingTimer();
140 if (error.message.includes("decrypt") || error.message.includes("tag")) {
141 errorMessage = "Invalid password";
142 } else {
143 errorMessage = error.message;
144 }
145 } finally {
146 isLoading = false;
147 isDeriving = false;
148 stopDerivingTimer();
149 }
150 }
151
152 function switchTab(tab) {
153 activeTab = tab;
154 errorMessage = "";
155 successMessage = "";
156 generatedNsec = "";
157 generatedNpub = "";
158 }
159
160 // Generate a new nsec using cryptographically secure random bytes
161 async function generateNewKey() {
162 isGenerating = true;
163 errorMessage = "";
164 successMessage = "";
165
166 try {
167 // Generate a new secret key using system entropy (crypto.getRandomValues)
168 const secretKey = generateSecretKey();
169
170 // Encode as nsec (bech32)
171 const nsec = nsecEncode(secretKey);
172
173 // Get the corresponding public key and encode as npub
174 const pubkey = getPublicKey(secretKey);
175 const npub = npubEncode(pubkey);
176
177 generatedNsec = nsec;
178 generatedNpub = npub;
179 nsecInput = nsec;
180
181 successMessage = "New key generated! Set an encryption password below to secure it.";
182 } catch (error) {
183 errorMessage = "Failed to generate key: " + error.message;
184 } finally {
185 isGenerating = false;
186 }
187 }
188
189 async function loginWithExtension() {
190 isLoading = true;
191 errorMessage = "";
192 successMessage = "";
193
194 try {
195 // Check if window.nostr is available
196 if (!window.nostr) {
197 throw new Error(
198 "No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.",
199 );
200 }
201
202 // Get public key from extension
203 const pubkey = await window.nostr.getPublicKey();
204
205 if (pubkey) {
206 // Store authentication info
207 localStorage.setItem("nostr_auth_method", "extension");
208 localStorage.setItem("nostr_pubkey", pubkey);
209
210 successMessage = "Successfully logged in with extension!";
211 dispatch("login", {
212 method: "extension",
213 pubkey: pubkey,
214 signer: window.nostr,
215 });
216
217 setTimeout(() => {
218 closeModal();
219 }, 1500);
220 }
221 } catch (error) {
222 errorMessage = error.message;
223 } finally {
224 isLoading = false;
225 }
226 }
227
228 async function loginWithNpub() {
229 isLoading = true;
230 errorMessage = "";
231
232 try {
233 const input = npubInput.trim();
234 if (!input) {
235 throw new Error("Please enter an npub");
236 }
237
238 let pubkey;
239 if (/^[0-9a-f]{64}$/i.test(input)) {
240 pubkey = input.toLowerCase();
241 } else {
242 const decoded = nip19Decode(input);
243 if (decoded.type !== "npub") {
244 throw new Error("Invalid npub — expected an npub1... string or 64-char hex pubkey");
245 }
246 pubkey = decoded.data;
247 }
248
249 localStorage.setItem("nostr_auth_method", "npub");
250 localStorage.setItem("nostr_pubkey", pubkey);
251
252 dispatch("login", {
253 method: "npub",
254 pubkey: pubkey,
255 signer: null,
256 });
257
258 closeModal();
259 } catch (error) {
260 errorMessage = error.message;
261 } finally {
262 isLoading = false;
263 }
264 }
265
266 async function loginWithNsec() {
267 isLoading = true;
268 errorMessage = "";
269 successMessage = "";
270
271 try {
272 if (!nsecInput.trim()) {
273 throw new Error("Please enter your nsec");
274 }
275
276 // Validate nsec format and bech32 checksum
277 if (!isValidNsec(nsecInput.trim())) {
278 throw new Error('Invalid nsec format or checksum');
279 }
280
281 // Validate password if provided
282 if (encryptionPassword) {
283 if (encryptionPassword.length < 8) {
284 throw new Error("Password must be at least 8 characters");
285 }
286 if (encryptionPassword !== confirmPassword) {
287 throw new Error("Passwords do not match");
288 }
289 }
290
291 // Create PrivateKeySigner from nsec
292 const signer = PrivateKeySigner.fromKey(nsecInput.trim());
293
294 // Get the public key from the signer
295 const publicKey = await signer.getPublicKey();
296
297 // Store with encryption if password provided
298 localStorage.setItem("nostr_auth_method", "nsec");
299 localStorage.setItem("nostr_pubkey", publicKey);
300
301 if (encryptionPassword) {
302 // Encrypt the nsec before storing
303 isDeriving = true;
304 startDerivingTimer();
305 const encryptedNsec = await encryptNsec(nsecInput.trim(), encryptionPassword);
306 stopDerivingTimer();
307 isDeriving = false;
308 localStorage.setItem("nostr_privkey_encrypted", encryptedNsec);
309 localStorage.removeItem("nostr_privkey"); // Remove any plaintext key
310 } else {
311 // Store plaintext (less secure)
312 localStorage.setItem("nostr_privkey", nsecInput.trim());
313 localStorage.removeItem("nostr_privkey_encrypted");
314 successMessage = "Successfully logged in with nsec!";
315 }
316
317 dispatch("login", {
318 method: "nsec",
319 pubkey: publicKey,
320 privateKey: nsecInput.trim(),
321 signer: signer,
322 });
323
324 setTimeout(() => {
325 closeModal();
326 }, 1500);
327 } catch (error) {
328 errorMessage = error.message;
329 } finally {
330 isLoading = false;
331 }
332 }
333
334 function handleKeydown(event) {
335 if (event.key === "Escape") {
336 closeModal();
337 }
338 if (event.key === "Enter" && activeTab === "nsec") {
339 loginWithNsec();
340 }
341 if (event.key === "Enter" && activeTab === "npub") {
342 loginWithNpub();
343 }
344 }
345 </script>
346
347 <svelte:window on:keydown={handleKeydown} />
348
349 {#if showModal}
350 <div
351 class="modal-overlay"
352 on:click={closeModal}
353 on:keydown={(e) => e.key === "Escape" && closeModal()}
354 role="button"
355 tabindex="0"
356 >
357 <div
358 class="modal"
359 class:dark-theme={isDarkTheme}
360 on:click|stopPropagation
361 on:keydown|stopPropagation
362 >
363 <div class="modal-header">
364 <h2>Login to Nostr</h2>
365 <button class="close-btn" on:click={closeModal}>×</button>
366 </div>
367
368 <div class="tab-container">
369 <div class="tabs">
370 <button
371 class="tab-btn"
372 class:active={activeTab === "extension"}
373 on:click={() => switchTab("extension")}
374 >
375 Extension
376 </button>
377 <button
378 class="tab-btn"
379 class:active={activeTab === "nsec"}
380 on:click={() => switchTab("nsec")}
381 >
382 Nsec
383 </button>
384 <button
385 class="tab-btn"
386 class:active={activeTab === "npub"}
387 on:click={() => switchTab("npub")}
388 >
389 Read-only
390 </button>
391 </div>
392
393 <div class="tab-content">
394 {#if activeTab === "extension"}
395 <div class="extension-login">
396 <p>
397 Login using a NIP-07 compatible browser
398 extension like nos2x or Alby.
399 </p>
400 <button
401 class="login-extension-btn"
402 on:click={loginWithExtension}
403 disabled={isLoading}
404 >
405 {isLoading
406 ? "Connecting..."
407 : "Log in using extension"}
408 </button>
409 </div>
410 {:else if activeTab === "npub"}
411 <div class="extension-login">
412 <p>
413 Enter an npub to browse in read-only mode.
414 You won't be able to post or sign events.
415 </p>
416 <input
417 type="text"
418 placeholder="npub1... or hex pubkey"
419 bind:value={npubInput}
420 disabled={isLoading}
421 class="nsec-input"
422 />
423 <button
424 class="login-nsec-btn"
425 on:click={loginWithNpub}
426 disabled={isLoading || !npubInput.trim()}
427 >
428 {isLoading ? "Logging in..." : "Browse read-only"}
429 </button>
430 </div>
431 {:else}
432 <div class="nsec-login">
433 {#if hasEncryptedKey}
434 <!-- Unlock existing encrypted key -->
435 <p>
436 You have a stored encrypted key. Enter your
437 password to unlock it.
438 </p>
439
440 {#if storedPubkey}
441 <div class="stored-info">
442 <label>Stored public key:</label>
443 <code class="npub-display">{storedPubkey.slice(0, 16)}...{storedPubkey.slice(-8)}</code>
444 </div>
445 {/if}
446
447 <input
448 type="password"
449 placeholder="Enter your password"
450 bind:value={unlockPassword}
451 disabled={isLoading || isDeriving}
452 class="password-input"
453 />
454
455 <button
456 class="login-nsec-btn"
457 on:click={unlockWithPassword}
458 disabled={isLoading || isDeriving || !unlockPassword}
459 >
460 {#if isDeriving}
461 Deriving key...
462 {:else if isLoading}
463 Unlocking...
464 {:else}
465 Unlock
466 {/if}
467 </button>
468
469 <button
470 class="clear-btn"
471 on:click={clearStoredCredentials}
472 disabled={isLoading || isDeriving}
473 >
474 Clear stored key & start fresh
475 </button>
476 {:else}
477 <!-- Normal nsec entry / generation -->
478 <p>
479 Enter your nsec or generate a new one. Optionally
480 set a password to encrypt it securely.
481 </p>
482
483 <button
484 class="generate-btn"
485 on:click={generateNewKey}
486 disabled={isLoading || isGenerating}
487 >
488 {isGenerating
489 ? "Generating..."
490 : "Generate New Key"}
491 </button>
492
493 {#if generatedNpub}
494 <div class="generated-info">
495 <label>Your new public key (npub):</label>
496 <code class="npub-display">{generatedNpub}</code>
497 </div>
498 {/if}
499
500 <input
501 type="password"
502 placeholder="nsec1..."
503 bind:value={nsecInput}
504 disabled={isLoading || isDeriving}
505 class="nsec-input"
506 />
507
508 <div class="password-section">
509 <label>Encryption Password (optional but recommended):</label>
510 <input
511 type="password"
512 placeholder="Enter password (min 8 chars)"
513 bind:value={encryptionPassword}
514 disabled={isLoading || isDeriving}
515 class="password-input"
516 />
517 {#if encryptionPassword}
518 <input
519 type="password"
520 placeholder="Confirm password"
521 bind:value={confirmPassword}
522 disabled={isLoading || isDeriving}
523 class="password-input"
524 />
525 {/if}
526 <small class="password-hint">
527 Password uses Argon2id with ~3 second derivation time for security.
528 </small>
529 </div>
530
531 <button
532 class="login-nsec-btn"
533 on:click={loginWithNsec}
534 disabled={isLoading || isDeriving || !nsecInput.trim()}
535 >
536 {#if isDeriving}
537 Deriving key...
538 {:else if isLoading}
539 Logging in...
540 {:else}
541 Log in with nsec
542 {/if}
543 </button>
544 {/if}
545 </div>
546 {/if}
547
548 {#if errorMessage}
549 <div class="message error-message">{errorMessage}</div>
550 {/if}
551
552 {#if successMessage}
553 <div class="message success-message">
554 {successMessage}
555 </div>
556 {/if}
557 </div>
558 </div>
559 </div>
560 </div>
561 {/if}
562
563 {#if isDeriving}
564 <div class="deriving-overlay">
565 <div class="deriving-modal" class:dark-theme={isDarkTheme}>
566 <div class="deriving-spinner"></div>
567 <h3>Deriving encryption key</h3>
568 <div class="deriving-timer">{derivingElapsed.toFixed(1)}s</div>
569 <p class="deriving-note">This may take 3-6 seconds for security</p>
570 </div>
571 </div>
572 {/if}
573
574 <style>
575 .modal-overlay {
576 position: fixed;
577 top: 0;
578 left: 0;
579 width: 100%;
580 height: 100%;
581 background-color: rgba(0, 0, 0, 0.5);
582 display: flex;
583 justify-content: center;
584 align-items: center;
585 z-index: 1000;
586 }
587
588 .modal {
589 background: var(--bg-color);
590 border-radius: 8px;
591 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
592 width: 90%;
593 max-width: 500px;
594 max-height: 90vh;
595 overflow-y: auto;
596 border: 1px solid var(--border-color);
597 }
598
599 .modal-header {
600 display: flex;
601 justify-content: space-between;
602 align-items: center;
603 padding: 20px;
604 border-bottom: 1px solid var(--border-color);
605 }
606
607 .modal-header h2 {
608 margin: 0;
609 color: var(--text-color);
610 font-size: 1.5rem;
611 }
612
613 .close-btn {
614 background: none;
615 border: none;
616 font-size: 1.5rem;
617 cursor: pointer;
618 color: var(--text-color);
619 padding: 0;
620 width: 30px;
621 height: 30px;
622 display: flex;
623 align-items: center;
624 justify-content: center;
625 border-radius: 50%;
626 transition: background-color 0.2s;
627 }
628
629 .close-btn:hover {
630 background-color: var(--tab-hover-bg);
631 }
632
633 .tab-container {
634 padding: 20px;
635 }
636
637 .tabs {
638 display: flex;
639 border-bottom: 1px solid var(--border-color);
640 margin-bottom: 20px;
641 }
642
643 .tab-btn {
644 flex: 1;
645 padding: 12px 16px;
646 background: none;
647 border: none;
648 cursor: pointer;
649 color: var(--text-color);
650 font-size: 1rem;
651 transition: all 0.2s;
652 border-bottom: 2px solid transparent;
653 }
654
655 .tab-btn:hover {
656 background-color: var(--tab-hover-bg);
657 }
658
659 .tab-btn.active {
660 border-bottom-color: var(--primary);
661 color: var(--primary);
662 }
663
664 .tab-content {
665 min-height: 200px;
666 }
667
668 .extension-login,
669 .nsec-login {
670 display: flex;
671 flex-direction: column;
672 gap: 16px;
673 }
674
675 .extension-login p,
676 .nsec-login p {
677 margin: 0;
678 color: var(--text-color);
679 line-height: 1.5;
680 }
681
682 .login-extension-btn,
683 .login-nsec-btn {
684 padding: 12px 24px;
685 background: var(--primary);
686 color: var(--text-color);
687 border: none;
688 border-radius: 6px;
689 cursor: pointer;
690 font-size: 1rem;
691 transition: background-color 0.2s;
692 }
693
694 .login-extension-btn:hover:not(:disabled),
695 .login-nsec-btn:hover:not(:disabled) {
696 background: #00acc1;
697 }
698
699 .login-extension-btn:disabled,
700 .login-nsec-btn:disabled {
701 background: #ccc;
702 cursor: not-allowed;
703 }
704
705 .nsec-input {
706 padding: 12px;
707 border: 1px solid var(--input-border);
708 border-radius: 6px;
709 font-size: 1rem;
710 background: var(--bg-color);
711 color: var(--text-color);
712 }
713
714 .nsec-input:focus {
715 outline: none;
716 border-color: var(--primary);
717 }
718
719 .generate-btn {
720 padding: 10px 20px;
721 background: var(--success);
722 color: white;
723 border: none;
724 border-radius: 6px;
725 cursor: pointer;
726 font-size: 0.95rem;
727 transition: background-color 0.2s;
728 }
729
730 .generate-btn:hover:not(:disabled) {
731 background: var(--success);
732 filter: brightness(0.9);
733 }
734
735 .generate-btn:disabled {
736 background: #ccc;
737 cursor: not-allowed;
738 }
739
740 .generated-info {
741 background: var(--card-bg, #f5f5f5);
742 padding: 12px;
743 border-radius: 6px;
744 border: 1px solid var(--border-color);
745 }
746
747 .generated-info label {
748 display: block;
749 font-size: 0.85rem;
750 color: var(--muted-foreground, #666);
751 margin-bottom: 6px;
752 }
753
754 .npub-display {
755 display: block;
756 word-break: break-all;
757 font-size: 0.85rem;
758 background: var(--bg-color);
759 padding: 8px;
760 border-radius: 4px;
761 color: var(--text-color);
762 }
763
764 .password-section {
765 display: flex;
766 flex-direction: column;
767 gap: 8px;
768 }
769
770 .password-section label {
771 font-size: 0.9rem;
772 color: var(--text-color);
773 font-weight: 500;
774 }
775
776 .password-input {
777 padding: 10px 12px;
778 border: 1px solid var(--input-border);
779 border-radius: 6px;
780 font-size: 0.95rem;
781 background: var(--bg-color);
782 color: var(--text-color);
783 }
784
785 .password-input:focus {
786 outline: none;
787 border-color: var(--primary);
788 }
789
790 .password-hint {
791 font-size: 0.8rem;
792 color: var(--muted-foreground, #888);
793 font-style: italic;
794 }
795
796 .stored-info {
797 background: var(--card-bg, #f5f5f5);
798 padding: 12px;
799 border-radius: 6px;
800 border: 1px solid var(--border-color);
801 }
802
803 .stored-info label {
804 display: block;
805 font-size: 0.85rem;
806 color: var(--muted-foreground, #666);
807 margin-bottom: 6px;
808 }
809
810 .clear-btn {
811 padding: 10px 20px;
812 background: transparent;
813 color: var(--error, #dc3545);
814 border: 1px solid var(--error, #dc3545);
815 border-radius: 6px;
816 cursor: pointer;
817 font-size: 0.9rem;
818 transition: all 0.2s;
819 }
820
821 .clear-btn:hover:not(:disabled) {
822 background: var(--error, #dc3545);
823 color: white;
824 }
825
826 .clear-btn:disabled {
827 opacity: 0.5;
828 cursor: not-allowed;
829 }
830
831 .message {
832 padding: 10px;
833 border-radius: 4px;
834 margin-top: 16px;
835 text-align: center;
836 }
837
838 .error-message {
839 background: #ffebee;
840 color: #c62828;
841 border: 1px solid #ffcdd2;
842 }
843
844 .success-message {
845 background: #e8f5e8;
846 color: #2e7d32;
847 border: 1px solid #c8e6c9;
848 }
849
850 .modal.dark-theme .error-message {
851 background: #4a2c2a;
852 color: #ffcdd2;
853 border: 1px solid #6d4c41;
854 }
855
856 .modal.dark-theme .success-message {
857 background: #2e4a2e;
858 color: #a5d6a7;
859 border: 1px solid var(--success);
860 }
861
862 /* Deriving modal overlay */
863 .deriving-overlay {
864 position: fixed;
865 top: 0;
866 left: 0;
867 width: 100%;
868 height: 100%;
869 background-color: rgba(0, 0, 0, 0.7);
870 display: flex;
871 justify-content: center;
872 align-items: center;
873 z-index: 2000;
874 }
875
876 .deriving-modal {
877 background: var(--bg-color, #fff);
878 border-radius: 12px;
879 padding: 2rem;
880 text-align: center;
881 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
882 min-width: 280px;
883 }
884
885 .deriving-modal h3 {
886 margin: 1rem 0 0.5rem;
887 color: var(--text-color, #333);
888 font-size: 1.2rem;
889 }
890
891 .deriving-timer {
892 font-size: 2.5rem;
893 font-weight: bold;
894 color: var(--primary);
895 font-family: monospace;
896 margin: 0.5rem 0;
897 }
898
899 .deriving-note {
900 margin: 0.5rem 0 0;
901 color: var(--muted-foreground, #666);
902 font-size: 0.9rem;
903 }
904
905 .deriving-spinner {
906 width: 48px;
907 height: 48px;
908 border: 4px solid var(--border-color, #e0e0e0);
909 border-top-color: var(--primary);
910 border-radius: 50%;
911 margin: 0 auto;
912 animation: spin 1s linear infinite;
913 }
914
915 @keyframes spin {
916 to {
917 transform: rotate(360deg);
918 }
919 }
920
921 .deriving-modal.dark-theme {
922 background: #1a1a1a;
923 }
924
925 .deriving-modal.dark-theme h3 {
926 color: #fff;
927 }
928
929 .deriving-modal.dark-theme .deriving-note {
930 color: #aaa;
931 }
932 </style>
933