Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/extensions/ai-sidebar/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. <code>https://</code> on a pit name needs ' +
'<code>moshcode dns enable</code> 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
? '<code>https://</code> on a pit name is trusted per name on first use, when the registry publishes its pin.'
: trust.why === 'no-certutil'
? '<code>https://</code> on a pit name will warn until <code>certutil</code> is installed (Debian/Ubuntu: <code>libnss3-tools</code>, Fedora: <code>nss-tools</code>, Arch: <code>nss</code>).'
: '<code>https://</code> on a pit name will warn on this platform; run <code>moshcode dns enable</code> for the certificate.';
const tip = `Clearnet names are untouched. ${httpsTip}`;
if (!turningOn) {
setPitButton(false);
hideNetStatus();
Expand Down
191 changes: 188 additions & 3 deletions apps/desktop/launcher/tron-tor-helper
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,31 @@
/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

Expand All @@ -49,7 +54,7 @@
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%
Expand Down Expand Up @@ -327,6 +332,178 @@
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 <name>` 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:
Comment thread
ralyodio marked this conversation as resolved.
Dismissed
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:
Expand Down Expand Up @@ -400,6 +577,11 @@
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:
Expand Down Expand Up @@ -497,6 +679,8 @@
log("pit: stopped")
with _pit_cache_lock:
_pit_cache.clear()
with _trust_lock:
_trust_seen.clear()


def pit_probe():
Expand All @@ -514,7 +698,8 @@
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):
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/launcher/tronbrowser
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down
47 changes: 39 additions & 8 deletions docs/moshpit-pit-toggle.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 <name>` 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-<name>.crt` and
imported into `~/.pki/nssdb` as a **peer** (`certutil -t P,,`) under the
nickname `moshpit <name>`, 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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
Loading