From 103a7f2f05dd9f4d1f1453efc4913853d63af7b3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 13:58:06 +0200 Subject: [PATCH] feat: persistent session (no re-unlock on reload) + reconnect ACL after createRoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session persistence (web/src/session.ts): the unlocked wallet identity is kept across reloads so an F5 no longer forces a password re-unlock. By default it lives in sessionStorage (survives F5, cleared with the tab); with 'keep me signed in' it lives in localStorage (survives closing the browser) bounded by a 30-day absolute TTL and a 12-hour inactivity auto-lock. logout clears it; activity (send/createRoom) refreshes the idle timer. No cookie is ever used — the private key never travels to any server. WalletLogin gains the 'keep me signed in' checkbox; Recover/Join keep the session by default (recovering/creating on a device implies it is yours). App.tsx restores the session on mount before falling back to the unlock screen. ACL reconnect: a room created while connected was not in the NATS per-subject ACL grant (subjects are frozen at connect time), so its first messages silently did not deliver until a re-login. WsNatsTransport gains reconnect(); BusClient.refresh() calls it; busService.createRoom reconnects after creating so the new room is usable immediately. Bumps uniweb to 0.4.0. --- app.md | 2 +- web/src/App.tsx | 18 +++++-- web/src/Join.tsx | 3 +- web/src/Recover.tsx | 3 +- web/src/WalletLogin.tsx | 11 +++- web/src/bus/client.ts | 13 +++++ web/src/bus/wstransport.ts | 34 +++++++++--- web/src/busService.ts | 66 ++++++++++++++++++----- web/src/session.ts | 105 +++++++++++++++++++++++++++++++++++++ web/src/wallet/account.ts | 7 +-- 10 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 web/src/session.ts diff --git a/app.md b/app.md index fdbe3ba..0d9b73f 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: uniweb lang: ts domain: infra -version: 0.3.0 +version: 0.4.0 description: "Cliente web browser-nativo del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) que habla DIRECTO al bus (nats.ws + control-plane HTTPS firmado), sin gateway. La clave privada nunca sale del navegador." tags: [messaging, web, frontend, e2e] uses_functions: [] diff --git a/web/src/App.tsx b/web/src/App.tsx index 07519d0..2dcb3ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -31,11 +31,12 @@ export function App() { const [token, setToken] = useState(""); const [storedHandle, setStoredHandle] = useState(""); - // Decide the entry screen on mount: an invite link goes straight to join; a device - // with a stored identity shows the password unlock; an empty device shows the - // welcome chooser. There is no "resume session" step: the bus session lives in - // memory (the SDK runs in the browser), so a reload always re-unlocks locally - // rather than resuming a server-side cookie session. + // Decide the entry screen on mount: an invite link goes straight to join; otherwise + // try to RESTORE a persisted session (survives reloads, and — with "keep me signed + // in" — closing the browser, up to its TTL/idle limits) so a reload does not force a + // re-unlock; if there is none, a device with a stored identity shows the password + // unlock and an empty device shows the welcome chooser. The private key stays in the + // browser throughout; nothing is resumed from a server-side cookie. useEffect(() => { const t = readJoinToken(); if (t) { @@ -45,6 +46,13 @@ export function App() { } let cancelled = false; (async () => { + const restored = await bus.restoreSession(); + if (cancelled) return; + if (restored) { + setUser(restored); + setRoute("chat"); + return; + } const stored = await localIdentity(); if (cancelled) return; if (stored) { diff --git a/web/src/Join.tsx b/web/src/Join.tsx index 235153a..a80282e 100644 --- a/web/src/Join.tsx +++ b/web/src/Join.tsx @@ -130,7 +130,8 @@ export function Join({ // session; if the identity is not yet authorized, openSession fails and we // tell the user to have an admin authorize their public key. const handle = identity.signPub.slice(0, 8); - const user = await saveAndOpen(identity, handle, password); + // Creating the account on this device implies it is yours: keep the session. + const user = await saveAndOpen(identity, handle, password, true); onJoined(user); } catch (e) { const base = diff --git a/web/src/Recover.tsx b/web/src/Recover.tsx index 33fc8e1..dbc852f 100644 --- a/web/src/Recover.tsx +++ b/web/src/Recover.tsx @@ -108,7 +108,8 @@ export function Recover({ try { // No register here: the identity is already in the allowlist. Just re-encrypt // locally and open the session as the recovered user. - const user = await saveAndOpen(identity, handle.trim(), pw); + // Recovering on this device implies it is yours: keep the session by default. + const user = await saveAndOpen(identity, handle.trim(), pw, true); onRecovered(user); } catch (e) { setError( diff --git a/web/src/WalletLogin.tsx b/web/src/WalletLogin.tsx index cd7ff98..058d443 100644 --- a/web/src/WalletLogin.tsx +++ b/web/src/WalletLogin.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core"; +import { Anchor, Button, Checkbox, Group, PasswordInput, Text } from "@mantine/core"; import { IconKey, IconWallet } from "@tabler/icons-react"; import { AuthCard, AuthHeader } from "./AuthShell"; import { SessionError } from "./busService"; @@ -20,6 +20,7 @@ export function WalletLogin({ onRecover: () => void; }) { const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -28,7 +29,7 @@ export function WalletLogin({ setBusy(true); setError(null); try { - const user = await unlockAndOpen(password); + const user = await unlockAndOpen(password, remember); onLoggedIn(user); } catch (e) { if (e instanceof WrongPasswordError) { @@ -60,6 +61,12 @@ export function WalletLogin({ onKeyDown={(e) => e.key === "Enter" && void unlock()} data-autofocus /> + setRemember(e.currentTarget.checked)} + /> {error && ( {error} diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts index c74cf7f..fd4823a 100644 --- a/web/src/bus/client.ts +++ b/web/src/bus/client.ts @@ -138,6 +138,10 @@ export type MessageHandler = (subject: string, data: Uint8Array) => void; export interface NatsTransport { publish(subject: string, data: Uint8Array): void | Promise; subscribe(subject: string, handler: MessageHandler): Promise; + // reconnect rebuilds the connection so the server's per-subject ACL re-evaluates + // this peer's room membership (a room created after connecting is otherwise not in + // the grant). Active subscriptions are dropped; re-subscribe after calling it. + reconnect(): Promise; close(): Promise; } @@ -367,4 +371,13 @@ export class BusClient { private async loadSigners(roomID: string): Promise { this.signCache.set(roomID, await this.control.signerKeys(roomID)); } + + // refresh reconnects the data plane so the server's per-subject ACL re-evaluates + // this peer's room membership. Call it after creating or joining a room while + // connected: NATS freezes a connection's publishable/subscribable subjects at + // connect time, so the new room's subject only becomes usable on a fresh + // connection. Active subscriptions are dropped — re-subscribe afterwards. + async refresh(): Promise { + await this.transport.reconnect(); + } } diff --git a/web/src/bus/wstransport.ts b/web/src/bus/wstransport.ts index 1db0b09..74c525f 100644 --- a/web/src/bus/wstransport.ts +++ b/web/src/bus/wstransport.ts @@ -14,17 +14,39 @@ import type { Identity, NatsTransport, MessageHandler, Subscription } from "./cl import { natsAuthenticator } from "./busauth.js"; export class WsNatsTransport implements NatsTransport { - private constructor(private nc: NatsConnection) {} + // servers + id are retained so reconnect() can rebuild the connection with the same + // identity — needed because the per-subject ACL freezes a peer's publishable/ + // subscribable subjects at connect time, so a room created after connecting only + // becomes usable after a fresh connection re-evaluates membership. + private constructor( + private nc: NatsConnection, + private servers: string[], + private id: Identity, + ) {} - // connect opens a WebSocket connection to one of the given ws(s):// servers, - // authenticating with the user's nkey identity. - static async connect(servers: string[], id: Identity): Promise { + private static newConn(servers: string[], id: Identity): Promise { const sign = natsAuthenticator(id.signPub, id.signPriv); // nats.ws's Authenticator returns the nkey + the base64url signature of the // server nonce; our natsAuthenticator produces exactly that shape. const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? ""); - const nc = await connect({ servers, authenticator }); - return new WsNatsTransport(nc); + return connect({ servers, authenticator }); + } + + // connect opens a WebSocket connection to one of the given ws(s):// servers, + // authenticating with the user's nkey identity. + static async connect(servers: string[], id: Identity): Promise { + const nc = await WsNatsTransport.newConn(servers, id); + return new WsNatsTransport(nc, servers, id); + } + + // reconnect drops the current connection and opens a fresh one with the same + // identity, so the server's subject-ACL re-evaluates this peer's room membership. + // Active subscriptions from the previous connection are lost; the caller must + // re-subscribe (BusClient.subscribe) to the rooms it cares about afterwards. + async reconnect(): Promise { + const old = this.nc; + this.nc = await WsNatsTransport.newConn(this.servers, this.id); + await old.close().catch(() => {}); } publish(subject: string, data: Uint8Array): void { diff --git a/web/src/busService.ts b/web/src/busService.ts index eb87d47..ed1d4fd 100644 --- a/web/src/busService.ts +++ b/web/src/busService.ts @@ -23,6 +23,7 @@ import { } from "./bus/index"; import type { WalletIdentity } from "./wallet/derive"; import type { MeInfo, Message, Room, User } from "./types"; +import { saveSession, loadSession, touchSession, clearSession } from "./session"; // Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy): // both planes are reached through this page's OWN origin, so there is no CORS and @@ -76,19 +77,49 @@ function require_(): Session { return session; } +// connectSession opens the live bus connection (control plane + nats.ws data plane) +// for a wallet identity, WITHOUT touching persistence. The private key is used here +// in the browser and never leaves it. +async function connectSession(wallet: WalletIdentity, handle: string): Promise { + const identity = toIdentity(wallet); + const endpoint = endpointID(identity.signPub); + const control = new ControlPlane(BUS_HTTP, identity); + const transport = await WsNatsTransport.connect([BUS_WS], identity); + const client = new BusClient(identity, transport, control); + session = { identity, handle, endpoint, control, transport, client }; + return { id: endpoint, handle: handle || endpoint.slice(0, 8) }; +} + export const bus = { - // openSession connects to the bus AS this wallet user: it builds the signed - // control-plane client and the nats.ws data-plane connection in the browser. The - // private key never leaves — this is the fix for the old gateway model where the - // browser POSTed its private key to /api/session. - async openSession(wallet: WalletIdentity, handle: string): Promise { - const identity = toIdentity(wallet); - const endpoint = endpointID(identity.signPub); - const control = new ControlPlane(BUS_HTTP, identity); - const transport = await WsNatsTransport.connect([BUS_WS], identity); - const client = new BusClient(identity, transport, control); - session = { identity, handle, endpoint, control, transport, client }; - return { id: endpoint, handle: handle || endpoint.slice(0, 8) }; + // openSession connects to the bus AS this wallet user and persists the session so a + // reload does not force a password re-unlock. remember=true keeps it across closing + // the browser (localStorage, up to 30 days / 12 h idle); false keeps it only for the + // tab (sessionStorage, survives F5). The private key never leaves the browser — this + // is the fix for the old gateway model where the browser POSTed its private key. + async openSession(wallet: WalletIdentity, handle: string, remember = false): Promise { + const user = await connectSession(wallet, handle); + saveSession(wallet, handle, remember); + return user; + }, + + // restoreSession re-opens a previously persisted session on page load, if one exists + // and has not expired (TTL/idle checked in loadSession). It does NOT re-save (so the + // absolute 30-day TTL is not renewed on every reload) — it only refreshes the idle + // timer. Returns the User on success, or null when there is nothing to restore. + async restoreSession(): Promise { + const persisted = loadSession(); + if (!persisted) return null; + try { + const user = await connectSession(persisted.wallet, persisted.handle); + touchSession(); // restart the idle window; keep createdAt (TTL) intact + return user; + } catch { + // Connection failed (offline, identity revoked, ...): drop the stale session so + // the router falls back to the password unlock rather than looping. + clearSession(); + session = null; + return null; + } }, // me returns the identity of the active session (was GET /api/me). @@ -97,8 +128,10 @@ export const bus = { return { endpoint: s.endpoint, sign_pub: "", handle: s.handle }; }, - // logout closes the data-plane connection and drops the session. + // logout closes the data-plane connection, drops the in-memory session, and clears + // the persisted session from both stores so it cannot be restored. async logout(): Promise { + clearSession(); if (session) { await session.transport.close().catch(() => {}); session = null; @@ -121,10 +154,14 @@ export const bus = { }, // createRoom creates an encrypted, signed room owned by this peer (the Matrix-like - // default). Returns the UI Room. + // default), then reconnects the data plane so the new room's subject enters this + // connection's ACL grant — otherwise publish/subscribe on a just-created room would + // silently not deliver until a reconnect/re-login. Returns the UI Room. async createRoom(subject: string): Promise { const s = require_(); const { roomID } = await s.control.createRoom(subject, ModeMatrix); + await s.client.refresh(); // re-evaluate the per-subject ACL with the new room + touchSession(); return { id: roomID, name: subject, encrypted: true, lastMessage: "", lastTs: 0, unread: 0, messages: [] }; }, @@ -133,6 +170,7 @@ export const bus = { async send(roomID: string, body: string): Promise { const s = require_(); await s.client.publish(roomID, new TextEncoder().encode(body)); + touchSession(); // user activity: restart the idle auto-lock window }, // subscribeRoom delivers decrypted, verified messages for a room (replaces the old diff --git a/web/src/session.ts b/web/src/session.ts new file mode 100644 index 0000000..8101cae --- /dev/null +++ b/web/src/session.ts @@ -0,0 +1,105 @@ +// Session persistence for the SPA. The bus session (the unlocked wallet identity) +// normally lives only in memory, so a page reload — even an F5 — drops it and forces +// a password re-unlock. This module keeps the session usable across reloads without +// ever sending anything to the network. +// +// Storage choice and its trade-off: +// - By DEFAULT the session is kept in sessionStorage: it survives an F5 but is +// cleared when the tab/window closes. This already fixes the "logs out on +// refresh" annoyance at minimal risk. +// - When the user ticks "keep me signed in" (remember=true) it is kept in +// localStorage instead: it survives closing the tab and the browser, until it +// EXPIRES or the user logs out. +// +// We never use a cookie: the wallet's private key must not travel to any server, and +// a cookie rides every request. The persisted value (the decrypted hex identity) +// stays on the device and is read only by this origin's own code. +// +// Two time bounds keep the persisted private key from living unbounded on disk: +// - TTL: an absolute lifetime (30 days). After it, re-unlock with the password. +// - IDLE: an inactivity auto-lock (12 h). Activity calls touchSession(); after 12 h +// with no activity the session re-locks even if the TTL has not elapsed. + +import type { WalletIdentity } from "./wallet/derive"; + +const KEY = "unibus-session"; +const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days absolute lifetime +const IDLE_MS = 12 * 60 * 60 * 1000; // 12 h inactivity auto-lock + +interface PersistedSession { + // The decrypted wallet identity (hex), INCLUDING the private halves. This is the + // sensitive part that lives on the device so the user need not re-enter the + // password on every reload. Bounded by TTL_MS + IDLE_MS and cleared on logout. + wallet: WalletIdentity; + handle: string; + remember: boolean; + createdAt: number; + lastActivity: number; +} + +function stores(): Storage[] { + // Guard for SSR/tests where window is absent. + if (typeof window === "undefined") return []; + return [window.localStorage, window.sessionStorage]; +} + +// saveSession persists the unlocked identity. remember=true uses localStorage +// (survives closing the browser); false uses sessionStorage (cleared with the tab). +export function saveSession(wallet: WalletIdentity, handle: string, remember: boolean): void { + clearSession(); // never keep it in both stores at once + const target = remember ? window.localStorage : window.sessionStorage; + const s: PersistedSession = { + wallet, + handle, + remember, + createdAt: Date.now(), + lastActivity: Date.now(), + }; + try { + target.setItem(KEY, JSON.stringify(s)); + } catch { + /* storage full/blocked: fall back to memory-only (no persistence) */ + } +} + +// loadSession returns the persisted identity if one exists and is still valid (not +// past its TTL and not idle-expired), otherwise null. An expired entry is removed. +export function loadSession(): { wallet: WalletIdentity; handle: string; remember: boolean } | null { + for (const st of stores()) { + const raw = st.getItem(KEY); + if (!raw) continue; + try { + const s = JSON.parse(raw) as PersistedSession; + const now = Date.now(); + if (now - s.createdAt > TTL_MS || now - s.lastActivity > IDLE_MS) { + st.removeItem(KEY); // expired by TTL or idle auto-lock + continue; + } + return { wallet: s.wallet, handle: s.handle, remember: s.remember }; + } catch { + st.removeItem(KEY); // corrupt entry + } + } + return null; +} + +// touchSession refreshes the last-activity timestamp so the idle auto-lock window +// restarts. Call it on meaningful user activity (sending, navigating rooms). +export function touchSession(): void { + for (const st of stores()) { + const raw = st.getItem(KEY); + if (!raw) continue; + try { + const s = JSON.parse(raw) as PersistedSession; + s.lastActivity = Date.now(); + st.setItem(KEY, JSON.stringify(s)); + } catch { + /* ignore */ + } + } +} + +// clearSession removes the persisted session from both stores (logout / lock). +export function clearSession(): void { + for (const st of stores()) st.removeItem(KEY); +} diff --git a/web/src/wallet/account.ts b/web/src/wallet/account.ts index f0dd1de..4a2ad91 100644 --- a/web/src/wallet/account.ts +++ b/web/src/wallet/account.ts @@ -18,6 +18,7 @@ export async function saveAndOpen( identity: WalletIdentity, handle: string, password: string, + remember = false, ): Promise { const enc = await encryptJSON(identity, password); await putIdentity({ @@ -27,17 +28,17 @@ export async function saveAndOpen( enc, createdAt: Date.now(), }); - return bus.openSession(identity, handle); + return bus.openSession(identity, handle, remember); } // unlockAndOpen reads this device's stored identity, decrypts the private key with // `password`, and opens a bus session locally. Throws WrongPasswordError on a bad // password (GCM auth failure) and NoLocalIdentityError if the device has none. -export async function unlockAndOpen(password: string): Promise { +export async function unlockAndOpen(password: string, remember = false): Promise { const stored = await getIdentity(); if (!stored) throw new NoLocalIdentityError(); const identity = await decryptJSON(stored.enc, password); - return bus.openSession(identity, stored.handle); + return bus.openSession(identity, stored.handle, remember); } // localIdentity returns the device's stored identity record (or null), for the