fix(kez-chat): show recovery phrase as words, never hex
The recovery reveal (Getting Started and Settings) fell back to the 64-char hex seed whenever the phrase wasn't in the session — which happened after every Face ID / passkey unlock (PRF only recovers the seed) and for accounts created before 12-word phrases. - Phrase stored but not in session: Getting Started asks for the passphrase once and decrypts it. - No stored phrase (legacy / hex-restored): show the seed's exact 24-word encoding, which Restore accepts. - Settings alert lists words numbered one per line. - README: document passkey unlock as per-browser and how the recovery phrase is shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
79e7e9536b
commit
3628199a5b
@ -42,7 +42,8 @@ can verify those links themselves, without trusting anyone.
|
||||
- **Push notifications** that tell the push provider nothing — the
|
||||
payload is empty.
|
||||
- **Unlock with Face ID / Touch ID / Windows Hello / a security key**
|
||||
(WebAuthn passkeys), or a passphrase.
|
||||
(WebAuthn passkeys), or a passphrase. Passkey unlock is per-browser;
|
||||
on a new or wiped browser you restore with your recovery phrase.
|
||||
- **Your relays, your choice.** Reorder, add, or remove nostr relays in
|
||||
Settings.
|
||||
|
||||
@ -89,6 +90,21 @@ WebCrypto key, keeps you signed in while the PWA is backgrounded, so push
|
||||
notifications stay useful. **Lock** ends it; **Forget this account**
|
||||
wipes the device.
|
||||
|
||||
Passkey unlock is a local convenience, not a login: the passkey-encrypted
|
||||
seed lives only in this browser's IndexedDB, and nothing is stored on the
|
||||
server. If the browser forgets you (storage cleared, new device), sign
|
||||
back in with **Restore** and your recovery phrase (or the 64-hex seed).
|
||||
|
||||
**Showing your recovery phrase** (Getting Started, or Settings → reveal)
|
||||
displays it as a numbered word list:
|
||||
|
||||
- 12 words for accounts created with a phrase. After a passkey unlock
|
||||
the phrase isn't in memory (the passkey only unlocks the seed), so the
|
||||
app asks for your passphrase once to decrypt it.
|
||||
- 24 words for older accounts created before 12-word phrases (or restored
|
||||
from a hex seed). Their seed has no 12-word form, but its exact 24-word
|
||||
encoding restores the same account.
|
||||
|
||||
Your **handle** (`you@kez.lat`) is registered with the chat-server by a
|
||||
request signed with your identity key. It's a public directory: handle
|
||||
→ public key.
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import {
|
||||
session,
|
||||
setMyProfile,
|
||||
@ -29,6 +28,7 @@
|
||||
DEFAULT_RELAYS,
|
||||
} from "../lib/relay-store.js";
|
||||
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
|
||||
import { mnemonicFromSeed24 } from "../lib/mnemonic.js";
|
||||
import {
|
||||
hasStoredBiometric,
|
||||
getStoredBiometricMeta,
|
||||
@ -381,9 +381,13 @@
|
||||
// session was originally biometric-unlocked — the passphrase
|
||||
// PRF key wasn't available before but is now).
|
||||
const phrase = fresh.phrase ?? session.unlocked.phrase;
|
||||
// Numbered one-per-line: alert() doesn't wrap long single lines
|
||||
// well on phones, and numbering prevents order mistakes.
|
||||
const numbered = (p: string) =>
|
||||
p.split(" ").map((w, i) => `${i + 1}. ${w}`).join("\n");
|
||||
if (phrase) {
|
||||
alert(
|
||||
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
|
||||
`Your 12-word recovery phrase (KEEP SECRET):\n\n${numbered(phrase)}\n\n` +
|
||||
`Write these 12 words down in order — they're the ONLY way to ` +
|
||||
`recover this account on another device.`,
|
||||
);
|
||||
@ -393,11 +397,12 @@
|
||||
`Lock and unlock again with your passphrase to reveal it.`,
|
||||
);
|
||||
} else {
|
||||
const hex = bytesToHex(fresh.seed);
|
||||
// Pre-12-word account (or restored from hex): no 12-word form
|
||||
// exists for this seed, but its exact 24-word encoding does.
|
||||
alert(
|
||||
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
|
||||
`This account was created before 12-word phrases were supported. ` +
|
||||
`The 64-character hex above is still your full recovery.`,
|
||||
`Your 24-word recovery phrase (KEEP SECRET):\n\n${numbered(mnemonicFromSeed24(fresh.seed))}\n\n` +
|
||||
`This account was created before 12-word phrases, so its backup ` +
|
||||
`is these 24 words. Write them down in order — Restore accepts them.`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import { onboarding } from "../lib/onboarding.svelte.js";
|
||||
import { listClaims } from "../lib/claims-store.js";
|
||||
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
|
||||
import { mnemonicFromSeed24 } from "../lib/mnemonic.js";
|
||||
import {
|
||||
hasStoredBiometric,
|
||||
setupBiometricUnlock,
|
||||
@ -24,8 +25,13 @@
|
||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||
|
||||
let backupRevealed = $state(false);
|
||||
let backupText = $state(""); // 12-word phrase if available, else hex seed
|
||||
let backupKind = $state<"phrase" | "seed">("phrase");
|
||||
let backupText = $state(""); // 12-word phrase, or 24-word for legacy accounts
|
||||
let backupKind = $state<"phrase" | "phrase24">("phrase");
|
||||
/** Phrase is stored but not in this session (Face ID / passkey unlock
|
||||
* only recovers the seed) — ask for the passphrase to decrypt it. */
|
||||
let needPassphrase = $state(false);
|
||||
let revealPass = $state("");
|
||||
let revealError = $state<string | null>(null);
|
||||
let backupCopied = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
@ -41,17 +47,37 @@
|
||||
notifPerm = notificationsSupported() ? notificationsPermission() : "unsupported";
|
||||
});
|
||||
|
||||
function revealSeed() {
|
||||
function showPhrase(phrase: string, kind: "phrase" | "phrase24") {
|
||||
backupText = phrase;
|
||||
backupKind = kind;
|
||||
needPassphrase = false;
|
||||
backupRevealed = true;
|
||||
}
|
||||
|
||||
async function revealSeed() {
|
||||
if (!session.unlocked) return;
|
||||
if (session.unlocked.phrase) {
|
||||
backupText = session.unlocked.phrase;
|
||||
backupKind = "phrase";
|
||||
showPhrase(session.unlocked.phrase, "phrase");
|
||||
} else if (await hasStoredPhrase()) {
|
||||
needPassphrase = true;
|
||||
} else {
|
||||
// Legacy account (pre-mnemonic) — fall back to the hex seed.
|
||||
backupText = bytesToHex(session.unlocked.seed);
|
||||
backupKind = "seed";
|
||||
// Account predates 12-word phrases (or was restored from hex):
|
||||
// its seed has no 12-word form, but every seed has an exact
|
||||
// 24-word encoding, which Restore accepts.
|
||||
showPhrase(mnemonicFromSeed24(session.unlocked.seed), "phrase24");
|
||||
}
|
||||
}
|
||||
|
||||
async function revealWithPassphrase() {
|
||||
revealError = null;
|
||||
try {
|
||||
const fresh = await unlockIdentity(revealPass);
|
||||
revealPass = "";
|
||||
if (fresh.phrase) showPhrase(fresh.phrase, "phrase");
|
||||
else showPhrase(mnemonicFromSeed24(fresh.seed), "phrase24");
|
||||
} catch (e) {
|
||||
revealError = (e as Error).message;
|
||||
}
|
||||
backupRevealed = true;
|
||||
}
|
||||
async function copySeed() {
|
||||
await navigator.clipboard.writeText(backupText);
|
||||
@ -134,29 +160,58 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
These 12 words are the <strong>only</strong> way to recover
|
||||
These words are the <strong>only</strong> way to recover
|
||||
your account. Lose them and it's gone forever — there's no
|
||||
reset. Write them on paper.
|
||||
</p>
|
||||
{#if !backupRevealed && !onboarding.seedAcked}
|
||||
{#if !backupRevealed && !needPassphrase && !onboarding.seedAcked}
|
||||
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal phrase</button>
|
||||
{/if}
|
||||
{#if needPassphrase}
|
||||
<form
|
||||
class="mt-2 space-y-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void revealWithPassphrase();
|
||||
}}
|
||||
>
|
||||
<p class="text-xs text-text-secondary">
|
||||
You signed in with Face ID / passkey, which doesn't unlock
|
||||
your phrase. Enter your passphrase to show it.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={revealPass}
|
||||
placeholder="passphrase"
|
||||
autocomplete="current-password"
|
||||
class="flex-1 px-3 py-1.5 text-sm bg-elevated border border-border rounded-md text-text focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md disabled:opacity-50"
|
||||
disabled={!revealPass}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
{#if revealError}<p class="text-xs text-danger">{revealError}</p>{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{#if backupRevealed}
|
||||
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
|
||||
{#if backupKind === "phrase"}
|
||||
<ol class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-xs">
|
||||
{#each backupText.split(" ") as word, i}
|
||||
<li class="flex items-baseline gap-1.5">
|
||||
<span class="text-text-muted w-4 text-right">{i + 1}.</span>
|
||||
<span class="text-text select-all">{word}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="font-mono text-xs text-text break-all select-all">{backupText}</p>
|
||||
<p class="mt-1 text-[10px] text-text-muted">
|
||||
Legacy 64-char hex — accounts created from now on get a
|
||||
12-word phrase instead.
|
||||
<ol class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-xs">
|
||||
{#each backupText.split(" ") as word, i}
|
||||
<li class="flex items-baseline gap-1.5">
|
||||
<span class="text-text-muted w-5 text-right">{i + 1}.</span>
|
||||
<span class="text-text select-all">{word}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if backupKind === "phrase24"}
|
||||
<p class="mt-2 text-[10px] text-text-muted">
|
||||
Your account was created before 12-word phrases, so its
|
||||
backup is these 24 words. Restore accepts them.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-2">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user