5.8 KiB
ADR-0047 — Tor onion service and host hardening on by default
Status: Accepted (implemented), 2026-06-28 Supersedes: none Superseded by: none
Context
Privacy is Morphit's first stated priority, and "as much security by default" is the operator-facing corollary. Both were supported but opt-in:
- Tor was a first-class endpoint, but reaching it required the operator to
generate a hidden-service key (
scripts/generate-onion.sh/mkp224o) and paste the resulting.onionintoMORPHIT_INSTANCE_TOR_ADDRESS. Most operators never did, so most instances shipped with no onion at all — the worst outcome for a privacy-first project. - Host hardening shipped as a complete, idempotent Ansible role
(
ops/ansible/roles/hardening, OPERATIONS §37 + §34) plus amorphit-ops hardenchecklist generator, but running it was a deliberate operator choice. A first-time operator following the happy path could expose an un-hardened internet-facing box.
The friction was the problem, not the capability. The fix is to flip both defaults to on while keeping every escape hatch intact.
Decision
1. Every instance generates and serves a Tor .onion by default
- The setup wizard (
morphit-ops init) generates a v3 onion in the background while the operator answers the other prompts — instant, no wait, no vanity grinding. Generation uses Node's built-incryptoonly (newapps/ops-cli/src/init/torOnion.ts, zero new dependencies) and follows rend-spec-v3 §6 exactly:address = base32(pubkey ‖ SHA3-256(".onion checksum" ‖ pubkey ‖ 0x03)[:2] ‖ 0x03) + ".onion"; the 96-bytehs_ed25519_secret_keyis the== ed25519v1-secret: type0 ==header plusclamp(SHA-512(seed)). The implementation is cross-checked against PyNaCl (libsodium), Python's stdlib base32, a from-spec checksum, and a fixed-seed vector. - The wizard never asks about Tor and never overwrites an operator-set
address: if
MORPHIT_INSTANCE_TOR_ADDRESSalready exists in the environment or an existing config, that value is kept. - The hidden-service key files are written to
tor-hidden-service/(hs_ed25519_secret_keymode 0600, dir 0700) only on the success path, so an aborted wizard leaves no orphan keys. - The shipped
torAnsible role (ops/ansible/roles/tor,enable_tordefault-on ingroup_vars/all.yml) installs Tor and points itsHiddenServiceDirat the wizard's keys (morphit_tor_key_src), so the daemon serves the same address the site advertises. - Reachability lights up automatically: the address flows from the env var →
indexer config →
/v1/instancealt_networks.tor→ the web instance store → the footer Tor pill and theOnion-Locationresponse header (so Tor Browser offers the onion with no user action).
2. Host hardening is applied by default, and the wizard hand-holds it
- The Ansible
hardeningrole already imports all 16 sub-features unconditionally (SSH, unattended-upgrades, sysctl, mounts, auditd, AppArmor, AIDE, secrets-perms, outbound, alerting, rkhunter, GRUB, password policy, UFW, fail2ban) and runs with nowhen:gate — so the automated path was already maximal. No change was needed there. - The wizard's hardening step now walks the operator through the major
pillars (SSH lockdown, firewall + fail2ban, automatic updates, kernel
hardening, intrusion detection) as a short run of yes-default confirmations,
and records them so the generated
morphit-hardening-checklist.mdreflects what the operator confirmed.
Why generate the onion locally instead of letting Tor do it
Tor can generate its own hidden-service key on first start, but then the
address is not known at config-write time. Generating it locally in the
wizard means the address can be written into the config, surfaced in the footer
pill + Onion-Location, and printed for the operator in the same setup run —
none of which is possible if the address only appears after the Tor daemon
boots later. Local generation is also instant and dependency-free at setup
time (Tor need not even be installed yet).
Security notes
- The HS secret key is treated like any other secret: written 0600 on
success only, and structurally excluded from the save-as-you-go progress
file (
WizardProgress = Partial<Omit<WizardAnswers, … | 'torOnion'>>plus a defense-in-depthdelete safe.torOnioninsaveProgress). Only the public.onionaddress is ever remembered (inaltNetworks.tor). - The wizard choices in pillar (2) never weaken the automated path — the Ansible playbook applies every hardening sub-feature regardless; a declined pillar only annotates the by-hand checklist ("strongly reconsider").
Consequences
- Every fresh instance is Tor-reachable by default — a direct privacy win, and
the onion pill/
Onion-Locationnow appear for the common case rather than the rare configured one. - Operators get a hardened host on the default path, with informed-consent framing rather than silent application.
- Setup produces one extra artifact (
tor-hidden-service/); operators serving the onion must point thetorrole'smorphit_tor_key_srcat it. - A vanity
.onionremains a deliberate manual step (generate on operator hardware, paste viamorphit-ops alt-address); pasting one simply replaces the basic address and is never overwritten.
Alternatives considered
- Keep Tor opt-in — rejected; the friction is exactly why most instances had no onion, which contradicts the project's first priority.
- Let the Tor daemon generate the key on first start — rejected; the address would be unknown during the wizard run (see "Why generate locally").
- Vanity onion by default — rejected; prefix grinding is slow and unbounded, defeating the "instant, no wait" property. Vanity stays a manual choice.