Vestibule/README.md

336 lines
17 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vestibule
**A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.**
Linux-first, single codebase, zero Chromium in the stack. Designed
for medical lobbies, public terminals, and any environment where a
browser must serve the public without leaking session data between
users.
- **Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net
- **License:** MIT (see [LICENSE](LICENSE))
- **Platforms:** Linux is the primary target — provisioned, packaged,
and installed with no signing gate. The Windows stack is complete
and CI-validated but ships unsigned: a code-signing certificate
costs $200–500/year, the project does not buy one, and SmartScreen
warns accordingly. A donation earmarked for code signing
(info@dcos.net) changes that decision.
- **Status:** Production-ready and deployable. Real Argon2id unlock, real per-origin cookie preservation, real power event detection (Linux D-Bus + Windows WM_POWERBROADCAST), safe-by-default navigation (every domain blocked until the operator safelists it), dedicated unlock popup, systemd/scheduled-task supervision, CI matrix, and (Phase 2) one-command OS-level lockdown provisioning on both platforms plus Inno Setup / Flatpak packaging.
---
## What Vestibule does
A public-facing browser that:
1. **Locks the perimeter at the OS level.** AssignedAccess on Windows,
`cage` compositor on Linux. The device is the kiosk, not an app
pretending to be one.
2. **Sanitizes every session.** Cookies, history, cache, formData,
downloads, localStorage, indexedDB, pluginData, serviceWorkers,
passwords, sessions — all wiped on idle timeout, wake-from-sleep,
unlock, and browser shutdown. No partial resets.
3. **Detects power state changes.** Subscribes to `login1.Manager` on
Linux via D-Bus. Emits `Wake` events on resume. The extension resets
the session on every wake.
4. **Hides its UI by default.** A menu appears only when the mouse
touches the top 3px of the viewport — the 2001 gesture, unchanged:
invisible to patients, findable by operators.
5. **Gates configuration behind a wizard.** Six-step flow, password-
protected, covering kiosk identity, URL policy, session behavior,
power management, and unlock method.
6. **Blocks the web by default.** The URL policy ships in safelist
mode: every domain is blocked — pages, frames, CDNs, everything —
until the operator lists it. The home page's domain is validated
against the safelist in the wizard and guaranteed navigable at
runtime, so the kiosk can never lock itself out.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ OS-level lockdown (provisioned by Phase 2 scripts) │
│ • Windows: AssignedAccess + auto-logon local account │
│ (Shell Launcher step-down on Ent/Edu) │
│ • Linux: cage compositor + systemd unit on a VT │
│ → scripts/provision-kiosk.ps1 / .sh — see DEPLOYMENT.md │
└────────────────────────┬────────────────────────────────┘
│ launches at boot
▼
┌─────────────────────────────────────────────────────────┐
│ LibreWolf / Firefox ESR (kiosk mode) │
│ • --kiosk URL │
│ • dedicated profile (vestibule-profile) │
│ • policies.json (strict lockdown, see config/) │
│ • auto-restart via systemd / scheduled task │
└────────────────────────┬────────────────────────────────┘
│ loads extension
▼
┌─────────────────────────────────────────────────────────┐
│ Vestibule WebExtension │
│ • URL policy engine — safelist default, domain-based │
│ (webRequest; legacy open/blocklist/substring modes) │
│ • hidden menu (3px-from-top gesture) │
│ • session reset on wake / idle / unlock │
│ • idle polling (browser.idle.queryState, 30s) │
│ • admin wizard (Ctrl+Shift+V or gear icon) │
└────────────────────────┬────────────────────────────────┘
│ Native Messaging (stdio JSON)
▼
┌─────────────────────────────────────────────────────────┐
│ usher (Rust binary, ~1.2 MB) │
│ Threaded architecture: │
│ • main — stdin read loop, message dispatch │
│ • power — D-Bus PrepareForSleep (Linux) │
│ • writer — owns stdout lock │
└─────────────────────────────────────────────────────────┘
```
## Why this stack
| Alternative | Rejection reason |
|---|---|
| Tauri 2 (WebView2) | WebView2 is Edge/Chromium |
| Electron | Ships full Chromium, ~150 MB binaries |
| Servo | Web compat gaps on arbitrary kiosk content |
| Stock Firefox ESR | Mozilla trademark policy complicates redistribution |
| Extension-only | Cannot reach OS keyring or supervisor process |
| Per-platform native (WinUI 3 + GTK4) | Two codebases, violates single-coder ethos |
The chosen stack delivers: one Rust codebase for the helper, one JS
codebase for the extension, Gecko as the webview (no Chromium), 1.2 MB
helper binary, ~6 KB extension.
Note the Firefox row above concerns *basing the shipped product on
Firefox* (trademark limits on redistribution). Deploying against an
existing Firefox install is different and fully supported since
Phase 2.1: the extension, usher, and policies are engine-standard, and
the provisioning wizards detect Firefox (native, Flatpak, or snap)
with LibreWolf still the preferred default. See
[DEPLOYMENT.md](DEPLOYMENT.md).
## Quick start
See [QUICKSTART.md](QUICKSTART.md) for the fast path. Summary:
```sh
cd helper && cargo build --release && cd ..
python3 scripts/test-native-messaging.py
./scripts/install-native-host.sh # Linux
# or: powershell -File scripts/install-native-host.ps1 # Windows
```
Then load `extension/manifest.json` in LibreWolf via `about:debugging`.
For production kiosks, skip the manual steps entirely:
[DEPLOYMENT.md](DEPLOYMENT.md) provisions the whole machine (OS lockdown
included) with one command per platform, and the CI-built installers
are ready to ship.
## Configuration
Open the admin wizard via the gear icon in the hidden menu, or
`Ctrl+Shift+V`. The wizard enforces a setup password on first run and
walks through six steps:
| Step | Scope |
|---|---|
| 0 | Set or enter admin password |
| 1 | Kiosk identity (name, home URL, attract URL) |
| 2 | URL policy (safelist default; open / blocklist / allowlist advanced) |
| 3 | Session behavior (idle timeout, onReset, persistence allowlist) |
| 4 | Power management (aware / always-on, on_wake behavior) |
| 5 | Unlock method + kiosk unlock password |
| 6 | Review and save |
Two distinct passwords protect two distinct surfaces:
| Password | Protects | Storage | Algorithm |
|---|---|---|---|
| Admin | Wizard access, config | browser.storage.local | PBKDF2-SHA-256, 100k iters |
| Kiosk unlock | Session release | file storage via usher (0600) | Argon2id |
Recovery: OS-level reset only. Delete the `vestibule-profile/storage`
directory and re-run the wizard. A backup recovery code is a Phase 1
task.
## Security model
### Data sanitization
Three enforcement layers, defense in depth:
1. **Block all save paths** — `policies.json` disables form history,
password manager, master password creation, Pocket, Screenshots,
Firefox Accounts, search suggestions, Firefox Home widgets,
extension installs, popup blocking override, protocol handler
registration, and encrypted media extensions. `SanitizeOnShutdown`
wipes Cache, Cookies, Downloads, FormData, History, Sessions,
SiteSettings, and OfflineApps on every browser exit.
2. **Reset on trigger events** — `resetSession()` in `background.js`
invokes `browser.browsingData.remove()` with all 11 data types on
idle timeout, wake-from-sleep, unlock, and manual reset. Resets are
debounced (5s window) to coalesce simultaneous triggers.
3. **OS-level defense** — AssignedAccess (Windows) and `cage` (Linux)
ensure the browser cannot be closed or escaped. The dedicated
`vestibule-profile` directory isolates the kiosk from any user
profile data.
### URL policy
The default mode is **safelist**: every request whose hostname is not
on the operator-maintained safelist is blocked. Matching is
hostname-based — the entry `example.org` grants `example.org` and any
subdomain, and nothing else. A URL that merely contains
`example.org` as a substring (a query string, a path segment, a
userinfo prefix) does not match; that smuggle class is exactly what
domain matching closes.
Two guarantees keep the default safe without bricking the kiosk:
- **Internal schemes are always permitted** (`about:`,
`moz-extension:`, `chrome:`, `resource:`) — the admin wizard, the
unlock popup, and `about:blank` keep working with an empty list.
- **The home origin is always navigable.** Session reset, wake, and
idle timeout all navigate home; the engine exempts the home URL's
hostname so an operator error cannot strand the kiosk on its own
block page. The wizard additionally refuses to save a safelist
configuration whose home (or attract) domain is not listed.
Blocked top-level navigations land on an in-extension block page that
names the blocked domain; blocked subresources are cancelled outright.
An empty safelist therefore degrades to a kiosk that shows `about:blank`
and nothing else — safe by default, open by explicit operator action.
Provisioned kiosks bridge the two worlds automatically: the provisioner
launches the browser with the kiosk home URL on the command line, and
while the policy is still the default, the extension adopts that
startup page as the home URL and adds its domain as the first safelist
entry. Only pages the browser was launched with qualify — a URL typed
after boot is never adopted — so the default-deny posture holds
against walk-up users on an unconfigured kiosk.
The legacy modes remain for deployments that need them: `open` (no
filtering), `blocklist` (substring deny), and `allowlist` (substring
allow — an empty allowlist allows everything; prefer safelist). A
corrupted or unrecognized mode string fails closed to safelist
semantics.
### Power state awareness
Vestibule detects wake-from-sleep and resets the session. It does not
block sleep — the OS power profile decides whether the device sleeps,
and that is a deployment-time decision.
| Platform | API | Event |
|---|---|---|
| Linux | D-Bus `org.freedesktop.login1.Manager.PrepareForSleep(false)` | ~2s after wake |
| Windows | `RegisterPowerSettingNotification` + `WM_POWERBROADCAST` (Phase 1) | `PBT_APMRESUMEAUTOMATIC` |
Crash recovery: if LibreWolf crashes during sleep, the supervisor
(systemd / scheduled task) restarts LibreWolf, usher cold-starts, and
usher's first action on `hello` emits a `Wake` event to the extension
(Phase 1 implementation; spike skips this to avoid wiping data on every
reconnect during testing).
## Phase roadmap
| Phase | Scope | Status |
|---|---|---|
| 0 | Architecture spike: extension + usher + Native Messaging + URL filter + wake detection + session reset + admin wizard | done |
| 1 | Argon2id unlock via file storage (0600); dedicated unlock popup; Windows power events; per-origin cookie preservation; `librewolf.overrides.cfg`; systemd + scheduled-task supervision; CI matrix | done |
| 2 | OS-level lockdown provisioning (AssignedAccess wizard on Win, cage systemd unit on Linux); packaging (Inno Setup on Windows, Flatpak on Linux); full deprovision | done |
| 3 | Crash auto-restart integration testing; telemetry hooks; remote management API stub; `always-on` power-inhibit mode | planned |
| 4 | Store distribution (Flathub submission); additional package formats (MSI, AppImage, .deb) only if deployments demand them | planned |
| 5 | Remote management API; retro 2001 theme; accessibility pass; contributor docs | planned |
| 5+ | Webcam presence detection (see [TODO](#webcam-presence-detection)) | deferred |
## Webcam presence detection
Deferred to Phase 5+. When a webcam is detected at startup, usher will
use it to:
1. Detect "user walked away" — no face visible for N seconds triggers
immediate session reset (faster than the idle timeout).
2. Detect "different user approached" — face embedding mismatch
triggers reset.
Privacy guarantees (non-negotiable):
- Frames processed in-memory only, never written to disk
- Face embeddings never leave the local process
- No telemetry, no cloud API calls
- Webcam LED respected; no capture while camera is in use by another
application
Deferred because: privacy review must precede any biometric code;
OpenCV/dlib adds ~50 MB to the usher binary; the idle timeout and wake
detection in the spike cover the common cases. See
`config/vestibule.toml.example` `[presence]` block for the proposed
schema.
## Honest limitations
1. **LibreWolf release cadence** tracks Firefox ESR (~4 weeks security,
~12 months major). CI matrix tests current ESR; operators should
test before upgrading.
2. **Windows binaries ship unsigned — a standing decision, not an
oversight.** A code-signing certificate costs $200–500/year and the
project does not buy one; SmartScreen warns on the usher binary and
the installer. That is why Linux is the primary target: it has no
equivalent gate. A donation earmarked for code signing
(info@dcos.net) changes the decision; until then, Windows operators
verify the SHA-256 from the release notes.
3. **`--kiosk` is not complete lockdown.** It removes UI chrome but
does not block all escape vectors. OS-level AssignedAccess / cage
setup is mandatory for production deployments — and since Phase 2 it
is one command: [DEPLOYMENT.md](DEPLOYMENT.md) covers interactive
and unattended provisioning plus one-command deprovision on both
platforms.
4. **usher force-exits on stdin close.** The power thread is blocked on
an OS-level receive call (D-Bus / GetMessage) and cannot be
interrupted without an async runtime. This is the correct shutdown
strategy for this architecture — the power thread has no cleanup.
5. **localStorage is not selectively preserved.** When
`dataPersistenceAllowlist` is non-empty, cookies for allowlisted
domains are preserved via `cookies.getAll()` + selective
`cookies.remove()`. localStorage is wiped unconditionally — the
WebExtension API cannot enumerate origins for selective removal.
localStorage typically holds UI state, not auth tokens; the risk
is low and documented in code.
6. **Safelist mode blocks third-party resources too.** A page on a
safelisted domain that pulls scripts, fonts, or images from a CDN
will render broken until the CDN's domain is also safelisted. This
is by design — data can leave through subresource requests, so
they are gated like navigations — but it means the operator's list
must cover every domain the kiosk content depends on. The wizard's
step-2 help text says so, and the browser console logs each block
with the exact URL.
7. **Windows power events require testing on real hardware.** The
`RegisterSuspendResumeNotification` + `WM_POWERBROADCAST`
implementation is complete and cfg-gated, but has not been tested
on a physical Windows machine. The Linux D-Bus path is fully
tested.
## Documentation
- [DEPLOYMENT.md](DEPLOYMENT.md) — operator runbook: kiosk
provisioning, unattended rollout, exit codes, deprovision
- [QUICKSTART.md](QUICKSTART.md) — fast-path install and verify
- [BLOG.md](BLOG.md) — narrative: 2001 VB6 to 2026 Rust
- [history/](history/) — the scrubbed 2001 VB6 source, kept as a
non-shipping historic artifact (nothing in the build depends on it)
- [docs/QA-PASS.md](docs/QA-PASS.md) — production readiness review
(Mixture-of-Experts panel)
- [LICENSE](LICENSE) — MIT
## Credits
Original concept: Jeremy Anderson, 2001 — coded on co-op while in
school, VB6.
Modern implementation: Jeremy Anderson, 2026.
- Website: [dcos.net](https://dcos.net)
- Email: info@dcos.net