From 9a55fe15f7bcbf04863f985bf93b561a1782a087 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 21 Jul 2026 15:34:45 -0400 Subject: [PATCH] v0.0.55 - Fix n_signer USB serial login: send kind-27235 caller auth envelope and detect /dev/ttyUSB* devices --- Makefile | 2 +- VERSION | 2 +- artifacts/mcp-after-restart.png | Bin 0 -> 13359 bytes artifacts/mcp-bookmark-toolbar-check.png | Bin 0 -> 13392 bytes artifacts/mcp-browser-full.png | Bin 0 -> 13337 bytes artifacts/mcp-header-white-check.png | Bin 0 -> 3494 bytes plans/webkit-data-isolation.md | 224 +++++++++++++++++++ src/agent_login.c | 48 +++- src/bookmarks.c | 152 +++++++------ src/bookmarks.h | 18 +- src/history.c | 1 + src/key_store.c | 37 ++++ src/key_store.h | 18 ++ src/login_dialog.c | 24 +- src/main.c | 265 +++++++++++++++++++---- src/net_services.c | 10 +- src/nostr_bridge.c | 43 +++- src/profile.c | 40 ++++ src/profile.h | 15 ++ src/tab_manager.c | 190 +++++++++++++--- src/tab_manager.h | 31 +++ src/version.h | 4 +- src/web_context.c | 156 +++++++++++++ src/web_context.h | 88 ++++++++ src/webkit_data.c | 113 ++++++++++ src/webkit_data.h | 54 +++++ tests/local-site/identity-leak-test.html | 111 ++++++++++ tests/test_bookmarks_tree.c | 18 +- tests/test_identity_isolation.sh | 134 ++++++++++++ www/agents/config.html | 2 +- www/bookmarks.css | 22 ++ www/bookmarks.js | 18 ++ www/settings.html | 2 +- www/sovereign-base.css | 9 +- 34 files changed, 1671 insertions(+), 180 deletions(-) create mode 100644 artifacts/mcp-after-restart.png create mode 100644 artifacts/mcp-bookmark-toolbar-check.png create mode 100644 artifacts/mcp-browser-full.png create mode 100644 artifacts/mcp-header-white-check.png create mode 100644 plans/webkit-data-isolation.md create mode 100644 src/web_context.c create mode 100644 src/web_context.h create mode 100644 src/webkit_data.c create mode 100644 src/webkit_data.h create mode 100644 tests/local-site/identity-leak-test.html create mode 100644 tests/test_identity_isolation.sh 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 0000000000000000000000000000000000000000..0c80d3256b1858ef200d20afe3b2ed2f1cb24d00 GIT binary patch literal 13359 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 literal 0 HcmV?d00001 diff --git a/artifacts/mcp-browser-full.png b/artifacts/mcp-browser-full.png new file mode 100644 index 0000000000000000000000000000000000000000..2f46bac6c2fcbed34fac4a9aeff7d5b6187c4b77 GIT binary patch literal 13337 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 literal 0 HcmV?d00001 diff --git a/artifacts/mcp-header-white-check.png b/artifacts/mcp-header-white-check.png new file mode 100644 index 0000000000000000000000000000000000000000..110203836bde3c0e66cd6555a6bfec55445c672c GIT binary patch literal 3494 zcmeAS@N?(olHy`uVBq!ia0y~yVE)g*z%-MC2`G}di{}$iJlE63F{EP7+iQl53<^94 z3=Zsn{*b9pjjO8c{28D!rgQIsY99!G=VxFLeJ9VrpmoQdfgxl^Jp;pv0-#|55C1VT zEI9O+nZcp;H#/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