diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c40febf9..a6cd1f24 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -116,13 +116,12 @@ jobs: # init` produces a full Gradle project, and a generated project that drifts from its source # is a source of silent errors. Regenerating costs a few seconds. # - # `VITE_API_URL` is frozen into the bundle **and** into the CSP — see `csp()` in - # `vite.config.ts`. Absent, it would produce a client pointing at the phone's own loopback, - # that is, at nothing. + # No API address is passed. The bundle carries none — the client asks its own origin on the + # web, and the packaged application asks the person installing it (`apps/desktop/src/ + # server.rs`). Freezing one in here used to produce an application pointing at the phone's + # own loopback, that is, at nothing. - name: Generate the Android project working-directory: apps/desktop - env: - VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }} run: cargo tauri android init # The camera scans the pairing square, and Android refuses `getUserMedia` without this @@ -142,8 +141,6 @@ jobs: - name: Build the APK working-directory: apps/desktop - env: - VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }} run: cargo tauri android build --debug --apk --target aarch64 - uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index d9db596b..984f715e 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -80,24 +80,20 @@ jobs: # Same choice as for Android: the Xcode project is generated on every build rather than # committed, so it cannot drift from `tauri.conf.json`. + # No API address is passed: the bundle carries none, and the packaged application asks the + # person installing it (`apps/desktop/src/server.rs`). - name: Generate the Xcode project working-directory: apps/desktop - env: - VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }} run: cargo tauri ios init - name: Build for the simulator if: ${{ !inputs.device }} working-directory: apps/desktop - env: - VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }} run: cargo tauri ios build --debug --target aarch64-sim - name: Build for a device if: ${{ inputs.device }} working-directory: apps/desktop - env: - VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }} run: cargo tauri ios build --debug - uses: actions/upload-artifact@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 734e506a..1ae7b5f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,22 @@ # A manifest is a claim about a build somebody can install. `dev` moves several times a day and # nothing deploys from it; a manifest per commit would be a list of hashes nobody could act on, # and would make the released ones harder to find. +# +# # The desktop job, and why the binaries are unsigned +# +# Windows code signing is a certificate somebody rents; Apple notarisation is a developer account +# somebody rents. Neither was bought, so the installers carry no platform signature: Windows shows +# SmartScreen, macOS asks for a right-click. That is a real cost at the door and it is written into +# `README.md` rather than left to be discovered. +# +# What replaces it is the same thing that carries the web manifest: `SHA256SUMS` per platform, +# attested through Sigstore to this commit and this workflow. It is a weaker promise about *who +# vouches* and a stronger one about *what was built* — a signature says a key-holder approved the +# bytes, an attestation says which source produced them. +# +# The repository's own Ed25519 key (`scripts/release.sh`, `release/whispee.pub`) stays what it is: +# the reproducible local build. `verify-release.sh` already states its limit — the key lives in the +# repository, so whoever controls the repository can replace it. name: Release on: @@ -113,3 +129,118 @@ jobs: the same claim." \ release/web/WEB-SHA256SUMS \ release/web/BUILD-INFO + + desktop: + name: Desktop installers + # The release has to exist before anything can be added to it, and `web` is what creates it. + # On `workflow_dispatch` this job still builds — the point of a rehearsal is to find out + # whether it builds — and publishes nothing, exactly like `web`. + needs: web + + strategy: + # One platform failing must not cancel the other two: a broken macOS build is not a reason + # to lose a Windows installer that was already compiling. + fail-fast: false + matrix: + include: + # **22.04 rather than `ubuntu-latest`, and it is a compatibility decision.** A binary + # linked against 24.04's glibc refuses to start on anything older, and "works on the + # newest Ubuntu only" is not what a `.deb` is for. + - os: ubuntu-22.04 + bundles: deb,rpm,appimage + artefacts: target/release/bundle/{deb/*.deb,rpm/*.rpm,appimage/*.AppImage} + - os: windows-latest + bundles: msi,nsis + artefacts: target/release/bundle/{msi/*.msi,nsis/*.exe} + # **Apple Silicon only.** `macos-14` is arm64, so this produces an arm64 `.dmg` and + # nothing for an Intel Mac. Building both would mean a second runner or a universal + # binary; neither is done here, and the gap is named in `README.md` rather than + # discovered by somebody downloading a file that will not open. + - os: macos-14 + bundles: dmg + artefacts: target/release/bundle/dmg/*.dmg + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v5 + + # Tauri links against the system webview, and without these `glib-sys`'s build script fails + # on `pkg-config` before anything is compiled. Same list as the `desktop` job in `test.yml`. + - name: Tauri's system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev librsvg2-dev patchelf + + - name: Toolchain + run: rustup toolchain install + + - uses: Swatinem/rust-cache@v2 + + # The same pinning as the `web` job above, and for a related reason: the interface inside + # these binaries is the same bundle the manifest describes, so it is built the same way. + - uses: actions/setup-node@v5 + with: + node-version-file: apps/web/.nvmrc + package-manager-cache: false + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@11.22.0 --activate + + - name: Install the Tauri CLI + run: cargo install tauri-cli --version "^2" --locked + + # **The version comes from the tag, not from `tauri.conf.json`.** + # + # Two files already carry a version — the manifest and `apps/desktop/Cargo.toml` — and a + # third place to bump is a third place to forget. Taking it from the ref means an installer + # is named after the release it is attached to, by construction. On a rehearsal there is no + # tag, so the file's own value stands. + - name: Build the installers + working-directory: apps/desktop + shell: bash + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + cargo tauri build --bundles ${{ matrix.bundles }} \ + --config "{\"version\": \"${GITHUB_REF_NAME#v}\"}" + else + cargo tauri build --bundles ${{ matrix.bundles }} + fi + + # Collected into one directory so the hash file, the attestation and the upload all name the + # same paths — three globs that have to agree is three chances for one of them to quietly + # match nothing. + - name: Collect and hash + shell: bash + run: | + shopt -s globstar nullglob + mkdir -p release/desktop + cp ${{ matrix.artefacts }} release/desktop/ + cd release/desktop + # Refuse an empty directory loudly. A release carrying a `SHA256SUMS` with no files + # beside it is worse than a failed job: it looks like a successful publication. + [ -n "$(ls -A)" ] || { echo "no bundle was produced" >&2; exit 1; } + sha256sum * > SHA256SUMS + cat SHA256SUMS + + # The bundles themselves, not the hash file. On the web side the manifest *is* the artefact, + # because the bytes being checked live on somebody else's server; here the bytes are what is + # downloaded, so that is what the provenance has to bind. + - uses: actions/attest-build-provenance@v3 + with: + # The hash file is excluded rather than attested alongside: it describes the bundles, and + # a provenance statement about a list of hashes is one indirection away from the thing + # somebody actually runs. + subject-path: | + release/desktop/* + !release/desktop/SHA256SUMS + + - name: Publish + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" release/desktop/* --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f25ad462..dde92e2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -210,9 +210,8 @@ jobs: # remove. # # `vite build` rather than `pnpm run build`: that script runs `tsc --noEmit` first, and the - # `web` job already does. `VITE_API_URL` is left unset on purpose — it is frozen into the - # bundle and the CSP, which matters for something installed and not at all for a bundle - # that exists so a macro can find a directory. + # `web` job already does. No API address is passed, and none exists to pass: the bundle + # carries no deployment's configuration at all. - uses: actions/setup-node@v5 with: node-version: 22 diff --git a/.gitignore b/.gitignore index c246fd2b..f3c066cc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ pkg/ *.tsbuildinfo dist/ release/artefacts/ +# `release-web.sh` writes here, and refuses to run on a dirty tree — so leaving its own +# output tracked means it works once and then blocks itself. +release/web/ apps/desktop/gen/ .claude/scheduled_tasks.lock .worktrees/ diff --git a/README.md b/README.md index d7b7dfb3..a39814bf 100644 --- a/README.md +++ b/README.md @@ -38,19 +38,28 @@ not their equal and does not try to be. | Disappearing messages | **On by default: seven days.** The lifetime is a group-context extension, so every member agrees on it; the server never learns it. Not enforceable on the other side — see [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md) | | History vault | On by default, encrypted under a key derived from the recovery phrase — **and off for any conversation with a lifetime**, which is what makes disappearing mean anything. Such a conversation does not survive the loss of every device | | Storage quota | 256 MiB per account by default, charged on vault writes and attachment uploads, credited back when a purge deletes. Envelopes are outside it: charging a sealed post would mean naming its sender — see [docs/ROADMAP.md](docs/ROADMAP.md) | -| Web, desktop | Vite 7 + React 19; Tauri 2 wraps the same build | +| Web, desktop, mobile | Vite 7 + React 19; Tauri 2 wraps the same build for Linux, Windows, macOS, Android and iOS. Each one is pointed at a server on first launch — there is no central service | +| Installable web client | A manifest, icons and a service worker that caches what is addressed by its content. It starts with no network, carries an unread badge, and — the part that is not cosmetic — is what lets iOS subscribe to push at all | | Push notifications | Web Push, off until a deployment names a contact in `VAPID_SUBJECT`. The wake-up carries no text, no sender and no group id — the worker cannot decrypt, so it says only that something arrived | | Verifiable web client | The bundle belongs to no deployment, so one published manifest of hashes describes every instance. CI attests it to GitHub; an extension compares what the browser actually received. See [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md) § 4quinquies for what that establishes and what it does not | | Deployment | `deploy/` — Postgres, the server, and Caddy terminating TLS on one origin. See [docs/DEPLOY.md](docs/DEPLOY.md) | | Reproducible, signed releases | `scripts/release.sh`, `scripts/verify-release.sh` | +| Desktop installers | `.deb`, `.rpm`, AppImage, `.msi`, NSIS and `.dmg`, built by CI on a tag with a `SHA256SUMS` and a Sigstore attestation. **Unsigned by the platforms**: Windows shows SmartScreen and macOS asks for a right-click → Open. The `.dmg` is Apple Silicon only | ## What does not work -- **Push reaches browsers, not the packaged mobile app.** Web Push works end to end — a browser - subscribes, the server signs a VAPID token, a notification arrives with the tab closed — and it - is off until a deployment sets `VAPID_SUBJECT`. FCM and APNs are not written: device-side - registration needs a Tauri plugin that does not exist, so the Tauri build is only notified while - it is open. The wake-up carries no text, no sender and no group id. +- **Push reaches browsers, not the packaged mobile app** — and the installed web client is + therefore *better* at notifications on a phone than the native one, which is a reversal worth + stating plainly. Web Push works end to end: a browser subscribes, the server signs a VAPID + token, a notification arrives with the tab closed, and iOS can now subscribe because the client + is installable. FCM and APNs are not written — not for want of tooling any more, since several + Tauri push plugins now exist, but because APNs cannot be exercised without a paid Apple + Developer membership and there is no Android device here. A Tauri webview has no service worker + either, so a packaged build has **no background wake-up path at all**. The wake-up carries no + text, no sender and no group id. See [docs/ROADMAP.md](docs/ROADMAP.md). +- **Nothing reaches a watch.** An Apple Watch or a Wear OS device shows the notifications its + phone received, so this waits entirely on the line above — and even then, a notice that says + only "New message" is not much of a wrist. - **Biometric unlock has never been executed.** The code exists; not one line of it has run. There is no Android NDK and no physical device on the development machine, so even the compilation of its dependency is unconfirmed. diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 22572d19..ba397258 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -19,6 +19,20 @@ publish.workspace = true name = "desktop_lib" crate-type = ["lib", "cdylib", "staticlib"] +# **The installed binary is `whispee`, not `desktop`.** +# +# Without this the binary takes the crate's name, and the first `.deb` ever built here proved what +# that costs: it installed `/usr/bin/desktop`, an icon keyed `desktop`, and a launcher entry whose +# `Exec` and `StartupWMClass` both said `desktop`. A name that generic on a shared `PATH` is a +# collision waiting for a second package, and a window class nothing can match is a taskbar entry +# that never groups with its own launcher. +# +# The package stays `desktop` because that is what serves desktop, Android and iOS from one crate +# and what every `-p desktop` in the workflows names. Only the artefact is renamed. +[[bin]] +name = "whispee" +path = "src/main.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/apps/desktop/icons/icon.icns b/apps/desktop/icons/icon.icns new file mode 100644 index 00000000..8d0adbbe Binary files /dev/null and b/apps/desktop/icons/icon.icns differ diff --git a/apps/desktop/icons/icon.ico b/apps/desktop/icons/icon.ico new file mode 100644 index 00000000..6c34f791 Binary files /dev/null and b/apps/desktop/icons/icon.ico differ diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 0494dc8b..45256c86 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -124,6 +124,28 @@ pub fn session_clear(vault: State<'_, Vault>) -> Result<(), String> { } } +/// The delivery service this installation was pointed at, or `None` before it has been. +/// +/// `None` is what puts `apps/web/src/app/ServerSetup.tsx` on screen instead of the application. +/// It is a first-launch state, not a failure, which is why it is an `Option` and not an error. +#[tauri::command] +pub fn server_url(vault: State<'_, Vault>) -> Result, String> { + crate::server::read(&vault.paths.server()).map_err(|_| failure("unreadable address")) +} + +/// Records the delivery service, and answers with the form that was stored. +/// +/// The answer is the normalised address rather than `()`, so the page uses exactly what the file +/// holds: the two would otherwise differ by a trailing slash or a default port, and the client +/// would build its URLs from a string the next launch does not agree with. +/// +/// The message on refusal is shown to the person typing, so it says what is wrong with the +/// address rather than that something is — see [`crate::server::normalise`]. +#[tauri::command] +pub fn server_set(url: String, vault: State<'_, Vault>) -> Result { + crate::server::write(&vault.paths.server(), &url) +} + /// Installs the vault into the application. /// /// Fails loudly if the secrets can be neither read nor created. That is deliberate: starting diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 2bb684da..4c9c18c4 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -35,6 +35,7 @@ pub mod cipher; pub mod commands; pub mod link; +pub mod server; pub mod store; /// Starts the application. @@ -67,6 +68,8 @@ pub fn run() { commands::session_load, commands::session_save, commands::session_clear, + commands::server_url, + commands::server_set, commands::master_seal, commands::master_open, commands::master_present, diff --git a/apps/desktop/src/server.rs b/apps/desktop/src/server.rs new file mode 100644 index 00000000..5f6af192 --- /dev/null +++ b/apps/desktop/src/server.rs @@ -0,0 +1,247 @@ +//! Which delivery service this installation talks to. +//! +//! # Why this is a stored value and not a constant +//! +//! It used to be a constant: `apps/web/src/lib/api.ts` carried `http://127.0.0.1:8787` and said, +//! in as many words, that "a desktop build aimed at another server would set `__WHISPEE_API__` +//! from the native side. Nothing does today." That made every packaged build — Linux, Windows, +//! macOS, Android, iOS — an application that could only reach a server running on the same +//! machine. It was a demonstration, not something anybody could install. +//! +//! This module is the caller that was missing. The address is asked for once, on first launch, +//! and kept beside the session. +//! +//! # Why the validation lives here rather than in the webview +//! +//! Because this string is what the shell's `connect-src` was widened for. `tauri.conf.json` now +//! allows `https:` and `wss:` — anywhere, since a policy cannot name an origin it will only learn +//! at runtime — and the compensation for that width is that the value reaching it went through a +//! parser first. A check written in the page could be skipped by the page; this one cannot. +//! +//! # Why the address cannot be changed afterwards +//! +//! Changing server means changing account. This device is attested by an account key the other +//! server has never heard of, and its MLS groups live in the first server's tables. So there is no +//! "switch server" that keeps anything, and offering one would offer a way to silently lose an +//! identity. Erasing the device is the exit, and it already exists. +//! +//! Nothing here enforces that — a second `write` would succeed. What enforces it is that the +//! interface offers no path to one; see `apps/web/src/app/ServerSetup.tsx`. + +use std::path::Path; + +use url::Url; + +/// Rejects everything that is not a bare origin this build may be pointed at. +/// +/// # What each rule is for +/// +/// **The scheme.** `https` anywhere, `http` only towards loopback. Plain HTTP to a remote host +/// would carry the signed requests, and every blob the server holds, over a network anybody on +/// the path can read — and it would do it *silently*, since nothing in the interface distinguishes +/// the two. The loopback exception is for development, where there is no certificate and no +/// network to be on. +/// +/// **No credentials.** `https://user:password@host` is a shape phishing uses to make a hostile +/// host read as a familiar one, and nothing here would ever use them. +/// +/// **No path, no query, no fragment.** The client appends `/v1/…` to what it is given. A base with +/// a path would produce URLs nobody wrote, and a base with a query would have it swallowed by the +/// concatenation — a failure whose symptom is a 404 that names nothing. +/// +/// The returned string is the origin and only the origin, without a trailing slash, so that +/// `format!("{base}/v1/…")` is right by construction. +pub fn normalise(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("no address"); + } + + let url = Url::parse(trimmed).map_err(|_| "not an address")?; + + let host = url.host_str().ok_or("an address needs a host")?; + + // `Url::is_special` is what makes a host comparison meaningful at all: for a non-special + // scheme the parser does not normalise the host, so this runs after the scheme check. + match url.scheme() { + "https" => {} + "http" if is_loopback(host) => {} + "http" => return Err("http reaches only a loopback address; use https"), + _ => return Err("an address begins with https:// or http://"), + } + + if !url.username().is_empty() || url.password().is_some() { + return Err("an address carries no username or password"); + } + + if url.path() != "/" && !url.path().is_empty() { + return Err("an address ends at the host, with no path"); + } + + if url.query().is_some() || url.fragment().is_some() { + return Err("an address ends at the host, with no query"); + } + + // `Url::port` is `None` when the port is the scheme's default, which is exactly the port that + // should not be spelled out: `https://example.test:443` and `https://example.test` name one + // server, and storing two spellings of it would show two. + Ok(match url.port() { + Some(port) => format!("{}://{}:{}", url.scheme(), host, port), + None => format!("{}://{}", url.scheme(), host), + }) +} + +/// Whether a host names this machine. +/// +/// # Why three literals and not `IpAddr::is_loopback` +/// +/// Because this list has to agree with the one in `apps/desktop/tauri.conf.json`, and a CSP can +/// only name hosts. `127.0.0.2` is a loopback address that `is_loopback` accepts and that the +/// shell's `connect-src` would then block — an address accepted by this validator, written to the +/// file, and refused by the browser engine at the first request, with no error naming the cause. +/// Two rules that are meant to be one rule have to be spelled the same way. +/// +/// Literal names rather than a DNS lookup, for the other half of the reason: resolving would let a +/// remote name that happens to answer `127.0.0.1` today unlock plain HTTP, and the answer could +/// change after the check. +fn is_loopback(host: &str) -> bool { + // `Url::host_str` returns an IPv6 literal in its bracketed serialised form, which is also the + // form a `connect-src` source has to be written in — so the two spellings match by themselves. + matches!(host, "localhost" | "127.0.0.1" | "[::1]") +} + +/// The address this installation was pointed at, or `None` before it has been. +/// +/// A file that exists but does not parse is treated as absent rather than as an error: the only +/// way to reach that state is manual editing, and the recoverable outcome — ask again — is better +/// than an application that will not start. +pub fn read(path: &Path) -> std::io::Result> { + let Some(bytes) = crate::store::Paths::read(path)? else { + return Ok(None); + }; + + let Ok(text) = String::from_utf8(bytes) else { + return Ok(None); + }; + + Ok(normalise(&text).ok()) +} + +/// Records the address, after validating it. +pub fn write(path: &Path, raw: &str) -> Result { + let address = normalise(raw).map_err(str::to_owned)?; + + crate::store::write_atomically(path, address.as_bytes()) + .map_err(|_| "the address could not be saved".to_owned())?; + + Ok(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_origin_survives_unchanged() { + assert_eq!(normalise("https://whispee.example").unwrap(), "https://whispee.example"); + } + + #[test] + fn a_trailing_slash_is_removed() { + assert_eq!(normalise("https://whispee.example/").unwrap(), "https://whispee.example"); + } + + #[test] + fn a_port_is_kept_and_a_default_port_is_not() { + assert_eq!( + normalise("https://whispee.example:8443").unwrap(), + "https://whispee.example:8443", + ); + assert_eq!(normalise("https://whispee.example:443").unwrap(), "https://whispee.example"); + } + + /// The development case, and the only one plain HTTP is allowed for. + #[test] + fn http_reaches_loopback() { + assert_eq!(normalise("http://127.0.0.1:8787").unwrap(), "http://127.0.0.1:8787"); + assert_eq!(normalise("http://localhost:8787").unwrap(), "http://localhost:8787"); + } + + /// **The test this module exists for.** Plain HTTP to a remote host would carry every signed + /// request in the clear, and nothing in the interface would say so. + #[test] + fn http_does_not_reach_anywhere_else() { + assert!(normalise("http://whispee.example").is_err()); + } + + /// A host that merely *looks* like loopback is not one. `127.0.0.1.example.test` is a name + /// somebody else owns. + #[test] + fn a_host_that_only_looks_like_loopback_is_refused() { + assert!(normalise("http://127.0.0.1.example.test").is_err()); + assert!(normalise("http://localhost.example.test").is_err()); + } + + /// **The rule this validator shares with the shell's `connect-src`.** + /// + /// `127.0.0.2` is a loopback address, and `tauri.conf.json` does not name it. Accepting it + /// here would store an address the browser engine refuses to contact, with nothing saying so. + #[test] + fn a_loopback_address_the_policy_does_not_name_is_refused() { + assert!(normalise("http://127.0.0.2:8787").is_err()); + assert!(normalise("http://[::1]:8787").is_ok()); + } + + #[test] + fn credentials_are_refused() { + assert!(normalise("https://someone:secret@whispee.example").is_err()); + } + + #[test] + fn a_path_a_query_and_a_fragment_are_refused() { + assert!(normalise("https://whispee.example/v1").is_err()); + assert!(normalise("https://whispee.example?token=x").is_err()); + assert!(normalise("https://whispee.example#x").is_err()); + } + + #[test] + fn a_scheme_that_is_not_http_is_refused() { + assert!(normalise("file:///etc/passwd").is_err()); + assert!(normalise("javascript:alert(1)").is_err()); + assert!(normalise("ws://whispee.example").is_err()); + } + + #[test] + fn nothing_at_all_is_refused() { + assert!(normalise("").is_err()); + assert!(normalise(" ").is_err()); + assert!(normalise("whispee.example").is_err()); + } + + #[test] + fn what_was_written_is_read_back() { + let root = std::env::temp_dir().join("wac-server-test-roundtrip"); + let _ = std::fs::remove_dir_all(&root); + let path = root.join("server.txt"); + + assert!(read(&path).unwrap().is_none()); + + write(&path, "https://whispee.example/").unwrap(); + assert_eq!(read(&path).unwrap().as_deref(), Some("https://whispee.example")); + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A hand-edited file reads as "not configured yet" rather than stopping the application. + #[test] + fn an_unreadable_file_reads_as_absent() { + let root = std::env::temp_dir().join("wac-server-test-garbage"); + let _ = std::fs::remove_dir_all(&root); + let path = root.join("server.txt"); + + crate::store::write_atomically(&path, b"not an address").unwrap(); + assert!(read(&path).unwrap().is_none()); + + std::fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/apps/desktop/src/store.rs b/apps/desktop/src/store.rs index 29321c98..8382b8e2 100644 --- a/apps/desktop/src/store.rs +++ b/apps/desktop/src/store.rs @@ -34,6 +34,10 @@ //! device keys, written once. Separating them allows erasing one without the other, which //! `session_clear` needs: forgetting a session must not destroy an identity the server still //! knows. +//! +//! Two more have joined them since — `master.bin` and `server.txt` — each for the same kind of +//! reason, argued on its own accessor below. The heading is kept as it was because the argument +//! it makes is about separation, not about the count. use std::fs; use std::io::Write; @@ -107,6 +111,20 @@ impl Paths { self.root.join("master.bin") } + /// Which delivery service this installation talks to. + /// + /// Plain text, and the one file here that is not a blob: it is a value somebody typed, and + /// somebody looking into their own application directory to find out which server it points + /// at should be able to read the answer. Nothing in it is secret — the address is in every + /// packet this application sends. + /// + /// A fourth file rather than a field of the session, for the same reason `master.bin` is one: + /// it has to be readable **before** the session is, being what the session is fetched from. + /// See [`crate::server`]. + pub fn server(&self) -> PathBuf { + self.root.join("server.txt") + } + /// Reads a file, or `None` if it does not exist. /// /// Absence is not an error: it is the state of a fresh install, and telling it apart from a @@ -192,15 +210,15 @@ mod tests { fs::remove_dir_all(root.parent().unwrap().parent().unwrap()).unwrap(); } - /// The three files are distinct, and that carries a property. + /// The four files are distinct, and that carries a property. /// - /// Forgetting a session, removing biometric unlock and destroying the device identity are - /// three acts with different consequences. Two paths that collided would run the wrong one of - /// the three, with nothing to signal it. + /// Forgetting a session, removing biometric unlock, destroying the device identity and + /// forgetting which server this is are four acts with different consequences. Two paths that + /// collided would run the wrong one of the four, with nothing to signal it. #[test] - fn the_three_files_do_not_collide() { + fn the_four_files_do_not_collide() { let paths = Paths::new(temp_dir("distinct")); - let files = [paths.session(), paths.secrets(), paths.master()]; + let files = [paths.session(), paths.secrets(), paths.master(), paths.server()]; for (i, one) in files.iter().enumerate() { for other in &files[i + 1..] { diff --git a/apps/desktop/tauri.conf.json b/apps/desktop/tauri.conf.json index 4162f021..b6f359c4 100644 --- a/apps/desktop/tauri.conf.json +++ b/apps/desktop/tauri.conf.json @@ -20,15 +20,22 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:8787 ws://127.0.0.1:8787; img-src 'self' data: blob: asset: http://asset.localhost; media-src blob:; object-src 'none'; worker-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: wss: http://127.0.0.1:* http://localhost:* http://[::1]:* ws://127.0.0.1:* ws://localhost:* ws://[::1]:*; img-src 'self' data: blob: asset: http://asset.localhost; media-src blob:; object-src 'none'; worker-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" } }, "bundle": { "active": true, "targets": [ "deb", - "appimage" + "rpm", + "appimage", + "nsis", + "msi", + "dmg" ], + "publisher": "Whispee contributors", + "copyright": "Whispee contributors, AGPL-3.0-or-later", + "licenseFile": "../../LICENSE", "category": "SocialNetworking", "shortDescription": "End-to-end encrypted messaging (MLS demonstration)", "longDescription": "A learning project, never audited. For communications that actually matter, use Signal.", @@ -36,7 +43,12 @@ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", - "icons/icon.png" - ] + "icons/icon.png", + "icons/icon.ico", + "icons/icon.icns" + ], + "macOS": { + "minimumSystemVersion": "10.15" + } } } diff --git a/apps/web/index.html b/apps/web/index.html index 08634cf5..f9dcd0d0 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -34,8 +34,57 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 00000000..65726a95 Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 00000000..6dd9299e Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 00000000..c633dd9d Binary files /dev/null and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 00000000..c7ec5a88 Binary files /dev/null and b/apps/web/public/icon-512.png differ diff --git a/apps/web/public/icon-maskable-512.png b/apps/web/public/icon-maskable-512.png new file mode 100644 index 00000000..a7995799 Binary files /dev/null and b/apps/web/public/icon-maskable-512.png differ diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 00000000..8fb7318a --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,35 @@ +{ + "id": "/", + "name": "Whispee", + "short_name": "Whispee", + "description": "End-to-end encrypted messaging over MLS (RFC 9420). A learning project, never audited.", + "lang": "en", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#fbfbfc", + "theme_color": "#fbfbfc", + "categories": ["social"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 74733dfc..da354f39 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,25 +1,49 @@ /** - * The service worker, and what it deliberately is not. + * The service worker: a wake-up point for Web Push, and a cache with a boundary. * - * # Why this file exists at all, when the project refused one + * # Why this file was allowed to exist, and what changed since * - * `src/lib/notifications.ts` refuses a service worker in as many words: "one would be a cache of - * the application shell served by the same server the desktop build exists to stop trusting". That - * objection is about **caching**. A worker that caches the shell keeps a copy of the application - * alive across visits, so a server that served a hostile bundle once keeps its victim even after - * it is fixed — which is a real and serious thing to refuse. + * `src/lib/notifications.ts` refused a service worker in as many words: "one would be a cache of + * the application shell served by the same server the desktop build exists to stop trusting". A + * worker that caches the shell keeps a copy of the application alive across visits, so a server + * that served a hostile bundle once keeps its victim even after it is fixed. That is a real and + * serious thing to refuse, and it was refused correctly. * - * This worker caches nothing. It registers no `fetch` handler, opens no `Cache`, keeps no - * precache manifest, and intercepts no request. Every load of the page comes from the network - * exactly as it did before this file existed, and deleting it changes nothing except that - * notifications stop arriving. It cannot serve a stale application because it cannot serve an - * application. + * This file then existed for a while caching **nothing** — no `fetch` handler, no `Cache` — because + * the Push API has no other delivery point: a push message wakes the *worker*, not the page. * - * The reason a worker is needed at all is that the Push API has no other delivery point: a push - * message wakes the *worker*, not the page, and there is no version of Web Push that reaches a - * document directly. + * It caches now, and the refusal above is answered rather than forgotten: * - * # Why the text is a constant + * 1. **`index.html` is never served from the cache while there is a network.** It is the entry + * point and the thing that names every other file, so it is fetched first, every time, and the + * cached copy is a fallback for being offline. A corrected deployment therefore takes effect on + * the next load, exactly as it did before this file cached anything. + * + * 2. **What is cached first is addressed by its content.** Vite fingerprints everything under + * `/assets/`, and it is `index.html` that names which fingerprints to load. A hostile asset + * kept in this cache is never asked for again once a corrected `index.html` names different + * files. The cache-first rule prolongs nothing; it only avoids re-downloading bytes whose name + * already asserts what they are. + * + * 3. **There is now a check that did not exist then.** `scripts/release-web.sh` publishes a + * manifest of hashes and `.github/workflows/release.yml` attests it, so what a deployment + * serves can be compared against what a commit produced — this file included, since it is + * served from `public/` like everything else. `scripts/verify-web.sh` is the other end. + * + * # What it still costs, stated rather than buried + * + * **Offline, the application starts from the last `index.html` this browser received** — which + * could be one served during an attack. It is bounded: the moment there is a network, it is + * replaced. And it is not a new exposure, because that `index.html` had already been executed when + * it arrived. What the cache adds is that it can be executed once more, with no network, before + * the correction can reach it. + * + * **Executable bytes that are not content-addressed are not cached first.** + * `crypto_wasm_bg.wasm` and `pdfjs/wasm/*` keep the same filename across releases, so a cached + * copy would be asked for again by name after a fix. They are fetched from the network and only + * fall back to the cache, like `index.html` and for the same reason. + * + * # Why the notification text is a constant * * The worker cannot decrypt. The MLS keys live in a WASM module inside the page, in memory the * worker has no access to, and moving them here would mean handing the decryption keys to a @@ -27,9 +51,8 @@ * about what: the same answer iOS forces on every messenger, arrived at here on purpose rather * than by constraint. * - * That is also the third of the three limits in `migrations/0011_push.sql`: the wake-up carries - * no text, no sender and no group id. There is nothing here to display even if this file wanted - * to. + * That is also the third of the three limits in `migrations/0011_push.sql`: the wake-up carries no + * text, no sender and no group id. There is nothing here to display even if this file wanted to. */ // Kept in step with `NOTICE_TITLE` and `NOTICE_BODY_ONE` in `src/lib/notifications.ts`. Duplicated @@ -39,6 +62,172 @@ const TITLE = "Whispee"; const BODY = "New message"; +/** + * The cache's name, and the only thing that empties it wholesale. + * + * Bumped by hand. This file is served verbatim from `public/` and is not processed by Vite, so + * nothing can inject a build hash into it, and a hand-bumped constant is the only form that stays + * byte-identical for everybody who builds this commit — which the published manifest requires. + * + * Forgetting to bump it is survivable, which is the point of `CACHE_LIMIT` below: deployments + * accumulate their fingerprinted assets in one generation, and the limit is what keeps that + * bounded without depending on anybody's memory. + */ +const GENERATION = "whispee-1"; + +/** + * How many entries one generation may hold. + * + * A full load asks for roughly thirty files, so this is about a dozen deployments' worth of + * fingerprinted assets. The oldest goes when a new one arrives — `caches` keeps insertion order, + * so "oldest" is the first key and needs no bookkeeping of its own. + */ +const CACHE_LIMIT = 400; + +/** + * What to do with a request, and nothing else. Pure, so it can be tested for what it decides + * rather than for what it downloads — see `push.test.ts`, which runs this file in a sandbox. + * + * - `"immutable"` — answer from the cache if it is there, otherwise fetch and keep it. Only for + * paths whose bytes cannot change under the same name, or whose contents are not code. + * - `"entry"` — like `"fresh"`, and additionally revalidated against the server rather than + * answered out of the browser's own HTTP cache. See `fresh`. + * - `"fresh"` — fetch, keep a copy, and fall back to that copy only when the network fails. + * - `"pass"` — do not touch it. The request goes out as if this file did not exist. + */ +function strategyFor(request) { + // A POST is not a thing to replay from a cache, and every write in this application is one. + if (request.method !== "GET") return "pass"; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return "pass"; + + // The delivery service. Signed requests and sealed envelopes: nothing here belongs in a store + // that outlives the tab, and a stale answer would be worse than no answer. + if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) return "pass"; + + // The entry point, in both spellings and in its navigation form. Never cache-first: see the + // header. + if (request.mode === "navigate") return "entry"; + if (url.pathname === "/" || url.pathname === "/index.html") return "entry"; + + // Fingerprinted by Vite. The name is derived from the bytes, so the bytes cannot change under + // it — and `index.html`, which is always fetched, is what says which names to ask for. + if (url.pathname.startsWith("/assets/")) return "immutable"; + + // Not fingerprinted, and cached first anyway because neither is code: the emoji files are JSON + // handed to `JSON.parse`, and the fonts are glyph tables handed to a shaper. A stale one draws + // the wrong picture; it does not run. Together they are nine megabytes that were fetched again + // on every single visit. + if (url.pathname.startsWith("/emoji/") && url.pathname.endsWith(".json")) return "immutable"; + if (url.pathname.startsWith("/fonts/")) return "immutable"; + + // Everything else, `crypto_wasm_bg.wasm` and `pdfjs/` included. Both are executable and both + // keep their filename across releases, which is exactly the pair of properties that would let a + // cached copy outlive its correction. + return "fresh"; +} + +/** Adds to the cache and drops the oldest entries once there are too many. */ +async function keep(request, response) { + const cache = await caches.open(GENERATION); + await cache.put(request, response); + + const keys = await cache.keys(); + // `keys()` answers in insertion order, so the front of the list is the oldest. + for (const stale of keys.slice(0, Math.max(0, keys.length - CACHE_LIMIT))) { + await cache.delete(stale); + } +} + +async function immutable(request) { + const hit = await caches.match(request, { cacheName: GENERATION }); + if (hit) return hit; + + const response = await fetch(request); + // Only a complete, successful answer. Caching a 404 or a range response would serve it back + // for as long as the generation lives. + if (response.ok && response.status === 200) await keep(request, response.clone()); + + return response; +} + +async function fresh(request, entry = false) { + try { + // **The entry point is revalidated, and that is not the same as fetching it.** + // + // `fetch` consults the browser's own HTTP cache first, so a deployment that serves + // `index.html` without `Cache-Control: no-cache` gets a stale entry point out of it — and the + // whole argument in this file's header, that a correction takes effect on the next load, would + // then rest on somebody's reverse-proxy configuration. `deploy/Caddyfile` sets that header and + // says why; this line is what makes the property hold for a deployment that does not. + // + // `no-cache` and not `no-store`: a conditional request still answers 304 from the cache, so + // the cost is a round trip, not the file. + // + // Constructing a `Request` from a navigation one downgrades its mode to `same-origin`, which + // the specification does deliberately and which costs nothing here — what is wanted is the + // bytes, and `respondWith` accepts any response for a navigation. + const response = await fetch(entry ? new Request(request, { cache: "no-cache" }) : request); + if (response.ok && response.status === 200) await keep(request, response.clone()); + + return response; + } catch (offline) { + const hit = await caches.match(request, { cacheName: GENERATION }); + if (hit) return hit; + + // A navigation with nothing cached for this exact URL still has somewhere to go: this is a + // single-page application, so every route is `index.html`. Without this, a reload of + // `/#/settings` offline would fail where the same visit to `/` would not. + if (request.mode === "navigate") { + const shell = await caches.match("/index.html", { cacheName: GENERATION }); + if (shell) return shell; + } + + throw offline; + } +} + +self.addEventListener("install", () => { + // **No `skipWaiting`, and no precache.** + // + // A worker that takes over a page already loaded can answer that page's later requests — a lazy + // chunk, a font — out of a generation it did not start with. The default lifecycle, where the + // new worker waits for every tab to go, is the one that keeps a session on one version of the + // application. + // + // Nothing is fetched here either: a precache list is a second manifest of filenames to keep in + // step with the build, and everything worth caching is asked for by the page a moment later + // anyway. +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // Older generations, and any cache some earlier version of this file opened. + const names = await caches.keys(); + await Promise.all(names.filter((name) => name !== GENERATION).map((name) => caches.delete(name))); + + // Claim the pages that loaded before this worker existed — the first visit, where the page + // was fetched from the network and nothing was controlling it. Without this, the first + // visit populates no cache at all and the offline start only works from the second. + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const strategy = strategyFor(event.request); + // Not calling `respondWith` at all is what "pass" means: the request goes to the network as if + // no worker were installed. Calling it with `fetch(event.request)` would look the same and is + // not — it would route the request through this worker's lifetime and stall it on termination. + if (strategy === "pass") return; + + if (strategy === "immutable") return event.respondWith(immutable(event.request)); + + event.respondWith(fresh(event.request, strategy === "entry")); +}); + self.addEventListener("push", (event) => { // `waitUntil` or the worker may be killed before the notification is shown. Browsers also // require that a push handler show *something*: staying silent gets the subscription revoked diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index af5bfaab..ddfb4a32 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,7 +13,12 @@ import { Onboarding } from "@/components/Onboarding"; import { RELOCK_MS, networkReported, observeIdle, observeLifecycle } from "@/lib/lifecycle"; import { compactNameOf } from "@/lib/naming"; import { addressedIn } from "@/lib/mention"; -import { countUnreadInTitle, createNotifier } from "@/lib/notifications"; +import { + clearUnreadBadge, + countUnreadInTitle, + createNotifier, + markUnreadBadge, +} from "@/lib/notifications"; import { type ProposedMigration, Session, start } from "@/lib/session"; import { RouterProvider, useNavigate } from "@/routes/Router"; import { DetailProvider } from "@/state/detail"; @@ -514,6 +519,7 @@ function Frame({ // longer knows the count. notices.dismissAll(); counter.restore(); + clearUnreadBadge(); }; }, [session, navigate]); @@ -633,6 +639,8 @@ function Frame({ } title.current?.show(unread); + // The same count, for the reader who installed this and has no tab to look at. + markUnreadBadge(unread); }); return ( diff --git a/apps/web/src/app/ServerSetup.tsx b/apps/web/src/app/ServerSetup.tsx new file mode 100644 index 00000000..383e8cd4 --- /dev/null +++ b/apps/web/src/app/ServerSetup.tsx @@ -0,0 +1,134 @@ +import { type FormEvent, useState } from "react"; + +import { Banner } from "../ui/Banner"; +import { Button } from "../ui/Button"; +import { Field } from "../ui/Field"; +import { Icon } from "../ui/Icon"; +import { Input } from "../ui/Input"; +import { chooseServer, reachable } from "../lib/server"; + +/** + * The first screen a packaged application shows: which server is this? + * + * # Why the question is asked at all + * + * A messenger with one server is a product; a messenger with an address field is a protocol. This + * project has been the second since it started — `deploy/` exists so that anybody can run the + * delivery service — and the applications were the one place that did not know it. Every packaged + * build carried `http://127.0.0.1:8787` compiled in, which is to say it could only reach a server + * running on the same machine. + * + * # Why there is no way back to this screen + * + * Changing server means changing account. This device is attested by an account key the other + * server has never heard of, and its conversations are MLS groups living in the first server's + * tables. There is no migration that keeps anything, and a "switch server" button would therefore + * be a button that silently discards an identity. + * + * So the address is shown in the settings, read-only, and the only exit is erasing the device — + * which exists, and says what it costs. The alternative would be an affordance that looks + * reversible and is not. + * + * # Why it checks before it stores + * + * The native side validates the *shape* of the address and would happily store a well-formed one + * that answers nothing. A typo in a hostname then produces an application that starts, tries to + * sign in, fails, and offers no way back to the field that was wrong. Reaching the server first + * is what keeps the mistake on this screen, where it can still be corrected. + */ +export function ServerSetup({ onReady }: { onReady: (origin: string) => void }) { + const [address, setAddress] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + + setBusy(true); + setError(null); + + try { + // The order matters: reach it, then keep it. Storing first would leave a broken address on + // disk, and the next launch would skip this screen and fail somewhere with less context. + // + // The typed string is probed rather than the normalised one, because normalising is the + // native side's job and asking it would mean storing. A trailing slash makes the probe URL + // `…//v1/push/vapid`, which every server this could be resolves the same way. + switch (await reachable(address.trim().replace(/\/+$/, ""))) { + case "not-whispee": + setError("Something answered, but it is not a Whispee server. Check the port."); + return; + case "unreachable": + setError("Nothing answered at that address."); + return; + } + + onReady(await chooseServer(address)); + } catch (failure) { + // The message comes from `apps/desktop/src/server.rs` and is written to be read here. + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +

Which server?

+

+ Whispee has no central service. This application talks to the one you name — somebody + else’s, or your own. Your messages are encrypted before they reach it either way; + what it does learn is in the threat model. +

+
+ + {error !== null && {error}} + +
void submit(event)} className="flex flex-col gap-gutter"> + + {({ id, describedBy, invalid }) => ( + setAddress(event.target.value)} + type="url" + inputMode="url" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + placeholder="https://whispee.example" + // The same argument `components/Lock.tsx` makes on the same line: this screen exists + // to receive one value and holds nothing else to read, so there is no content the + // focus could be taken away from. It is rendered before the application, never + // beside it. + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + /> + )} + + + +
+ +

+ This is asked once. Changing it later means starting a new account, because this device is + known to that server and to no other. +

+
+ ); +} diff --git a/apps/web/src/components/Devices.tsx b/apps/web/src/components/Devices.tsx index 8bca0087..812e77d9 100644 --- a/apps/web/src/components/Devices.tsx +++ b/apps/web/src/components/Devices.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { ResolvedAccount } from "@/lib/account"; import { describePresence } from "@/lib/presence"; +import { configuredServer } from "@/lib/server"; import { useBump, useSession } from "@/state/SessionProvider"; import { useReport } from "@/state/report"; import { Avatar } from "@/ui/Avatar"; @@ -46,6 +47,24 @@ export function DeviceSettings({ onClose }: { onClose: () => void }) { const [busy, setBusy] = useState(false); const [rotation, setRotation] = useState(false); const [phrase, setPhrase] = useState(null); + /** + * Which server knows these devices. + * + * Empty on the web, where it is this page's own origin and saying so would be telling the + * reader the address in their own URL bar. A packaged application is the case that needs it: + * its window carries no address anywhere, and "this device is known to a server" is a sentence + * with no meaning until the server is named. + * + * Read-only, and there is no control beside it. Changing server means changing account — this + * device is attested by an account key the other server has never heard of — so an editable + * field here would be a field that silently discards an identity. Erasing the device is the + * exit, and it is the last thing on this panel already. + */ + const [server, setServer] = useState(""); + + useEffect(() => { + void configuredServer().then((address) => setServer(address ?? "")); + }, []); const reload = () => { session @@ -185,6 +204,14 @@ export function DeviceSettings({ onClose }: { onClose: () => void }) { )} + {server !== "" && ( +

+ Known to{" "} + {server}. + This cannot be changed: your account exists there and nowhere else. +

+ )} + {/* The list above and the explanation below are two different kinds of thing — a roster and an argument about what revoking costs — so the change of subject is carried by the distance rather than by a rule. `mt-section` is a step above the `gap-snug` between the diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d89a76f5..26b3bfb0 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -8,7 +8,6 @@ import type { Admission } from "./call"; import type { DeviceCipher } from "./cipher"; import { fromBase64, toBase64, toHex } from "./keys"; -import { isTauri } from "./platform"; import type { AttestedDevice } from "./wasm"; /** See the note about `buffer` in `keys.ts`. */ @@ -17,12 +16,12 @@ function buffer(bytes: Uint8Array): BufferSource { } /** - * Where the delivery service is, and why this is no longer compiled in. + * Where the delivery service is. * - * # The empty string is the answer, and it is not a fallback + * # On the web, the empty string is the answer, and it is not a fallback * - * On the web the API shares this page's origin — `deploy/` puts Caddy in front of both, and the - * development server proxies `/v1` to make the same thing true there. So the base is relative, and + * The API shares this page's origin — `deploy/` puts Caddy in front of both, and the development + * server proxies `/v1` to make the same thing true there. So the base is relative, and * `fetch("/v1/…")` reaches the right place without anything being configured. * * That is what it buys, and the reason is not tidiness: `VITE_API_URL` used to be **substituted @@ -32,50 +31,41 @@ function buffer(bytes: Uint8Array): BufferSource { * by anybody, self-hosted deployments included — see `docs/THREAT-MODEL.md` on what that check is * and is not. * - * # Why an injected global rather than an import + * # On the packaged shell, it is a value somebody typed * - * The desktop shell is the one target where the page's origin says nothing: it is `tauri://`, and - * the server is elsewhere. The native side sets `__WHISPEE_API__` before the webview runs, which - * keeps the address out of the bytes this file compiles to. + * The shell's own origin is `tauri://` and says nothing about where a server is. This used to be a + * compiled-in `http://127.0.0.1:8787`, which made every packaged build — Linux, Windows, macOS, + * Android, iOS — an application that could only reach a server on the same machine. * - * A hostile web server could inject that global too. It gains nothing by it: it is already serving - * every line of this application, so redirecting the API is not a power it lacked. + * It is now read from the native side, which validated it before storing it + * (`apps/desktop/src/server.rs`), and handed here by `main.tsx` before anything renders. The + * address still changes no bytes in this bundle, which is the property that had to survive. + * + * # Why a variable and not a constant + * + * Because the value is only known after an `await`: the native side is asked over the IPC. A + * module-level constant would have to be computed at import time, and there is no synchronous way + * to ask. The write happens exactly once, from `configureApi`, before the first render. */ -export const BASE_URL = apiBase(); +let base = ""; /** - * The address the desktop shell reaches, and the reason a literal here costs nothing. + * Points this client at a delivery service. Called once, from `main.tsx`, before anything renders. * - * Compiled in, but **not configurable**, which is the distinction that matters: every build - * contains this same string, so it changes no bytes between deployments. `tauri.conf.json` already - * pins the same origin in its own policy, and `csp.test.ts` fails if the two disagree — so this is - * not a new coupling, it is the existing one written where the code can read it. + * The empty string is the web's answer and means "this page's own origin" — it is not a missing + * value, so this function does not refuse it. * - * A desktop build aimed at another server would set `__WHISPEE_API__` from the native side. Nothing - * does today, and inventing the mechanism before there is a caller would be inventing the wrong - * one. + * **What this does not do: validate.** On the packaged shell the address arrived from + * `apps/desktop/src/server.rs`, which parsed it and refused anything that was not a bare origin; + * repeating that here in a language the page controls would add a check nothing relies on. The + * trailing slashes are stripped because the callers below all write `${base}/v1/…`. */ -const DESKTOP_API = "http://127.0.0.1:8787"; - -function apiBase(): string { - const injected = (globalThis as { __WHISPEE_API__?: unknown }).__WHISPEE_API__; - if (typeof injected === "string" && injected !== "") return injected.replace(/\/+$/, ""); - - // The packaged shell is loaded from `tauri://`, so its own origin names nothing reachable. - if (isTauri()) return DESKTOP_API; - - return ""; +export function configureApi(origin: string): void { + base = origin.replace(/\/+$/, ""); } -/** - * The WebSocket URL for a path, whichever way the base is expressed. - * - * `BASE_URL.replace(/^http/, "ws")` was enough while the base was absolute. It is not now: an empty - * base leaves a bare path, and `new WebSocket("/v1/gateway")` throws `SyntaxError` — at the one - * moment the real-time session is being opened, with a message naming nothing. - */ export function socketUrl(path: string): string { - const origin = BASE_URL === "" ? globalThis.location.origin : BASE_URL; + const origin = base === "" ? globalThis.location.origin : base; return `${origin.replace(/^http/, "ws")}${path}`; } @@ -173,7 +163,7 @@ export class Api { * a name it merely answers to. */ static async createAccount(handle: string, identityKey: Uint8Array): Promise { - const response = await fetch(`${BASE_URL}/v1/accounts`, { + const response = await fetch(`${base}/v1/accounts`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ handle, identity_key: toBase64(identityKey) }), @@ -212,7 +202,7 @@ export class Api { mlsKey: Uint8Array, attestation: Uint8Array, ): Promise { - const response = await fetch(`${BASE_URL}/v1/devices`, { + const response = await fetch(`${base}/v1/devices`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -256,7 +246,7 @@ export class Api { await signingPayload(method, path, timestamp, nonce, encoded), ); - const response = await fetch(`${BASE_URL}${path}`, { + const response = await fetch(`${base}${path}`, { method, headers: { // The server does not inspect the body: this type is indicative, and attachments are @@ -442,7 +432,7 @@ export class Api { * key, the packet is unreadable. Returns `null` while there is nothing. */ static async claimPairing(id: Uint8Array): Promise { - const response = await fetch(`${BASE_URL}/v1/pairings/${toHex(id)}`); + const response = await fetch(`${base}/v1/pairings/${toHex(id)}`); if (response.status === 404) return null; if (!response.ok) throw new ApiError(response.status, await response.text()); @@ -499,7 +489,7 @@ export class Api { params: Uint8Array; sealed: Uint8Array; } | null> { - const response = await fetch(`${BASE_URL}/v1/recovery/claim`, { + const response = await fetch(`${base}/v1/recovery/claim`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ lookup: toBase64(lookup) }), @@ -594,7 +584,7 @@ export class Api { * Unsigned: a name has to be resolvable before there is an account to resolve it with. */ static async resolveHandle(handle: string): Promise { - const response = await fetch(`${BASE_URL}/v1/handles/${encodeURIComponent(handle)}`); + const response = await fetch(`${base}/v1/handles/${encodeURIComponent(handle)}`); if (!response.ok) { throw new ApiError( @@ -656,7 +646,7 @@ export class Api { * one fewer thing, and the screen hides the control the way it does for calls. */ static async vapidPublicKey(): Promise { - const response = await fetch(`${BASE_URL}/v1/push/vapid`); + const response = await fetch(`${base}/v1/push/vapid`); if (response.status === 503) return null; if (!response.ok) throw new ApiError(response.status, await response.text()); @@ -737,7 +727,7 @@ export class Api { nonce: Uint8Array, mac: Uint8Array, ): Promise { - const response = await fetch(`${BASE_URL}${path}`, { + const response = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/octet-stream", @@ -803,7 +793,7 @@ export class Api { const nonce = crypto.getRandomValues(new Uint8Array(16)); const mac = posting.mac(posting.key, groupId, nonce, payload); - const response = await fetch(`${BASE_URL}/v1/groups/${toHex(groupId)}/signals`, { + const response = await fetch(`${base}/v1/groups/${toHex(groupId)}/signals`, { method: "POST", headers: { "content-type": "application/octet-stream", @@ -839,7 +829,7 @@ export class Api { const nonce = crypto.getRandomValues(new Uint8Array(16)); const mac = posting.mac(posting.key, groupId, nonce, payload); - const response = await fetch(`${BASE_URL}/v1/groups/${toHex(groupId)}/call/token`, { + const response = await fetch(`${base}/v1/groups/${toHex(groupId)}/call/token`, { method: "POST", headers: { "content-type": "application/json", diff --git a/apps/web/src/lib/csp.test.ts b/apps/web/src/lib/csp.test.ts index d2db5e5e..a9aefce8 100644 --- a/apps/web/src/lib/csp.test.ts +++ b/apps/web/src/lib/csp.test.ts @@ -14,16 +14,6 @@ import { csp } from "./csp.ts"; * be loud, and the only place it can be made loud is here. */ -/** - * The API origin the desktop configuration is pinned to. - * - * It is a **desktop-only** source now. The web policy names no origin at all — the API is reached - * on the page's own origin, so `'self'` covers it — while the packaged shell is loaded from - * `tauri://` and has to be told where the server is. That difference is a transport difference, - * exactly like `ipc:`, which is why it belongs in the list below rather than in both policies. - */ -const DESKTOP_API = "http://127.0.0.1:8787"; - /** * Sources the desktop policy is allowed to have and the web policy is not. * @@ -35,10 +25,40 @@ const DESKTOP_ONLY: Record = { "connect-src": [ "ipc:", "http://ipc.localhost", - // The two forms of the API origin. The web build stopped naming them when the client began - // asking its own origin; the desktop cannot, because its own origin is `tauri://`. - DESKTOP_API, - DESKTOP_API.replace(/^http/, "ws"), + // **The two schemes, with no host, and that width is the deliberate part.** + // + // The shell is pointed at a server by the person installing it, so this policy has to be + // written before the origin is known — and a `connect-src` cannot name a host it will only + // learn at run time. The choice was between one application that reaches any deployment and + // one application built per deployment; the first is what makes a build worth putting in + // anybody's hands. + // + // What makes the width affordable here and nowhere else: this policy governs JavaScript + // packaged inside the installed binary. No server ships it, so no server can replace it — + // which is the whole argument `apps/desktop/src/lib.rs` makes for the application existing. On + // the web, where the server does ship the code, `connect-src 'self'` stays as tight as it is. + // + // The compensation is that the address went through a parser first: + // `apps/desktop/src/server.rs` refuses anything that is not a bare origin. + "https:", + "wss:", + // Loopback over plain HTTP, for development against a local server where there is no + // certificate and `https:` above would not match. + // + // **These three hosts are the same three `server.rs::is_loopback` accepts, and that is not a + // coincidence to be tidied away.** A validator that accepted a loopback address this policy + // does not name — `127.0.0.2`, say — would store an address the browser engine then refuses + // to contact, with no error naming the cause. The comment on that function says so from its + // side. + // + // The port is a wildcard because `scripts/dev-env.sh` hands each branch its own: pinning + // 8787 would work on `main` and silently fail on every other checkout. + "http://127.0.0.1:*", + "http://localhost:*", + "http://[::1]:*", + "ws://127.0.0.1:*", + "ws://localhost:*", + "ws://[::1]:*", ], "img-src": ["asset:", "http://asset.localhost"], }; diff --git a/apps/web/src/lib/csp.ts b/apps/web/src/lib/csp.ts index d4eb4a7e..b1b36df2 100644 --- a/apps/web/src/lib/csp.ts +++ b/apps/web/src/lib/csp.ts @@ -153,6 +153,16 @@ export function csp(media?: string): string { // same-origin `workerPort` precisely so that never happens; this is the second lock. "worker-src 'self'", "base-uri 'none'", + // **Inert as delivered, and kept anyway.** + // + // This policy reaches the browser in a ``, and the specification says + // `frame-ancestors` is ignored there — Chrome logs it as such. So this line protects nothing + // today, and `deploy/Caddyfile` carries an `X-Frame-Options: DENY` header that does. + // + // It stays because it is not wrong, only undelivered: the day this policy is sent as a header + // — which is what a deployment ought to do — it becomes the stronger of the two, and taking it + // out now would mean rediscovering the need later. The desktop shell repeats it for the same + // reason, and `csp.test.ts` compares the two sets. "frame-ancestors 'none'", "form-action 'self'", ].join("; "); diff --git a/apps/web/src/lib/notifications.test.ts b/apps/web/src/lib/notifications.test.ts index fea64416..697bf3e3 100644 --- a/apps/web/src/lib/notifications.test.ts +++ b/apps/web/src/lib/notifications.test.ts @@ -9,8 +9,10 @@ import { NOTICE_BODY_MANY, NOTICE_BODY_ONE, NOTICE_TITLE, + clearUnreadBadge, countUnreadInTitle, createNotifier, + markUnreadBadge, notificationPermission, requestNotificationPermission, unreadTitle, @@ -313,3 +315,49 @@ test("an unchanged count writes nothing", () => { assert.equal(writes, 1); assert.equal(target.title, "(3) Whispee"); }); + +/** + * The badge is the count for a reader with no tab to look at, and zero has to take it off rather + * than set it to zero — some platforms draw a bare dot for `setAppBadge(0)`, which is the same + * defect as a title left reading `(0) Whispee`. + */ +test("the badge follows the count, and zero removes it", () => { + const calls: string[] = []; + const target = { + setAppBadge: (count?: number) => { + calls.push(`set ${String(count)}`); + return Promise.resolve(); + }, + clearAppBadge: () => { + calls.push("clear"); + return Promise.resolve(); + }, + }; + + markUnreadBadge(3, target); + markUnreadBadge(0, target); + + assert.deepEqual(calls, ["set 3", "clear"]); +}); + +/** + * The ordinary state of this application: a page that is not installed, on a browser with no + * Badging API at all. Nothing may throw, and nothing may reject unhandled. + */ +test("a target without the API is not an error", () => { + assert.doesNotThrow(() => markUnreadBadge(3, {})); + assert.doesNotThrow(() => clearUnreadBadge({})); +}); + +/** + * `setAppBadge` rejects where the method exists and the call is refused — an uninstalled page on + * some builds. That is not an incident and there is nothing for a caller to do, so the one thing + * that must not happen is an unhandled rejection on every arriving message. + */ +test("a refused badge does not reject into the void", async () => { + markUnreadBadge(1, { setAppBadge: () => Promise.reject(new Error("not installed")) }); + clearUnreadBadge({ clearAppBadge: () => Promise.reject(new Error("not installed")) }); + + // A tick, so an unhandled rejection would have been raised by now and failed this test. + await new Promise((resolve) => setTimeout(resolve, 0)); +}); diff --git a/apps/web/src/lib/notifications.ts b/apps/web/src/lib/notifications.ts index 72de834e..ed9a0d26 100644 --- a/apps/web/src/lib/notifications.ts +++ b/apps/web/src/lib/notifications.ts @@ -317,12 +317,16 @@ export function createNotifier({ // // **This used to say there was no service worker here, and that a worker would be a cache // of the application shell served by the same server the desktop build exists to stop - // trusting.** There is one now — `public/sw.js` — and the sentence needed amending rather - // than deleting, because the objection it made is still right about the thing it names. - // That worker caches nothing: no `fetch` handler, no `Cache`, no precache manifest, and - // `push.test.ts` asserts the absence rather than trusting the comment. It exists because - // the Push API has no other delivery point — a push message wakes the worker, never a - // document — and it cannot serve a stale application because it cannot serve one at all. + // trusting.** There is one now — `public/sw.js` — and it does cache, which is the second + // amendment this comment has taken. + // + // The objection was right about `index.html`, and that is what the worker answers: the + // entry point is fetched from the network every time and read from the cache only when + // there is none, so a corrected deployment takes effect on the next load. What it answers + // from the cache first is addressed by its content, or is not code at all. `push.test.ts` + // runs the worker in a sandbox and asserts those decisions rather than trusting this + // paragraph; the worker's own header carries the argument in full, the residual cost + // included. // // What that does **not** fix is the path this `catch` is on: the worker only runs for a // push, so a tab open on Android Chrome still has no notification to show. The feature is @@ -400,6 +404,54 @@ export function countUnreadInTitle(target: TitleTarget = document): TitleCounter }; } +/** + * Anything that can carry a count on the application's icon. Satisfied by `navigator` where the + * Badging API exists, and by nothing at all where it does not. + * + * Optional members rather than a second type, because the absence is the common case: no Firefox, + * no Safari on the desktop, and — everywhere — nothing at all until the application is installed. + */ +export interface BadgeTarget { + setAppBadge?: (count?: number) => Promise; + clearAppBadge?: () => Promise; +} + +/** + * The unread count on the installed application's icon. + * + * # Why this is the same information twice, and worth having twice + * + * The tab title already carries the count, and a tab title is invisible to somebody who installed + * this to a home screen: there is no tab. The badge is where that reader finds out, and it is the + * one place the count survives the application not being open at all. + * + * # The count and nothing else + * + * Same rule as the title, for the same reason argued there: a number is a number, where a name on + * a lock screen is a fact about who somebody talks to, visible to whoever picks the phone up. The + * Badging API cannot carry a name even if this wanted to, which is a rare case of a platform + * agreeing. + * + * # Why the failures are swallowed + * + * `setAppBadge` rejects on a browser that has the method and refuses the call — an uninstalled + * page on some builds. That is the ordinary state of this application, not an incident, and there + * is nothing for a caller to do about it. The one thing that must not happen is an unhandled + * rejection every time a message arrives. + */ +export function markUnreadBadge(unread: number, target: BadgeTarget = navigator): void { + // Zero is `clearAppBadge`, not `setAppBadge(0)`: the second shows a dot on some platforms, which + // is the same defect as a title left reading `(0) Whispee`. + if (unread <= 0) return clearUnreadBadge(target); + + void target.setAppBadge?.(unread).catch(() => {}); +} + +/** Takes the badge off. For unmount, and for the count reaching zero. */ +export function clearUnreadBadge(target: BadgeTarget = navigator): void { + void target.clearAppBadge?.().catch(() => {}); +} + /* * # On the sound that is not here * diff --git a/apps/web/src/lib/push.test.ts b/apps/web/src/lib/push.test.ts index a96f0b14..65e40ecd 100644 --- a/apps/web/src/lib/push.test.ts +++ b/apps/web/src/lib/push.test.ts @@ -9,6 +9,7 @@ */ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { createContext, runInContext } from "node:vm"; import { test } from "node:test"; import { NOTICE_BODY_ONE, NOTICE_TITLE } from "./notifications.ts"; @@ -47,19 +48,165 @@ test("the service worker shows the same words the application does", () => { }); /** - * **The property that matters about the worker**, and it is an absence. + * The worker, run in a sandbox with the globals a service worker has and nothing else. + * + * It is a plain script, not a module — served verbatim so that what is deployed is what can be + * read — which is exactly what makes this possible: its top-level function declarations become + * properties of the sandbox, so `strategyFor` can be called and the listeners it registers can be + * fired. That is worth more than matching its text with a regular expression, because what has to + * hold is what it *decides*, not how it is spelled. + */ +function sandbox(overrides: Record = {}) { + const listeners: Record void> = {}; + const self = { + location: { origin: "https://whispee.example" }, + addEventListener: (name: string, handler: (event: unknown) => void) => { + listeners[name] = handler; + }, + registration: { showNotification: () => Promise.resolve() }, + clients: { claim: () => Promise.resolve(), matchAll: () => Promise.resolve([]) }, + }; + + const context = createContext({ self, URL, Promise, Math, ...overrides }); + runInContext(readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"), context); + + return { context: context as Record, listeners }; +} + +function asked(url: string, mode = "no-cors", method = "GET") { + return { url, mode, method }; +} + +/** + * **The property that answers the refusal this worker was written against.** * * `notifications.ts` refused a service worker because one would cache the application shell served - * by the server the desktop build exists to stop trusting. This worker is allowed to exist because - * it caches nothing. That is not a promise in a comment: a `fetch` handler or a `caches` call is - * what would turn it into the thing that was refused, so their absence is asserted. + * by the server the desktop build exists to stop trusting. The worker caches now, and what makes + * that acceptable is this line: the entry point is fetched, never answered from the cache while + * there is a network. A corrected deployment therefore takes effect on the next load. + * + * `/v1` is the other absence that has to hold: signed requests and sealed envelopes do not belong + * in a store that outlives the tab. */ -test("the service worker intercepts nothing and caches nothing", () => { - const worker = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"); - const code = worker.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +test("the entry point is never answered from the cache, and /v1 is never touched", () => { + const { context } = sandbox(); + const strategyFor = context.strategyFor as (request: unknown) => string; + + assert.equal(strategyFor(asked("https://whispee.example/", "navigate")), "entry"); + assert.equal(strategyFor(asked("https://whispee.example/index.html")), "entry"); + assert.equal(strategyFor(asked("https://whispee.example/#/settings", "navigate")), "entry"); + + assert.equal(strategyFor(asked("https://whispee.example/v1/messages")), "pass"); + assert.equal(strategyFor(asked("https://whispee.example/v1/gateway")), "pass"); +}); + +/** + * Cache-first is for bytes whose name asserts what they are, and for bytes that are not code. + * + * The two exclusions carry the argument: `crypto_wasm_bg.wasm` and `pdfjs/wasm/*` are executable + * **and** keep their filename across releases, which is the pair of properties that would let a + * cached copy outlive its correction. + */ +test("only content-addressed files and non-code are answered from the cache first", () => { + const { context } = sandbox(); + const strategyFor = context.strategyFor as (request: unknown) => string; + + assert.equal(strategyFor(asked("https://whispee.example/assets/index-abc123.js")), "immutable"); + assert.equal(strategyFor(asked("https://whispee.example/emoji/base.json")), "immutable"); + assert.equal(strategyFor(asked("https://whispee.example/fonts/inter.woff2")), "immutable"); + + assert.equal(strategyFor(asked("https://whispee.example/crypto_wasm_bg.wasm")), "fresh"); + assert.equal(strategyFor(asked("https://whispee.example/pdfjs/wasm/jbig2.wasm")), "fresh"); +}); + +/** Nothing this worker does applies to a write, or to another origin. */ +test("a write and another origin pass straight through", () => { + const { context } = sandbox(); + const strategyFor = context.strategyFor as (request: unknown) => string; + + assert.equal(strategyFor(asked("https://whispee.example/assets/x.js", "no-cors", "POST")), "pass"); + assert.equal(strategyFor(asked("https://elsewhere.example/assets/x.js")), "pass"); +}); + +/** + * The decision above, carried out: with a network, the cached entry point is not what comes back. + * + * A stub `caches` that would answer and a `fetch` that does — if the order were the other way + * round, the stale copy would win and the whole argument in the worker's header would be false. + */ +test("with a network, a cached entry point is not what is served", async () => { + const served: string[] = []; + const { context } = sandbox({ + fetch: () => { + served.push("network"); + return Promise.resolve({ ok: true, status: 200, clone: () => ({}) }); + }, + caches: { + open: () => + Promise.resolve({ put: () => Promise.resolve(), keys: () => Promise.resolve([]) }), + match: () => { + served.push("cache"); + return Promise.resolve({ ok: true, status: 200 }); + }, + }, + }); + + await (context.fresh as (request: unknown) => Promise)(asked("https://whispee.example/")); + + assert.deepEqual(served, ["network"], "the cache was consulted before the network"); +}); + +/** + * **The property must not rest on the deployment's headers.** + * + * `fetch` consults the browser's own HTTP cache first, so a reverse proxy that serves + * `index.html` without `Cache-Control: no-cache` would hand back a stale entry point and the + * argument in the worker's header would be false for that deployment. `deploy/Caddyfile` sets the + * header; the worker asks for a revalidation anyway. + */ +test("the entry point is revalidated rather than read out of the HTTP cache", async () => { + const asked_for: unknown[] = []; + const { context } = sandbox({ + // A plain factory, not a class with parameter properties: `node --test` strips types, it does + // not transform, and `constructor(readonly x)` is a transform. `lib/push.ts` is written the + // way it is for the same reason. + Request: function (input: unknown, init: { cache?: string }) { + return { input, init }; + }, + fetch: (request: { init?: { cache?: string } }) => { + asked_for.push(request.init?.cache ?? "default"); + return Promise.resolve({ ok: true, status: 200, clone: () => ({}) }); + }, + caches: { + open: () => + Promise.resolve({ put: () => Promise.resolve(), keys: () => Promise.resolve([]) }), + match: () => Promise.resolve(undefined), + }, + }); + + const fresh = context.fresh as (request: unknown, entry?: boolean) => Promise; + await fresh(asked("https://whispee.example/"), true); + await fresh(asked("https://whispee.example/crypto_wasm_bg.wasm")); + + assert.deepEqual(asked_for, ["no-cache", "default"]); +}); + +/** And without one, it is — which is the whole reason any of this is here. */ +test("with no network, the cached entry point is what is served", async () => { + const { context } = sandbox({ + fetch: () => Promise.reject(new Error("offline")), + caches: { + open: () => + Promise.resolve({ put: () => Promise.resolve(), keys: () => Promise.resolve([]) }), + match: () => Promise.resolve({ ok: true, status: 200, cached: true }), + }, + }); + + const response = await ( + context.fresh as (request: unknown) => Promise<{ cached?: boolean }> + )(asked("https://whispee.example/")); - assert.doesNotMatch(code, /addEventListener\(\s*["']fetch["']/, "it would serve a stale bundle"); - assert.doesNotMatch(code, /caches\b/, "it would keep a copy of the application"); + assert.equal(response.cached, true); }); test("a base64url key decodes to the sixty-five bytes of an uncompressed point", () => { diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts index 9b5ac4c1..77ead5c4 100644 --- a/apps/web/src/lib/push.ts +++ b/apps/web/src/lib/push.ts @@ -104,6 +104,12 @@ export function decodeApplicationServerKey(base64url: string): Uint8Array { if (!pushSupported()) return null; diff --git a/apps/web/src/lib/server.ts b/apps/web/src/lib/server.ts new file mode 100644 index 00000000..f9d884e4 --- /dev/null +++ b/apps/web/src/lib/server.ts @@ -0,0 +1,81 @@ +/** + * Which delivery service this installation talks to. + * + * # Why the shell has to be told, and the web does not + * + * On the web the answer is the page's own origin: `deploy/` puts one reverse proxy in front of + * the client and the API, and the development server proxies `/v1`. The empty string means + * exactly that, and it is a configured value rather than a missing one — see `api.ts`. + * + * The packaged shell is loaded from `tauri://`, which names nothing reachable. It used to carry a + * compiled-in `http://127.0.0.1:8787`, so every build that was installed anywhere could only talk + * to a server on the same machine. This module is what replaces that constant. + * + * # Why nothing here validates + * + * The address is parsed and refused in Rust, by `apps/desktop/src/server.rs`, before it is + * stored. Repeating the rules here would put a second copy of a security decision in the one + * place that cannot enforce it — the page. What this module does instead is *ask*, and report + * what the native side answered, message included. + * + * The one thing it does on its own is [`reachable`], which is not validation: it is the + * difference between an address that is well formed and an address where something is listening. + */ +import { invoke } from "@tauri-apps/api/core"; + +import { isTauri } from "./platform"; + +/** + * The address this installation is pointed at, or `null` if it has not been pointed anywhere. + * + * `""` on the web — this page's own origin, which is an answer. `null` only ever comes back from + * a packaged shell on first launch, and it is what puts `app/ServerSetup.tsx` on screen. + */ +export async function configuredServer(): Promise { + if (!isTauri()) return ""; + + return (await invoke("server_url")) ?? null; +} + +/** + * Records the address, and answers with the form that was stored. + * + * The answer is the *normalised* address, not the string that was typed: the two differ by a + * trailing slash or a default port, and building URLs from a spelling the next launch does not + * agree with is the kind of difference that shows up as one broken request in ten. + * + * Throws with the native side's own message when the address is refused. That message is written + * to be read by the person typing — see `server.rs`. + */ +export async function chooseServer(raw: string): Promise { + return invoke("server_set", { url: raw }); +} + +/** + * Whether a delivery service answers at this address. + * + * # Why `/v1/push/vapid` and not a health route + * + * Because it is public (`crates/server/src/routes.rs`), and because both of its answers are + * informative: 200 when a deployment has configured Web Push, 503 when it has not. Either proves + * a Whispee server is there. There is no `/v1/health`, and adding a public route for one caller + * would be adding public surface to answer a question an existing route already answers. + * + * A 404 is the useful failure: something is listening, and it is not this. Telling that apart + * from "nothing is listening" is worth the extra branch, because the two have different fixes — + * one is a typo in the host, the other a typo in the port. + */ +export type Reach = "ok" | "not-whispee" | "unreachable"; + +export async function reachable(origin: string): Promise { + try { + const response = await fetch(`${origin}/v1/push/vapid`, { method: "GET" }); + + // 503 is "push is off here", which only a server that has the route can say. + return response.ok || response.status === 503 ? "ok" : "not-whispee"; + } catch { + // `fetch` rejects for DNS, TLS, connection refused and a policy refusal alike, and the browser + // deliberately does not say which — so neither does this. + return "unreachable"; + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7e51971c..78d0a6e1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,12 +6,63 @@ * them would consume the same message keys twice, and MLS refuses the second read. The symptom * would be messages lost in development only, the worst kind of divergence between the two * environments. + * + * # Why the first render waits + * + * Because the client no longer knows where its server is until it asks. On the web the answer is + * immediate and is the empty string — this page's own origin — but on a packaged shell it comes + * back over the IPC, and there is no synchronous way to ask. Rendering first and configuring + * afterwards would let the earliest request go out against an unset base, which is a bare path in + * a `tauri://` document: a `SyntaxError` naming nothing, at sign-in. + * + * The wait is one IPC round trip and nothing is painted before it. That is a blank frame on the + * desktop and no frame at all on the web, where the promise is already resolved. */ import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { ServerSetup } from "./app/ServerSetup"; +import { configureApi } from "./lib/api"; +import { configuredServer } from "./lib/server"; import "./index.css"; -const root = document.getElementById("root"); -if (!root) throw new Error("mount point not found"); +/** + * The worker is registered here, at boot, and no longer only when notifications are turned on. + * + * It caches now (`public/sw.js`), and a cache that exists only for the people who enabled push is + * a cache for almost nobody — the nine megabytes of emoji data and every fingerprinted asset were + * fetched again on every visit for everybody else. Registering it here is also what makes a cold + * start with no network work at all. + * + * `lib/push.ts` still calls `register` on its own path. That is deliberate and not a duplicate: + * `register` with the same script and scope answers with the existing registration rather than + * making a second one, and push must not depend on this call having happened first. + * + * Silent on failure, and the list of ways it fails is the reason: no `serviceWorker` at all in a + * Tauri webview, an insecure context, a browser configured to refuse them. None is a condition the + * reader asked about or can act on, and every one of them leaves an application that works exactly + * as it did before this file registered anything. + */ +if ("serviceWorker" in navigator) { + void navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {}); +} + +const element = document.getElementById("root"); +if (!element) throw new Error("mount point not found"); + +const root = createRoot(element); + +function start(origin: string) { + configureApi(origin); + root.render(); +} -createRoot(root).render(); +void configuredServer() + // A failure to *read* the address is treated as not having one, for the same reason + // `server.rs::read` treats an unparseable file as absent: asking again is recoverable, and an + // application that will not start is not. The only way here is an IPC that is broken, in which + // case the setup screen will fail too — but it will fail with a sentence on screen. + .catch(() => null) + .then((configured) => { + if (configured === null) root.render(); + else start(configured); + }); diff --git a/deploy/Caddyfile b/deploy/Caddyfile index d7742f56..da493007 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -21,6 +21,18 @@ Strict-Transport-Security "max-age=63072000; includeSubDomains" # Content sniffing turns a file the client uploaded into a script the browser executes. X-Content-Type-Options "nosniff" + # **The one directive a `` Content-Security-Policy cannot carry.** + # + # `apps/web/src/lib/csp.ts` declares `frame-ancestors 'none'` and `index.html` delivers the + # policy in a meta element, where the specification says that directive is ignored — Chrome + # logs it as such. The result was a deployment with no defence against being framed at all. + # Found by reading a real browser's console against this stack, not by inspection. + # + # `X-Frame-Options` rather than a `Content-Security-Policy` header: sending the policy from + # here would be the third hand-maintained copy of it, which is the drift the note below + # refuses. This header says one thing, it is the thing the meta cannot say, and no part of + # it repeats a decision made elsewhere. + X-Frame-Options "DENY" # A referrer would name this deployment to every site a message links to — which is one # of the metadata leaks the rest of the architecture spends real effort removing. Referrer-Policy "no-referrer" @@ -65,7 +77,17 @@ # The mutable half. `index.html` carries the CSP and the script tags that name the # fingerprinted files: cached, it would pin a client to a version that no longer exists on # disk after a deploy — a blank page nobody can reproduce. - @entry path /index.html / + # + # `sw.js` joins it, and the manifest with it. A stale service worker is the same defect one + # level up: it decides what the *next* load is answered from, so a cached one keeps its + # routing rules after they have been corrected. Browsers already leave a worker script out + # of the HTTP cache on their own — that is what `updateViaCache` defaults to — and this + # line is for the proxy in between that does not know it. + # + # The client does not rely on any of this: `apps/web/public/sw.js` asks for `index.html` + # with `cache: "no-cache"` precisely so a deployment that omits these headers still heals. + # Two locks on the property that matters most. + @entry path /index.html / /sw.js /manifest.webmanifest header @entry Cache-Control "no-cache" try_files {path} /index.html diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6843aad..cdbea5f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -321,6 +321,7 @@ Everything else follows: | Device auth key | Non-extractable `CryptoKey` in IndexedDB | `secrets.bin`, held by the Rust process | | State-at-rest key | Non-extractable `CryptoKey` in IndexedDB | Same file, same process | | Master key when biometrics are on | Not available | `master.bin` — its existence *is* the on/off flag | +| Which server this is | This page's own origin | `server.txt`, typed on first launch and parsed by `server.rs` | | Purged when | Browser eviction rules apply | Only on uninstall | Why the native side exists at all: a mobile webview's storage **is not guaranteed**. iOS diff --git a/docs/BUILD.md b/docs/BUILD.md index ce1d8703..eac552ce 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -39,7 +39,6 @@ Host port 55432 avoids colliding with a system Postgres (5432) or a local Supaba | `ALLOWED_ORIGINS` | `http://127.0.0.1:5173,http://localhost:5173,tauri://localhost,http://tauri.localhost` | CORS allow-list. Derived per branch in development | | `THROTTLE_PER_MINUTE` | `60` | Per-address limit on the four routes that cannot be authenticated | | `CLAIM_QUOTA_PER_MINUTE` | `5` | Per caller-target pair limit on KeyPackage consumption | -| `VITE_API_URL` | `http://127.0.0.1:8787` | **Build-time**, web side. See below | The two Tauri origins are in the default rather than only in the documentation: the operating system imposes them — `tauri://localhost` on Linux and macOS, `http://tauri.localhost` on @@ -85,7 +84,7 @@ cannot undo. The only recovery was to drop the volume, which destroys every othe data along with the offending row. **The ports**, because four values have to agree before a client reaches a server at all: -`SERVER_ADDR`, the client's `VITE_API_URL`, the CSP computed from it, and the server's +`SERVER_ADDR`, `WHISPEE_API` (where Vite proxies `/v1`), `WEB_PORT`, and the server's `ALLOWED_ORIGINS`. Move one and the browser refuses the request before sending it — "Failed to fetch", no server log, no cause named. `dev-env.sh` emits all four together, which is why the client has a launcher of its own rather than a documented `pnpm run dev`. @@ -94,11 +93,17 @@ Run `scripts/dev-env.sh` by hand to see what a branch resolves to; it prints she lines and changes nothing else. `cargo run -p server` still works in a shell that has evaluated them. -**`cargo tauri dev` does not follow.** `apps/desktop/tauri.conf.json` pins `devUrl` to port 5173 -and repeats the CSP by hand for `127.0.0.1:8787`; that duplication is deliberate and guarded by -`csp.test.ts` (see the header of `apps/web/src/lib/csp.ts`). The desktop shell therefore only -runs against index 0, unless you edit that configuration for the session. Widening a security -policy for a development convenience is not a trade worth making. +**`cargo tauri dev` follows only halfway.** `apps/desktop/tauri.conf.json` pins `devUrl` to port +5173, so the shell loads the client of index 0 whatever branch is checked out. What it no longer +pins is the *server*: the address is typed on first launch and validated in +`apps/desktop/src/server.rs`, and the shell's `connect-src` allows any loopback port for exactly +this case — `dev-env.sh` hands each branch its own, and a policy naming 8787 would have worked on +`main` and failed silently everywhere else. + +That policy is still written twice — computed for the web in `apps/web/src/lib/csp.ts`, typed by +hand into `tauri.conf.json` — and `csp.test.ts` still fails on any divergence neither copy +declares. Its `DESKTOP_ONLY` list is where the shell's extra transports are argued, `https:` and +`wss:` included. Migrations in `crates/server/migrations/` are applied by the process at startup, along with creating the transparency log's signing key on first run and backfilling accounts that predate @@ -168,16 +173,24 @@ APK contains the module as a versioned artefact under `apps/web/`, so changing The binary is about 1.5 MB raw, 512 KB gzipped. Serve it compressed and with a long cache: it is a direct user cost on every first load. -### `VITE_API_URL` drives two things +### The bundle carries no deployment's configuration + +There is no `VITE_API_URL` any more. The web client asks its own origin for `/v1` — `deploy/` +puts one reverse proxy in front of both, and the development server proxies — so the API needs +no address, and `connect-src 'self'` covers the WebSocket too under CSP level 3. + +That is what lets **one** build serve every deployment, and therefore what lets one published +manifest of hashes describe every deployment. While the origin was substituted in at build time, +three files out of two hundred and twenty-six differed per instance, and the manifest could only +ever have described the official one. See `apps/web/src/lib/api.ts` and `scripts/release-web.sh`. -It sets the API origin **and** the Content-Security-Policy, which is computed at build time by -`csp()` in `apps/web/vite.config.ts` rather than written into `index.html`. A hard-coded policy -and a configurable origin diverge on the first deployment, and the symptom is a "Failed to -fetch" the browser emits before sending anything — the server sees nothing and the message -does not name the cause. +The one variable that still reaches the bytes is `VITE_MEDIA_URL`, which widens `connect-src` for +a media server. A deployment configuring calls therefore stops matching the published manifest — +verifiable or calls, not both, until the media server sits behind the same origin. `release-web.sh` +builds without it on purpose. -`connect-src` carries **both** origins, `http(s)://` and `ws(s)://`: the second is not derived -from the first, and keeping only one cuts half the client without the other half reporting it. +The policy is still computed rather than written into `index.html`, by `csp()` in +`apps/web/src/lib/csp.ts` via the plugin in `vite.config.ts`. Two settings in `vite.config.ts` must not be undone. `build.modulePreload.polyfill` is `false` because Vite otherwise injects a small inline script — reintroducing exactly the inline script @@ -207,6 +220,29 @@ normally adds the feature; this project does not use that CLI for desktop, so convention, because the common case here is running the application, not editing its interface. +### Installers + +```sh +cargo install tauri-cli --version "^2" --locked +cargo tauri build # every target this host can produce +cargo tauri build --bundles deb # or just one +``` + +Output lands in `target/release/bundle/`. The configuration lists six targets — `deb`, `rpm`, +`appimage`, `nsis`, `msi`, `dmg` — and each host produces the ones that belong to it. + +`release.yml` builds all six on a tag across three runners and attaches them to the release with a +`SHA256SUMS` and a Sigstore attestation. **They carry no platform signature**: Windows shows +SmartScreen and macOS asks for a right-click → Open, because a Windows certificate and an Apple +developer account are both things somebody rents. The `.dmg` is Apple Silicon only, `macos-14` +being an arm64 runner. + +Linux builds on `ubuntu-22.04` rather than the newest image, deliberately: a binary linked against +24.04's glibc refuses to start on anything older. + +**The version comes from the tag**, passed with `--config`, not from `tauri.conf.json`. Two files +already carry a version and a third place to bump is a third place to forget. + **A path trap in `tauri.conf.json`**, which has cost one build: `frontendDist` is resolved from the configuration file, so from `apps/desktop`, while `beforeBuildCommand` and `beforeDevCommand` run from `apps/`. The two are not written with the same prefix. JSON takes @@ -323,8 +359,10 @@ cargo tauri ios init # regenerates gen/apple, m cargo tauri ios build --debug --target aarch64-sim ``` -`VITE_API_URL` must be set for both: it is frozen into the bundle **and** into the CSP. -Missing, it produces a client pointing at the phone's own loopback — that is, at nothing. +Neither needs an API address at build time any more: the packaged application asks for one on +first launch and keeps it in `server.txt` (`apps/desktop/src/server.rs`). Before that, both +workflows froze `http://127.0.0.1:8787` into the bundle, which produced an application pointing at +the phone's own loopback — that is, at nothing. The native projects under `apps/desktop/gen/` are **regenerated on every build rather than versioned**: a generated project that drifts from its source is a source of silent errors. @@ -338,7 +376,8 @@ erased by the next `android init`. |---|---|---| | Push to `dev` | nothing | — | | Push to `main` | Android build, if `apps/` or `Cargo.lock` changed | Ubuntu runner | -| Manual dispatch | Android or iOS, your choice | depends on the target | +| Tag `v*` | The web manifest, then desktop installers on three runners | Ubuntu, Windows, macOS | +| Manual dispatch | Android or iOS, your choice; or a release rehearsal that publishes nothing | depends on the target | Manual dispatch is the normal way to get a mobile binary. **iOS never runs automatically**: a macOS runner minute is billed ten times a Linux one, which makes it the decisive expense. @@ -351,6 +390,10 @@ of the whole dependency tree — enough to approach a runner's timeout once the invalidated. iOS builds unsigned, for the simulator, so it produces nothing installable on a real device; that needs an Apple developer account, a provisioning profile and a certificate. +**These builds have run in CI. `ios.yml` has had one green run**, its first ever, twelve and a half +minutes on `macos-14`. What has still never happened is a build on this machine, or an install on a +phone. + ## See also - [`./ARCHITECTURE.md`](./ARCHITECTURE.md) — what the crates are and how a message travels diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 61f014e1..1b481f14 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,7 +32,7 @@ Everything in this list is implemented and has tests, unless the row says otherw | Local lock | Argon2id 64 MiB / 3 passes, unlock key → master key indirection, re-locking after five minutes without the user | | Disappearing messages | Seven days by default, carried in a `0xF101` group-context extension; admin or moderator may change it | | History vault | On by default, revocable in settings — and never used for a conversation that has a lifetime | -| Desktop application | Tauri 2, interface packaged in the binary | +| Desktop application | Tauri 2, interface packaged in the binary. Installers for the three desktop systems are built by CI on a tag, attested, and unsigned by the platforms | | Reproducible signed releases | `scripts/release.sh` and `scripts/verify-release.sh` | | Mobile adaptation | Navigation, safe areas, keyboard, touch targets, lifecycle, offline state, native storage, QR pairing | | Push notifications | Web Push, off until a deployment sets `VAPID_SUBJECT`; the wake-up carries nothing. Browsers only — no FCM, no APNs | @@ -47,11 +47,11 @@ These are finished features whose last mile could not be exercised on the develo | Area | What has not been checked | |---|---| -| Keyboard and safe areas | Never seen on a physical device — only in a browser and an emulator | +| Keyboard and safe areas | Never seen on a physical device — only in a browser and an emulator. No longer untestable, though: an iPhone can load the `deploy/` stack through a tunnel | | Native storage migration | The end-to-end migration path has never been run from start to finish | | Background re-locking | Verified only in its wiring, not its timing | | QR pairing | The scan itself, for want of `BarcodeDetector` on Chrome under Linux; encoding and decoding are tested | -| Mobile builds | Only ever built in CI, never locally — no Android NDK and no macOS host here | +| Mobile builds | Only ever built in CI, never locally — no Android NDK and no macOS host here. `ios.yml` has since had one green run on `workflow_dispatch`; `android.yml` produces an unsigned debug APK | | Mobile builds in CI | `test.yml` runs the suites and the WebAssembly check on every pull request; `android.yml` and `ios.yml` stay manual or `main`-only, so no mobile artefact is built on a PR | ## Push notifications — Web Push works, FCM and APNs do not @@ -73,10 +73,23 @@ preserve first. The roadmap used to describe FCM and APNs, and said the missing part was "all of it the part that requires secrets". That was true and it was not the hard part. The hard part is device-side -registration: it needs a Tauri plugin that does not exist, therefore Kotlin and Swift, and none of -it compiles or runs on the development machine — no NDK, no macOS host, no physical device. -Writing it would have produced exactly what this document refuses elsewhere: integration code that -has never been executed and looks like a feature. +registration, and this section used to say it "needs a Tauri plugin that does not exist". + +**That sentence has expired.** Several exist now — `tauri-plugin-notifications` (0.5.0-rc.11, +20k downloads, last published 2026-06-30) announces FCM and APNs delivery outright, and +`tauri-plugin-mobile-push`, `tauri-plugin-remote-push` and `tauri-plugin-fcm` sit beside it. A +release candidate is not a thing to lean a messenger on without reading it, but "no such plugin" +is no longer why this is unwritten. + +What is still why: **APNs cannot be exercised at all without a paid Apple Developer membership.** +Registering for remote notifications needs the `aps-environment` entitlement, which needs a +provisioning profile, which needs the membership — a free personal team is not offered the Push +Notifications capability, and the simulator receives no remote push. There is an iPhone here now +and it changes nothing about that. On the Android side there is no device here at all. + +So writing it would still produce what this document refuses elsewhere: integration code that has +never been executed and looks like a feature. The wall moved from "the tooling does not exist" to +"nothing here can run it", which is a smaller wall and an honest one. Web Push removed that wall for one specific reason. **The wake-up carries nothing**, so there is no payload to encrypt, so the whole content-encryption half of Web Push — RFC 8291, `aes128gcm`, @@ -93,9 +106,17 @@ follow carrying a bearer token. No request is made unless a deployment sets the **There is a service worker**, and `notifications.ts` used to argue against one. The objection was that a worker would cache the application shell served by the server the desktop build exists to -stop trusting. This one caches nothing — no `fetch` handler, no `Cache`, no precache manifest — -and `push.test.ts` asserts that rather than trusting the comment. It exists because a push message -wakes a worker and never a document. +stop trusting. It exists because a push message wakes a worker and never a document — and it +caches now as well. + +What makes that acceptable is written in its header and asserted in `push.test.ts`, which runs the +file in a sandbox and checks what it decides: `index.html` is fetched from the network every time +and read from the cache only when there is none, so a corrected deployment takes effect on the next +load; what is answered from the cache first is either addressed by its content (`/assets/`, which +Vite fingerprints) or is not code (`/emoji/*.json`, `/fonts/`). `crypto_wasm_bg.wasm` and +`pdfjs/wasm/` are excluded for being both executable and stably named. The residual cost is stated +rather than buried: offline, the application starts from the last `index.html` this browser +received. ### What is still missing @@ -103,9 +124,48 @@ wakes a worker and never a document. browser are. `Vapid::wake` matches on the provider name, so a second emitter lands beside it without touching the call site, but neither is written and the wall described above has not moved. -- **iOS needs the site installed to the home screen** before it will subscribe at all, and even - then a notification there can never show content — the service extension is a separate Swift - process while the keys live in a WASM module inside the webview. + + A Tauri webview has no service worker either, so `pushSupported()` is false there and a packaged + build has **no background wake-up path at all**. Since the web client became installable, the + ranking is inverted on a phone: the site added to the home screen is notified and the native + application is not. Saying so is not a recommendation to prefer the web — it is the shape of the + next piece of work. + +- **Android without Google services.** Recorded until now under "What will not be resolved" as + "not specified, and not planned". The first half of that was wrong, and the entry was in the + wrong list: UnifiedPush is specified, precisely, and its fix is inside this design rather than + outside it. Its endpoints are Web Push endpoints — RFC 8030, authenticated with the same VAPID + signature `vapid.rs` already mints — so the transport, the token cache and the dead-subscription + handling all apply unchanged. + + It is **not** free, and the first reading of this said it was. The Android specification requires + the body be RFC 8291 content of between 1 and 4096 bytes: an empty POST, which is exactly what + this server sends and what the section above calls the reason Web Push was affordable, is not a + legal UnifiedPush message. So it needs the content-encryption half after all — ECDH on P-256, + HKDF, AES-128-GCM — plus the two subscription secrets `p256dh` and `auth`, which + `migrations/0011_push.sql` has no columns for because there was nothing to encrypt under them. + + What that costs is smaller than it sounds and worth writing down before somebody re-estimates + it: `p256`, `hkdf`, `sha2` and `aes-gcm` are already dependencies of this server, so it is one + cargo feature (`p256/ecdh`), one module and one migration. **And the wake-up stays empty of + meaning** — the ciphertext can carry a single constant byte, which satisfies the minimum without + telling the distributor, the push server or the lock screen anything. The property is preserved, + not traded. + + The client half is unchanged by any of this and is still the reason it is unplanned: registering + with a distributor is Kotlin, over Android broadcast intents, and there is no Android device + here. + +- **Watches.** Nothing, and nothing is possible before the line above: a watch shows the + notifications its phone received. A generic "New message" on a wrist is also close to worthless, + which is a second reason this is a whole piece of work rather than a setting. +- **iOS needs the site installed to the home screen** before it will subscribe at all. That is now + possible — `public/manifest.webmanifest` and the `apple-mobile-web-app-*` metas exist, and until + they did there was no version of this client iOS would have subscribed. Still untested on a + device, but no longer for want of one: there is an iPhone here and a tunnelled `deploy/` stack + reaches it. It is the next thing to run, and it is the one that decides how much APNs is worth + buying. Even then a notification there can never show content: the service extension is + a separate Swift process while the keys live in a WASM module inside the webview. - **The notification is generic.** "New message", and nothing else. The worker cannot decrypt: the MLS keys are in the page's memory, not the worker's, and moving them would hand the decryption keys to a context that outlives every tab. Same constraint iOS imposes, arrived at on purpose. @@ -209,10 +269,14 @@ design. - **On iOS, a notification can never show content.** The service extension is a separate Swift process; the keys live in a WASM module inside the webview. Fixing that means porting the cryptography to native code. -- **There is no physical device here.** Biometric invalidation on re-enrolment, a real notch, - `windowSoftInputMode`: three things no emulator settles honestly. -- **Android without Google services has no wake path.** UnifiedPush would be the answer. It is - not specified, and it is not planned. +- **No device can run a *native* build here.** This used to read "there is no physical device + here", and there is an iPhone now — which changes less than it sounds. Biometric invalidation on + re-enrolment and `windowSoftInputMode` live in a packaged application, and packaging one for + that iPhone needs a paid Apple Developer membership; the Android side has no device at all. + + A real notch and a real virtual keyboard are the exception and have left this list: they belong + to the web client, which that iPhone can load from a tunnelled `deploy/` stack. Untested, but no + longer untestable — see "What is not fully verified". ## Longer-standing gaps diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index d2496022..42504af9 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -538,6 +538,16 @@ answers differently to a second request defeats this and nothing here detects it the cost of an attack from "serve anything" to "serve one thing consistently and hope nobody compares" — real, and not the same as impossible. +**A service worker now sits between the page and the network.** `public/sw.js` answers +`/assets/*`, `/emoji/*.json` and `/fonts/*` from a cache, and it is itself covered by the manifest +like every other file in `dist`. Two consequences worth naming: the extension's re-request may be +answered by that cache rather than by the server, which is a check of the bytes that ran and not of +the bytes the server would serve now; and offline, the application starts from the last +`index.html` this browser received. Neither is a new way in — the entry point is revalidated +against the server on every load, and the cached assets are named by their own content — but both +are places where "what this browser is running" and "what that server is serving" can differ for +as long as there is no network. + **Verifiable or calls, not both.** `VITE_MEDIA_URL` still enters the Content-Security-Policy, so a deployment configuring calls produces a bundle that no longer matches the published one. Until the media server sits behind the same origin, an operator chooses between the two. diff --git a/scripts/release.sh b/scripts/release.sh index 51220647..1079f9d6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -86,7 +86,9 @@ echo "→ building the front end" echo "→ building the binary" cargo build -p desktop --release -cp target/release/desktop "$output/whispee" +# `whispee`, because that is what `apps/desktop/Cargo.toml` names the binary — the crate is called +# `desktop` and its artefact is not. This line said `target/release/desktop` while it was. +cp target/release/whispee "$output/whispee" # Tool versions are part of the release, not of the documentation. Reproducibility holds **for a # given environment**: a different `rustc` or `pnpm` produces a different binary without anything