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.