§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:
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.
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:
| Restriction | Impact on Tor UI | Resolution |
|---|---|---|
| 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:
[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
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
// 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
maskandunmask. 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
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).
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
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.
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).
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.
## ─── 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.
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:
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
# 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
| Issue | Severity | Status |
|---|---|---|
| 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. |