diff options
| author | Adam Soltys <[email protected]> | 2025-01-09 23:44:40 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2025-01-09 23:44:40 -0800 |
| commit | 8588de25f16486b56ef0ad533013bd8cb48ff120 (patch) | |
| tree | 4c1e39704e2edb332c5fe8c58fb0d81c8154af2b /src | |
| parent | 2358d1bf39c34a508db6c50f0f6312ee411a8a17 (diff) | |
| download | coinos-ui-8588de25f16486b56ef0ad533013bd8cb48ff120.tar.gz coinos-ui-8588de25f16486b56ef0ad533013bd8cb48ff120.zip | |
nostr signing
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/AppHeader.svelte | 2 | ||||
| -rw-r--r-- | src/components/Nostr.svelte | 310 | ||||
| -rw-r--r-- | src/lib/nostr.ts | 95 | ||||
| -rw-r--r-- | src/lib/settings.ts | 14 | ||||
| -rw-r--r-- | src/lib/store.ts | 13 | ||||
| -rw-r--r-- | src/lib/utils.ts | 20 | ||||
| -rw-r--r-- | src/locales/en.json | 1 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 6 | ||||
| -rw-r--r-- | src/routes/(app)/[username]/(profile)/+layout.svelte | 22 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[recipient]/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/settings/+layout.svelte | 28 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.server.ts | 6 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.svelte | 114 | ||||
| -rw-r--r-- | src/routes/(static)/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.server.ts | 8 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 76 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 4 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 46 |
18 files changed, 422 insertions, 347 deletions
diff --git a/src/components/AppHeader.svelte b/src/components/AppHeader.svelte index a667085b..7c131ec4 100644 --- a/src/components/AppHeader.svelte +++ b/src/components/AppHeader.svelte @@ -61,7 +61,7 @@ {#if !$loading} <header - class="bg-gradient-to-r bg-primary bg-accent h-[175px] w-full relative bg-cover mb-20" + class="bg-gradient-to-r bg-primary bg-accent h-[175px] w-full relative bg-cover mb-20 !z-30" style:background-image={bg} > <nav class="flex justify-end items-center space-x-4 p-5"> diff --git a/src/components/Nostr.svelte b/src/components/Nostr.svelte index 0765f440..a4f20d23 100644 --- a/src/components/Nostr.svelte +++ b/src/components/Nostr.svelte @@ -1,264 +1,212 @@ <script lang="ts"> - import { applyAction, deserialize } from "$app/forms"; import { onMount } from "svelte"; import { browser } from "$app/environment"; import { t } from "$lib/translations"; import PasswordInput from "$comp/PasswordInput.svelte"; import { Relay } from "nostr-tools/relay"; - import { encrypt, decrypt, getConversationKey } from "nostr-tools/nip44"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { nip04, nip19, getEventHash, - finalizeEvent, generateSecretKey, getPublicKey, } from "nostr-tools"; import { AmberClipboardSigner } from "applesauce-signer"; import { copy, focus, fail, post } from "$lib/utils"; - import { password as pw, passwordPrompt } from "$lib/store"; - // import { PUBLIC_COINOS_RELAY as relayUrl } from "$env/static/public"; - const relayUrl = "wss://relay.nsec.app"; + import { eventToSign, signer } from "$lib/store"; + import { nostrConnectRelay } from "$lib/nostr"; + import Success from "$comp/Success.svelte"; - let { id, redirect, token, nostrSignin = $bindable() } = $props(); - let extension = $state(); - - onMount(() => { - if (browser && window.nostr) extension = true; - }); + let { id } = $props(); + let extensionAvailable = $derived(browser && window.nostr); let cancel = () => { - nostrSignin = false; + eventToSign.set(); showNsec = false; pubkey = undefined; connectUrl = undefined; }; - let sk = $state(); - let ck = $state(); - let pk = $state(); - let pubkey = $state(); - - let signer = $derived( + let signerAvailable = $derived( browser && navigator.userAgent.includes("Android") && navigator.clipboard && navigator.clipboard.readText, ); + let hadSigner = $state(!!$signer); + let connectUrl = $state(); let nostrConnect = async () => { - let secret = crypto.randomUUID(); - - sk = generateSecretKey(); - pubkey = getPublicKey(sk); + let connectionSecret = crypto.randomUUID(); - connectUrl = `nostrconnect://${pubkey}?relay=${encodeURIComponent(relayUrl)}&perms=sign_event%3A1&name=Coinos&secret=${secret}`; + let sk = generateSecretKey(); + let pubkey = getPublicKey(sk); - let relay = await Relay.connect(relayUrl); + connectUrl = `nostrconnect://${pubkey}?relay=${encodeURIComponent(nostrConnectRelay)}&perms=sign_event%3A1&name=Coinos&secret=${connectionSecret}`; - let signatureRequested; - const sub = relay.subscribe([{ kinds: [24133], "#p": [pubkey] }], { + let relay = await Relay.connect(nostrConnectRelay); + relay.subscribe([{ kinds: [24133], "#p": [pubkey] }], { async onevent(event) { - pk = event.pubkey; - ck = getConversationKey(sk, pk); - let decrypted; - console.log("EVENT", event); + let pk = event.pubkey; try { - decrypted = JSON.parse(decrypt(event.content, ck)); - } catch (e) { - try { - decrypted = JSON.parse(await nip04.decrypt(sk, pk, event.content)); - } catch (e) { - console.log("ERROR", e); - } - } - - if (decrypted?.result?.includes("sig")) { - await nostrLogin(JSON.parse(decrypted.result)); - } else if (!signatureRequested) { - signatureRequested = true; - let signEvent = { - id: crypto.randomUUID(), - method: "sign_event", - params: [JSON.stringify({ ...ev, pubkey })], - }; - - // let content = encrypt(JSON.stringify(signEvent), ck); - let content = await nip04.encrypt(sk, pk, JSON.stringify(signEvent)); - console.log("CONTENT", content); - let signedSignEvent = finalizeEvent( - { - created_at: Math.round(Date.now() / 1000), - kind: 24133, - pubkey, - content, - tags: [["p", pk]], - }, - sk, + let { result } = JSON.parse( + await nip04.decrypt(sk, pk, event.content), ); - - await new Promise((r) => setTimeout(r, 1000)); - console.log("PUBLISHING", signedSignEvent); - relay.publish(signedSignEvent); + if (result === connectionSecret) { + $signer = { + method: "connect", + params: { + sk: bytesToHex(sk), + pk, + pubkey, + }, + ready: true, + }; + } + } catch (e) { + console.log("failed to parse nostr connect event", e); } }, }); }; - let ev = $derived({ - kind: 1, - created_at: Date.now(), - content: id, - tags: [], - }); - let nsec = $state(); let showNsec = $state(); - let toggleNsec = () => (showNsec = !showNsec); + let toggleNsec = () => { + $signer = { + method: "nsec", + }; + }; + let nsecSign = async () => { let sk; if (nsec.startsWith("nsec")) sk = nip19.decode(nsec).data as Uint8Array; else sk = hexToBytes(nsec); - nostrLogin(finalizeEvent(ev, sk)); + $signer = { + method: "nsec", + params: { sk: bytesToHex(sk), pk: getPublicKey(sk) }, + ready: true, + }; }; let extensionSign = async () => { - nostrLogin(await window.nostr.signEvent(ev)); + $signer = { method: "extension", ready: true }; }; let signerGetPubkey = async () => { - const signer = new AmberClipboardSigner(); - pubkey = await signer.getPublicKey(); - ev.pubkey = pubkey; - console.log("PUBKEY", ev.pubkey); + const pubkey = await new AmberClipboardSigner().getPublicKey(); + $signer = { + method: "signer", + params: { pubkey }, + }; }; - let signerSigning; + let signerSign = async () => { - signerSigning = true; document.addEventListener("focus", signerSigned, true); }; const signerSigned = async () => { - document.removeEventListener("focus", signerSigned, true); - await new Promise((resolve) => setTimeout(resolve, 500)); - ev.sig = await navigator.clipboard.readText(); - ev.id = getEventHash(ev); - nostrLogin(ev); + await new Promise((r) => setTimeout(r, 100)); + let sig = await navigator.clipboard.readText(); + $signer.params.sig = sig; + $signer.ready = true; + $signer = $signer; }; - let nostrLogin = async (signedEvent) => { - // let pubkey = await window.nostr.getPublicKey(); - const formData = new FormData(); - - try { - formData.append("loginRedirect", redirect); - formData.append("token", token); - formData.append("event", JSON.stringify(signedEvent)); - formData.append("id", id); - - let response = await fetch("/login?/nostr", { - method: "POST", - body: formData, - }); - - const result = deserialize(await response.text()); - - if (result.type === "success") { - await invalidateAll(); - } - - applyAction(result); - } catch (e) { - fail(e.message); - } - }; + let signUrl = $derived( + `nostrsigner:${encodeURIComponent(JSON.stringify($eventToSign))}?compressionType=none&returnType=signature&type=sign_event&appName=Coinos`, + ); </script> -hi -{nostrSignin} -{#if nostrSignin} +{#if $eventToSign && !$signer?.ready} <div class="fixed inset-0 bg-base-100 bg-opacity-90 overflow-y-auto h-full w-full z-50" > <div class="relative mx-auto mt-10 p-5 border w-96 shadow-lg rounded-md bg-base-100 space-y-5" > - <h1 class="text-center text-2xl font-semibold"> - {$t("login.nostr")} - </h1> + <h1 class="text-center text-2xl font-semibold">Nostr sign</h1> - {#if showNsec} - <input bind:value={nsec} placeholder="nsec..." /> - <button type="button" class="btn" onclick={nsecSign}> - <iconify-icon icon="ph:key-bold" width="32"></iconify-icon> - <div class="my-auto">Login</div> - </button> - {:else if connectUrl} - <a href={connectUrl}> - <img - src={`/qr/${encodeURIComponent(connectUrl)}/raw`} - class="z-10 border-4 border-white" - alt="QR" - /> - <div class="break-all"> - {connectUrl} - </div> - </a> - <button class="btn" onclick={() => copy(connectUrl)}>Copy</button> - {:else if signerSigning && pubkey} - <a - href={`nostrsigner:${encodeURIComponent(JSON.stringify(ev))}?compressionType=none&returnType=signature&type=sign_event&appName=Coinos`} - type="button" - class="btn" - onclick={signerSign} - > - <iconify-icon - icon="material-symbols:diamond-rounded" - width="32" - class="text-orange-400" - ></iconify-icon> - <div class="my-auto">NIP-55 Signer Step 2</div> - </a> + {#if $eventToSign.sig && !hadSigner} + <Success /> {:else} - {#if extension} - <button type="button" class="btn" onclick={extensionSign}> - <iconify-icon - icon="lucide-lab:bee" - width="32" - class="text-yellow-400" - ></iconify-icon> - <div class="my-auto">NIP-10 Extension</div> - </button> - {/if} - <button type="button" class="btn" onclick={nostrConnect}> - <img src="/images/nostr.png" class="w-8" /> - <div class="my-auto">NIP-46 Connect</div> - </button> - {#if signer} - <a type="button" class="btn" onclick={signerGetPubkey}> - <iconify-icon - icon="material-symbols:diamond-rounded" - width="32" - class="text-orange-400" - ></iconify-icon> - <div class="my-auto">NIP-55 Signer</div> + {#if connectUrl} + <a href={connectUrl}> + <img + src={`/qr/${encodeURIComponent(connectUrl)}/raw`} + class="z-10 border-4 border-white" + alt="QR" + /> + <div class="break-all"> + {connectUrl} + </div> </a> + <button class="btn" onclick={() => copy(connectUrl)}>Copy</button> + {:else} + {#if $signer?.method === "nsec"} + <input bind:value={nsec} placeholder="nsec..." /> + <button type="button" class="btn btn-accent" onclick={nsecSign}> + <iconify-icon icon="ph:paper-plane-right-bold" width="32" + ></iconify-icon> + <div class="my-auto">Submit</div> + </button> + {/if} + + {#if $signer?.method === "signer"} + <a href={signUrl} type="button" class="btn" onclick={signerSign}> + <iconify-icon + icon="material-symbols:diamond-rounded" + width="32" + class="text-orange-400" + ></iconify-icon> + <div class="my-auto">NIP-55 Signer</div> + </a> + {/if} + + {#if !$signer} + {#if extensionAvailable} + <button type="button" class="btn" onclick={extensionSign}> + <iconify-icon + icon="lucide-lab:bee" + width="32" + class="text-yellow-400" + ></iconify-icon> + <div class="my-auto">Browser extension</div> + </button> + {/if} + + <button type="button" class="btn" onclick={nostrConnect}> + <img src="/images/nostr.png" class="w-8" alt="Nostr" /> + <div class="my-auto">Nostr connect</div> + </button> + + {#if signerAvailable} + <button type="button" class="btn" onclick={signerGetPubkey}> + <iconify-icon + icon="material-symbols:diamond-rounded" + width="32" + class="text-orange-400" + ></iconify-icon> + <div class="my-auto">NIP-55 Signer</div> + </button> + {/if} + + <button type="button" class="btn" onclick={toggleNsec}> + <iconify-icon icon="ph:key-bold" width="32"></iconify-icon> + <div class="my-auto">Secret key</div> + </button> + {/if} {/if} - <button type="button" class="btn" onclick={toggleNsec}> - <iconify-icon icon="ph:key-bold" width="32"></iconify-icon> - <div class="my-auto">Nsec Login</div> + <button type="button" class="btn" onclick={cancel}> + <iconify-icon icon="ph:x-bold" width="32"></iconify-icon> + <div class="my-auto">Cancel</div> </button> {/if} - - <button type="button" class="btn" onclick={cancel}> - <iconify-icon icon="ph:x-bold" width="32"></iconify-icon> - <div class="my-auto">Cancel</div> - </button> </div> </div> {/if} diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts index 77c1d60a..85f1bdaa 100644 --- a/src/lib/nostr.ts +++ b/src/lib/nostr.ts @@ -2,18 +2,21 @@ import { Buffer } from "buffer"; import { browser } from "$app/environment"; import { decrypted, + eventToSign, password as pw, passwordPrompt, - signaturePrompt, + signer as $signer, } from "$lib/store"; import { post, wait } from "$lib/utils"; import { type EventTemplate, finalizeEvent, + getEventHash, getPublicKey, nip04, nip19, } from "nostr-tools"; +import { Relay } from "nostr-tools/relay"; import { get } from "svelte/store"; import { @@ -21,8 +24,6 @@ import { encrypt as nip49encrypt, } from "nostr-tools/nip49"; -import { bech32m } from "@scure/base"; - type User = { [key: string]: any; }; @@ -101,19 +102,17 @@ export const encryptNsec = async (nsec: string) => { return nip49encrypt(d, await getPassword()); }; -type SignParams = { - event: EventTemplate; - user: User; -}; - -export const sign = async ({ event, user }: SignParams) => { - let sk; - if (user.nsec) { - sk = await getPrivateKey(user); - event = finalizeEvent(event, sk); - } else { - signaturePrompt.set(event); - } +export const sign = async (event) => { + eventToSign.set(event); + let unsubscribe; + const { method, params } = await new Promise((r) => { + unsubscribe = $signer.subscribe((v) => v?.ready && r(v)); + }); + await unsubscribe(); + const signedEvent = await signingMethods[method](event, params); + eventToSign.set(signedEvent); + setTimeout(() => eventToSign.set(null), 1000); + return signedEvent; }; export const send = (event: EventTemplate) => { @@ -125,3 +124,67 @@ const getPassword = async (): Promise<string> => { await wait(() => !!get(pw)); return get(pw) || ""; }; + +export const nostrConnectRelay = "wss://relay.nsec.app"; + +const signingMethods = { + async connect(event, { sk, pk, pubkey }) { + const id = crypto.randomUUID(); + const signEvent = { + id, + method: "sign_event", + params: [JSON.stringify(event)], + }; + + const content = await nip04.encrypt(sk, pk, JSON.stringify(signEvent)); + + const signedSignEvent = finalizeEvent( + { + created_at: Math.round(Date.now() / 1000), + kind: 24133, + pubkey, + content, + tags: [["p", pk]], + }, + sk, + ); + + const relay = await Relay.connect(nostrConnectRelay); + relay.publish(signedSignEvent); + + return new Promise((resolve) => { + relay.subscribe([{ kinds: [24133], "#p": [pubkey] }], { + async onevent(event) { + pk = event.pubkey; + try { + const response = JSON.parse( + await nip04.decrypt(sk, pk, event.content), + ); + + if (response.id === id) resolve(JSON.parse(response.result)); + } catch (e) { + console.log("failed to parse nostr connect event", e); + } + }, + }); + }); + }, + + async nsec(event, { sk }) { + return finalizeEvent(event, sk); + }, + + async extension(event) { + return window.nostr.signEvent(event); + }, + + async signer(event, { pubkey, sig }) { + event.pubkey = pubkey; + const signedEvent = { + ...event, + id: getEventHash(event), + sig, + }; + return signedEvent; + }, +}; diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 3ce1edd5..53a25e18 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,4 +1,4 @@ -import { auth, fd, get, post } from "$lib/utils"; +import { auth, fd, post } from "$lib/utils"; import { fail } from "@sveltejs/kit"; type Error = { @@ -16,16 +16,14 @@ export default async ({ cookies, request }) => { form.autowithdraw = form.autowithdraw === "on"; } - let user = { ...(await get("/me", auth(cookies))), ...form }; - try { - ({ user } = await post("/user", user, auth(cookies))); + const { user } = await post("/user", form, auth(cookies)); + + if (user.language) cookies.set("lang", user.language, { path: "/" }); + + return { user, success: true }; } catch (e) { const { message } = e as Error; return fail(400, { message }); } - - if (user.language) cookies.set("lang", user.language, { path: "/" }); - - return { user, success: true }; }; diff --git a/src/lib/store.ts b/src/lib/store.ts index 0aba3d46..23b38ace 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -28,7 +28,13 @@ const persistLocal = (key, defaultValue: any = undefined) => { s.subscribe((v) => { try { - browser && localStorage.setItem(key, JSON.stringify(v)); + if (browser) { + if (v) { + localStorage.setItem(key, JSON.stringify(v)); + } else { + localStorage.removeItem(key); + } + } } catch (e) { console.log("problem setting key", v); console.log(e); @@ -54,7 +60,10 @@ export const mnemonic = persistSession("mnemonic"); export const newPayment = persistLocal("newPayment"); export const password = writable<string | undefined>(); export const passwordPrompt = writable(); -export const signaturePrompt = writable(); +export const eventToSign = writable(); +export const pubkey = persistLocal("pubkey"); +export const signer = persistLocal("signer"); +export const selectSigner = writable(); export const pin = persistLocal("pin"); export const rate = writable(); export const request = writable(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b21ce176..e39c2adc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -338,26 +338,6 @@ export const wait = async ( return f(); }; -export const stretch = async (password: string, salt: Uint8Array) => - crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt, - iterations: 100000, - hash: "SHA-256", - }, - await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(password), - { name: "PBKDF2" }, - false, - ["deriveBits", "deriveKey"], - ), - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"], - ); - export const types = { bitcoin: "bitcoin", liquid: "liquid", diff --git a/src/locales/en.json b/src/locales/en.json index 848b467d..b07b47a4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -253,6 +253,7 @@ }, "settings": { "integrations": "Integrations", + "syncWithExtension": "Sync with extension", "connectNostr": "Nostr Wallet Connect", "pleaseEnterYourPassword": "Please enter your password", "memoPrompt": "Checkout instructions", diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e179005f..d037e5be 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -8,7 +8,7 @@ request, ndef, passwordPrompt, - signaturePrompt, + selectSigner, password, pin, theme as themeStore, @@ -91,8 +91,8 @@ <Password {user} /> {/if} -{#if browser && $signaturePrompt} - <Nostr event={$signaturePrompt} /> +{#if browser} + <Nostr /> {/if} <svelte:head> diff --git a/src/routes/(app)/[username]/(profile)/+layout.svelte b/src/routes/(app)/[username]/(profile)/+layout.svelte index a83ed8d0..a5f336e7 100644 --- a/src/routes/(app)/[username]/(profile)/+layout.svelte +++ b/src/routes/(app)/[username]/(profile)/+layout.svelte @@ -77,7 +77,7 @@ }; try { - await sign({ event, user }); + await sign(event); await send(event); } catch (e) { console.log(e); @@ -205,7 +205,8 @@ )}`} class="my-auto" > - <iconify-icon noobserver icon="ph:qr-code-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:qr-code-bold" width="32" + ></iconify-icon> </a> </div> </div> @@ -222,7 +223,8 @@ ></iconify-icon></button > <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> - <iconify-icon noobserver icon="ph:qr-code-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:qr-code-bold" width="32" + ></iconify-icon> </a> </div> </div> @@ -234,12 +236,14 @@ {#if user && user.username !== subject.username && subject.pubkey} {#if following} <button class="btn" onclick={unfollow}> - <iconify-icon noobserver icon="ph:user-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:user-bold" width="32" + ></iconify-icon> <div class="my-auto">{$t("user.unfollow")}</div> </button> {:else} <button class="btn" onclick={follow}> - <iconify-icon noobserver icon="ph:user-bold" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:user-bold" width="32" + ></iconify-icon> <div class="my-auto">{$t("user.follow")}</div> </button> {/if} @@ -266,7 +270,8 @@ class="rounded-2xl border py-5 px-6 font-bold hover:opacity-80 flex w-60" > <div class="mx-auto flex"> - <iconify-icon noobserver icon="ph:clock" width="32"></iconify-icon> + <iconify-icon noobserver icon="ph:clock" width="32" + ></iconify-icon> </div> </button> </form> @@ -296,7 +301,10 @@ </div> <div class="text-secondary flex ml-auto"> <div class="flex items-center"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300" + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" ></iconify-icon> {s((1 * sats) / rate)} = {f(1, currency, locale, 0, 0)} diff --git a/src/routes/(app)/messages/[recipient]/+page.svelte b/src/routes/(app)/messages/[recipient]/+page.svelte index bc1a6383..d1cf6f94 100644 --- a/src/routes/(app)/messages/[recipient]/+page.svelte +++ b/src/routes/(app)/messages/[recipient]/+page.svelte @@ -91,7 +91,7 @@ user, }); event.id = getEventHash(event); - await sign({ event, user }); + await sign(event); await send(event); sent = true; diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index 85e5b0e6..c6cc81c4 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -9,7 +9,7 @@ import Pin from "$comp/Pin.svelte"; import { loading, t } from "$lib/translations"; import { fd, fail, auth, post, sleep, warning, success } from "$lib/utils"; - import { avatar, banner, password, pin, save } from "$lib/store"; + import { avatar, banner, signer, password, pin, save } from "$lib/store"; import { upload } from "$lib/upload"; import { page } from "$app/stores"; import { sign, send, setNsec } from "$lib/nostr"; @@ -53,10 +53,21 @@ }, }); - if (data.get("newNsec")) { - await setNsec(user, data.get("newNsec")); - data.set("nsec", user.nsec); - data.set("pubkey", user.pubkey); + if (!user.pubkey || user.pubkey === prev.pubkey) { + data.delete("pubkey"); + } else { + signer.set( + user.extension ? { method: "extension", ready: true } : null, + ); + let event = { + kind: 1, + created_at: Date.now(), + content: user.challenge, + tags: [], + }; + + let signedEvent = await sign(event); + data.set("event", JSON.stringify(signedEvent)); } if ($avatar) { @@ -89,7 +100,7 @@ } if ( - ["username", "about", "picture"].some( + ["username", "about", "picture", "display", "banner"].some( (a) => user[a] && user[a] !== prev[a], ) ) { @@ -101,14 +112,17 @@ name: user.username, about: user.about, picture: user.picture, + banner: user.banner, + displayName: user.display, }), tags: [], }; try { - await sign({ event, user }); + await sign(event); await send(event); } catch (e) { + console.log(e); warning("Nostr profile could not be updated"); } } diff --git a/src/routes/(app)/settings/nostr/+page.server.ts b/src/routes/(app)/settings/nostr/+page.server.ts index 4145935e..b4bb124b 100644 --- a/src/routes/(app)/settings/nostr/+page.server.ts +++ b/src/routes/(app)/settings/nostr/+page.server.ts @@ -1,5 +1,11 @@ +import { get } from "$lib/utils"; import updateUser from "$lib/settings"; +export const load = async () => { + const { challenge } = await get("/challenge"); + return { challenge }; +}; + export const actions = { default: updateUser, }; diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte index 3757f96f..cfcfaf39 100644 --- a/src/routes/(app)/settings/nostr/+page.svelte +++ b/src/routes/(app)/settings/nostr/+page.svelte @@ -3,35 +3,29 @@ import { page } from "$app/stores"; import { tick } from "svelte"; import { t } from "$lib/translations"; - import { copy } from "$lib/utils"; + import { copy, fail } from "$lib/utils"; import { PUBLIC_COINOS_PUBKEY as pk, PUBLIC_COINOS_RELAY as relay, } from "$env/static/public"; + import { signer, save } from "$lib/store"; let { data } = $props(); - let { user } = $derived(data); - let { id, npub } = $derived(user); + let { challenge, user } = $derived(data); + let { npub } = $state(user); - let importing = $state(), - newNsec = $state(), + let newNsec = $state(), nsec = $state(), pin = $state(""), revealNsec = $state(), revealNwc = $state(); - let toggleImporting = () => { - revealNsec = false; - importing = !importing; - }; - let toggleNsec = async () => { try { nsec = await getNsec(user); revealNsec = !revealNsec; - importing = false; } catch (e) { - console.log(e); + fail("Failed to decrypt nsec"); } }; @@ -42,9 +36,19 @@ )}&secret=${user.nwc}&lud16=${lud16}`, ); let toggleNwc = () => (revealNwc = !revealNwc); + + let extension = $state(); + let getPubkey = async () => { + extension = true; + npub = await window.nostr.getPublicKey(); + await tick(); + $save.click(); + }; </script> <div> + <input type="hidden" name="challenge" value={challenge} /> + <input type="hidden" name="extension" value={extension} /> <label for="nwc" class="font-bold">{$t("user.settings.nwc")}</label> <p class="text-secondary mb-1"> @@ -83,12 +87,10 @@ </div> {/if} -<div> +<div class="space-y-2"> <div class="font-bold">{$t("user.nostrPubkey")}</div> <div class="flex gap-4"> - <div class="break-all grow"> - {npub} - </div> + <textarea name="pubkey" bind:value={npub}> </textarea> <div class="flex my-auto gap-1"> <button type="button" class="my-auto" onclick={() => copy(npub)} ><iconify-icon noobserver icon="ph:copy-bold" width="32" @@ -100,54 +102,48 @@ </a> </div> </div> -</div> -<div class="space-y-2"> - <label for="seedphrase" class="font-bold" - >{$t("user.settings.nostrKeys")}</label + <button class="btn" type="button" onclick={getPubkey}> + <iconify-icon icon="lucide-lab:bee" width="32" class="text-yellow-400" + ></iconify-icon> + {$t("user.settings.syncWithExtension")}</button > +</div> - <p class="text-secondary mb-1"> - {$t("user.settings.nostrDescription")} - </p> - - <div class="flex flex-wrap sm:flex-nowrap gap-2"> - <button - type="button" - class="btn !w-auto flex-grow" - onclick={toggleImporting} +{#if user.nsec} + <div class="space-y-2"> + <label for="seedphrase" class="font-bold" + >{$t("user.settings.nostrKeys")}</label > - <iconify-icon noobserver icon="ph:arrow-down-left-bold" width="32" - ></iconify-icon> - {$t("user.settings.import")} - </button> - <button type="button" class="btn !w-auto flex-grow" onclick={toggleNsec}> - {#if revealNsec} - <iconify-icon noobserver icon="ph:eye-slash-bold" width="32" - ></iconify-icon> - {$t("user.settings.hideNsec")} - {:else} - <iconify-icon noobserver icon="ph:warning-bold" width="32" - ></iconify-icon> - {$t("user.settings.revealNsec")} - {/if} - </button> - </div> + <p class="text-secondary mb-1"> + {$t("user.settings.nostrDescription")} + </p> - {#if importing} - <input name="newNsec" bind:value={newNsec} placeholder="nsec..." /> - {/if} + <div class="flex flex-wrap sm:flex-nowrap gap-2"> + <button type="button" class="btn !w-auto flex-grow" onclick={toggleNsec}> + {#if revealNsec} + <iconify-icon noobserver icon="ph:eye-slash-bold" width="32" + ></iconify-icon> + {$t("user.settings.hideNsec")} + {:else} + <iconify-icon noobserver icon="ph:warning-bold" width="32" + ></iconify-icon> + {$t("user.settings.revealNsec")} + {/if} + </button> + </div> - {#if revealNsec} - <button - type="button" - class="btn break-all !h-auto font-normal leading-normal flex-nowrap" - onclick={() => copy(nsec)} - > - <div>{nsec}</div> + {#if revealNsec} + <button + type="button" + class="btn break-all !h-auto font-normal leading-normal flex-nowrap" + onclick={() => copy(nsec)} + > + <div>{nsec}</div> - <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> - </button> - {/if} -</div> + <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> + </button> + {/if} + </div> +{/if} diff --git a/src/routes/(static)/+layout.svelte b/src/routes/(static)/+layout.svelte index 44310c97..a0061a19 100644 --- a/src/routes/(static)/+layout.svelte +++ b/src/routes/(static)/+layout.svelte @@ -1,6 +1,7 @@ <script> import { run } from 'svelte/legacy'; + import Nostr from "$comp/Nostr.svelte"; import { SvelteToast } from "@zerodevx/svelte-toast"; import Footer from "$comp/Footer.svelte"; import LandingHeader from "$comp/LandingHeader.svelte"; @@ -16,6 +17,7 @@ <SvelteToast options={{ reversed: true, intro: { y: 192 } }} /> <LandingHeader {user} /> +<Nostr /> <main class="container flex px-4 md:px-40 mx-auto min-h-[600px]" data-theme={theme} diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index 6bf06ad2..f86f62e9 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -5,8 +5,8 @@ import { fail, redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); if (user?.pubkey) redirect(307, `/${user.username}`); - const { id } = await get("/challenge"); - return { id }; + const { challenge } = await get("/challenge"); + return { challenge }; }; export const actions = { @@ -29,7 +29,7 @@ export const actions = { nostr: async ({ cookies, fetch, request }) => { const form = await fd(request); - let { id, event, loginRedirect } = form; + let { challenge, event, loginRedirect } = form; if (loginRedirect === "null") loginRedirect = undefined; event = JSON.parse(event); @@ -37,7 +37,7 @@ export const actions = { const res = await fetch(`${PUBLIC_COINOS_URL}/nostrLogin`, { method: "POST", - body: JSON.stringify({ id, event }), + body: JSON.stringify({ challenge, event }), headers: { "content-type": "application/json", accept: "application/json", diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 7e8234ee..e4dcbf15 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -1,39 +1,42 @@ <script> import { browser } from "$app/environment"; import handler from "$lib/handler"; - - import { onMount } from "svelte"; import { applyAction, deserialize } from "$app/forms"; - import { tick } from "svelte"; + import { onMount, tick } from "svelte"; import { fly } from "svelte/transition"; import { enhance } from "$app/forms"; - import Nostr from "$comp/Nostr.svelte"; import Pinpad from "$comp/Pinpad.svelte"; import PasswordInput from "$comp/PasswordInput.svelte"; + import Spinner from "$comp/Spinner.svelte"; import { focus, fail } from "$lib/utils"; - import { password, pin, loginRedirect } from "$lib/store"; + import { password, signer, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; + import { sign } from "$lib/nostr"; import { invalidateAll } from "$app/navigation"; let { data, form } = $props(); - onMount(() => { - $pin = undefined; - localStorage.clear(); - sessionStorage.clear(); + page.subscribe(async () => { + if (browser) { + setTimeout(() => { + password.set(undefined); + pin?.set(undefined); + signer.set(undefined); + localStorage.clear(); + sessionStorage.clear(); + }, 500); + } + }); + onMount(() => { let lang = $page.url.searchParams.get("lang"); if (lang) document.cookie = `lang=${lang} ;`; }); - let { id } = $derived(data); - let nostrSignin = $state(); - + let { challenge } = $derived(data); let token = $state(""); - $password = undefined; - let cancel = () => (need2fa = false); let username = $state(), @@ -81,13 +84,43 @@ let redirect = $derived( $loginRedirect || $page.url.searchParams.get("redirect"), ); + + let nostrLogin = async () => { + let event = { + kind: 1, + created_at: Date.now(), + content: challenge, + tags: [], + }; + + let signedEvent = await sign(event); + + const formData = new FormData(); + + try { + formData.append("loginRedirect", redirect); + formData.append("token", token); + formData.append("event", JSON.stringify(signedEvent)); + formData.append("challenge", challenge); + + let response = await fetch("/login?/nostr", { + method: "POST", + body: formData, + }); + + const result = deserialize(await response.text()); + + applyAction(result); + } catch (e) { + fail(e.message); + } + }; </script> <div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20" > <h1 class="text-2xl font-bold text-center">{$t("login.signIn")}</h1> - {#if form?.error && !form?.message.includes("2fa")} <div class="text-red-600 text-center" in:fly> {form.error} @@ -126,10 +159,17 @@ {$t("login.signIn")} </button> - <button type="button" class="btn" onclick={() => (nostrSignin = true)}> - <img src="/images/nostr.png" class="w-8" /> + <button type="button" class="btn" onclick={nostrLogin}> + {#if $signer?.ready} + <div class="shrink"> + <Spinner /> + </div> + {:else} + <img src="/images/nostr.png" class="w-8" /> + {/if} <div class="my-auto">{$t("login.nostr")}</div> </button> + <p class="text-secondary text-center font-medium"> {$t("login.noAccount")} <a @@ -163,5 +203,3 @@ </div> </div> {/if} - -<Nostr {id} bind:nostrSignin {redirect} {token} /> diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index bf908c54..2e5c5f19 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -6,9 +6,9 @@ export const load = async ({ parent }) => { if (user) redirect(307, `/${user.username}`); const index = Math.floor(Math.random() * 64) + 1; - const { id } = await get("/challenge"); + const { challenge } = await get("/challenge"); - return { index, id }; + return { index, challenge }; }; export const actions = { diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 77394e1a..e7819f4f 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -8,13 +8,12 @@ import { fly } from "svelte/transition"; import { enhance } from "$app/forms"; - import Nostr from "$comp/Nostr.svelte"; import Pin from "$comp/Pin.svelte"; import Spinner from "$comp/Spinner.svelte"; import PasswordInput from "$comp/PasswordInput.svelte"; import { focus, fail } from "$lib/utils"; - import { avatar, password, loginRedirect } from "$lib/store"; + import { avatar, signer, password, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; import { @@ -24,11 +23,22 @@ adjectives, animals, } from "unique-names-generator"; + import { sign } from "$lib/nostr"; let { form, data } = $props(); - let { id } = $derived(data); + let { challenge } = $derived(data); - let nostrSignin = $state(); + page.subscribe(async () => { + if (browser) { + setTimeout(() => { + password.set(undefined); + pin.set(undefined); + signer.set(undefined); + localStorage.clear(); + sessionStorage.clear(); + }, 500); + } + }); let username = $state(); let { index } = $state(data); @@ -161,24 +171,22 @@ }); let nostrLogin = async () => { - let pubkey = await window.nostr.getPublicKey(); - const formData = new FormData(); - - let ev = { + let event = { kind: 1, - pubkey, created_at: Date.now(), - content: id, + content: challenge, tags: [], }; - try { - let signedEvent = await window.nostr.signEvent(ev); + let signedEvent = await sign(event); + const formData = new FormData(); + + try { formData.append("loginRedirect", redirect); formData.append("token", token); formData.append("event", JSON.stringify(signedEvent)); - formData.append("id", id); + formData.append("challenge", challenge); let response = await fetch("/login?/nostr", { method: "POST", @@ -309,8 +317,14 @@ {/if} </button> - <button type="button" class="btn" onclick={() => (nostrSignin = true)}> - <img src="/images/nostr.png" class="w-8" /> + <button type="button" class="btn" onclick={nostrLogin}> + {#if $signer?.ready} + <div class="shrink"> + <Spinner /> + </div> + {:else} + <img src="/images/nostr.png" class="w-8" /> + {/if} <div class="my-auto">{$t("login.nostr")}</div> </button> @@ -325,5 +339,3 @@ </p> </form> </div> - -<Nostr {id} {nostrSignin} {redirect} {token} /> |
