chore: flatten repository history

This commit is contained in:
randogoth 2026-10-09 13:47:42 +03:00
commit 3a03fab876
30 changed files with 7223 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.devbox/
.venv/
result
result-*
.claude/
AGENTS.md

11
AI-DECLARATION.md Normal file
View file

@ -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.

201
LICENSE Normal file
View file

@ -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.

37
README.md Normal file
View file

@ -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 <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.

154
bridge.py Executable file
View file

@ -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())

26
devbox.json Normal file
View file

@ -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"
]
}
}
}

143
devbox.lock Normal file
View file

@ -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"
}
}
}
}
}

27
flake.lock generated Normal file
View file

@ -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
}

27
flake.nix Normal file
View file

@ -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;
};
}

449
locales/en-US/main.ftl Normal file
View file

@ -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 = <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.

62
nix/bridge.nix Normal file
View file

@ -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;
};
}

234
nix/module.nix Normal file
View file

@ -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}
'';
})
]);
}

46
scripts/build-locale.mjs Normal file
View file

@ -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}`);

457
test/client.mjs Normal file
View file

@ -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 <command> [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)} <unreadable: ${err.message}>`);
}
}
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]();

162
test/e2e.py Normal file
View file

@ -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())

203
test/gen_vectors.py Normal file
View file

@ -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())

236
test/vectors.json Normal file
View file

@ -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"
}
]
}
}

246
test/vectors.test.mjs Normal file
View file

@ -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`);

6
web/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="smol mail">
<rect width="32" height="32" rx="7" fill="#0b57d0"/>
<rect x="7" y="11" width="18" height="11" rx="2" fill="#fff"/>
<path d="M7.8 12 16 17.6 24.2 12" fill="none" stroke="#0b57d0" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

274
web/index.html Normal file
View file

@ -0,0 +1,274 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>smol mail</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- One sprite, referenced by <use>; same-document so the CSP needs no exception. -->
<svg class="sprite" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<symbol id="i-compose" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
<symbol id="i-sent" viewBox="0 0 24 24"><path d="M22 2 11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></symbol>
<symbol id="i-contacts" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>
<symbol id="i-auto" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></symbol>
<symbol id="i-reply" viewBox="0 0 24 24"><path d="M9 17 4 12l5-5"/><path d="M4 12h11a5 5 0 0 1 5 5v2"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
</svg>
<header class="topbar">
<div class="brand">smol mail</div>
<input id="search" class="search" placeholder="search mail" aria-label="search mail"
spellcheck="false" hidden>
<div id="top-actions" class="top-actions" hidden>
<button id="theme" class="icon-only" aria-label="theme"><svg class="icon"><use href="#i-auto"/></svg></button>
<button id="settings-open" class="icon-only" data-l10n-aria-label="settings-nav-label"
data-l10n-title="settings-nav-label"><svg class="icon"><use href="#i-settings"/></svg></button>
<div id="account" class="account"></div>
<div id="avatar" class="avatar" aria-hidden="true"></div>
</div>
</header>
<!-- Five screens, one concern each: generate-or-recover an identity, establish
trust in a server, then bind a name to it. No screen asks about the next
screen's concern — e.g. the server screen never asks for a username. -->
<main id="setup" class="setup" hidden>
<section id="setup-intro" class="setup-card">
<h2 data-l10n-id="onboard-welcome-title">welcome</h2>
<p class="lead" data-l10n-id="onboard-welcome-description">Encrypted mail with no account and no
password. Your identity is one secret, kept in this browser and nowhere else.</p>
<button type="button" id="create-identity" class="filled large" data-l10n-id="onboard-create-button">create a new identity</button>
<button type="button" id="show-restore" class="link" data-l10n-id="onboard-restore-button">restore existing identity from seed</button>
</section>
<section id="setup-seed" class="setup-card" hidden>
<h2 data-l10n-id="onboard-created-title">write this seed down</h2>
<p class="lead" data-l10n-id="onboard-created-description">It is the only secret, and it is shown once.</p>
<div class="seed-card">
<div id="setup-seed-value" class="seed"></div>
</div>
<button type="button" id="seed-saved" class="filled large" data-l10n-id="onboard-saved-button">I saved it</button>
<button type="button" id="seed-back" class="link" data-l10n-id="action-back">back</button>
</section>
<section id="setup-restore" class="setup-card" hidden>
<h2 data-l10n-id="onboard-restore-title">restore from a seed</h2>
<p class="lead" data-l10n-id="onboard-restore-description">Paste the secret you saved. Your keys
come back from it — it is all this client needs.</p>
<form id="restore-form" class="stack">
<label><span data-l10n-id="onboard-seed-entry-title">your secret</span>
<input id="restore-master" spellcheck="false" required>
</label>
<span id="restore-error" class="error"></span>
<button class="filled large" data-l10n-id="onboard-restore-go-button">restore</button>
</form>
<button type="button" id="restore-back" class="link" data-l10n-id="action-back">back</button>
</section>
<section id="setup-server" class="setup-card" hidden>
<h2 data-l10n-id="signup-server-title">set up the server</h2>
<form id="server-form" class="stack">
<label><span data-l10n-id="signup-host-label">host</span>
<input id="server-host" placeholder="example.org" spellcheck="false" required>
</label>
<div id="server-key-area" class="seed-card" hidden></div>
<span id="server-error" class="error"></span>
<button id="server-action" class="filled large" data-l10n-id="signup-fetch-key-button">fetch the server's key</button>
</form>
<button type="button" id="server-back" class="link" data-l10n-id="signup-start-over-button">start over</button>
</section>
<section id="setup-account" class="setup-card" hidden>
<h2 data-l10n-id="signup-account-title">choose your address</h2>
<div id="account-mode" class="mode-toggle" role="group">
<button type="button" data-mode="new" class="active" aria-current="true"
data-l10n-id="signup-mode-new-label">create a new address</button>
<button type="button" data-mode="existing" data-l10n-id="signup-mode-existing-label">I already have this address</button>
</div>
<form id="account-form" class="stack">
<label><span data-l10n-id="signup-user-label">user</span>
<input id="account-user" placeholder="alice" spellcheck="false" required>
</label>
<label id="account-token-label"><span data-l10n-id="signup-invite-label">invite token (if the server requires one)</span>
<input id="account-token">
</label>
<span id="account-error" class="error"></span>
<button id="account-action" class="filled large" data-l10n-id="signup-register-button">register</button>
</form>
<button type="button" id="account-back" class="link" data-l10n-id="action-back">back</button>
</section>
</main>
<main id="app" hidden>
<nav class="rail" aria-label="folders">
<button id="write" class="compose"><svg class="icon"><use href="#i-compose"/></svg><span data-l10n-id="mail-write-button">compose</span></button>
<div id="folders">
<button data-folder="inbox" class="active" aria-current="true" data-l10n-aria-label="folder-inbox">
<svg class="icon"><use href="#i-inbox"/></svg><span class="label" data-l10n-id="folder-inbox">inbox</span>
<span id="count-inbox" class="count"></span>
</button>
<button data-folder="requests" data-l10n-aria-label="folder-requests">
<svg class="icon"><use href="#i-inbox"/></svg><span class="label" data-l10n-id="folder-requests">requests</span>
<span id="count-requests" class="count"></span>
</button>
<button data-folder="sent" data-l10n-aria-label="folder-sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label" data-l10n-id="folder-sent">sent</span></button>
<button data-folder="contacts" data-l10n-aria-label="contacts-caption"><svg class="icon"><use href="#i-contacts"/></svg><span class="label" data-l10n-id="contacts-caption">contacts</span></button>
</div>
</nav>
<div class="listpane">
<div class="toolbar">
<div id="mail-tools" class="tools">
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label" data-l10n-id="mail-fetch-button">fetch</span></button>
<span id="list-status" class="list-status"></span>
</div>
<form id="import-form" class="tools" hidden>
<input name="uri" placeholder="smol://alice@example.org/…" data-l10n-placeholder="contacts-import-placeholder"
data-l10n-aria-label="contacts-import-placeholder" spellcheck="false" required>
<button class="primary" data-l10n-id="contacts-import-button">import</button>
</form>
</div>
<section id="list" class="list" aria-label="messages"></section>
</div>
<article id="view" class="view" aria-label="message"><p class="placeholder" data-l10n-id="mail-empty-title">select a message</p></article>
</main>
<dialog id="compose">
<form id="compose-form" method="dialog">
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
<input name="subject" spellcheck="false" data-l10n-placeholder="compose-subject-label" placeholder="subject">
<textarea name="body" rows="12" data-l10n-placeholder="compose-body-placeholder" placeholder="message"></textarea>
<div id="compose-unpinned" class="seed-card" hidden>
<h4 id="compose-unpinned-title"></h4>
<p id="compose-unpinned-body" class="hint"></p>
<div id="compose-unpinned-key-area" hidden></div>
<div class="actions">
<button type="button" id="compose-unpinned-fetch" data-l10n-id="compose-pin-button-fetch">Fetch their server's key</button>
<button type="button" id="compose-unpinned-pin" class="filled" hidden data-l10n-id="compose-pin-button-send">Pin and send</button>
</div>
<span id="compose-unpinned-error" class="error"></span>
</div>
<footer>
<label class="anon"><input type="checkbox" name="anonymous"> <span data-l10n-id="compose-anonymous-label">anonymous — omit my address</span></label>
<span id="compose-error" class="error"></span>
<button type="button" id="compose-cancel" data-l10n-id="action-cancel">cancel</button>
<button id="send" class="filled" value="send" data-l10n-id="compose-send-button">send</button>
</footer>
</form>
</dialog>
<!-- Tabs, not one scroll: identity, server, the backup file and the
irreversible operations have nothing to say to each other, and reading
all four at once was the overwhelming part. -->
<dialog id="settings">
<header class="dialog-head">
<h2 data-l10n-id="settings-nav-label">settings</h2>
<button type="button" id="settings-close" class="icon-only" data-l10n-aria-label="settings-close-aria-label"
data-l10n-title="settings-close-tooltip"><svg class="icon"><use href="#i-close"/></svg></button>
</header>
<div class="settings-body">
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
<button type="button" data-tab="identity" class="active" aria-current="true"
data-l10n-id="settings-tab-identity">identity</button>
<button type="button" data-tab="server" data-l10n-id="settings-tab-server">server</button>
<button type="button" data-tab="backup" data-l10n-id="settings-tab-backup">backup</button>
<button type="button" data-tab="advanced" data-l10n-id="settings-tab-advanced">advanced</button>
</nav>
<div id="settings-panels" class="settings-panels">
<section class="panel" data-panel="identity">
<div id="identity-fields"></div>
<h3 data-l10n-id="settings-recovery-secret-title">recovery secret</h3>
<div id="identity-secret"></div>
</section>
<section class="panel" data-panel="server" hidden>
<h3 data-l10n-id="server-connection-title">connection</h3>
<div id="server-connection"></div>
<h3 data-l10n-id="server-fetching-title">fetching</h3>
<label class="check-row">
<input type="checkbox" id="leave-on-server">
<span>
<span class="check-title" data-l10n-id="server-leave-mail-title">Leave mail on server</span>
<span class="hint" data-l10n-id="server-leave-mail-subtitle">Keep a copy on the server after
fetching, so another client can fetch it too. When this is enabled, deleting a message
removes it from this client and the server.</span>
</span>
</label>
</section>
<section class="panel" data-panel="backup" hidden>
<p class="hint" data-l10n-id="backup-page-description">One file holding your mail, contacts and
server info for moving to another client, or for keeping what the server no longer has. It
is sealed to this identity and deliberately leaves the secret itself out.</p>
<div class="action-row">
<button type="button" id="export-data" class="filled" data-l10n-id="backup-export-title">Export</button>
<span class="hint" data-l10n-id="backup-export-subtitle">Write a backup file</span>
</div>
<div class="action-row">
<button type="button" id="import-data-trigger" class="outline" data-l10n-id="backup-import-title">Import</button>
<span class="hint" data-l10n-id="backup-import-subtitle">Restore from a backup file</span>
<input id="import-data" type="file" accept="application/json" hidden>
</div>
<span id="backup-error" class="error"></span>
</section>
<section class="panel" data-panel="advanced" hidden>
<h3 data-l10n-id="advanced-trust-section-title">trust</h3>
<label class="check-row">
<input type="checkbox" id="require-pinned-server">
<span>
<span class="check-title" data-l10n-id="advanced-strict-pinning-title">Require a pinned server</span>
<span class="hint" data-l10n-id="advanced-strict-pinning-subtitle">Refuse to look up a new
recipient's key from a server you haven't pinned. An address that already carries a key,
or a recipient you already know, is unaffected — this only applies to a new, hand-typed
address.</span>
</span>
</label>
<h3 data-l10n-id="advanced-rotations-title">rotations</h3>
<p id="rotations-description" class="hint" data-l10n-id="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.</p>
<div id="rotations-count"></div>
<button type="button" id="rotate-identity" class="outline" data-l10n-id="advanced-rotate-button">Rotate identity</button>
<span id="rotate-error" class="error"></span>
<h3 class="danger-head" data-l10n-id="advanced-logout-title">log out</h3>
<p id="logout-description" class="hint" data-l10n-id="advanced-logout-description">Removes this
identity from this client entirely: the secret, pins, contacts and every cached message.
Without a backup of the secret there is no way back in. Mail already fetched here may have no
other copy anywhere, unless "leave mail on server" was on. Whatever is still on the server
comes back on the first fetch after logging back in.</p>
<button type="button" id="logout" class="danger" data-l10n-id="advanced-logout-button">Log out and delete all data</button>
<span id="logout-error" class="error"></span>
</section>
</div>
</div>
</dialog>
<dialog id="confirm">
<div class="confirm-body">
<h2 id="confirm-title"></h2>
<p id="confirm-text"></p>
</div>
<footer>
<button type="button" id="confirm-cancel" autofocus data-l10n-id="action-cancel">Cancel</button>
<button type="button" id="confirm-ok"></button>
</footer>
</dialog>
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

1660
web/js/app.js Normal file

File diff suppressed because it is too large Load diff

7
web/js/config.js Normal file
View file

@ -0,0 +1,7 @@
// Deploy-time configuration, empty by default. A plain checkout (`devbox run
// serve`) ships this file untouched, so PRESET_SERVER is null and nothing
// below changes behavior. The nix package can overwrite this file at build
// time with a real value — see nix/bridge.nix and the NixOS module's
// `services.gsmol-bridge.presetServer` option.
export const PRESET_SERVER = null;
// Shape when set: { host: "example.org", publicKey: "base32-encoded-key" }

437
web/js/crypto.js Normal file
View file

@ -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));
}

31
web/js/i18n.js Normal file
View file

@ -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));
}

92
web/js/noise.js Normal file
View file

@ -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;
}
}

481
web/js/proto.js Normal file
View file

@ -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 = /^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\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}`);
}

564
web/js/store.js Normal file
View file

@ -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
});
}

29
web/js/transport.js Normal file
View file

@ -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?.();
});
}

287
web/locales/en-US.json Normal file
View file

@ -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": "<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."
}

628
web/style.css Normal file
View file

@ -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 <button>, so they are reachable and openable from the
keyboard; the generic button styling above is undone here. */
.item {
display: flex;
gap: 12px;
align-items: baseline;
width: 100%;
padding: 9px 16px;
text-align: left;
border: 0;
border-bottom: 1px solid var(--divider);
border-radius: 0;
background: var(--surface);
cursor: pointer;
color: var(--muted);
}
.item:hover { background: var(--hover); }
.item.selected { background: var(--accent-soft); }
.item.unread { color: var(--fg); }
.item .who { width: 150px; flex: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item .mid { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .subject { font-weight: 400; }
.item.unread .who { font-weight: 700; }
.item.unread .subject { font-weight: 700; }
.item .snippet { color: var(--muted); }
.item.unread .snippet { color: var(--fg); }
.item .when { flex: none; font-size: 12px; color: var(--muted); }
.item button { padding: 2px 12px; font-size: 12px; }
.item.contact .mid { white-space: normal; line-height: 1.45; font-family: var(--mono); font-size: 12px; color: var(--muted); }
.item.contact .badge + .badge { margin-left: 6px; }
/* A sender whose key is bound to no address, visible before opening the row. */
.item .dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background: var(--alert-fg);
}
.badge {
display: inline-block;
margin-top: 3px;
font-family: var(--ui);
font-size: 11px;
padding: 1px 9px;
border-radius: 10px;
background: var(--hover);
color: var(--muted);
}
.badge.ok { background: var(--ok-bg); color: var(--ok-fg); }
.badge.alert { background: var(--alert-bg); color: var(--alert-fg); }
.view { flex: 1; min-width: 0; overflow-y: auto; padding: 24px 28px; }
.view h2 { margin: 0 0 10px; font-size: 20px; font-weight: 400; }
.view .meta { font-size: 13px; color: var(--muted); margin-bottom: 14px; white-space: pre-line; word-break: break-word; }
.view .fields { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 10px; white-space: pre-line; }
.view pre {
white-space: pre-wrap;
overflow-wrap: anywhere; /* an unbroken URL must not widen the reader */
font-family: inherit;
margin: 14px 0 0;
padding: 14px 0 0;
border-top: 1px solid var(--border);
}
.view .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
.view .actions button { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); box-shadow: var(--shadow); color: var(--fg); }
.view .actions button:hover { background: var(--hover); color: var(--fg); }
/* Only meaningful once the reader covers the list, below the breakpoint. */
.back { display: none; align-items: center; gap: 6px; margin: 0 0 10px -8px; }
/* What is actually known about the signer: a signature always verifies here, so
the chip reports the key-to-address binding, which is the part that can be weak. */
.trust { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.trust .keyblock { font-family: var(--mono); font-size: 12px; line-height: 1.55; color: var(--muted); }
.trust .keyblock .full { word-break: break-all; }
/* Superseded contact keys: the local record that a rotation happened. */
.history { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.history h3 {
margin: 0 0 4px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.history .keyblock { margin-top: 12px; font-family: var(--mono); font-size: 12px; color: var(--muted); line-height: 1.55; }
.history .keyblock .full { word-break: break-all; }
.history .replaced { font-family: var(--ui); font-style: italic; }
.warn { color: var(--warn); }
.placeholder { color: var(--muted); font-style: italic; padding: 12px 16px; }
.name-sender { display: flex; gap: 8px; margin: 12px 0 0; }
.name-sender input { flex: 1; }
/* --- setup -------------------------------------------------------------------- */
/* One card at a time: "create" and "restore" are the same question asked two
ways, and showing both at once was most of what made first run look busy. */
.setup {
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 24px 16px;
overflow-y: auto;
}
.setup-card {
width: min(440px, 100%);
display: flex;
flex-direction: column;
gap: 14px;
padding: 28px 28px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.setup-card h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
text-align: center;
}
.setup-card .lead { margin: 0; color: var(--muted); text-align: center; }
.setup-card .stack { display: flex; flex-direction: column; gap: 14px; }
.setup-card label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--muted);
}
.setup-card label input { font-family: var(--mono); font-size: 13px; }
#restore-extra label { margin-top: 10px; }
.hint.centered { text-align: center; }
.large { padding: 11px 20px; font-size: 15px; }
/* The quieter sibling of a card's one real action, not a second one. */
.link {
align-self: center;
padding: 6px 8px;
font-size: 13px;
color: var(--muted);
text-decoration: underline;
text-underline-offset: 3px;
}
.link:hover { color: var(--accent); background: none; }
/* A two-way choice feeding one piece of state (screen 5's new/existing
address mode) — same shape as .settings-tabs, laid out horizontally. */
.mode-toggle {
display: flex;
gap: 2px;
padding: 3px;
background: var(--raised);
border-radius: var(--radius);
}
.mode-toggle button {
flex: 1;
padding: 8px 10px;
font-size: 13px;
font-weight: 500;
color: var(--fg);
border-radius: calc(var(--radius) - 3px);
}
.mode-toggle button:hover { background: var(--hover); }
.mode-toggle button.active { background: var(--accent-soft); color: var(--accent); }
/* --- dialogs --------------------------------------------------------------------- */
dialog {
border: 0;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 0;
color: var(--fg);
background: var(--surface);
min-width: min(460px, calc(100vw - 32px));
max-width: min(640px, calc(100vw - 32px));
max-height: calc(100dvh - 48px);
overflow: hidden;
}
dialog::backdrop { background: var(--backdrop); }
.dialog-head {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 12px 13px 22px;
border-bottom: 1px solid var(--border);
}
.dialog-head h2 { flex: 1; margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
.dialog-head .icon-only { color: var(--muted); }
.dialog-head .icon-only:hover { color: var(--fg); }
/* The recovery card can push content past the viewport on a short window;
dialog's own overflow:hidden would just clip it with no way back. */
#compose { overflow-y: auto; }
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
#compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; }
#compose .seed-card { margin: 0; }
#compose .actions { display: flex; gap: 8px; margin: 4px 0; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
#confirm { max-width: min(420px, calc(100vw - 32px)); }
#confirm .confirm-body { padding: 22px 22px 4px; }
#confirm .confirm-body h2 { margin: 0 0 8px; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
#confirm .confirm-body p { margin: 0; font-size: 13px; color: var(--muted); white-space: pre-wrap; }
#confirm footer { display: flex; gap: 8px; justify-content: flex-end; padding: 18px 22px 22px; }
/* Without this every action read exactly like "cancel" and "close". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
.filled:disabled { background: var(--hover); color: var(--muted); box-shadow: none; }
/* Irreversible, not just consequential — rotate stays plain-text, this doesn't. */
.danger { background: var(--warn); color: var(--on-warn); font-weight: 500; padding: 8px 20px; }
.danger:hover { background: var(--warn); box-shadow: var(--shadow); }
.error { color: var(--warn); font-size: 13px; }
/* A secondary action standing on its own under a paragraph: the border is what
stops its padding from reading as a stray indent. */
.outline { border: 1px solid var(--border); background: var(--surface); color: var(--fg); font-weight: 500; }
.outline:hover { background: var(--hover); box-shadow: var(--shadow); }
/* --- settings -------------------------------------------------------------------- */
/* Tabs, because the account, the server, a mailbox preference, the backup file
and the irreversible operations have nothing to say to each other — reading
all five in one scroll was the overwhelming part. Scoped to [open]: a bare
`display: flex` would override the UA's `display: none` on a closed dialog. */
#settings { max-width: min(760px, calc(100vw - 32px)); }
#settings[open] { display: flex; flex-direction: column; }
.settings-body { flex: 1; min-height: min(440px, 58dvh); display: flex; align-items: stretch; }
.settings-tabs {
flex: none;
width: 148px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 8px;
background: var(--raised);
border-right: 1px solid var(--border);
}
.settings-tabs button {
width: 100%;
padding: 8px 12px;
text-align: left;
color: var(--fg);
font-weight: 500;
}
.settings-tabs button:hover { background: var(--hover); }
.settings-tabs button.active { background: var(--accent-soft); color: var(--accent); }
.settings-panels { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px 24px; }
#settings h3 {
margin: 24px 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
#settings h3:first-child { margin-top: 0; }
#settings .danger-head { color: var(--warn); }
#settings form { display: flex; gap: 10px; margin: 12px 0 6px; flex-wrap: wrap; align-items: flex-end; }
#settings label {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
min-width: 170px;
font-size: 12px;
color: var(--muted);
}
#settings label input { font-family: var(--mono); font-size: 13px; }
#settings details { margin: 12px 0 2px; }
/* A checkbox with a multi-line label, e.g. "leave mail on server" — the box
sits at the label's cap height rather than centered against the full wrapped
block, which is what made it look adrift. */
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
#settings label.check-row input { flex: none; margin: 3px 0 0; }
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
.check-row .hint { display: block; margin: 2px 0 0; }
.hint { font-size: 12px; color: var(--muted); margin: 4px 0; }
/* label / value rows, in place of the monospace `key: value` dump */
.field { display: flex; align-items: baseline; gap: 12px; padding: 4px 0; }
.field-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); }
.field-value { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.field-value.empty { font-family: inherit; font-style: italic; color: var(--muted); }
.seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 4px 0 12px; }
.seed-card .row { display: flex; align-items: center; gap: 8px; }
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
/* A button whose result is explained underneath rather than beside it — each
of export/import needs its own line, so side-by-side ran out of room. */
.action-row { margin: 14px 0; }
.action-row .hint { margin: 4px 0 0; }
/* A title/subtitle pair with a control at the end, e.g. the timeout spinner —
same shape as .check-row but for a value instead of a toggle. */
.spin-row { display: flex; align-items: center; gap: 10px; margin: 14px 0; }
.spin-row .spin-text { flex: 1; min-width: 0; }
.spin-row .spin-text .check-title { display: block; }
.spin-row .spin-text .hint { display: block; margin: 2px 0 0; }
.spin-row input[type="number"] { width: 72px; font-family: var(--mono); font-size: 13px; text-align: right; }
.copy { font-size: 12px; padding: 4px 10px; }
/* --- toast ------------------------------------------------------------------------ */
.toast {
position: fixed;
left: 24px;
bottom: 24px;
max-width: 520px;
margin: 0;
padding: 12px 18px;
border-radius: var(--radius);
background: var(--toast-bg);
color: var(--toast-fg);
font-size: 13px;
white-space: pre-wrap;
box-shadow: var(--shadow);
cursor: pointer;
}
.toast.warn { background: var(--warn); color: var(--on-warn); }
.toast[hidden] { display: none; }
/* --- narrow layouts ----------------------------------------------------------------- */
/* The reader takes the whole pane and the list steps aside, rather than both
being squeezed below the width either one needs. */
@media (max-width: 1000px) {
.listpane { flex: 1; min-width: 0; max-width: none; border-right: 0; }
.view { display: none; }
#app.reading .listpane { display: none; }
#app.reading .view { display: block; }
.back { display: inline-flex; }
}
@media (max-width: 640px) {
main { flex-direction: column; }
.rail {
width: auto;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.compose { margin: 0; padding: 8px 20px; }
.compose { align-self: center; }
#folders { display: flex; gap: 4px; }
#folders button { width: auto; gap: 8px; padding: 9px 12px; }
#folders .label { display: none; } /* aria-label keeps the accessible name */
.topbar { gap: 8px; padding: 8px 12px; }
.search { max-width: none; padding: 6px 12px; }
.account { display: none; }
.item .who { width: auto; flex: 0 1 110px; }
.view { padding: 16px; }
.toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
.setup-card { padding: 22px 18px 14px; }
/* The tab rail turns into a scrollable strip rather than eating half the
width of a phone-sized dialog. */
.settings-body { flex-direction: column; min-height: 0; }
.settings-tabs {
width: auto;
flex-direction: row;
overflow-x: auto;
padding: 8px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.settings-tabs button { width: auto; }
.settings-panels { padding: 16px 16px 20px; }
.dialog-head { padding: 12px 8px 12px 16px; }
}
@media (prefers-reduced-motion: reduce) {
button { transition: none; }
}