tor.sailfishos.dev / blog
Sailfish OS 5.0.0.73 Jolla C2 2026-03
// Technical Write-Up

Tor on Sailfish OS 5.0
Hidden Services & the Control Interface

A deep dive into running tor as a managed systemd service, building a Sailjail-sandboxed QML control UI, and exposing local ports as v3 .onion hidden services — all on the Jolla C2 running SFOS 5.0.0.73.

Sailfish Community Dev tor-0.4.8.x Sailjail systemd Polkit D-Bus

§01 Architecture & Trust Model

Before writing a single line of QML, it's worth being explicit about what we're actually building and which security boundaries we're crossing. A phone-based Tor control app sits at the intersection of three distinct privilege domains:

┌─────────────────────────────────────────────────────────────────────┐ USER SPACE (defaultuser, uid 100000) harbour-tor-button.qml ──D-Bus──▶ Nemo.DBus proxy (Sailjail) │ ├─────────────────────────────────────────────────────────────────────┤ POLKIT GATE (org.freedesktop.systemd1.manage-units) Subject: defaultuser ──check──▶ polkitd ──YES/NO──▶ ├─────────────────────────────────────────────────────────────────────┤ SYSTEM SPACE (root / systemd) systemd ◀──StartUnit/StopUnit── D-Bus call (after Polkit YES) └──▶ tor.service (runs as user tor, uid 107) └──▶ ControlPort 9051 (cookie auth) └──▶ /var/lib/tor/hidden_service/ (mode 0700) └─────────────────────────────────────────────────────────────────────┘

The key design principle: the QML app never runs as root, never reads the hidden-service private key, and never touches torrc directly. All privileged operations route through a narrow, auditable D-Bus interface. This is the only architecture that passes muster for Harbour submission and for actual security.

ℹ Note for Tor Developers
The tor process itself runs under a dedicated tor system user created by the RPM %pre scriptlet. The hidden service directory is owned tor:tor with mode 0700 — consistent with the restrictions tor enforces on startup. We never relax these permissions to accommodate the UI.

§02 The Sailjail Sandbox Problem

Sailjail, introduced in SFOS 4.x and significantly tightened in 5.0, applies a Firejail-based seccomp+namespaces sandbox to all Harbour apps. On the C2, the default profile aggressively restricts:

RestrictionImpact on Tor UIResolution
No raw socket access Cannot open direct TCP connection to ControlPort 9051 Route via D-Bus proxy; the proxy process runs outside Sailjail
No access to /var/lib/tor/ Cannot read hostname file for .onion address Expose address via a dedicated D-Bus method on the proxy
No AF_UNIX outside $XDG_RUNTIME_DIR Blocks some D-Bus socket paths Use Permissions=Privileged only for system bus, not session bus
Namespace isolation invoker mis-identifies QML entry point as binary Explicit --type=silica-qt5 flag; see §7

The full Sailjail permission set for the .desktop entry ended up being minimal — a good sign:

harbour-tor-button.desktopdesktop
[Desktop Entry]
Name=Tor Control
Exec=invoker --type=silica-qt5 /usr/share/harbour-tor-button/qml/harbour-tor-button.qml
Icon=harbour-tor-button
Type=Application

[X-Sailjail]
# Principle of least privilege: no Internet permission needed.
# All network ops are delegated to the D-Bus helper process.
Permissions=Privileged
OrganizationName=harbour
ApplicationName=harbour-tor-button
⚠ Avoid This
Earlier drafts used Permissions=Internet;Network;Privileged. The Internet permission is unnecessary because the app never opens a socket itself, and granting it leaks capability to the sandbox. Harbour reviewers will flag this. Use the minimum permission set.

§03 Privilege Escalation via Polkit

The central challenge: systemctl start tor.service requires root. The correct solution on a systemd+Polkit system is a narrow Polkit rule — not sudo rules, not SUID wrappers, not running the entire app as root.

The Rule

/etc/polkit-1/rules.d/50-tor-sailfish.rulesjavascript
// Allow 'defaultuser' to start/stop/restart tor.service only.
// Any other unit name is denied by default rule fallthrough.
polkit.addRule(function(action, subject) {
    if (action.id === "org.freedesktop.systemd1.manage-units"
        && action.lookup("unit") === "tor.service"
        && action.lookup("verb") !== "mask"
        && action.lookup("verb") !== "unmask"
        && subject.user === "defaultuser"
        && subject.local
        && subject.active) {
        return polkit.Result.YES;
    }
});

Key hardening additions over a naive implementation:

  • Verb check — explicitly deny mask and unmask. Without this, a malicious invocation could mask an unrelated system service.
  • subject.local && subject.active — ensures the authorization only applies to an active local session, blocking remote or background invocations.
  • Exact unit name match — not a prefix or glob. "tor.service" only.

QML-Side D-Bus Call

TorControl.qml (excerpt)qml
DBusInterface {
    id: systemd
    service: "org.freedesktop.systemd1"
    path: "/org/freedesktop/systemd1"
    iface: "org.freedesktop.systemd1.Manager"
    bus: DBus.SystemBus
}

function startTor() {
    // "replace" mode: if a job is already pending, replace it.
    systemd.call("StartUnit", ["tor.service", "replace"])
}

function stopTor() {
    systemd.call("StopUnit", ["tor.service", "replace"])
}

function getActiveState() {
    // Poll the unit's ActiveState property for UI feedback.
    var unitPath = systemd.call("GetUnit", ["tor.service"])
    return unitIface.getProperty("ActiveState")
    // Returns: "active" | "inactive" | "activating" | "failed"
}

§04 Tor Control Protocol Integration

For richer interaction — reading circuit status, sending NEWNYM, querying the GETINFO namespace — we need to talk the Tor Control Protocol (tor-spec §3). This is a simple line-oriented text protocol over TCP port 9051 (or a Unix socket).

💡 For Tor Devs
We deliberately chose cookie authentication over HashedControlPassword because it avoids storing a password-derived secret on a device that may have physical access risks. The cookie file is at /var/run/tor/control.authcookie (mode 0600, owned by tor). The D-Bus helper — running as a service with supplementary group tor — reads the cookie; the Sailjail app never sees it.

Control Protocol Handshake

tor-dbus-helper / control.py (simplified)python
import socket, binascii, hmac, hashlib, os

COOKIE_PATH = "/var/run/tor/control.authcookie"
CONTROL_ADDR = ("127.0.0.1", 9051)

def safe_cookie_authenticate(sock: socket.socket) -> None:
    """SAFECOOKIE auth per tor control-spec §3.24"""

    # 1. Client nonce (32 bytes random)
    client_nonce = os.urandom(32)

    sock.sendall(b"AUTHCHALLENGE SAFECOOKIE "
                 + binascii.hexlify(client_nonce) + b"\r\n")

    resp = sock.recv(512).decode()
    # Expect: 250 AUTHCHALLENGE SERVERHASH=... SERVERNONCE=...
    assert resp.startswith("250")

    server_hash  = binascii.unhexlify(resp.split("SERVERHASH=")[1].split()[0])
    server_nonce = binascii.unhexlify(resp.split("SERVERNONCE=")[1].strip())

    cookie = open(COOKIE_PATH, "rb").read()

    # 2. Verify server's knowledge of the cookie (MITM guard)
    expected_server = hmac.new(
        cookie,
        b"Tor safe cookie authentication server-to-controller hash"
        + cookie + client_nonce + server_nonce,
        hashlib.sha256
    ).digest()
    assert hmac.compare_digest(server_hash, expected_server), "Server auth failed!"

    # 3. Send our own HMAC
    client_hash = hmac.new(
        cookie,
        b"Tor safe cookie authentication controller-to-server hash"
        + cookie + client_nonce + server_nonce,
        hashlib.sha256
    ).hexdigest()

    sock.sendall(f"AUTHENTICATE {client_hash}\r\n".encode())
    assert sock.recv(64).startswith(b"250")

After authentication, the helper exposes a simple D-Bus API: SendNewnym(), GetCircuitStatus(), GetOnionAddress(serviceId). The QML app calls these methods; it never touches the TCP socket or sees the cookie bytes.

§05 v3 Hidden Service Setup & Key Management

Tor's v3 hidden services use Ed25519 keys. The .onion address is a base32-encoded SHA3-256 truncation of the public key, version byte, and checksum — it's deterministically derived from the key material. This matters for the UI: we display the address by reading the hostname file, never by re-deriving it ourselves.

// v3 Hidden Service Circuit (simplified)
Client
Tor Browser
Guard
layer 3
Middle
layer 2
Rendezvous
layer 1
HS (Jolla C2)
Ed25519

Key Storage & Backup UX

The generated key lives at /var/lib/tor/hidden_service/hs_ed25519_secret_key. This 64-byte file is your .onion identity. The UI needs to let the user back it up without ever displaying the raw key material on-screen (it could be shoulder-surfed or screenshot).

tor-dbus-helper: key export methodpython
import dbus.service, dbus.mainloop.glib, stat, os, shutil, tempfile

KEY_PATH = "/var/lib/tor/hidden_service/hs_ed25519_secret_key"

class TorHelper(dbus.service.Object):

    # Polkit-protected method — only callable by defaultuser on active session.
    @dbus.service.method("harbour.tor.button.Helper",
                         in_signature="s", out_signature="b")
    def ExportKeyToPath(self, dest_dir):
        """Copy hidden service keypair to a user-chosen directory."""
        dest_dir = os.path.realpath(dest_dir)

        # Refuse paths outside the user's home to prevent exfil.
        allowed = ("/home/defaultuser", "/media/sdcard")
        if not any(dest_dir.startswith(p) for p in allowed):
            raise dbus.DBusException("Destination outside allowed paths")

        # Write to a temp file first, then atomic rename.
        fd, tmp = tempfile.mkstemp(dir=dest_dir)
        try:
            with open(KEY_PATH, "rb") as src, os.fdopen(fd, "wb") as dst:
                dst.write(src.read())
            os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)  # 0600
            os.rename(tmp, os.path.join(dest_dir, "hs_ed25519_secret_key.bak"))
            return True
        except Exception:
            os.unlink(tmp)
            raise

§06 torrc Configuration Deep Dive

The following torrc is hardened beyond the defaults shipped by most distros. Annotations explain each non-obvious directive.

/etc/tor/torrctorrc
## ─── Identity & Paths ────────────────────────────────────────────── ##
DataDirectory          /var/lib/tor
PidFile                /var/run/tor/tor.pid
Log                    notice syslog

## ─── Control Port ────────────────────────────────────────────────── ##
# Bind only on loopback. Never 0.0.0.0.
ControlPort            127.0.0.1:9051
CookieAuthentication   1
CookieAuthFileGroupReadable 1
CookieAuthFile         /var/run/tor/control.authcookie

## ─── SOCKS Proxy ─────────────────────────────────────────────────── ##
SocksPort              127.0.0.1:9050 IsolateDestAddr IsolateDestPort
# IsolateDestAddr: different destination IPs use different circuits.
# IsolateDestPort: different ports use different circuits.
# This limits correlation across app connections.

## ─── Hidden Service ──────────────────────────────────────────────── ##
HiddenServiceDir       /var/lib/tor/hidden_service
HiddenServicePort      80 127.0.0.1:8080
# HiddenServiceVersion defaults to 3 since tor 0.4.0 — no need to set it.

## ─── Hardening ───────────────────────────────────────────────────── ##
DisableAllSwap         1     # Prevent key material leaking to swap.
HardwareAccel          0     # Deterministic behaviour; enable if benchmarked.
AvoidDiskWrites        1     # Reduces writes; good for eMMC longevity.
MaxMemInQueues         64 MB # Phone has constrained RAM; cap relay buffer.
NumCPUs                2     # Jolla C2 has 4 cores; leave headroom for UI.

## ─── Not a Relay ─────────────────────────────────────────────────── ##
ExitPolicy             reject *:*
RelayBandwidthRate     0
# These ensure we contribute no relay capacity.
# Improves latency; reduces legal exposure on mobile networks.
💡 SocksPort Isolation
The IsolateDestAddr and IsolateDestPort flags are underused on mobile. Without them, all app traffic on the device can share a single circuit, which allows a malicious server to probe for co-resident apps by timing correlation. With these flags, each (dest-addr, dest-port) pair gets its own circuit.

§07 Debugging & the exit(1) Mystery

The exit(1) silent crash is the first thing new SFOS 5.0 developers hit. The failure chain is subtle:

mapplauncherd (booster daemon) ├─▶ receives launch request for harbour-tor-button ├─▶ peeks at Exec= value: /usr/share/.../harbour-tor-button.qml ├─▶ no --type flag → defaults to generic ELF booster ├─▶ tries to dlopen() a QML file as a shared library └─▶ exit(1) — no error log, journal entry is ambiguous

The fix — adding --type=silica-qt5 to the invoker call — routes the launch through the correct QML-aware booster. The X-Nemo-Application-Type=silica-qt5 line in the desktop entry is for the launcher UI metadata only; the invoker argument is what actually matters at runtime.

Useful Diagnostic Commands

SSH into the Jolla C2shell
# Tail the journal for the app and systemd in parallel
journalctl -f -u tor.service &
journalctl -f _COMM=invoker &

# Check Polkit evaluation for a simulated request
pkcheck --action-id org.freedesktop.systemd1.manage-units \
        --system-bus-name $(dbus-send --system --print-reply \
          --dest=org.freedesktop.DBus /org/freedesktop/DBus \
          org.freedesktop.DBus.GetNameOwner string:org.freedesktop.systemd1 \
          | awk '/string/{print $2}' | tr -d '"') \
        --process $$ -v

# Verify tor is listening on control port
ss -tlnp | grep 9051

# Quick AUTHENTICATE test from shell
printf 'AUTHENTICATE ""\r\nGETINFO version\r\nQUIT\r\n' \
  | nc 127.0.0.1 9051

§08 Security Considerations & Known Gaps

⛔ Known Limitations
This is an honest list. Shipping a Tor UI without acknowledging what it doesn't protect is harmful.
IssueSeverityStatus
No transparent proxy High Apps that bypass SOCKS (e.g., using raw DNS) leak to the cell network. Full tproxy requires kernel netfilter rules as root — out of scope for a Harbour app.
DNS leaks High Only apps configured to use SOCKS5 with remote DNS resolution are protected. System resolver is unaffected. Use DNSPort 9053 + dnsmasq forwarding (root required).
ControlPort unauthenticated from localhost Low Cookie auth is used. Any local process with supplementary group tor can read the cookie. Acceptable on single-user phone; problematic if the phone runs third-party daemons.
No circuit isolation between apps Medium The SocksPort isolation flags help but don't prevent all cross-app correlation. A proper per-app Tor port (one SocksPort per app, via TransProxy) would be needed.
Ed25519 key not hardware-backed Medium The hidden service key lives on the eMMC. If the device is seized, the key is recoverable. TEE/Keystore integration would require significant changes to tor's key management.