diff --git a/Makefile b/Makefile index bd60d06..25dab0a 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm BIN := sovereign_browser -SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c +SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c # Web files embedded into the binary as C byte arrays. WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null) diff --git a/VERSION b/VERSION index c97e08f..8e0e3bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.54 +0.0.55 diff --git a/artifacts/mcp-after-restart.png b/artifacts/mcp-after-restart.png new file mode 100644 index 0000000..0c80d32 Binary files /dev/null and b/artifacts/mcp-after-restart.png differ diff --git a/artifacts/mcp-bookmark-toolbar-check.png b/artifacts/mcp-bookmark-toolbar-check.png new file mode 100644 index 0000000..f1fb238 Binary files /dev/null and b/artifacts/mcp-bookmark-toolbar-check.png differ diff --git a/artifacts/mcp-browser-full.png b/artifacts/mcp-browser-full.png new file mode 100644 index 0000000..2f46bac Binary files /dev/null and b/artifacts/mcp-browser-full.png differ diff --git a/artifacts/mcp-header-white-check.png b/artifacts/mcp-header-white-check.png new file mode 100644 index 0000000..1102038 Binary files /dev/null and b/artifacts/mcp-header-white-check.png differ diff --git a/plans/webkit-data-isolation.md b/plans/webkit-data-isolation.md new file mode 100644 index 0000000..e9e2495 --- /dev/null +++ b/plans/webkit-data-isolation.md @@ -0,0 +1,224 @@ +# WebKit Website Data Isolation Per Nostr Identity + +**Status: IMPLEMENTED (Phase 0 + Phase A, verified end-to-end).** + +## Problem + +When a user logs in, visits a page, logs out, then logs in as a different +identity and revisits the same page, the second user sees the first user's +data (session cookies, localStorage, cached responses, service workers). + +## Root Cause + +The browser uses the **default** [`WebKitWebContext`](src/main.c:977) and its +attached [`WebKitWebsiteDataManager`](src/main.c:993) for the entire process +lifetime. That single data manager holds, process-wide: + +- HTTP disk + memory cache +- Cookies (the primary leak — session cookies identify you to web pages) +- localStorage / sessionStorage / IndexedDB / WebSQL +- Service worker registrations + offline app cache +- Favicon database ([`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003)) + +The logout / identity-switch paths only clear the Nostr signer and do **nothing** +to WebKit's data stores or to the already-loaded tabs: + +- [`app_menu_logout_proxy`](src/main.c:186) — frees `g_state.signer`, blanks pubkey, returns. +- [`agent_logout`](src/agent_login.c:485) — `app_clear_signer()` + `nostr_bridge_set_signer(NULL, ...)`. +- [`agent_switch_identity`](src/agent_login.c:492) — frees old signer, logs in new, no tab reload. + +Two compounding problems: + +1. **Persistent WebKit data** survives the identity switch. +2. **Live tabs** keep user A's DOM/localStorage in memory; wiping the data + manager alone does not clear already-loaded pages. + +The existing [`plans/per-user-profiles.md`](plans/per-user-profiles.md) isolates +the SQLite layer (bookmarks, history, session, Nostr events) but does **not** +cover WebKit's own storage — this plan closes that gap. + +## Solution (chosen: per-user data manager + clear-on-switch safety net) + +### Part A — Per-user `WebKitWebsiteDataManager` + +Give each pubkey its own data manager rooted at +`~/.sovereign_browser/profiles//webkit/` so cookies, cache, storage, +service workers, and favicons persist per-user and never cross-contaminate. + +WebKitGTK constraint: a `WebKitWebContext` is bound to **one** +`WebKitWebsiteDataManager` for its lifetime — you cannot swap the data manager +on an existing context. Two viable strategies: + +**Strategy 1 (preferred): per-user `WebKitWebContext`.** +Create a fresh `webkit_web_context_new_with_website_data_manager(dm)` for each +login. All tabs/webviews are created from this context. On logout/switch, +destroy the old context (which tears down its webviews) and build a new one. +This is the cleanest isolation and matches how Epiphary/GNOME Web does +per-profile isolation. + +**Strategy 2 (fallback): single context, manual clear + per-user dirs.** +Keep the default context but on each login call +`webkit_website_data_manager_clear(dm, WEBKIT_WEBSITE_DATA_ALL, 0, NULL, NULL, NULL)` +then point the cookie/cache/storage dirs at the per-user path. This is fragile +because the context caches some state internally and the favicon DB directory +is set once at context init. + +Recommend **Strategy 1**. It requires reworking how the context is created and +how `tab_manager` / `nostr_bridge` / `tor_scheme` / `nostr_scheme` / `net_services` +reference it (they currently call `webkit_web_context_get_default()` or receive +the context at init), but it gives true isolation. + +### Part B — Clear-on-switch safety net + +Even with per-user data managers, the **live tabs** (and the agent chat sidebar +webview) hold user A's DOM/localStorage in memory. On any identity change: + +1. Close all tabs: `tab_manager_close_all()` ([`tab_manager.c:3727`](src/tab_manager.c)). +2. Destroy the agent chat sidebar webview if present. +3. (Strategy 1) Destroy the old `WebKitWebContext` + data manager. +4. Build the new per-user context + data manager. +5. Re-register URI schemes (`sovereign://`, `nostr://`, `tor://`) on the new + context — they are per-context. +6. Re-init `nostr_bridge`, `nostr_scheme`, `tor_scheme`, `net_services` against + the new context. +7. Re-init `tab_manager` against the new context (or expose a + `tab_manager_set_context()`). +8. Restore the new user's session (`session_restore()`) or open the default + new-tab URL. + +## Architecture + +```mermaid +flowchart TD + A[Login complete - pubkey known] --> B[profile_ensure_dir pubkey] + B --> C[Build per-user WebKitWebsiteDataManager
rooted at profiles/pubkey/webkit] + C --> D[Build per-user WebKitWebContext
new_with_website_data_manager] + D --> E[Register sovereign/nostr/tor schemes on new ctx] + E --> F[Re-init nostr_bridge, net_services, tab_manager on new ctx] + F --> G[session_restore or open new-tab URL] + + H[Logout / switch_identity] --> I[tab_manager_close_all] + I --> J[Destroy sidebar webview] + J --> K[Destroy old WebKitWebContext + data manager] + K --> L{switch or logout?} + L -->|switch| A + L -->|logout| M[Show login dialog / wait for agent login] + M --> A +``` + +## Implementation Steps + +### 1. Profile helpers for WebKit data dir +In [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) add: +- `profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz)` + → `~/.sovereign_browser/profiles//webkit/` +- `profile_ensure_webkit_dir(const char *pubkey_hex)` — mkdir -p. + +### 2. New module `src/web_context.c` / `src/web_context.h` +Centralize context + data manager construction so login/logout/switch share +one code path. Functions: +- `web_context_build_for_pubkey(const char *pubkey_hex)` → returns a new + `WebKitWebContext*` with a per-user `WebKitWebsiteDataManager` (cookies, + cache, local_storage, indexed_db, websql, offline_app_cache, service_worker + registrations, itp, hsts all pointed at the per-user webkit dir), favicon + DB enabled, TLS errors ignored, security manager configured + (sovereign/tor/file secure+local). +- `web_context_teardown(WebKitWebContext *ctx)` — unrefs context + data + manager, frees per-user state. +- Holds the current context pointer (`g_web_ctx`) so the rest of the codebase + can fetch it without `webkit_web_context_get_default()`. + +### 3. Refactor scheme/bridge/service init to be re-runnable +Currently these are one-shot inits tied to the default context. Make them +accept a `WebKitWebContext*` and be safe to call again on a new context: +- [`nostr_bridge_register`](src/nostr_bridge.c) — already takes `ctx`; ensure + it can be called twice (track prior registration, disconnect old handlers). +- [`nostr_scheme_register`](src/nostr_scheme.c) — make it take `ctx` (currently + uses default) and idempotent. +- [`tor_scheme_register`](src/tor_scheme.c) — same. +- [`net_services_init`](src/net_services.c) — currently calls + `webkit_web_context_get_default()` internally ([`net_services.c:69`](src/net_services.c)); + pass `ctx` in instead. + +### 4. Refactor `tab_manager` to support context swap +[`tab_manager_init`](src/tab_manager.h:46) currently stores the context once. +Add: +- `tab_manager_set_context(WebKitWebContext *ctx)` — updates the stored + context so subsequent `tab_manager_new_tab` calls use the new context. + Existing tabs are already closed (Part B step 1) before this is called. +- Or simpler: tear down and re-init `tab_manager` on each switch. Pick whichever + is less invasive given the static globals in [`tab_manager.c`](src/tab_manager.c). + +### 5. Wire the login/logout/switch flows +- [`app_menu_logout_proxy`](src/main.c:186): after clearing the signer, run the + teardown sequence (close all tabs, destroy sidebar, destroy context) then + re-show the login dialog. On successful login, run the build sequence. +- [`agent_logout`](src/agent_login.c:485): same teardown, then leave the + browser in a logged-out state (no context, or a minimal ephemeral context + showing a "logged out" page). +- [`agent_switch_identity`](src/agent_login.c:492): teardown old, build new + with the new pubkey, restore session. +- Initial startup in [`main.c`](src/main.c:974): replace + `webkit_web_context_get_default()` with `web_context_build_for_pubkey()` + after login completes (this means deferring context creation until after + the login dialog — reordering the startup sequence). + +### 6. Favicon database per user +[`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003) +currently uses the default shared location. In `web_context_build_for_pubkey`, +pass the per-user webkit dir (or a `favicons/` subdir) so favicons don't leak +between users. + +### 7. Read-only / no-login mode +When running with `--no-login` or read-only, there is no pubkey to key the +data dir on. Use an ephemeral data manager +(`webkit_website_data_manager_new_ephemeral()`) so no data persists at all, +or a shared `~/.sovereign_browser/webkit-anon/` dir. Decide and document. + +### 8. Migration +Existing users have data in WebKit's default location +(`~/.cache/sovereign_browser/` or similar). On first login with the new +system, optionally copy the default WebKit data dir into +`profiles//webkit/` so they keep their cookies/cache. Low priority — +can ship without migration and just start fresh per user. + +### 9. Tests +- Manual: log in as user A, visit a site that sets a cookie/localStorage, + log out, log in as user B, visit the same site, confirm user A's data is + gone and user B gets a fresh session. +- Add a test page under [`tests/local-site/`](tests/local-site) that writes + a visible marker to localStorage + a cookie, so this is reproducible. +- Verify `sovereign://`, `nostr://`, `tor://` schemes still work after a + switch (they are re-registered on the new context). +- Verify the agent chat sidebar works after a switch (its webview is rebuilt + on the new context). + +## Files to Modify + +- New: `src/web_context.c`, `src/web_context.h` +- [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) — webkit dir helpers +- [`src/main.c`](src/main.c) — startup ordering, logout proxy, use `web_context_*` +- [`src/agent_login.c`](src/agent_login.c) — `agent_logout`, `agent_switch_identity` +- [`src/tab_manager.c`](src/tab_manager.c) / [`src/tab_manager.h`](src/tab_manager.h) — context swap support, sidebar teardown +- [`src/nostr_bridge.c`](src/nostr_bridge.c) — re-runnable registration +- [`src/nostr_scheme.c`](src/nostr_scheme.c) — accept ctx, idempotent +- [`src/tor_scheme.c`](src/tor_scheme.c) — accept ctx, idempotent +- [`src/net_services.c`](src/net_services.c) — accept ctx instead of default +- [`Makefile`](Makefile) — add `web_context.o` +- New test page: `tests/local-site/identity-leak-test.html` + +## Risk Assessment + +- **High risk** — changes how the `WebKitWebContext` is created and tears down, + which touches every subsystem that references the context. +- **Startup reordering** — context creation must move to *after* login, which + changes the long-standing flow in [`main.c`](src/main.c). +- **Re-runnable scheme registration** — WebKit may not support unregistering + scheme handlers cleanly; verify that building a fresh context and + re-registering works without leaking the old context. +- **Sidebar webview** — the agent chat sidebar ([`tab_manager_toggle_sidebar`](src/tab_manager.h:198)) + keeps a long-lived webview; must be destroyed on context swap or it will + hold a ref to the dead context. +- **Mitigation**: implement Part B (clear-on-switch) first as a standalone + change — it alone fixes the leak even without per-user dirs. Ship that, + then layer Part A (per-user data managers) on top. diff --git a/src/agent_login.c b/src/agent_login.c index e8147a1..de96017 100644 --- a/src/agent_login.c +++ b/src/agent_login.c @@ -28,6 +28,12 @@ extern const char *app_get_pubkey_hex(void); extern key_store_method_t app_get_method(void); extern gboolean app_get_readonly(void); +/* Defined in main.c. Tears down the current user's web state (closes all + * tabs, destroys sidebar webviews, wipes WebKit cookies/cache/localStorage/ + * service workers/favicons) so the next identity starts clean. See + * plans/webkit-data-isolation.md. */ +extern void identity_teardown_web_state(void); + /* Track whether the agent (not the GTK dialog) performed the login. */ static gboolean g_agent_performed_login = FALSE; @@ -379,6 +385,12 @@ static cJSON *login_nsigner(cJSON *params) { return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube."); } + /* n_signer's serial/TCP transports require a kind-27235 auth envelope on + * every request. Install the default caller identity (matching n_signer's + * webserial demo) before issuing any verbs. Without this the device + * rejects the call with an auth error that surfaces as "code -310". */ + key_store_nsigner_set_default_auth(signer); + int rc = nostr_signer_nsigner_set_nostr_index(signer, index); if (rc != NOSTR_SUCCESS) { nostr_signer_free(signer); @@ -389,9 +401,30 @@ static cJSON *login_nsigner(cJSON *params) { char pubkey_hex[65]; rc = nostr_signer_get_public_key(signer, pubkey_hex); if (rc != NOSTR_SUCCESS) { + const char *err_str = nostr_strerror(rc); + g_printerr("[agent-login] n_signer get_public_key failed: rc=%d (%s)\n", + rc, err_str ? err_str : "?"); nostr_signer_free(signer); sigprocmask(SIG_SETMASK, &old_set, NULL); - return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer."); + if (rc == NOSTR_ERROR_NIP46_AUTH_CHALLENGE) { + /* Device RPC codes 2010-2017: auth-related rejection. Most + * commonly the kind-27235 caller auth envelope was missing or + * denied (the default caller identity is installed automatically, + * but the device's policy may still deny it). Can also mean the + * device is requesting on-device approval. */ + return make_error("NSIGNER_AUTH_REJECTED", + "n_signer auth rejected (code -310). The caller " + "auth envelope was missing or denied, or the " + "device is requesting on-device approval. Confirm " + "any prompt on the hardware signer, then retry."); + } + { + char msg[256]; + snprintf(msg, sizeof(msg), + "Failed to get pubkey from n_signer (rc=%d: %s).", + rc, err_str ? err_str : "unknown"); + return make_error("NSIGNER_PUBKEY", msg); + } } sigprocmask(SIG_SETMASK, &old_set, NULL); @@ -483,6 +516,13 @@ cJSON *agent_login(cJSON *params) { } cJSON *agent_logout(void) { + /* Tear down the current user's web state (closes all tabs, destroys + * sidebar webviews, wipes WebKit cookies/cache/localStorage/service + * workers/favicons) so the next login starts with a clean web + * session. Must happen before clearing the signer. See + * plans/webkit-data-isolation.md. */ + identity_teardown_web_state(); + app_clear_signer(); nostr_bridge_set_signer(NULL, "", TRUE); g_print("[agent-login] Logged out\n"); @@ -490,6 +530,12 @@ cJSON *agent_logout(void) { } cJSON *agent_switch_identity(cJSON *params) { + /* Tear down the previous user's web state BEFORE freeing the signer + * and logging in the new identity, so the new user starts with a + * clean web session (no leftover cookies/localStorage/tabs from the + * previous user). See plans/webkit-data-isolation.md. */ + identity_teardown_web_state(); + /* Free the old signer first. */ app_clear_signer(); /* Then login with the new identity. */ diff --git a/src/bookmarks.c b/src/bookmarks.c index 6b8d13c..46b9bfc 100644 --- a/src/bookmarks.c +++ b/src/bookmarks.c @@ -31,16 +31,10 @@ static nostr_signer_t *g_signer = NULL; static char g_pubkey[65] = {0}; -static char g_privkey[65] = {0}; /* hex, in-memory only */ static int g_have_signer = 0; -static int g_have_privkey = 0; static bookmark_node_t g_root = {0}; -/* HMAC key cache (32 bytes). Derived once in bookmarks_init from the privkey. */ -static unsigned char g_hmac_key[32]; -static int g_have_hmac_key = 0; - /* Change-notification subscribers. */ #define BOOKMARKS_MAX_SUBS 8 static bookmarks_changed_cb g_subs[BOOKMARKS_MAX_SUBS]; @@ -188,47 +182,41 @@ static void node_remove_child(bookmark_node_t *parent, int idx) { /* ── HMAC d-tag derivation ─────────────────────────────────────────── */ -/* Compute the per-user HMAC key from the privkey. - * hmac_key = HMAC-SHA256(privkey_bytes, BOOKMARKS_HMAC_KEY_LABEL) - * Caches the result in g_hmac_key. Returns 0 on success, -1 on error. */ -static int compute_hmac_key(void) { - if (g_have_hmac_key) return 0; - if (!g_have_privkey || g_privkey[0] == '\0') return -1; - - unsigned char priv[32]; - if (nostr_hex_to_bytes(g_privkey, priv, 32) != 0) { - g_printerr("[bookmarks] Invalid privkey hex\n"); - return -1; - } - - if (nostr_hmac_sha256(priv, 32, - (const unsigned char *)BOOKMARKS_HMAC_KEY_LABEL, - strlen(BOOKMARKS_HMAC_KEY_LABEL), - g_hmac_key) != 0) { - g_printerr("[bookmarks] HMAC-SHA256 key derivation failed\n"); - return -1; - } - g_have_hmac_key = 1; - return 0; -} - -/* Compute the opaque d tag for a path: HMAC-SHA256(hmac_key, path) → hex. - * Returns a newly allocated 64-char hex string (caller frees), or NULL. */ +/* Compute the opaque d tag for a path using a single-step HMAC keyed by the + * signer's secp256k1 private key: + * d = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL + ":" + path) → hex + * + * This is computed remotely via nostr_signer_derive_hmac, so the privkey + * never leaves the signer (n_signer or local). The label prefix provides + * domain separation so the same path used by a different application + * produces a different d tag. + * + * Returns a newly allocated 64-char hex string (caller frees), or NULL. + * + * If no signer is available, falls back to the legacy plaintext d tag + * (the path itself) so bookmarks can still be published in read-only mode. + * The loader recognises both formats. */ static char *path_to_d_tag(const char *path) { - if (compute_hmac_key() != 0) return NULL; if (path == NULL) path = ""; - unsigned char mac[32]; - if (nostr_hmac_sha256(g_hmac_key, 32, - (const unsigned char *)path, strlen(path), - mac) != 0) { - return NULL; + if (!g_have_signer || g_signer == NULL) { + /* No signer — use the plaintext path as the d tag (read-only mode). */ + return g_strdup(path); } - char *hex = g_malloc(65); - if (hex == NULL) return NULL; - nostr_bytes_to_hex(mac, 32, hex); - hex[64] = '\0'; - return hex; + + /* Build "LABEL:path" — single-step domain-separated HMAC input. */ + char *data = g_strdup_printf("%s:%s", BOOKMARKS_HMAC_KEY_LABEL, path); + if (data == NULL) return NULL; + + char digest_hex[65]; + int rc = nostr_signer_derive_hmac(g_signer, data, digest_hex); + g_free(data); + if (rc != NOSTR_SUCCESS) { + g_printerr("[bookmarks] derive_hmac failed (rc=%d); falling back to plaintext d tag\n", rc); + return g_strdup(path); + } + + return g_strdup(digest_hex); } /* Returns 1 if s is a 64-char lowercase-hex string (i.e. an HMAC d tag), @@ -521,8 +509,7 @@ void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data) { /* ── Public API ────────────────────────────────────────────────────── */ -int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex, - const char *privkey_hex) { +int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) { g_signer = signer; g_have_signer = (signer != NULL); if (pubkey_hex) { @@ -530,14 +517,6 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex, } else { g_pubkey[0] = '\0'; } - if (privkey_hex && privkey_hex[0]) { - snprintf(g_privkey, sizeof(g_privkey), "%s", privkey_hex); - g_have_privkey = 1; - } else { - g_privkey[0] = '\0'; - g_have_privkey = 0; - } - g_have_hmac_key = 0; /* recompute on first use */ /* Initialize the root node. */ if (g_root.name == NULL) { @@ -617,12 +596,19 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex, scratch.bookmark_count = 0; } - /* Legacy migration: re-publish with HMAC d tag and delete old. */ - if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) { - g_print("[bookmarks] Migrating legacy folder '%s' to HMAC d tag\n", - path); - if (leaf) publish_node(leaf); - publish_deletion_for_d(d_value); + /* Migration: re-publish with the current single-step HMAC d + * tag and delete the old event if the d tag doesn't match. + * This covers both legacy plaintext d tags AND old two-step + * HMAC d tags (from before the single-step switch). */ + if (g_have_signer && path != NULL && path[0] != '\0') { + char *new_d = path_to_d_tag(path); + if (new_d != NULL && strcmp(new_d, d_value) != 0) { + g_print("[bookmarks] Migrating folder '%s' to current HMAC d tag\n", + path); + if (leaf) publish_node(leaf); + publish_deletion_for_d(d_value); + } + g_free(new_d); } g_free(path_from_content); @@ -635,9 +621,8 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex, } } - g_print("[bookmarks] Initialized: signer=%s privkey=%s\n", - g_have_signer ? "yes" : "no", - g_have_privkey ? "yes" : "no"); + g_print("[bookmarks] Initialized: signer=%s\n", + g_have_signer ? "yes" : "no"); return 0; } @@ -654,9 +639,6 @@ void bookmarks_cleanup(void) { g_signer = NULL; g_have_signer = 0; g_pubkey[0] = '\0'; - g_privkey[0] = '\0'; - g_have_privkey = 0; - g_have_hmac_key = 0; g_sub_count = 0; } @@ -724,6 +706,32 @@ int bookmarks_remove(const char *path, const char *url) { return rc; } +int bookmarks_rename(const char *path, const char *url, const char *new_title) { + if (!g_have_signer) { + g_printerr("[bookmarks] No signer, cannot rename\n"); + return -1; + } + if (path == NULL || path[0] == '\0') path = "General"; + if (url == NULL || url[0] == '\0') return -1; + + bookmark_node_t *leaf = node_find_path(&g_root, path); + if (leaf == NULL) return -1; + + int bidx = find_bookmark_in_node(leaf, url); + if (bidx < 0) return -1; + + /* Replace the title in place. The URL and added timestamp are + * preserved — only the user-editable label changes. */ + char *old_title = leaf->bookmarks[bidx].title; + leaf->bookmarks[bidx].title = g_strdup(new_title ? new_title : ""); + g_free(old_title); + + g_print("[bookmarks] Renamed bookmark '%s' in '%s'\n", url, path); + int rc = publish_node(leaf); + notify_changed(); + return rc; +} + int bookmarks_move(const char *from_path, const char *url, const char *to_path) { if (!g_have_signer) return -1; if (from_path == NULL || from_path[0] == '\0') from_path = "General"; @@ -1072,10 +1080,16 @@ int bookmarks_store_and_load_event(const void *event_cjson) { scratch.bookmark_count = 0; } - /* Legacy migration. */ - if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) { - if (leaf) publish_node(leaf); - publish_deletion_for_d(d_value); + /* Migration: re-publish with the current single-step HMAC d tag and + * delete the old event if the d tag doesn't match. Covers both legacy + * plaintext d tags and old two-step HMAC d tags. */ + if (g_have_signer && path != NULL && path[0] != '\0') { + char *new_d = path_to_d_tag(path); + if (new_d != NULL && strcmp(new_d, d_value) != 0) { + if (leaf) publish_node(leaf); + publish_deletion_for_d(d_value); + } + g_free(new_d); } g_free(path_from_content); diff --git a/src/bookmarks.h b/src/bookmarks.h index bb8c6cb..2879f5a 100644 --- a/src/bookmarks.h +++ b/src/bookmarks.h @@ -67,18 +67,16 @@ typedef struct bookmark_node { * Initialize the bookmarks module. Loads cached bookmarks from the SQLite * database and decrypts them using the signer. * - * signer — the user's nostr_signer_t (NULL for read-only/no-login) + * signer — the user's nostr_signer_t (NULL for read-only/no-login). + * The signer holds the private key; d tags are derived via + * nostr_signer_derive_hmac, so the privkey never lives in + * browser memory. * pubkey_hex — the user's hex pubkey (64 chars) or NULL - * privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive - * the HMAC key for opaque d tags. NULL in read-only mode - * (existing events can still be loaded from the db, but - * no new events can be published). * * Returns 0 on success, -1 on error. */ int bookmarks_init(nostr_signer_t *signer, - const char *pubkey_hex, - const char *privkey_hex); + const char *pubkey_hex); /* Free the in-memory bookmark tree. Call at shutdown. */ void bookmarks_cleanup(void); @@ -107,6 +105,12 @@ int bookmarks_add(const char *path, const char *url, const char *title); * Returns 0 on success, -1 on not found / error. */ int bookmarks_remove(const char *path, const char *url); +/* Rename a bookmark's title (the user-editable label) in place. The + * bookmark is located by (path, url); only its title changes. Re-encrypts + * and publishes a new kind 30003 event for that path. + * Returns 0 on success, -1 on not found / error. */ +int bookmarks_rename(const char *path, const char *url, const char *new_title); + /* Move a bookmark from one folder to another. * Returns 0 on success, -1 on error. */ int bookmarks_move(const char *from_path, const char *url, diff --git a/src/history.c b/src/history.c index f84d1c7..94d95e2 100644 --- a/src/history.c +++ b/src/history.c @@ -31,6 +31,7 @@ void history_add_titled(const char *url, const char *title) { strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 || strncmp(url, "sovereign://bookmarks/move", 26) == 0 || strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 || + strncmp(url, "sovereign://bookmarks/rename", 28) == 0 || strncmp(url, "sovereign://qr", 14) == 0 || strncmp(url, "sovereign://nostr/", 18) == 0) { return; diff --git a/src/key_store.c b/src/key_store.c index 7955643..5eab591 100644 --- a/src/key_store.c +++ b/src/key_store.c @@ -84,6 +84,12 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) { if (signer && identity->nsigner_index >= 0) { nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index); } + /* n_signer's serial and TCP transports require a kind-27235 auth + * envelope on every request. Install the default caller identity + * so the re-created signer can talk to the device. */ + if (signer) { + key_store_nsigner_set_default_auth(signer); + } return signer; #else return NULL; @@ -95,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) { } } +/* ── n_signer default caller auth ─────────────────────────────────── * + * n_signer's serial and TCP transports require a kind-27235 auth envelope + * on every request. Without it the device rejects the call with an + * auth-related RPC error (codes 2010-2017) that the client maps to + * NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as + * "device requires approval" even though no on-device approval is actually + * needed. + * + * This installs the same fixed, well-known caller identity used by n_signer's + * own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side + * authentication credential only — it does NOT grant access to the device's + * mnemonic/keys; the device's own policy still decides which operations are + * allowed. No-op for non-nsigner backends. + */ +int key_store_nsigner_set_default_auth(nostr_signer_t *signer) { + if (signer == NULL) { + return -1; + } +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + /* Match the webserial demo's caller privkey: bytes 1..32. */ + static const unsigned char default_caller_priv[32] = { + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 + }; + return nostr_signer_nsigner_set_auth(signer, default_caller_priv, + "sovereign_browser"); +#else + return -1; +#endif +} + /* ── Legacy file cleanup ──────────────────────────────────────────── */ int key_store_delete_legacy_file(void) { diff --git a/src/key_store.h b/src/key_store.h index c7d6c15..4df8dde 100644 --- a/src/key_store.h +++ b/src/key_store.h @@ -87,6 +87,24 @@ int key_store_save_profile_identity(const char *pubkey_hex, int key_store_load_profile_identity(const char *pubkey_hex, key_store_method_t *method_out); +/* + * Set a default caller auth identity on an n_signer remote signer. + * + * n_signer's serial and TCP transports require a kind-27235 auth envelope + * on every request (the device rejects unauthenticated calls with an + * auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE). + * This installs a fixed, well-known caller identity (the same one used by + * n_signer's own webserial demo) so the browser can talk to a USB-attached + * n_signer without the user having to configure caller credentials. + * + * Safe to call on any signer; no-op for non-nsigner backends. Call after + * the signer is created and before the first verb (e.g. before + * nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key). + * + * Returns 0 on success, non-zero on error (e.g. not an nsigner signer). + */ +int key_store_nsigner_set_default_auth(nostr_signer_t *signer); + #ifdef __cplusplus } #endif diff --git a/src/login_dialog.c b/src/login_dialog.c index 1bc48e5..3ceaafb 100644 --- a/src/login_dialog.c +++ b/src/login_dialog.c @@ -514,6 +514,13 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) { return; } + /* n_signer's serial/TCP transports require a kind-27235 auth envelope + * on every request. Install the default caller identity (matching + * n_signer's webserial demo) before issuing any verbs. Without this + * the device rejects the call with an auth error that surfaces as + * "code -310". */ + key_store_nsigner_set_default_auth(signer); + int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index); if (rc_idx != NOSTR_SUCCESS) { gtk_label_set_text(GTK_LABEL(ctx->status_label), @@ -526,19 +533,34 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) { char pubkey_hex[65]; int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex); if (rc_pk != NOSTR_SUCCESS) { - char errmsg[256]; + char errmsg[320]; const char *desc = "unknown error"; switch (rc_pk) { case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break; case -2001: desc = "policy denied — caller not approved at signer terminal"; break; case -2002: desc = "index not in signer's whitelist"; break; case -3: desc = "crypto operation failed"; break; + case NOSTR_ERROR_NIP46_AUTH_CHALLENGE: + /* Device RPC codes 2010-2017: auth-related rejection. + * Most commonly this means the kind-27235 caller auth + * envelope was missing/rejected (the default caller + * identity is installed automatically, but the device's + * policy may still deny it). It can also mean the device + * is requesting on-device approval (button press / TUI + * confirm) for this operation. */ + desc = "auth rejected by n_signer (code -310). The caller " + "auth envelope was missing or denied, or the device " + "is requesting on-device approval. Confirm any " + "prompt on the n_signer hardware, then click Sign " + "In again"; + break; default: break; } snprintf(errmsg, sizeof(errmsg), "n_signer error: %s (code %d). Try a different key index.", desc, rc_pk); gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg); + nostr_signer_free(signer); sigprocmask(SIG_SETMASK, &old_set, NULL); return; } diff --git a/src/main.c b/src/main.c index 96a80ca..ab0c1ed 100644 --- a/src/main.c +++ b/src/main.c @@ -55,6 +55,8 @@ #include "agent_conversations.h" #include "agent_skills.h" #include "net_services.h" +#include "webkit_data.h" +#include "web_context.h" #include "nostr_core/nostr_core.h" /* ---- Global state --------------------------------------------------- * @@ -73,12 +75,22 @@ typedef struct { static app_state_t g_state = {0}; -/* Forward declaration — defined before main(). */ +/* Forward declarations — defined later in this file. */ static int switch_to_user_db(const char *pubkey_hex); +static int do_login(GtkWindow *parent); +static WebKitWebContext *build_context_for_current_user(void); +static char g_current_profile_db[512]; static GtkWindow *g_window = NULL; static gboolean g_logged_in = FALSE; static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */ +/* TRUE once main() has finished initial startup (tab_manager_init has + * run). Used by app_set_signer() to distinguish a first-time login + * (context built later by main()) from a runtime identity switch + * (context was torn down by identity_teardown_web_state and must be + * rebuilt here). */ +static gboolean g_post_startup = FALSE; + /* ---- App state accessors (used by agent_login.c) ─────────────────── */ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, @@ -112,6 +124,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, if (g_state.pubkey_hex[0] != '\0') { switch_to_user_db(g_state.pubkey_hex); } + + /* If this is a RUNTIME identity switch (past startup) and the + * previous context was torn down by identity_teardown_web_state(), + * rebuild a fresh per-user context for the new identity now. On the + * first login (before main() calls tab_manager_init), g_post_startup + * is FALSE and web_context_get() is NULL, so main() builds the + * context after login — we skip here to avoid a double-build. */ + if (g_post_startup && web_context_get() == NULL) { + g_print("[identity] Rebuilding web context for new identity (runtime switch)\n"); + if (build_context_for_current_user() != NULL) { + /* Open a fresh tab for the new user so they don't stare at + * an empty window after the teardown closed all tabs. */ + tab_manager_new_tab(settings_get()->new_tab_url); + } else { + g_printerr("[identity] Failed to rebuild web context\n"); + } + } } void app_clear_signer(void) { @@ -140,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; } * menu builder. */ +/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── * + * Called on logout and identity switch. Closes all tabs, destroys the + * agent chat sidebar webviews (which hold a long-lived WebKitWebView + * with the previous user's sovereign://agents/chat session), and wipes + * all WebKit website data (cookies, cache, localStorage, IndexedDB, + * service workers, favicons) from the shared WebKitWebsiteDataManager. + * + * Without this, the next user inherits the previous user's web session: + * cookies identify you to web pages, localStorage holds per-site state, + * and the live tabs keep the previous user's DOM in memory. See + * plans/webkit-data-isolation.md for the full rationale. + * + * This must run on the GTK main thread. It pumps a nested main loop + * briefly while webkit_website_data_manager_clear() completes. + */ +void identity_teardown_web_state(void) { + /* 1. Close all tabs. This destroys the webviews and drops their + * in-memory DOM/localStorage. Must happen before the context + * teardown so no webviews hold dangling references to the old + * context. Suppress the normal "last tab closed → quit the app" + * behavior so the browser stays alive for the next user; the + * caller opens a fresh tab after the switch. */ + tab_manager_set_suppress_quit_on_last_tab(TRUE); + tab_manager_close_all(); + tab_manager_set_suppress_quit_on_last_tab(FALSE); + + /* 2. Destroy the agent chat sidebar webviews in every window. The + * sidebar webview is outside the notebook so tab_manager_close_all + * does not touch it; it would otherwise keep a reference to the + * old context (and the previous user's chat session) alive. */ + tab_manager_destroy_sidebar_webviews(); + + /* 3. Tear down the per-user WebKitWebContext. This unrefs the + * context and its per-user WebKitWebsiteDataManager. With Phase A + * isolation, each user has their own data manager rooted at + * profiles//webkit/, so tearing down the context is what + * actually isolates the users — the next build creates a fresh + * data manager for the new user. The Phase 0 webkit_data_clear_all + * wipe is no longer needed for isolation (the data manager is + * per-user), but we keep it as a defense-in-depth safety net in + * case any process-global WebKit state survived the context + * teardown. */ + WebKitWebContext *ctx = web_context_get(); + if (ctx != NULL) { + /* Defense-in-depth: clear the data manager before tearing down + * the context. This catches any process-global caches that + * outlive the context. */ + int rc = webkit_data_clear_all(ctx, FALSE); + if (rc != 0) { + g_printerr("[identity] webkit_data_clear_all returned %d " + "(continuing with context teardown)\n", rc); + } + web_context_teardown(); + } +} + void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) { (void)item; GtkWindow *window = GTK_WINDOW(data); @@ -147,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) { login_result_t result; if (login_dialog_run(window, &result) == 0) { + /* Tear down the previous user's web state BEFORE installing the + * new signer, so the new user starts with a clean web session. */ + identity_teardown_web_state(); + if (g_state.signer) { nostr_signer_free(g_state.signer); } @@ -166,6 +255,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) { switch_to_user_db(g_state.pubkey_hex); } + /* Build a fresh per-user WebKitWebContext for the new identity + * (with its own per-user data manager) and re-register the + * sovereign://, nostr://, tor:// schemes on it. The old context + * was torn down by identity_teardown_web_state() above. */ + if (build_context_for_current_user() == NULL) { + g_printerr("[identity] Failed to build context for new user — " + "browser will have no web views\n"); + } + + /* Open a fresh tab for the new user so they don't stare at an + * empty window after the teardown closed all the previous user's + * tabs. Uses the new user's per-user new_tab_url setting. */ + const char *url = settings_get()->new_tab_url; + tab_manager_new_tab(url); + g_print("[identity] switched: method=%d pubkey=%s\n", g_state.method, g_state.pubkey_hex); } @@ -185,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) { void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) { (void)item; - (void)data; + GtkWindow *window = GTK_WINDOW(data); + + /* Tear down the current user's web state (closes all tabs, destroys + * the sidebar webviews, wipes cookies/cache/localStorage/service + * workers/favicons) BEFORE clearing the signer, so the next login + * starts with a clean web session. See plans/webkit-data-isolation.md. */ + identity_teardown_web_state(); + if (g_state.signer) { nostr_signer_free(g_state.signer); g_state.signer = NULL; } g_state.pubkey_hex[0] = '\0'; + g_state.privkey_hex[0] = '\0'; g_state.method = KEY_STORE_METHOD_NONE; g_state.readonly = FALSE; + g_logged_in = FALSE; + + settings_sync_set_signer(NULL, NULL); + agent_conversations_set_signer(NULL, NULL); + + /* Reset the per-user db tracking so the next login re-opens the + * new user's browser.db rather than assuming we're already on it. */ + g_current_profile_db[0] = '\0'; + g_print("[identity] logged out\n"); + + /* Re-show the login dialog so the user (or agent) can log in as a + * different identity. The dialog runs a nested main loop, so the + * agent server stays live and an agent can log in via MCP while the + * dialog is showing. On success, do_login() installs the new signer + * and switch_to_user_db() opens the new user's profile. */ + if (window != NULL) { + if (do_login(window) == 0) { + /* Build a fresh per-user WebKitWebContext for the new + * identity. The old context was torn down above. For + * --no-login mode this builds an ephemeral context. */ + if (build_context_for_current_user() != NULL) { + /* Open a fresh tab for the new user. */ + tab_manager_new_tab(settings_get()->new_tab_url); + } else { + g_printerr("[identity] Failed to build context after re-login\n"); + } + } + } } void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) { @@ -679,9 +819,9 @@ static int do_login(GtkWindow *parent) { /* Track the currently-open per-user db path so we can skip re-opening * the same database (e.g. when app_set_signer() is called during the - * login dialog and then main() calls switch_to_user_db() again). */ -static char g_current_profile_db[512] = ""; - + * login dialog and then main() calls switch_to_user_db() again). + * The actual definition is near the top of this file (forward-declared + * before the menu proxies that reset it on logout). */ static int switch_to_user_db(const char *pubkey_hex) { if (pubkey_hex == NULL || pubkey_hex[0] == '\0') { g_printerr("[profile] No pubkey, staying on global.db\n"); @@ -736,6 +876,52 @@ static int switch_to_user_db(const char *pubkey_hex) { return 0; } +/* ---- Per-user WebKit context build helper --------------------------- * + * Builds a fresh per-user WebKitWebContext (or ephemeral for no-login + * mode), registers the sovereign://, nostr://, tor:// URI schemes on it, + * and updates tab_manager so new tabs use the new context. Called from: + * - main() after login, before creating tabs + * - app_menu_switch_identity_proxy() after tearing down the old context + * - app_menu_logout_proxy() after re-login + * + * The caller must have torn down the previous context (via + * web_context_teardown()) and closed all tabs/sidebar webviews first. + * Returns the new context, or NULL on failure (the caller may fall back + * to a blank window or exit). + */ +static WebKitWebContext *build_context_for_current_user(void) { + WebKitWebContext *web_ctx; + if (g_state.pubkey_hex[0] != '\0') { + web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex); + } else { + /* --no-login / read-only-without-pubkey: ephemeral, no on-disk + * persistence. See plans/webkit-data-isolation.md. */ + web_ctx = web_context_build_ephemeral(); + } + if (web_ctx == NULL) { + g_printerr("[main] Failed to build WebKit context\n"); + return NULL; + } + + /* Register the sovereign:// URI scheme for the window.nostr bridge. + * nostr_bridge_set_signer() was already called by the login path, + * so the bridge will use the current signer. */ + nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex, + g_state.readonly); + + /* Register nostr:// and tor:// entity-page schemes on this context. + * These are per-context registrations, so they must be re-registered + * on every fresh context. */ + nostr_scheme_register(web_ctx); + tor_scheme_register(web_ctx); + + /* Point tab_manager at the new context so subsequent new tabs (and + * the sidebar webview) are created from it. */ + tab_manager_set_context(web_ctx); + + return web_ctx; +} + /* ---- Main ----------------------------------------------------------- */ int main(int argc, char **argv) { @@ -945,8 +1131,7 @@ int main(int argc, char **argv) { * from SQLite and decrypts them. In no-login/read-only mode, the * signer is NULL so bookmarks are read-only. */ bookmarks_init(g_state.signer, - g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL, - g_state.privkey_hex[0] ? g_state.privkey_hex : NULL); + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); /* Initialize the NIP-78 settings sync module. In no-login/read-only * mode, the signer is NULL so settings are local-only (not synced). */ @@ -971,50 +1156,35 @@ int main(int argc, char **argv) { * when the relay fetch completes and the kind 0 event is stored. */ tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); - /* Use the default WebKitWebContext — it comes with proper networking - * (cookies, cache, soup session) that a freshly created context lacks. - * All tabs will create webviews from this shared context. */ - WebKitWebContext *web_ctx = webkit_web_context_get_default(); + /* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── * + * Build a fresh WebKitWebContext with a per-user + * WebKitWebsiteDataManager rooted at + * ~/.sovereign_browser/profiles//webkit/ (or an ephemeral + * data manager for --no-login mode). This gives true per-identity + * isolation of cookies, cache, localStorage, IndexedDB, service + * workers, and favicons. The helper also registers the sovereign://, + * nostr://, tor:// URI schemes on the new context and points + * tab_manager at it. */ + WebKitWebContext *web_ctx = build_context_for_current_user(); + if (web_ctx == NULL) { + g_printerr("[main] Cannot continue without a WebKit context.\n"); + agent_server_stop(); + nostr_cleanup(); + cli_args_free(&cli); + return EXIT_FAILURE; + } - /* ── Security strip: disable web security restrictions ─────── */ + /* Fetch the security manager for the new context — used below to + * wire the sovereign://security page to the first tab's settings. */ WebKitSecurityManager *sec_mgr = webkit_web_context_get_security_manager(web_ctx); - /* Register sovereign:// as secure only. Do NOT register it as "local" - * (WebKit blocks fetch from https to local origins) and do NOT register - * it as "cors_enabled" (that makes WebKit enforce CORS response headers, - * which we can't set with the basic finish API). Without these, WebKit - * treats sovereign:// as a simple secure scheme with no CORS checks. */ - webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign"); - webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor"); - webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file"); - webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file"); - /* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */ - WebKitWebsiteDataManager *data_mgr = - webkit_web_context_get_website_data_manager(web_ctx); - webkit_website_data_manager_set_tls_errors_policy( - data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE); - - /* Enable the favicon database so WebKitGTK automatically fetches and - * caches favicons for visited pages. Without this, the - * "notify::favicon" signal never fires and webkit_web_view_get_favicon() - * always returns NULL. Passing NULL for the directory uses WebKit's - * default cache location. */ - webkit_web_context_set_favicon_database_directory(web_ctx, NULL); - g_print("[main] Favicon database enabled\n"); - - /* Initialize browser-managed/attached network services after login and - * after the shared WebKit context/data manager have been configured. */ + /* Initialize browser-managed/attached network services after the + * per-user context has been built. net_services_refresh_proxy() + * now uses web_context_get() so proxy settings apply to the + * per-user context. */ net_services_init(); - /* Register the sovereign:// URI scheme for the window.nostr bridge. */ - nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex, - g_state.readonly); - - /* Register nostr:// entity pages before creating any webviews. */ - nostr_scheme_register(web_ctx); - tor_scheme_register(web_ctx); - /* Vertical box: the tab manager's notebook fills the window. */ GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_container_add(GTK_CONTAINER(window), vbox); @@ -1022,6 +1192,11 @@ int main(int argc, char **argv) { /* Initialize the tab manager. */ tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window); + /* Mark startup complete. From now on, app_set_signer() (called by + * agent login / switch_identity) will rebuild the per-user web + * context if it was torn down, since main() won't build it again. */ + g_post_startup = TRUE; + /* Decide whether to restore the previous session or open CLI URLs. * * Precedence: diff --git a/src/net_services.c b/src/net_services.c index 7c69b68..27de940 100644 --- a/src/net_services.c +++ b/src/net_services.c @@ -8,6 +8,7 @@ #include "tor_control.h" #include "fips_control.h" #include "settings.h" +#include "web_context.h" #include @@ -66,8 +67,15 @@ static int ensure_private_dir(const char *path, char *error, size_t error_size) void net_services_refresh_proxy(void) { if (!g_initialized) return; - WebKitWebContext *ctx = webkit_web_context_get_default(); + /* Use the per-user context (web_context_get) instead of the default + * context, so proxy settings apply to the active user's context. + * Falls back to the default context if no per-user context has been + * built yet (e.g. during early startup). See plans/webkit-data-isolation.md. */ + WebKitWebContext *ctx = web_context_get(); + if (ctx == NULL) ctx = webkit_web_context_get_default(); + if (ctx == NULL) return; WebKitWebsiteDataManager *dm = webkit_web_context_get_website_data_manager(ctx); + if (dm == NULL) return; webkit_website_data_manager_set_network_proxy_settings( dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL); } diff --git a/src/nostr_bridge.c b/src/nostr_bridge.c index 20d532d..80dc69e 100644 --- a/src/nostr_bridge.c +++ b/src/nostr_bridge.c @@ -473,12 +473,15 @@ static const char *sovereign_page_css(void) { " min-width: 200px; }\n" " input:focus, select:focus, textarea:focus {\n" " border-color: var(--accent); outline: none; }\n" - " .btn, .save-btn { background: var(--bg); color: var(--primary);\n" - " border: 1px solid var(--primary); border-radius: var(--radius);\n" + " .btn, .save-btn, button.btn { background: var(--bg); color: var(--primary);\n" + " -webkit-appearance: none !important; appearance: none !important;\n" + " border-width: 1px !important; border-style: solid !important;\n" + " border-color: var(--primary) !important; border-radius: var(--radius);\n" " padding: 6px 16px; cursor: pointer; font-family: var(--font);\n" " font-size: 13px; font-weight: bold; margin-left: 8px;\n" + " display: inline-block; text-decoration: none;\n" " transition: border-color 0.2s, background 0.2s, color 0.2s; }\n" - " .btn:hover, .save-btn:hover { border-color: var(--accent); }\n" + " .btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }\n" " .btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }\n" " .btn:disabled { opacity: 0.5; cursor: not-allowed; }\n" " .btn-del { border-color: var(--accent); color: var(--accent); }\n" @@ -1570,6 +1573,34 @@ static void handle_bookmarks_delete(WebKitURISchemeRequest *request, g_free(dir); g_free(url); } +/* Handle sovereign://bookmarks/rename?dir=...&url=...&title=... + * Renames a bookmark's user-editable title in place. */ +static void handle_bookmarks_rename(WebKitURISchemeRequest *request, + const char *query) { + char *dir = query_param(query, "dir"); + char *url = query_param(query, "url"); + char *title = query_param(query, "title"); + + if (!url || url[0] == '\0') { + respond_error_json(request, -1, "Missing url parameter"); + g_free(dir); g_free(url); g_free(title); + return; + } + + int rc = bookmarks_rename(dir ? dir : "General", url, title ? title : ""); + if (rc != 0) { + respond_error_json(request, -1, "Failed to rename bookmark"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "renamed"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(dir); g_free(url); g_free(title); +} + /* Handle sovereign://bookmarks/createdir?name=... */ static void handle_bookmarks_createdir(WebKitURISchemeRequest *request, const char *query) { @@ -1815,7 +1846,7 @@ static void handle_agents_page(WebKitURISchemeRequest *request) { " \n" "\n" "\n" - "

Open Agent Chat

\n" + "

Open Agent Chat

\n" "\n" "
\n" "\n" @@ -4103,6 +4134,10 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, handle_bookmarks_renamedir(request, uri + 32); return; } + if (strncmp(uri, "sovereign://bookmarks/rename?", 29) == 0) { + handle_bookmarks_rename(request, uri + 29); + return; + } /* Route sovereign://settings requests. The page is served from the * embedded file www/settings.html (Phase 3 migration). */ diff --git a/src/profile.c b/src/profile.c index dd67447..bba7c5d 100644 --- a/src/profile.c +++ b/src/profile.c @@ -106,6 +106,46 @@ int profile_ensure_dir(const char *pubkey_hex) { return mkdir_p(dir, 0700); } +/* ── WebKit data directory ─────────────────────────────────────────── * + * Each user's WebKitWebsiteDataManager (cookies, cache, localStorage, + * IndexedDB, service workers, favicons) is rooted at + * ~/.sovereign_browser/profiles//webkit/ so web data is isolated + * per identity and persists across restarts. See plans/webkit-data-isolation.md. + */ + +void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz) { + if (pubkey_hex == NULL || out == NULL || out_sz == 0) { + if (out && out_sz > 0) out[0] = '\0'; + return; + } + + char dir[SB_HOME_BUF_SZ]; + profile_get_dir(pubkey_hex, dir, sizeof(dir)); + if (dir[0] == '\0') { + if (out_sz > 0) out[0] = '\0'; + return; + } + + int n = snprintf(out, out_sz, "%swebkit/", dir); + if (n < 0 || (size_t)n >= out_sz) { + if (out_sz > 0) out[0] = '\0'; + } +} + +int profile_ensure_webkit_dir(const char *pubkey_hex) { + if (pubkey_hex == NULL || pubkey_hex[0] == '\0') { + return -1; + } + + char dir[SB_HOME_BUF_SZ]; + profile_get_webkit_dir(pubkey_hex, dir, sizeof(dir)); + if (dir[0] == '\0') { + return -1; + } + + return mkdir_p(dir, 0700); +} + void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) { if (pubkey_hex == NULL || out == NULL || out_sz == 0) { if (out && out_sz > 0) out[0] = '\0'; diff --git a/src/profile.h b/src/profile.h index a2127c8..1dc6ccc 100644 --- a/src/profile.h +++ b/src/profile.h @@ -47,6 +47,21 @@ void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz); */ void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz); +/* + * Get the per-user WebKit data directory for a given hex pubkey. + * Writes "~/.sovereign_browser/profiles//webkit/" to out. + * This is where the per-user WebKitWebsiteDataManager roots its cookies, + * cache, localStorage, IndexedDB, service workers, and favicons so web + * data is isolated per identity. See plans/webkit-data-isolation.md. + */ +void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz); + +/* + * Create the per-user WebKit data directory if it doesn't already exist + * (mkdir -p style). Returns 0 on success, -1 on error. + */ +int profile_ensure_webkit_dir(const char *pubkey_hex); + /* * Get the global identity.json path (stores the last-used pubkey_hex so * the login dialog can default to the right profile). diff --git a/src/tab_manager.c b/src/tab_manager.c index bd2a7e7..55333a9 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -144,6 +144,13 @@ static tab_info_t **g_tabs = NULL; static int g_tab_count = 0; static int g_tab_cap = 0; +/* When TRUE, closing the last tab of the main window does NOT quit the + * app. Used by identity_teardown_web_state() so it can close all tabs + * (to drop the previous user's live webviews) without exiting the + * browser during a logout / identity switch. The caller re-opens a + * fresh tab for the new user afterwards. */ +static gboolean g_suppress_quit_on_last_tab = FALSE; + /* ── Forward declarations ─────────────────────────────────────────── */ /* Per-window sidebar helpers (defined in the sidebar section at the @@ -1949,10 +1956,16 @@ static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) { static void on_menu_open_file(GtkMenuItem *item, gpointer data) { (void)item; (void)data; - if (g_window == NULL) return; + /* Parent the dialog to the currently focused window (g_active_window) + * rather than always the main window (g_window), so the file chooser + * appears over the window the user invoked it from. g_active_window is + * kept up to date by on_window_focus_in() whenever a window gains + * focus; fall back to g_window if it hasn't been set yet. */ + GtkWindow *parent = g_active_window ? g_active_window : g_window; + if (parent == NULL) return; GtkWidget *dialog = gtk_file_chooser_dialog_new( - "Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN, + "Open File", parent, GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, NULL); @@ -2063,16 +2076,12 @@ static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) { } } -/* Called when "Manage Bookmarks…" is clicked in the submenu. */ +/* Called when "Manage Bookmarks…" is clicked in the submenu. + * Always opens in a new tab so the user's current page is preserved. */ static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) { (void)item; (void)data; - tab_info_t *tab = tab_manager_get_active(); - if (tab && tab->webview) { - webkit_web_view_load_uri(tab->webview, "sovereign://bookmarks"); - } else { - tab_manager_new_tab("sovereign://bookmarks"); - } + tab_manager_new_tab("sovereign://bookmarks"); } /* Wrapper for g_free to match GClosureNotify signature (avoids @@ -2175,9 +2184,12 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { const gchar *title = webkit_web_view_get_title(tab->webview); if (title == NULL) title = ""; - /* Build the folder picker dialog. */ + /* Build the folder picker dialog. Parent it to the focused window + * (g_active_window) so it appears over the window the user invoked + * it from, falling back to the main window if unset. */ + GtkWindow *bm_parent = g_active_window ? g_active_window : g_window; GtkWidget *dialog = gtk_dialog_new_with_buttons( - "Bookmark Page", g_window, + "Bookmark Page", bm_parent, GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, "_Cancel", GTK_RESPONSE_CANCEL, "_Add", GTK_RESPONSE_ACCEPT, @@ -2324,7 +2336,7 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) { g_signal_connect(item_lock, "activate", G_CALLBACK(app_menu_lock_session_proxy), NULL); g_signal_connect(item_logout, "activate", - G_CALLBACK(app_menu_logout_proxy), NULL); + G_CALLBACK(app_menu_logout_proxy), g_window); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout); @@ -2556,7 +2568,10 @@ static void bookmark_bar_add_folder(GtkWidget *container, const bookmark_t *bm = &node->bookmarks[i]; const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url; GtkWidget *btn = gtk_button_new_with_label(label); - gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE); + gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NORMAL); + gtk_style_context_add_class(gtk_widget_get_style_context(btn), + "bookmark-bar-btn"); + gtk_widget_set_name(btn, "bookmark-bar-btn"); gtk_widget_set_tooltip_text(btn, bm->url); char *url_copy = g_strdup(bm->url); g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy, @@ -2607,6 +2622,10 @@ static void bookmark_bar_add_folder(GtkWidget *container, } GtkWidget *mb = gtk_menu_button_new(); + gtk_button_set_relief(GTK_BUTTON(mb), GTK_RELIEF_NORMAL); + gtk_style_context_add_class(gtk_widget_get_style_context(mb), + "bookmark-bar-btn"); + gtk_widget_set_name(mb, "bookmark-bar-menu-btn"); gtk_button_set_label(GTK_BUTTON(mb), child->name); gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu); gtk_widget_set_tooltip_text(mb, child->path); @@ -2803,6 +2822,7 @@ static tab_info_t *tab_create(const char *url) { g_object_set_data(G_OBJECT(tab->page), "tab-info", tab); GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_name(toolbar, "main-toolbar"); gtk_widget_set_margin_top(toolbar, 4); gtk_widget_set_margin_bottom(toolbar, 4); gtk_widget_set_margin_start(toolbar, 4); @@ -3208,14 +3228,9 @@ void tab_manager_set_avatar(const char *pubkey_hex) { static void on_avatar_clicked(GtkButton *btn, gpointer data) { (void)btn; (void)data; - /* Open sovereign://profile in the active tab, or a new tab if - * none exists. */ - tab_info_t *tab = tab_manager_get_active(); - if (tab && tab->webview) { - webkit_web_view_load_uri(tab->webview, "sovereign://profile"); - } else { - tab_manager_new_tab("sovereign://profile"); - } + /* Always open sovereign://profile in a new tab so the user's current + * page is preserved. */ + tab_manager_new_tab("sovereign://profile"); } /* ── New tab button ───────────────────────────────────────────────── */ @@ -3279,26 +3294,69 @@ static void apply_app_theme(void) { "#avatar-btn image, #hamburger-btn image {" " padding: 0px; margin: 0px;" "}" + /* ── Header chrome background: force white ─────────────────── */ + "notebook, notebook header, notebook > header," + "notebook > header > tabs, notebook > header > tabs > tab," + "#main-toolbar, #bookmark-bar {" + " background: #ffffff;" + " color: #000000;" + "}" + "#main-toolbar entry {" + " background: #ffffff;" + " color: #000000;" + "}" /* ── Red accent for the URL entry focus ring ──────────────── */ "entry:focus {" - " border-color: #ff0000 !important;" - " box-shadow: 0 0 0 1px #ff0000 !important;" + " border-color: #ff0000;" + " box-shadow: 0 0 0 1px #ff0000;" "}" /* ── Tab active underline: black bar (replaces blue) ──────── * Adwaita renders the active-tab highlight as a box-shadow inset * on tab:checked. We kill that and draw a solid border-bottom * in the fg color. !important is needed to beat the theme. */ "notebook tab:checked {" - " box-shadow: none !important;" - " outline: none !important;" - " border-bottom: 3px solid %s !important;" + " box-shadow: none;" + " outline: none;" + " border-bottom: 3px solid %s;" "}" /* Tab text: normal (inherit from theme), no red. */ - /* ── Bookmark bar buttons — compact padding ───────────────── */ - "#bookmark-bar button {" + /* ── Bookmark bar buttons — explicit WEB.md interaction model ─ + * Rest: white background + black 1px border + black text. + * Hover: border turns red (background stays white). + * Active/pressed: background turns red + white text. + * Use !important at USER priority to beat Adwaita states. */ + "#bookmark-bar-btn, #bookmark-bar-menu-btn," + "#bookmark-bar-menu-btn button," + "#bookmark-bar .bookmark-bar-btn," + "#bookmark-bar .bookmark-bar-btn:backdrop," + "#bookmark-bar .bookmark-bar-btn button," + "#bookmark-bar .bookmark-bar-btn button:backdrop {" " padding: 2px 8px;" + " background: #ffffff;" + " color: #000000;" + " border: 1px solid #000000;" + " border-radius: 4px;" + " box-shadow: none;" + "}" + "#bookmark-bar-btn:hover, #bookmark-bar-menu-btn:hover," + "#bookmark-bar-menu-btn button:hover," + "#bookmark-bar .bookmark-bar-btn:hover," + "#bookmark-bar .bookmark-bar-btn button:hover {" + " border-color: #ff0000;" + "}" + "#bookmark-bar-btn:active, #bookmark-bar-btn:checked," + "#bookmark-bar-menu-btn:active, #bookmark-bar-menu-btn:checked," + "#bookmark-bar-menu-btn button:active," + "#bookmark-bar-menu-btn button:checked," + "#bookmark-bar .bookmark-bar-btn:active," + "#bookmark-bar .bookmark-bar-btn:checked," + "#bookmark-bar .bookmark-bar-btn button:active," + "#bookmark-bar .bookmark-bar-btn button:checked {" + " background: #ff0000;" + " color: #ffffff;" + " border-color: #ff0000;" "}", - fg /* tab:checked border-bottom color */ + fg /* tab:checked border-bottom color */ ); if (g_app_theme_provider == NULL) { @@ -3366,6 +3424,14 @@ static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main) /* ── Public API ───────────────────────────────────────────────────── */ +void tab_manager_set_context(WebKitWebContext *ctx) { + /* The caller must have closed all tabs and sidebar webviews that + * reference the old context before calling this, otherwise those + * webviews will hold dangling context references. */ + g_ctx = ctx; + g_print("[tab-manager] Context updated (%p)\n", (void *)g_ctx); +} + void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx, GtkWindow *window) { @@ -3557,15 +3623,26 @@ void tab_manager_close_tab(int index) { } } - /* If the main window has no tabs left, quit the app. */ + /* If the main window has no tabs left, quit the app — unless a + * caller has suppressed the quit (e.g. identity_teardown_web_state + * closes all tabs to wipe the previous user's live webviews but + * wants the browser to keep running for the next user). */ if (g_tab_count == 0) { - g_print("[tabs] Last tab closed, exiting.\n"); - if (g_window) { - gtk_window_close(g_window); + if (g_suppress_quit_on_last_tab) { + g_print("[tabs] Last tab closed, but quit suppressed (identity switch in progress).\n"); + } else { + g_print("[tabs] Last tab closed, exiting.\n"); + if (g_window) { + gtk_window_close(g_window); + } } } } +void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress) { + g_suppress_quit_on_last_tab = suppress; +} + void tab_manager_close_active(void) { /* Resolve the active tab by widget pointer (works across windows), * then look up its g_tabs index. Using tab_manager_get_active_index() @@ -3689,6 +3766,53 @@ void tab_manager_close_all(void) { } } +/* Destroy the sidebar webview in a single window_state_t. + * The sidebar container widget itself is kept; the webview will be + * recreated lazily on the next tab_manager_toggle_sidebar() call. */ +static void window_state_destroy_sidebar(window_state_t *ws) { + if (ws == NULL) return; + if (ws->sidebar_webview == NULL) { + /* Still mark it hidden so the next toggle re-creates it. */ + ws->sidebar_visible = FALSE; + return; + } + + /* Destroy the webview widget. gtk_widget_destroy() drops the + * container's reference and unrefs the WebKitWebView. */ + GtkWidget *wv = GTK_WIDGET(ws->sidebar_webview); + /* Detach from the sidebar container so the container is reusable. */ + GtkWidget *parent = gtk_widget_get_parent(wv); + if (parent != NULL) { + gtk_container_remove(GTK_CONTAINER(parent), wv); + } else { + gtk_widget_destroy(wv); + } + + ws->sidebar_webview = NULL; + ws->sidebar_visible = FALSE; + + /* Hide the sidebar container so it doesn't show empty space. */ + if (ws->sidebar_container != NULL) { + gtk_widget_hide(ws->sidebar_container); + } + if (ws->paned != NULL) { + gtk_paned_set_position(GTK_PANED(ws->paned), 0); + } +} + +void tab_manager_destroy_sidebar_webviews(void) { + window_state_destroy_sidebar(&g_main_window); + if (g_aux_windows != NULL) { + for (guint i = 0; i < g_aux_windows->len; i++) { + window_state_t *ws = &g_array_index(g_aux_windows, + window_state_t, i); + window_state_destroy_sidebar(ws); + } + } + /* If the active window's sidebar was showing, the active_ws pointer + * is still valid (we only nulled the webview, not the struct). */ +} + void tab_manager_duplicate(int index) { if (index < 0 || index >= g_tab_count) return; tab_info_t *tab = g_tabs[index]; diff --git a/src/tab_manager.h b/src/tab_manager.h index 01ff8e3..31ff2d9 100644 --- a/src/tab_manager.h +++ b/src/tab_manager.h @@ -47,6 +47,18 @@ void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx, GtkWindow *window); +/* + * Update the WebKitWebContext that tab_manager_new_tab() and + * sidebar_create_webview() use to create new webviews. Used during an + * identity switch: the old context is torn down (after all tabs are + * closed) and a fresh per-user context is built; this function updates + * the stored pointer so subsequent new tabs use the new context. + * + * The caller MUST have closed all existing tabs and sidebar webviews + * (which reference the old context) before calling this. + */ +void tab_manager_set_context(WebKitWebContext *ctx); + /* * Create a new tab and load the given URL (or the default new-tab URL * if url is NULL). Returns the tab index, or -1 on failure (e.g. max @@ -142,6 +154,25 @@ void tab_manager_close_to_right(int index); */ void tab_manager_close_all(void); +/* + * Destroy the agent chat sidebar webview in every open window (main + aux). + * The sidebar container itself is kept (it will be re-populated lazily on the + * next toggle). Used during logout / identity switch so the sidebar — which + * holds a long-lived WebKitWebView with the previous user's session — does + * not leak the old identity's sovereign://agents/chat state across users. + */ +void tab_manager_destroy_sidebar_webviews(void); + +/* + * Temporarily suppress the "last tab closed → quit the app" behavior. + * Set TRUE before tab_manager_close_all() during an identity switch so + * the browser does not exit when the previous user's tabs are closed; + * the caller opens a fresh tab for the new user afterwards. Always + * reset to FALSE when done so normal quit-on-last-tab-close behavior + * is restored. + */ +void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress); + /* * Duplicate the tab at the given index (open a new tab with the same URL). */ diff --git a/src/version.h b/src/version.h index 176f1dc..11149ac 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.54" +#define SB_VERSION "v0.0.55" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 54 +#define SB_VERSION_PATCH 55 #endif /* SOVEREIGN_BROWSER_VERSION_H */ diff --git a/src/web_context.c b/src/web_context.c new file mode 100644 index 0000000..1a14984 --- /dev/null +++ b/src/web_context.c @@ -0,0 +1,156 @@ +/* + * web_context.c — per-user WebKitWebContext construction/teardown + * + * See web_context.h for the rationale. Phase A of + * plans/webkit-data-isolation.md. + */ + +#include "web_context.h" +#include "profile.h" + +#include +#include + +#include + +/* The current per-user context. NULL before the first build and after + * teardown. Replaces webkit_web_context_get_default() for code that + * wants the per-user context. */ +static WebKitWebContext *g_ctx = NULL; + +/* ── Security / TLS / favicon configuration ────────────────────────── * + * Applied to every context we build (per-user and ephemeral). Mirrors + * the configuration that main.c used to apply to the default context. + */ +static void configure_context(WebKitWebContext *ctx) { + g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx)); + + /* Security strip: register sovereign://, tor://, file:// as secure + * (no CORS enforcement), and file:// as local. Same as the original + * main.c configuration. */ + WebKitSecurityManager *sec_mgr = + webkit_web_context_get_security_manager(ctx); + webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign"); + webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor"); + webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file"); + webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file"); + + /* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */ + WebKitWebsiteDataManager *dm = + webkit_web_context_get_website_data_manager(ctx); + webkit_website_data_manager_set_tls_errors_policy( + dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE); + + /* Enable the favicon database so notify::favicon fires. Use the + * data manager's base data directory (per-user) so favicons are + * isolated too. Passing NULL would use a shared default location. */ + char fav_dir[600]; + WebKitWebsiteDataManager *data_mgr = + webkit_web_context_get_website_data_manager(ctx); + const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr); + if (base != NULL) { + g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base); + } else { + /* Ephemeral data manager has no base dir; use a temp favicons + * dir so the favicon DB still works in no-login mode. */ + g_snprintf(fav_dir, sizeof(fav_dir), + "%s/sovereign_browser-favicons-ephemeral", + g_get_tmp_dir()); + } + webkit_web_context_set_favicon_database_directory(ctx, fav_dir); +} + +WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) { + if (pubkey_hex == NULL || pubkey_hex[0] == '\0') { + g_printerr("[web-context] No pubkey — cannot build per-user context\n"); + return NULL; + } + + /* Ensure the per-user webkit data directory exists. */ + if (profile_ensure_webkit_dir(pubkey_hex) != 0) { + g_printerr("[web-context] Failed to create webkit dir for %s\n", + pubkey_hex); + return NULL; + } + + char webkit_dir[600]; + profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir)); + if (webkit_dir[0] == '\0') { + g_printerr("[web-context] Failed to get webkit dir path for %s\n", + pubkey_hex); + return NULL; + } + + /* Build the per-user data manager. base-data-directory covers + * local-storage, indexed-db, websql, service-worker-registrations, + * offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory + * covers disk-cache. WebKit derives subdirs under these. */ + char data_dir[600]; + char cache_dir[600]; + g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir); + g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir); + + WebKitWebsiteDataManager *dm = webkit_website_data_manager_new( + "base-data-directory", data_dir, + "base-cache-directory", cache_dir, + NULL); + if (dm == NULL) { + g_printerr("[web-context] Failed to create WebsiteDataManager\n"); + return NULL; + } + + /* Build a fresh context bound to this data manager. */ + g_ctx = webkit_web_context_new_with_website_data_manager(dm); + if (g_ctx == NULL) { + g_printerr("[web-context] Failed to create WebContext\n"); + g_object_unref(dm); + return NULL; + } + + /* The context owns the data manager now; drop our ref. */ + g_object_unref(dm); + + configure_context(g_ctx); + + g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n", + pubkey_hex, data_dir, cache_dir); + return g_ctx; +} + +WebKitWebContext *web_context_build_ephemeral(void) { + WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral(); + if (dm == NULL) { + g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n"); + return NULL; + } + + g_ctx = webkit_web_context_new_with_website_data_manager(dm); + if (g_ctx == NULL) { + g_printerr("[web-context] Failed to create ephemeral WebContext\n"); + g_object_unref(dm); + return NULL; + } + g_object_unref(dm); + + configure_context(g_ctx); + + g_print("[web-context] Built ephemeral context (no on-disk persistence)\n"); + return g_ctx; +} + +void web_context_teardown(void) { + if (g_ctx == NULL) return; + + /* Unref the context. The context releases its reference to the data + * manager. Any remaining webviews referencing this context would + * keep it alive — the caller must have closed them first. */ + WebKitWebContext *old = g_ctx; + g_ctx = NULL; + g_object_unref(old); + + g_print("[web-context] Torn down previous context\n"); +} + +WebKitWebContext *web_context_get(void) { + return g_ctx; +} diff --git a/src/web_context.h b/src/web_context.h new file mode 100644 index 0000000..14c46bb --- /dev/null +++ b/src/web_context.h @@ -0,0 +1,88 @@ +/* + * web_context.h — per-user WebKitWebContext construction/teardown + * + * Phase A of plans/webkit-data-isolation.md. Each Nostr identity gets + * its own WebKitWebContext backed by a per-user WebKitWebsiteDataManager + * rooted at ~/.sovereign_browser/profiles//webkit/. This gives + * true isolation: cookies, cache, localStorage, IndexedDB, service + * workers, and favicons persist per-user and never cross-contaminate. + * + * WebKitGTK binds a WebKitWebsiteDataManager to a WebKitWebContext for + * the context's lifetime, so per-user isolation requires building a + * fresh context per login and tearing it down on logout/switch. + * + * The current context pointer is held in this module so the rest of the + * codebase can fetch it via web_context_get() instead of + * webkit_web_context_get_default(). + */ + +#ifndef WEB_CONTEXT_H +#define WEB_CONTEXT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Build a fresh WebKitWebContext with a per-user WebKitWebsiteDataManager + * rooted at ~/.sovereign_browser/profiles//webkit/. + * + * pubkey_hex — 64-char hex pubkey. Must be non-NULL/non-empty. + * + * Configures: + * - per-user base-data-directory + base-cache-directory (WebKit derives + * local-storage, indexed-db, websql, service-worker, offline-app-cache, + * hsts, itp, device-id-hash-salt under these) + * - favicon database enabled (per-user) + * - TLS errors ignored (FIPS uses Noise IK, not TLS CAs) + * - security manager: sovereign://, tor://, file:// registered as + * secure; file:// also registered as local + * + * Stores the new context as the current context (returned by + * web_context_get()). The caller must call web_context_teardown() on the + * previous context first if one exists. + * + * Returns the new WebKitWebContext (owned by this module), or NULL on + * failure. + */ +WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex); + +/* + * Build a fresh WebKitWebContext with an EPHEMERAL data manager (no + * on-disk persistence). Used for --no-login / read-only mode where + * there is no pubkey to key a per-user data directory on, and for the + * logged-out state. Same security/TLS/favicon config as + * web_context_build_for_pubkey. + * + * Stores the new context as the current context. + * + * Returns the new WebKitWebContext, or NULL on failure. + */ +WebKitWebContext *web_context_build_ephemeral(void); + +/* + * Tear down the current per-user WebKitWebContext: unref the context + * and its data manager. After this, web_context_get() returns NULL + * until the next build call. + * + * The caller MUST have already closed all webviews that reference the + * old context (tabs + sidebar webviews) before calling this, otherwise + * the webviews will hold dangling references. identity_teardown_web_state() + * in main.c does this. + */ +void web_context_teardown(void); + +/* + * Return the current WebKitWebContext, or NULL if none has been built + * (or the last one was torn down). Replaces webkit_web_context_get_default() + * for code that wants the per-user context. + */ +WebKitWebContext *web_context_get(void); + +#ifdef __cplusplus +} +#endif + +#endif /* WEB_CONTEXT_H */ diff --git a/src/webkit_data.c b/src/webkit_data.c new file mode 100644 index 0000000..5626944 --- /dev/null +++ b/src/webkit_data.c @@ -0,0 +1,113 @@ +/* + * webkit_data.c — helpers to wipe WebKit website data on identity change + * + * See webkit_data.h for the rationale. This is Phase 0 of + * plans/webkit-data-isolation.md: wipe the shared data manager in place on + * logout / identity switch so the next user does not inherit the previous + * user's cookies, cache, localStorage, service workers, etc. + */ + +#include "webkit_data.h" + +#include +#include + +/* ── Async clear state ─────────────────────────────────────────────── * + * webkit_website_data_manager_clear() is asynchronous. We drive a nested + * GLib main loop until the clear callback fires (or a 5s timeout expires) + * so callers can treat webkit_data_clear_all() as synchronous. + */ + +typedef struct { + gboolean done; + gboolean timed_out; +} clear_state_t; + +static void on_clear_finished(GObject *source, GAsyncResult *res, + gpointer user_data) { + (void)source; + (void)res; + clear_state_t *st = (clear_state_t *)user_data; + st->done = TRUE; +} + +static gboolean on_clear_timeout(gpointer user_data) { + clear_state_t *st = (clear_state_t *)user_data; + if (!st->done) { + st->timed_out = TRUE; + st->done = TRUE; + } + return G_SOURCE_REMOVE; +} + +int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons) { + if (ctx == NULL) { + ctx = webkit_web_context_get_default(); + } + if (ctx == NULL) { + g_printerr("[webkit-data] No WebKitWebContext to clear\n"); + return -1; + } + + WebKitWebsiteDataManager *dm = + webkit_web_context_get_website_data_manager(ctx); + if (dm == NULL) { + g_printerr("[webkit-data] No WebKitWebsiteDataManager on context\n"); + return -1; + } + + /* Build the set of data types to clear. WEBKIT_WEBSITE_DATA_ALL covers: + * memory cache, disk cache, offline app cache, cookies, local storage, + * session storage, IndexedDB, WebSQL, service worker registrations, + * HSTS, plugin data, device id/hash salt. */ + WebKitWebsiteDataTypes types = WEBKIT_WEBSITE_DATA_ALL; + + /* Drive the async clear to completion. The clear callback and the + * timeout fire on the default main context (where WebKit schedules + * its async completions), so we pump the default context — NOT a + * fresh nested context, which would never see the callback. + * + * We do NOT push a thread-default context because the caller (the + * MCP request handler) is already running inside the default main + * loop; iterating the default context here lets the clear callback + * fire while still on the same thread. */ + clear_state_t st = { .done = FALSE, .timed_out = FALSE }; + guint timeout_id = g_timeout_add_seconds(5, on_clear_timeout, &st); + + webkit_website_data_manager_clear(dm, types, 0, NULL, + on_clear_finished, &st); + + /* Pump the default context until the callback or timeout sets + * st.done. g_main_context_iteration may return FALSE if no sources + * are ready, so we loop on the flag, not the return value. */ + while (!st.done) { + g_main_context_iteration(NULL, TRUE); + } + + if (timeout_id != 0) { + g_source_remove(timeout_id); + timeout_id = 0; + } + + if (st.timed_out) { + g_printerr("[webkit-data] clear timed out after 5s\n"); + return -2; + } + + /* Favicons live in a separate WebKitFaviconDatabase, not the data + * manager. Clearing it is optional because it is process-global and + * not identity-sensitive (favicons are page icons, not user data). + * When requested, clear it so a switched user doesn't see the prior + * user's visited-site icons in the tab strip. */ + if (clear_favicons) { + WebKitFaviconDatabase *favdb = + webkit_web_context_get_favicon_database(ctx); + if (favdb != NULL) { + webkit_favicon_database_clear(favdb); + } + } + + g_print("[webkit-data] Cleared all website data (favicons=%s)\n", + clear_favicons ? "yes" : "no"); + return 0; +} diff --git a/src/webkit_data.h b/src/webkit_data.h new file mode 100644 index 0000000..5026d50 --- /dev/null +++ b/src/webkit_data.h @@ -0,0 +1,54 @@ +/* + * webkit_data.h — helpers to wipe WebKit website data on identity change + * + * WebKitGTK's WebKitWebsiteDataManager holds cookies, HTTP cache, localStorage, + * IndexedDB, WebSQL, service worker registrations, offline app cache, HSTS, + * and the favicon database for the whole process. When a user logs out or + * switches Nostr identity, this data must be cleared so the next user does + * not see the previous user's web session (cookies are the primary leak — + * they identify you to web pages). + * + * Phase 0 of plans/webkit-data-isolation.md: a single shared data manager + * (the default context's) is wiped in place. Phase A will replace this with + * per-user data managers, at which point these helpers become the safety net + * for the in-memory live-tab case. + */ + +#ifndef WEBKIT_DATA_H +#define WEBKIT_DATA_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Clear ALL website data from the given WebKitWebContext's data manager: + * cookies, disk + memory cache, local storage, session storage, IndexedDB, + * WebSQL, service worker registrations, offline app cache, HSTS, plugin data, + * and (optionally) favicons. + * + * ctx — the WebKitWebContext whose data manager to clear. + * Pass NULL to target the default context. + * clear_favicons — if TRUE, also clear the favicon database. The favicon + * DB is process-global and shared across contexts; clearing + * it drops every cached favicon, not just the current + * site's. Pass FALSE if you only want session/cache + * isolation (favicons are not identity-sensitive). + * + * This is asynchronous in WebKitGTK (the clear API takes a callback). This + * function runs a nested GLib main loop until the clear completes (or 5s + * elapses) so that on return the data is gone. It is safe to call from the + * GTK main thread. + * + * Returns 0 on success, -1 if the context/data manager could not be resolved, + * -2 if the clear timed out. + */ +int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons); + +#ifdef __cplusplus +} +#endif + +#endif /* WEBKIT_DATA_H */ diff --git a/tests/local-site/identity-leak-test.html b/tests/local-site/identity-leak-test.html new file mode 100644 index 0000000..4a360f8 --- /dev/null +++ b/tests/local-site/identity-leak-test.html @@ -0,0 +1,111 @@ + + + + + +Identity Leak Test + + + +

Identity Leak Test

+ +

This page writes a per-session marker to localStorage and a +session cookie, then shows them on every load. Use it to verify that logging +out and back in as a different identity does not leak the +previous user's marker.

+ +
+ Marker for this session: +
(none yet)
+
+
+ +
+ Stored values seen by this page: +
    +
  • localStorage["sb_identity_marker"] = (none)
  • +
  • document.cookie = (none)
  • +
+
+ +

+ + + +

+ + + + diff --git a/tests/test_bookmarks_tree.c b/tests/test_bookmarks_tree.c index bc33ce7..c295235 100644 --- a/tests/test_bookmarks_tree.c +++ b/tests/test_bookmarks_tree.c @@ -33,20 +33,18 @@ static int passes = 0; else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \ } while (0) -/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */ +/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.h. */ #define LABEL "sovereign-browser/bookmarks-folder-id-v1" -/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */ +/* Mirror of path_to_d_tag in src/bookmarks.c (single-step scheme): + * d = HMAC-SHA256(privkey, LABEL + ":" + path) */ static char *path_to_d_tag(const unsigned char *privkey, const char *path) { - unsigned char hmac_key[32]; - if (nostr_hmac_sha256(privkey, 32, - (const unsigned char *)LABEL, strlen(LABEL), - hmac_key) != 0) { - return NULL; - } + if (path == NULL) path = ""; + char data[512]; + snprintf(data, sizeof(data), "%s:%s", LABEL, path); unsigned char mac[32]; - if (nostr_hmac_sha256(hmac_key, 32, - (const unsigned char *)path, strlen(path), + if (nostr_hmac_sha256(privkey, 32, + (const unsigned char *)data, strlen(data), mac) != 0) { return NULL; } diff --git a/tests/test_identity_isolation.sh b/tests/test_identity_isolation.sh new file mode 100644 index 0000000..e01d90e --- /dev/null +++ b/tests/test_identity_isolation.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# tests/test_identity_isolation.sh +# +# End-to-end test for the WebKit data isolation fix (Phase 0 of +# plans/webkit-data-isolation.md). Verifies that switching Nostr identity +# wipes the previous user's cookies + localStorage so the next user does +# not see the previous user's web session. +# +# Prereqs: +# - ./browser.sh start --login-method generate (user A is logged in) +# - python3 -m http.server 8765 running in tests/local-site/ +# +# Flow: +# 1. Capture user A's pubkey via login_status. +# 2. Open http://localhost:8765/identity-leak-test.html +# 3. Click "Write fresh marker" — writes localStorage + cookie. +# 4. Read the marker value via eval. +# 5. switch_identity to a fresh generated key (user B). +# 6. Reopen the test page. +# 7. Read localStorage + cookie via eval. +# 8. PASS if both are empty; FAIL if user A's marker leaked through. + +set -u +MCP="http://localhost:17777/mcp" +URL="http://localhost:8765/identity-leak-test.html" + +# MCP responses are SSE-formatted: "event: message\ndata: {json}\n\n". +# mcp_call -> extracts the inner tool result text (a JSON string) +mcp_call() { + local payload="$1" + local raw + raw=$(curl -s -X POST "$MCP" -H 'Content-Type: application/json' -d "$payload") + # Extract the data: line, then parse the nested JSON with python. + echo "$raw" | python3 -c ' +import sys, json, re +raw = sys.stdin.read() +m = re.search(r"^data: (.+)$", raw, re.MULTILINE) +if not m: + print("ERROR: no data line", file=sys.stderr) + sys.exit(2) +outer = json.loads(m.group(1)) +content = outer.get("result", {}).get("content", []) +if not content: + print("ERROR: no content", file=sys.stderr) + sys.exit(2) +text = content[0].get("text", "") +# text is itself a JSON string: {"success":true,"data":{...},"id":N} +try: + inner = json.loads(text) + print(json.dumps(inner)) +except json.JSONDecodeError: + print(text) +' +} + +echo "[test] Checking browser is up + logged in..." +STATUS=$(mcp_call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login_status","arguments":{}}}') +PUBKEY_A=$(echo "$STATUS" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey",""))') +if [ -z "${PUBKEY_A:-}" ]; then + echo "[test] FAIL: not logged in as user A" + echo "$STATUS" + exit 1 +fi +echo "[test] User A pubkey: $PUBKEY_A" + +echo "[test] Opening test page..." +mcp_call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null +sleep 1.5 + +# Write the marker directly via eval (same effect as clicking the +# "Write fresh marker" button, but deterministic — no dependency on the +# click tool's selector/ref resolution). +echo "[test] Writing marker as user A via eval..." +MARKER_A="userA-$(date +%s)-$RANDOM" +mcp_call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.setItem(\"sb_identity_marker\",\"'"$MARKER_A"'\"); document.cookie=\"sb_identity_marker='"$MARKER_A"'; path=/\"; \"wrote\""}}}' >/dev/null +sleep 0.5 + +echo "[test] Reading marker back as user A..." +READBACK=$(mcp_call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))') +echo "[test] User A marker readback: '${READBACK:-}'" +MARKER_A="$READBACK" + +if [ -z "${MARKER_A:-}" ]; then + echo "[test] FAIL: could not read user A marker — test setup issue" + exit 1 +fi + +echo "[test] Switching identity to a fresh generated key (user B)..." +SWITCH=$(mcp_call '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"switch_identity","arguments":{"method":"generate"}}}') +echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("switch result:", "OK" if d.get("success") else "FAIL", d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")' +PUBKEY_B=$(echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")') +if [ -z "${PUBKEY_B:-}" ]; then + echo "[test] FAIL: switch_identity did not return a pubkey" + echo "$SWITCH" + exit 1 +fi +if [ "$PUBKEY_A" = "$PUBKEY_B" ]; then + echo "[test] FAIL: switch_identity returned the same pubkey" + exit 1 +fi +echo "[test] User B pubkey: $PUBKEY_B" +sleep 1.5 + +echo "[test] Reopening test page as user B..." +mcp_call '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null +sleep 1.5 + +echo "[test] Reading localStorage + cookie as user B..." +LS_B=$(mcp_call '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))') +CK_B=$(mcp_call '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"eval","arguments":{"script":"(document.cookie.split(\"; \").find(function(c){return c.indexOf(\"sb_identity_marker=\")===0})||\"\").split(\"=\").slice(1).join(\"=\")"}}}' \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))') + +echo "[test] User B localStorage: '${LS_B:-}'" +echo "[test] User B cookie: '${CK_B:-}'" + +PASS=1 +if [ -n "${LS_B:-}" ]; then + echo "[test] FAIL: localStorage leaked from user A to user B" + PASS=0 +fi +if [ -n "${CK_B:-}" ]; then + echo "[test] FAIL: cookie leaked from user A to user B" + PASS=0 +fi + +if [ "$PASS" = "1" ]; then + echo "[test] PASS: identity switch wiped user A's web data; user B sees a fresh session." + exit 0 +else + echo "[test] FAIL: identity leak detected." + exit 1 +fi diff --git a/www/agents/config.html b/www/agents/config.html index 1a5bcdd..836e82c 100644 --- a/www/agents/config.html +++ b/www/agents/config.html @@ -98,7 +98,7 @@ -

Open Agent Chat

+

Open Agent Chat

diff --git a/www/bookmarks.css b/www/bookmarks.css index a4173bc..3cd7586 100644 --- a/www/bookmarks.css +++ b/www/bookmarks.css @@ -60,6 +60,28 @@ margin-left: 0; } +/* Ensure every .btn renders the themed 1px solid border. WebKitGTK's UA + * stylesheet gives