From 3a03fab876f9b7dbff3a7143438afdef1792194e Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 9 Oct 2026 13:47:42 +0300 Subject: [PATCH] chore: flatten repository history --- .gitignore | 6 + AI-DECLARATION.md | 11 + LICENSE | 201 +++++ README.md | 37 + bridge.py | 154 ++++ devbox.json | 26 + devbox.lock | 143 ++++ flake.lock | 27 + flake.nix | 27 + locales/en-US/main.ftl | 449 +++++++++++ nix/bridge.nix | 62 ++ nix/module.nix | 234 ++++++ scripts/build-locale.mjs | 46 ++ test/client.mjs | 457 +++++++++++ test/e2e.py | 162 ++++ test/gen_vectors.py | 203 +++++ test/vectors.json | 236 ++++++ test/vectors.test.mjs | 246 ++++++ web/favicon.svg | 6 + web/index.html | 274 +++++++ web/js/app.js | 1660 ++++++++++++++++++++++++++++++++++++++ web/js/config.js | 7 + web/js/crypto.js | 437 ++++++++++ web/js/i18n.js | 31 + web/js/noise.js | 92 +++ web/js/proto.js | 481 +++++++++++ web/js/store.js | 564 +++++++++++++ web/js/transport.js | 29 + web/locales/en-US.json | 287 +++++++ web/style.css | 628 ++++++++++++++ 30 files changed, 7223 insertions(+) create mode 100644 .gitignore create mode 100644 AI-DECLARATION.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bridge.py create mode 100644 devbox.json create mode 100644 devbox.lock create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 locales/en-US/main.ftl create mode 100644 nix/bridge.nix create mode 100644 nix/module.nix create mode 100644 scripts/build-locale.mjs create mode 100644 test/client.mjs create mode 100644 test/e2e.py create mode 100644 test/gen_vectors.py create mode 100644 test/vectors.json create mode 100644 test/vectors.test.mjs create mode 100644 web/favicon.svg create mode 100644 web/index.html create mode 100644 web/js/app.js create mode 100644 web/js/config.js create mode 100644 web/js/crypto.js create mode 100644 web/js/i18n.js create mode 100644 web/js/noise.js create mode 100644 web/js/proto.js create mode 100644 web/js/store.js create mode 100644 web/js/transport.js create mode 100644 web/locales/en-US.json create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38a6edb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.devbox/ +.venv/ +result +result-* +.claude/ +AGENTS.md diff --git a/AI-DECLARATION.md b/AI-DECLARATION.md new file mode 100644 index 0000000..360124f --- /dev/null +++ b/AI-DECLARATION.md @@ -0,0 +1,11 @@ +--- +version: "0.1.2" +level: copilot +--- + +This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2). + +## Notes + +- I directed the work step by step, reviewed and committed the results, and verified the UI live; build, packaging and cross-platform scripts. +- Claude Code was used throughout, prompted and reviewed by me at each step. Its main task was UI and string refinements. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c98d27d --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/LICENSE-2.0 + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6014d63 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# gsmol + +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Built with Devbox](https://www.jetify.com/img/devbox/shield_galaxy.svg)](https://github.com/jetify-com/devbox/) [![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) ![Nix Flake](https://img.shields.io/badge/Nix-Flake-5277C3?logo=nixos&logoColor=white) + +[Smol Mail](../smolmail) in a browser tab. All the crypto, keys and mail handling run client-side in JavaScript; a small local bridge just relays bytes to a smolmaild server over WebSocket. + +``` +browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild (TCP :1961) +``` + +The bridge holds no keys and never parses a byte of traffic. The trust boundary is the machine running the browser. + +## Run + +``` +devbox run serve +``` + +Open http://127.0.0.1:8096. Onboarding walks you through creating or restoring an identity (the seed is shown once — write it down), pinning your server's public key, and claiming an address. Then fetch. + +Non-default server port: `uv run bridge.py --allow-port `. + +## Test + +``` +devbox run test +``` + +Byte-for-byte checks against the reference vectors, protocol cases, and a live end-to-end exchange with the reference client through the bridge — the exact byte path the browser takes. Only clicking through the UI is left to a manual pass. + +## Deploy + +``` +nix build .#default # -> ./result/bin/gsmol-bridge +``` + +The bridge binds to 127.0.0.1 and has no TLS of its own — for anything beyond localhost, put `nginx` or `caddy` in front. A NixOS module is included (`nixosModules.default` → `services.gsmol-bridge`) with the same options. \ No newline at end of file diff --git a/bridge.py b/bridge.py new file mode 100755 index 0000000..6cd241b --- /dev/null +++ b/bridge.py @@ -0,0 +1,154 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiohttp>=3.9"] +# /// +"""gsmol bridge: serve the web client and relay WebSocket bytes to smolmaild TCP. + +Browsers cannot open raw TCP sockets, so the Noise_NX session runs in the +browser and this bridge carries its bytes. The bridge holds no keys and never +parses a byte of the traffic; it is a pipe with a port allowlist, so it is not +an open proxy. + + uv run bridge.py # serve web/ and relay port 1961 + uv run bridge.py --allow-port 11961 # relay to an extra port +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +from pathlib import Path + +import aiohttp +from aiohttp import web + +log = logging.getLogger("gsmol") + + +def origin_allowed(request: web.Request) -> bool: + """Whether this upgrade may open a relay. + + WebSockets are exempt from CORS, so without this check any page the user + happens to visit could drive the relay to an allowlisted port on any host. + Browsers always send Origin on an upgrade; non-browser clients (test/client.mjs + on Node) send none, so an absent header is allowed and a foreign one is not. + """ + origin = request.headers.get("Origin") + if origin is None: + return True + own = {f"http://{request.host}", f"https://{request.host}"} + return origin in own or origin in request.app["allow_origins"] + + +async def relay(request: web.Request) -> web.WebSocketResponse: + """A WebSocket whose binary frames are piped to host:port untouched.""" + if not origin_allowed(request): + raise web.HTTPForbidden(text="cross-origin WebSocket upgrade refused") + port = int(request.match_info["port"]) + if port not in request.app["relay_ports"]: + raise web.HTTPForbidden(text=f"port {port} is not allowed") + host = request.match_info["host"] + + ws = web.WebSocketResponse(autoping=True) + await ws.prepare(request) + try: + reader, writer = await asyncio.open_connection(host, port) + except OSError as exc: + await ws.close(code=1011, message=str(exc).encode()) + return ws + log.info("relaying to %s:%d", host, port) + + async def pump_up() -> None: + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.BINARY: + writer.write(msg.data) + await writer.drain() + elif msg.type == aiohttp.WSMsgType.ERROR: + break + finally: + writer.close() + + async def pump_down() -> None: + try: + while chunk := await reader.read(65536): + await ws.send_bytes(chunk) + except (ConnectionError, asyncio.IncompleteReadError): + pass + finally: + await ws.close() + + # return_exceptions so a failure in one pump cannot leave the other running: + # closing the writer ends the downstream read, closing the socket ends the + # upstream iteration, so each side unblocks the other. + await asyncio.gather(pump_up(), pump_down(), return_exceptions=True) + try: + await writer.wait_closed() + except OSError: + pass + log.info("relay to %s:%d closed", host, port) + return ws + + +# The seed lives in localStorage, so an XSS in this page is game over (README). +# The client loads no inline script, no inline style and no foreign origin, so a +# strict policy costs nothing. form-action stays 'self' rather than 'none' so the +# compose dialog's method="dialog" form is untouched. +# +# Nix normalizes every built file's mtime to the same fixed epoch for +# reproducibility, so Last-Modified is identical across every deploy — with no +# explicit Cache-Control, a browser's heuristic freshness calculation (based on +# that ~56-year-old timestamp) can treat a page as fresh indefinitely and never +# revalidate again, silently pinning a visitor to whatever JS they first +# loaded across every future deploy. no-cache forces a conditional GET each +# time rather than disabling caching outright — the ETag aiohttp already sends +# still turns an unchanged file into a bodyless 304. +@web.middleware +async def headers(request: web.Request, handler): + response = await handler(request) + response.headers.setdefault("Content-Security-Policy", "; ".join([ + "default-src 'self'", + f"connect-src 'self' ws://{request.host} wss://{request.host}", + "base-uri 'none'", + "form-action 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + ])) + response.headers.setdefault("Cache-Control", "no-cache") + return response + + +async def index(request: web.Request) -> web.FileResponse: + return web.FileResponse(Path(request.app["web_dir"]) / "index.html") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8096) + parser.add_argument("--dir", default=str(Path(__file__).parent / "web")) + parser.add_argument("--allow-port", type=int, action="append", default=[1961], + metavar="PORT", dest="allow_ports", + help="relayed TCP port; repeatable") + parser.add_argument("--allow-origin", action="append", default=[], + metavar="ORIGIN", dest="allow_origins", + help="extra browser origin permitted to open a relay; repeatable") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + app = web.Application(middlewares=[headers]) + app["web_dir"], app["relay_ports"] = args.dir, set(args.allow_ports) + app["allow_origins"] = set(args.allow_origins) + app.router.add_get("/", index) + app.router.add_get("/tcp/{host}/{port}", relay) + app.router.add_static("/", args.dir, show_index=False) + log.info("serving %s on http://%s:%d — allowed TCP ports: %s", + args.dir, args.host, args.port, ", ".join(map(str, sorted(app["relay_ports"])))) + web.run_app(app, host=args.host, port=args.port, print=None) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..14ccc87 --- /dev/null +++ b/devbox.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", + "packages": [ + "nodejs@latest", + "python3@latest", + "uv@latest" + ], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "serve": [ + "uv run bridge.py" + ], + "locale": [ + "node scripts/build-locale.mjs" + ], + "test": [ + "uv run test/gen_vectors.py", + "node test/vectors.test.mjs", + "uv run test/e2e.py" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..9a2964e --- /dev/null +++ b/devbox.lock @@ -0,0 +1,143 @@ +{ + "lockfile_version": "1", + "packages": { + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D" + }, + "nodejs@latest": { + "last_modified": "2026-08-27T23:47:58Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/7d1a7c21a4c00ea653fc7ed5c083d261340f185f#nodejs_26", + "source": "devbox-search", + "version": "26.8.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/szf4xdpd4znb3hzprlyivq6nd507hyas-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/szf4xdpd4znb3hzprlyivq6nd507hyas-nodejs-26.8.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/s3azam32y3bmwd6b2zynqqg9yv99lnam-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/s3azam32y3bmwd6b2zynqqg9yv99lnam-nodejs-26.8.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ymlp5q9l64224b9jqpqwp298bgbjv7p9-nodejs-26.8.1", + "default": true + } + ], + "store_path": "/nix/store/ymlp5q9l64224b9jqpqwp298bgbjv7p9-nodejs-26.8.1" + } + } + }, + "python3@latest": { + "last_modified": "2025-01-06T03:40:18Z", + "plugin_version": "0.0.4", + "resolved": "github:NixOS/nixpkgs/3df3c47c19dc90fec35359e89ffb52b34d2b0e94#python3", + "source": "devbox-search", + "version": "3.12.8", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8zc3wcplydp8gsxms24scpzdca438dk5-python3-3.12.8", + "default": true + } + ], + "store_path": "/nix/store/8zc3wcplydp8gsxms24scpzdca438dk5-python3-3.12.8" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/66pn6ysmvx675061xaq2vz93s9vdc5p4-python3-3.12.8", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/brcg3a34fi45ffky63g5pqd22sksvq13-python3-3.12.8-debug" + } + ], + "store_path": "/nix/store/66pn6ysmvx675061xaq2vz93s9vdc5p4-python3-3.12.8" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fpmkmdzgd1q7kqadc7czcjdhjj7bsc0i-python3-3.12.8", + "default": true + } + ], + "store_path": "/nix/store/fpmkmdzgd1q7kqadc7czcjdhjj7bsc0i-python3-3.12.8" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/c9m6yd8fg1flz2j5r4bif1ib5j20a0cy-python3-3.12.8", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/cicfrcjr8pky8qd0gxw0x84ynyviy6b5-python3-3.12.8-debug" + } + ], + "store_path": "/nix/store/c9m6yd8fg1flz2j5r4bif1ib5j20a0cy-python3-3.12.8" + } + } + }, + "uv@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#uv", + "source": "devbox-search", + "version": "0.12.5", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/71zq15rr7i4avy58y89hfgf2lhp3b7la-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/71zq15rr7i4avy58y89hfgf2lhp3b7la-uv-0.12.5" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/s2pqw31dkyrcym72fcdn0h9myl0yy9ix-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/s2pqw31dkyrcym72fcdn0h9myl0yy9ix-uv-0.12.5" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v6av5m4035ds4558ax9sh0ywwnkjnd7b-uv-0.12.5", + "default": true + } + ], + "store_path": "/nix/store/v6av5m4035ds4558ax9sh0ywwnkjnd7b-uv-0.12.5" + } + } + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..388599b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790323409, + "narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7b4e79b --- /dev/null +++ b/flake.nix @@ -0,0 +1,27 @@ +{ + description = "gsmol: browser client for Smol Mail — bridge package and NixOS module"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = forEachSystem (pkgs: { + default = pkgs.callPackage ./nix/bridge.nix { }; + }); + + apps = forEachSystem (pkgs: { + default = { + type = "app"; + program = pkgs.lib.getExe self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + }); + + # Curried on `self` so the module's default package is drawn from the + # `packages` output above rather than a second, driftable copy. + nixosModules.default = import ./nix/module.nix self; + }; +} diff --git a/locales/en-US/main.ftl b/locales/en-US/main.ftl new file mode 100644 index 0000000..b7c1612 --- /dev/null +++ b/locales/en-US/main.ftl @@ -0,0 +1,449 @@ +## Loaded once at startup by `src/i18n.rs`; every user-facing string in +## the app lives here, grouped by the feature it belongs to. + +## Seed parsing — restoring an identity from a pasted seed + +seed-bad-hex = That's not valid hex. Check for typos or extra characters. +seed-wrong-length = A seed is 32 bytes. Check you copied the whole thing. + +## Local files — errors reading/writing the key and its sidecars + +file-error = { $path }: { $reason } +key-file-wrong-length = { $path }: not a 32-byte secret + +## Compose — validating a typed `Key: value` header row + +compose-header-colon-missing = Headers need a colon, like `Key: value`. +compose-header-reserved-key = { $key } is set automatically and can't be changed. +compose-header-invalid-key = Keys can be 1 to 64 letters, digits, and dashes. +compose-header-value-multiline = Values need to stay on one line. +compose-header-duplicate-key = { $key } is set twice. Remove one of them. +compose-headers-too-large = Your headers take up too much space. Remove some and try again. +compose-headers-too-many = You have too many headers. Remove some and try again. + +## Contacts — importing a smol:// address + +contacts-import-needs-key = Import needs a smol:// address that includes a key. + +## Mail — opening a message by id + +mail-message-id-wrong-length = Message IDs are 64 hex characters. +mail-message-id-wrong-bytes = Message IDs are 32 bytes. +mail-message-not-found = Message not found. + +## Fetch — the status note after a fetch completes +## +## Split from one selector into one message per combination of "N +## rejected" and "(cancelled)" that can apply; the caller in ops.rs +## picks the id to match, since a flat message can't branch on a +## variable itself. + +fetch-result-note-rejected = { $total } fetched, { $stored } stored, { $rejected } rejected +fetch-result-note-cancelled = { $total } fetched, { $stored } stored (cancelled) +fetch-result-note-rejected-cancelled = { $total } fetched, { $stored } stored, { $rejected } rejected (cancelled) +fetch-result-note-plain = { $total } fetched, { $stored } stored +mail-envelope-id-mismatch-error = id does not match the envelope +mail-fetch-summary-counts = { $stored } new, { $rejected } rejected +mail-fetch-summary-verified-one = { $verified } sender verified from a signed Reply-To +mail-fetch-summary-verified-other = { $verified } senders verified from a signed Reply-To +mail-fetch-summary-left-on-server = left on the server + +## Send — the status note after a send completes +## +## Split from one selector into one message per combination of +## trust-change clause and accept-token clause that can apply; the +## caller in ops.rs picks the id to match. Any warning fumi itself +## reports is appended separately in code, since that text is fumi's +## own and not part of this catalogue. + +send-result-note-token = Sent to { $address } ({ $bytes } bytes), accept token attached +send-result-note-unverified = Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified) +send-result-note-unverified-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified) +send-result-note-tofu = Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use) +send-result-note-tofu-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use) +send-result-note-rotated = Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified) +send-result-note-rotated-token = Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified) +send-result-note-plain = Sent to { $address } ({ $bytes } bytes) +mail-sent-note = sent to { $address } +mail-sent-note-accepted = sent to { $address } (accepted) + +## Background-operation status notes +## +## Built in `spawn_op` (src/view.rs) rather than ops.rs, since that is +## where each `PendingOp` result is assembled for the worker thread. + +status-registered-note = Registered +status-restored-note = Restored at rotation { $index } +status-deleted-note = Deleted +status-accepted-note = Accepted { $address } +status-blocked-note = Blocked { $address } +status-not-blocked-note = { $address } was not blocked +contacts-no-key-error = no key for { $address } yet +# Split from one selector into flat siblings for the token count, matching +# this file's existing convention (see fetch-result-note-* above). +contacts-accepted-note-one = { $address } accepted; server now holds { $held } accept token +contacts-accepted-note-other = { $address } accepted; server now holds { $held } accept tokens +contacts-blocked-note-one = { $address } blocked; server now holds { $held } accept token +contacts-blocked-note-other = { $address } blocked; server now holds { $held } accept tokens +status-imported-note = Imported { $address } (verified) +status-rotated-note = Rotated to index { $index }; new key { $key_prefix }… + +## Backup + +backup-written-note = Backup written to { $path } + +## Drafts + +draft-saved-note = Draft saved + +## Log out + +logged-out-note = Signed out. Create a new identity or restore a seed. + +## Onboarding + +identity-created-note = Identity created. Write the seed down. It's the only secret. +seed-restored-note = Seed restored. Bind a username or recall an address. + +## Signup + +signup-enter-host-error = Enter the server host first. +signup-enter-user-host-error = Enter the user and host. +# A pin is keyed by bare host, so these two catch the mistakes that would +# otherwise silently pin a string nothing looks up again: a whole address, +# or a host with its port still attached. +signup-host-looks-like-address-error = "{ $host }" looks like an address, not a server — the part after the @ goes here +signup-host-has-port-error = "{ $host }": a pin is by host only, no port — just the part before the colon + +## Compose + +compose-enter-recipient-error = Enter a recipient. +compose-header-row-error = Header { $index }: { $reason } +reply-no-address-error = This sender left no address to reply to. +reply-subject-prefix = Re: { $subject } +address-invalid-error = That address isn't in the right format. + +## Contacts + +contacts-reply-address-invalid-error = The sender's reply address isn't in the right format. +contacts-sender-key-invalid-error = The sender's key isn't in the right format. +contacts-accept-no-address-error = This sender left no address to accept. +contacts-block-no-address-error = This sender left no address to block. +contacts-sender-key-mismatch-error = that address carries a different key than this message's sender +# A pinned, conflicting Reply-To claim is refused outright rather than asked +# about — see saveContactChecked's dialog for the softer self-certifying case. +contacts-reply-key-conflict-error = { $address } is already known with a different key — verify out of band before replying +# Shown when a typed address or a smol:// link would overwrite an address +# already saved under a different key, and the user declined the prompt +# that showed both fingerprints and asked. +contacts-key-kept-note = kept the existing key for { $address } +contacts-sender-saved-note-verified = { $address } saved for this sender (verified key) +contacts-sender-saved-note-unverified = { $address } saved for this sender +# Richer than status-imported-note: this client shows the imported key and +# its fingerprint alongside the confirmation. +contacts-import-detail-note = imported { $address } { $key } (verified){"\u000A"}fingerprint: { $fingerprint } + +## Common — short words reused verbatim across several buttons/dialogs + +action-cancel = Cancel +action-back = Back +action-block = Block +action-unblock = Unblock +action-accept = Accept +action-reply = Reply +action-delete = Delete +# Shown wherever a fetched server key is on screen before it's pinned: +# the compose's pin card, the signup server step, and the contact detail +# body when a key was fetched for that contact. +trust-fingerprint-hint = If the server operator published a key or fingerprint, make sure it matches before trusting it. +# Reused wherever an action needs an identity that doesn't exist yet +# (sending, fetching, pushing accept tokens). +identity-missing-error = no identity yet +account-not-registered-fetch-warning = not registered yet — claim an address in settings +account-not-registered-tokens-note = not registered; the set will be pushed with your first fetch + +## Sidebar + +mail-fetch-button = Fetch +mail-write-button = Write +folder-inbox = Inbox +folder-requests = Requests +folder-sent = Sent +folder-drafts = Drafts +settings-nav-label = Settings +settings-close-aria-label = close settings +settings-close-tooltip = close +identity-you-caption = you +contacts-caption = contacts +contacts-import-placeholder = Import a smol:// address +contacts-import-button = Import +# "Follow the system" is a real preference, not the absence of one, so it +# gets its own label rather than defaulting silently to one of the other two. +theme-auto-label = theme: following the system +theme-light-label = theme: light +theme-dark-label = theme: dark +account-not-registered-value = not registered +mail-search-placeholder = search { $folder } + +## Mail — the open-message meta line +## +## Split from one selector into one message per variant, each a full +## sentence; the caller in view.rs picks `-yes`/`-no` to match `$kept`. + +mail-meta-to = to +mail-meta-from = from +mail-meta-line-no = { $label } { $from } · { $time } UTC · id { $id }… +mail-meta-line-yes = { $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists +mail-no-subject = (no subject) +mail-no-reply-address = no reply address: can't accept or block +contacts-empty-hint = no contacts +# The §4/§8 trust chip on an open message: a signature always verifies, or +# unseal() would have thrown already — what varies is the binding between +# the key that signed it and an address. +mail-back-to-list-button = messages +mail-meta-sent = to: { $recipient }{"\u000A"}id: { $id }{"\u000A"}sent: { $time } +mail-meta-received = from: { $from }{"\u000A"}id: { $id }{"\u000A"}date: { $time } +mail-trust-own-copy = your own sealed copy +mail-trust-verified-sender = verified sender key +mail-trust-tofu-sender = key pinned on first use +mail-trust-unbound-sender = key bound to no address +mail-sender-unbound-tooltip = sender key not bound to an address +mail-signed-reply-hint = this message carries the sender's signed address: { $address } +mail-save-sender-reply-button = save sender & reply +mail-name-sender-button = name sender & reply +mail-unknown-sender-hint = The wire carries no sender name, only this signed key. To reply, give the sender's address — it is checked against this key: +mail-delete-not-registered-error = not registered; cannot reach the server to delete this message +mail-request-unaccepted-hint = unsolicited mail from { $address }, not yet accepted into your main tier +mail-accept-button = accept — future mail lands in your main tier + +## Mail list — empty-folder messages, and the own-identity detail + +mail-inbox-empty-title = Inbox is empty +mail-inbox-empty-description = Fetch to check the mailbox. +mail-requests-empty-title = No requests +mail-requests-empty-description = Mail from senders you haven't accepted yet arrives here. +mail-sent-empty-title = Nothing sent yet +mail-sent-empty-description = Messages you send keep a sealed copy here. +mail-drafts-empty-title = No drafts +mail-drafts-empty-description = Closing a compose from the sidebar keeps it here. +own-identity-meta = you · rotation { $rotations } +contacts-pin-button-fetched = Pin this key +contacts-pin-button-unfetched = Pin server + +mail-empty-title = No message selected +mail-empty-description = Pick a message from the list above to read it. +# The two sentences shown under Fetch when the inbox is empty. +mail-empty-hint = Fetching removes mail from the server once it's stored here. Turn on "Leave mail on server" in Settings to keep it there instead. +detail-qr-tooltip = Show the address as a QR code +detail-copy-tooltip = Copy the address +mail-row-unreadable-error = +mail-contacts-reader-hint = contacts and their keys are listed alongside +mail-list-count-one = { $count } message +mail-list-count-other = { $count } messages +mail-list-shown-of-total = { $shown } of { $total } + +## Compose + +compose-to-label = To +compose-subject-label = Subject +compose-add-header-button = + header +compose-send-button = Send +compose-header-placeholder = Key: value +compose-header-remove-tooltip = Remove this header +compose-body-placeholder = message +compose-anonymous-label = anonymous — omit my address + +## Contacts — trust state shown in the sidebar and the detail pane + +contact-state-accepted = accepted +contact-state-blocked = blocked +contact-state-verified = verified +contact-state-tofu = trust on first use +# `$state` is one of the four words above, already resolved by the caller +# through `contact_state`. Split from one selector into one message per +# pin state; the caller in view.rs picks `-yes`/`-no` to match `$pinned`. +contact-meta-line-yes = { $state } · server pinned +contact-meta-line-no = { $state } · server not pinned + +## Detail pane — the monospace key/value block shared by contacts and the +## own identity view. Column widths are padded to these labels in code +## (14 characters), so a much longer translation would throw the +## alignment off; flagged in the extraction report. + +detail-key-label = key +detail-fingerprint-label = fingerprint +detail-address-label = address +detail-server-label = server +contact-server-key-offered = this server presents +contacts-server-pinned-badge = pinned +contacts-server-not-pinned-badge = not pinned +contacts-history-badge = { $count } previous +contacts-import-empty-hint = no contacts yet — import a smol:// address above, or write to someone +contacts-refresh-button = re-resolve +contacts-accept-token-title = accept token +contacts-accepted-hint = This contact's mail lands in your main tier. +contacts-unaccepted-hint = This contact's mail lands in requests until accepted. +contacts-history-title = previous keys ({ $count }) +contacts-history-hint = Replaced by a signed rotation chain. Mail signed with these was sent before the change; rotation is not revocation, so a stolen key can still rotate onward. +contacts-history-replaced = replaced { $time } + +## Settings — tab switcher labels + +settings-tab-identity = Identity +settings-tab-server = Server +settings-tab-backup = Backup +settings-tab-advanced = Advanced + +## Settings — Identity tab + +settings-field-address = Address +settings-field-key = Key +settings-field-fingerprint = Fingerprint +settings-field-uri = URI +property-row-copy-tooltip = Copy the { $field } +settings-recovery-secret-title = Recovery secret +settings-secret-reveal-button = reveal +settings-secret-hide-button = hide +settings-secret-reveal-tooltip = Reveal the recovery secret +settings-secret-hide-tooltip = Hide the recovery secret +settings-secret-copy-tooltip = Copy the recovery secret +settings-secret-warning = Anyone who holds this can read your mail and write as you, and losing it loses the address with it — there is no reset. Keep it in a password manager. +# A copy button's own label, flashed on click instead of a toast. +copy-button-idle = copy +copy-button-success = copied +copy-button-failed = failed +settings-not-registered-value = not registered yet +settings-uri-claim-hint = claim an address to get one + +## Settings — Server tab + +settings-field-host = Host +settings-field-public-key = Public key +settings-public-key-not-pinned = not pinned yet +server-connection-title = Connection +server-timeout-title = Timeout +server-timeout-subtitle = Seconds before a network call gives up +server-fetching-title = Fetching +server-leave-mail-title = Leave mail on server +server-leave-mail-subtitle = Keep a copy on the server after fetching, so you can fetch it again from another app or device. When this is on, deleting a message removes it here and on the server. + +## Settings — Backup tab + +backup-page-description = One file with your mail, contacts, and server info, for moving to another app or keeping what the server no longer has. It's sealed to this identity and never includes the secret itself. +backup-export-title = Export +backup-export-subtitle = Write a backup file +backup-import-title = Import +backup-import-subtitle = Restore from a backup file +backup-export-dialog-title = Export backup +backup-import-dialog-title = Import backup + +## Settings — Advanced tab + +advanced-trust-section-title = trust +advanced-rotations-title = Rotations +advanced-rotations-description = Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation. +advanced-rotations-completed = Completed +advanced-rotate-button = Rotate identity +advanced-rotate-needs-account-error = rotate needs a registered account +advanced-first-contact-title = First contact +advanced-first-contact-description = A recipient who isn't a contact yet, and whose address carries no key, has to be looked up on their own server. That answer is only as good as the server: one with no pinned key can return a key of its own and read what you send. +advanced-strict-pinning-title = Require a pinned server +advanced-strict-pinning-subtitle = Stop that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards. +advanced-logout-title = Sign out +advanced-logout-description = Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless "Leave mail on server" was on. Whatever's still on the server comes back on the first fetch after you sign back in. +advanced-logout-button = Sign out and delete all data + +## Onboarding +## +## The app name itself ("carrier") is a brand, not translated prose, and +## is left as a literal in code — see the extraction report. + +onboard-welcome-title = welcome +onboard-welcome-description = Encrypted mail with no account and no password. Your identity is one secret, kept in this browser and nowhere else. +onboard-tagline = smolmail: one seed, sealed mail, five operations +onboard-create-button = Create a new identity +onboard-restore-button = Restore an existing identity from a seed +onboard-created-title = Write this seed down +onboard-created-description = It's the only secret, and it's shown once. +onboard-saved-button = I saved it +onboard-restore-title = Restore from a seed +onboard-restore-description = Paste the secret you saved. Your keys come back from it — it is all this client needs. +onboard-seed-entry-title = Seed: 32 bytes, base32 or hex +onboard-restore-go-button = Restore + +## Signup + +signup-server-title = Set up the server +signup-host-label = host +signup-fetch-key-button = Fetch the server's key +signup-continue-button = Continue +signup-start-over-button = Start over +signup-account-title = Choose your address +signup-mode-new-label = Create a new address +signup-mode-existing-label = I already have this address +signup-user-label = User +signup-invite-label = Invite token (if the server requires one) +signup-register-button = Register +signup-recall-button = Restore access + +## Compose — the inline pin card shown after a blocked send + +compose-pin-title = { $host } isn't pinned +compose-pin-body = { $address } isn't a contact yet, so carrier would have to ask this server for its key. A server with no pinned key can answer with one of its own, then read the message. +compose-pin-button-fetch = Fetch their server's key +compose-pin-button-send = Pin and send + +## Dialogs + +delete-dialog-title = Delete this message? +delete-dialog-body-kept = Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too. +delete-dialog-body-gone = This removes the only copy, which is sealed and stored on this device. +delete-dialog-everywhere-button = Delete everywhere +rotate-dialog-title = Rotate the identity? +rotate-dialog-confirm-button = Rotate +reset-dialog-title = Sign out and delete all data? +reset-dialog-confirm-button = Sign Out +# `$list` is the sender's own frontmatter rows, already typed by a human +# and reproduced verbatim — not translatable prose itself, just data +# interpolated into this one sentence. +reply-dialog-title = Carry the sender's fields into the reply? +reply-dialog-body = This message has its own headers:{"\u000A"}{"\u000A"}{ $list }{"\u000A"}{"\u000A"}Reply with copies of these fields, or start with none. The fields stay editable either way. +reply-dialog-cancel-button = Cancel reply +reply-dialog-without-button = Reply without +reply-dialog-copy-button = Copy fields +key-change-dialog-title = Replace this contact's key? +key-change-dialog-body = { $address } is already on file with a different key.{"\u000A"}{"\u000A"}known { $known_fingerprint }{"\u000A"}new { $offered_fingerprint }{"\u000A"}{"\u000A"}The address you were given carries the new key and would replace the one on file. If this contact didn't hand it to you themselves, stop: a key swapped in on the way reads everything you send them. +key-change-dialog-replace-button = Replace key + +## Status bar +## +## `model.busy` also doubles as an internal state-machine key compared +## against literal English in app.rs (e.g. `self.busy.as_deref() == +## Some("fetching")`), so that value itself is not translated — only its +## display here is, via this lookup from the internal label to a phrase. +## See the extraction report for the coupling this implies. + +status-busy-fetching = fetching… +status-busy-refreshing = refreshing… +status-busy-registering = registering… +status-busy-restoring = restoring… +status-busy-rotating = rotating… +status-fetching-progress = fetching… { $count } in inbox + +## First contact / server trust + +first-contact-blocked-error = { $address } isn't a contact yet, and { $host } isn't pinned. Pin their server's key to send. +server-key-offered-note = The server presented this key. Pin it only if the fingerprint matches what the operator published. +server-key-already-pinned-note = This server's key is already pinned. +server-key-pinned-note = Server key pinned +connect-unpinned-warning = { $host } is not pinned; its key is { $key }.{"\u000A"}Lookups from this session are UNVERIFIED. +recall-rotation-not-found-error = the key bound to { $address } is not derived from this master within { $max } rotations +contacts-refresh-embedded-key-error = that address already carries a key; use import instead +# Shared by a first send to a never-before-seen recipient and a contact's +# own "re-resolve" button — both land here the same way, by RESOLVE finding +# no existing key for the address. +contacts-resolved-note = { $address } pinned (trust on first use) +contacts-resolved-note-unverified = { $address } pinned (trust on first use, UNVERIFIED server) +contacts-refresh-unchanged-note = { $address }: key unchanged +contacts-refresh-rotated-note = { $address } rotated its key; a signed chain confirms it.{"\u000A"}now { $key } +contacts-refresh-no-chain-warning = { $address } presents a different key with no valid rotation chain.{"\u000A"}Verify out of band, then import the new smol:// address. diff --git a/nix/bridge.nix b/nix/bridge.nix new file mode 100644 index 0000000..6a32abf --- /dev/null +++ b/nix/bridge.nix @@ -0,0 +1,62 @@ +# Packages bridge.py + web/ as a standalone executable. bridge.py's own +# 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 +# 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.2.0"; + + # Respects the repo's own .gitignore, so .venv/.devbox/.jj never enter the + # store path even when building from an unclean local checkout. + src = nix-gitignore.gitignoreSource [ ] ../.; + + nativeBuildInputs = [ makeWrapper ]; + dontBuild = true; + # The copied bridge.py is run as an argument to python3 (below), never via + # its own shebang — but fixupPhase mangles that shebang anyway, silently + # replacing "uv run --script" with a broken command. Harmless as shipped, + # but a landmine for anyone who later runs the store copy directly. + dontPatchShebangs = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/gsmol + 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" + + runHook postInstall + ''; + + meta = { + description = "Static file server and WebSocket-to-TCP relay for the gsmol Smol Mail client"; + homepage = "https://code.randogoth.com/randogoth/gsmol"; + mainProgram = "gsmol-bridge"; + platforms = lib.platforms.unix; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..6fc01bc --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,234 @@ +# NixOS module for running the gsmol bridge as a service. Curried on `self` so +# the systemd unit's default package is exactly what `nix build .#default` +# produces for the target system, with one definition to keep in sync. +self: +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gsmol-bridge; +in +{ + options.services.gsmol-bridge = { + enable = lib.mkEnableOption "the gsmol web client and Smol Mail relay bridge"; + + package = lib.mkOption { + type = lib.types.package; + default = + if cfg.presetServer == null + then self.packages.${pkgs.stdenv.hostPlatform.system}.default + else self.packages.${pkgs.stdenv.hostPlatform.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 { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Address the bridge listens on. The bridge speaks plain HTTP/WS with no + TLS of its own, and browsers refuse a `ws://` connection from an + `https://` page — so leave this at loopback and put a reverse proxy + (`services.gsmol-bridge.nginx` or `.caddy`) in front for TLS and + public exposure, rather than binding this directly to a public + interface. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8096; + description = "Port the bridge listens on."; + }; + + allowPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + default = [ 1961 ]; + description = '' + TCP ports the bridge is allowed to relay WebSocket bytes to + (smolmaild's port on each server your users register with). + ''; + }; + + allowOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "https://mail.example.org" ]; + description = '' + Extra browser origins permitted to open a relay, beyond the bridge's + own. The bridge always refuses a WebSocket upgrade whose `Origin` is + neither absent nor its own page, so a reverse-proxied public domain + that differs from what the bridge itself sees needs listing here. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Open the firewall for `port`. Leave this off when + `services.gsmol-bridge.nginx.enable` (or another reverse proxy) is + doing the public exposure instead. + ''; + }; + + nginx = { + enable = lib.mkEnableOption "an nginx virtual host in front of the bridge, with TLS via ACME"; + + domain = lib.mkOption { + type = lib.types.str; + example = "mail.example.org"; + description = "Public hostname to serve gsmol from."; + }; + }; + + caddy = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Put a Caddy virtual host in front of the bridge, with automatic + HTTPS — no `enableACME`/`forceSSL` to set, Caddy does this itself + for any site address that isn't `http://`-prefixed. + + This wires into the standard `services.caddy.virtualHosts` option + and has no effect if your own configuration replaces + `services.caddy.configFile` outright with a hand-written Caddyfile + — that style of deployment needs the domain added to that file + instead (e.g. an imported drop-in), proxying to + `services.gsmol-bridge.host`:`.port`. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + example = "mail.example.org"; + description = "Public hostname to serve gsmol from."; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + assertions = [ + { + assertion = !(cfg.nginx.enable && cfg.caddy.enable); + message = "services.gsmol-bridge: enable only one of .nginx or .caddy"; + } + ]; + + systemd.services.gsmol-bridge = { + description = "gsmol web client and Smol Mail bridge"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecStart = lib.escapeShellArgs ([ + (lib.getExe cfg.package) + "--host" cfg.host + "--port" (toString cfg.port) + ] + ++ lib.concatMap (p: [ "--allow-port" (toString p) ]) cfg.allowPorts + ++ lib.concatMap (o: [ "--allow-origin" o ]) cfg.allowOrigins); + + DynamicUser = true; + Restart = "on-failure"; + RestartSec = "2s"; + + # No key material, no writes: bridge.py holds no keys and touches + # no filesystem state beyond serving web/ read-only (README, "why + # there is a bridge"). + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + ProtectHostname = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RemoveIPC = true; + UMask = "0077"; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + SystemCallArchitectures = "native"; + }; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + } + + (lib.mkIf cfg.nginx.enable { + services.nginx.enable = true; + services.nginx.virtualHosts.${cfg.nginx.domain} = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://${cfg.host}:${toString cfg.port}"; + proxyWebsockets = true; + }; + }; + }) + + (lib.mkIf cfg.caddy.enable { + services.caddy.enable = true; + # No proxyWebsockets-style flag needed: Caddy's reverse_proxy upgrades a + # WebSocket connection transparently, and it self-provisions TLS for a + # non-http:// site address, so neither forceSSL nor enableACME has a + # caddy-side equivalent to set here. + services.caddy.virtualHosts.${cfg.caddy.domain}.extraConfig = '' + reverse_proxy ${cfg.host}:${toString cfg.port} + ''; + }) + ]); +} diff --git a/scripts/build-locale.mjs b/scripts/build-locale.mjs new file mode 100644 index 0000000..5318169 --- /dev/null +++ b/scripts/build-locale.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Converts locales/en-US/main.ftl into web/locales/en-US.json: a flat +// id -> string dictionary the browser can fetch with no parser of its own. +// +// Deliberately hand-rolled rather than a dependency on @fluent/syntax: the +// source file only ever uses Fluent's simplest shape (comments, flat +// `id = value` messages, `{ $var }` references, and `{"literal"}` string +// inserts for embedded newlines) — confirmed by grep before writing this, +// not assumed. If the file ever gains a select expression, a term, or a +// multiline value, this script will need to grow with it; until then a +// real parser is more dependency than the input justifies. + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const srcPath = join(root, "locales/en-US/main.ftl"); +const outPath = join(root, "web/locales/en-US.json"); + +const MESSAGE = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(.*)$/; +// A Fluent string literal, e.g. {"\u000A"} — only used in this file to +// embed a literal newline inside an otherwise single-line value. +const LITERAL = /\{"((?:[^"\\]|\\.)*)"\}/g; + +function unescapeLiteral(text) { + return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/\\(.)/g, "$1"); +} + +function parseFtl(text) { + const messages = {}; + for (const line of text.split("\n")) { + if (!line || line.startsWith("#")) continue; // blank line or comment + const match = MESSAGE.exec(line); + if (!match) continue; + const [, id, rawValue] = match; + messages[id] = rawValue.replace(LITERAL, (_, inner) => unescapeLiteral(inner)); + } + return messages; +} + +const messages = parseFtl(readFileSync(srcPath, "utf8")); +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, JSON.stringify(messages, null, 2) + "\n"); +console.log(`wrote ${Object.keys(messages).length} messages to ${outPath}`); diff --git a/test/client.mjs b/test/client.mjs new file mode 100644 index 0000000..87d5a74 --- /dev/null +++ b/test/client.mjs @@ -0,0 +1,457 @@ +// Headless driver for the end-to-end test: the same web/js modules the browser +// uses, over a TCP socket (or, with GSMOL_TRANSPORT=ws, through the bridge's +// WebSocket relay — the exact byte path the browser takes). +// +// node test/client.mjs --state bob.json [args] +// +// Commands mirror the reference smolmail.py: keygen, trust, register, resolve, +// import, accept, block, send, fetch, list, read, rotate. State lives in a +// JSON file; mail is stored sealed, like the browser store does. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { hex, timingSafeEqual, unhex, utf8Bytes } from "../web/js/crypto.js"; +import * as proto from "../web/js/proto.js"; + +const { b32decode, b32encode, fingerprint } = proto; + +const args = process.argv.slice(2); +const stateIndex = args.indexOf("--state"); +const statePath = stateIndex >= 0 ? args[stateIndex + 1] : "client-state.json"; +const command = args.find((a, i) => i !== stateIndex && i !== stateIndex + 1 && !a.startsWith("--")); +const rest = args.slice(args.indexOf(command) + 1); + +const load = () => existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {}; +const save = (state) => writeFileSync(statePath, JSON.stringify(state)); + +function parseOption(name) { + const i = rest.indexOf(name); + return i >= 0 ? rest[i + 1] : null; +} + +// --- transports ------------------------------------------------------------- + +function connectTcp(host, port) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }); + socket.on("error", reject); + socket.on("connect", () => { + const stream = { + send: (bytes) => socket.write(bytes), + close: () => socket.end(), + onData: null, + onClose: null, + }; + socket.on("data", (chunk) => stream.onData?.(new Uint8Array(chunk))); + socket.on("close", () => stream.onClose?.()); + resolve(stream); + }); + }); +} + +function connectWs(host, port) { + const bridge = process.env.GSMOL_BRIDGE || "ws://127.0.0.1:8096"; + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${bridge}/tcp/${host}/${port}`); + socket.binaryType = "arraybuffer"; + const stream = { + send: (bytes) => socket.send(bytes), + close: () => socket.close(), + onData: null, + onClose: null, + }; + socket.onerror = () => reject(new Error(`cannot reach the bridge at ${bridge}`)); + socket.onopen = () => resolve(stream); + socket.onmessage = (event) => stream.onData?.(new Uint8Array(event.data)); + socket.onclose = () => stream.onClose?.(); + }); +} + +const connectStream = process.env.GSMOL_TRANSPORT === "ws" ? connectWs : connectTcp; + +// --- session ------------------------------------------------------------------ + +async function open(host, port, { requirePin }) { + const state = load(); + const pinned = state.servers?.[host] ? b32decode(state.servers[host]) : null; + if (requirePin && !pinned) + throw new proto.SmolError(`no pinned key for ${host}`); + const stream = await connectStream(host, port); + const opened = await proto.openSession(stream, host, pinned); + if (!pinned) + console.error(`warning: ${host} is not pinned; its key is ${b32encode(opened.serverStatic)}`); + return opened; +} + +// §2: the identity at the state's current rotation index, plus every earlier +// one — mail sealed to a superseded key is readable with nothing else. +function currentIdentity(state) { + return proto.identityFromSeed(proto.identitySeed(unhex(state.master), state.rotations ?? 0)); +} + +function identities(state) { + const master = unhex(state.master); + const out = []; + for (let n = state.rotations ?? 0; n >= 0; n--) out.push(proto.identityFromSeed(proto.identitySeed(master, n))); + return out; +} + +function cursor(state) { + return [state.afterTime ?? 0, state.afterId ? unhex(state.afterId) : new Uint8Array(proto.ID_LEN)]; +} + +// §4/§5.8: the accept tokens to push with AUTH, and whether to push at all — +// a client that cannot vouch for its own set (freshly restored) must not +// replace the server's with an incomplete one. +function tokenSet(state) { + if (state.syncOk === false) return { sync: 0, tokens: [] }; + const accepted = Object.entries(state.accepted || {}).filter(([, a]) => a.active) + .sort(([a], [b]) => a.localeCompare(b)); + const master = unhex(state.master); + return { sync: 1, tokens: accepted.map(([, a]) => proto.tokenFor(master, b32decode(a.identity))) }; +} + +function addressOfSigner(state, sender, replyToField) { + const known = Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(sender))?.[0]; + if (known) return known; + if (!replyToField) return null; + try { + const parsed = proto.parseAddress(replyToField); + if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short; + } catch { /* malformed claim */ } + return null; +} + +// --- commands ------------------------------------------------------------------- + +const commands = { + keygen() { + const state = load(); + state.master = hex(cryptoRandom(32)); + state.rotations = 0; + state.syncOk = true; + save(state); + const me = currentIdentity(state); + console.log(`public key: ${b32encode(me.publicKey)}`); + console.log(`fingerprint: ${fingerprint(me.publicKey)}`); + }, + + trust() { + const host = rest[0], key = b32decode(rest[1]); + if (key.length !== 32) throw new proto.SmolError(`server key is ${key.length} bytes, expected 32`); + const state = load(); + state.servers = { ...(state.servers || {}), [host]: b32encode(key) }; + save(state); + console.log(`pinned ${host} ${b32encode(key)}`); + }, + + async register() { + const state = load(); + const me = currentIdentity(state); + const addr = proto.parseAddress(rest[0]); + const opened = await open(addr.host, addr.port, { requirePin: true }); + try { + await proto.registerOp(opened.session, opened.serverStatic, { + username: addr.user, identity: me, token: parseOption("--token") || "", + }); + } finally { + opened.session.stream.close(); + } + state.account = { user: addr.user, host: addr.host, port: addr.port }; + save(state); + console.log(`registered ${addr.short}`); + console.log(`share: ${addr.uri(me.publicKey)}`); + }, + + async resolve() { + const state = load(); + const addr = proto.parseAddress(rest[0]); + const opened = await open(addr.host, addr.port, { requirePin: false }); + let current, chain; + try { + ({ identity: current, chain } = await proto.resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + const known = state.contacts?.[addr.short]; + if (!known) { + state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(current), verified: false } }; + save(state); + console.log(`${addr.short} ${b32encode(current)}`); + console.log(` pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`); + return; + } + if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`); + if (proto.walkChain(addr.user, b32decode(known.key), current, chain)) { + state.contacts[addr.short] = { key: b32encode(current), verified: known.verified }; + save(state); + console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`); + return; + } + throw new proto.SmolError(`${addr.short} presents a different key with no valid rotation chain`); + }, + + import() { + const state = load(); + const addr = proto.parseAddress(rest[0]); + if (!addr.identity) throw new proto.SmolError("import needs a smol:// address carrying a key"); + state.contacts = { + ...(state.contacts || {}), + [addr.short]: { key: b32encode(addr.identity), verified: true }, + }; + save(state); + console.log(`imported ${addr.short} ${b32encode(addr.identity)} (verified)`); + }, + + contacts() { + const state = load(); + for (const [address, c] of Object.entries(state.contacts || {})) { + const accepted = state.accepted?.[address]; + const tag = accepted ? (accepted.active ? "accepted" : "blocked") : ""; + console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"} ${tag}`.trimEnd()); + } + }, + + // §5.8: admit a contact to the main tier; their token travels in our next + // message to them. Pushed to the server right away. + async accept() { + const state = load(); + const address = rest[0]; + const contact = state.contacts?.[address]; + if (!contact) throw new proto.SmolError(`no key for ${address} yet`); + state.accepted = { ...(state.accepted || {}), + [address]: { identity: state.accepted?.[address]?.identity ?? contact.key, active: true } }; + state.syncOk = true; + save(state); + const held = await pushTokens(state); + console.log(`accepted ${address}; server now holds ${held} accept token(s)`); + }, + + // §5.8: withdraw a contact's accept token; their mail lands in requests + // from their next message on. + async block() { + const state = load(); + const address = rest[0]; + if (!state.accepted?.[address]) throw new proto.SmolError(`${address} was never accepted`); + state.accepted[address] = { ...state.accepted[address], active: false }; + save(state); + const held = await pushTokens(state); + console.log(`blocked ${address}; server now holds ${held} accept token(s)`); + }, + + async send() { + const state = load(); + const me = currentIdentity(state); + const master = unhex(state.master); + const addr = proto.parseAddress(rest[0]); + let recipient; + if (addr.identity) { + recipient = addr.identity; + state.contacts = { + ...(state.contacts || {}), + [addr.short]: { key: b32encode(recipient), verified: true }, + }; + } else if (state.contacts?.[addr.short]) { + recipient = b32decode(state.contacts[addr.short].key); + } else { + const opened = await open(addr.host, addr.port, { requirePin: false }); + try { + ({ identity: recipient } = await proto.resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + state.contacts = { ...(state.contacts || {}), [addr.short]: { key: b32encode(recipient), verified: false } }; + console.error(`warning: ${addr.short} pinned (trust on first use)`); + } + save(state); + + const fields = [["Subject", parseOption("--subject")], ["In-Reply-To", parseOption("--reply-to")]] + .filter(([, v]) => v); + if (state.account && !rest.includes("--anonymous")) + fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]); + // §5.8: hand an accepted correspondent the token for our own mailbox. + const accepted = state.accepted?.[addr.short]; + if (accepted?.active) fields.push(["Accept", b32encode(proto.tokenFor(master, b32decode(accepted.identity)))]); + const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n")); + const envelope = proto.seal(me, recipient, body); + // §5.8: our token for their mailbox, if they have given us one. + const held = state.tokens?.[addr.short]; + const mac = held ? proto.acceptMac(b32decode(held.token), proto.messageId(envelope)) : null; + const opened = await open(addr.host, addr.port, { requirePin: false }); + try { + await proto.sendOp(opened.session, envelope, mac); + } finally { + opened.session.stream.close(); + } + state.sent = [...(state.sent || []), { + id: hex(proto.messageId(envelope)), recipient: addr.short, + envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(), + }]; + save(state); + console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 16)} to ${addr.short}` + + (mac ? " (accepted)" : "")); + }, + + async fetch() { + const state = load(); + const me = currentIdentity(state); + const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); + const ids = identities(state); + const keep = rest.includes("--keep"); + if (rest.includes("--reset")) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); } + let [afterTime, afterId] = keep ? cursor(state) : [0, new Uint8Array(proto.ID_LEN)]; + const opened = await open(account.host, account.port, { requirePin: true }); + let total = 0, stored = 0, rejected = 0; + try { + const { sync, tokens } = tokenSet(state); + await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens }); + for (;;) { + const records = await proto.fetchOp(opened.session, afterTime, afterId); + if (!records.length) break; + const acked = []; + for (const record of records) { + total++; + afterTime = record.receivedAt; + afterId = record.id; + let unsealed; + try { + if (!timingSafeEqual(proto.messageId(record.envelope), record.id)) + throw new proto.SmolError("id does not match the envelope"); + unsealed = proto.unseal(ids, record.envelope); + } catch (err) { + rejected++; + console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`); + continue; + } + if (!state.inbox) state.inbox = []; + if (!state.inbox.some(m => m.id === hex(record.id))) { + state.inbox.push({ + id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt, + isRequest: record.isRequest, keptOnServer: keep, + }); + stored++; + const { fields } = proto.parseFrontmatter(new TextDecoder().decode(unsealed.body)); + const raw = fields.accept; + if (raw) { + try { + const token = b32decode(raw); + if (token.length === proto.TOKEN_LEN) { + const address = addressOfSigner(state, unsealed.sender, fields["reply-to"]); + if (address) state.tokens = { ...(state.tokens || {}), [address]: { token: b32encode(token) } }; + } + } catch { /* malformed token: ignore */ } + } + } + acked.push(record.id); + } + if (keep) { + state.afterTime = afterTime; + state.afterId = hex(afterId); + } else if (acked.length) { + await proto.deleteOp(opened.session, acked); + } + } + } finally { + opened.session.stream.close(); + } + if (!keep) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); } + save(state); + console.log(`${total} message(s): ${stored} new, ${rejected} rejected`); + }, + + list() { + const state = load(); + const wantRequests = rest.includes("--requests"); + for (const m of state.inbox || []) { + if (Boolean(m.isRequest) === wantRequests) describe(state, m, "inbox"); + } + if (rest.includes("--sent")) for (const m of state.sent || []) describe(state, m, "sent"); + }, + + read() { + const state = load(); + const wanted = rest[0].toLowerCase(); + const match = [...(state.inbox || []).map(m => [m, "inbox"]), ...(state.sent || []).map(m => [m, "sent"])] + .find(([m]) => m.id.startsWith(wanted)); + if (!match) throw new proto.SmolError(`no message matching ${wanted}`); + describe(state, match[0], match[1], true); + }, + + async rotate() { + const state = load(); + const old = currentIdentity(state); + const master = unhex(state.master); + const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); + const nextIndex = (state.rotations ?? 0) + 1; + const freshSeed = proto.identitySeed(master, nextIndex); + const fresh = proto.identityFromSeed(freshSeed); + const cert = proto.makeCert(account.user, old, freshSeed); + const opened = await open(account.host, account.port, { requirePin: true }); + try { + await proto.registerOp(opened.session, opened.serverStatic, { username: account.user, identity: fresh, cert }); + } finally { + opened.session.stream.close(); + } + state.rotations = nextIndex; + save(state); + console.log(`rotated ${account.short}`); + console.log(`new key: ${b32encode(fresh.publicKey)}`); + console.log(`fingerprint: ${fingerprint(fresh.publicKey)}`); + }, +}; + +async function pushTokens(state) { + if (!state.account) { + console.error("warning: not registered; the set will be pushed with your first fetch"); + return 0; + } + const me = currentIdentity(state); + const account = proto.parseAddress(`${state.account.user}@${state.account.host}` + + (state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)); + const opened = await open(account.host, account.port, { requirePin: true }); + try { + const { sync, tokens } = tokenSet(state); + return await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens }); + } finally { + opened.session.stream.close(); + } +} + +function describe(state, row, box, verbose = false) { + const id = row.id; + try { + const opened = proto.unseal(identities(state), unhex(row.envelope)); + const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body)); + const who = box === "sent" ? row.recipient + : Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(opened.sender))?.[0] + ?? `<${b32encode(opened.sender).slice(0, 20)}…>`; + const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " "); + if (!verbose) return console.log( + `${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.subject || "(no subject)"}`); + console.log(`id: ${id}`); + console.log(`${box === "sent" ? "to" : "from"}: ${who}`); + console.log(`key: ${b32encode(opened.sender)}`); + console.log(`date: ${new Date(opened.time * 1000).toISOString()}`); + for (const [k, v] of Object.entries(fields)) if (k !== "accept") console.log(`${k}: ${v}`); + console.log("signature verified\n"); + console.log(body); + } catch (err) { + if (verbose) throw err; + console.log(`${id.slice(0, 8)} `); + } +} + +function cryptoRandom(n) { + const out = new Uint8Array(n); + crypto.getRandomValues(out); + return out; +} + +if (!commands[command]) { + console.error(`unknown command: ${command}`); + process.exit(2); +} +await commands[command](); diff --git a/test/e2e.py b/test/e2e.py new file mode 100644 index 0000000..746197b --- /dev/null +++ b/test/e2e.py @@ -0,0 +1,162 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""End-to-end test: the browser JS stack (web/js, driven headless by +test/client.mjs) against the reference server and client in ../smolmail. + +Two transports are covered: the driver connects directly over TCP, and once +more through bridge.py's WebSocket relay — the exact byte path the browser +takes. Alice is always the reference smolmail.py client, so every exchange +crosses implementations. +""" + +from __future__ import annotations + +import os +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +SMOL = Path(__file__).resolve().parent.parent.parent / "smolmail" +NODE = "node" + +failures: list[str] = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + print(("ok " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else "")) + if not condition: + failures.append(name) + + +def wait_port(port: int, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.2) + raise TimeoutError(f"port {port} never opened") + + +def run(command: list[str], expect: int = 0, env: dict | None = None) -> str: + result = subprocess.run(command, capture_output=True, text=True, env=env) + if result.returncode != expect: + raise RuntimeError(f"command failed ({result.returncode}): {command}\n{result.stdout}{result.stderr}") + return result.stdout + result.stderr + + +def main() -> int: + work = Path(tempfile.mkdtemp(prefix="gsmol-e2e-")) + server_port, bridge_port = 11961, 18096 + smolmaild = ["uv", "run", str(SMOL / "smolmaild.py")] + smolmail = ["uv", "run", str(SMOL / "smolmail.py")] + alice = smolmail + ["--key", str(work / "alice.key"), "--db", str(work / "alice.db")] + bob = [NODE, "test/client.mjs", "--state", str(work / "bob.json")] + server_key = re.search(r"public key: (\S+)", run( + smolmaild + ["keygen", "--key", str(work / "server.key")])).group(1) + + server = subprocess.Popen( + smolmaild + ["serve", "--key", str(work / "server.key"), "--db", str(work / "mail.db"), + "--host", "127.0.0.1", "--port", str(server_port)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + bridge = subprocess.Popen( + ["uv", "run", "bridge.py", "--port", str(bridge_port), "--allow-port", str(server_port)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + wait_port(server_port) + wait_port(bridge_port) + host = f"127.0.0.1:{server_port}" + + # --- both sides register on the reference server + run(alice + ["keygen"]) + run(alice + ["trust", "127.0.0.1", server_key]) + run(alice + ["register", f"alice@{host}"]) + run(bob + ["keygen"]) + run(bob + ["trust", "127.0.0.1", server_key]) + run(bob + ["register", f"bob@{host}"]) + check("bob registers on the reference server", True) + + # bob needs alice as a known contact before he can accept her (§5.8). + run(bob + ["resolve", f"alice@{host}"]) + + # --- reference client sends to the JS client, unsolicited: bob has not + # accepted alice yet, so it lands in his requests tier, not the main one + run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"]) + print(run(bob + ["fetch"]).strip()) + bob_requests = run(bob + ["list", "--requests"]) + check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests, + bob_requests) + check("bob's main tier is still empty", "greetings" not in run(bob + ["list"])) + bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)]) + alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1) + check("bob reads the message", "hello bob" in bob_read, bob_read) + check("bob sees alice's key", alice_pub in bob_read) + check("signature verifies", "signature verified" in bob_read) + # the reference client now emits its signed reply address by default + check("reference client emits the signed reply address", + "reply-to: smol://alice@" in bob_read, bob_read) + + # --- bob accepts alice (§5.8): pushed to the server right away, so her + # future mail reaches his main tier + accepted = run(bob + ["accept", f"alice@{host}"]) + check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted, + accepted) + + # --- JS client replies, through the bridge's WebSocket relay. Bob has + # accepted alice, so this carries the token issued to her — but it is + # still unsolicited from alice's side, landing in her requests tier. + ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}") + reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}", + "--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1) + check("bob sends through the bridge", bool(reply_id)) + run(alice + ["fetch"]) + alice_requests = run(alice + ["list", "--requests"]) + check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests, + alice_requests) + alice_read = run(alice + ["read", reply_id[:8]]) + check("alice reads bob's reply", "hello alice" in alice_read, alice_read) + check("reference client verifies the JS signature", "signature verified" in alice_read) + # bob's reply carries his signed smol:// address; a receiver that does + # not know the field must still preserve and display it (SPEC.md §5.5) + check("reference client preserves the signed reply address", + bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read) + + # --- having now learned bob's accept token from that reply's Accept + # field, alice's next message to him carries a matching MAC and + # reaches his main tier even after he rotates his key + bob_rotated = run(bob + ["rotate"]) + new_key = re.search(r"new key: (\S+)", bob_rotated).group(1) + resolved = run(alice + ["resolve", f"bob@{host}"]) + check("chain confirms the rotation", "a signed chain confirms it" in resolved, resolved) + run(alice + ["send", f"bob@{host}", "--subject", "post-rotation", "--body", "still here", + "--anonymous"]) + print(run(bob + ["fetch"]).strip()) + bob_list = run(bob + ["list"]) + check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list, + bob_list) + # --anonymous omits the reply address; the field list must show none + post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation", + bob_list).group(1)]) + check("--anonymous omits the reply address", "reply-to:" not in post_read, post_read) + + print(f"\n{len(failures)} failure(s)" + (f": {failures}" if failures else "")) + return 1 if failures else 0 + finally: + for process in (bridge, server): + process.send_signal(signal.SIGTERM) + process.wait(timeout=10) + print(f"workdir left at {work}" if failures else f"cleaning {work}") + + +if __name__ == "__main__": + os.chdir(Path(__file__).resolve().parent.parent) + sys.exit(main()) diff --git a/test/gen_vectors.py b/test/gen_vectors.py new file mode 100644 index 0000000..7edac53 --- /dev/null +++ b/test/gen_vectors.py @@ -0,0 +1,203 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["noiseprotocol>=0.3.1", "pynacl>=1.5"] +# /// +"""Generate test/vectors.json for the JS implementation. + +The JS crypto in web/js must agree byte for byte with the reference stack +(hashlib/hmac, PyNaCl, noiseprotocol), so the ground truth is generated here +with fixed inputs and consumed by test/vectors.test.mjs. Everything is +derived from SHA-256 of fixed labels, so the file is reproducible. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import struct +from pathlib import Path + +import nacl.bindings as sodium +from noise.connection import Keypair, NoiseConnection + +LABEL_SEAL, LABEL_MSG, LABEL_ID = b"smolmail/1 seal", b"smolmail/1 msg", b"smolmail/1 id" +LABEL_IDENTITY, LABEL_ACCEPT, LABEL_MAC = b"smolmail/1 identity", b"smolmail/1 accept", b"smolmail/1 mac" +TIME = 1730000000 # fixed so the envelope vector is reproducible + + +def seed(label: str) -> bytes: + return hashlib.sha256(label.encode()).digest() + + +def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes: + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + out, block, counter = b"", b"", 1 + while len(out) < length: + block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() + out, counter = out + block, counter + 1 + return out[:length] + + +def main() -> int: + out: dict = {} + + # --- hashes over empty, one-block and multi-block inputs + out["sha256"] = [ + {"in": m.hex(), "out": hashlib.sha256(m).hexdigest()} + for m in (b"", b"abc", bytes(range(64)), b"smolmail" * 40) + ] + out["sha512"] = [ + {"in": m.hex(), "out": hashlib.sha512(m).hexdigest()} + for m in (b"", b"abc", bytes(range(64))) + ] + + # --- HMAC/HKDF, including RFC 5869 test case 1 + out["hkdf"] = [] + for ikm, salt, info, length, name in ( + (bytes.fromhex("0b" * 22), bytes.fromhex("000102030405060708090a0b0c"), + bytes.fromhex("f0f1f2f3f4f5f6f7f8f9"), 42, "rfc5869-1"), + (b"agreement shared", b"epk" + b"recipient", b"smolmail/1 seal", 32, "seal-shaped"), + ): + out["hkdf"].append({"name": name, "ikm": ikm.hex(), "salt": salt.hex(), + "info": info.hex(), "len": length, + "out": hkdf_sha256(ikm, salt, info, length).hex()}) + + # --- ChaCha20-Poly1305 (RFC 8439 IETF AEAD) over varied shapes + key, nonce = seed("aead key"), seed("aead nonce")[:12] + aad = bytes.fromhex("50515253c0c1c2c3c4c5c6c7") + out["aead"] = [] + for plaintext, use_aad, name in ( + (b"", False, "empty"), (b"hello", True, "short"), + (b"Ladies and Gentlemen of the class of '99: if I could offer you " + b"only one tip for the future, sunscreen would be it.", True, "multiline"), + ): + sealed = sodium.crypto_aead_chacha20poly1305_ietf_encrypt( + plaintext, aad if use_aad else b"", nonce, key) + out["aead"].append({"name": name, "key": key.hex(), "nonce": nonce.hex(), + "aad": (aad if use_aad else b"").hex(), "plaintext": plaintext.hex(), + "sealed": sealed.hex()}) + + # --- X25519: base multiplication and agreement, including the §2 checks + out["x25519"] = [] + for name, priv in (("alice", seed("x25519 alice")), ("bob", seed("x25519 bob"))): + pub = sodium.crypto_scalarmult_base(priv) + out["x25519"].append({"name": name, "priv": priv.hex(), "pub": pub.hex()}) + out["x25519"].append({"name": "agree", + "shared": sodium.crypto_scalarmult( + seed("x25519 alice"), + sodium.crypto_scalarmult_base(seed("x25519 bob"))).hex()}) + for bad in (b"\x00" * 32, bytes.fromhex("0100" + "00" * 30)): + try: + sodium.crypto_scalarmult(seed("x25519 alice"), bad) + raised = False + except Exception: + raised = True + out["x25519"].append({"name": f"low-order-{bad[0]}", "peer": bad.hex(), + "peer_rejected": True, "raised": raised}) + assert raised, "PyNaCl must reject this low-order point for the vector to mean anything" + + # --- Ed25519: keypair, signature, verification, and a tampered case + out["ed25519"] = [] + for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))): + pub, _ = sodium.crypto_sign_seed_keypair(s) + for message in (b"", b"smolmail/1 auth" + bytes(32), bytes(range(64))): + sig = sodium.crypto_sign(message, s + pub)[:64] + out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(), + "message": message.hex(), "signature": sig.hex(), + "valid": True}) + bad = bytes([sig[0] ^ 1]) + sig[1:] + out["ed25519"].append({"name": name, "seed": s.hex(), "pub": pub.hex(), + "message": message.hex(), "signature": bad.hex(), + "valid": False}) + + # --- §2 conversions between the identity key and X25519 + out["ed_to_x25519"] = [] + for name, s in (("vector-seed-a", seed("ed25519 a")), ("vector-seed-b", seed("ed25519 b"))): + pub, sk64 = sodium.crypto_sign_seed_keypair(s) + out["ed_to_x25519"].append({ + "name": name, "seed": s.hex(), + "x_priv": sodium.crypto_sign_ed25519_sk_to_curve25519(sk64).hex(), + "x_pub": sodium.crypto_sign_ed25519_pk_to_curve25519(pub).hex()}) + + # --- §5 envelope sealed with a fixed ephemeral, mirroring smolmail.py seal() + sender_seed, recipient_seed, esk = seed("envelope sender"), seed("envelope recipient"), seed("envelope esk") + sender_pub, _ = sodium.crypto_sign_seed_keypair(sender_seed) + recipient_pub, _ = sodium.crypto_sign_seed_keypair(recipient_seed) + epk = sodium.crypto_scalarmult_base(esk) + shared = sodium.crypto_scalarmult(esk, sodium.crypto_sign_ed25519_pk_to_curve25519(recipient_pub)) + key = hkdf_sha256(shared, epk + recipient_pub, LABEL_SEAL, 32) + body = b"---\nSubject: vector\n---\nhello bob\n" + sig = sodium.crypto_sign(LABEL_MSG + recipient_pub + epk + + bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body)) + body, + sender_seed + sender_pub)[:64] + plaintext = (bytes([1]) + sender_pub + struct.pack(">qI", TIME, len(body)) + + body + sig) + aad = b"SMOL" + bytes([1]) + recipient_pub + epk + envelope = aad + sodium.crypto_aead_chacha20poly1305_ietf_encrypt(plaintext, aad, bytes(12), key) + out["envelope"] = { + "sender_seed": sender_seed.hex(), "recipient_seed": recipient_seed.hex(), + "esk": esk.hex(), "body": body.hex(), "time": TIME, + "envelope": envelope.hex(), + "id": hashlib.sha256(LABEL_ID + envelope).digest().hex(), + "unpadded_plaintext_len": len(plaintext)} + + # --- §2/§5.8: master-derived rotation seeds and accept tokens + master = seed("master") + correspondent = seed("correspondent identity") + accept_key = hkdf_sha256(master, b"", LABEL_ACCEPT, 32) + token = hmac.new(accept_key, correspondent, hashlib.sha256).digest() + mid = seed("token message id") + out["tokens"] = { + "master": master.hex(), + "identity_seed_0": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 0), 32).hex(), + "identity_seed_1": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 1), 32).hex(), + "accept_key": accept_key.hex(), + "correspondent": correspondent.hex(), + "token": token.hex(), + "message_id": mid.hex(), + "accept_mac": hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest().hex(), + } + + # --- Noise NX transcript with every key fixed + i_e, r_e, s_priv = seed("nx initiator eph"), seed("nx responder eph"), seed("nx server static") + s_pub = sodium.crypto_scalarmult_base(s_priv) + n1 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256") + n1.set_as_initiator() + n1.set_prologue(b"smolmail/1") + n1.set_keypair_from_private_bytes(Keypair.EPHEMERAL, i_e) + n1.start_handshake() + m1 = n1.write_message() + + n2 = NoiseConnection.from_name(b"Noise_NX_25519_ChaChaPoly_SHA256") + n2.set_as_responder() + n2.set_prologue(b"smolmail/1") + n2.set_keypair_from_private_bytes(Keypair.STATIC, s_priv) + n2.set_keypair_from_private_bytes(Keypair.EPHEMERAL, r_e) + n2.start_handshake() + n2.read_message(m1) + m2 = n2.write_message() + n1.read_message(m2) + assert n1.handshake_finished and n2.handshake_finished + + out["noise"] = { + "initiator_eph_priv": i_e.hex(), "responder_eph_priv": r_e.hex(), + "server_static_priv": s_priv.hex(), "server_static_pub": s_pub.hex(), + "message1": m1.hex(), "message2": m2.hex(), + "handshake_hash": n1.get_handshake_hash().hex(), + "assert_hash_equal": n1.get_handshake_hash() == n2.get_handshake_hash(), + "initiator_frames": [{"plaintext": p.hex(), "sealed": n1.encrypt(p).hex()} + for p in (b"ping", b"second frame to test the counter")], + "responder_frames": [{"plaintext": p.hex(), "sealed": n2.encrypt(p).hex()} + for p in (b"pong", b"x" * 300)], + } + + path = Path(__file__).parent / "vectors.json" + path.write_text(json.dumps(out, indent=2) + "\n") + print(f"wrote {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/vectors.json b/test/vectors.json new file mode 100644 index 0000000..3bf13a2 --- /dev/null +++ b/test/vectors.json @@ -0,0 +1,236 @@ +{ + "sha256": [ + { + "in": "", + "out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "in": "616263", + "out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108" + }, + { + "in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c", + "out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462" + } + ], + "sha512": [ + { + "in": "", + "out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + }, + { + "in": "616263", + "out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + }, + { + "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949" + } + ], + "hkdf": [ + { + "name": "rfc5869-1", + "ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "salt": "000102030405060708090a0b0c", + "info": "f0f1f2f3f4f5f6f7f8f9", + "len": 42, + "out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + }, + { + "name": "seal-shaped", + "ikm": "61677265656d656e7420736861726564", + "salt": "65706b726563697069656e74", + "info": "736d6f6c6d61696c2f31207365616c", + "len": 32, + "out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c" + } + ], + "aead": [ + { + "name": "empty", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "", + "plaintext": "", + "sealed": "941b286ea0ee86bb66236fc3c16b2e48" + }, + { + "name": "short", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "68656c6c6f", + "sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29" + }, + { + "name": "multiline", + "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", + "nonce": "3edd69829394f0807df7b01d", + "aad": "50515253c0c1c2c3c4c5c6c7", + "plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e", + "sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24" + } + ], + "x25519": [ + { + "name": "alice", + "priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33", + "pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664" + }, + { + "name": "bob", + "priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7", + "pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252" + }, + { + "name": "agree", + "shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13" + }, + { + "name": "low-order-0", + "peer": "0000000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + }, + { + "name": "low-order-1", + "peer": "0100000000000000000000000000000000000000000000000000000000000000", + "peer_rejected": true, + "raised": true + } + ], + "ed25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "", + "signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": true + }, + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", + "valid": false + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "", + "signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", + "signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": true + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", + "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", + "valid": false + } + ], + "ed_to_x25519": [ + { + "name": "vector-seed-a", + "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", + "x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68", + "x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910" + }, + { + "name": "vector-seed-b", + "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", + "x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963", + "x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d" + } + ], + "envelope": { + "sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc", + "recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf", + "esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48", + "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", + "time": 1730000000, + "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", + "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553", + "unpadded_plaintext_len": 143 + }, + "tokens": { + "master": "fc613b4dfd6736a7bd268c8a0e74ed0d1c04a959f59dd74ef2874983fd443fc9", + "identity_seed_0": "35c2f058e7568f68e99e4fba7a730bd2d3cf7e1f6a2a266a2c5f5ca150d0074c", + "identity_seed_1": "fef4bd7f73b6459031a79dd589011a120c2cbc19b693b918f4aa9a8afe9dba7b", + "accept_key": "4fbaf4883885875100dc157864977e9a533866b15adc9daed35eedcc5f62fb20", + "correspondent": "fbaa80b2e87dc300cd33a2497dfe672c47ca8c8af099db8d45644054dff85297", + "token": "fe3085aeb2952b2bdd5abdcf8908ffd00054e871f47dfed2aa541cdb0e2006d6", + "message_id": "7c65ce5d23376fb508b4dda83a271095fd41e0ce47604213153520b0a0db50ac", + "accept_mac": "1d56673df4952dedcc1e40dfce2ad7820864090809a381e225e0f2fafa7cbfc7" + }, + "noise": { + "initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2", + "responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80", + "server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e", + "server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32", + "message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e", + "message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9", + "handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72", + "assert_hash_equal": true, + "initiator_frames": [ + { + "plaintext": "70696e67", + "sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0" + }, + { + "plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572", + "sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c" + } + ], + "responder_frames": [ + { + "plaintext": "706f6e67", + "sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b" + }, + { + "plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", + "sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62" + } + ] + } +} diff --git a/test/vectors.test.mjs b/test/vectors.test.mjs new file mode 100644 index 0000000..13130bd --- /dev/null +++ b/test/vectors.test.mjs @@ -0,0 +1,246 @@ +// Unit tests: web/js must reproduce test/vectors.json byte for byte, plus +// protocol-level checks for frontmatter, addresses and rotation chains. +// Run: node test/vectors.test.mjs + +import { readFileSync } from "node:fs"; +import * as crypto from "../web/js/crypto.js"; +import * as noise from "../web/js/noise.js"; +import * as proto from "../web/js/proto.js"; + +const vectors = JSON.parse(readFileSync(new URL("./vectors.json", import.meta.url), "utf8")); +const unhex = crypto.unhex; +const hex = crypto.hex; +let passed = 0; + +function ok(name, condition, detail = "") { + if (!condition) throw new Error(`FAIL ${name} ${detail}`); + passed++; +} + +const eq = (name, got, want) => ok(name, got === want, `got ${got}, want ${want}`); + +// --- hashes, HMAC/HKDF, AEAD -------------------------------------------------- + +for (const v of vectors.sha256) eq(`sha256[${v.in.slice(0, 12)}]`, hex(crypto.sha256(unhex(v.in))), v.out); +for (const v of vectors.sha512) eq(`sha512[${v.in.slice(0, 12)}]`, hex(crypto.sha512(unhex(v.in))), v.out); +for (const v of vectors.hkdf) + eq(`hkdf[${v.name}]`, hex(crypto.hkdfSha256(unhex(v.ikm), unhex(v.salt), unhex(v.info), v.len)), v.out); +for (const v of vectors.aead) { + const sealed = crypto.aeadEncrypt(unhex(v.key), unhex(v.nonce), unhex(v.plaintext), unhex(v.aad)); + eq(`aead-seal[${v.name}]`, hex(sealed), v.sealed); + eq(`aead-open[${v.name}]`, + hex(crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed), unhex(v.aad))), v.plaintext); + let threw = false; + try { crypto.aeadDecrypt(unhex(v.key), unhex(v.nonce), unhex(v.sealed).slice(0, -1), unhex(v.aad)); } + catch { threw = true; } + ok(`aead-tamper[${v.name}]`, threw); +} + +// --- X25519 ------------------------------------------------------------------- + +const x = Object.fromEntries(vectors.x25519.map(v => [v.name, v])); +for (const name of ["alice", "bob"]) + eq(`x25519-base[${name}]`, hex(crypto.x25519Base(unhex(x[name].priv))), x[name].pub); +eq("x25519-agree", hex(crypto.x25519(unhex(x.alice.priv), unhex(x.bob.pub))), x.agree.shared); +for (const v of vectors.x25519.filter(v => v.name.startsWith("low-order"))) { + let threw = false; + try { crypto.x25519(unhex(x.alice.priv), unhex(v.peer)); } catch { threw = true; } + ok(`x25519-rejects[${v.name}]`, threw); +} + +// --- Ed25519 ------------------------------------------------------------------ + +for (const v of vectors.ed25519) { + const seed = unhex(v.seed); + eq(`ed25519-pub[${v.name}]`, hex(crypto.ed25519PublicKey(seed)), v.pub); + const sig = crypto.ed25519Sign(seed, unhex(v.message)); + if (v.valid) { + eq(`ed25519-sign[${v.name}]`, hex(sig), v.signature); + ok(`ed25519-verify[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), sig)); + ok(`ed25519-verify-pynacl[${v.name}]`, crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); + } else { + ok(`ed25519-reject[${v.name}]`, + !crypto.ed25519Verify(unhex(v.pub), unhex(v.message), unhex(v.signature))); + } +} + +// --- §2 conversions ----------------------------------------------------------- + +for (const v of vectors.ed_to_x25519) { + eq(`x_priv[${v.name}]`, hex(crypto.ed25519SeedToX25519(unhex(v.seed))), v.x_priv); + eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub); +} + +// --- §2/§5.8: master-derived rotation seeds and accept tokens ----------------- + +{ + const t = vectors.tokens; + const master = unhex(t.master); + eq("identity-seed-0", hex(proto.identitySeed(master, 0)), t.identity_seed_0); + eq("identity-seed-1", hex(proto.identitySeed(master, 1)), t.identity_seed_1); + eq("accept-key", hex(proto.acceptKeyFor(master)), t.accept_key); + eq("token-for", hex(proto.tokenFor(master, unhex(t.correspondent))), t.token); + eq("accept-mac", hex(proto.acceptMac(unhex(t.token), unhex(t.message_id))), t.accept_mac); +} + +// --- §5 envelope -------------------------------------------------------------- + +{ + const v = vectors.envelope; + const sender = proto.identityFromSeed(unhex(v.sender_seed)); + const recipient = proto.identityFromSeed(unhex(v.recipient_seed)); + const seal = (opts) => proto.seal(sender, recipient.publicKey, unhex(v.body), v.time, + { esk: unhex(v.esk), ...opts }); + eq("envelope", hex(seal({ pad: false })), v.envelope); + eq("envelope-id", hex(proto.messageId(unhex(v.envelope))), v.id); + const opened = proto.unseal([recipient], unhex(v.envelope)); + eq("envelope-unseal-sender", hex(opened.sender), hex(sender.publicKey)); + eq("envelope-unseal-time", String(opened.time), String(v.time)); + eq("envelope-unseal-body", hex(opened.body), v.body); + let threw = false; + try { proto.unseal([proto.identityFromSeed(unhex(v.esk))], unhex(v.envelope)); } catch { threw = true; } + ok("envelope-wrong-recipient", threw); + // padding round-trips and is ignored by the receiver (§5.3) + const padded = seal({}); + // the padded envelope is 69 header + 16 tag + a multiple of 1 KiB of plaintext + ok("envelope-padded", + (padded.length - proto.ENVELOPE_HEADER - 16) % proto.PAD_TO === 0 + && padded.length > v.envelope.length / 2); + eq("envelope-padded-body", hex(proto.unseal([recipient], padded).body), v.body); +} + +// --- Noise NX transcript ------------------------------------------------------ + +{ + const v = vectors.noise; + ok("noise-transcript-selfcheck", v.assert_hash_equal); + const nx = new noise.NxInitiator(); + eq("noise-m1", hex(nx.writeMessage1(unhex(v.initiator_eph_priv))), v.message1); + const ciphers = nx.readMessage2(unhex(v.message2)); + eq("noise-server-static", hex(nx.serverStatic), v.server_static_pub); + eq("noise-handshake-hash", hex(nx.handshakeHash), v.handshake_hash); + for (const [i, frame] of v.initiator_frames.entries()) { + eq(`noise-frame-i${i}`, hex(ciphers.send.encrypt(unhex(frame.plaintext))), frame.sealed); + } + for (const [i, frame] of v.responder_frames.entries()) { + eq(`noise-frame-r${i}`, hex(ciphers.recv.decrypt(unhex(frame.sealed))), frame.plaintext); + } + // the responder direction must also produce identical ciphertexts (AEAD is + // deterministic), so the recv cipher can be checked in both directions + const mirrored = new noise.NxInitiator(); + mirrored.writeMessage1(unhex(v.initiator_eph_priv)); + const mirrorCiphers = mirrored.readMessage2(unhex(v.message2)); + eq("noise-frame-r0-mirror", + hex(mirrorCiphers.recv.encrypt(unhex(v.responder_frames[0].plaintext))), v.responder_frames[0].sealed); +} + +// --- frontmatter (§5.5) ------------------------------------------------------- + +{ + const rid = "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; // §5.4: 64 hex chars + const spec = `---\nSubject: Re: the thing\nIn-Reply-To: ${rid}\nX-Mood: cautiously optimistic\n---\nBody text starts here.`; + const { fields, body } = proto.parseFrontmatter(spec); + // §5.5: keys are compared case-insensitively, so they come back lowercased. + eq("fm-subject", fields.subject, "Re: the thing"); + eq("fm-reply", fields["in-reply-to"], rid); + eq("fm-case-insensitive", proto.parseFrontmatter("---\nSUBJECT: hi\n---\nx").fields.subject, "hi"); + eq("fm-body", body, "Body text starts here."); + // a malformed line invalidates the whole block, which fails closed toward display + eq("fm-malformed", proto.parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest"); + eq("fm-unterminated", proto.parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end"); + eq("fm-first-wins", proto.parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields.a, "1"); + eq("fm-escape", proto.buildFrontmatter([], "---\nactual body"), "---\n---\n---\nactual body"); + eq("fm-build", proto.buildFrontmatter([["Subject", "hi"]], "there"), "---\nSubject: hi\n---\nthere"); + eq("fm-no-block", proto.buildFrontmatter([], "plain"), "plain"); + eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none"); +} + +// --- addresses (§3) ----------------------------------------------------------- + +{ + const a = proto.parseAddress("Alice@Example.ORG:1961"); + eq("addr-user", a.user, "alice"); + eq("addr-port", String(a.port), "1961"); + eq("addr-short", a.short, "alice@example.org"); // a default port is dropped, as in the reference + eq("addr-default-port", String(proto.parseAddress("bob@host").port), "1961"); + const key = unhex(vectors.ed25519[0].pub); + const parsed = proto.parseAddress(proto.parseAddress("bob@h").uri(key)); + ok("addr-uri-roundtrip", key.every((b, i) => b === parsed.identity[i]) && parsed.user === "bob"); + let threw = false; + try { proto.parseAddress("-bob@h"); } catch { threw = true; } + ok("addr-separator-rejected", threw); + // §3: fingerprints are the first 20 base32 characters in groups of four + const b32 = proto.b32encode(key); + eq("fingerprint", proto.fingerprint(key), + [b32.slice(0, 4), b32.slice(4, 8), b32.slice(8, 12), b32.slice(12, 16), b32.slice(16, 20)].join(" ")); + for (const n of [1, 2, 5, 32, 52, 64]) { + const raw = crypto.randomBytes(n); + eq(`b32[${n}]`, proto.b32encode(proto.b32decode(proto.b32encode(raw))), proto.b32encode(raw)); + } +} + +// --- rotation chains (§7) ------------------------------------------------------ + +{ + const user = "alice"; + const old = proto.identityFromSeed(crypto.randomBytes(32)); + const mid = crypto.randomBytes(32); + const fresh = crypto.randomBytes(32); + const freshPub = crypto.ed25519PublicKey(fresh); + const when = proto.nowSeconds(); + const chain = [proto.makeCert(user, old, mid, when), + proto.makeCert(user, proto.identityFromSeed(mid), fresh, when)]; + ok("chain-valid", proto.walkChain(user, old.publicKey, freshPub, chain)); + ok("chain-unchanged", proto.walkChain(user, old.publicKey, old.publicKey, [])); + ok("chain-missing-link", !proto.walkChain(user, old.publicKey, freshPub, chain.slice(1))); + ok("chain-wrong-username", !proto.walkChain("bob", old.publicKey, freshPub, chain)); + const forged = chain.slice(); + forged[1] = proto.makeCert(user, proto.identityFromSeed(mid), crypto.randomBytes(32), when); + ok("chain-broken", !proto.walkChain(user, old.publicKey, freshPub, forged)); + ok("chain-oversize", !proto.walkChain(user, old.publicKey, freshPub, Array(17).fill(chain[0]))); + eq("cert-len", String(chain[0].length), "200"); +} + +// --- response framing guards (§6.1) ------------------------------------------- + +// A Session over a scripted server. The cipher states are the identity, so the +// bytes handed in are exactly what call() parses; readNoise wants at least 16, +// and trailing bytes past the frame length are ignored by design. +function scriptedSession(frame) { + const message = crypto.concat(frame, new Uint8Array(Math.max(0, 16 - frame.length))); + const wire = { + queue: crypto.concat(proto.u16BE(message.length), message), + pos: 0, + async readExact(n) { + if (this.pos + n > this.queue.length) throw new Error("script exhausted"); + this.pos += n; + return this.queue.slice(this.pos - n, this.pos); + }, + }; + const passthrough = { encrypt: bytes => bytes, decrypt: bytes => bytes }; + return new proto.Session(wire, { send() {}, close() {} }, passthrough, passthrough); +} + +async function refuses(name, frame, op) { + let threw = false; + try { await scriptedSession(frame).call(op); } catch { threw = true; } + ok(name, threw, "call resolved instead of throwing"); +} + +// The shortest legal response is a type byte and a status byte. +await refuses("frame-too-short", + crypto.concat(proto.u32BE(1), Uint8Array.of(proto.OP.FETCH)), proto.OP.FETCH); + +// A response reuses the request's type byte; a mismatch means the session +// desynchronised, which must not be read as a status. +await refuses("frame-op-mismatch", + crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.RESOLVE, 0)), proto.OP.FETCH); + +// The same frame with the right echo still passes, so the guard is not +// simply rejecting everything. +{ + const session = scriptedSession(crypto.concat(proto.u32BE(2), Uint8Array.of(proto.OP.FETCH, 0))); + eq("frame-op-echo-ok", (await session.call(proto.OP.FETCH)).status, 0); +} + +console.log(`${passed} checks passed`); diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..fdde838 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..df0d582 --- /dev/null +++ b/web/index.html @@ -0,0 +1,274 @@ + + + + + +smol mail + + + + + + +
+
smol mail
+ + +
+ + +
+
+

welcome

+

Encrypted mail with no account and no + password. Your identity is one secret, kept in this browser and nowhere else.

+ + +
+ + + + + + + + +
+ +
+ +
+
+
+ + +
+ +
+
+
+

select a message

+
+ + +
+ + + + +
+ + + + +
+
+
+ + + +
+

settings

+ +
+ +
+ + +
+
+
+ +

recovery secret

+
+
+ + + + + + +
+
+
+ + +
+

+

+
+
+ + +
+
+ + + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..a227f46 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,1660 @@ +// UI wiring for the smol mail client: identity setup, folders, reading, +// composing, fetching with verify-before-ack, and the §4/§7 trust surfaces. + +import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js"; +import { + DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode, + b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed, + makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp, + resolveOp, seal, sendOp, tokenFor, unseal, walkChain, +} from "./proto.js"; +import * as store from "./store.js"; +import { connectStream } from "./transport.js"; +import { PRESET_SERVER } from "./config.js"; +import { applyStaticLocale, loadLocale, t } from "./i18n.js"; + +const $ = (id) => document.getElementById(id); + +// Blocks the rest of this module's top-level code (including every eager +// render below) until the dictionary is in, so nothing races t() before it +// has anything to look up. +await loadLocale(); +applyStaticLocale(); + +// Everything is built as nodes rather than markup, so no message content can +// ever reach innerHTML. Null children are dropped, which keeps conditional +// pieces inline at the call site. +function el(tag, props = {}, ...children) { + const node = Object.assign(document.createElement(tag), props); + node.append(...children.filter(child => child != null)); + return node; +} + +// SVG lives in its own namespace, so el()'s createElement cannot build these; +// each is a reference into the sprite in index.html. +function icon(name) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "icon"); + svg.setAttribute("aria-hidden", "true"); + const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); + use.setAttribute("href", `#i-${name}`); + svg.append(use); + return svg; +} + +const placeholder = (text, warn = false) => + el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text }); + +// A native shown with showModal() renders in the browser's top +// layer, above the rest of the page — a toast anchored to sits +// visually behind an open dialog's backdrop no matter its z-index. Keeping +// the toast inside whichever dialog is open puts it in that same top-layer +// subtree, so a status raised while settings or compose is open (e.g. after +// "register") actually gets seen instead of landing, barely visible, behind it. +function reparentStatus() { + const toast = $("status"); + const target = document.querySelector("dialog[open]") ?? document.body; + if (toast.parentElement !== target) target.append(toast); +} +for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("close", reparentStatus); + +// The status line is a toast: click to dismiss, empty means hidden. Plain +// notices fade; warnings stay until dismissed, since those carry the §4/§8 +// trust messages the user must actually read. +let toastTimer = null; +function status(text, warn = false) { + reparentStatus(); + const toast = $("status"); + toast.textContent = text; + toast.hidden = !text; + toast.className = warn ? "toast warn" : "toast"; + clearTimeout(toastTimer); + if (text && !warn) toastTimer = setTimeout(() => status(""), 6000); +} +$("status").onclick = () => status(""); + +const fmtTime = (seconds) => + new Date(seconds * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); + +// Gmail-style row stamp: time for today, date within this year, full date older. +const fmtRowTime = (seconds) => { + const date = new Date(seconds * 1000), today = new Date(); + if (date.toDateString() === today.toDateString()) + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + if (date.getFullYear() === today.getFullYear()) + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +}; + +// A copy button beats asking the user to select 64 hex characters by hand. +// Flashes its own label rather than a toast — settings' rows need feedback +// that doesn't float away from the row that caused it. +function copyButton(text, what) { + return el("button", { type: "button", className: "copy", title: t("property-row-copy-tooltip", { field: what }), + textContent: t("copy-button-idle"), + onclick: async (event) => { + const button = event.currentTarget; + const resting = button.textContent; + try { + await navigator.clipboard.writeText(text); + button.textContent = t("copy-button-success"); + } catch { + button.textContent = t("copy-button-failed"); + } + setTimeout(() => { button.textContent = resting; }, 1500); + } }); +} + +// The stored account as an address object, default port included when custom. +function accountAddress() { + const account = store.account(); + if (!account) return null; + const suffix = account.port === DEFAULT_PORT ? "" : `:${account.port}`; + return parseAddress(`${account.user}@${account.host}${suffix}`); +} + +// --- appearance ---------------------------------------------------------------- + +// Three states, because "follow the system" is a real preference and not the +// absence of one. The CSS does the rest: color-scheme decides which half of +// every light-dark() token applies, so nothing here touches a colour. +const THEMES = [ + { value: null, icon: "auto", labelId: "theme-auto-label" }, + { value: "light", icon: "sun", labelId: "theme-light-label" }, + { value: "dark", icon: "moon", labelId: "theme-dark-label" }, +]; + +function applyTheme() { + const mode = THEMES.find(entry => entry.value === store.theme()) ?? THEMES[0]; + if (mode.value) document.documentElement.dataset.theme = mode.value; + else delete document.documentElement.dataset.theme; + const button = $("theme"); + button.querySelector("use").setAttribute("href", `#i-${mode.icon}`); + const label = t(mode.labelId); + button.title = label; + button.setAttribute("aria-label", label); +} + +$("theme").onclick = () => { + const current = THEMES.findIndex(entry => entry.value === store.theme()); + store.setTheme(THEMES[(current + 1) % THEMES.length].value); + applyTheme(); + status(t(THEMES.find(entry => entry.value === store.theme()).labelId)); +}; + +// --- connection -------------------------------------------------------------- + +// Every caller that needs its own account's server reachable here already +// checked for one before calling (see e.g. fetch's and rotateIdentity's own +// `!addr` guards) — and that host is always pinned by the time it exists, +// since onboarding pins before it ever binds an account. A correspondent's +// server, by contrast, legitimately proceeds unpinned (the UNVERIFIED +// warning below is the only consequence); there is no live case left where +// this needs to refuse a connection outright, so it no longer tries to. +async function connect(addr) { + const pinned = store.serverPin(addr.host); + const timeoutMs = store.timeoutSeconds() * 1000; + const stream = await connectStream(addr.host, addr.port, timeoutMs); + const opened = await openSession(stream, addr.host, pinned, timeoutMs); + if (!pinned) + status(t("connect-unpinned-warning", { host: addr.host, key: b32encode(opened.serverStatic) }), true); + return opened; +} + +// --- identity setup ------------------------------------------------------------ + +// Nothing in the top bar works before an identity exists, so it stays hidden +// rather than offering buttons that can only answer "no identity yet". +function showSetup() { + $("setup").hidden = false; + $("app").hidden = true; + $("search").hidden = true; + $("top-actions").hidden = true; + $("account").textContent = ""; +} + +function showApp() { + $("setup").hidden = true; + $("app").hidden = false; + $("search").hidden = false; + $("top-actions").hidden = false; + renderAccount(); + renderFolder(); +} + +// Generate-or-recover an identity, establish trust in a server, then bind a +// name to it — three concerns, three screens, no bleed-through between them. +const SETUP_SECTIONS = { + intro: "setup-intro", + "seed-shown": "setup-seed", + "seed-restore": "setup-restore", + "server-setup": "setup-server", + "account-setup": "setup-account", +}; + +function showSetupStage(stage) { + for (const [key, id] of Object.entries(SETUP_SECTIONS)) $(id).hidden = key !== stage; + if (stage === "server-setup") renderServerScreen(); +} + +// Discards the identity screens 2 and 4 can still walk away from — nothing is +// bound to an account yet, so nothing is lost: the same seed typed again on +// screen 3 regenerates the identical identity. +function discardSetupIdentity() { + store.discardIdentity(); + serverFetchedKey = null; + $("server-host").value = ""; + showSetupStage("intro"); +} + +// A deployment that baked in a server key already knows its own host, so +// screen 4 starts from it rather than asking again. +function enterServerSetup() { + if (!$("server-host").value && PRESET_SERVER) $("server-host").value = PRESET_SERVER.host; + showSetupStage("server-setup"); +} + +$("create-identity").onclick = () => { + const seed = newMaster(); + store.setIdentity(seed); + $("setup-seed-value").textContent = hex(seed); + showSetupStage("seed-shown"); +}; + +$("show-restore").onclick = () => showSetupStage("seed-restore"); + +$("seed-saved").onclick = () => { + enterServerSetup(); +}; +$("seed-back").onclick = () => discardSetupIdentity(); + +// A master secret in either of its two interchangeable encodings: 64 hex +// characters, or the base32 form used everywhere else a key is shown. +function parseSeedText(text) { + text = text.trim(); + if (/^[0-9a-fA-F]{64}$/.test(text)) return unhex(text); + try { + const bytes = b32decode(text); + if (bytes.length === 32) return bytes; + } catch { /* fall through to the error below */ } + throw new SmolError(t("seed-wrong-length")); +} + +$("restore-form").onsubmit = (event) => { + event.preventDefault(); + $("restore-error").textContent = ""; + let seed; + try { + seed = parseSeedText($("restore-master").value); + } catch (err) { + $("restore-error").textContent = err.message; + return; + } + // §2: a master alone does not say which rotation index a server has bound; + // account-setup's "existing address" mode discovers it once a server can be + // asked. Either way the accepted-correspondent set is unknown, so it must + // not overwrite the server's (syncOk: false). + store.restoreMaster(seed, 0); + enterServerSetup(); +}; + +$("restore-back").onclick = () => { + $("restore-master").value = ""; + $("restore-error").textContent = ""; + showSetupStage("intro"); +}; + +// Holds a key fetched this screen visit but not yet pinned, tied to the host +// it came from — editing the host field makes renderServerScreen() stop +// showing it, since it only ever matches the host currently typed. +let serverFetchedKey = null; +// The host screen 4 ends on, carried into screen 5 to build the full address. +let setupHost = null; + +function renderServerScreen() { + const host = $("server-host").value.trim().toLowerCase(); + const pinned = host ? store.serverPin(host) : null; + const fetched = host && serverFetchedKey?.host === host ? serverFetchedKey.key : null; + const key = pinned ?? fetched; + const area = $("server-key-area"); + area.hidden = !key; + area.replaceChildren(); + if (key) { + area.append( + field(t("detail-fingerprint-label"), fingerprint(key)), + field(t("detail-key-label"), b32encode(key)), + el("p", { className: "hint", textContent: t("trust-fingerprint-hint") })); + } + $("server-action").textContent = key ? t("signup-continue-button") : t("signup-fetch-key-button"); + $("server-error").textContent = ""; +} + +$("server-host").oninput = renderServerScreen; + +function setServerBusy(busy) { + for (const node of [$("server-host"), $("server-action"), $("server-back")]) node.disabled = busy; +} + +$("server-form").onsubmit = async (event) => { + event.preventDefault(); + $("server-error").textContent = ""; + let host; + try { + host = cleanHost($("server-host").value); + } catch (err) { + $("server-error").textContent = err.message; + return; + } + const pinned = store.serverPin(host); + const fetched = serverFetchedKey?.host === host ? serverFetchedKey.key : null; + if (pinned || fetched) { + if (!pinned) store.pinServer(host, fetched); + serverFetchedKey = null; + setupHost = host; + showSetupStage("account-setup"); + return; + } + setServerBusy(true); + try { + // Bypassed connect(): its "UNVERIFIED" status toast is right everywhere + // else a session proceeds unpinned, but here the whole point of the + // fetch is that nothing is pinned yet — the toast would just sit over + // the button that is about to pin it. + const timeoutMs = store.timeoutSeconds() * 1000; + const stream = await connectStream(host, DEFAULT_PORT, timeoutMs); + const opened = await openSession(stream, host, null, timeoutMs); + opened.session.stream.close(); + serverFetchedKey = { host, key: opened.serverStatic }; + renderServerScreen(); + } catch (err) { + $("server-error").textContent = err.message; + } finally { + setServerBusy(false); + } +}; + +$("server-back").onclick = () => discardSetupIdentity(); + +let accountMode = "new"; + +function setAccountMode(mode) { + accountMode = mode; + for (const button of document.querySelectorAll("#account-mode button")) + button.classList.toggle("active", button.dataset.mode === mode); + $("account-token-label").hidden = mode !== "new"; + $("account-action").textContent = mode === "new" ? t("signup-register-button") : t("signup-recall-button"); + $("account-error").textContent = ""; +} + +for (const button of document.querySelectorAll("#account-mode button")) + button.onclick = () => setAccountMode(button.dataset.mode); + +function setAccountBusy(busy) { + for (const node of [$("account-user"), $("account-token"), $("account-action"), $("account-back"), + ...document.querySelectorAll("#account-mode button")]) + node.disabled = busy; +} + +$("account-form").onsubmit = async (event) => { + event.preventDefault(); + $("account-error").textContent = ""; + const me = store.identity(); + const button = $("account-action"); + const resting = button.textContent; + let addr; + try { + addr = parseAddress(`${$("account-user").value.trim()}@${setupHost}`); + } catch (err) { + $("account-error").textContent = err.message; + return; + } + setAccountBusy(true); + button.textContent = accountMode === "new" ? t("status-busy-registering") : t("status-busy-restoring"); + try { + if (accountMode === "new") { + const opened = await connect(addr); + try { + await registerOp(opened.session, opened.serverStatic, + { username: addr.user, identity: me, token: $("account-token").value.trim() }); + } finally { + opened.session.stream.close(); + } + store.setAccount(addr); + showApp(); + status(t("status-registered-note")); + } else { + await recallAndDiscoverRotation(store.master(), addr.short); + showApp(); + status(t("status-restored-note", { index: store.rotations() })); + } + } catch (err) { + $("account-error").textContent = err.message; + } finally { + setAccountBusy(false); + button.textContent = resting; + } +}; + +$("account-back").onclick = () => showSetupStage("server-setup"); + +// --- header --------------------------------------------------------------------- + +function renderAccount() { + const me = store.identity(); + const account = store.account(); + $("account").textContent = me + ? (account ? `${account.user}@${account.host}` : t("account-not-registered-value")) + : ""; + $("avatar").textContent = account ? account.user[0].toUpperCase() : "·"; +} + +// --- folders and list --------------------------------------------------------- + +let folder = "inbox"; +let selectedId = null; +let query = ""; +// Bumped per render, so a superseded pass stops appending rows mid-flight. +let renderToken = 0; +// Rows opened per turn: a cold folder yields between batches rather than +// blocking the tab for an X25519 and an Ed25519 verification per message. +const BATCH = 20; + +for (const button of $("folders").querySelectorAll("button")) + button.onclick = () => { + folder = button.dataset.folder; + for (const b of $("folders").querySelectorAll("button")) { + const active = b === button; + b.classList.toggle("active", active); + if (active) b.setAttribute("aria-current", "true"); + else b.removeAttribute("aria-current"); + } + selectedId = null; + resetView(); + renderFolder(); + }; + +// The reader empties when a folder changes, so a stale message cannot linger +// under a different list. +function resetView() { + $("app").classList.remove("reading"); + $("view").replaceChildren(placeholder(folder === "contacts" + ? t("mail-contacts-reader-hint") + : t("mail-empty-title"))); +} + +// Counted from ids and read markers alone, independent of the visible folder — +// leaving the inbox used to blank the badge. +async function updateUnreadBadge() { + $("count-inbox").textContent = (await store.unreadCount()) || ""; + $("count-requests").textContent = (await store.requestsUnreadCount()) || ""; +} + +async function renderFolder() { + const list = $("list"); + updateUnreadBadge(); + // The toolbar carries the current folder's action, not a fixed one. + const contacts = folder === "contacts"; + $("mail-tools").hidden = contacts; + $("import-form").hidden = !contacts; + // The filter never reaches past the open folder, so the field says which one. + const searchLabel = t("mail-search-placeholder", { folder }); + $("search").placeholder = searchLabel; + $("search").setAttribute("aria-label", searchLabel); + if (contacts) return renderContacts(list); + const token = ++renderToken; + const rows = await store.listMessages(folder); // newest first + if (token !== renderToken) return; + list.replaceChildren(); + if (!rows.length) { + list.append(placeholder(folder === "inbox" ? t("mail-inbox-empty-title") + : folder === "requests" ? t("mail-requests-empty-title") : t("mail-sent-empty-title"))); + return void updateListStatus(); + } + for (let offset = 0; offset < rows.length; offset += BATCH) { + if (offset) await new Promise(resolve => setTimeout(resolve)); + if (token !== renderToken) return; + list.append(...rows.slice(offset, offset + BATCH).map(messageRow)); + applyQuery(); + } +} + +// A real button, so every row is reachable and openable from the keyboard. +function messageRow(row) { + const opened = describe(row); + // Both mail tiers have a read state; sent copies were never "unread". + const inbound = folder === "inbox" || folder === "requests"; + const unread = inbound && !store.isRead(row.id); + const item = el("button", { + type: "button", + className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`, + onclick: () => openMessage(row.id), + }); + if (row.id === selectedId) item.setAttribute("aria-current", "true"); + const who = el("div", { className: "who", + textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") }); + if (inbound && !opened.error && !opened.knownSender) + who.append(el("span", { className: "dot", title: t("mail-sender-unbound-tooltip") })); + item.append(who, + el("div", { className: "mid" }, + el("span", { className: "subject", textContent: opened.error + ? t("mail-row-unreadable-error", { error: opened.error }) : (opened.subject || t("mail-no-subject")) }), + el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })), + el("div", { className: "when", + textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) })); + // Sender or recipient, their key, the subject and the whole body — not the + // snippet, and not the timestamp. + item.dataset.search = (opened.error + ? `${row.recipient ?? ""} ${opened.error}` + : [row.recipient ?? "", opened.from, b32encode(opened.sender), opened.subject, opened.body].join(" ") + ).toLowerCase(); + return item; +} + +// Filters the current folder client-side; nothing leaves the page. Each row +// carries its own haystack, built from the fields worth matching rather than +// from whatever the row happens to render. Re-applied after every render, so a +// redraw cannot silently drop it. +function applyQuery() { + for (const item of $("list").querySelectorAll(".item")) + item.hidden = Boolean(query) && !(item.dataset.search ?? "").includes(query); + updateListStatus(); +} + +// The toolbar's right side, which also makes a narrowed search visible. +function updateListStatus() { + const items = [...$("list").querySelectorAll(".item")]; + const shown = items.filter(item => !item.hidden).length; + $("list-status").textContent = !items.length ? "" + : shown === items.length + ? t(items.length === 1 ? "mail-list-count-one" : "mail-list-count-other", { count: items.length }) + : t("mail-list-shown-of-total", { shown, total: items.length }); +} + +$("search").oninput = () => { + query = $("search").value.trim().toLowerCase(); + applyQuery(); +}; + +// Opening an envelope costs an X25519 agreement and an Ed25519 verification, and +// an envelope's plaintext never changes — so the result is kept. Failures are +// cached too, so one bad message is not retried on every render. +const openedCache = new Map(); + +function openEnvelope(row) { + let entry = openedCache.get(row.id); + if (!entry) { + try { + const opened = unseal(store.identities(), row.envelope); + const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body)); + entry = { ...opened, fields, body, + subject: fields.subject || "", + snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) }; + } catch (err) { + entry = { error: err.message }; + } + openedCache.set(row.id, entry); + } + return entry; +} + +// What is known about a sender changes as the user binds addresses to keys, so +// this layer sits over the cached envelope and is recomputed per render — it is +// a map lookup, not crypto. The wire carries no sender name, so an unknown key +// can only be shown as its fingerprint until bound, which a signed Reply-To +// field lets the sender do in-band. +function describe(row) { + const opened = openEnvelope(row); + if (opened.error) return opened; + const known = store.addressForKey(opened.sender); + let replyAddress = null; + if (opened.fields["reply-to"]) { + try { + const claimed = parseAddress(opened.fields["reply-to"]); + // Only a full smol:// address whose key matches the signer counts; + // anything else is ordinary text. + if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender)) + replyAddress = claimed; + } catch { /* malformed claim: display, never bind */ } + } + return { + ...opened, + from: known ?? "unknown sender", + // rows stay narrow: the full fingerprint belongs to the reader + fromShort: known ?? `unknown · ${b32encode(opened.sender).slice(0, 4)}`, + knownSender: Boolean(known), + verifiedSender: Boolean(known && store.contact(known)?.verified), + replyAddress, + }; +} + +// A label/value row; `empty` marks a value that is guidance rather than data. +const field = (label, value, empty = false, extra = null) => + el("div", { className: "field" }, + el("span", { className: "field-label", textContent: label }), + el("span", { className: empty ? "field-value empty" : "field-value", textContent: value }), + extra); + +// --- reading ------------------------------------------------------------------ + +async function openMessage(id) { + const row = await store.getMessage(folder, id); + if (!row) return; + selectedId = id; + store.markRead(id); + renderFolder(); + const opened = describe(row); + const view = $("view"); + // Under the narrow breakpoint the reader covers the list, so it needs a way back. + $("app").classList.add("reading"); + const back = el("button", { type: "button", className: "back", + onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("mail-back-to-list-button")); + if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true)); + + // "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content. + const fields = Object.entries(opened.fields).filter(([k]) => k !== "subject" && k !== "accept"); + const actions = el("div", { className: "actions" }); + view.replaceChildren(...[ + back, + el("h2", { textContent: opened.subject || t("mail-no-subject") }), + el("div", { className: "meta", textContent: folder === "sent" + ? t("mail-meta-sent", { recipient: row.recipient, id: row.id, time: fmtTime(row.sentAt) }) + : t("mail-meta-received", { from: opened.from, id: row.id, time: fmtTime(opened.time) }) }), + trustRow(opened), + fields.length ? el("div", { className: "fields", + textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null, + el("pre", { textContent: opened.body }), + actions, + ].filter(node => node != null)); + + // Deleting removes the local copy, and the server's too if "leave mail on + // server" left one there to remove (settings; SPEC.md §10). + actions.append(el("button", { type: "button", onclick: async () => { + try { + await deleteMessage(folder, row); + status(t("status-deleted-note")); + selectedId = null; + resetView(); + renderFolder(); + } catch (err) { + status(err.message, true); // the local copy is kept if a needed server delete failed + } + } }, t("action-delete"))); + + if (folder !== "inbox" && folder !== "requests") return; + + if (folder === "requests" && opened.knownSender) { + const known = store.addressForKey(opened.sender); + if (!store.accepted(known)?.active) { + actions.before(placeholder(t("mail-request-unaccepted-hint", { address: known }))); + actions.append(el("button", { type: "button", onclick: () => acceptContact(known) }, + t("mail-accept-button"))); + } + } + + if (opened.knownSender) { + actions.append(el("button", { type: "button", onclick: () => startReply(row) }, + icon("reply"), t("action-reply"))); + } else if (opened.replyAddress) { + // The sender signed their own smol:// address into the payload, so binding + // it is one click. A pinned conflicting key is refused (§8). + actions.before(placeholder(t("mail-signed-reply-hint", { address: opened.replyAddress.short }))); + actions.append(el("button", { type: "button", textContent: t("mail-save-sender-reply-button"), onclick: () => { + try { + bindReplyAddress(opened.replyAddress, opened.sender); + status(t("contacts-sender-saved-note-verified", { address: opened.replyAddress.short })); + renderFolder(); + openMessage(row.id); + startReply(row); + } catch (err) { + status(err.message, true); + } + } })); + } else { + // The wire gives only a signed key; a reply needs the sender's address, + // which the user supplies once and we bind to that exact key. + const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false }); + const submit = el("button", { textContent: t("mail-name-sender-button") }); + const form = el("form", { className: "name-sender" }, input, submit); + form.onsubmit = async (event) => { + event.preventDefault(); + submit.disabled = true; + try { + await nameSender(input.value, opened.sender); + openMessage(row.id); // the sender is named now; the reply button appears + startReply(row); + } catch (err) { + status(err.message, true); + } finally { + submit.disabled = false; + } + }; + actions.before(placeholder(t("mail-unknown-sender-hint"))); + actions.append(form); + } +} + +// Deletes a message locally, and from the server too if it might still be +// sitting there (only possible when "leave mail on server" was on when it was +// fetched — SPEC.md §10). Sent copies are local-only; there is nothing +// server-side to remove for them (§5.6). Throws, leaving the local copy in +// place, if a needed server-side delete fails — otherwise a message could +// look gone locally while silently persisting on the server. +async function deleteMessage(folder, row) { + if (folder !== "sent" && row.keptOnServer) { + const me = store.identity(); + const addr = accountAddress(); + if (!me || !addr) throw new SmolError(t("mail-delete-not-registered-error")); + const opened = await connect(addr); + try { + await authenticate(opened.session, opened.handshakeHash, addr.user, me); + await deleteOp(opened.session, [unhex(row.id)]); + } finally { + opened.session.stream.close(); + } + } + await store.removeMessage(folder, row.id); +} + +// §4/§8: a signature always verifies here, because unseal() throws otherwise. +// What can be weak is the binding between that key and an address, so that is +// what the chip reports. +function trustRow(opened) { + if (folder === "sent") + return el("div", { className: "trust" }, + el("span", { className: "badge", textContent: t("mail-trust-own-copy") })); + const chip = opened.verifiedSender ? ["badge ok", t("mail-trust-verified-sender")] + : opened.knownSender ? ["badge", t("mail-trust-tofu-sender")] + : ["badge alert", t("mail-trust-unbound-sender")]; + return el("div", { className: "trust" }, + el("span", { className: chip[0], textContent: chip[1] }), + el("div", { className: "keyblock" }, + el("div", { textContent: fingerprint(opened.sender) }), + el("div", { className: "full", textContent: b32encode(opened.sender) }))); +} + +const replySubject = (subject) => + !subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { subject }); + +// One path for all three ways a reply can start, so the Re: rule lives once. +function startReply(row) { + const opened = describe(row); + openCompose({ + to: store.addressForKey(opened.sender) ?? "", + subject: replySubject(opened.subject), + replyTo: row.id, + }); +} + +// Never silently overwrites a different key already held for the same +// address — a self-certifying key is still just a claim until compared +// against what's already trusted. Re-saving an identical key proceeds +// unasked, matching saveContact()'s own "not a rotation" rule. Throws on +// decline, so every call site's existing catch already reports it correctly +// with no further changes needed there. +async function saveContactChecked(address, keyBytes, verified) { + const existing = store.contact(address); + if (existing && !timingSafeEqual(existing.key, keyBytes)) { + const ok = await confirmDialog({ + title: t("key-change-dialog-title"), + body: t("key-change-dialog-body", { address, + known_fingerprint: fingerprint(existing.key), offered_fingerprint: fingerprint(keyBytes) }), + confirmLabel: t("key-change-dialog-replace-button"), + danger: true, + }); + if (!ok) throw new SmolError(t("contacts-key-kept-note", { address })); + } + store.saveContact(address, keyBytes, verified); +} + +// Bind a user-supplied address to the key that signed a message. A smol:// +// address carries its own key (verified); a short address is resolved and the +// result kept on first use. A key that disagrees with this message's sender +// is refused outright; one that disagrees with an address already saved +// under a different key is asked about instead (saveContactChecked). +async function nameSender(text, senderKey) { + const addr = parseAddress(text.trim()); + let key = addr.identity, verified = true; + if (!key) { + const opened = await connect(addr); + try { + ({ identity: key } = await resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + verified = false; // trust on first use, as with any RESOLVE + } + if (!timingSafeEqual(key, senderKey)) + throw new SmolError(t("contacts-sender-key-mismatch-error")); + await saveContactChecked(addr.short, senderKey, verified); + status(t(verified ? "contacts-sender-saved-note-verified" : "contacts-sender-saved-note-unverified", + { address: addr.short })); + renderFolder(); +} + +// A signed Reply-To is the sender's own claim, verified the moment unseal() +// verified the message's signature — no further trust step to gate behind a +// click. Never overwrites an address already pinned to a different key (§8: +// a key change without a rotation chain needs out-of-band confirmation). No +// UI side effects: callers (the reader's button, fetch's auto-bind) decide +// how to report the result and whether many need batching into one summary. +function bindReplyAddress(addr, senderKey) { + const existing = store.contact(addr.short); + if (existing && !timingSafeEqual(existing.key, senderKey)) + throw new SmolError(t("contacts-reply-key-conflict-error", { address: addr.short })); + store.saveContact(addr.short, senderKey, true); +} + +// --- compose and send ----------------------------------------------------------- + +const compose = $("compose"); + +function openCompose({ to = "", subject = "", body = "", replyTo = null } = {}) { + const form = compose.querySelector("form"); + form.to.value = to; + form.subject.value = subject; + form.body.value = body; + form.dataset.replyTo = replyTo ?? ""; + $("compose-error").textContent = ""; + resetUnpinnedPrompt(); + compose.showModal(); +} + +$("write").onclick = () => openCompose(); + +$("compose-cancel").onclick = () => compose.close(); + +// Holds the address a refused send is waiting on — "fetch their key, pin it, +// resend" without losing the draft. Tied to one address at a time: editing +// the recipient away from it retires the card (the oninput handler below). +let unpinnedPrompt = null; // { addressShort, host, port, fetchedKey } + +function resetUnpinnedPrompt() { + unpinnedPrompt = null; + $("compose-unpinned").hidden = true; + $("compose-unpinned-key-area").hidden = true; + $("compose-unpinned-key-area").replaceChildren(); + $("compose-unpinned-fetch").hidden = false; + $("compose-unpinned-pin").hidden = true; + $("compose-unpinned-error").textContent = ""; +} + +function showUnpinnedPrompt(addr) { + // Only #send is disabled mid-flight, not the inputs — if the field no + // longer names this address by the time the refusal comes back, there's + // nothing to recover for. + let live; + try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; } + if (live !== addr.short) return; + unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null }; + $("compose-unpinned-title").textContent = t("compose-pin-title", { host: addr.host }); + $("compose-unpinned-body").textContent = t("compose-pin-body", { address: addr.short }); + $("compose-unpinned").hidden = false; +} + +// Every submission is a send: cancel is a plain button, and Enter in a field +// submits with no submitter — which the old check read as cancel, discarding +// the draft. Factored out from the submit handler so "Pin and send" can +// retry the exact same attempt inline, rather than synthesizing a submit. +async function attemptSend(form) { + const toText = form.to.value; + const send = $("send"); + $("compose-error").textContent = ""; + send.disabled = true; // one click, one envelope: a second seals a second message + try { + await sendMail(toText, form.subject.value, form.body.value, form.dataset.replyTo, + form.elements.anonymous.checked); + compose.close(); + } catch (err) { + if (err instanceof UnpinnedServerError) { + let addr; + try { addr = parseAddress(toText); } catch { addr = null; } + if (addr) { showUnpinnedPrompt(addr); return; } + } + $("compose-error").textContent = err.message; + resetUnpinnedPrompt(); + } finally { + send.disabled = false; + } +} + +compose.querySelector("form").onsubmit = (event) => { + event.preventDefault(); + attemptSend(event.target); +}; + +compose.querySelector("form").elements.to.oninput = () => { + if (!unpinnedPrompt) return; + let short; + try { short = parseAddress(compose.querySelector("form").to.value).short; } catch { short = null; } + if (short !== unpinnedPrompt.addressShort) resetUnpinnedPrompt(); +}; + +$("compose-unpinned-fetch").onclick = async () => { + if (!unpinnedPrompt) return; + const { host, port, addressShort } = unpinnedPrompt; + const button = $("compose-unpinned-fetch"); + button.disabled = true; + $("compose-unpinned-error").textContent = ""; + try { + // Bypasses connect() deliberately, same reasoning as onboarding's server + // screen: its "UNVERIFIED" toast would sit over the button that's about + // to pin the result. + const timeoutMs = store.timeoutSeconds() * 1000; + const stream = await connectStream(host, port, timeoutMs); + const opened = await openSession(stream, host, null, timeoutMs); + opened.session.stream.close(); + if (!unpinnedPrompt || unpinnedPrompt.addressShort !== addressShort) return; // retired mid-fetch + unpinnedPrompt.fetchedKey = opened.serverStatic; + $("compose-unpinned-key-area").hidden = false; + $("compose-unpinned-key-area").replaceChildren( + field(t("detail-fingerprint-label"), fingerprint(opened.serverStatic)), + field(t("detail-key-label"), b32encode(opened.serverStatic)), + el("p", { className: "hint", textContent: t("trust-fingerprint-hint") })); + $("compose-unpinned-fetch").hidden = true; + $("compose-unpinned-pin").hidden = false; + } catch (err) { + $("compose-unpinned-error").textContent = err.message; + } finally { + button.disabled = false; + } +}; + +$("compose-unpinned-pin").onclick = async () => { + if (!unpinnedPrompt?.fetchedKey) return; + const { host, fetchedKey } = unpinnedPrompt; + const button = $("compose-unpinned-pin"); + button.disabled = true; + try { + store.pinServer(host, fetchedKey); // a local write, done inline before the retry + resetUnpinnedPrompt(); + await attemptSend(compose.querySelector("form")); + } catch (err) { + $("compose-unpinned-error").textContent = err.message; + } finally { + button.disabled = false; + } +}; + +async function sendMail(toText, subject, body, replyTo, anonymous) { + const me = store.identity(); + const master = store.master(); + if (!me || !master) throw new SmolError(t("identity-missing-error")); + const addr = parseAddress(toText); + const recipient = await resolveRecipient(addr); + const account = accountAddress(); + const fields = [["Subject", subject], ["In-Reply-To", replyTo]]; + // A signed Reply-To lets a first-time recipient name and answer us (§5.5 + // allows unknown keys); "anonymous" omits it. + if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]); + // §5.8: hand an accepted correspondent the token for our own mailbox, so a + // first reply from them reaches our main tier. + const accepted = store.accepted(addr.short); + if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]); + const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v), + body.replace(/\s+$/, "") + "\n")); + const envelope = seal(me, recipient, bodyBytes); + // §5.8: our token for their mailbox, if they have given us one. + const held = store.tokenFrom(addr.short); + const mac = held ? acceptMac(held, messageId(envelope)) : null; + const opened = await connect(addr); + try { + await sendOp(opened.session, envelope, mac); + } finally { + opened.session.stream.close(); + } + // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. + await store.storeMessage("sent", { + id: hex(messageId(envelope)), + recipient: addr.short, + envelope: seal(me, me.publicKey, bodyBytes), + sentAt: Math.floor(Date.now() / 1000), + }); + status(t(mac ? "mail-sent-note-accepted" : "mail-sent-note", { address: addr.short })); + if (folder === "sent") renderFolder(); +} + +// Thrown only when the key would be learned from a server the user hasn't +// pinned: a bare address, not yet a contact, host unpinned. A distinct class, +// not a SmolError with a particular message, so compose's catch can show the +// recovery card instead of plain text. +class UnpinnedServerError extends SmolError { + constructor(host) { + super(`${host} is not pinned`); + this.host = host; + } +} + +// Prefer a key we already trust; fall back to RESOLVE with trust on first use. +// A smol:// address and an already-known contact are exempt from "require a +// pinned server" below by construction — both return before that check is +// ever reached, which is what keeps the gate from blocking sends it was +// never meant to touch. +async function resolveRecipient(addr) { + if (addr.identity) { + await saveContactChecked(addr.short, addr.identity, true); + return addr.identity; + } + const known = store.contact(addr.short); + if (known) return known.key; + if (store.requirePinnedServer() && !store.serverPin(addr.host)) + throw new UnpinnedServerError(addr.host); + const opened = await connect(addr); + let current; + try { + ({ identity: current } = await resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + store.saveContact(addr.short, current, false); + status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short })); + return current; +} + +// --- fetch ---------------------------------------------------------------------- + +// The address we know a signer by: a contact, or the Reply-To it signed for +// itself. Naming a mailbox is not trusting a key, so nothing is pinned here +// (§5.7, §8). +function addressOfSigner(sender, replyToField) { + const known = store.addressForKey(sender); + if (known) return known; + if (!replyToField) return null; + try { + const parsed = parseAddress(replyToField); + if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short; + } catch { /* malformed claim: no address to learn a token under */ } + return null; +} + +// §5.8: an Accept field is bound to the signer of the message that carried +// it, which unseal() has already verified. +function learnTokenFrom(unsealed, opened) { + const raw = opened.fields["accept"]; + if (!raw) return; + let token; + try { + token = b32decode(raw); + } catch { + return; + } + if (token.length !== TOKEN_LEN) return; + const address = addressOfSigner(unsealed.sender, opened.fields["reply-to"]); + if (address) store.learnToken(address, token); +} + +$("fetch").onclick = async () => { + const me = store.identity(); + const master = store.master(); + const addr = accountAddress(); + if (!me || !master) return status(t("identity-missing-error"), true); + if (!addr) return status(t("account-not-registered-fetch-warning"), true); + const button = $("fetch"), label = button.querySelector(".label"); + const resting = label.textContent; + button.disabled = true; // one session at a time; the icon spins while it is + label.textContent = t("status-busy-fetching"); + status(t("status-busy-fetching")); + let stored = 0, verified = 0; + const rejected = []; + // §10: acknowledging (deleting) is the default; "leave mail on server" pages + // forward by cursor instead, so already-fetched mail is never re-downloaded + // even though it isn't deleted (storeIfNew also dedupes, as a second line + // of defense). + const leaveOnServer = store.leaveOnServer(); + let [afterTime, afterId] = store.cursor(); + try { + const opened = await connect(addr); + try { + const { sync, tokens } = store.tokenSet(master); + await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens }); + for (;;) { + const records = await fetchOp(opened.session, afterTime, afterId); + if (!records.length) break; + const acked = []; + for (const record of records) { + afterTime = record.receivedAt; + afterId = record.id; + let unsealed; + try { + if (!timingSafeEqual(messageId(record.envelope), record.id)) + throw new SmolError(t("mail-envelope-id-mismatch-error")); + unsealed = unseal(store.identities(), record.envelope); + } catch (err) { + // Left on the server rather than destroyed, so a client-side bug + // cannot lose mail. Collected rather than announced, because the + // closing summary would overwrite each one in turn. + rejected.push(`${hex(record.id)}: ${err.message}`); + continue; + } + const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt, + tier: record.isRequest ? store.TIER_REQUESTS : store.TIER_MAIN, keptOnServer: leaveOnServer }; + if (await store.storeIfNew("inbox", row)) { + stored++; + // A signed Reply-To is exactly as verified as the signature + // unseal() just checked — see bindReplyAddress(). describe() + // both parses the same claim the reader would show and warms its + // cache, so opening the message right after costs nothing extra. + const described = describe(row); + if (described.replyAddress) { + try { + bindReplyAddress(described.replyAddress, unsealed.sender); + verified++; + } catch { /* already known under a different key: leave it be */ } + } + learnTokenFrom(unsealed, described); + } + acked.push(record.id); + } + if (leaveOnServer) { + // Persisted per batch, so an interrupted fetch resumes here rather + // than re-paging from the start next time. + store.setCursor(afterTime, afterId); + } else if (acked.length) { + await deleteOp(opened.session, acked); + } + } + } finally { + opened.session.stream.close(); + } + // Everything acknowledged is deleted, so the next fetch starts fresh; a + // record left on the server (rejected above) simply resurfaces then. + if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN)); + status([t("mail-fetch-summary-counts", { stored, rejected: rejected.length }), + verified ? t(verified === 1 ? "mail-fetch-summary-verified-one" : "mail-fetch-summary-verified-other", + { verified }) : null, + ...rejected.map(line => ` ${line}`), + rejected.length ? t("mail-fetch-summary-left-on-server") : null].filter(Boolean).join("\n"), + rejected.length > 0); + } catch (err) { + status(err.message, true); + } finally { + button.disabled = false; + label.textContent = resting; + } + renderFolder(); +}; + +// --- contacts ------------------------------------------------------------------- + +function renderContacts(list) { + const contacts = store.allContacts(); + list.replaceChildren(...contacts.map(({ address, key, verified, history }) => { + const accepted = store.accepted(address); + const item = el("button", { + type: "button", + className: `item contact${address === selectedId ? " selected" : ""}`, + onclick: () => openContact(address), + }); + if (address === selectedId) item.setAttribute("aria-current", "true"); + item.append( + el("div", { className: "who", textContent: address }), + el("div", { className: "mid" }, + el("div", { textContent: fingerprint(b32decode(key)) }), + el("span", { className: verified ? "badge ok" : "badge", + textContent: t(verified ? "contact-state-verified" : "contact-state-tofu") }), + accepted?.active ? el("span", { className: "badge ok", textContent: t("contact-state-accepted") }) : null, + history.length ? el("span", { className: "badge", + textContent: t("contacts-history-badge", { count: history.length }) }) : null)); + item.dataset.search = + `${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase(); + return item; + })); + if (!contacts.length) + list.append(placeholder(t("contacts-import-empty-hint"))); +} + +// The full key, the address to hand out, and any key this one replaced — the +// row can only afford a fingerprint, and §8 turns on the user being able to +// inspect exactly what changed. +function openContact(address) { + const known = store.contact(address); + if (!known) return; + selectedId = address; + renderFolder(); + const addr = parseAddress(address); + const view = $("view"); + $("app").classList.add("reading"); + + const refresh = el("button", { type: "button", onclick: async (event) => { + event.target.disabled = true; + try { + await refreshContact(address); + } finally { + event.target.disabled = false; + } + } }, icon("refresh"), t("contacts-refresh-button")); + + const isAccepted = Boolean(store.accepted(address)?.active); + const tokenButton = el("button", { type: "button", + textContent: t(isAccepted ? "action-block" : "action-accept"), + onclick: async (event) => { + event.target.disabled = true; + try { + if (isAccepted) await blockContact(address); else await acceptContact(address); + } finally { + event.target.disabled = false; + } + } }); + + view.replaceChildren(...[ + el("button", { type: "button", className: "back", + onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("contacts-caption")), + el("h2", { textContent: address }), + el("div", { className: "trust" }, + el("span", { className: known.verified ? "badge ok" : "badge", + textContent: t(known.verified ? "contact-state-verified" : "contact-state-tofu") })), + field(t("detail-fingerprint-label"), fingerprint(known.key)), + field(t("detail-key-label"), b32encode(known.key)), + field(t("detail-address-label"), addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")), + field(t("detail-server-label"), addr.host, false, + el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge", + textContent: t(store.serverPin(addr.host) ? "contacts-server-pinned-badge" : "contacts-server-not-pinned-badge") })), + el("div", { className: "seed-card" }, + el("h4", { textContent: t("contacts-accept-token-title") }), + el("p", { className: "hint", textContent: t(isAccepted ? "contacts-accepted-hint" : "contacts-unaccepted-hint") }), + el("div", { className: "actions" }, tokenButton)), + known.history.length ? el("section", { className: "history" }, + el("h3", { textContent: t("contacts-history-title", { count: known.history.length }) }), + el("p", { className: "hint", textContent: t("contacts-history-hint") }), + ...known.history.map(({ key, until }) => el("div", { className: "keyblock" }, + el("div", { textContent: fingerprint(b32decode(key)) }), + el("div", { className: "full", textContent: key }), + el("div", { className: "replaced", + textContent: t("contacts-history-replaced", { time: fmtTime(Math.floor(until / 1000)) }) })))) + : null, + el("div", { className: "actions" }, refresh), + ].filter(node => node != null)); +} + +// Re-resolve a contact and apply §8: a valid rotation chain is accepted and +// surfaced; anything else requires out-of-band verification. +async function refreshContact(address) { + try { + const addr = parseAddress(address); + if (addr.identity) throw new SmolError(t("contacts-refresh-embedded-key-error")); + const known = store.contact(addr.short); + const opened = await connect(addr); + let current, chain; + try { + ({ identity: current, chain } = await resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + if (!known) { + store.saveContact(addr.short, current, false); + status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short })); + } else if (timingSafeEqual(known.key, current)) { + status(t("contacts-refresh-unchanged-note", { address: addr.short })); + } else if (walkChain(addr.user, known.key, current, chain)) { + store.saveContact(addr.short, current, known.verified); + status(t("contacts-refresh-rotated-note", { address: addr.short, key: b32encode(current) }), true); + } else { + status(t("contacts-refresh-no-chain-warning", { address: addr.short }), true); + } + } catch (err) { + status(err.message, true); + } + if (folder === "contacts" && selectedId === address) openContact(address); + else renderFolder(); +} + +// --- accept tokens (§5.8) -------------------------------------------------------- + +// An accept or a block only takes effect once the server holds the changed +// set, so it is pushed right away rather than at the next fetch. +async function pushTokens() { + const me = store.identity(); + const master = store.master(); + const addr = accountAddress(); + if (!me || !master) throw new SmolError(t("identity-missing-error")); + if (!addr) { + status(t("account-not-registered-tokens-note"), true); + return 0; + } + const opened = await connect(addr); + try { + const { sync, tokens } = store.tokenSet(master); + return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens }); + } finally { + opened.session.stream.close(); + } +} + +// Admit a contact to the main tier; their token travels in our next message +// to them. +async function acceptContact(address) { + try { + const key = store.contact(address)?.key; + if (!key) throw new SmolError(t("contacts-no-key-error", { address })); + store.accept(address, key); + store.setSyncOk(true); + const held = await pushTokens(); + status(t(held === 1 ? "contacts-accepted-note-one" : "contacts-accepted-note-other", { address, held })); + renderFolder(); + if (folder === "contacts" && selectedId === address) openContact(address); + } catch (err) { + status(err.message, true); + } +} + +// Withdraw a contact's accept token; their mail lands in requests from their +// next message on. +async function blockContact(address) { + try { + store.block(address); + const held = await pushTokens(); + status(t(held === 1 ? "contacts-blocked-note-one" : "contacts-blocked-note-other", { address, held })); + renderFolder(); + if (folder === "contacts" && selectedId === address) openContact(address); + } catch (err) { + status(err.message, true); + } +} + +// --- settings --------------------------------------------------------------------- + +const settings = $("settings"); +const settingsTabs = [...$("settings-tabs").querySelectorAll("button")]; +const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")]; + +function showTab(name) { + for (const button of settingsTabs) { + const active = button.dataset.tab === name; + button.classList.toggle("active", active); + if (active) button.setAttribute("aria-current", "true"); + else button.removeAttribute("aria-current"); + } + for (const panel of settingsPanels) panel.hidden = panel.dataset.panel !== name; + $("settings-panels").scrollTop = 0; +} + +for (const button of settingsTabs) button.onclick = () => showTab(button.dataset.tab); + +function openSettings() { + renderSettings(); + showTab("identity"); + if (!settings.open) settings.showModal(); + // showModal() focuses the first focusable node, which is the close button; + // the current tab says where you are instead. + $("settings-tabs").querySelector("button.active").focus(); +} + +$("settings-open").onclick = () => openSettings(); +$("settings-close").onclick = () => settings.close(); + +$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked); +$("require-pinned-server").onchange = (event) => store.setRequirePinnedServer(event.target.checked); + +// --- settings panels -------------------------------------------------------------- + +// One renderer per panel, all of them cheap, so every mutation below can just +// call this rather than reason about which panel it touched. +function renderSettings() { + $("leave-on-server").checked = store.leaveOnServer(); + $("require-pinned-server").checked = store.requirePinnedServer(); + $("backup-error").textContent = ""; + renderIdentityPanel(); + renderServerPanel(); + renderAdvancedPanel(); +} + +// A reusable Cancel/confirm modal for the advanced panel's two irreversible +// actions. 's own cancel path (Escape, backdrop click) counts as +// Cancel, same as clicking the button. +function confirmDialog({ title, body, confirmLabel, danger = false }) { + return new Promise((resolve) => { + $("confirm-title").textContent = title; + $("confirm-text").textContent = body; + const ok = $("confirm-ok"); + ok.textContent = confirmLabel; + ok.className = danger ? "danger" : "filled"; + const dialog = $("confirm"); + let decided = false; + const finish = (result) => { decided = true; resolve(result); dialog.close(); }; + $("confirm-cancel").onclick = () => finish(false); + ok.onclick = () => finish(true); + dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true }); + dialog.showModal(); + }); +} + +function renderServerPanel() { + const account = store.account(); + const host = account?.host ?? null; + const key = host ? store.serverPin(host) : null; + $("server-connection").replaceChildren( + field(t("settings-field-host"), host ?? t("settings-not-registered-value"), !host, + host ? copyButton(host, "host") : null), + field(t("settings-field-public-key"), key ? b32encode(key) : t("settings-public-key-not-pinned"), !key, + key ? copyButton(b32encode(key), "public key") : null), + el("div", { className: "spin-row" }, + el("div", { className: "spin-text" }, + el("span", { className: "check-title", textContent: t("server-timeout-title") }), + el("span", { className: "hint", textContent: t("server-timeout-subtitle") })), + el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(), + onchange: (event) => { + store.setTimeoutSeconds(event.target.value); + event.target.value = store.timeoutSeconds(); // reflects clamping or an ignored non-numeric edit + } }))); +} + +function renderIdentityPanel() { + const me = store.identity(); + const account = store.account(); + const fields = $("identity-fields"); + fields.replaceChildren(); + const secret = $("identity-secret"); + secret.replaceChildren(); + if (!me) return; + + const address = account ? `${account.user}@${account.host}` : t("settings-not-registered-value"); + const key = b32encode(me.publicKey); + const uri = account ? accountAddress().uri(me.publicKey) : t("settings-uri-claim-hint"); + fields.append( + field(t("settings-field-address"), address, !account, account ? copyButton(address, "address") : null), + field(t("settings-field-key"), key, false, copyButton(key, "key")), + field(t("settings-field-fingerprint"), fingerprint(me.publicKey), false, + copyButton(fingerprint(me.publicKey), "fingerprint")), + field(t("settings-field-uri"), uri, !account, account ? copyButton(uri, "uri") : null)); + + const masterBytes = store.master(); + if (!masterBytes) return; + // Revealed on request rather than on open: the panel a new user is sent to + // first is a poor place to paint the only secret there is across the screen. + const masked = "\u2022".repeat(64); + const seedText = hex(masterBytes); + const seed = el("div", { className: "seed", textContent: masked }); + const reveal = el("button", { type: "button", textContent: t("settings-secret-reveal-button"), + title: t("settings-secret-reveal-tooltip"), + onclick: (event) => { + const showing = seed.textContent !== masked; + seed.textContent = showing ? masked : seedText; + event.currentTarget.textContent = t(showing ? "settings-secret-reveal-button" : "settings-secret-hide-button"); + event.currentTarget.title = t(showing ? "settings-secret-reveal-tooltip" : "settings-secret-hide-tooltip"); + } }); + + secret.append(el("div", { className: "seed-card" }, + el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")), + el("p", { textContent: t("settings-secret-warning") }))); +} + +function renderAdvancedPanel() { + $("rotations-count").replaceChildren(field(t("advanced-rotations-completed"), String(store.rotations()))); + $("rotate-error").textContent = ""; + $("logout-error").textContent = ""; +} + +// A pin is keyed by bare host — connect() strips the port before looking one +// up (parseAddress splits user@host:port into three), so "user@host" or +// "host:port" here would silently pin a string nothing ever looks up again, +// exactly the mistake that produced "pinned
" followed by a +// confusing "no pinned key" from recall right after. Shared by the settings +// pin form and the onboarding server screen. +function cleanHost(hostText) { + const host = hostText.trim().toLowerCase(); + if (host.includes("@")) + throw new SmolError(t("signup-host-looks-like-address-error", { host })); + if (host.includes(":")) + throw new SmolError(t("signup-host-has-port-error", { host })); + return host; +} + +$("import-form").onsubmit = async (event) => { + event.preventDefault(); + const form = event.target; + try { + const addr = parseAddress(form.uri.value.trim()); + if (!addr.identity) throw new SmolError(t("contacts-import-needs-key")); + await saveContactChecked(addr.short, addr.identity, true); + status(t("contacts-import-detail-note", + { address: addr.short, key: b32encode(addr.identity), fingerprint: fingerprint(addr.identity) })); + renderFolder(); + form.reset(); + } catch (err) { + status(err.message, true); + } +}; + +// §2: a master alone does not say which rotation index a server has bound, +// so restoring resolves the address and walks indices 0..MAX_CHAIN until one +// derives the key RESOLVE returned, correcting restore-identity's default +// guess of index 0. Also binds "account" — onboarding's only path to it now. +async function recallAndDiscoverRotation(masterBytes, text) { + const addr = parseAddress(text.trim()); + const opened = await connect(addr); + let current; + try { + ({ identity: current } = await resolveOp(opened.session, addr.user)); + } finally { + opened.session.stream.close(); + } + let found = null; + for (let n = 0; n <= MAX_CHAIN; n++) { + if (timingSafeEqual(identityFromSeed(identitySeed(masterBytes, n)).publicKey, current)) { + found = n; + break; + } + } + if (found === null) + throw new SmolError(t("recall-rotation-not-found-error", { address: addr.short, max: MAX_CHAIN })); + store.setRotationIndex(found); + store.setAccount(addr); + return addr; +} + +// The File System Access API gives a real save dialog with a suggested name; +// where it is unavailable (Firefox, Safari), a plain download is the +// equivalent a browser can offer. Either way nothing is shown on success — +// the file landing where the user put it is the confirmation. +$("export-data").onclick = async () => { + $("backup-error").textContent = ""; + try { + const data = await store.exportData(); + const stamp = new Date(data.exportedAt).toISOString().slice(0, 10); + const filename = `gsmol-export-${stamp}.json`; + const text = JSON.stringify(data); + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ suggestedName: filename, + types: [{ description: t("backup-export-dialog-title"), accept: { "application/json": [".json"] } }] }); + const writable = await handle.createWritable(); + await writable.write(text); + await writable.close(); + } else { + const blob = new Blob([text], { type: "application/json" }); + const url = URL.createObjectURL(blob); + Object.assign(document.createElement("a"), { href: url, download: filename }).click(); + URL.revokeObjectURL(url); + } + } catch (err) { + if (err.name !== "AbortError") $("backup-error").textContent = err.message; // AbortError: picker cancelled + } +}; + +// Shared by both the File System Access path and the +// fallback it reduces to where that API is unavailable. +async function importBackupText(text) { + if (!text.trim()) return; // empty or whitespace-only content does nothing + await store.importData(JSON.parse(text)); + renderSettings(); + renderFolder(); +} + +$("import-data-trigger").onclick = async () => { + $("backup-error").textContent = ""; + if (!window.showOpenFilePicker) return $("import-data").click(); + try { + const [handle] = await window.showOpenFilePicker({ + types: [{ description: t("backup-import-dialog-title"), accept: { "application/json": [".json"] } }] }); + await importBackupText(await (await handle.getFile()).text()); + } catch (err) { + if (err.name !== "AbortError") $("backup-error").textContent = err.message; + } +}; + +$("import-data").onchange = async (event) => { + const file = event.target.files[0]; + event.target.value = ""; // same file re-selected twice must still fire change + if (!file) return; + try { + await importBackupText(await file.text()); + } catch (err) { + $("backup-error").textContent = err.message; + } +}; + +// A confirm dialog's body repeats its group's description verbatim, read +// straight from the panel rather than duplicated here so the two can never +// drift apart. textContent carries the HTML source's line-wrap whitespace, +// which this collapses back to plain prose. +const panelText = (id) => $(id).textContent.replace(/\s+/g, " ").trim(); + +// §7: rotate to the next index's derived key and rebind the account with a +// signed certificate. The superseded key stays derivable from the master, +// since mail sealed to it stays readable with nothing else. +async function rotateIdentity() { + const me = store.identity(); + const master = store.master(); + const addr = accountAddress(); + $("rotate-error").textContent = ""; + if (!me || !master || !addr) { + $("rotate-error").textContent = t("advanced-rotate-needs-account-error"); + return; + } + const confirmed = await confirmDialog({ + title: t("rotate-dialog-title"), body: panelText("rotations-description"), + confirmLabel: t("rotate-dialog-confirm-button") }); + if (!confirmed) return; + const button = $("rotate-identity"); + const resting = button.textContent; + button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert + button.textContent = t("status-busy-rotating"); + try { + const freshSeed = identitySeed(master, store.rotations() + 1); + const fresh = identityFromSeed(freshSeed); + const cert = makeCert(addr.user, me, freshSeed); + const opened = await connect(addr); + try { + await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert }); + } finally { + opened.session.stream.close(); + } + store.advanceRotation(); + // The key set grew, so a cached "not one of our keys" failure may be stale. + openedCache.clear(); + renderAccount(); + renderSettings(); + renderFolder(); + } catch (err) { + $("rotate-error").textContent = err.message; + } finally { + button.disabled = false; + button.textContent = resting; + } +} +$("rotate-identity").onclick = rotateIdentity; + +// Unlike rotate, nothing is kept: this is the master's only local copy, gone. +async function logout() { + $("logout-error").textContent = ""; + const confirmed = await confirmDialog({ title: t("reset-dialog-title"), + body: panelText("logout-description"), confirmLabel: t("reset-dialog-confirm-button"), danger: true }); + if (!confirmed) return; + try { + await store.clearAll(); + location.reload(); + } catch (err) { + $("logout-error").textContent = err.message; + } +} +$("logout").onclick = logout; + +// --- 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(); + +// An identity not yet bound to an account always resumes at the server +// screen, however it was reached — there is nothing a banner needs to remind +// the user of afterward, since showApp() is unreachable until both are done. +if (!store.identity()) { + showSetupStage("intro"); + showSetup(); +} else if (!store.account()) { + enterServerSetup(); + showSetup(); +} else { + showApp(); +} 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" } diff --git a/web/js/crypto.js b/web/js/crypto.js new file mode 100644 index 0000000..d897099 --- /dev/null +++ b/web/js/crypto.js @@ -0,0 +1,437 @@ +// Dependency-free primitives for Smol Mail (SPEC.md §1): SHA-256, SHA-512, +// HMAC/HKDF-SHA256, ChaCha20-Poly1305, X25519, Ed25519, and the §2 key +// conversions between the two curves. Pure JS so the same code runs in the +// browser and in Node for testing against the reference implementation. + +const utf8 = new TextEncoder(); + +export function concat(...parts) { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.length; } + return out; +} + +export function utf8Bytes(text) { return utf8.encode(text); } + +export function hex(bytes) { + return [...bytes].map(b => b.toString(16).padStart(2, "0")).join(""); +} + +export function unhex(text) { + if (text.length % 2) throw new Error(`odd-length hex string: ${text}`); + const out = new Uint8Array(text.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export function leBytesToBigInt(bytes) { + let n = 0n; + for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); + return n; +} + +export function bigIntToLeBytes(value, length) { + const out = new Uint8Array(length); + for (let i = 0; i < length; i++) { out[i] = Number(value & 0xffn); value >>= 8n; } + return out; +} + +export function randomBytes(n) { + const out = new Uint8Array(n); + crypto.getRandomValues(out); + return out; +} + +export function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +// --- SHA-256 ---------------------------------------------------------------- + +const K256 = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotl32 = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0; +const rotr32 = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0; + +export function sha256(message) { + const padded = new Uint8Array((((message.length + 9) + 63) >> 6 << 6)); + padded.set(message); + padded[message.length] = 0x80; + const bits = BigInt(message.length) * 8n; + new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); + + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + const view = new DataView(padded.buffer); + for (let off = 0; off < padded.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K256[i] + w[i]) >>> 0; + const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; + d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + const add = [a, b, c, d, e, f, g, hh]; + for (let i = 0; i < 8; i++) h[i] = (h[i] + add[i]) >>> 0; + } + const out = new Uint8Array(32); + for (let i = 0; i < 8; i++) new DataView(out.buffer).setUint32(i * 4, h[i]); + return out; +} + +// --- HMAC-SHA256 and HKDF (RFC 2104, RFC 5869) ------------------------------- + +export function hmacSha256(key, message) { + let block = new Uint8Array(64).fill(0x36); + for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; + const inner = sha256(concat(block, message)); + block = new Uint8Array(64).fill(0x5c); + for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; + return sha256(concat(block, inner)); +} + +export function hkdfSha256(ikm, salt, info, length = 32) { + const prk = hmacSha256(salt, ikm); + let out = new Uint8Array(0), block = new Uint8Array(0), counter = 1; + while (out.length < length) { + block = hmacSha256(prk, concat(block, info, Uint8Array.of(counter))); + out = concat(out, block); + counter++; + } + return out.slice(0, length); +} + +// --- SHA-512 (Ed25519 only) -------------------------------------------------- + +const M64 = (1n << 64n) - 1n; +const K512 = [ + 0x428a2f98d728ae22n, 0x7137449123ef65cdn, 0xb5c0fbcfec4d3b2fn, 0xe9b5dba58189dbbcn, + 0x3956c25bf348b538n, 0x59f111f1b605d019n, 0x923f82a4af194f9bn, 0xab1c5ed5da6d8118n, + 0xd807aa98a3030242n, 0x12835b0145706fben, 0x243185be4ee4b28cn, 0x550c7dc3d5ffb4e2n, + 0x72be5d74f27b896fn, 0x80deb1fe3b1696b1n, 0x9bdc06a725c71235n, 0xc19bf174cf692694n, + 0xe49b69c19ef14ad2n, 0xefbe4786384f25e3n, 0x0fc19dc68b8cd5b5n, 0x240ca1cc77ac9c65n, + 0x2de92c6f592b0275n, 0x4a7484aa6ea6e483n, 0x5cb0a9dcbd41fbd4n, 0x76f988da831153b5n, + 0x983e5152ee66dfabn, 0xa831c66d2db43210n, 0xb00327c898fb213fn, 0xbf597fc7beef0ee4n, + 0xc6e00bf33da88fc2n, 0xd5a79147930aa725n, 0x06ca6351e003826fn, 0x142929670a0e6e70n, + 0x27b70a8546d22ffcn, 0x2e1b21385c26c926n, 0x4d2c6dfc5ac42aedn, 0x53380d139d95b3dfn, + 0x650a73548baf63den, 0x766a0abb3c77b2a8n, 0x81c2c92e47edaee6n, 0x92722c851482353bn, + 0xa2bfe8a14cf10364n, 0xa81a664bbc423001n, 0xc24b8b70d0f89791n, 0xc76c51a30654be30n, + 0xd192e819d6ef5218n, 0xd69906245565a910n, 0xf40e35855771202an, 0x106aa07032bbd1b8n, + 0x19a4c116b8d2d0c8n, 0x1e376c085141ab53n, 0x2748774cdf8eeb99n, 0x34b0bcb5e19b48a8n, + 0x391c0cb3c5c95a63n, 0x4ed8aa4ae3418acbn, 0x5b9cca4f7763e373n, 0x682e6ff3d6b2b8a3n, + 0x748f82ee5defb2fcn, 0x78a5636f43172f60n, 0x84c87814a1f0ab72n, 0x8cc702081a6439ecn, + 0x90befffa23631e28n, 0xa4506cebde82bde9n, 0xbef9a3f7b2c67915n, 0xc67178f2e372532bn, + 0xca273eceea26619cn, 0xd186b8c721c0c207n, 0xeada7dd6cde0eb1en, 0xf57d4f7fee6ed178n, + 0x06f067aa72176fban, 0x0a637dc5a2c898a6n, 0x113f9804bef90daen, 0x1b710b35131c471bn, + 0x28db77f523047d84n, 0x32caab7b40c72493n, 0x3c9ebe0a15c9bebcn, 0x431d67c49c100d4cn, + 0x4cc5d4becb3e42b6n, 0x597f299cfc657e2an, 0x5fcb6fab3ad6faecn, 0x6c44198c4a475817n, +]; + +export function sha512(message) { + const padded = new Uint8Array((((message.length + 17) + 127) >> 7 << 7)); + padded.set(message); + padded[message.length] = 0x80; + const bits = BigInt(message.length) * 8n; + new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); + + let h = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n]; + const rotr = (x, n) => ((x >> BigInt(n)) | (x << (64n - BigInt(n)))) & M64; + const view = new DataView(padded.buffer); + const w = new Array(80); + for (let off = 0; off < padded.length; off += 128) { + for (let i = 0; i < 16; i++) w[i] = view.getBigUint64(off + i * 8); + for (let i = 16; i < 80; i++) { + const s0 = rotr(w[i - 15], 1) ^ rotr(w[i - 15], 8) ^ (w[i - 15] >> 7n); + const s1 = rotr(w[i - 2], 19) ^ rotr(w[i - 2], 61) ^ (w[i - 2] >> 6n); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & M64; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 80; i++) { + const S1 = rotr(e, 14) ^ rotr(e, 18) ^ rotr(e, 41); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K512[i] + w[i]) & M64; + const S0 = rotr(a, 28) ^ rotr(a, 34) ^ rotr(a, 39); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) & M64; + hh = g; g = f; f = e; e = (d + t1) & M64; + d = c; c = b; b = a; a = (t1 + t2) & M64; + } + const sum = [a, b, c, d, e, f, g, hh]; + h = h.map((v, i) => (v + sum[i]) & M64); + } + const out = new Uint8Array(64); + for (let i = 0; i < 8; i++) new DataView(out.buffer).setBigUint64(i * 8, h[i], false); + return out; +} + +// --- ChaCha20-Poly1305 AEAD (RFC 8439) -------------------------------------- + +function chachaBlock(key, counter, nonce) { + const state = new Uint32Array(16); + state.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); + const kview = new DataView(key.buffer); + for (let i = 0; i < 8; i++) state[4 + i] = kview.getUint32(i * 4, true); + state[12] = counter >>> 0; + const nview = new DataView(nonce.buffer); + for (let i = 0; i < 3; i++) state[13 + i] = nview.getUint32(i * 4, true); + const x = Uint32Array.from(state); + const qr = (a, b, c, d) => { + x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 16); + x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 12); + x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 8); + x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 7); + }; + for (let i = 0; i < 10; i++) { + qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15); + qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14); + } + const out = new Uint8Array(64); + const view = new DataView(out.buffer); + for (let i = 0; i < 16; i++) view.setUint32(i * 4, (x[i] + state[i]) >>> 0, true); + return out; +} + +function chacha20Xor(key, counter, nonce, data) { + const out = new Uint8Array(data.length); + for (let off = 0; off < data.length; off += 64) { + const stream = chachaBlock(key, counter + (off / 64), nonce); + const n = Math.min(64, data.length - off); + for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ stream[i]; + } + return out; +} + +// Poly1305 over BigInt; correctness over speed, messages here stay small. +function poly1305(key, message) { + const P1305 = (1n << 130n) - 5n; + const r = leBytesToBigInt(key.slice(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn; + const s = leBytesToBigInt(key.slice(16, 32)); + let acc = 0n; + for (let off = 0; off < message.length; off += 16) { + const block = message.slice(off, Math.min(off + 16, message.length)); + acc = ((acc + leBytesToBigInt(block) + (1n << BigInt(block.length * 8))) * r) % P1305; + } + return bigIntToLeBytes((acc + s) & ((1n << 128n) - 1n), 16); +} + +export function aeadEncrypt(key, nonce, plaintext, aad) { + const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); + const ciphertext = chacha20Xor(key, 1, nonce, plaintext); + const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); + const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); + const mac = poly1305(polyKey, concat( + aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); + return concat(ciphertext, mac); +} + +export function aeadDecrypt(key, nonce, sealed, aad) { + if (sealed.length < 16) throw new Error("ciphertext shorter than the Poly1305 tag"); + const ciphertext = sealed.slice(0, sealed.length - 16); + const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); + const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); + const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); + const expect = poly1305(polyKey, concat( + aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); + if (!timingSafeEqual(expect, sealed.slice(sealed.length - 16))) + throw new Error("decryption failed: bad Poly1305 tag"); + return chacha20Xor(key, 1, nonce, ciphertext); +} + +// --- X25519 (RFC 7748) ------------------------------------------------------- + +const P_ED = (1n << 255n) - 19n; + +function mod(value, p = P_ED) { return ((value % p) + p) % p; } + +function powMod(base, exponent, p = P_ED) { + let out = 1n; + base = mod(base, p); + while (exponent > 0n) { + if (exponent & 1n) out = out * base % p; + base = base * base % p; + exponent >>= 1n; + } + return out; +} + +function clampScalar(scalar) { + const k = Uint8Array.from(scalar); + k[0] &= 248; k[31] &= 127; k[31] |= 64; + return k; +} + +function x25519Raw(scalar, u) { + const k = leBytesToBigInt(clampScalar(scalar)); + const x1 = leBytesToBigInt(u) & ((1n << 255n) - 1n); + const a24 = 121665n; + let x2 = 1n, z2 = 0n, x3 = x1, z3 = 1n, swap = 0n; + for (let t = 254n; t >= 0n; t--) { + const kt = (k >> t) & 1n; + swap ^= kt; + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + swap = kt; + const a = mod(x2 + z2), aa = a * a % P_ED; + const b = mod(x2 - z2), bb = b * b % P_ED; + const e = mod(aa - bb); + const c = mod(x3 + z3), d = mod(x3 - z3); + const da = d * a % P_ED, cb = c * b % P_ED; + x3 = mod(da + cb) ** 2n % P_ED; + z3 = x1 * mod(da - cb) ** 2n % P_ED; + x2 = aa * bb % P_ED; + z2 = e * mod(aa + a24 * e) % P_ED; + } + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + return x2 * powMod(z2, P_ED - 2n) % P_ED; +} + +// §2's low-order rejection: a clamped scalar is a multiple of 8, so any +// low-order peer point yields an all-zero shared secret — rejecting the zero +// output rejects all of them. +export function x25519(scalar, peerPublic) { + const shared = bigIntToLeBytes(x25519Raw(scalar, peerPublic), 32); + if (shared.every(b => b === 0)) throw new Error("rejected low-order key agreement point"); + return shared; +} + +export function x25519Base(scalar) { + return bigIntToLeBytes(x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), 32); +} + +// --- Ed25519 (RFC 8032) ------------------------------------------------------ + +const L_ED = (1n << 252n) + 27742317777372353535851937790883648493n; +const D_ED = mod(-121665n * powMod(121666n, P_ED - 2n)); +const B_ED = { x: 15112221349535400772501151409588531511454012693041857206046113283949847762202n, + y: mod(4n * powMod(5n, P_ED - 2n)) }; +const IDENTITY = { x: 0n, y: 1n, z: 1n, t: 0n }; + +const toProjective = ({ x, y }) => ({ x, y, z: 1n, t: mod(x * y) }); + +function pointAdd(p, q) { + const a = mod(p.y - p.x) * mod(q.y - q.x) % P_ED; + const b = mod(p.y + p.x) * mod(q.y + q.x) % P_ED; + const c = 2n * p.t * q.t % P_ED * D_ED % P_ED; + const d = 2n * p.z * q.z % P_ED; + const e = mod(b - a), f = mod(d - c), g = mod(d + c), h = b + a; + return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; +} + +function pointDouble(p) { + const a = p.x * p.x % P_ED; + const b = p.y * p.y % P_ED; + const c = 2n * p.z * p.z % P_ED; + const d = P_ED - a; // a = -1 on this curve, so d = -A + const e = mod(mod(p.x + p.y) ** 2n - a - b); + const g = mod(d + b); + const f = mod(g - c); + const h = mod(d - b); + return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; +} + +function scalarMult(scalar, point) { + let result = IDENTITY; + for (let t = 254n; t >= 0n; t--) { + result = pointDouble(result); + if ((scalar >> t) & 1n) result = pointAdd(result, point); + } + return result; +} + +function encodePoint(p) { + const zInv = powMod(p.z, P_ED - 2n); + const x = p.x * zInv % P_ED, y = p.y * zInv % P_ED; + const out = bigIntToLeBytes(y, 32); + out[31] |= Number(x & 1n) << 7; + return out; +} + +function decodePoint(bytes) { + if (bytes.length !== 32) throw new Error("Ed25519 public key must be 32 bytes"); + const sign = bytes[31] >> 7; + const y = leBytesToBigInt(bytes) & ((1n << 255n) - 1n); + if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); + const u = mod(y * y - 1n), v = mod(D_ED * y * y + 1n); + const v2 = v * v % P_ED, v3 = v2 * v % P_ED, v4 = v2 * v2 % P_ED; + let x = u * v3 % P_ED * powMod(u * v4 % P_ED * v3 % P_ED, (P_ED - 5n) / 8n) % P_ED; + if (mod(v * x % P_ED * x) !== u) { + if (mod(v * x % P_ED * x) === mod(-u)) x = x * powMod(2n, (P_ED - 1n) / 4n) % P_ED; + else throw new Error("not a point on the Ed25519 curve"); + } + if (x === 0n && sign) throw new Error("invalid sign bit on x = 0"); + if (Number(x & 1n) !== sign) x = P_ED - x; + return { x, y }; +} + +function seedToScalar(seed) { + const h = sha512(seed); + return leBytesToBigInt(clampScalar(h.slice(0, 32))); +} + +export function ed25519PublicKey(seed) { + if (seed.length !== 32) throw new Error("identity seed must be 32 bytes"); + return encodePoint(scalarMult(seedToScalar(seed), toProjective(B_ED))); +} + +export function ed25519Sign(seed, message) { + const h = sha512(seed); + const a = leBytesToBigInt(clampScalar(h.slice(0, 32))); + const publicKey = encodePoint(scalarMult(a, toProjective(B_ED))); + const r = leBytesToBigInt(sha512(concat(h.slice(32), message))) % L_ED; + const rEnc = encodePoint(scalarMult(r, toProjective(B_ED))); + const k = leBytesToBigInt(sha512(concat(rEnc, publicKey, message))) % L_ED; + return concat(rEnc, bigIntToLeBytes((r + k * a) % L_ED, 32)); +} + +export function ed25519Verify(publicKey, message, signature) { + try { + const a = toProjective(decodePoint(publicKey)); + const r = toProjective(decodePoint(signature.slice(0, 32))); + const s = leBytesToBigInt(signature.slice(32, 64)); + if (signature.length !== 64 || s >= L_ED) return false; + const k = leBytesToBigInt(sha512(concat(signature.slice(0, 32), publicKey, message))) % L_ED; + const lhs = scalarMult(s, toProjective(B_ED)); + const rhs = pointAdd(scalarMult(k, a), r); + return lhs.x * rhs.z % P_ED === rhs.x * lhs.z % P_ED + && lhs.y * rhs.z % P_ED === rhs.y * lhs.z % P_ED; + } catch { + return false; + } +} + +// --- §2 conversions between the identity key and X25519 ---------------------- + +export function ed25519ToX25519(publicKey) { + const y = leBytesToBigInt(publicKey) & ((1n << 255n) - 1n); + if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); + if (mod(1n - y) === 0n) throw new Error("identity element has no X25519 image"); + return bigIntToLeBytes(mod(1n + y) * powMod(1n - y, P_ED - 2n) % P_ED, 32); +} + +export function ed25519SeedToX25519(seed) { + return clampScalar(sha512(seed).slice(0, 32)); +} diff --git a/web/js/i18n.js b/web/js/i18n.js new file mode 100644 index 0000000..88c7e37 --- /dev/null +++ b/web/js/i18n.js @@ -0,0 +1,31 @@ +// Strings, loaded once from the flat dictionary scripts/build-locale.mjs +// generates from locales/en-US/main.ftl. No Fluent parser ships here — the +// build step already resolved the source file's syntax down to plain +// `{ $var }` placeholders, which is all this needs to substitute. + +let messages = null; + +export async function loadLocale() { + const response = await fetch("locales/en-US.json"); + messages = await response.json(); +} + +// Falls back to the id itself on a miss, so a typo'd or forgotten call +// shows up as visibly wrong text in the UI rather than disappearing. +export function t(id, args = {}) { + const value = messages?.[id]; + if (value === undefined) return id; + return value.replace(/\{\s*\$(\w+)\s*\}/g, (_, name) => args[name] ?? ""); +} + +// index.html's static markup keeps its literal English text and gains +// data-l10n-* attributes alongside it, so the page still reads correctly +// even if this runs late — this pass just overwrites it from the same +// English source, proving the wiring for whenever a second locale exists. +export function applyStaticLocale(root = document) { + for (const el of root.querySelectorAll("[data-l10n-id]")) el.textContent = t(el.dataset.l10nId); + for (const el of root.querySelectorAll("[data-l10n-placeholder]")) el.placeholder = t(el.dataset.l10nPlaceholder); + for (const el of root.querySelectorAll("[data-l10n-title]")) el.title = t(el.dataset.l10nTitle); + for (const el of root.querySelectorAll("[data-l10n-aria-label]")) + el.setAttribute("aria-label", t(el.dataset.l10nAriaLabel)); +} diff --git a/web/js/noise.js b/web/js/noise.js new file mode 100644 index 0000000..771536e --- /dev/null +++ b/web/js/noise.js @@ -0,0 +1,92 @@ +// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics. +// The initiator is anonymous; the responder's static key arrives encrypted in +// message two, which is what server pinning checks. + +import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, utf8Bytes, x25519, x25519Base } from "./crypto.js"; + +const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name +const PROLOGUE = utf8Bytes("smolmail/1"); + +// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE. +function nonce(n) { + const out = new Uint8Array(12); + new DataView(out.buffer).setBigUint64(4, BigInt(n), true); + return out; +} + +// One direction of the post-handshake transport. The key is unique per +// session, so the counter starting at zero is safe. +class CipherState { + constructor(key) { + this.key = key; + this.counter = 0; + } + + encrypt(plaintext) { + const sealed = aeadEncrypt(this.key, nonce(this.counter), plaintext, new Uint8Array(0)); + this.counter++; + return sealed; + } + + decrypt(sealed) { + const plaintext = aeadDecrypt(this.key, nonce(this.counter), sealed, new Uint8Array(0)); + this.counter++; + return plaintext; + } +} + +export class NxInitiator { + constructor() { + this.h = utf8Bytes(PROTOCOL); + this.ck = this.h.slice(); + this.mixHash(PROLOGUE); + this.key = null; + } + + mixHash(data) { + this.h = sha256(concat(this.h, data)); + } + + mixKey(ikm) { + const okm = hkdfSha256(ikm, this.ck, new Uint8Array(0), 64); + this.ck = okm.slice(0, 32); + this.key = okm.slice(32); + } + + // Message one is just our ephemeral public key. No key is set yet, so the + // empty payload travels in the clear — and is still mixed into h. + // `esk` is pinned only by the test vectors, like the spec's fixed-ephemeral + // envelope. + writeMessage1(esk) { + this.esk = esk ?? randomBytes(32); + this.epk = x25519Base(this.esk); + this.mixHash(this.epk); + this.mixHash(new Uint8Array(0)); + return this.epk; + } + + // Message two: e (plaintext), ee, then the responder's static and the + // (empty) payload as AEAD ciphertexts chained through h. Each MixKey + // restarts the nonce at zero. + readMessage2(message) { + if (message.length !== 32 + 48 + 16) + throw new Error(`unexpected NX message length ${message.length}`); + const re = message.slice(0, 32); + this.mixHash(re); + this.mixKey(x25519(this.esk, re)); + this.serverStatic = this.decryptAndHash(message.slice(32, 80)); + this.mixKey(x25519(this.esk, this.serverStatic)); // es + const payload = this.decryptAndHash(message.slice(80)); + if (payload.length !== 0) throw new Error("unexpected payload in handshake"); + this.handshakeHash = this.h; + // Split(): two transport keys from the final chaining key, zero-length ikm + const okm = hkdfSha256(new Uint8Array(0), this.ck, new Uint8Array(0), 64); + return { send: new CipherState(okm.slice(0, 32)), recv: new CipherState(okm.slice(32)) }; + } + + decryptAndHash(sealed) { + const plaintext = aeadDecrypt(this.key, nonce(0), sealed, this.h); + this.mixHash(sealed); + return plaintext; + } +} diff --git a/web/js/proto.js b/web/js/proto.js new file mode 100644 index 0000000..7499268 --- /dev/null +++ b/web/js/proto.js @@ -0,0 +1,481 @@ +// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed and +// signed envelopes, body frontmatter, key rotation, accept tokens, and the +// framed request and response bodies of the five operations. + +import { + aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519, + ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, hmacSha256, randomBytes, sha256, + timingSafeEqual, utf8Bytes, x25519, x25519Base, +} from "./crypto.js"; +import { NxInitiator } from "./noise.js"; + +export const DEFAULT_PORT = 1961; +export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 200, ID_LEN = 32, TOKEN_LEN = 32; +export const MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024; +export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16; +export const MAX_SKEW = 86400; // §5.3: how far ahead of our clock a payload may be dated +export const FLAG_REQUESTS = 0x01; // §6.1: set when a FETCH record missed an accept token +const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64; + +export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 }; +export const STATUS = { 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", + 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", + 8: "rate limited", 9: "not permitted", 10: "internal error" }; + +export class SmolError extends Error {} + +const LABEL = Object.freeze({ + auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"), + msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"), + rotate: utf8Bytes("smolmail/1 rotate"), identity: utf8Bytes("smolmail/1 identity"), + accept: utf8Bytes("smolmail/1 accept"), mac: utf8Bytes("smolmail/1 mac"), + register: utf8Bytes("smolmail/1 register"), +}); + +// --- encoding helpers ------------------------------------------------------- + +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function b32encode(bytes) { + let out = "", value = 0, bits = 0; + for (const b of bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { bits -= 5; out += B32[(value >>> bits) & 31]; } + } + if (bits) out += B32[(value << (5 - bits)) & 31]; + return out.toLowerCase(); +} + +export function b32decode(text) { + let out = [], value = 0, bits = 0; + for (const ch of text.trim().toUpperCase().replace(/=+$/, "")) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new SmolError(`invalid base32 character '${ch}'`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { bits -= 8; out.push((value >>> bits) & 0xff); } + } + return Uint8Array.from(out); +} + +// §3: the first 20 base32 characters of the identity, in groups of four. +export function fingerprint(identity) { + const s = b32encode(identity).slice(0, 20); + return s.match(/.{4}/g).join(" "); +} + +export const u16BE = (n) => Uint8Array.of((n >> 8) & 0xff, n & 0xff); + +export const u32BE = (n) => Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff); + +export const i64BE = (n) => { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigInt64(0, BigInt(n), false); + return out; +}; + +export const nowSeconds = () => Math.floor(Date.now() / 1000); + +// Fail-closed reader; every parse raises rather than reading past the end. +export class Reader { + constructor(buf) { this.buf = buf; this.pos = 0; } + take(n) { + if (n < 0 || this.pos + n > this.buf.length) throw new SmolError("truncated message"); + this.pos += n; + return this.buf.slice(this.pos - n, this.pos); + } + u8() { return this.take(1)[0]; } + u16() { const b = this.take(2); return (b[0] << 8) | b[1]; } + u32() { return new DataView(this.take(4).buffer).getUint32(0); } + i64() { return new DataView(this.take(8).buffer).getBigInt64(0); } + get left() { return this.buf.length - this.pos; } + get done() { return this.pos === this.buf.length; } +} + +// --- identity (§2) ------------------------------------------------------------ + +// An Ed25519 keypair with the X25519 agreement keys derived from it. +export function identityFromSeed(seed) { + if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`); + return { seed, publicKey: ed25519PublicKey(seed) }; +} + +export function newMaster() { + return randomBytes(KEY_LEN); +} + +// §2: the only secret a user holds. Every rotation index's signing seed, and +// the accept key, are derived from it with HKDF. +export function identitySeed(master, index) { + return hkdfSha256(master, new Uint8Array(0), concat(LABEL.identity, u32BE(index))); +} + +export function acceptKeyFor(master) { + return hkdfSha256(master, new Uint8Array(0), LABEL.accept); +} + +// §5.8: the token this account issues to one correspondent, independent of +// the rotation index so it survives the owner's key rotation. +export function tokenFor(master, correspondentIdentity) { + return hmacSha256(acceptKeyFor(master), correspondentIdentity); +} + +// §5.8: what a sender attaches to SEND to reach the recipient's main tier. +export function acceptMac(token, id) { + return hmacSha256(token, concat(LABEL.mac, id)); +} + +// --- addressing (§3) -------------------------------------------------------- + +const ADDRESS = /^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$/; + +export function parseAddress(text) { + text = text.trim(); + let identity = null; + if (text.startsWith("smol://")) { + const rest = text.slice("smol://".length); + const slash = rest.lastIndexOf("/"); + if (slash < 0) throw new SmolError(`${text}: smol:// address carries no key`); + identity = b32decode(rest.slice(slash + 1)); + if (identity.length !== KEY_LEN) + throw new SmolError(`${text}: key is ${identity.length} bytes, expected ${KEY_LEN}`); + text = rest.slice(0, slash); + } + const m = ADDRESS.exec(text.toLowerCase()); + if (!m) throw new SmolError(`'${text}' is not a valid address`); + const { user, host } = m.groups; + if ("._-".includes(user[0]) || "._-".includes(user.at(-1))) + throw new SmolError(`${user} may not begin or end with a separator`); + const port = m.groups.port ? Number(m.groups.port) : DEFAULT_PORT; + return { + user, host, port, identity, + get short() { return `${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}`; }, + uri: (key) => `smol://${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}/${b32encode(key)}`, + }; +} + +// --- message format (§5) ---------------------------------------------------- + +// §5.4: derived from the envelope so no sender can choose it; used whole, +// nothing truncates it. +export function messageId(envelope) { + return sha256(concat(LABEL.id, envelope)); +} + +// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender +// cannot decrypt what they sent; opts.esk exists only so tests can pin it. +export function seal(identity, recipient, body, when = nowSeconds(), opts = {}) { + const esk = opts.esk ?? randomBytes(KEY_LEN); + const epk = x25519Base(esk); + const key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), concat(epk, recipient), LABEL.seal); + const header = concat(Uint8Array.of(1), identity.publicKey, i64BE(when), u32BE(body.length)); + let plaintext = concat(header, body, + ed25519Sign(identity.seed, concat(LABEL.msg, recipient, epk, header, body))); + if (opts.pad !== false) + plaintext = concat(plaintext, new Uint8Array((PAD_TO - plaintext.length % PAD_TO) % PAD_TO)); + const aad = concat(utf8Bytes("SMOL"), Uint8Array.of(1), recipient, epk); + return concat(aad, aeadEncrypt(key, new Uint8Array(12), plaintext, aad)); +} + +// Inverse of seal(); throws unless the signature and the recipient both check +// out. `identities` may include retired keys, per §7. +export function unseal(identities, envelope) { + if (envelope.length < ENVELOPE_HEADER + 16) throw new SmolError("envelope too short"); + if (utf8Bytes("SMOL").some((b, i) => b !== envelope[i])) throw new SmolError("not a Smol Mail envelope"); + if (envelope[4] !== 1) throw new SmolError(`unsupported envelope version ${envelope[4]}`); + const to = envelope.slice(5, 37), epk = envelope.slice(37, 69), sealed = envelope.slice(69); + const me = identities.find(i => timingSafeEqual(i.publicKey, to)); + if (!me) throw new SmolError(`addressed to ${b32encode(to).slice(0, 16)}…, not one of our keys`); + const key = hkdfSha256(x25519(ed25519SeedToX25519(me.seed), epk), concat(epk, to), LABEL.seal); + let plaintext; + try { + plaintext = aeadDecrypt(key, new Uint8Array(12), sealed, envelope.slice(0, ENVELOPE_HEADER)); + } catch { + throw new SmolError("decryption failed: wrong key or corrupt envelope"); + } + const r = new Reader(plaintext); + if (r.u8() !== 1) throw new SmolError("unsupported payload version"); + const sender = r.take(KEY_LEN), when = r.i64(), bodyLen = r.u32(); + if (bodyLen > r.left) throw new SmolError("payload body length exceeds the payload"); + const body = r.take(bodyLen), signature = r.take(SIG_LEN); // trailing bytes are padding + if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature)) + throw new SmolError("signature does not verify"); + if (when > BigInt(nowSeconds() + MAX_SKEW)) throw new SmolError("payload is dated in the future"); + return { sender, time: Number(when), body, id: messageId(envelope) }; +} + +// --- body frontmatter (§5.5) ------------------------------------------------ + +const FM_KEY = /^[A-Za-z0-9-]{1,64}$/; + +// A flat `Key: value` block, deliberately not YAML. Any malformed line +// invalidates the whole block, which is then returned as ordinary body text: +// frontmatter fails closed toward display, never toward silent discard. Keys +// are compared case-insensitively (§5.5), so they are returned lowercased. +export function parseFrontmatter(text) { + if (!text.startsWith("---\n")) return { fields: {}, body: text }; + const lines = text.split("\n"); + const close = lines.indexOf("---", 1); + if (close < 0) return { fields: {}, body: text }; + const block = lines.slice(1, close), rest = lines.slice(close + 1).join("\n"); + const encoded = block.map(line => utf8Bytes(line).length + 1); + if (block.length > FRONTMATTER_KEYS || encoded.reduce((a, b) => a + b, 0) > FRONTMATTER_MAX) + return { fields: {}, body: text }; + const fields = {}; + for (let i = 0; i < block.length; i++) { + const line = block[i], colon = line.indexOf(":"); + if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text }; + const key = line.slice(0, colon).toLowerCase(); + if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins + } + return { fields, body: rest }; +} + +// Emit a block only when needed, including to escape a body that genuinely +// begins with `---` (§5.5). +export function buildFrontmatter(fields, body) { + if (!fields.length && !body.startsWith("---\n")) return body; + return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`; +} + +// --- key rotation (§7) ------------------------------------------------------- + +// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys +// sign, so the old key alone cannot hand the username to a key nobody +// controls; the username is covered but not carried, so a verifier always +// supplies the one it is checking. +export function makeCert(username, oldIdentity, newSeed, when = nowSeconds()) { + const newPub = ed25519PublicKey(newSeed), time = i64BE(when); + const signed = concat(LABEL.rotate, utf8Bytes(username), oldIdentity.publicKey, newPub, time); + return concat(oldIdentity.publicKey, newPub, time, + ed25519Sign(oldIdentity.seed, signed), ed25519Sign(newSeed, signed)); +} + +// Accept a key change only when a signed chain leads from the key we hold to +// the one the server now returns, both keys signing each link (§7). +export function walkChain(username, pinned, current, chain) { + const same = (a, b) => timingSafeEqual(a, b); + if (same(pinned, current)) return true; + if (!chain.length || chain.length > MAX_CHAIN) return false; + let key = pinned, started = false; + for (const cert of chain) { + const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72); + const sigOld = cert.slice(72, 136), sigNew = cert.slice(136, 200); + if (!started) { + if (!same(old, key)) continue; // a link predating the key we hold + started = true; + } else if (!same(old, key)) { + return false; // the chain is not continuous + } + const signed = concat(LABEL.rotate, utf8Bytes(username), old, next, when); + if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) return false; + key = next; + } + return started && same(key, current); +} + +// --- framing and operations (§4, §6) ----------------------------------------- + +// Reassembles a byte stream (WebSocket or TCP) into exact-length reads. +class ByteStream { + constructor(stream, timeoutMs = 0) { + this.chunks = []; + this.length = 0; + this.closed = null; + this.waiters = []; + this.timeoutMs = timeoutMs; + stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); }; + stream.onClose = () => this.fail(new SmolError("server closed the connection")); + } + + wake() { + this.waiters = this.waiters.filter(w => { + if (this.closed) { w.reject(this.closed); return false; } + if (this.length >= w.need) { w.resolve(); return false; } + return true; + }); + } + + fail(error) { + this.closed = error; + this.waiters.forEach(w => w.reject(error)); + this.waiters = []; + } + + async readExact(n) { + if (this.closed) throw this.closed; + if (this.length < n) { + await new Promise((resolve, reject) => { + const waiter = { need: n, resolve, reject }; + this.waiters.push(waiter); + if (!this.timeoutMs) return; + // Settings' timeout, applied per read: a stalled handshake or request + // otherwise waits here forever, since nothing else ever rejects it. + setTimeout(() => { + if (!this.waiters.includes(waiter)) return; // already settled elsewhere + this.waiters = this.waiters.filter(w => w !== waiter); + reject(new SmolError(`no response from the server within ${this.timeoutMs / 1000}s`)); + }, this.timeoutMs); + }); + if (this.closed) throw this.closed; + } + const out = new Uint8Array(n); + let off = 0; + while (off < n) { + const chunk = this.chunks[0]; + const take = Math.min(chunk.length, n - off); + out.set(off ? chunk.slice(0, take) : chunk.subarray(0, take), off); + if (take === chunk.length) this.chunks.shift(); + else this.chunks[0] = chunk.subarray(take); + off += take; + this.length -= take; + } + return out; + } +} + +// One Noise session: application frames split across u16-prefixed Noise +// messages, requests and responses as in §6.1. +export class Session { + constructor(wire, stream, send, recv) { + this.wire = wire; + this.stream = stream; + this.send = send; + this.recv = recv; + } + + async readNoise() { + const head = await this.wire.readExact(2); + const length = (head[0] << 8) | head[1]; + if (length < 16) throw new SmolError(`server sent a ${length}-byte Noise message`); + return this.recv.decrypt(await this.wire.readExact(length)); + } + + async call(op, body = new Uint8Array(0)) { + const frame = concat(u32BE(1 + body.length), Uint8Array.of(op), body); + if (frame.length > MAX_FRAME + 4) throw new SmolError("request exceeds the maximum frame size"); + for (let off = 0; off < frame.length; off += NOISE_PAYLOAD) { + const packet = this.send.encrypt(frame.slice(off, off + NOISE_PAYLOAD)); + this.stream.send(concat(u16BE(packet.length), packet)); + } + let length = -1; + let have = new Uint8Array(0); + while (length < 0 || have.length < 4 + length) { + have = concat(have, await this.readNoise()); + if (length < 0 && have.length >= 4) { + length = new DataView(have.buffer).getUint32(0); + // §6.1: the shortest response is a type byte and a status byte. + if (length < 2 || length > MAX_FRAME) + throw new SmolError(`server sent a frame of length ${length}`); + } + } + const payload = have.slice(4, 4 + length); + // §6.1: a response reuses the request's type byte. A mismatch means the + // session desynchronised, which must not be mistaken for a status. + if (payload[0] !== op) + throw new SmolError(`server answered op 0x${payload[0].toString(16)}, expected 0x${op.toString(16)}`); + return { status: payload[1], body: payload.slice(2) }; // op echo, status, body (§6.1) + } +} + +// Handshake plus §4 pinning. Returns the session, the server's static key as +// revealed by the handshake, and whether that key was already pinned. +export async function openSession(stream, host, pinned = null, timeoutMs = 0) { + const wire = new ByteStream(stream, timeoutMs); + const nx = new NxInitiator(); + const m1 = nx.writeMessage1(); + stream.send(concat(u16BE(m1.length), m1)); + const head = await wire.readExact(2); + const ciphers = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1])); + if (pinned && !timingSafeEqual(pinned, nx.serverStatic)) + throw new SmolError(`${host} presented a different key than the one pinned\n` + + ` pinned: ${b32encode(pinned)}\n presented: ${b32encode(nx.serverStatic)}`); + return { + session: new Session(wire, stream, ciphers.send, ciphers.recv), + serverStatic: nx.serverStatic, + pinned: pinned !== null, + handshakeHash: nx.handshakeHash, + }; +} + +export function expectOk(status, what) { + if (status !== 0) throw new SmolError(`${what} failed: ${STATUS[status] ?? status} (${status})`); +} + +// §4 session authentication: sign the handshake hash, which binds the +// signature to this session's server ephemeral and cannot be replayed, and +// push the accept token set (§5.8). `sync = 0` leaves the server's stored set +// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly. +// Returns the number of accept tokens the server now holds. +export async function authenticate(session, handshakeHash, username, identity, { sync = 0, tokens = [] } = {}) { + const name = utf8Bytes(username); + if (name.length > 255) throw new SmolError("username too long"); + if (tokens.length > 0xffff) throw new SmolError("too many accept tokens for one AUTH"); + const body = concat(Uint8Array.of(name.length), name, identity.publicKey, + ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)), + Uint8Array.of(sync), u16BE(tokens.length), ...tokens); + const { status, body: reply } = await session.call(OP.AUTH, body); + expectOk(status, "authentication"); + return reply.length >= 2 ? new Reader(reply).u16() : 0; +} + +// RESOLVE, returning the current key and its rotation chain (§6.1). +export async function resolveOp(session, user) { + const name = utf8Bytes(user); + if (name.length > 255) throw new SmolError("username too long"); + const { status, body } = await session.call(OP.RESOLVE, concat(Uint8Array.of(name.length), name)); + expectOk(status, `resolving ${user}`); + const r = new Reader(body); + return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) }; +} + +// §5.8: `mac` is the sender's proof of an accept token, absent or TOKEN_LEN bytes. +export async function sendOp(session, envelope, mac = null) { + const macBytes = mac ?? new Uint8Array(0); + if (macBytes.length && macBytes.length !== TOKEN_LEN) + throw new SmolError(`accept MAC must be ${TOKEN_LEN} bytes`); + const body = concat(Uint8Array.of(macBytes.length), macBytes, envelope); + const { status, body: reply } = await session.call(OP.SEND, body); + expectOk(status, "sending"); + return reply.length === ID_LEN ? reply : messageId(envelope); +} + +// §6.1: pages forward from a cursor; an all-zero id starts at the beginning. +export async function fetchOp(session, afterReceivedAt = 0, afterId = new Uint8Array(ID_LEN)) { + const body = concat(i64BE(afterReceivedAt), afterId); + const { status, body: reply } = await session.call(OP.FETCH, body); + expectOk(status, "fetching"); + const r = new Reader(reply); + return Array.from({ length: r.u16() }, () => { + const id = r.take(ID_LEN), receivedAt = Number(r.i64()), flags = r.u8(); + return { id, receivedAt, flags, envelope: r.take(r.u32()), isRequest: Boolean(flags & FLAG_REQUESTS) }; + }); +} + +export async function deleteOp(session, ids) { + if (ids.length > 0xffff) throw new SmolError("too many ids for one DELETE"); + const body = concat(u16BE(ids.length), ...ids); + const { status, body: reply } = await session.call(OP.DELETE, body); + expectOk(status, "acknowledging"); + return new Reader(reply).u16(); +} + +// §6.1: the signature is proof of possession, bound to the server that will +// store the binding so it cannot be replayed to another server. +export function registerSigned(serverStatic, username, identity) { + return concat(LABEL.register, serverStatic, utf8Bytes(username), identity); +} + +export async function registerOp(session, serverStatic, { username, identity, token = "", cert = null }) { + const name = utf8Bytes(username); + const tokenBytes = utf8Bytes(token); + if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255) + throw new SmolError("REGISTER field too long"); + const body = concat(Uint8Array.of(name.length), name, identity.publicKey, + ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)), + Uint8Array.of(tokenBytes.length), tokenBytes, + Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0)); + const { status } = await session.call(OP.REGISTER, body); + expectOk(status, `registering ${username}`); +} diff --git a/web/js/store.js b/web/js/store.js new file mode 100644 index 0000000..e2924b8 --- /dev/null +++ b/web/js/store.js @@ -0,0 +1,564 @@ +// Browser state: identity, pins, contacts and read markers in localStorage; +// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest +// is plaintext (the master secret excepted — it is the user's browser profile). + +import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js"; +import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js"; + +const KEY = "gsmol"; + +export const TIER_MAIN = 0, TIER_REQUESTS = 1; + +// Every getter reads the whole blob and some are called per message row, so the +// parse is cached. `derived` holds the identities that follow from it, which +// cost an Ed25519 scalar multiplication each. +let cached = null, derived = null; + +function load() { + if (cached) return cached; + try { + cached = JSON.parse(localStorage.getItem(KEY) || "{}"); + } catch { + cached = {}; + } + return cached; +} + +function save(state) { + localStorage.setItem(KEY, JSON.stringify(state)); + cached = state; + derived = null; +} + +// Another tab writing this key leaves our copy stale. +window.addEventListener("storage", (event) => { + if (event.key === KEY || event.key === null) cached = derived = null; +}); + +function update(fn) { + const state = load(); + const next = fn(state); + save(next ?? state); +} + +// --- identity (§2) ------------------------------------------------------------- + +export function master() { + const raw = load().master; + return raw ? unhex(raw) : null; +} + +// The rotation index (§7) of the identity currently in use. +export function rotations() { + return load().rotations ?? 0; +} + +export function identity() { + return identities()[0] ?? null; +} + +// §7: every key rotated away from is re-derivable from the master, since mail +// sealed to a superseded key is readable with nothing else. +export function identities() { + if (derived) return derived; + const m = master(); + if (!m) return []; + derived = []; + for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n))); + return derived; +} + +function bindMaster(newMaster, rotationIndex, syncOk) { + if (master()) throw new Error("an identity already exists; rotate it instead"); + update(state => { + state.master = hex(newMaster); + state.rotations = rotationIndex; + state.syncOk = syncOk; + }); + setCursor(0, new Uint8Array(ID_LEN)); +} + +// A fresh identity: rotation index 0, and an empty accepted set is already +// complete, so it may sync. +export function setIdentity(newMaster) { + bindMaster(newMaster, 0, true); +} + +// §2: recovering a master alone does not recover which correspondents were +// accepted, so that set must not overwrite the server's until rebuilt. +export function restoreMaster(newMaster, rotationIndex = 0) { + bindMaster(newMaster, rotationIndex, false); +} + +// Corrects the rotation index once a RESOLVE against the account's address +// reveals which one the server actually has bound (see restoreMaster()'s +// default of 0, the common case of a never-rotated identity). +export function setRotationIndex(n) { + update(state => { state.rotations = n; }); +} + +// Whether the accepted-correspondent set held here may replace the server's +// on the next AUTH — false right after a restore from the master alone, whose +// empty set must not erase the server's (§4). +export function syncOk() { + return load().syncOk ?? true; +} + +export function setSyncOk(ok) { + update(state => { state.syncOk = ok; }); +} + +// Rotation (§7): only the index advances; the superseded key stays derivable +// from the master, so nothing has to be archived. +export function advanceRotation() { + const current = rotations(); + if (!master()) throw new Error("no identity to rotate"); + if (current >= MAX_CHAIN) throw new Error(`the rotation chain is full at ${MAX_CHAIN} links`); + update(state => { state.rotations = current + 1; }); +} + +// --- account and server pins --------------------------------------------------- + +export function account() { + const a = load().account; + return a ? { ...a } : null; +} + +export function setAccount(addr) { + update(state => { + state.account = { user: addr.user, host: addr.host, port: addr.port }; + }); +} + +export function serverPin(host) { + const raw = (load().servers || {})[host]; + return raw ? b32decode(raw) : null; +} + +export function pinServer(host, keyBytes) { + update(state => { + state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) }; + }); +} + +export function allPins() { + return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key })); +} + +// §4/first contact: when true, resolving a never-before-seen recipient's key +// from a server that isn't pinned is refused instead of merely toasted after +// the envelope is already sealed. Persisted, not in-memory — an in-memory +// toggle fails open on every restart, worth little as a security setting. +export function requirePinnedServer() { + return load().requirePinnedServer ?? false; +} + +export function setRequirePinnedServer(value) { + update(state => { state.requirePinnedServer = value; }); +} + +// --- FETCH behavior and cursor (§6.1) ------------------------------------------- + +// When true, fetch does not acknowledge (delete) what it retrieves — mail +// stays on the server until explicitly deleted. Defaults to the original +// behavior: fetched mail is acknowledged immediately. +export function leaveOnServer() { + return load().leaveOnServer ?? false; +} + +export function setLeaveOnServer(value) { + update(state => { state.leaveOnServer = value; }); +} + +// Seconds a network call waits for a response before giving up. Applied per +// read, not per operation, so a slow-but-trickling exchange is not cut off +// just because it spans several reads. +export function timeoutSeconds() { + return load().timeoutSeconds ?? 30; +} + +export function setTimeoutSeconds(value) { + const n = Math.trunc(Number(value)); + if (!Number.isFinite(n)) return; // non-numeric input is ignored, not an error + update(state => { state.timeoutSeconds = Math.min(600, Math.max(1, n)); }); +} + +// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over", +// before anything is bound to an account. Pins and contacts are left alone — +// server trust is not identity-scoped. +export function discardIdentity() { + if (account()) throw new Error("already bound to an account; log out instead"); + update(state => { + delete state.master; + delete state.rotations; + delete state.syncOk; + delete state.afterTime; + delete state.afterId; + }); +} + +export function cursor() { + const state = load(); + const afterId = state.afterId; + return [state.afterTime ?? 0, afterId ? unhex(afterId) : new Uint8Array(ID_LEN)]; +} + +export function setCursor(afterTime, afterId) { + update(state => { + state.afterTime = afterTime; + state.afterId = hex(afterId); + }); +} + +// --- contacts ------------------------------------------------------------------ + +export function contact(address) { + const c = (load().contacts || {})[address]; + return c ? { key: b32decode(c.key), verified: c.verified, history: c.history ?? [] } : null; +} + +// A key that displaces another is kept: it is the only local record that the +// contact rotated, and §8 turns on the user being able to notice such changes. +// Re-saving the same key is not a rotation and must not add an entry. +export function saveContact(address, keyBytes, verified) { + const key = b32encode(keyBytes); + update(state => { + const contacts = { ...(state.contacts || {}) }; + const previous = contacts[address]; + const history = previous && previous.key !== key + ? [...(previous.history ?? []), { key: previous.key, until: Date.now() }] + : previous?.history ?? []; + contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) }; + state.contacts = contacts; + }); +} + +export function addressForKey(keyBytes) { + return Object.entries(load().contacts || {}) + .find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null; +} + +export function allContacts() { + return Object.entries(load().contacts || {}) + .map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] })); +} + +// --- accept tokens (§5.8) -------------------------------------------------------- + +export function accepted(address) { + const a = (load().accepted || {})[address]; + return a ? { identity: b32decode(a.identity), active: a.active } : null; +} + +// Admit a contact to the main tier. The identity is frozen at acceptance — a +// re-accept after a block must not change which key the token is derived +// from (§5.8). +export function accept(address, identityBytes) { + update(state => { + const table = { ...(state.accepted || {}) }; + const previous = table[address]; + table[address] = { + identity: previous?.identity ?? b32encode(identityBytes), + active: true, + addedAt: previous?.addedAt ?? Date.now(), + }; + state.accepted = table; + }); +} + +// Withdraw a contact's accept token; their mail lands in the requests tier +// from their next message on. Throws if the contact was never accepted. +export function block(address) { + if (!(load().accepted || {})[address]) throw new Error(`${address} was never accepted`); + update(state => { + const table = { ...(state.accepted || {}) }; + table[address] = { ...table[address], active: false }; + state.accepted = table; + }); +} + +export function allAccepted() { + return Object.entries(load().accepted || {}) + .map(([address, a]) => ({ address, identity: b32decode(a.identity), active: a.active })); +} + +// §4: the tokens to push with AUTH, and whether to push at all. A client that +// cannot vouch for its own set — one restored from the master alone — must +// not replace the server's with an incomplete one. +export function tokenSet(masterBytes) { + if (!syncOk()) return { sync: 0, tokens: [] }; + const active = allAccepted().filter(a => a.active).sort((a, b) => a.address.localeCompare(b.address)); + return { sync: 1, tokens: active.map(a => tokenFor(masterBytes, a.identity)) }; +} + +// A token received from a correspondent, filed under the address that issued +// it: an address outlives the keys behind it, so the token keeps working +// across the issuer's rotations (§5.8). +export function tokenFrom(address) { + const raw = (load().tokens || {})[address]; + return raw ? b32decode(raw.token) : null; +} + +export function learnToken(address, tokenBytes) { + update(state => { + const tokens = { ...(state.tokens || {}) }; + tokens[address] = { token: b32encode(tokenBytes), seenAt: Date.now() }; + state.tokens = tokens; + }); +} + +// --- read markers --------------------------------------------------------------- + +export function markRead(idHex) { + update(state => { + state.read = { ...(state.read || {}), [idHex]: true }; + }); +} + +export function isRead(idHex) { + return Boolean((load().read || {})[idHex]); +} + +// --- appearance ------------------------------------------------------------ + +// "light" or "dark" to pin a side; null to follow the system. +export function theme() { + return load().theme ?? null; +} + +export function setTheme(value) { + update(state => { + if (value) state.theme = value; + else delete state.theme; + }); +} + +// --- sealed mail (IndexedDB) ------------------------------------------------------ + +const DB_NAME = "gsmol", DB_VERSION = 1; + +// Held open: reopening per read cost more than the reads themselves. +let dbPromise = null; + +function openDb() { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + for (const box of ["inbox", "sent"]) { + if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }).catch(error => { dbPromise = null; throw error; }); + return dbPromise; +} + +function tx(db, box, mode, fn) { + return new Promise((resolve, reject) => { + const request = fn(db.transaction(box, mode).objectStore(box)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// "requests" is a view over the same physical "inbox" records, filtered by +// tier (§5.8) — not a separate folder, so a message keeps one identity +// regardless of which tier it arrived in. +const physicalFolder = (folder) => folder === "requests" ? "inbox" : folder; + +export async function storeMessage(box, record) { + const db = await openDb(); + await tx(db, box, "readwrite", store => store.put(record)); +} + +// Returns null when the id already exists, so fetch can leave server state alone. +export async function storeIfNew(box, record) { + const db = await openDb(); + const existing = await tx(db, box, "readonly", store => store.get(record.id)); + if (existing) return null; + await tx(db, box, "readwrite", store => store.put(record)); + return record; +} + +export async function listMessages(folder) { + const physical = physicalFolder(folder); + const db = await openDb(); + const rows = await tx(db, physical, "readonly", store => store.getAll()); + const wantTier = folder === "requests" ? TIER_REQUESTS : TIER_MAIN; + const filtered = physical === "inbox" ? rows.filter(r => (r.tier ?? TIER_MAIN) === wantTier) : rows; + return filtered.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first +} + +export async function getMessage(folder, id) { + const db = await openDb(); + return await tx(db, physicalFolder(folder), "readonly", store => store.get(id)) ?? null; +} + +export async function removeMessage(folder, id) { + const db = await openDb(); + await tx(db, physicalFolder(folder), "readwrite", store => store.delete(id)); +} + +// The unread badge must survive leaving the inbox, so it is counted from ids +// and read markers alone — no envelope is opened. +async function unreadIn(folder) { + const rows = await listMessages(folder); + const read = load().read || {}; + return rows.filter(row => !read[row.id]).length; +} + +export const unreadCount = () => unreadIn("inbox"); +export const requestsUnreadCount = () => unreadIn("requests"); + +// --- export / import: mail, contacts, pins — never the master -------------------- + +// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per +// byte); this just bridges that to Uint8Array. Browser-only, like the rest of +// this file — no Node test exercises store.js, unlike crypto.js/proto.js. +const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes)); +const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0)); + +// A gsmol-only construction, not the wire protocol's own HKDF namespace +// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in +// SPEC.md, so it gets its own label rather than borrowing the protocol's. +// Sealing to a key derived from the identity's own master means the file is +// opaque without it — no new passphrase to manage, and it can only ever be +// opened where that master is already restored, exactly the situation import +// already assumes. +const EXPORT_LABEL = utf8Bytes("gsmol/1 export"); +const exportKey = (masterBytes) => hkdfSha256(masterBytes, new Uint8Array(0), EXPORT_LABEL, 32); + +// Deliberately excludes the master: it already has its own reveal-and-copy +// flow in settings, meant for a password manager, not a downloadable file. +// Beyond that, everything else in here was worth hiding too — contacts and +// pins are a social graph and a list of which mail servers you use, not just +// the mail SPEC.md §5 makes irreplaceable once fetched — so the whole payload +// is sealed, not just the parts that were already ciphertext at rest. +export async function exportData() { + const m = master(); + if (!m) throw new Error("no identity yet"); + const [inbox, requests, sent] = await Promise.all( + [listMessages("inbox"), listMessages("requests"), listMessages("sent")]); + const payload = { + servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])), + contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) => + [address, { key, verified, history }])), + inbox: [...inbox, ...requests].map(row => ({ + id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope), + tier: row.tier ?? TIER_MAIN, keptOnServer: row.keptOnServer ?? false, + })), + sent: sent.map(row => + ({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })), + }; + // Random per export: the key is the same every time (deterministic from the + // master), so nonce reuse has to be ruled out the way it always is under a + // fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s + // in proto.js gets away with (there, a fresh ephemeral key each message + // makes the derived key itself unique, so a zero nonce is safe). + const nonce = randomBytes(12); + const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0)); + return { + gsmolExport: 2, + exportedAt: Date.now(), + nonce: toBase64(nonce), + ciphertext: toBase64(ciphertext), + }; +} + +// Never overwrites a trust binding that already differs locally — the same +// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin +// or contact key changes only by explicit user action, never silently. A +// malformed entry (hand-edited file, corruption) is skipped, not fatal — one +// bad record cannot abort the rest of the import, matching describe()'s +// per-message fail-open elsewhere in the app. +// A plain object, as JSON.parse would produce for `{...}`; Object.entries() +// on a string iterates its characters rather than failing, which is exactly +// the kind of malformed input this rejects as one unit instead of one per char. +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); + +export async function importData(data) { + let payload; + if (data?.gsmolExport === 2) { + const m = master(); + if (!m) throw new Error("no identity yet — restore it before importing"); + try { + const plaintext = aeadDecrypt( + exportKey(m), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0)); + payload = JSON.parse(new TextDecoder().decode(plaintext)); + } catch { + throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted"); + } + } else if (data?.gsmolExport === 1) { + payload = data; // pre-encryption shape: the fields already sit at the top level + } else { + throw new Error("not a gsmol export file"); + } + + const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0, + mailAdded: 0, malformed: 0 }; + + update(state => { + const servers = { ...(state.servers || {}) }; + if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++; + for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) { + try { + if (b32decode(key).length !== 32) throw new Error("bad length"); + } catch { summary.malformed++; continue; } + if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; } + else if (servers[host] !== key) summary.pinsConflicted++; + } + state.servers = servers; + + const contacts = { ...(state.contacts || {}) }; + if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++; + for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) { + try { + if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key"); + } catch { summary.malformed++; continue; } + if (!(address in contacts)) { + contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(), + ...(Array.isArray(c.history) && c.history.length && { history: c.history }) }; + summary.contactsAdded++; + } else if (contacts[address].key !== c.key) { + summary.contactsConflicted++; + } + } + state.contacts = contacts; + }); + + for (const [box, rows] of [["inbox", payload.inbox], ["sent", payload.sent]]) { + if (rows !== undefined && !Array.isArray(rows)) summary.malformed++; + for (const row of Array.isArray(rows) ? rows : []) { + try { + const record = box === "inbox" + ? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope), + tier: row.tier ?? TIER_MAIN, keptOnServer: Boolean(row.keptOnServer) } + : { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) }; + if (await storeIfNew(box, record)) summary.mailAdded++; + } catch { summary.malformed++; } + } + } + + return summary; +} + +// Logout: erases the master, pins, contacts and every cached message from +// this browser. Closes the held connection first so the delete isn't left +// "blocked" waiting for a handle that never closes on its own. +export async function clearAll() { + localStorage.removeItem(KEY); + cached = derived = null; + if (dbPromise) { + (await dbPromise).close(); + dbPromise = null; + } + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => resolve(); // still completes once the reload closes every handle + }); +} diff --git a/web/js/transport.js b/web/js/transport.js new file mode 100644 index 0000000..669c178 --- /dev/null +++ b/web/js/transport.js @@ -0,0 +1,29 @@ +// The byte pipe to a smolmaild server. Browsers cannot open TCP sockets, so +// bytes travel through the local bridge's WebSocket relay unchanged; the +// Noise session lives in this page either way. + +const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`; + +export function connectStream(host, port, timeoutMs = 0) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`); + socket.binaryType = "arraybuffer"; + const stream = { + send: bytes => socket.send(bytes), + close: () => socket.close(), + onData: null, + onClose: null, + }; + // Settings' timeout only governs this; the WebSocket may otherwise never + // fire open, error, or close if something between here and the bridge + // just drops packets. + const timer = timeoutMs ? setTimeout(() => { + socket.close(); + reject(new Error(`could not reach ${host}:${port} within ${timeoutMs / 1000}s`)); + }, timeoutMs) : null; + socket.onopen = () => { clearTimeout(timer); resolve(stream); }; + socket.onerror = () => { clearTimeout(timer); reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); }; + socket.onmessage = event => stream.onData?.(new Uint8Array(event.data)); + socket.onclose = () => stream.onClose?.(); + }); +} diff --git a/web/locales/en-US.json b/web/locales/en-US.json new file mode 100644 index 0000000..641c530 --- /dev/null +++ b/web/locales/en-US.json @@ -0,0 +1,287 @@ +{ + "seed-bad-hex": "That's not valid hex. Check for typos or extra characters.", + "seed-wrong-length": "A seed is 32 bytes. Check you copied the whole thing.", + "file-error": "{ $path }: { $reason }", + "key-file-wrong-length": "{ $path }: not a 32-byte secret", + "compose-header-colon-missing": "Headers need a colon, like `Key: value`.", + "compose-header-reserved-key": "{ $key } is set automatically and can't be changed.", + "compose-header-invalid-key": "Keys can be 1 to 64 letters, digits, and dashes.", + "compose-header-value-multiline": "Values need to stay on one line.", + "compose-header-duplicate-key": "{ $key } is set twice. Remove one of them.", + "compose-headers-too-large": "Your headers take up too much space. Remove some and try again.", + "compose-headers-too-many": "You have too many headers. Remove some and try again.", + "contacts-import-needs-key": "Import needs a smol:// address that includes a key.", + "mail-message-id-wrong-length": "Message IDs are 64 hex characters.", + "mail-message-id-wrong-bytes": "Message IDs are 32 bytes.", + "mail-message-not-found": "Message not found.", + "fetch-result-note-rejected": "{ $total } fetched, { $stored } stored, { $rejected } rejected", + "fetch-result-note-cancelled": "{ $total } fetched, { $stored } stored (cancelled)", + "fetch-result-note-rejected-cancelled": "{ $total } fetched, { $stored } stored, { $rejected } rejected (cancelled)", + "fetch-result-note-plain": "{ $total } fetched, { $stored } stored", + "mail-envelope-id-mismatch-error": "id does not match the envelope", + "mail-fetch-summary-counts": "{ $stored } new, { $rejected } rejected", + "mail-fetch-summary-verified-one": "{ $verified } sender verified from a signed Reply-To", + "mail-fetch-summary-verified-other": "{ $verified } senders verified from a signed Reply-To", + "mail-fetch-summary-left-on-server": "left on the server", + "send-result-note-token": "Sent to { $address } ({ $bytes } bytes), accept token attached", + "send-result-note-unverified": "Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified)", + "send-result-note-unverified-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified)", + "send-result-note-tofu": "Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use)", + "send-result-note-tofu-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use)", + "send-result-note-rotated": "Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified)", + "send-result-note-rotated-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified)", + "send-result-note-plain": "Sent to { $address } ({ $bytes } bytes)", + "mail-sent-note": "sent to { $address }", + "mail-sent-note-accepted": "sent to { $address } (accepted)", + "status-registered-note": "Registered", + "status-restored-note": "Restored at rotation { $index }", + "status-deleted-note": "Deleted", + "status-accepted-note": "Accepted { $address }", + "status-blocked-note": "Blocked { $address }", + "status-not-blocked-note": "{ $address } was not blocked", + "contacts-no-key-error": "no key for { $address } yet", + "contacts-accepted-note-one": "{ $address } accepted; server now holds { $held } accept token", + "contacts-accepted-note-other": "{ $address } accepted; server now holds { $held } accept tokens", + "contacts-blocked-note-one": "{ $address } blocked; server now holds { $held } accept token", + "contacts-blocked-note-other": "{ $address } blocked; server now holds { $held } accept tokens", + "status-imported-note": "Imported { $address } (verified)", + "status-rotated-note": "Rotated to index { $index }; new key { $key_prefix }…", + "backup-written-note": "Backup written to { $path }", + "draft-saved-note": "Draft saved", + "logged-out-note": "Signed out. Create a new identity or restore a seed.", + "identity-created-note": "Identity created. Write the seed down. It's the only secret.", + "seed-restored-note": "Seed restored. Bind a username or recall an address.", + "signup-enter-host-error": "Enter the server host first.", + "signup-enter-user-host-error": "Enter the user and host.", + "signup-host-looks-like-address-error": "\"{ $host }\" looks like an address, not a server — the part after the @ goes here", + "signup-host-has-port-error": "\"{ $host }\": a pin is by host only, no port — just the part before the colon", + "compose-enter-recipient-error": "Enter a recipient.", + "compose-header-row-error": "Header { $index }: { $reason }", + "reply-no-address-error": "This sender left no address to reply to.", + "reply-subject-prefix": "Re: { $subject }", + "address-invalid-error": "That address isn't in the right format.", + "contacts-reply-address-invalid-error": "The sender's reply address isn't in the right format.", + "contacts-sender-key-invalid-error": "The sender's key isn't in the right format.", + "contacts-accept-no-address-error": "This sender left no address to accept.", + "contacts-block-no-address-error": "This sender left no address to block.", + "contacts-sender-key-mismatch-error": "that address carries a different key than this message's sender", + "contacts-reply-key-conflict-error": "{ $address } is already known with a different key — verify out of band before replying", + "contacts-key-kept-note": "kept the existing key for { $address }", + "contacts-sender-saved-note-verified": "{ $address } saved for this sender (verified key)", + "contacts-sender-saved-note-unverified": "{ $address } saved for this sender", + "contacts-import-detail-note": "imported { $address } { $key } (verified)\nfingerprint: { $fingerprint }", + "action-cancel": "Cancel", + "action-back": "Back", + "action-block": "Block", + "action-unblock": "Unblock", + "action-accept": "Accept", + "action-reply": "Reply", + "action-delete": "Delete", + "trust-fingerprint-hint": "If the server operator published a key or fingerprint, make sure it matches before trusting it.", + "identity-missing-error": "no identity yet", + "account-not-registered-fetch-warning": "not registered yet — claim an address in settings", + "account-not-registered-tokens-note": "not registered; the set will be pushed with your first fetch", + "mail-fetch-button": "Fetch", + "mail-write-button": "Write", + "folder-inbox": "Inbox", + "folder-requests": "Requests", + "folder-sent": "Sent", + "folder-drafts": "Drafts", + "settings-nav-label": "Settings", + "settings-close-aria-label": "close settings", + "settings-close-tooltip": "close", + "identity-you-caption": "you", + "contacts-caption": "contacts", + "contacts-import-placeholder": "Import a smol:// address", + "contacts-import-button": "Import", + "theme-auto-label": "theme: following the system", + "theme-light-label": "theme: light", + "theme-dark-label": "theme: dark", + "account-not-registered-value": "not registered", + "mail-search-placeholder": "search { $folder }", + "mail-meta-to": "to", + "mail-meta-from": "from", + "mail-meta-line-no": "{ $label } { $from } · { $time } UTC · id { $id }…", + "mail-meta-line-yes": "{ $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists", + "mail-no-subject": "(no subject)", + "mail-no-reply-address": "no reply address: can't accept or block", + "contacts-empty-hint": "no contacts", + "mail-back-to-list-button": "messages", + "mail-meta-sent": "to: { $recipient }\nid: { $id }\nsent: { $time }", + "mail-meta-received": "from: { $from }\nid: { $id }\ndate: { $time }", + "mail-trust-own-copy": "your own sealed copy", + "mail-trust-verified-sender": "verified sender key", + "mail-trust-tofu-sender": "key pinned on first use", + "mail-trust-unbound-sender": "key bound to no address", + "mail-sender-unbound-tooltip": "sender key not bound to an address", + "mail-signed-reply-hint": "this message carries the sender's signed address: { $address }", + "mail-save-sender-reply-button": "save sender & reply", + "mail-name-sender-button": "name sender & reply", + "mail-unknown-sender-hint": "The wire carries no sender name, only this signed key. To reply, give the sender's address — it is checked against this key:", + "mail-delete-not-registered-error": "not registered; cannot reach the server to delete this message", + "mail-request-unaccepted-hint": "unsolicited mail from { $address }, not yet accepted into your main tier", + "mail-accept-button": "accept — future mail lands in your main tier", + "mail-inbox-empty-title": "Inbox is empty", + "mail-inbox-empty-description": "Fetch to check the mailbox.", + "mail-requests-empty-title": "No requests", + "mail-requests-empty-description": "Mail from senders you haven't accepted yet arrives here.", + "mail-sent-empty-title": "Nothing sent yet", + "mail-sent-empty-description": "Messages you send keep a sealed copy here.", + "mail-drafts-empty-title": "No drafts", + "mail-drafts-empty-description": "Closing a compose from the sidebar keeps it here.", + "own-identity-meta": "you · rotation { $rotations }", + "contacts-pin-button-fetched": "Pin this key", + "contacts-pin-button-unfetched": "Pin server", + "mail-empty-title": "No message selected", + "mail-empty-description": "Pick a message from the list above to read it.", + "mail-empty-hint": "Fetching removes mail from the server once it's stored here. Turn on \"Leave mail on server\" in Settings to keep it there instead.", + "detail-qr-tooltip": "Show the address as a QR code", + "detail-copy-tooltip": "Copy the address", + "mail-row-unreadable-error": "", + "mail-contacts-reader-hint": "contacts and their keys are listed alongside", + "mail-list-count-one": "{ $count } message", + "mail-list-count-other": "{ $count } messages", + "mail-list-shown-of-total": "{ $shown } of { $total }", + "compose-to-label": "To", + "compose-subject-label": "Subject", + "compose-add-header-button": "+ header", + "compose-send-button": "Send", + "compose-header-placeholder": "Key: value", + "compose-header-remove-tooltip": "Remove this header", + "compose-body-placeholder": "message", + "compose-anonymous-label": "anonymous — omit my address", + "contact-state-accepted": "accepted", + "contact-state-blocked": "blocked", + "contact-state-verified": "verified", + "contact-state-tofu": "trust on first use", + "contact-meta-line-yes": "{ $state } · server pinned", + "contact-meta-line-no": "{ $state } · server not pinned", + "detail-key-label": "key", + "detail-fingerprint-label": "fingerprint", + "detail-address-label": "address", + "detail-server-label": "server", + "contact-server-key-offered": "this server presents", + "contacts-server-pinned-badge": "pinned", + "contacts-server-not-pinned-badge": "not pinned", + "contacts-history-badge": "{ $count } previous", + "contacts-import-empty-hint": "no contacts yet — import a smol:// address above, or write to someone", + "contacts-refresh-button": "re-resolve", + "contacts-accept-token-title": "accept token", + "contacts-accepted-hint": "This contact's mail lands in your main tier.", + "contacts-unaccepted-hint": "This contact's mail lands in requests until accepted.", + "contacts-history-title": "previous keys ({ $count })", + "contacts-history-hint": "Replaced by a signed rotation chain. Mail signed with these was sent before the change; rotation is not revocation, so a stolen key can still rotate onward.", + "contacts-history-replaced": "replaced { $time }", + "settings-tab-identity": "Identity", + "settings-tab-server": "Server", + "settings-tab-backup": "Backup", + "settings-tab-advanced": "Advanced", + "settings-field-address": "Address", + "settings-field-key": "Key", + "settings-field-fingerprint": "Fingerprint", + "settings-field-uri": "URI", + "property-row-copy-tooltip": "Copy the { $field }", + "settings-recovery-secret-title": "Recovery secret", + "settings-secret-reveal-button": "reveal", + "settings-secret-hide-button": "hide", + "settings-secret-reveal-tooltip": "Reveal the recovery secret", + "settings-secret-hide-tooltip": "Hide the recovery secret", + "settings-secret-copy-tooltip": "Copy the recovery secret", + "settings-secret-warning": "Anyone who holds this can read your mail and write as you, and losing it loses the address with it — there is no reset. Keep it in a password manager.", + "copy-button-idle": "copy", + "copy-button-success": "copied", + "copy-button-failed": "failed", + "settings-not-registered-value": "not registered yet", + "settings-uri-claim-hint": "claim an address to get one", + "settings-field-host": "Host", + "settings-field-public-key": "Public key", + "settings-public-key-not-pinned": "not pinned yet", + "server-connection-title": "Connection", + "server-timeout-title": "Timeout", + "server-timeout-subtitle": "Seconds before a network call gives up", + "server-fetching-title": "Fetching", + "server-leave-mail-title": "Leave mail on server", + "server-leave-mail-subtitle": "Keep a copy on the server after fetching, so you can fetch it again from another app or device. When this is on, deleting a message removes it here and on the server.", + "backup-page-description": "One file with your mail, contacts, and server info, for moving to another app or keeping what the server no longer has. It's sealed to this identity and never includes the secret itself.", + "backup-export-title": "Export", + "backup-export-subtitle": "Write a backup file", + "backup-import-title": "Import", + "backup-import-subtitle": "Restore from a backup file", + "backup-export-dialog-title": "Export backup", + "backup-import-dialog-title": "Import backup", + "advanced-trust-section-title": "trust", + "advanced-rotations-title": "Rotations", + "advanced-rotations-description": "Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation.", + "advanced-rotations-completed": "Completed", + "advanced-rotate-button": "Rotate identity", + "advanced-rotate-needs-account-error": "rotate needs a registered account", + "advanced-first-contact-title": "First contact", + "advanced-first-contact-description": "A recipient who isn't a contact yet, and whose address carries no key, has to be looked up on their own server. That answer is only as good as the server: one with no pinned key can return a key of its own and read what you send.", + "advanced-strict-pinning-title": "Require a pinned server", + "advanced-strict-pinning-subtitle": "Stop that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.", + "advanced-logout-title": "Sign out", + "advanced-logout-description": "Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless \"Leave mail on server\" was on. Whatever's still on the server comes back on the first fetch after you sign back in.", + "advanced-logout-button": "Sign out and delete all data", + "onboard-welcome-title": "welcome", + "onboard-welcome-description": "Encrypted mail with no account and no password. Your identity is one secret, kept in this browser and nowhere else.", + "onboard-tagline": "smolmail: one seed, sealed mail, five operations", + "onboard-create-button": "Create a new identity", + "onboard-restore-button": "Restore an existing identity from a seed", + "onboard-created-title": "Write this seed down", + "onboard-created-description": "It's the only secret, and it's shown once.", + "onboard-saved-button": "I saved it", + "onboard-restore-title": "Restore from a seed", + "onboard-restore-description": "Paste the secret you saved. Your keys come back from it — it is all this client needs.", + "onboard-seed-entry-title": "Seed: 32 bytes, base32 or hex", + "onboard-restore-go-button": "Restore", + "signup-server-title": "Set up the server", + "signup-host-label": "host", + "signup-fetch-key-button": "Fetch the server's key", + "signup-continue-button": "Continue", + "signup-start-over-button": "Start over", + "signup-account-title": "Choose your address", + "signup-mode-new-label": "Create a new address", + "signup-mode-existing-label": "I already have this address", + "signup-user-label": "User", + "signup-invite-label": "Invite token (if the server requires one)", + "signup-register-button": "Register", + "signup-recall-button": "Restore access", + "compose-pin-title": "{ $host } isn't pinned", + "compose-pin-body": "{ $address } isn't a contact yet, so carrier would have to ask this server for its key. A server with no pinned key can answer with one of its own, then read the message.", + "compose-pin-button-fetch": "Fetch their server's key", + "compose-pin-button-send": "Pin and send", + "delete-dialog-title": "Delete this message?", + "delete-dialog-body-kept": "Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too.", + "delete-dialog-body-gone": "This removes the only copy, which is sealed and stored on this device.", + "delete-dialog-everywhere-button": "Delete everywhere", + "rotate-dialog-title": "Rotate the identity?", + "rotate-dialog-confirm-button": "Rotate", + "reset-dialog-title": "Sign out and delete all data?", + "reset-dialog-confirm-button": "Sign Out", + "reply-dialog-title": "Carry the sender's fields into the reply?", + "reply-dialog-body": "This message has its own headers:\n\n{ $list }\n\nReply with copies of these fields, or start with none. The fields stay editable either way.", + "reply-dialog-cancel-button": "Cancel reply", + "reply-dialog-without-button": "Reply without", + "reply-dialog-copy-button": "Copy fields", + "key-change-dialog-title": "Replace this contact's key?", + "key-change-dialog-body": "{ $address } is already on file with a different key.\n\nknown { $known_fingerprint }\nnew { $offered_fingerprint }\n\nThe address you were given carries the new key and would replace the one on file. If this contact didn't hand it to you themselves, stop: a key swapped in on the way reads everything you send them.", + "key-change-dialog-replace-button": "Replace key", + "status-busy-fetching": "fetching…", + "status-busy-refreshing": "refreshing…", + "status-busy-registering": "registering…", + "status-busy-restoring": "restoring…", + "status-busy-rotating": "rotating…", + "status-fetching-progress": "fetching… { $count } in inbox", + "first-contact-blocked-error": "{ $address } isn't a contact yet, and { $host } isn't pinned. Pin their server's key to send.", + "server-key-offered-note": "The server presented this key. Pin it only if the fingerprint matches what the operator published.", + "server-key-already-pinned-note": "This server's key is already pinned.", + "server-key-pinned-note": "Server key pinned", + "connect-unpinned-warning": "{ $host } is not pinned; its key is { $key }.\nLookups from this session are UNVERIFIED.", + "recall-rotation-not-found-error": "the key bound to { $address } is not derived from this master within { $max } rotations", + "contacts-refresh-embedded-key-error": "that address already carries a key; use import instead", + "contacts-resolved-note": "{ $address } pinned (trust on first use)", + "contacts-resolved-note-unverified": "{ $address } pinned (trust on first use, UNVERIFIED server)", + "contacts-refresh-unchanged-note": "{ $address }: key unchanged", + "contacts-refresh-rotated-note": "{ $address } rotated its key; a signed chain confirms it.\nnow { $key }", + "contacts-refresh-no-chain-warning": "{ $address } presents a different key with no valid rotation chain.\nVerify out of band, then import the new smol:// address." +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..8d24a1d --- /dev/null +++ b/web/style.css @@ -0,0 +1,628 @@ +/* Every colour is a token, so the dark palette at the foot of this file needs + only to re-point them — no rule is written twice. */ +/* One declaration per token: light-dark() picks a side from the resolved + color-scheme, so the theme toggle only has to change `color-scheme` and no + rule below ever needs a dark twin. */ +:root { + color-scheme: light dark; + + --bg: light-dark(#f4f6fa, #121316); + --surface: light-dark(#ffffff, #1c1e22); + /* A second surface for rails and inset cards, so nesting reads without a + second border everywhere. */ + --raised: light-dark(#fafbfd, #23262b); + --fg: light-dark(#1b1c1f, #e6e8ec); + --muted: light-dark(#5e6572, #9ba1ad); + --border: light-dark(#e1e4ea, #333740); + --divider: light-dark(#eef0f4, #262930); + /* Both sides clear AA on --surface, --bg and --accent-soft alike, which is + what the compose pill, the active folder, the tab rail and the setup + checklist all need. */ + --accent: light-dark(#3a55c8, #9db1ff); + --accent-soft: light-dark(#eceffd, #242a3e); + --on-accent: light-dark(#ffffff, #0e1430); + --hover: light-dark(#f1f3f7, #262a31); + --warn: light-dark(#c5221f, #f28b82); + --on-warn: light-dark(#ffffff, #3c0d0a); + --ok-bg: light-dark(#e6f4ea, #1d3826); + --ok-fg: light-dark(#137333, #81c995); + --alert-bg: light-dark(#feefc3, #3d3520); + --alert-fg: light-dark(#7a4f01, #fdd663); + --toast-bg: light-dark(#1b1c1f, #e6e8ec); + --toast-fg: light-dark(#e6e8ec, #1b1c1f); + --backdrop: light-dark(rgba(27, 28, 31, 0.34), rgba(0, 0, 0, 0.62)); + --shadow-near: light-dark(rgba(23, 26, 38, 0.10), rgba(0, 0, 0, 0.50)); + --shadow-far: light-dark(rgba(23, 26, 38, 0.08), rgba(0, 0, 0, 0.34)); + + --ui: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + /* Cards and dialogs take --radius; anything hit-sized takes --radius-sm, so + a small button never wears a curve it is too short to carry. */ + --radius: 12px; + --radius-sm: 8px; + --radius-pill: 999px; + --shadow: 0 1px 2px var(--shadow-near), 0 6px 18px -8px var(--shadow-far); + --shadow-lg: 0 2px 6px var(--shadow-near), 0 24px 56px -16px var(--shadow-far); +} + +/* The toggle pins a side; without the attribute the system decides. */ +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; /* mobile browser chrome eats the last row of 100vh */ + font: 14px/1.55 var(--ui); + color: var(--fg); + background: var(--surface); +} + +button { + font: inherit; + border: 0; + border-radius: var(--radius-sm); + background: none; + color: var(--accent); + padding: 7px 14px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} +button:hover { background: var(--hover); } +button:disabled { color: var(--muted); cursor: default; } +button:disabled:hover { background: none; } + +/* Line icons from the sprite in index.html; they take their colour from the + button, so no icon needs a palette of its own. */ +.sprite { display: none; } +.icon { + width: 18px; + height: 18px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon-only { padding: 8px; display: inline-flex; } + +input, textarea { + font: inherit; + padding: 8px 11px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--fg); +} + +/* Keyboard focus was invisible: buttons carry no border and the input ring used + --accent-soft, which is near-white on white. */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); } + +details > summary { + width: fit-content; + padding: 2px 0; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} +details > summary:hover { color: var(--accent); } + +/* --- top bar --------------------------------------------------------------- */ + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 11px 18px; + border-bottom: 1px solid var(--border); +} +.brand { font-size: 17px; font-weight: 600; letter-spacing: -0.015em; } +.search { + flex: 1; + min-width: 0; /* an input's intrinsic size is its flex floor otherwise, and it overflows narrow bars */ + max-width: 640px; + margin: 0 auto; + background: var(--hover); + border: 0; + border-radius: var(--radius-pill); + padding: 9px 16px; +} +.search:focus { background: var(--surface); outline: 2px solid var(--accent); } +.top-actions { display: flex; align-items: center; gap: 4px; } +.top-actions .icon-only { color: var(--muted); } +.top-actions .icon-only:hover { color: var(--fg); } +.account { font-size: 13px; color: var(--muted); margin-right: 4px; } +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + color: var(--on-accent); + font-size: 15px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +/* --- layout ------------------------------------------------------------------ */ + +main { flex: 1; min-height: 0; display: flex; background: var(--bg); } + +.rail { + width: 200px; + padding: 12px 8px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 4px; +} +/* The one primary action here, so it carries a solid fill rather than the soft + one it shares with selected rows — and its box edge lines up with the folder + buttons below, so the rail reads as a single column. */ +.compose { + align-self: stretch; + display: flex; + align-items: center; + gap: 14px; + margin: 2px 0 18px; + padding: 12px 16px; + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--on-accent); + font-weight: 500; +} +.compose:hover { background: var(--accent); box-shadow: var(--shadow); } + +#folders button { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + text-align: left; + padding: 9px 16px; + color: var(--fg); + font-weight: 500; +} +#folders .label { flex: 1; } +#folders button:hover { background: var(--hover); } +#folders button.active { background: var(--accent-soft); color: var(--accent); } +#folders .count { color: var(--accent); font-weight: 500; margin-left: 8px; } + +.listpane { + display: flex; + flex-direction: column; + min-width: 360px; + max-width: 460px; + background: var(--surface); + border-right: 1px solid var(--border); +} +.list { flex: 1; min-height: 0; overflow-y: auto; } + +/* Fetch acts on the mailbox, so it sits over the mailbox rather than in the + account cluster — and it can stand down in the contacts folder. */ +.toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 12px; + border-bottom: 1px solid var(--border); +} +.toolbar .tools { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; } +.toolbar .primary { display: flex; align-items: center; gap: 8px; color: var(--accent); font-weight: 500; } +#import-form input { + flex: 1; + min-width: 0; /* an input's intrinsic width is its flex floor otherwise */ + font-family: var(--mono); + font-size: 12px; + padding: 5px 10px; +} +/* The fetch icon turns while a session is open. */ +.toolbar .primary:disabled .icon { animation: spin 1.1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .toolbar .primary:disabled .icon { animation: none; } } +.list-status { margin-left: auto; padding-right: 6px; font-size: 12px; color: var(--muted); } + +/* Message rows are