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
|
- **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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user