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:
Jason Tudisco 2026-09-27 09:49:10 -06:00
parent 79e7e9536b
commit 3628199a5b
3 changed files with 109 additions and 33 deletions

View File

@ -42,7 +42,8 @@ can verify those links themselves, without trusting anyone.
- **Push notifications** that tell the push provider nothing — the - **Push notifications** that tell the push provider nothing — the
payload is empty. payload is empty.
- **Unlock with Face ID / Touch ID / Windows Hello / a security key** - **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 - **Your relays, your choice.** Reorder, add, or remove nostr relays in
Settings. 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** notifications stay useful. **Lock** ends it; **Forget this account**
wipes the device. 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 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 request signed with your identity key. It's a public directory: handle
→ public key. → public key.

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { import {
session, session,
setMyProfile, setMyProfile,
@ -29,6 +28,7 @@
DEFAULT_RELAYS, DEFAULT_RELAYS,
} from "../lib/relay-store.js"; } from "../lib/relay-store.js";
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js"; import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
import { mnemonicFromSeed24 } from "../lib/mnemonic.js";
import { import {
hasStoredBiometric, hasStoredBiometric,
getStoredBiometricMeta, getStoredBiometricMeta,
@ -381,9 +381,13 @@
// session was originally biometric-unlocked — the passphrase // session was originally biometric-unlocked — the passphrase
// PRF key wasn't available before but is now). // PRF key wasn't available before but is now).
const phrase = fresh.phrase ?? session.unlocked.phrase; 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) { if (phrase) {
alert( 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 ` + `Write these 12 words down in order — they're the ONLY way to ` +
`recover this account on another device.`, `recover this account on another device.`,
); );
@ -393,11 +397,12 @@
`Lock and unlock again with your passphrase to reveal it.`, `Lock and unlock again with your passphrase to reveal it.`,
); );
} else { } 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( alert(
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` + `Your 24-word recovery phrase (KEEP SECRET):\n\n${numbered(mnemonicFromSeed24(fresh.seed))}\n\n` +
`This account was created before 12-word phrases were supported. ` + `This account was created before 12-word phrases, so its backup ` +
`The 64-character hex above is still your full recovery.`, `is these 24 words. Write them down in order — Restore accepts them.`,
); );
} }
} catch (e) { } catch (e) {

View File

@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js"; import { onboarding } from "../lib/onboarding.svelte.js";
import { listClaims } from "../lib/claims-store.js"; import { listClaims } from "../lib/claims-store.js";
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
import { mnemonicFromSeed24 } from "../lib/mnemonic.js";
import { import {
hasStoredBiometric, hasStoredBiometric,
setupBiometricUnlock, setupBiometricUnlock,
@ -24,8 +25,13 @@
let notifPerm = $state<NotificationPermission | "unsupported">("default"); let notifPerm = $state<NotificationPermission | "unsupported">("default");
let backupRevealed = $state(false); let backupRevealed = $state(false);
let backupText = $state(""); // 12-word phrase if available, else hex seed let backupText = $state(""); // 12-word phrase, or 24-word for legacy accounts
let backupKind = $state<"phrase" | "seed">("phrase"); 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 backupCopied = $state(false);
let busy = $state(false); let busy = $state(false);
@ -41,17 +47,37 @@
notifPerm = notificationsSupported() ? notificationsPermission() : "unsupported"; 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) return;
if (session.unlocked.phrase) { if (session.unlocked.phrase) {
backupText = session.unlocked.phrase; showPhrase(session.unlocked.phrase, "phrase");
backupKind = "phrase"; } else if (await hasStoredPhrase()) {
needPassphrase = true;
} else { } else {
// Legacy account (pre-mnemonic) — fall back to the hex seed. // Account predates 12-word phrases (or was restored from hex):
backupText = bytesToHex(session.unlocked.seed); // its seed has no 12-word form, but every seed has an exact
backupKind = "seed"; // 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() { async function copySeed() {
await navigator.clipboard.writeText(backupText); await navigator.clipboard.writeText(backupText);
@ -134,29 +160,58 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Back up your recovery phrase</p> <p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
<p class="text-xs text-text-secondary"> <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 your account. Lose them and it's gone forever — there's no
reset. Write them on paper. reset. Write them on paper.
</p> </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> <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}
{#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} {#if backupRevealed}
<div class="mt-2 p-3 bg-elevated border border-border rounded-md"> <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"> <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} {#each backupText.split(" ") as word, i}
<li class="flex items-baseline gap-1.5"> <li class="flex items-baseline gap-1.5">
<span class="text-text-muted w-4 text-right">{i + 1}.</span> <span class="text-text-muted w-5 text-right">{i + 1}.</span>
<span class="text-text select-all">{word}</span> <span class="text-text select-all">{word}</span>
</li> </li>
{/each} {/each}
</ol> </ol>
{:else} {#if backupKind === "phrase24"}
<p class="font-mono text-xs text-text break-all select-all">{backupText}</p> <p class="mt-2 text-[10px] text-text-muted">
<p class="mt-1 text-[10px] text-text-muted"> Your account was created before 12-word phrases, so its
Legacy 64-char hex — accounts created from now on get a backup is these 24 words. Restore accepts them.
12-word phrase instead.
</p> </p>
{/if} {/if}
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">