Backing up now enrols a passkey (enrollDeviceCredential) and revealing the phrase runs a user-verifying passkey ceremony (confirmPhraseAccess), so the recovery phrase — a bearer secret — is not handed out without confirming the person is present. Honest by construction: this is a confirmation, not encryption. The account secret still lives in device storage so the app opens instantly (local-first); the UI copy says so. A device without WebAuthn can still back up and is told the reveal was unconfirmed; a backed-up device can add a passkey guard later via "Protect phrase with a passkey". session.ts: createBackupPasskey / confirmPhraseAccess / isAuthError + phraseGuarded state. AccountGate: enrol-then-reveal, guarded reveal, protect action, honest notes. Docs + starter snapshot updated. Suite green, lint/fmt clean.
- commit
- 9f9992c08428e433f043a448bb0393ad42234eca
- parent
- 945e1bdcc3b573d3df8f8005fda47e36bf645b80
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 78 | ++++++++++++++++++++++++++++++- |
| M | apps/ | 58 | ++++++++++++++++++++--- |
| M | docs/ | 24 | +++++++--- |
| M | package/ | 4 | +- |
@@ -19,6 +19,7 @@
1919 import { BrowserAuthSecretStore } from "jazz-tools";2020 import { appId, setSyncElected, syncAvailable, syncElected, syncing } from "./config.ts";2121 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";22+import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";2223 import { recreateRuntime, runtimeRecreatedEvent } from "./runtime.ts";2324 2425 /** A snapshot of the account: what is possible and what the user has chosen. */@@ -29,11 +30,46 @@ export type Session = {
2930 backedUp: boolean;3031 /** Whether writes replicate right now (available *and* elected). */3132 syncing: boolean;33+ /**34+ * Whether a passkey guards the recovery phrase on this device — revealing it35+ * then requires a user-verifying ceremony. This confirms the person is present;36+ * it does **not** encrypt the account secret held on the device.37+ */38+ phraseGuarded: boolean;3239 };3340 41+// A per-device flag recording that the user enrolled a passkey to guard the42+// recovery phrase. The passkey itself is a resident credential on the device;43+// this only remembers to ask for it before revealing the phrase.44+const phraseGuardKey = `lofi:phrase-passkey:${appId}`;45+46+function phraseGuarded(): boolean {47+ if (typeof localStorage === "undefined") return false;48+ try {49+ return localStorage.getItem(phraseGuardKey) === "1";50+ } catch {51+ return false;52+ }53+}54+55+function setPhraseGuarded(guarded: boolean): void {56+ if (typeof localStorage === "undefined") return;57+ try {58+ if (guarded) localStorage.setItem(phraseGuardKey, "1");59+ else localStorage.removeItem(phraseGuardKey);60+ } catch {61+ // A private-mode storage failure must not break the ceremony itself.62+ }63+}64+3465 /** Reads the current session. Synchronous — it never prompts or touches the network. */3566 export function readSession(): Session {36- return { syncAvailable, backedUp: syncElected(), syncing: syncing() };67+ return {68+ syncAvailable,69+ backedUp: syncElected(),70+ syncing: syncing(),71+ phraseGuarded: phraseGuarded(),72+ };3773 }3874 3975 // The account secret the runtime is already using. `getOrCreateSecret` is a@@ -43,6 +79,41 @@ async function currentSecret(): Promise<string> {
4379 return await BrowserAuthSecretStore.getOrCreateSecret({ appId });4480 }4581 82+/**83+ * Enrolls a passkey to guard the recovery phrase on this device. Enrollment is a84+ * user-verifying ceremony, so it doubles as the confirmation for the reveal that85+ * immediately follows. Returns `true` when a guard was set; `false` (without86+ * throwing) when this browser or origin cannot enroll a passkey, so the caller87+ * can still show the phrase while telling the user it is unguarded. Re-throws a88+ * `cancelled` ceremony so the caller can abort.89+ */90+export async function createBackupPasskey(label?: string): Promise<boolean> {91+ try {92+ await enrollDeviceCredential(label ? { label } : {});93+ setPhraseGuarded(true);94+ return true;95+ } catch (error) {96+ if (97+ error instanceof AuthError &&98+ (error.code === "unsupported" || error.code === "origin-rejected")99+ ) {100+ setPhraseGuarded(false);101+ return false;102+ }103+ throw error;104+ }105+}106+107+/**108+ * Runs the user-verifying passkey ceremony that must precede revealing the phrase109+ * on a guarded device. A no-op when the device is not guarded. Throws `cancelled`110+ * if the user dismisses the prompt — the phrase is then not revealed.111+ */112+export async function confirmPhraseAccess(): Promise<void> {113+ if (!phraseGuarded()) return;114+ await authenticateDeviceCredential();115+}116+46117 /**47118 * Reveals the current account's recovery phrase — the same 32-byte secret encoded48119 * as words. Show it for the user to write down; never persist it. Works whether@@ -101,3 +172,8 @@ export async function restoreFromRecoveryPhrase(phrase: string): Promise<Session
101172 export function isRecoveryError(error: unknown): error is RecoveryError {102173 return error instanceof RecoveryError;103174 }175+176+/** True when an error came from a passkey ceremony (enroll / confirm). */177+export function isAuthError(error: unknown): error is AuthError {178+ return error instanceof AuthError;179+}@@ -1,6 +1,9 @@
11 import { useCallback, useEffect, useState } from "preact/hooks";22 import {3+ confirmPhraseAccess,4+ createBackupPasskey,35 enableSyncBackup,6+ isAuthError,47 isRecoveryError,58 readSession,69 restoreFromRecoveryPhrase,@@ -21,26 +24,41 @@ import {
2124 * phrase only matters once there is somewhere to sync to.2225 */2326 27+type Busy = "enable" | "stop" | "reveal" | "restore" | "protect";28+2429 // Turns any thrown value into a sentence a person can act on.2530 function describe(error: unknown): string {31+ if (isAuthError(error)) {32+ switch (error.code) {33+ case "cancelled":34+ return "Passkey prompt dismissed — your recovery phrase was not shown.";35+ case "unsupported":36+ return "This browser does not support passkeys.";37+ default:38+ return error.message;39+ }40+ }2641 if (isRecoveryError(error)) return error.message;2742 return error instanceof Error ? error.message : String(error);2843 }2944 3045 export default function AccountGate() {3146 const [session, setSession] = useState<Session | null>(null);32- const [busy, setBusy] = useState<"enable" | "stop" | "reveal" | "restore" | null>(null);47+ const [busy, setBusy] = useState<Busy | null>(null);3348 const [error, setError] = useState<string | null>(null);3449 const [phrase, setPhrase] = useState<string | null>(null);3550 const [phraseInput, setPhraseInput] = useState("");3651 const [restoring, setRestoring] = useState(false);52+ // Set when a backup proceeded without a passkey guard (WebAuthn unavailable),53+ // so the phrase block can say so honestly rather than imply a confirmation.54+ const [unguarded, setUnguarded] = useState(false);3755 3856 useEffect(() => {3957 setSession(readSession());4058 }, []);4159 4260 const run = useCallback(43- (kind: "enable" | "stop" | "reveal" | "restore", action: () => Promise<unknown>) => {61+ (kind: Busy, action: () => Promise<unknown>) => {4462 setBusy(kind);4563 setError(null);4664 void action().then(@@ -67,6 +85,12 @@ export default function AccountGate() {
6785 words down and keep them somewhere safe. Anyone with them can open this account; lose every6886 copy and the account cannot be recovered.6987 </p>88+ {unguarded && (89+ <p class="account-note">90+ A passkey could not be created on this browser or origin, so this reveal was not confirmed91+ with one. Save the phrase carefully.92+ </p>93+ )}7094 <ol class="account-words" aria-label="Recovery phrase">7195 {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}7296 </ol>@@ -89,13 +113,19 @@ export default function AccountGate() {
89113 </header>90114 <p>91115 This account replicates to your managed Jazz app and can be recovered with its phrase.92- Reveal the phrase again any time to save it on another device.116+ {session.phraseGuarded117+ ? " Revealing the phrase asks for your passkey first."118+ : " Reveal the phrase again any time to save it on another device."}93119 </p>94120 <div class="account-actions">95121 <button96122 type="button"97123 disabled={disabled}98- onClick={() => run("reveal", () => revealRecoveryPhrase().then(setPhrase))}124+ onClick={() =>125+ run("reveal", async () => {126+ await confirmPhraseAccess();127+ setPhrase(await revealRecoveryPhrase());128+ })}99129 >100130 {busy === "reveal"101131 ? "Revealing…"@@ -103,6 +133,20 @@ export default function AccountGate() {
103133 ? "Show phrase again"104134 : "Show recovery phrase"}105135 </button>136+ {!session.phraseGuarded && (137+ <button138+ type="button"139+ class="account-secondary"140+ disabled={disabled}141+ onClick={() =>142+ run("protect", async () => {143+ setUnguarded(!(await createBackupPasskey()));144+ return readSession();145+ })}146+ >147+ {busy === "protect" ? "Setting up…" : "Protect phrase with a passkey"}148+ </button>149+ )}106150 <button107151 type="button"108152 class="account-secondary"@@ -130,7 +174,7 @@ export default function AccountGate() {
130174 <p>131175 You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it132176 up to sync across your devices and recover it if this one is lost. Your existing data comes133- along.177+ along. You will create a passkey first, so revealing your recovery phrase asks for it.134178 </p>135179 <div class="account-actions">136180 <button@@ -138,8 +182,8 @@ export default function AccountGate() {
138182 disabled={disabled}139183 onClick={() =>140184 run("enable", async () => {141- const words = await revealRecoveryPhrase();142- setPhrase(words);185+ setUnguarded(!(await createBackupPasskey()));186+ setPhrase(await revealRecoveryPhrase());143187 return await enableSyncBackup();144188 })}145189 >@@ -46,27 +46,37 @@ The same 32-byte secret encodes as a 24-word recovery phrase (`RecoveryPhrase.fr
4646 recovery phrase = words( account secret ) # the phrase *is* the account4747 ```4848 49-- **Back up** → reveal the phrase once and write it down. It is the exact same account, offline and50- portable.49+- **Back up** → create a passkey, then reveal the phrase once and write it down. It is the exact50+ same account, offline and portable.5151 - **Recover** → type the phrase on any device → the same secret → the same account → its synced data5252 flows back down, decrypted (Jazz E2E-encrypts synced data keyed on the account).5353 5454 Restoring replaces whatever account the device currently holds, so the UI confirms before discarding5555 a local-only account's un-backed-up data.5656 57+### Passkey confirmation on reveal58+59+Backing up enrols a passkey (`enrollDeviceCredential`), and revealing the phrase afterwards runs a60+user-verifying passkey ceremony (`confirmPhraseAccess`) — so the recovery phrase, a bearer secret,61+is not handed out without confirming the person is present. Stated honestly: this is a62+**confirmation**, not encryption. The account secret still lives in device storage so the app can63+open instantly; the passkey does not encrypt it. A device where WebAuthn is unavailable can still64+back up, and the UI says the reveal was unconfirmed. (For real at-rest encryption of the secret, the65+PRF primitive in `src/_lofi/auth.ts` is available — see below.)66+5767 ### Boot flow5868 5969 - **First boot** → local-only account opens immediately. No gate.60-- **Back up & sync** (a click) → reveal the recovery phrase to save → turn on sync → the runtime61- recreates and the account replicates.70+- **Back up & sync** (a click) → create a passkey → reveal the recovery phrase to save → turn on71+ sync → the runtime recreates and the account replicates.6272 - **Return boots** → the same cached secret opens the same account, synced or not.6373 - **A new device** → restore from the recovery phrase → the account and its synced data appear.6474 - **Stop syncing** → detaches the network and returns to local-only; the account and data are6575 untouched, and electing again resumes against the same account.6676 67-The framework side lives in `src/_lofi/session.ts` (`readSession`, `revealRecoveryPhrase`,68-`enableSyncBackup`, `stopSyncBackup`, `restoreFromRecoveryPhrase`); the gate is the author-owned69-example you can restyle or replace.77+The framework side lives in `src/_lofi/session.ts` (`readSession`, `createBackupPasskey`,78+`confirmPhraseAccess`, `revealRecoveryPhrase`, `enableSyncBackup`, `stopSyncBackup`,79+`restoreFromRecoveryPhrase`); the gate is the author-owned example you can restyle or replace.7080 7181 ## Custody and recovery — stated plainly7282 @@ -28,7 +28,7 @@
2828 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2929 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",3030 "src/_lofi/runtime.ts": "a33d7a802d4ae0a5d34efc11f958a25f55f84db23fa3732a468ed427c74eb3ac",31- "src/_lofi/session.ts": "be22b8ed6196d90409ef4448094124f47781f402edf8f28cc11835a9c507d903",31+ "src/_lofi/session.ts": "783dc6d4fff855f7e8486b5a2e22db881b02faac809631cc2c509f31daa4a368",3232 "src/_lofi/Shell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",3333 "src/_lofi/table-store_test.ts": "1b8cfd06192b9c0daacc8a8c7811b691338f4ef459b209b3830fb87a0a039955",3434 "src/_lofi/table-store.ts": "7ad445cc34c11e0cab3afcf1cde097345f1a53f2583da6c7fd00c7cb3e082df8",@@ -38,7 +38,7 @@
3838 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",3939 "src/app.ts": "7e65c5308f645164357b02944d1e265f2be942686a634c552add14cfb8fd384f",4040 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",41- "src/islands/AccountGate.tsx": "84573d9f5b66a435152fe5fdcdb698b382b2f54960e361ace00ad75d77d72b1a",41+ "src/islands/AccountGate.tsx": "11c7d59edc61400c0e15592112f74dd4d3b33ba3e67a1263d51b30389b5b0482",4242 "src/islands/TaskList.tsx": "a3d054078819ea8b5609a014e94878bf1b21b2908c340237ed029e80e58f2349",4343 "src/islands/use-tasks.ts": "1df5748b14427755352b8438977ce8720abc42f1e2f69bf1e927ceaa52166246",4444 "src/pages/index.astro": "6bcbcbd1c1aad8d55855e5f52473b7a7711c439d4877ef54fe7f6f5d4f88ec3c",