feat: bake a deploy-time server pin into the client via presetServer
This commit is contained in:
parent
db83ec1289
commit
4075afbb11
5 changed files with 93 additions and 6 deletions
|
|
@ -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)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
7
web/js/config.js
Normal file
7
web/js/config.js
Normal file
|
|
@ -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" }
|
||||
Loading…
Add table
Add a link
Reference in a new issue