336 lines
17 KiB
Markdown
Executable File
336 lines
17 KiB
Markdown
Executable File
# 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
|