b79a6fd7f263aa707bac9778ce58a65f5734bf4c
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b79a6fd7f2 |
ssh: don't open port 22 for the loopback-only sshd
sshd-localhost.nix turns sshd on for every role so that "ssh root@localhost"
works, and binds it to 127.0.0.1 only ("zero network exposure", as its
own comment says, and as sshd.nix expects: the sshd feature "extends this
to 0.0.0.0 and opens port 22 on the firewall when the user enables remote
SSH").
It never turned off services.openssh.openFirewall, which NixOS defaults
to true and applies whether or not sshd listens on the ports. So port 22
was open in the firewall on every role, Desktop Only included, with
nothing behind it. No listener means no live exposure today; it does mean
the firewall was not saying what the documentation says, and the day
something does bind 22 on a wider address (a listenAddresses change, a
second daemon) it would be reachable without anyone having opened it.
openFirewall is now mkDefault false there. Nothing that wants SSH
published loses it: sshd.nix (feature sshd) and remote-deploy.nix both
add 22 explicitly, and only when enabled. Found by evaluating the real
module set; grepping for allowedTCPPorts does not see a NixOS default.
Evaluated with nix eval, TCP firewall ports:
before after
Server + Desktop 22 80 443 3051 8937 80 443 3051 8937
Bitcoin Node Only 22 3051 8937 60847 3051 8937 60847
Desktop Only 22 (none)
Desktop + features.sshd 22 22
Desktop + deploy.enable 22 3389 22 3389
and sshd's listen addresses are unchanged: 127.0.0.1 by default,
127.0.0.1 and 0.0.0.0 with the sshd feature. Desktop Only now opens no
TCP port at all; UDP 5353 (mDNS) is the only port open there, and
SECURITY.md says so.
Add tests/test_ssh_exposure.py.
|
||
|
|
3694ea6489 |
bitcoin: drop the stray UDP 3051 firewall rule
allowedUDPPorts was set to [ 3051 ] alongside allowedTCPPorts, which looks like it was copied from the line above. Caddy serves Ride The Lightning over TCP on 3051; nothing listens for UDP there, so the rule only opened a port for no reason. The comment above the pair also said "Hub management port"; 3051 is RTL. Evaluated with nix eval, UDP firewall ports per role: Server + Desktop 80 443 3051 5353 -> 80 443 5353, Bitcoin Node Only 3051 5353 -> 5353, Desktop Only 5353. (80 and 443 are Caddy's; 5353 is mDNS.) |
||
|
|
6987d9bf2c |
installer: raise generated password entropy from ~23 to ~33 bits
generate_diceware_password() built the password from 3 words out of a 96 word list plus a single digit: 96^3 x 10 = 8,847,360 combinations, about 23 bits. That one password is the desktop login, the 'free' account password, and the only thing standing in front of the Hub, which runs as root and displays the root password, the SSH passphrase, the RTL password and the Vaultwarden admin token. 23 bits is thin for something that valuable, and the rate limiting in front of it was weaker than intended (see "hub: make the login lockout that LOGIN_FAIL_MAX described"). Now 4 words plus 2 digits: 96^4 x 100 = 8,493,465,600, about 33 bits, for the cost of one more word to write down. - iso/installer.py: generate_diceware_password(). - modules/credentials.nix: the three fallback generators in root-password-setup, free-password-setup and free-password-migration, so a machine provisioned without the installer gets the same strength. Affects new installs only; existing passwords are untouched. Checked by running the real thing. The installer function was exercised 2000 times: 96 words in the list, always word-word-word-word-NN, 33.0 bits. For the three services, the generator lines were taken from the script the evaluated module really produces (nix eval on the nixpkgs revision flake.lock pins) and run 1500 times each: 96 words in each list, always word-word-word-word-NN, every two-digit suffix from 00 to 99 seen, and no repeated password. |
||
|
|
ebcc17ae3c |
caddy: stop filtering the RTL and Mempool sites by client address
|
||
|
|
78bfc5b408 |
hub: serve the Hub on its own port instead of through Caddy
Caddy fronted the Hub at http://sovransystemsos.local, but the Hub already listens on 0.0.0.0:8937 itself, and nothing Caddy added is something it needs: - Not the name. That is avahi's: mDNS advertises a hostname, not a port, so the name resolves wherever the Hub listens. - Not TLS (the site was plain http), not authentication, not cache headers. The header block duplicated NoCacheMiddleware, and its Clear-Site-Data ("cache") overrode the app's stronger ("cache", "storage"). - Not access control, and this is the point. With ports 80/443 forwarded for public services, a Host header on those ports reached the Hub. That second door is how the reported bug happened, and |
||
|
|
361b25a8bd |
hub: answer the local network only, whichever way a client arrives
The reported bug was the Hub being reachable from outside the local
network when Server + Desktop is active.
|
||
|
|
7d784eb653 |
hub: make the login lockout that LOGIN_FAIL_MAX described
LOGIN_FAIL_MAX was declared as "max failures in window before extra delay" and then never read anywhere in the file. _record_failure() only ever slept a flat LOGIN_FAIL_DELAY. So the Hub's entire defence against online guessing was a constant 2 second pause per wrong password: no escalation, no lockout, no ban, and fail2ban is configured for SSH only. Worse, the old 60 second window could not have worked even if the constant had been wired up. With a delay per attempt, reaching 10 failures takes about 80 seconds, so the earliest failures aged out of the window before the count could ever reach the limit. - security_helpers.py: new LoginThrottle. The delay ramps with the failure count (2s, 4s, ... capped at 10s), and once LOGIN_FAIL_MAX failures land inside the window the address is refused outright for LOGIN_LOCKOUT_SECONDS (5 minutes). A successful login clears the address, so an operator who fumbles a password is not penalised later. The sleep is never taken under the lock, so one slow client cannot stall every other login. Tracked addresses are evicted, so a distributed sweep cannot grow the table without bound. The window moves from 60s to 900s so the whole ramp fits inside it. clock and sleep are injectable, which is what makes it testable. - server.py: /api/login checks the lockout before the scrypt hash, so a locked-out client costs almost nothing to reject, and answers 429 with a human-readable wait instead of a bare 401. - tests/test_login_throttle.py: covers the ramp, the cap, the lockout firing and expiring, per-address isolation, clearing on success, eviction, and that the limit is actually reachable inside the window. Verified against the real app with TestClient: 10 wrong passwords return 401 and the 11th returns 429 "Too many failed attempts. Try again in about 5 minute(s)." A correct password clears the counter. |