morphit/docs/LOCK-SESSION-DESIGN.md

200 lines
7.4 KiB
Markdown

# Lock Session vs Sign Out — design note
**Status:** ✅ SHIPPED. Persistent keystore + Lock Session
landed via `apps/web/src/lib/crypto/persistentKeystore.ts`
and `lockSession()` at `apps/web/src/lib/stores/identity.ts`.
Onboarding-time keystore-mode choice ("Password (fast)" vs
"Seed every time (most private)") is wired through
`keystoreMode` state in the onboarding form, with the password
gate on the persist path. Sign Out (the nuclear option) now
wipes the envelope from localStorage; Lock Session preserves
the envelope for next-session password unlock.
This doc captures the original design rationale. The header
"Decision needed from you" section below documents what was
ratified before implementation; the body matches what shipped.
**Last updated:** 2026-04-21 (design ratification); shipped
shortly after. Doc maintained through 2026-05-17 (Part 122
cp27-DD2 fixed a route-path reference for cp7 per-locale
prerendering migration).
## Problem
The avatar dropdown currently has one destructive action: **Sign Out**.
You asked for both **Lock Session** and **Sign Out** with confirmation
modals. This doc explains why only Sign Out shipped this turn, and
what's required for Lock Session to be genuinely different rather
than a lie to the user.
## Current behavior
`reset()` in `apps/web/src/lib/stores/identity.ts` does exactly this:
1. Zero the in-memory `LiveIdentity` (posting + memo privates wiped)
2. Set the store back to `{ state: 'locked' }`
That's it. **The encrypted keystore envelope is never written to
localStorage.** Each session starts from nothing — the user has to
either paste their seed phrase or upload a keyfile to unlock.
So today, "Lock" and "Sign Out" would produce an identical end state:
the user needs their seed/keyfile to get back in. Shipping two buttons
that do the same thing is misleading.
## What Lock Session should actually do
- **Lock**: clear in-memory privates, KEEP encrypted envelope in
localStorage → user can unlock on same device with just their
password, no seed re-entry.
- **Sign Out**: clear in-memory privates AND wipe envelope from
localStorage → user needs full re-import next time.
For this distinction to be real, the envelope must actually live in
localStorage between sessions.
## The security gotcha: fallback passwords
Look at `apps/web/src/routes/[lang]/onboarding/+page.svelte`, the point where
the envelope gets created after the seed-confirmation quiz:
```typescript
if (!sessionPassword || sessionPassword.length < 8) {
// Derive a temporary password. User will set a real one via
// Settings later; this is a placeholder for the session.
const rnd = crypto.getRandomValues(new Uint8Array(24));
sessionPassword = Array.from(rnd, (b) => b.toString(16).padStart(2, '0')).join('');
}
const env = await encryptIdentity(full, sessionPassword);
await bootFromEnvelope(env, sessionPassword);
```
The onboarding flow accepts a session without the user setting a
password — it falls back to a random 24-byte hex string. That works
fine for **in-memory unlock** (the password is in JS memory already
anyway), but it's **useless for persistent storage**:
- Random password exists only in JS memory
- When tab closes, password is gone forever
- Envelope-in-localStorage without its password is unrecoverable
So: persisting the envelope to localStorage is only meaningful if the
user set a real password during onboarding. If they used the random
fallback, we cannot meaningfully persist.
## Proposed implementation
### Onboarding flow changes
Change the onboarding password step from "optional placeholder" to
"required if you want to persist." Two-option choice:
> **How do you want to unlock next time?**
>
> [ ] Password (fast) — set a password now. Your encrypted keys stay
> on this device. You can Lock Session instead of signing out,
> and just re-enter the password to unlock.
>
> [ ] Seed phrase every time (most private) — nothing stored on this
> device. You'll re-enter your 12-word seed each session.
If they pick password: require min-8 password, store envelope in
localStorage under `morphit.keystore.envelope`.
If they pick seed-every-time: don't write anything to localStorage.
In that mode, "Lock" and "Sign Out" collapse to a single option,
which is honest.
### Storage shape
```typescript
// localStorage key: morphit.keystore.envelope
// Value: JSON of KeystoreEnvelope (ciphertext, salt, IV, KDF params)
interface StoredEnvelope {
version: 1;
envelope: KeystoreEnvelope;
createdAt: number; // unix ms
}
```
Use `safeStorage.ts` (already shipped) so Private Mode and Tor
Browser at high security gracefully fall back to seed-every-time.
### App boot path
On `+layout.svelte` mount:
1. Check `localStorage.getItem('morphit.keystore.envelope')`
2. If present → set identity store to new state `{ state: 'keystore-present' }`
3. Login page renders "Unlock with password" form when state is
`keystore-present`, "Import seed or keyfile" form when state is
`locked`.
### Lock Session action
`lockSession()` on the identity store:
1. Zero live privates (same as `reset()`)
2. Keep envelope in localStorage (do not remove)
3. Set state to `{ state: 'keystore-present' }`
Next time user visits, login shows "Welcome back — enter your
password" instead of the seed import flow.
### Sign Out action (changes)
Today's `reset()` stays the same in behavior but gains a localStorage
wipe:
1. Zero live privates
2. `safeLocal.remove('morphit.keystore.envelope')`
3. `safeLocal.remove('morphit.displayName')`
4. `safeLocal.remove('morphit.nostrUrl')`
5. `safeLocal.remove('morphit.blurtMediaUrl')`
6. State to `{ state: 'locked' }`
Sign Out becomes a **real** clean slate on this device. The user's
on-chain data remains untouched.
## Modal copy (already needed next turn)
Lock Session modal:
- Title: "Lock this session?"
- Body: "Your encrypted keys stay on this device. You can unlock again
with your password — no seed phrase needed. Choose Sign Out if
you're on a shared computer and want everything wiped."
- Confirm: "Lock session"
- Cancel: "Stay signed in"
Sign Out modal (existing, copy refined):
- Title: "Sign out of Morphit?"
- Body: "This wipes your encrypted keys from this device. You'll need
your 12-word seed phrase or keyfile to sign back in. Your on-chain
data, orders, and reputation aren't affected — they live on Blurt,
not in this browser."
- Confirm: "Sign out completely"
- Cancel: "Stay signed in"
The two modals' bodies are clearly different, so the user's choice
matters.
## Decision needed from you
1. **Approve the onboarding password choice screen** — "Password (fast)
vs Seed every time (most private)" as the two options at signup.
2. **Approve localStorage as the persistence store** (alternative:
IndexedDB, which survives some storage-pressure eviction scenarios
better but adds complexity).
3. **Approve `safeStorage` as the access layer** — gracefully degrades
to seed-every-time in Private Mode / Tor Browser.
4. **Approve auto-lock timeout?** — optional: auto-lock the session
after N minutes of inactivity. Would need a Settings toggle. Yes/no
and default duration (30 min? 2 hours?).
Once ratified, this is ~1 day of work:
- Onboarding flow update
- Login page "unlock with password" form
- identity store new state variant + `lockSession()` action
- Sign Out localStorage wipe
- Lock Session menu item + modal in AvatarMenu
- Settings section for password change + auto-lock timeout