Finish browser daily-driver hardening
ober
95ec1033a777d1305e96f477781804f1066a34d4
--- a/.build.yml +++ b/.build.yml @@ -25,8 +25,8 @@ environment: LATEST_SITE: lisp.srht.site LATEST_SUBDIR: /jerboa-browser/latest LATEST_TARBALL: jerboa-browser-linux-amd64.tar.gz - JERBOA_COMMIT: a18b57415c011b66091749cb382d73346f163365 - JERBOA_TREE: 2272f217b3d3af8d39cd12358c509cb3a1b0f92c + JERBOA_COMMIT: 73176e945f656334b8b1cf0d6b11c6c41f8ba427 + JERBOA_TREE: 5e2ce0ac497d4b93d3a5859a13856fdbb0987ba3 sources: - https://git.sr.ht/~lisp/jerboa-browser artifacts: --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,7 @@ lib/libyubikey_native.* # support/ensure-vendor.sh; do not commit repository copies here. /vendor/jerboa-fuse/ /vendor/jerboa-yubikey/ +/vendor/adblock-rust-ffi/ /vendor/.jerboa-fuse.tmp.*/ /vendor/.jerboa-yubikey.tmp.*/ +/vendor/.adblock-rust-ffi.tmp.*/ --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ # jerbuild bundles Chez Scheme + the jerboa stdlib, so building the browser # entry needs only `jerbuild` + a C compiler. CI uses a pinned project-local # release toolchain; developer machines can still override JERBUILD explicitly. -JERBOA_VERSION ?= v0.2.4 -JERBOA_BUNDLE_SHA256 ?= 64efc7750859ac555905204cec289454c83642e4ea9b8f673985cf0fcead6194 +JERBOA_VERSION ?= v0.2.7 +JERBOA_COMMIT ?= 73176e945f656334b8b1cf0d6b11c6c41f8ba427 +JERBOA_TREE ?= 5e2ce0ac497d4b93d3a5859a13856fdbb0987ba3 +JERBOA_BUNDLE_SHA256 ?= ac8587f3b466f7d6d3047b4cafefd9985a08983503a5ad0764d4947cef25e828 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin JERBUILD ?= $(JERBOA_TOOL_DIR)/jerbuild # Recursive on purpose: check-jerbuild may install the pinned local tool before @@ -21,7 +23,11 @@ JERBOA_YUBIKEY_COMMIT ?= e448afca926ca9ad40fd98effa8d6264cb1019f5 JERBOA_YUBIKEY_TREE ?= 67e4417511b63c0b41b1b9c5dc1c7d897df51f08 JERBOA_YUBIKEY_DIR := vendor/jerboa-yubikey JERBOA_YUBIKEY_LIB := $(JERBOA_YUBIKEY_DIR)/lib -JEXEC = JERBOA_BROWSER_DEV_NATIVE=1 JERBOA_BROWSER_LIB=$(CURDIR)/qt-webengine/build/libjerboa_browser.$(SOEXT) $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/$(JERBOA_FUSE_LIB):$(JH)/lib +ADBLOCK_RUST_FFI_URL ?= https://github.com/brave/adblock-rust-ffi +ADBLOCK_RUST_FFI_COMMIT ?= 5382a0669bdbebaa9db740d74510c6e347fac27f +ADBLOCK_RUST_FFI_TREE ?= d5e602ccb210678eabcf44fd1fc629db3f3581d0 +ADBLOCK_RUST_FFI_DIR := vendor/adblock-rust-ffi +JEXEC = JERBOA_DEV_NATIVE=1 JERBOA_NATIVE_LIB=$(CURDIR)/lib/libjerboa_native.$(SOEXT) JERBOA_BROWSER_DEV_NATIVE=1 JERBOA_BROWSER_LIB=$(CURDIR)/qt-webengine/build/libjerboa_browser.$(SOEXT) $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/$(JERBOA_FUSE_LIB):$(JH)/lib JERBUILD_ERROR := "ERROR: jerbuild unavailable (or '$(JERBUILD) --jerboa-home' failed). Run make ensure-jerboa-tools, install jerbuild, or set JERBUILD=/path/to/jerbuild." BIN_DIR ?= $(HOME)/.local/bin BIN ?= jerboa-browser @@ -74,7 +80,9 @@ lib/libjerboa_native.$(SOEXT): FORCE | check-jerbuild cargo build --locked --release --no-default-features --features crypto \ --manifest-path "$(JH)/jerboa-native-rs/Cargo.toml"; \ fi; \ - if [ -f "$$src" ]; then ln -sf "$$src" "$@" && echo "staged $@ -> $$src"; \ + if [ -f "$$src" ]; then \ + rm -f "$@" && cp -f "$$src" "$@" && echo "staged $@ from $$src"; \ + if [ "$(SOEXT)" != "so" ]; then rm -f lib/libjerboa_native.so && cp -f "$$src" lib/libjerboa_native.so; fi; \ else echo "note: libjerboa_native.$(SOEXT) unavailable; crypto tests may fail"; fi $(FUSE_SHIM): vendor-fuse $(JERBOA_FUSE_DIR)/src/mount_helper.c support/browser_secure_fs.c support/browser_secure_fs.h @@ -99,6 +107,15 @@ vendor-yubikey: "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.lock" || \ { echo "ERROR: YubiKey Cargo lock overlay mismatch" >&2; exit 1; } +vendor-adblock: + sh support/ensure-vendor.sh adblock-rust-ffi "$(ADBLOCK_RUST_FFI_URL)" \ + "$(ADBLOCK_RUST_FFI_COMMIT)" "$(ADBLOCK_RUST_FFI_TREE)" + @test -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" || \ + { echo "ERROR: missing $(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" >&2; exit 1; } + @cmp -s support/adblock-rust-ffi-Cargo.lock \ + "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" || \ + { echo "ERROR: adblock Cargo lock overlay mismatch" >&2; exit 1; } + lib/libyubikey_native.$(SOEXT): vendor-yubikey $(CARGO) build --locked --manifest-path $(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml --release --no-default-features @mkdir -p lib @@ -106,7 +123,7 @@ lib/libyubikey_native.$(SOEXT): vendor-yubikey yubikey-native: lib/libyubikey_native.$(SOEXT) -.PHONY: all build binary install run test test-all test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-gui test-buffers test-native test-supply-chain test-native-loader repl security audit sbom reproducibility-report binary-smoke webengine-sandbox-evidence verify release-evidence clean help check-jerbuild check-podman ensure-jerboa-tools static-qt podman-static-qt native-static vendor-fuse vendor-yubikey yubikey-native lint +.PHONY: all build binary install run test test-all test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-fuzz test-gui test-buffers test-native test-supply-chain test-native-loader repl security audit sbom reproducibility-report binary-smoke webengine-sandbox-evidence verify release-evidence clean help check-jerbuild check-podman ensure-jerboa-tools static-qt podman-static-qt native-static vendor-fuse vendor-yubikey vendor-adblock yubikey-native lint .DEFAULT_GOAL := help all: binary @@ -114,7 +131,8 @@ all: binary check-jerbuild: @if [ "$(JERBUILD)" = "$(JERBOA_TOOL_DIR)/jerbuild" ] && \ { [ ! -x "$(JERBUILD)" ] || [ "$$(cat "$(JERBOA_TOOL_DIR)/.jerboa-version" 2>/dev/null || true)" != "$(JERBOA_VERSION)" ]; }; then \ - sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \ + JERBOA_SOURCE_COMMIT="$(JERBOA_COMMIT)" JERBOA_SOURCE_TREE="$(JERBOA_TREE)" \ + sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \ fi @"$(JERBUILD)" --jerboa-home >/dev/null 2>&1 || { echo $(JERBUILD_ERROR) >&2; exit 1; } @if [ "$(JERBUILD)" = "$(JERBOA_TOOL_DIR)/jerbuild" ]; then \ @@ -129,7 +147,8 @@ ensure-jerboa-tools: echo "=== Using explicit Jerboa toolchain: $(JERBUILD) ==="; \ else \ echo "=== Fetching Jerboa $(JERBOA_VERSION) release tools into $(JERBOA_TOOL_DIR) ==="; \ - sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \ + JERBOA_SOURCE_COMMIT="$(JERBOA_COMMIT)" JERBOA_SOURCE_TREE="$(JERBOA_TREE)" \ + sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_TOOL_DIR)"; \ fi @"$(JERBUILD)" --jerboa-home >/dev/null || { \ echo "ERROR: Jerboa toolchain is unavailable; set JERBUILD=/path/to/jerbuild or JERBOA_VERSION=<tag>"; \ @@ -152,7 +171,7 @@ native-static: check-jerbuild --manifest-path "$(JERBOA_NATIVE_CRATE_DIR)/Cargo.toml"; \ fi -binary: check-jerbuild native-static vendor-yubikey $(FUSE_SHIM) +binary: check-jerbuild native-static vendor-yubikey vendor-adblock $(FUSE_SHIM) $(JERBUILD) build @echo "" && ls -lh jerboa-browser && file jerboa-browser @@ -168,7 +187,7 @@ install: binary # The crypto-using interpreter suites load (std crypto native-rust), so stage the # Rust crypto lib (rule above) as a prerequisite of each. -test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock: lib/libjerboa_native.$(SOEXT) vendor-yubikey +test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-fuzz: lib/libjerboa_native.$(SOEXT) vendor-yubikey test test-keymap test-minibuffer test-gui test-buffers repl: check-jerbuild vendor-fuse vendor-yubikey # Run the (browser) test suite in the interpreter (no binary needed). Depends on @@ -177,7 +196,7 @@ test: test-securestore QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ $(JEXEC) scheme/browser-test.ss -test-all: test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-gui test-buffers test-native test-supply-chain +test-all: test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-fuzz test-gui test-buffers test-native test-supply-chain lint: test-supply-chain @@ -243,6 +262,10 @@ test-adblock: QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ $(JEXEC) scheme/browser-adblock-test.ss +test-fuzz: + QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + $(JEXEC) scheme/browser-fuzz-test.ss + # Offline Qt GUI / snapshot harness (headless). Writes PNGs to ./test-artifacts. test-gui: QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ @@ -264,25 +287,25 @@ security: sh scripts/security-check.sh JERBUILD="$(JERBUILD)" bash test/native-loader-security-test.sh -audit: vendor-yubikey +audit: vendor-adblock vendor-yubikey @mkdir -p "$(DIST_DIR)" @test -x "$(CARGO_AUDIT)" || { echo "cargo-audit is required" >&2; exit 1; } @"$(CARGO_AUDIT)" --version | grep -q " $(CARGO_AUDIT_VERSION)$$" || \ { echo "cargo-audit $(CARGO_AUDIT_VERSION) is required" >&2; exit 1; } - @test -f qt-webengine/vendor/adblock-rust-ffi/Cargo.lock - @cd qt-webengine/vendor/adblock-rust-ffi && \ - "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt" + @test -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" + @cd "$(ADBLOCK_RUST_FFI_DIR)" && \ + "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt" @test -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.lock" @cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && \ - "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt" + "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt" @test -f "$(JH)/jerboa-native-rs/Cargo.lock" @cd "$(JH)/jerboa-native-rs" && \ - "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt" + "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt" @if [ -d "$$HOME/.cargo/advisory-db/.git" ]; then \ git -C "$$HOME/.cargo/advisory-db" rev-parse HEAD > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; \ else echo unavailable > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; fi -sbom: vendor-yubikey lib/libjerboa_native.$(SOEXT) +sbom: vendor-adblock vendor-yubikey lib/libjerboa_native.$(SOEXT) JBROWSER_SBOM_DIR="$(SBOM_DIR)" \ JERBUILD="$(JERBUILD)" \ BIN="$(BIN)" \ @@ -291,7 +314,7 @@ sbom: vendor-yubikey lib/libjerboa_native.$(SOEXT) FUSE_SHIM="$(FUSE_SHIM)" \ sh scripts/sbom.sh -reproducibility-report: +reproducibility-report: vendor-adblock vendor-yubikey JBROWSER_REPRO_DIR="$(REPRO_DIR)" \ JERBUILD="$(JERBUILD)" \ BIN="$(BIN)" \ @@ -333,7 +356,7 @@ release-evidence: verify } > "$(DIST_DIR)/build-environment.txt" @QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu ./jerboa-browser version > "$(DIST_DIR)/browser-version.txt" 2>&1 || true @sh scripts/source-manifest.sh > "$(DIST_DIR)/source-sha256.txt" - @shasum -a 256 qt-webengine/vendor/adblock-rust-ffi/Cargo.toml qt-webengine/vendor/adblock-rust-ffi/Cargo.lock "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" support/yubikey-native-Cargo.lock > "$(DIST_DIR)/native-cargo-sha256.txt" + @shasum -a 256 "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" "$(ADBLOCK_RUST_FFI_DIR)/Cargo.lock" "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" support/yubikey-native-Cargo.lock > "$(DIST_DIR)/native-cargo-sha256.txt" @shasum -a 256 Makefile .jerbuild .jerboa/security.json supply-chain.lock rust-toolchain.toml SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/webengine-sandbox.md docs/release-evidence.md scripts/security-check.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/binary-smoke.sh scripts/webengine-sandbox-evidence.sh scripts/sanitize-evidence.sh support/jerboa-release-sha256.txt support/ensure-rust.sh > "$(DIST_DIR)/release-inputs-sha256.txt" @shasum -a 256 "$(BIN)" > "$(DIST_DIR)/binary-sha256.txt" @if command -v otool >/dev/null 2>&1; then \ @@ -343,16 +366,16 @@ release-evidence: verify else \ echo "no otool/ldd available" > "$(DIST_DIR)/native-linkage.txt"; \ fi - @if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f qt-webengine/vendor/adblock-rust-ffi/Cargo.toml ]; then \ - "$(CARGO)" metadata --locked --format-version 1 --manifest-path qt-webengine/vendor/adblock-rust-ffi/Cargo.toml > "$(DIST_DIR)/cargo-metadata-adblock-ffi.json"; \ + @if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" ]; then \ + "$(CARGO)" metadata --locked --format-version 1 --manifest-path "$(ADBLOCK_RUST_FFI_DIR)/Cargo.toml" > "$(DIST_DIR)/cargo-metadata-adblock-ffi.json"; \ fi @if command -v "$(CARGO)" >/dev/null 2>&1 && [ -f "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" ]; then \ "$(CARGO)" metadata --locked --format-version 1 --manifest-path "$(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml" > "$(DIST_DIR)/cargo-metadata-yubikey-native.json"; \ fi @test -x "$(CARGO_AUDIT)" - @cd qt-webengine/vendor/adblock-rust-ffi && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt" - @cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt" - @cd "$(JH)/jerboa-native-rs" && "$(CARGO_AUDIT)" audit --deny warnings > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt" + @cd "$(ADBLOCK_RUST_FFI_DIR)" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-adblock-ffi.txt" + @cd "$(JERBOA_YUBIKEY_DIR)/yubikey-native" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-yubikey-native.txt" + @cd "$(JH)/jerboa-native-rs" && "$(CARGO_AUDIT)" audit --deny warnings --no-yanked > "$(CURDIR)/$(DIST_DIR)/rustsec-jerboa-native.txt" @if [ -d "$$HOME/.cargo/advisory-db/.git" ]; then git -C "$$HOME/.cargo/advisory-db" rev-parse HEAD > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; else echo unavailable > "$(DIST_DIR)/rustsec-advisory-db-commit.txt"; fi @rm -rf "$(DIST_DIR)/sbom" "$(DIST_DIR)/reproducibility" "$(DIST_DIR)/binary-smoke" "$(DIST_DIR)/webengine-sandbox" @cp -R "$(SBOM_DIR)" "$(DIST_DIR)/sbom" @@ -425,7 +448,7 @@ podman-static-qt: check-podman clean: rm -f jerboa-browser jb-main.c jb-main.o jb_*.h jerboa-browser.boot rm -f $(FUSE_SHIM) - rm -f lib/libyubikey_native.$(SOEXT) + rm -f lib/libyubikey_native.$(SOEXT) lib/libjerboa_native.$(SOEXT) lib/libjerboa_native.so rm -f $(STATIC_QT_TARBALL) rm -rf .bcache test-artifacts dist find scheme -name '*.so' -delete 2>/dev/null || true @@ -443,6 +466,7 @@ help: @echo " make binary-smoke start the binary through offscreen version" @echo " make webengine-sandbox-evidence record WebEngine sandbox status" @echo " make release-evidence write release evidence under dist/release-evidence" + @echo " make vendor-adblock verify/fetch locked adblock-rust-ffi into vendor/" @echo " make vendor-yubikey verify/fetch locked jerboa-yubikey into vendor/" @echo " make yubikey-native build lib/libyubikey_native for direct USB" @echo " make test run the (browser) test suite (interpreter)" --- a/build-binary.ss +++ b/build-binary.ss @@ -31,9 +31,9 @@ ;; --- locate Jerboa + Chez ------------------------------------------------ (define jerboa-dir (or (getenv "JERBOA_HOME") - (let ((p (format "~a/mine/jerboa" (getenv "HOME")))) - (and (file-exists? p) p)) - (begin (display "Error: cannot find Jerboa; set JERBOA_HOME\n") (exit 1)))) + (begin + (display "Error: cannot find Jerboa; set JERBOA_HOME or run the pinned Makefile jerbuild flow\n") + (exit 1)))) (define (find-csv-dir lib-dir mt) (let ((csv (let lp ((dirs (guard (e (#t '())) (directory-list lib-dir)))) --- a/docs/credential-handling.md +++ b/docs/credential-handling.md @@ -4,8 +4,8 @@ The password manager in `(browser passwords)` is intentionally RAM-only. It does not write credentials to config files, environment variables, command lines, or -the browser profile. Stored secrets are XOR-masked with a per-session pad and -wiped after each `vault-with-secret` use. +the browser profile. Stored secrets are copied into locked secure-memory regions +and wiped after each `vault-with-secret` use. Autofill reconstitutes plaintext only long enough to inject into the selected page form. A compromised page that receives autofill can read the filled fields, @@ -19,7 +19,21 @@ slot public key with RSA-OAEP-SHA256; unlock requires the YubiKey, PIN, and touch when configured by the slot policy. The in-RAM data-key session mirrors the password vault: the data key is masked -with a random pad and wiped when locked. +with a random pad and wiped when locked. PIV PIN prompts are wrapped as +one-use bytevector secrets before the card operation and wiped after use. + +## History + +History persistence is opt-in with `JERBOA_BROWSER_HISTORY=1`. When enabled, +visited URL/title pairs are kept in RAM and, after the YubiKey store is +initialized or unlocked, sealed to `history.enc` with the same AES-256-GCM and +PIV-wrapped data-key format used for bookmarks. When disabled, history commands +report that history is disabled and no history entries are recorded. + +Encrypted bookmark and history writes go through `(std os secure-output)`. +The pinned v0.2.7 toolchain ships the upstream module; the browser keeps the +same fail-closed write path when release archives are unavailable by building +that exact v0.2.7 source tree into the project-local `.jerboa/bin` toolchain. ## Browser Profile Vault @@ -44,3 +58,8 @@ release checkout must not contain `scheme/browser/build-vault-secret.ss`. Diagnostics may record URLs, page titles, dependency versions, and linkage, but must not record passwords, PIV PINs, data keys, passphrase file contents, cookies, local storage, or bookmark plaintext. + +`show-metrics` exposes in-process counters and histograms in Prometheus text +format. Tamper-evident audit logging is opt-in with +`JERBOA_BROWSER_AUDIT_LOG=/path/to/audit.jsonl`; audit events record security +event categories and non-secret metadata only. --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -14,6 +14,8 @@ remotes, or `uname -a` host fingerprints remain. `make release-evidence` also runs: +- `make test-all`, including bounded fuzz smoke for keymap token parsing and + Scheme-to-native browser string ABI calls. - `make reproducibility-report`, which rebuilds `./jerboa-browser` twice using a fixed Jerbuild object directory without invoking the destructive `make clean` target after an unmeasured warmup and stabilization build. It compares the --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -41,13 +41,23 @@ vendored YubiKey/FUSE dependencies. - Network, application-local content, downloads, popups, canonical-rooted local-file access, clipboard access, and persistent storage are independently capability-gated per context. +- Web camera/microphone-style permission requests are denied by default. The + native layer records the request and the Scheme controller surfaces an + allow-once/reject prompt; grants remain RAM-only and are audited when audit + logging is enabled. - Vault runtime paths are random owner-only directories. Host path traversal is descriptor-relative/no-follow; vault files are exclusive 0600 single-link files owned by the effective user. -- Passwords are RAM-only, masked in heap storage, and wiped after transient - reconstitution. +- Passwords are RAM-only, stored in locked secure-memory regions, and wiped + after transient reconstitution. - Bookmark persistence uses AES-256-GCM with a random data key wrapped to a YubiKey PIV public key. +- History persistence is disabled by default; when enabled, history is sealed + with the same encrypted store format as bookmarks. +- Metrics are in-process debug data exposed through an explicit browser command. + Security audit logging is disabled by default and writes only when + `JERBOA_BROWSER_AUDIT_LOG` names a log file; audit events must not include + passwords, PINs, data keys, cookies, local storage, or full page contents. - Browser profile vault unlock defaults to YubiKey PIV. It accepts a protected passphrase file or prompt for recovery/testing, but not a passphrase environment variable and not a build-local embedded secret. new file mode 100644 --- /dev/null +++ b/final-kimi3.md @@ -0,0 +1,303 @@ +# Jerboa Browser — Final Handoff (last mile to daily-driver) + +**Author**: kimi-k3 review pass, 2026-07-29 +**Repo state at review**: branch `main`, HEAD `29225ad`, working tree clean +**Sibling repo surveyed**: `~/mine/jerboa` @ HEAD `1e43b3df`, VERSION 0.2.7 +**Audience**: whoever picks this up next (including future-me) to finish the last +mile and run this browser on a work laptop. + +This document is a verified state assessment plus a concrete adoption map of +`~/mine/jerboa` features for robustness, security, performance, and correctness. +Every claim below was checked against the source (grep/read, not recollection); +std-lib module availability was checked **per release tag** against the repo's +pinned toolchain (`JERBOA_VERSION ?= v0.2.4` in the Makefile). + +--- + +## 1. Where things stand + +### 1.1 What exists (all verified) + +- **Architecture**: Jerboa Scheme frontend (~3,700 lines under `scheme/`) driving + a Qt WebEngine C++ backend (~1,700 lines under `qt-webengine/src/`) through a + backend-neutral `jwb_*` C ABI (`include/jerboa_browser.h`). Rust components + (Brave `adblock-rust`, YubiKey CCID/FIDO2, crypto) are vendored under `vendor/` + and linked into `libjerboa_browser`. +- **Feature completeness**: ROADMAP stages 0–7 all DONE; WISHLIST 8/8 checked. + Buffers (not tabs), emacs keymap + chords, fuzzy minibuffer, link hints, + RAM-only XOR-masked password vault, YubiKey-PIV-encrypted bookmarks, + encrypted FUSE profile vault, adblock on-by-default. +- **Tests**: ~200 hermetic offscreen cases across 13 Scheme suites + 4 ctest + suites + 2 shell suites, all wired into `make test-all` and SourceHut CI + (`.build.yml`). Snapshot PNGs committed under `test-artifacts/`. +- **Assurance pipeline**: `make verify` = tests + `scripts/security-check.sh` + (greps release sources for `QTWEBENGINE_DISABLE_SANDBOX` / `--no-sandbox`) + + cargo-audit on 3 Rust graphs + double-build reproducibility + SBOM + + WebEngine sandbox evidence + binary smoke. This is unusually thorough; keep it. +- **Code hygiene**: a grep for `TODO|FIXME|XXX|HACK|unimplemented|stub|placeholder` + across `scheme/`, `support/`, `qt-webengine/src/`, `include/` finds **zero real + markers** (3 hits, all false positives or intentional test notes). The real + gaps are the deliberately *deferred* roadmap items, listed in §2. + +### 1.2 Stdlib baseline + +The browser today imports **only** `(jerboa prelude)` / `(scheme)` plus its own +modules. **No `(std *)` module is used anywhere.** Everything in §3 is greenfield +adoption, not replacement. + +### 1.3 Toolchain skew + +- Pinned build toolchain: **Jerboa v0.2.4** (`Makefile:4`). +- Current Jerboa release: **v0.2.7**; `main` has more (e.g. `(std security worker)` + exists only on unreleased `main`). +- Recommendation availability below is tagged `[0.2.4]` (usable now), + `[0.2.5+]` (bump pin), or `[unreleased]` (wait or track main). + +--- + +## 2. Last-mile blockers (ranked for a work laptop) + +These are the things that will actually hurt within the first hour of daily use, +in order. Each has a short design sketch; §3 maps the Jerboa features to build +them with. + +### B1 — Form-field passthrough / focus mode *(the blocker)* + +**Status**: ROADMAP Stage 4 deferred it: "form-field passthrough (when an input +is focused, route keys to the page instead of the keymap)". Today the app-wide +`KeyFilter` in `browser_window.cpp` feeds *every* key to the Scheme keymap, so +**typing into web inputs is impossible**. This is the single daily-use blocker. + +**Sketch**: +1. C++: on focus change, run a tiny JS probe + (`document.activeElement` → editable? `input/textarea/[contenteditable]`) + and cache a per-view `editable-focused` flag. Cheap signal: also hook + `focusInEvent`/`focusOutEvent` won't work for page-internal focus, so the JS + probe on `jwb_pump_wait` heartbeat (already 250 ms) or on key-press-before- + filtering is simplest. +2. KeyFilter: when flag is set, pass keys through except a single escape chord + (`C-g` and/or `ESC`) which clears focus (blur via JS) and returns to the + keymap. This is nyxt's "insert mode" — name it that in the mode indicator. +3. Scheme: add `insert` to the mode list shown in the status line + (`[modes]` slot already exists); command `insert-mode` / `leave-insert-mode`. +4. Tests: `browser-keys-test.ss` pattern — inject keys into a `data:`/`qrc:` + page with an `<input>`, assert the input's value grew and no command fired. + +### B2 — TLS certificate errors & the corporate-MITM story *(work-laptop critical)* + +**Status**: verified zero hits for `certificateError`/`featurePermission`/ssl in +`qt-webengine/src` and `include/`. TLS errors and permission requests get silent +Qt defaults. On a work laptop behind a corporate TLS-intercepting proxy this +means: either everything fails mysteriously, or you get no chance to +(deliberately, auditably) trust the corporate CA. + +**Sketch**: +1. C++: override `QWebEnginePage::certificateError`, route to Scheme callback + with (host, error string, cert chain DER, overridable flag). Default deny; + interactive allow-once/allow-for-host stored in RAM only. +2. Surface in minibuffer: "TLS error on host: … [a]ccept-once [r]eject" — never + auto-accept. +3. Persist nothing by default; if persistence is wanted later it belongs in the + encrypted store (`secure-store.ss` pattern), not plaintext. +4. Corporate CA: document that `SSL_CERT_FILE`/system store covers WebEngine on + Linux; on macOS the login keychain covers it. The interactive path above is + the fallback for everything else. + +### B3 — Permission mediation + +**Status**: no `featurePermissionRequested` handling; Qt defaults (deny) apply. +Mostly fine (deny-by-default matches the capability model), but video calls on a +work laptop need camera/mic. Add a `JWB_CAP_MEDIA`-style grant + Scheme prompt, +same shape as B2's callback. Low priority until B1/B2 land. + +### B4 — Real adblock lists + +**Status**: ROADMAP Stage 7 deferred "load a full EasyList/EasyPrivacy file at +startup (the starter list is only ~30 built-in `||host^` rules)" and cosmetic +filtering. The vendored `adblock-rust` engine fully supports it — this is a +loading problem, not an engine problem. + +**Sketch**: fetch EasyList+EasyPrivacy once (manual `curl`, pinned URL+sha256 in +`supply-chain.lock`), store under `$XDG_DATA_HOME/jerboa-browser/lists/`, load +at engine init, reload lazily. PSL-aware resolver is a separate deferred item; +the engine's built-in handling is likely sufficient — measure before building. + +### B5 — History persistence (opt-in, encrypted) + +**Status**: WISHLIST "later/maybe". For daily use, session-history loss is +annoying; full history is a privacy decision. Reuse the `secure-store.ss` +AES-256-GCM + RSA-OAEP-to-PIV-9D format verbatim — the crypto and the test +harness already exist (`browser-securestore-test.ss`). Atomic writes via +`(std os secure-output)` `[0.2.5+]`. + +### B6 — Download manager + +**Status**: WISHLIST "later/maybe"; the ABI already has `JWB_CAP_DOWNLOADS`. +Minimum viable: a `*Downloads*` buffer listing items from +`QWebEngineDownloadRequest` signals (name, state, bytes), `C-g` to cancel. Pure +plumbing, no design risk. + +### B7 — Hygiene items (quick wins, do first) + +1. **`build-binary.ss:32-34` falls back to `~/mine/jerboa`** for JERBOA_HOME. + This violates this repo's own AGENTS.md rule ("Never reference sibling + checkouts in build files") and the repo-boundary rule. On a work laptop that + path won't exist; fail closed instead: require `JERBOA_HOME` env or the + installed toolchain, error with instructions. +2. **`jpkg.sexp` says `(license "UNLICENSED")`** but the repo is MIT + (`LICENSE`, commit `993793d`). One-word fix. +3. **Toolchain pin v0.2.4 → v0.2.7**: unlocks `(std os secure-output)` and three + releases of stdlib fixes. Verify `make binary && make verify` after bump; + the pin exists to keep CI hermetic, so bump deliberately, not casually. +4. **Hide internal Rust symbols in the adblock staticlib** (Stage 7 note) — + symbol hygiene for the shipped binary. + +--- + +## 3. Adoption map: `~/mine/jerboa` → this repo + +Verified against `data/api-signatures.sexp` (738 modules; everything below is +tier **stable** unless noted). Availability tags refer to the pinned toolchain. + +### 3.1 Robustness + +| Feature | Use here | Avail | +|---|---|---| +| `(std misc custodian)` | **Per-buffer custodian**: tie each buffer's timers, pending async Qt callbacks, and any future socket/port to a custodian; `session-close-buffer!` (`scheme/browser/buffers.ss`) shuts it down wholesale. Prevents callback-into-dead-view bugs as the buffer model grows. | 0.2.4 | +| `(std result)` (prelude) | Already the convention (`browser.ss` returns `(ok/err)`). Extend to *all* new code paths; no new bare `error` calls in command handlers — a command failure should echo, not crash the run loop. | now | +| `(std misc retry)` + `(std circuit)` | Adblock-list refresh, any future network fetch (update checks, EasyList): `retry/backoff` with jitter + circuit breaker so a down origin doesn't stall startup. | 0.2.4 | +| `(std concur structured)` | `with-task-scope` for the hint-mode JS overlay pipeline and any future parallel fetch (favicon, prefetch): scope lifetime = buffer lifetime; cancel on buffer close. Composes with custodians. | 0.2.4 | +| `(std errdefer)` / prelude `unwind-protect` | PIV unlock flows (`yubikey-unlock.ss`) and vault mount (`vault.ss`) — guarantee lock/wipe/unmount on every exit path. The current code is careful; make it structurally careful. | 0.2.4 | +| `(std os secure-output)` | `call-with-secure-replacement-file` for bookmarks.enc, history, session — atomic replace, no half-written crypto blobs on crash. Pairs with `secure-directory-open-strict` (no-symlink-race) — same threat model as `support/browser_secure_fs.c`, so port that philosophy to the Scheme side. | 0.2.5+ | +| `(std safe-fasl)` | If session restore ever serializes Scheme values: `safe-fasl-read` with record-type allowlists + size caps. Do **not** use raw `fasl-read` on session files. | 0.2.4 | +| `(std health)` | `register-check!`/`run-checks` for the vault mount, WebEngine process liveness — feed the status line. | 0.2.4 | + +### 3.2 Security + +The posture is already strong (deny-by-default caps, secure-fs C shim, vaults, +supply-chain evidence). These add depth where there are currently seams: + +| Feature | Use here | Avail | +|---|---|---| +| `(std crypto secure-mem)` | The password vault XOR-masks secrets in the heap (`passwords.ss`) — a decent obfuscation but not protection against memory disclosure. `secure-mem` gives **mlock'd, guard-paged, wiping buffers**. Migrate vault entries; keep the zero-after-use discipline (`wipe-bytevector!` already used). | 0.2.4 | +| `(std security secret)` | `make-secret` (use-once) for the PIV PIN between prompt and card operation — the PIN currently lives as a normal string/bytevector through the minibuffer → `yubikey-unlock.ss` path. | 0.2.4 | +| `(std security taint)` | Label page-derived strings (`html-label`) and password-vault secrets; `define-sink` on (a) JS evaluation (`jwb_view_run_js`), (b) clipboard set, (c) autofill. Catches the class of bug where vault data or page HTML flows somewhere unintended — precisely the trust boundaries `docs/credential-handling.md` describes. | 0.2.4 | +| `(std security env)` | `make-env-policy` before spawning any helper (FUSE mount helper already; future crash reporter/downloader): allow-list env, redact secrets. The browser already refuses vault passphrases via env — this generalizes the rule. | 0.2.4 | +| `(std net allow-proxy)` | Future extension/userscript or prefetch model: sandboxed child with *no* network except a 127.0.0.1 CONNECT proxy with host:port glob allowlist. This is the clean way to ever run untrusted code alongside the browser. | 0.2.4 | +| `(std security worker)` | **The renderer-grade primitive**: confined child (seccomp+Landlock / Seatbelt / Capsicum), memory limits, egress policy, output caps, fail-closed. Overkill while Chromium does the sandboxing; bookmark this for a future Servo backend or for userscript/extension isolation. | unreleased | +| TLS patterns from `(std net tls)` / `(std net tls-rustls)` | Not directly usable (Qt owns the browser's TLS), but steal the **policy shape**: min-TLS floor, verify-always default, and `make-pin-set`-style host pinning as the model for B2's Scheme-side cert store. | 0.2.4 | +| `(std net request)` SSRF guard | Same story: the browser's URL authority is `browser_url_policy.cpp`. When prefetch/extensions fetch URLs *from Scheme*, parameterize `*http-ssrf-guard* #t` (it's **off by default**) to block 169.254.169.254/RFC1918/loopback, and reuse `http-framing-conflict?` for desync defense. | 0.2.4 | +| `(std security audit)` | Tamper-evident hash-chained log of security events: cert overrides (B2), capability grants, vault unlocks. On a work laptop this doubles as your personal compliance evidence. | 0.2.4 | + +### 3.3 Performance + +No benchmark suite exists today; idle CPU was already fixed (`39027d9`, +`jwb_pump_wait` uses `processEvents(WaitForMoreEvents)` correctly). Targets now +are allocation churn and startup: + +| Feature | Use here | Avail | +|---|---|---| +| `(std arena)` | **Per-page-load arena** for hint-mode label computation, fuzzy-match scratch, JS-result strings; `arena-checkpoint`/`arena-rollback!` around transient work. `arena-intern!` for repeated URL/host strings. | 0.2.4 | +| `(std misc lru-cache)` | Fuzzy-match result cache in the minibuffer (re-score on every keystroke is fine at 100 bookmarks, less fine at 10k history entries once B5 lands). Capacity-bounded, with stats. | 0.2.4 | +| `(std misc memoize)` | `define-memoized` on URL canonicalization and key-token parsing — both pure, both hot during typing. | 0.2.4 | +| `(std metrics)` | Prometheus-format counters/gauges: adblock blocks (already counted ad hoc), command latency, hint-mode JS round-trip time. Expose on a debug buffer; zero deps. | 0.2.4 | +| Build flags | `build-binary.ss` already does whole-program compilation. Confirm `release:` semantics from jerbuild (Chez `optimize-level 3` + stripped inspector info) for the shipped binary, and `tree-shake-imports` once `(std *)` deps land — otherwise each adoption above grows the binary. CI's `JERBOA_CC_OPT=-O0` is an OOM workaround, keep it scoped to CI. | now | +| `(std profile)` | `with-profile`/`profile-report` around startup and hint-mode on a heavy page before optimizing anything else. Measure first; the codebase is small enough that guesses are usually wrong. | 0.2.4 | + +### 3.4 Correctness & testing + +| Feature | Use here | Avail | +|---|---|---| +| `(std test fuzz)` | **Fuzz the FFI string boundary** (`browser_utf8.cpp` ↔ Scheme bytevector conversion) and the keymap token parser (`keymap.ss`). The repo itself knows this gap: "no fuzzing of the FFI string boundary". jerboa's own CI gate (`fuzz-smoke` with corpus + regression inputs) is the template. | 0.2.4 | +| `(std proptest)` | Model-test buffer-list invariants (open/switch/close against a reference list model — extends `browser-buffers-test.ss`'s 27 cases to generated interleavings) and minibuffer state machine. Shrinking gives minimal failing sequences. | 0.2.4 | +| `(std contract2)` | **Temporal contracts** on the PIV unlock protocol (must verify PIN before RSA unwrap; must wipe data key on lock) and the minibuffer lifecycle. Protocol-order bugs are exactly what unit tests miss. | 0.2.4 | +| `(std contract)` | `define/contract` on the `jwb_*` wrapper boundary (`browser.ss`): handle validity, capability flag ranges, URL strings non-empty. Cheap, self-documenting. | 0.2.4 | +| prelude ergo typing | `(: expr pred?)` checked casts where network/page data becomes Scheme values (JS results, titles, URLs) — the trust boundary `docs/ffi-boundary.md` already polices manually. | now | +| `(std spec)` / `(std config)` | If prefs become a file (they will, eventually): `load-config` + `config-schema` + `watch-config!` beats ad-hoc parsing; `s-valid?` for shapes. | 0.2.4 | + +### 3.5 Deliberately *not* recommended (avoid scope creep) + +- `(std actor *)` / cluster/CRDTs — the browser is single-process driving a Qt + event loop; actor infrastructure buys nothing until userscripts/extensions. + Revisit then, starting with bounded mailboxes for backpressure. +- `(std fiber)` — same reason: `jwb_pump_wait` heartbeat + Qt's loop already + solves the concurrency model. Don't run two schedulers. +- `(std net http2)` — frame/HPACK only, no client state machine; Qt handles + HTTP/2 anyway. +- `(std db leveldb)` — compat tier; if history needs a DB, use + `(std db sqlite)` (native rusqlite backend, encrypted-at-rest possible via + `sqlite-db->bytevector` + the existing GCM store). +- SpiderMonkey (`mozjs`) / WASM (`wasmi`) features in `jerboa-native-rs` — + fascinating, but Qt WebEngine *is* the JS engine. Irrelevant unless a Servo + backend happens, and `notes/servo-watchpoint.md` lists 6 preconditions first. + +--- + +## 4. Suggested sequencing + +Two-pass plan: **Pass 1 makes it usable, Pass 2 makes it deep.** Everything in +Pass 1 is small, testable, and independent. + +**Pass 1 — daily-driver (this week)** + +1. B7 hygiene (3 one-line-ish fixes; 30 min, do first so nothing rots). +2. B1 form-field passthrough (the blocker) + `browser-keys-test`-style test. +3. B4 EasyList loading (unblocks real ad/tracker coverage). +4. B2 cert-error surfacing (work-laptop MITM story). +5. Toolchain bump v0.2.4 → v0.2.7, then `make binary && make verify`. + +**Pass 2 — depth (following weeks, any order)** + +6. `(std crypto secure-mem)` migration for the vault; `(std security secret)` for + PIV PIN handling. (Small, high-value.) +7. `(std os secure-output)` atomic writes for bookmarks.enc (prereq for B5). +8. B5 history persistence (encrypted store reuse) + `(std misc lru-cache)` on + the now-larger fuzzy corpus. +9. `(std test fuzz)` on the FFI string boundary + keymap parser (this is the + repo's own acknowledged hole). +10. B6 download manager; B3 permission prompts. +11. `(std metrics)` + `(std security audit)` observability pass. +12. Custodian-per-buffer + temporal contracts (structural insurance). + +**Verification protocol per change** (per AGENTS.md): macOS → `make binary` +must succeed before any commit; run `make test-all` for functional changes, +`make verify` before tagging anything. Use the jerboa-mcp tools +(`jerboa_verify`, `jerboa_balanced_insert`, `jerboa_check_balance`) for all +`.ss` edits — never raw `edit`/`sed`. + +--- + +## 5. Risks & watchpoints + +- **Focus-mode edge cases (B1)**: JS-heavy pages that move focus programmatically + (search boxes that steal focus on load) will fight the keymap. Mitigation: + only enter insert-mode on *user-initiated* focus (click/key into field), or + show the mode loudly so `C-g` is discoverable. Test on a SPA (GitHub, Gmail). +- **Cert overrides are a loaded gun (B2)**: keep allow-once RAM-only, log to the + audit trail, never add "always allow for all hosts". The threat model + (`docs/threat-model.md`) should gain a section when this lands. +- **EasyList size (B4)**: full lists are ~50k rules; the adblock-rust engine + handles this fine, but startup cost should be measured (`(std profile)`) and + the engine is lazily built today (Stage 7 note) — keep it lazy. +- **Binary size**: each `(std *)` adoption grows the WPO closure. After Pass 2, + compare `ls -l jerboa-browser` and enable `tree-shake-imports` if it balloons. +- **Version pin**: `(std os secure-output)` needs ≥ v0.2.5; `(std security worker)` + is unreleased — don't code against it yet, just design for it. +- **macOS-only assumption**: dev happens on macOS arm64 with Homebrew Qt; the + work laptop target should be confirmed (Linux tarball via `make static-qt` + exists and is CI-published). Test the actual target early in Pass 1. + +--- + +## 6. Appendix — quick reference + +- Build: `make binary` · Test: `make test-all` · Full assurance: `make verify` +- Key sources: `scheme/browser/commands.ss` (controller/run-loop), + `scheme/browser/keymap.ss` (chords), `qt-webengine/src/browser_window.cpp` + (KeyFilter — B1 lives here), `browser_page.cpp:76-95` (pump loop), + `browser_url_policy.cpp` (scheme authority), `scheme/browser/secure-store.ss` + (crypto format to reuse for B5) +- Jerboa stdlib survey: `~/mine/jerboa/data/api-signatures.sexp` (738 modules, + stability tiers), `~/mine/jerboa/docs/status.md` (honest production status) +- Deferred upstream: full `(std security worker)` release; PSL resolver and + cosmetic filtering are adblock follow-ups already noted in ROADMAP Stage 7. --- a/include/jerboa_browser.h +++ b/include/jerboa_browser.h @@ -54,7 +54,8 @@ typedef enum { JWB_CAP_POPUPS = 1u << 6, /* In-process content schemes (data/blob/qrc and inert about:blank). This is * deliberately separate from NETWORK and FILESYSTEM authority. */ - JWB_CAP_LOCAL_CONTENT = 1u << 7 + JWB_CAP_LOCAL_CONTENT = 1u << 7, + JWB_CAP_MEDIA = 1u << 8 } JwbCapabilityFlags; typedef void (*JwbStringCallback)(const uint8_t *ptr, @@ -209,6 +210,35 @@ JWB_API int jwb_view_find(JwbHandle view, const char *text, int flags, JWB_API int jwb_clipboard_set(const char *utf8_text); JWB_API const char *jwb_clipboard_get(void); +/* TLS certificate errors are fail-closed. The last denied certificate error is + * surfaced as "HOST\tDESCRIPTION\tOVERRIDABLE\tURL\tDER_CHAIN_BASE64". Chain + * entries are DER-encoded certificates joined by commas. Host grants are + * RAM-only; pass once=1 to accept the next overridable error for that host only. */ +JWB_API const char *jwb_tls_last_error(void); +JWB_API int jwb_tls_allow_host(const char *host, int once); +JWB_API int jwb_tls_clear_host(const char *host); + +/* Web capability prompts (camera/microphone/etc.) are denied by default. The + * last denied request is surfaced as "ORIGIN\tTYPE". One-shot grants are + * RAM-only and are consumed by the next matching request; they only grant if the + * context also has the corresponding capability bit (currently MEDIA for + * microphone/camera capture, CLIPBOARD for clipboard read/write). */ +JWB_API const char *jwb_permission_last_request(void); +JWB_API int jwb_permission_allow_once(const char *origin, const char *type); +JWB_API int jwb_permission_clear(const char *origin, const char *type); + +/* Helpers for Scheme secure-memory regions. Allocation uses guard pages, + * mlock/munlock, and volatile wipe. Store/load do raw byte copies; callers own + * their logical length and must pass the same allocation size to free. */ +JWB_API uint64_t jwb_secure_alloc(uintptr_t size); +JWB_API int jwb_secure_free(uint64_t region_ptr, uintptr_t size); +JWB_API int jwb_secure_wipe(uint64_t region_ptr, uintptr_t size); +JWB_API int jwb_secure_random_fill(uint64_t region_ptr, uintptr_t size); +JWB_API int jwb_secure_region_store(uint64_t region_ptr, const uint8_t *src, + uintptr_t len); +JWB_API int jwb_secure_region_load(uint8_t *dst, uint64_t region_ptr, + uintptr_t len); + /* === Window / buffer chrome (Stage 1) ================================== * A BrowserWindow is a QMainWindow hosting a QStackedWidget of views (the * nyxt "buffers" model: one window, many buffers, one visible at a time) plus @@ -248,6 +278,11 @@ JWB_API int jwb_window_remove_key_hook(JwbHandle window); * empty). Returns "" when the queue is empty. The returned pointer is a * thread-local buffer valid until the next call. */ JWB_API const char *jwb_window_poll_key(JwbHandle window); +/* Cached insert-mode flag. The key filter refreshes this before routing real + * keys; status rendering reads this cached value without running JavaScript. */ +JWB_API int jwb_window_insert_mode(JwbHandle window); +/* Refresh and return insert mode by probing the visible page's activeElement. */ +JWB_API int jwb_window_refresh_insert_mode(JwbHandle window); /* 1 while the window is mapped/visible; 0 once the user closes it. Lets the * Scheme run-loop exit when the window is dismissed. */ JWB_API int jwb_window_is_visible(JwbHandle window); @@ -262,8 +297,17 @@ JWB_API int jwb_window_is_visible(JwbHandle window); JWB_API int jwb_adblock_set_enabled(int on); /* returns the new state (1/0) */ JWB_API int jwb_adblock_is_enabled(void); JWB_API int jwb_adblock_block_count(void); /* requests blocked since start */ +JWB_API int jwb_adblock_external_rule_bytes(void); /* bytes loaded from local lists */ JWB_API int jwb_adblock_available(void); /* 1 if compiled in, else 0 */ +/* Download manager snapshot. Rows are: + * ID<TAB>STATE<TAB>RECEIVED<TAB>TOTAL<TAB>FILENAME<TAB>URL + * with one row per known request. Denied downloads are recorded as cancelled. + * jwb_download_cancel returns 1 if a live matching request was cancelled. */ +JWB_API const char *jwb_downloads_snapshot(void); +JWB_API int jwb_download_cancel(uint32_t id); +JWB_API int jwb_download_count(void); + #ifdef __cplusplus } /* extern "C" */ #endif --- a/jpkg.sexp +++ b/jpkg.sexp @@ -2,7 +2,7 @@ (name "@lisp/jerboa-browser") (version "0.1.0") (description "Programmable Qt WebEngine browser controlled from Jerboa") - (license "UNLICENSED") + (license "MIT") (source "https://git.sr.ht/~lisp/jerboa-browser") (jerboa ">=0.2.0") (modules ((root "scheme"))) --- a/qt-webengine/CMakeLists.txt +++ b/qt-webengine/CMakeLists.txt @@ -31,16 +31,25 @@ target_include_directories(jerboa_browser target_link_libraries(jerboa_browser PRIVATE Qt6::Core Qt6::Widgets Qt6::WebEngineWidgets ) +if(APPLE) + target_link_options(jerboa_browser PRIVATE + "LINKER:-exported_symbols_list,${CMAKE_CURRENT_SOURCE_DIR}/jerboa_browser.exports") +endif() # --- ad / tracker blocking (Stage 7) --------------------------------------- -# Brave's adblock-rust via its C FFI, vendored under vendor/adblock-rust-ffi. +# Brave's adblock-rust via its C FFI, fetched at build time into +# ../vendor/adblock-rust-ffi by `make vendor-adblock`. # cargo builds a staticlib (libadblock.a) that links into the jerboa_browser # shared lib; the interceptor in browser_policy.cpp calls engine_match(). Turn # off (-DJWB_ADBLOCK=OFF) to build without a Rust toolchain — blocking then # compiles out to no-ops. -option(JWB_ADBLOCK "Ad/tracker blocking via vendored adblock-rust (needs cargo)" ON) +option(JWB_ADBLOCK "Ad/tracker blocking via pinned adblock-rust checkout (needs cargo)" ON) if(JWB_ADBLOCK) - set(ADBLOCK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/adblock-rust-ffi) + set(ADBLOCK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vendor/adblock-rust-ffi" + CACHE PATH "Pinned adblock-rust-ffi checkout") + if(NOT EXISTS "${ADBLOCK_DIR}/Cargo.toml") + message(FATAL_ERROR "missing ${ADBLOCK_DIR}/Cargo.toml; run `make vendor-adblock` first") + endif() set(ADBLOCK_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/adblock-rust-target) set(ADBLOCK_LIB ${ADBLOCK_TARGET_DIR}/release/libadblock.a) find_program(CARGO_EXE cargo REQUIRED) @@ -51,6 +60,7 @@ if(JWB_ADBLOCK) add_custom_command( OUTPUT ${ADBLOCK_LIB} COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${ADBLOCK_TARGET_DIR} + RUSTFLAGS=-Csymbol-mangling-version=v0 ${CARGO_EXE} build --locked --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml ${ADBLOCK_RANLIB_COMMAND} COMMENT "Building vendored adblock-rust staticlib (cargo build --release)" @@ -61,6 +71,9 @@ if(JWB_ADBLOCK) target_include_directories(jerboa_browser PRIVATE ${ADBLOCK_DIR}/src) target_link_libraries(jerboa_browser PRIVATE ${ADBLOCK_LIB}) if(NOT APPLE) + target_link_options(jerboa_browser PRIVATE "LINKER:--exclude-libs,ALL") + endif() + if(NOT APPLE) target_link_libraries(jerboa_browser PRIVATE pthread dl) endif() endif() new file mode 100644 --- /dev/null +++ b/qt-webengine/jerboa_browser.exports @@ -0,0 +1,83 @@ +_jwb_adblock_available +_jwb_adblock_block_count +_jwb_adblock_external_rule_bytes +_jwb_adblock_is_enabled +_jwb_adblock_set_enabled +_jwb_back +_jwb_clipboard_get +_jwb_clipboard_set +_jwb_context_free +_jwb_context_new +_jwb_context_open +_jwb_current_url +_jwb_current_url_sync +_jwb_download_cancel +_jwb_download_count +_jwb_downloads_snapshot +_jwb_eval +_jwb_eval_js +_jwb_eval_js_sync +_jwb_exec +_jwb_forward +_jwb_get_title +_jwb_get_url +_jwb_last_error +_jwb_last_status +_jwb_load +_jwb_load_url +_jwb_load_url_sync +_jwb_permission_allow_once +_jwb_permission_clear +_jwb_permission_last_request +_jwb_pump_events +_jwb_pump_wait +_jwb_quit +_jwb_reload +_jwb_runtime_init +_jwb_send_key +_jwb_secure_alloc +_jwb_secure_free +_jwb_secure_random_fill +_jwb_secure_region_load +_jwb_secure_region_store +_jwb_secure_wipe +_jwb_stop +_jwb_string_free +_jwb_title +_jwb_title_sync +_jwb_tls_allow_host +_jwb_tls_clear_host +_jwb_tls_last_error +_jwb_version +_jwb_view_find +_jwb_view_focus +_jwb_view_free +_jwb_view_get_zoom +_jwb_view_grab_png +_jwb_view_hide +_jwb_view_new +_jwb_view_open +_jwb_view_resize +_jwb_view_set_title +_jwb_view_set_zoom +_jwb_view_show +_jwb_window_add_view +_jwb_window_close +_jwb_window_current_index +_jwb_window_grab_png +_jwb_window_hide +_jwb_window_install_key_hook +_jwb_window_insert_mode +_jwb_window_is_visible +_jwb_window_open +_jwb_window_poll_key +_jwb_window_refresh_insert_mode +_jwb_window_remove_key_hook +_jwb_window_remove_view +_jwb_window_resize +_jwb_window_set_minibuffer +_jwb_window_set_status +_jwb_window_set_title +_jwb_window_show +_jwb_window_show_view +_jwb_window_view_count --- a/qt-webengine/src/browser_ffi.cpp +++ b/qt-webengine/src/browser_ffi.cpp @@ -9,9 +9,15 @@ // It adds NO Qt dependency — it only composes the existing extern "C" entry // points — and targets the synchronous, single-threaded Jerboa REPL embedding. #include "jerboa_browser.h" +#include "browser_internal.h" +#include <algorithm> #include <cstring> +#include <fcntl.h> +#include <limits> +#include <sys/mman.h> #include <string> +#include <unistd.h> namespace { @@ -33,6 +39,41 @@ const char *capture(char *owned) { return g_strbuf.c_str(); } +uintptr_t page_size() { + const long value = ::sysconf(_SC_PAGESIZE); + return value > 0 ? static_cast<uintptr_t>(value) : 4096; +} + +bool round_up_to_page(uintptr_t size, uintptr_t *out) { + const uintptr_t page = page_size(); + if (size > std::numeric_limits<uintptr_t>::max() - (page - 1)) return false; + *out = ((size + page - 1) / page) * page; + return true; +} + +void volatile_wipe(void *ptr, uintptr_t size) { + volatile uint8_t *p = static_cast<volatile uint8_t *>(ptr); + for (uintptr_t i = 0; i < size; ++i) p[i] = 0; +} + +bool fill_random(void *ptr, uintptr_t size) { + uint8_t *out = static_cast<uint8_t *>(ptr); + int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + uintptr_t off = 0; + while (off < size) { + const ssize_t n = + ::read(fd, out + off, static_cast<size_t>(size - off)); + if (n <= 0) { + ::close(fd); + return false; + } + off += static_cast<uintptr_t>(n); + } + ::close(fd); + return true; +} + } // namespace extern "C" { @@ -81,4 +122,130 @@ JWB_API const char *jwb_get_url(JwbHandle view) { return capture(jwb_current_url_sync(view, &g_status)); } +JWB_API uint64_t jwb_secure_alloc(uintptr_t size) { + jwb::clear_last_error(); + if (size == 0) { + jwb::set_last_error("secure_alloc: size is zero"); + return 0; + } + uintptr_t rounded = 0; + if (!round_up_to_page(size, &rounded)) { + jwb::set_last_error("secure_alloc: size overflow"); + return 0; + } + const uintptr_t page = page_size(); + if (rounded > std::numeric_limits<uintptr_t>::max() - (2 * page)) { + jwb::set_last_error("secure_alloc: total size overflow"); + return 0; + } + const uintptr_t total = rounded + (2 * page); + void *base = ::mmap(nullptr, total, PROT_READ | PROT_WRITE, +#if defined(MAP_ANONYMOUS) + MAP_PRIVATE | MAP_ANONYMOUS, +#else + MAP_PRIVATE | MAP_ANON, +#endif + -1, 0); + if (base == MAP_FAILED) { + jwb::set_last_error("secure_alloc: mmap failed"); + return 0; + } + auto cleanup = [&]() { ::munmap(base, total); }; + uint8_t *data = + static_cast<uint8_t *>(base) + page + (rounded - size); + if (::mprotect(base, page, PROT_NONE) != 0 || + ::mprotect(static_cast<uint8_t *>(base) + page + rounded, page, + PROT_NONE) != 0 || + ::mlock(data, size) != 0) { + cleanup(); + jwb::set_last_error("secure_alloc: guard page or mlock failed"); + return 0; + } +#if defined(MADV_DONTDUMP) + (void)::madvise(data, size, MADV_DONTDUMP); +#endif +#if defined(MADV_DONTFORK) + (void)::madvise(data, size, MADV_DONTFORK); +#endif + return static_cast<uint64_t>(reinterpret_cast<uintptr_t>(data)); +} + +JWB_API int jwb_secure_free(uint64_t region_ptr, uintptr_t size) { + jwb::clear_last_error(); + if (region_ptr == 0 || size == 0) { + jwb::set_last_error("secure_free: invalid pointer or size"); + return 0; + } + uintptr_t rounded = 0; + if (!round_up_to_page(size, &rounded)) { + jwb::set_last_error("secure_free: size overflow"); + return 0; + } + const uintptr_t page = page_size(); + uint8_t *data = reinterpret_cast<uint8_t *>(static_cast<uintptr_t>(region_ptr)); + uint8_t *base = data - page - (rounded - size); + volatile_wipe(data, size); + const int unlock_rc = ::munlock(data, size); + const int unmap_rc = ::munmap(base, rounded + (2 * page)); + if (unlock_rc != 0 || unmap_rc != 0) { + jwb::set_last_error("secure_free: munlock or munmap failed"); + return 0; + } + return 1; +} + +JWB_API int jwb_secure_wipe(uint64_t region_ptr, uintptr_t size) { + jwb::clear_last_error(); + if (region_ptr == 0) { + jwb::set_last_error("secure_wipe: invalid pointer"); + return 0; + } + volatile_wipe(reinterpret_cast<void *>(static_cast<uintptr_t>(region_ptr)), + size); + return 1; +} + +JWB_API int jwb_secure_random_fill(uint64_t region_ptr, uintptr_t size) {