Add pomade key recovery

This commit is contained in:
Jon Staab
2026-01-12 15:21:58 -08:00
parent fe30aa4af2
commit cdee6ca743
9 changed files with 330 additions and 164 deletions
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import {nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/util"
import type {Profile} from "@welshman/util"
import {preventDefault, downloadText} from "@lib/html"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import ArrowDown from "@assets/icons/arrow-down.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SignUpComplete from "@app/components/SignUpComplete.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {PLATFORM_NAME} from "@app/core/state"
type Props = {
next: () => unknown
submitText?: string
}
const {next, submitText = "Continue"}: Props = $props()
const secret = makeSecret()
const back = () => history.back()
const cleanupCopy = (copy: string) =>
copy
.replace(/\n\s*\n\s*/g, "NEWLINE")
.replace(/\s+/g, " ")
.replace(/NEWLINE/g, "\n\n")
.trim()
const downloadKey = () => {
const sharedCopy = `
Most online services keep track of users by giving them a username and password. This gives the
service total control over their users, allowing them to ban them at any time, or sell their activity.
On Nostr, you control your own identity and social data, through the magic of cryptography. The basic
idea is that you have a public key, which acts as your user ID, and a private key which allows you to
prove your identity.
It's very important to keep your private key secret because it grants permanent and complete access to your
account.
`
if (usePassword) {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Your password must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
const instructions = `
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using
a password you chose when you signed up.
${sharedCopy}
Your encrypted private key is:
${ncryptsec}
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
place to look), and import your key.
`
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
} else {
const nsec = nsecEncode(hexToBytes(secret))
const instructions = `
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME}.
${sharedCopy}
Your private key is:
${nsec}
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
place to look), and import your key.
`
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
}
didDownload = true
}
const onPasswordChange = () => {
didDownload = false
}
const toggleUsePassword = () => {
usePassword = !usePassword
didDownload = false
}
let password = $state("")
let usePassword = $state(false)
let didDownload = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Your Keys are Ready!</div>
{/snippet}
</ModalHeader>
<p>
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your
account, while your <strong>private key</strong> acts sort of like a master password.
</p>
<p>
Securing your private key is very important, so make sure to take the time to save your key in a
secure place (like a password manager).
</p>
{#if usePassword}
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
{/if}
<div class="flex flex-col">
<Button class="btn {didDownload ? 'btn-neutral' : 'btn-primary'}" onclick={downloadKey}>
Download my key
<Icon icon={ArrowDown} />
</Button>
<Button class="btn btn-link no-underline" onclick={toggleUsePassword}>
{#if usePassword}
Nevermind, I want to download the plain version
{:else}
I want to download an encrypted version
{/if}
</Button>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button disabled={!didDownload} class="btn btn-primary" type="submit">
{submitText}
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import {Client, decodeChallenge} from '@pomade/core'
import {chunk, sleep, uniq, identity, tryCatch} from "@welshman/lib"
import {
makeEvent,
createProfile,
PROFILE,
isReplaceable,
getAddress,
RelayMode,
getPubkey,
} from "@welshman/util"
import type {SessionPomade} from '@welshman/app'
import {pubkey, session, publishThunk, repository, derivePubkeyRelays} from "@welshman/app"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal, clearModals} from "@app/util/modal"
import {logout} from "@app/core/commands"
import {INDEXER_RELAYS, POMADE_SIGNERS, PLATFORM_NAME, userSpaceUrls} from "@app/core/state"
const {email, clientOptions: {secret, peers}} = $session as SessionPomade
const requestRecovery = async () => {
await Client.requestChallenge(email, peers)
sent = true
}
const confirmRecovery = async () => {
const challenges = input.split(/\n/).map(x => x.trim()).filter(x => tryCatch(() => decodeChallenge(x)))
if (challenges.length < 2) {
return pushToast({
theme: "error",
message: "Failed to recover, not enough valid challenges were provided."
})
}
const request = await Client.recoverWithChallenge(email, challenges)
if (!request.ok) {
console.log(request.messages)
return pushToast({
theme: "error",
message: `Failed to recover: ${request.messages[0]?.payload.message.toLowerCase()}`
})
}
const result = await Client.selectRecovery(request.clientSecret, getPubkey(secret), peers)
if (!result.ok) {
console.log(result.messages)
return pushToast({
theme: "error",
message: `Failed to recover: ${result.messages[0]?.payload.message.toLowerCase()}`
})
}
pushModal(KeyDownload, {secret: result.userSecret, next: clearModals, submitText: "Done"})
}
const submit = async () => {
loading = true
try {
if (sent) {
await confirmRecovery()
} else {
await requestRecovery()
}
} finally {
loading = false
}
}
const back = () => history.back()
let sent = $state(false)
let loading = $state(false)
let input = $state("")
let userSecret = $state("")
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
Recover your Key
{/snippet}
{#snippet info()}
Take control over your cryptographic identity
{/snippet}
</ModalHeader>
{#if sent}
<p>
Your recovery codes have been sent!
</p>
<p>
For security reasons, you may receive three or more emails with recovery codes in them. Please paste <i>all</i>
recovery codes into the text box below, on separate lines.
</p>
<textarea
rows={POMADE_SIGNERS.length + 1}
class="textarea textarea-bordered leading-4 text-xs"
bind:value={input}></textarea>
{:else}
<p>
When you signed up, your Nostr secret key was split into multiple pieces and stored on separate
third-party servers to keep it safe.
</p>
<p>
If you're ready to take control of your cryptographic identity, click below. We'll confirm your
email by sending you some recovery codes.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if sent}
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm recovery</Spinner>
<Icon icon={AltArrowRight} />
</Button>
{:else}
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request recovery</Spinner>
<Icon icon={AltArrowRight} />
</Button>
{/if}
</ModalFooter>
</form>
+1 -1
View File
@@ -46,7 +46,7 @@
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
+1 -1
View File
@@ -46,7 +46,7 @@
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
if (res.ok && clientOptions) {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
+1 -1
View File
@@ -35,7 +35,7 @@
await sleep(800)
try {
loginWithPomade(clientOptions.group.group_pk.slice(2), clientOptions)
loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions)
pushToast({message: "Successfully logged in!"})
initProfile(profile)
setChecked("*")
+5 -156
View File
@@ -1,168 +1,17 @@
<script lang="ts">
import {nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/util"
import type {Profile} from "@welshman/util"
import {preventDefault, downloadText} from "@lib/html"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import ArrowDown from "@assets/icons/arrow-down.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import SignUpComplete from "@app/components/SignUpComplete.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {PLATFORM_NAME} from "@app/core/state"
type Props = {
secret: string
profile: Profile
}
const {profile}: Props = $props()
const props: Props = $props()
const secret = makeSecret()
const back = () => history.back()
const cleanupCopy = (copy: string) =>
copy
.replace(/\n\s*\n\s*/g, "NEWLINE")
.replace(/\s+/g, " ")
.replace(/NEWLINE/g, "\n\n")
.trim()
const downloadKey = () => {
const sharedCopy = `
Most online services keep track of users by giving them a username and password. This gives the
service total control over their users, allowing them to ban them at any time, or sell their activity.
On Nostr, you control your own identity and social data, through the magic of cryptography. The basic
idea is that you have a public key, which acts as your user ID, and a private key which allows you to
prove your identity.
It's very important to keep your private key secret because it grants permanent and complete access to your
account.
`
if (usePassword) {
if (password.length < 12) {
return pushToast({
theme: "error",
message: "Your password must be at least 12 characters long.",
})
}
const ncryptsec = encrypt(hexToBytes(secret), password)
const instructions = `
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using
a password you chose when you signed up.
${sharedCopy}
Your encrypted private key is:
${ncryptsec}
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
place to look), and import your key.
`
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
} else {
const nsec = nsecEncode(hexToBytes(secret))
const instructions = `
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME}.
${sharedCopy}
Your private key is:
${nsec}
To use it to log in to other Nostr apps, find a Nostr Signer app (https://nostrapps.com/#signers is a good
place to look), and import your key.
`
downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
}
didDownload = true
}
const next = () => {
pushModal(SignUpComplete, {profile, secret})
}
const onPasswordChange = () => {
didDownload = false
}
const toggleUsePassword = () => {
usePassword = !usePassword
didDownload = false
}
let password = $state("")
let usePassword = $state(false)
let didDownload = $state(false)
const next = () => pushModal(SignUpComplete, props)
</script>
<form class="column gap-4" onsubmit={preventDefault(next)}>
<ModalHeader>
{#snippet title()}
<div>Your Keys are Ready!</div>
{/snippet}
</ModalHeader>
<p>
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your
account, while your <strong>private key</strong> acts sort of like a master password.
</p>
<p>
Securing your private key is very important, so make sure to take the time to save your key in a
secure place (like a password manager).
</p>
{#if usePassword}
<Field>
{#snippet label()}
Password*
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Key} />
<input bind:value={password} onchange={onPasswordChange} class="grow" type="password" />
</label>
{/snippet}
{#snippet info()}
<p>Passwords should be at least 12 characters long. Write this down!</p>
{/snippet}
</Field>
{/if}
<div class="flex flex-col">
<Button class="btn {didDownload ? 'btn-neutral' : 'btn-primary'}" onclick={downloadKey}>
Download my key
<Icon icon={ArrowDown} />
</Button>
<Button class="btn btn-link no-underline" onclick={toggleUsePassword}>
{#if usePassword}
Nevermind, I want to download the plain version
{:else}
I want to download an encrypted version
{/if}
</Button>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button disabled={!didDownload} class="btn btn-primary" type="submit">
Continue
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
<KeyDownload {next} />
+3 -1
View File
@@ -60,6 +60,8 @@
{$session.signer}
{:else if $session.method === SessionMethod.Pubkey}
public key (readonly)
{:else if $session.method === SessionMethod.Pomade}
email and password
{/if}
</p>
<p>
@@ -69,7 +71,7 @@
</div>
{#if isDisconnected}
<Button class="btn btn-outline btn-error" onclick={logout}>Logout to Reconnect</Button>
{:else if $session?.method === SessionMethod.Pomade}
{:else}
<PomadeSessions />
{/if}
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {partition, call, sortBy, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {partition, call, sortBy, assoc, dissoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {
getListTags,
getRelayTagValues,
@@ -94,7 +94,7 @@ const pullAndListen = ({relays, filters, signal}: PullOpts) => {
request({
relays,
signal,
filters: unionFilters(filters).map(assoc("limit", 0)),
filters: unionFilters(filters.map(dissoc('limit'))).map(assoc("limit", 0)),
})
}
+11 -2
View File
@@ -2,7 +2,7 @@
import * as nip19 from "nostr-tools/nip19"
import {hexToBytes} from "@welshman/lib"
import {displayPubkey, displayProfile} from "@welshman/util"
import {pubkey, session, displayNip05, deriveProfile} from "@welshman/app"
import {pubkey, session, displayNip05, deriveProfile, SessionMethod} from "@welshman/app"
import {slideAndFade} from "@lib/transition"
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
@@ -20,6 +20,7 @@
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileDelete from "@app/components/ProfileDelete.svelte"
import KeyRecovery from "@app/components/KeyRecovery.svelte"
import SignerStatus from "@app/components/SignerStatus.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte"
import {PLATFORM_NAME} from "@app/core/state"
@@ -40,6 +41,8 @@
const startDelete = () => pushModal(ProfileDelete)
const startRecovery = () => pushModal(KeyRecovery)
let showAdvanced = false
</script>
@@ -156,7 +159,13 @@
</div>
{#if showAdvanced}
<div transition:slideAndFade class="flex flex-col gap-2 pt-4">
<Button class="btn btn-outline btn-error" onclick={startDelete}>
{#if $session?.method === SessionMethod.Pomade}
<Button class="btn btn-neutral" onclick={startRecovery}>
<Icon icon={Key} />
Recover your key
</Button>
{/if}
<Button class="btn btn-error" onclick={startDelete}>
<Icon icon={TrashBin2} />
Delete your profile
</Button>