From a2faf6cfffb121355e6d4eca7b0fc678540a22e0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 10:37:52 +0000 Subject: [PATCH] Pit toggle: trust each pit name's certificate on first https use, against the registry pin https://chovy.hacker/ on the pit route showed an invalid certificate: the resolver reaches the real origin, which serves a self-signed leaf for its own name, and nothing told the browser to trust it. moshcode dns trust does that for the system store with root; the helper now does the no-root browser equivalent per name. On the first HTTPS CONNECT it fetches the served certificate, computes the RFC 7469 pin of its key (small DER walker, no deps), checks it against the registry's published pins, refuses CA:TRUE, and imports the leaf as a peer (certutil -t P,,) into ~/.pki/nssdb under the launcher's nickname, all before the SOCKS reply so the browser's handshake already finds it trusted. Linux + certutil only; /pit/start reports trust availability and the sidebar says which case applies. Proven with tstclnt: 200 OK with the imported DB, SEC_ERROR_UNKNOWN_ISSUER with an empty one. Helper 3.4.0. Co-Authored-By: Claude Fable 5.1 --- .../extensions/ai-sidebar/background.js | 2 +- .../extensions/ai-sidebar/sidepanel.js | 15 +- apps/desktop/launcher/tron-tor-helper | 191 +++++++++++++++++- apps/desktop/launcher/tronbrowser | 2 +- docs/moshpit-pit-toggle.md | 47 ++++- 5 files changed, 241 insertions(+), 16 deletions(-) diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index cced25d..c4b01be 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -390,7 +390,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } await enablePit(); - sendResponse({ enabled: true, check: started.check || null, port: started.port }); + sendResponse({ enabled: true, check: started.check || null, trust: started.trust || null, port: started.port }); } else { await disablePit(); await stopPitViaHelper(); diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js index 87796b8..bd2fdbc 100644 --- a/apps/desktop/extensions/ai-sidebar/sidepanel.js +++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js @@ -314,9 +314,18 @@ async function togglePit() { if (turningOn) showNetStatus('', 'Starting the pit resolver…'); try { const res = await chrome.runtime.sendMessage({ type: 'pit-set', on: turningOn }); - const tip = - 'Clearnet names are untouched. https:// on a pit name needs ' + - 'moshcode dns enable once, for the certificate.'; + // What https:// on a pit name will do here. The helper trusts each name's + // certificate on first use when the registry publishes a matching pin, but + // only where it can write the browser's trust store (Linux + certutil). + const trust = res && res.trust; + const httpsTip = !trust + ? '' + : trust.available + ? 'https:// on a pit name is trusted per name on first use, when the registry publishes its pin.' + : trust.why === 'no-certutil' + ? 'https:// on a pit name will warn until certutil is installed (Debian/Ubuntu: libnss3-tools, Fedora: nss-tools, Arch: nss).' + : 'https:// on a pit name will warn on this platform; run moshcode dns enable for the certificate.'; + const tip = `Clearnet names are untouched. ${httpsTip}`; if (!turningOn) { setPitButton(false); hideNetStatus(); diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper index eb39bca..1f4acd0 100755 --- a/apps/desktop/launcher/tron-tor-helper +++ b/apps/desktop/launcher/tron-tor-helper @@ -18,26 +18,31 @@ Endpoints (POST/GET on 127.0.0.1): /status {"running": bool, "ready": bool, "torInstalled": bool, "pit": {…}} /pit/start start the Moshpit SOCKS5 resolver → {"started": true, "check": {…}} /pit/stop stop it → {"stopped": true} - /pit/status {"running": bool, "port": int, "doh": url} + /pit/status {"running": bool, "port": int, "doh": url, "trust": {…}} Single-instance: binds a fixed loopback port; a second copy exits cleanly when the port is taken, so the launcher can fire-and-forget it every launch. """ import base64 import glob +import hashlib import json import os +import platform import random import re import select import shutil import signal import socket +import ssl import struct import subprocess import sys import threading import time +import urllib.error +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -49,7 +54,7 @@ BUNDLED_DIR = os.environ.get("TRON_TOR_BIN_DIR", "") PIDFILE = os.environ.get("TRON_TOR_PIDFILE", "") # Bumped whenever the helper protocol/behaviour changes; the launcher kills a # stale helper so the current version always runs. -HELPER_VERSION = "3.3.1" +HELPER_VERSION = "3.4.0" _lock = threading.Lock() _proc = None # the running tor subprocess (or None) _ready = False # True once tor reported Bootstrapped 100% @@ -327,6 +332,178 @@ def pit_resolve(name): return ips +# --- HTTPS on a pit name: trust the served leaf, on the strength of its pin ---- +# No public CA issues for a name outside the ICANN root, so an origin such as +# chovy.hacker serves a self-signed leaf for its own name and the registry +# publishes the SHA-256 of that key (`/api/moshpit/pins?name=`, RFC 7469 pin). +# `moshcode dns trust ` installs such a leaf into the SYSTEM store, with +# root. The no-root equivalent for this browser: on the first HTTPS CONNECT for +# a name, fetch the certificate it serves, check its key against the published +# pins, refuse anything marked CA:TRUE, and import the leaf as a *peer* ("P,,") +# into the user's NSS database — the store Chromium on Linux consults, and the +# same nickname the launcher's sync_moshpit_trust uses, so neither imports the +# other's work twice. Peer trust vouches for that one certificate and the name +# in its SAN, nothing else. Done before the SOCKS reply, so the browser's TLS +# handshake that follows already finds the certificate trusted. +PIT_REGISTRY = os.environ.get("TRON_PIT_REGISTRY", "https://pit.moshcode.sh").rstrip("/") +PIT_NSSDB = os.environ.get("TRON_PIT_NSSDB", os.path.expanduser("~/.pki/nssdb")) +PIT_CERT_DIR = os.environ.get("TRON_PIT_CERT_DIR", os.path.expanduser("~/.tronbrowser/pit-certs")) +_trust_lock = threading.Lock() +_trust_seen = {} # name -> (ok, why); retried after a failure only once the pit restarts + + +def _der_tlv(buf, pos): + """One DER element at `pos`: (tag, value, raw_bytes, end).""" + tag = buf[pos] + length = buf[pos + 1] + head = 2 + if length & 0x80: + n = length & 0x7F + length = int.from_bytes(buf[pos + 2:pos + 2 + n], "big") + head += n + end = pos + head + length + if end > len(buf): + raise ValueError("truncated DER") + return tag, buf[pos + head:end], buf[pos:end], end + + +def _der_children(value): + out, pos = [], 0 + while pos < len(value): + tag, val, raw, pos = _der_tlv(value, pos) + out.append((tag, val, raw)) + return out + + +def cert_pin_and_ca(der): + """(spki pin, is_ca) for an X.509 certificate in DER. + pin = base64(sha256(SubjectPublicKeyInfo)); is_ca from basicConstraints.""" + _tag, cert, _raw, _end = _der_tlv(der, 0) + tbs = _der_children(cert)[0][1] + fields = _der_children(tbs) + if fields and fields[0][0] == 0xA0: # explicit version + fields = fields[1:] + # serial, signature, issuer, validity, subject, subjectPublicKeyInfo, ... + spki_raw = fields[5][2] + pin = base64.b64encode(hashlib.sha256(spki_raw).digest()).decode("ascii") + is_ca = False + for tag, val, _raw in fields[6:]: + if tag != 0xA3: # extensions + continue + for _t, ext, _r in _der_children(_der_children(val)[0][1]): + parts = _der_children(ext) + if parts and parts[0][1] == b"\x55\x1d\x13": # OID 2.5.29.19 basicConstraints + octets = parts[-1][1] + bc = _der_children(_der_children(octets)[0][1]) if octets else [] + is_ca = any(t == 0x01 and v and v[0] != 0 for t, v, _ in bc) + return pin, is_ca + + +def served_certificate(ip, name, port=443, timeout=8.0): + """DER of the certificate `ip` serves for SNI `name` — fetched WITHOUT + verification, because deciding whether to trust it is the whole point.""" + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection((ip, port), timeout=timeout) as raw: + with ctx.wrap_socket(raw, server_hostname=name) as tls: + return tls.getpeercert(binary_form=True) + + +def published_pins(name, timeout=6.0): + url = "%s/api/moshpit/pins?name=%s" % (PIT_REGISTRY, urllib.parse.quote(name)) + req = urllib.request.Request(url, headers={"Accept": "application/json", + "User-Agent": "tron-pit-helper/%s" % HELPER_VERSION}) + try: + with urllib.request.urlopen(req, timeout=timeout) as res: + data = json.loads(res.read(65535).decode("utf-8", "replace")) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return [] # the registry has no record for this name: no pin, not an outage + raise + pins = data.get("pins") if isinstance(data, dict) else None + return [p for p in pins if isinstance(p, str)] if isinstance(pins, list) else [] + + +def _safe_name(name): + return re.sub(r"\.{2,}", ".", re.sub(r"[^a-z0-9.-]", "", name.lower())).strip(".-") + + +def trust_available(): + """Can this machine take a per-name import at all? {available, why}.""" + if platform.system() != "Linux": + return {"available": False, "why": "unsupported-platform"} + if not shutil.which("certutil"): + return {"available": False, "why": "no-certutil"} + return {"available": True, "why": "certutil"} + + +def _certutil(*args): + return subprocess.run(["certutil", "-d", "sql:" + PIT_NSSDB] + list(args), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=20) + + +def ensure_leaf_trust(name, ip): + """Make the browser trust what `name` serves on 443, if the registry vouches + for it. Returns (ok, why); never raises. Runs once per name per pit run.""" + key = _safe_name(name) + with _trust_lock: + if key in _trust_seen: + return _trust_seen[key] + result = _ensure_leaf_trust(key, ip) + _trust_seen[key] = result + return result + + +def _ensure_leaf_trust(name, ip): + avail = trust_available() + if not avail["available"]: + log("pit: https for %s: cannot import trust here (%s)" % (name, avail["why"])) + return False, avail["why"] + if not name or "." not in name: + return False, "bad-name" + nick = "moshpit %s" % name + if not os.path.exists(os.path.join(PIT_NSSDB, "cert9.db")): + os.makedirs(PIT_NSSDB, mode=0o700, exist_ok=True) + if _certutil("-N", "--empty-password").returncode != 0: + return False, "nssdb-create-failed" + if _certutil("-L", "-n", nick).returncode == 0: + return True, "already-trusted" + try: + der = served_certificate(ip, name) + pin, is_ca = cert_pin_and_ca(der) + except (OSError, ValueError, IndexError, ssl.SSLError) as exc: + log("pit: https for %s: could not read the served certificate: %s" % (name, exc)) + return False, "no-certificate" + try: + pins = published_pins(name) + except Exception as exc: # noqa: BLE001 — an outage is not a failed pin check + log("pit: https for %s: registry unreachable for pins: %s" % (name, exc)) + return False, "registry-unreachable" + if not pins: + log("pit: https for %s: the registry publishes no pin — nothing vouches for its certificate" % name) + return False, "no-pin" + if pin not in pins: + log("pit: https for %s: served key %s is not among the %d published pin(s) — refusing" % (name, pin, len(pins))) + return False, "pin-mismatch" + if is_ca: + log("pit: https for %s: certificate is CA:TRUE — refusing to trust a key that could vouch for any name" % name) + return False, "ca-true" + try: + os.makedirs(PIT_CERT_DIR, mode=0o700, exist_ok=True) + cert_file = os.path.join(PIT_CERT_DIR, "moshpit-%s.crt" % name) + with open(cert_file, "w") as f: + f.write(ssl.DER_cert_to_PEM_cert(der)) + except OSError as exc: + return False, "write-failed: %s" % exc + res = _certutil("-A", "-t", "P,,", "-n", nick, "-i", cert_file) + if res.returncode != 0: + log("pit: https for %s: certutil failed: %s" % (name, res.stdout.strip())) + return False, "certutil-failed" + log("pit: https for %s: trusted its certificate (pin %s matches the registry) in %s" % (name, pin, PIT_NSSDB)) + return True, "trusted" + + def _recv_exact(sock, n): buf = b"" while len(buf) < n: @@ -400,6 +577,11 @@ def _pit_serve(conn): log("pit: no address for %s" % host) _socks_reply(conn, 0x04) # host unreachable return + if atyp == 3 and port == 443: + # Before the browser's TLS handshake, so it already finds the leaf + # trusted. Once per name; a refusal just leaves the browser's own + # warning in place. + ensure_leaf_trust(host, ips[0]) rep = 0x05 for ip in ips[:3]: try: @@ -497,6 +679,8 @@ def stop_pit(): log("pit: stopped") with _pit_cache_lock: _pit_cache.clear() + with _trust_lock: + _trust_seen.clear() def pit_probe(): @@ -514,7 +698,8 @@ def pit_probe(): def pit_status(): with _pit_lock: running = _pit is not None and _pit.is_alive() - return {"running": running, "port": PIT_SOCKS_PORT, "doh": PIT_DOH_URL, "version": HELPER_VERSION} + return {"running": running, "port": PIT_SOCKS_PORT, "doh": PIT_DOH_URL, + "trust": trust_available(), "version": HELPER_VERSION} class Handler(BaseHTTPRequestHandler): diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser index 0916c4f..784e66a 100755 --- a/apps/desktop/launcher/tronbrowser +++ b/apps/desktop/launcher/tronbrowser @@ -175,7 +175,7 @@ if [ "$TOR" != "1" ]; then # running helper isn't this version — otherwise leave a healthy current # helper alone (don't drop an active Tor session). All backgrounded so the # kill+settle never holds up the browser launch. - HELPER_VERSION=3.3.1 + HELPER_VERSION=3.4.0 ( _pf="$DATA/tor-helper.pid" _rv="$(curl -fsS --max-time 1 http://127.0.0.1:9061/status 2>/dev/null | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')" diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md index 72e3ff2..197a736 100644 --- a/docs/moshpit-pit-toggle.md +++ b/docs/moshpit-pit-toggle.md @@ -1,6 +1,6 @@ # 🤘 Pit toggle — Moshpit names for one browser session -**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.3.0 +**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.4.0 **Owner:** desktop (`apps/desktop`) **Scope:** resolve Moshpit names in the running browser with one click. Not a replacement for `moshcode dns enable`, which does it for the whole machine. @@ -18,7 +18,7 @@ ways to make them work, and the settings page says so: | Scope | every application on the machine | this browser session | | Needs root | yes (rewrites the resolver config, installs a local CA) | no | | Survives restart | yes | no — off again on every launch, like 🧅 Tor | -| `https://` on a pit name | works (pinned proxy + local CA) | warns, unless `moshcode dns enable` has installed the CA | +| `https://` on a pit name | works (pinned proxy + local CA) | works on Linux: the leaf is trusted per name on first use, against the registry pin | | Clearnet names | forwarded to public resolvers | never touched | The toggle is for the laptop where DNS is not yours to change, or the first @@ -57,6 +57,37 @@ no ending list to fetch, cache or age out. The PAC is not `mandatory`: if it ever fails to evaluate, Chromium falls back to `DIRECT` and ordinary browsing keeps working. +## HTTPS on a pit name + +No public CA issues for a name outside the ICANN root, so an origin such as +`chovy.hacker` serves a self-signed leaf for its own name and the registry +publishes the SHA-256 of that key (`/api/moshpit/pins?name=`, the RFC 7469 pin +format). `moshcode dns trust ` installs such a leaf into the system store, +with root. The pit toggle does the no-root equivalent for this browser: + +1. On the first HTTPS `CONNECT` for a name, the helper fetches the certificate + the origin serves (without verifying it: deciding whether to trust it is the + point), computes the pin of its key, and fetches the registry's pins. +2. The key must match a published pin, and the certificate must not be marked + `CA:TRUE` (a CA trusted directly could vouch for any name; the same refusal + `moshcode dns trust` makes). +3. The leaf is written to `~/.tronbrowser/pit-certs/moshpit-.crt` and + imported into `~/.pki/nssdb` as a **peer** (`certutil -t P,,`) under the + nickname `moshpit `, the same nickname the launcher's trust sync uses, + so neither imports the other's work twice. Peer trust vouches for that one + certificate and the name in its SAN, nothing else. +4. All of this happens before the SOCKS reply, so the browser's TLS handshake + that follows already finds the certificate trusted. + +Linux only for now (Chromium on macOS reads the keychain, which needs an +interactive prompt), and it needs `certutil` (Debian/Ubuntu `libnss3-tools`, +Fedora `nss-tools`, Arch `nss`); `install.sh` installs it on machines that have +Moshpit certificates. The sidebar says which case applies when the pit turns on. +A name the registry publishes no pin for is left alone and the browser's own +warning stands. If a name was already opened and rejected in this session +before the pit was on, Chromium may keep that verdict cached for a while; +reopening the tab or restarting the browser clears it. + ## Tor and the pit are exclusive The pit's PAC asks the **system** resolver about every host. With Tor on, that @@ -85,14 +116,15 @@ would leak every lookup outside Tor, so: | File | Role | | --- | --- | -| `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client | +| `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client, per-name leaf trust | | `apps/desktop/launcher/tronbrowser` | starts the helper; `HELPER_VERSION` must match the helper's so a stale one is replaced | | `apps/desktop/extensions/ai-sidebar/pit-proxy.js` | the PAC + proxy config (pure, tested in `pit-proxy.test.js`) | | `apps/desktop/extensions/ai-sidebar/background.js` | `pit-set` / `pit-status` messages, badge, session-scoped state | | `apps/desktop/extensions/ai-sidebar/sidepanel.*` | the button and its status copy | Environment knobs on the helper: `TRON_PIT_SOCKS_PORT` (9081), -`TRON_PIT_DOH_URL`, `TRON_PIT_PROBE_NAME`. +`TRON_PIT_DOH_URL`, `TRON_PIT_PROBE_NAME`, `TRON_PIT_REGISTRY`, +`TRON_PIT_NSSDB` (`~/.pki/nssdb`), `TRON_PIT_CERT_DIR`. ## Testing the helper by hand @@ -106,10 +138,9 @@ curl -X POST http://127.0.0.1:19061/pit/stop ## Not in this version -- **`https://` on pit names without the CA.** The pit page documents it: no - public CA issues for a namespace outside the ICANN root. `moshcode dns enable` - installs the Moshpit CA and the launcher mirrors it into Chromium's trust - store on every start, so the two features compose. +- **`https://` on macOS and Windows.** Per-name trust writes the NSS database, + which only Chromium on Linux reads. `moshcode dns enable` remains the answer + there. - **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a registered name override a clearnet one. The toggle only implements the default `fallback` policy.