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.