From 4075afbb11eb03b8b6836c38f4485bc0cf8efea3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 20:00:12 +0300 Subject: [PATCH] feat: bake a deploy-time server pin into the client via presetServer --- README.md | 5 +++-- nix/bridge.nix | 18 +++++++++++++++- nix/module.nix | 53 +++++++++++++++++++++++++++++++++++++++++++++--- web/js/app.js | 16 +++++++++++++++ web/js/config.js | 7 +++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 web/js/config.js diff --git a/README.md b/README.md index 522099b..304f9d3 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,6 @@ browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild `bridge.py` serves the web app and relays WebSocket bytes to the server untouched. It holds no keys, never parses a byte of traffic, and relays only to allowlisted ports (`--allow-port`, default 1961), so it is not an open proxy. It also relays only for upgrades whose `Origin` is its own page — WebSockets are exempt from CORS, so without that check any page the user happened to visit could drive the relay. `--allow-origin` admits others, and an upgrade carrying no `Origin` at all, which a browser never sends, is left alone for non-browser clients. The Noise session, sealing, signing, pinning and trust-on-first-use all run in the browser's JavaScript — the trust boundary is the machine running the browser, as with the CLI client's `identity.key` file. -The bridge exists only because the protocol is TCP, and it is the smaller dependency it looks like: `devbox run test` already needs Python and `uv` for the reference vectors and the end-to-end run. What would retire it altogether is a WebSocket transport binding in `smolmaild` itself — Noise messages already carry a `u16` length prefix, so a binary WebSocket stream is a drop-in byte stream — but that is an addition to SPEC.md §4 and to the server, not something this client can decide. - ## Run ``` @@ -62,6 +60,8 @@ For a NixOS host, `nixosModules.default` provides `services.gsmol-bridge`: The service runs under `DynamicUser`, restarts on failure, and — like the standalone script — listens on `127.0.0.1` by default. `nginx.enable` puts a TLS-terminating reverse proxy in front rather than exposing the bridge directly: it has no TLS of its own, and a browser refuses a `ws://` connection from an `https://` page, so a real deployment needs the proxy regardless of `--allow-origin`. `openFirewall = true` is for exposing the bridge's port directly instead, which is not the recommended path (see "Notes and limits"). +`services.gsmol-bridge.presetServer = { host, publicKey }` bakes a server's key into the pages this deployment serves, so a first-time visitor is pre-pinned instead of asked to copy a base32 key into settings by hand (§4). The value reaches the browser over this deployment's own TLS, so it only stands in for "from the operator, through a trusted channel" when the deployment operator and the smolmaild operator are the same trusted party — set it only for a server you operate or otherwise vouch for. It seeds the first run only: the browser stores it as an ordinary pin from then on, replaceable or removable in settings like any other. + ## What it implements - **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary. @@ -79,6 +79,7 @@ web/js/noise.js Noise_NX_25519_ChaChaPoly_SHA256 initiator web/js/proto.js addresses, seal/unseal, frontmatter, rotation, op framing web/js/store.js localStorage + IndexedDB, sealed at rest web/js/app.js the UI +web/js/config.js deploy-time server preset, empty by default (see "Deploy") test/ vectors and end-to-end tests (below) ``` diff --git a/nix/bridge.nix b/nix/bridge.nix index 2c90567..d47fa13 100644 --- a/nix/bridge.nix +++ b/nix/bridge.nix @@ -2,11 +2,23 @@ # shebang (`uv run --script`) is only for `devbox run serve`; here it runs # under a plain interpreter with aiohttp from nixpkgs, so no network access # or uv is needed at build or run time — required for the Nix sandbox anyway. -{ lib, stdenvNoCC, makeWrapper, python3, nix-gitignore }: +{ lib, stdenvNoCC, makeWrapper, python3, nix-gitignore +# A server this deployment pre-pins for every first-time visitor (the NixOS +# module's `presetServer`). Both or neither — see web/js/config.js. Baking a +# config value into the served JS is a build-time concern, not something +# bridge.py — "one file, no keys" — should gain runtime logic to do. +, serverHost ? null +, serverPublicKey ? null +}: let pythonEnv = python3.withPackages (ps: [ ps.aiohttp ]); + hasPreset = serverHost != null && serverPublicKey != null; + presetLine = "export const PRESET_SERVER = " + + builtins.toJSON { host = serverHost; publicKey = serverPublicKey; } + ";"; in +assert lib.assertMsg ((serverHost == null) == (serverPublicKey == null)) + "gsmol-bridge: serverHost and serverPublicKey must be set together, or not at all"; stdenvNoCC.mkDerivation { pname = "gsmol-bridge"; version = "0.1.0"; @@ -30,6 +42,10 @@ stdenvNoCC.mkDerivation { cp bridge.py $out/share/gsmol/bridge.py cp -r web $out/share/gsmol/web + ${lib.optionalString hasPreset '' + printf '%s\n' ${lib.escapeShellArg presetLine} > $out/share/gsmol/web/js/config.js + ''} + makeWrapper ${pythonEnv}/bin/python3 $out/bin/gsmol-bridge \ --add-flags "$out/share/gsmol/bridge.py" \ --add-flags "--dir $out/share/gsmol/web" diff --git a/nix/module.nix b/nix/module.nix index 8313bd5..3f02147 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -13,9 +13,56 @@ in package = lib.mkOption { type = lib.types.package; - default = self.packages.${pkgs.system}.default; - defaultText = lib.literalExpression "gsmol.packages.\${system}.default"; - description = "The gsmol-bridge package to run."; + default = + if cfg.presetServer == null + then self.packages.${pkgs.system}.default + else self.packages.${pkgs.system}.default.override { + serverHost = cfg.presetServer.host; + serverPublicKey = cfg.presetServer.publicKey; + }; + defaultText = lib.literalExpression + "gsmol.packages.\${system}.default, built with presetServer baked in when set"; + description = '' + The gsmol-bridge package to run. Overriding this yourself bypasses + `presetServer` — bake it in via the same `.override` if you need both. + ''; + }; + + presetServer = lib.mkOption { + type = lib.types.nullOr (lib.types.submodule { + options = { + host = lib.mkOption { + type = lib.types.str; + example = "example.org"; + description = "Hostname of the smolmaild server to pre-pin."; + }; + publicKey = lib.mkOption { + type = lib.types.str; + example = "mfrggzdfmztwq2lknnwg23tpobxxk4tznb2xg5btmvwgy3zmn5xg4==="; + description = '' + Its static key, base32-encoded exactly as gsmol's own settings + dialog and `smolmaild --keygen` display it. + ''; + }; + }; + }); + default = null; + description = '' + Bake a server key into the pages this deployment serves, so a + first-time visitor is pre-pinned rather than asked to copy a base32 + key into settings by hand (SPEC.md §4). + + This only makes sense when whoever runs this deployment and whoever + runs that smolmaild are the same trusted party: the value reaches the + browser over this deployment's own TLS, which stands in for the "get + it from the operator through a trusted channel" step. Do not set this + for a key you do not operate or otherwise vouch for — it pins it for + every visitor, unasked. + + It only seeds the first run: the browser stores it as an ordinary + pin from then on, which the user can still replace or remove in + settings like any other. + ''; }; host = lib.mkOption { diff --git a/web/js/app.js b/web/js/app.js index 888461c..7e89968 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -9,6 +9,7 @@ import { } from "./proto.js"; import * as store from "./store.js"; import { connectStream } from "./transport.js"; +import { PRESET_SERVER } from "./config.js"; const $ = (id) => document.getElementById(id); @@ -912,6 +913,21 @@ async function rotateIdentity() { // --- boot -------------------------------------------------------------------------- +// A deployment MAY bake in a server key (the NixOS module's `presetServer`), +// which only makes sense when whoever serves this page and whoever runs that +// smolmaild are the same trusted party: the value arrives over this +// deployment's own TLS, which is the trusted channel SPEC.md §4 asks a pin to +// come from. This only seeds the first run — once written it is an ordinary +// pin, editable and removable in settings like any other, and never +// overwrites a host the user (or a previous boot) already pinned. +function applyPresetServer() { + if (!PRESET_SERVER || store.serverPin(PRESET_SERVER.host)) return; + try { + store.pinServer(PRESET_SERVER.host, b32decode(PRESET_SERVER.publicKey)); + } catch { /* malformed preset: leave unpinned rather than block boot */ } +} + +applyPresetServer(); applyTheme(); store.identity() ? showApp() : showSetup(); status(store.identity() ? "" : "create an identity to begin"); diff --git a/web/js/config.js b/web/js/config.js new file mode 100644 index 0000000..6a5e1d6 --- /dev/null +++ b/web/js/config.js @@ -0,0 +1,7 @@ +// Deploy-time configuration, empty by default. A plain checkout (`devbox run +// serve`) ships this file untouched, so PRESET_SERVER is null and nothing +// below changes behavior. The nix package can overwrite this file at build +// time with a real value — see nix/bridge.nix and the NixOS module's +// `services.gsmol-bridge.presetServer` option. +export const PRESET_SERVER = null; +// Shape when set: { host: "example.org", publicKey: "base32-encoded-key" }