Compare commits

..
18 Commits
Author SHA1 Message Date
Sovran Systems be9afb2f53 php: pin the shared interpreter to 8.5.12 for CVE-2026-109618/-109619
PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 were released on 2026-10-21 to fix two
critical vulnerabilities in PHP itself:

  CVE-2026-109618  RCE via OOM corruption (GHSA-w8hw-w9fx-j79w)
  CVE-2026-109619  RCE via stale global state (GHSA-6wmf-4rv5-p3rp)

Both are unauthenticated RCE, CVSS v4.0 9.2, and PHP states that updating is
the only complete mitigation.  The advisories name PHP-FPM as susceptible, so
the phpfpm-nextcloud and phpfpm-wordpress pools are in scope, not just the
Apache/mod_php configuration the public write-ups focus on.

nixpkgs bumped php85 in 81513f364091c759812f716cef3c175109d07342 on the same
day, but the revision this flake pins (7694c4f6b853, 2026-10-16) predates it
and still reports php85 8.5.11 — as does the pinned nixos-26.05 rev, so
`pkgs.stable` is not a way out of this either.  Rather than pin the whole
nixpkgs input forward and freeze every other package, pin the interpreter:

  • phpSecurityFloor records the first patched 8.5.x release.
  • phpBase falls through to pkgs.php85 untouched when nixpkgs has caught up,
    so the workaround retires itself; bump the floor only upward.
  • `.override` rather than `overrideAttrs`: php85 is a makeOverridable
    buildEnv, so version/phpSrc reach generic.nix while the default extension
    set survives.  overrideAttrs would keep the new source but drop `enabled`,
    silently removing gd, intl, pdo_pgsql and the rest from both pools.

Nothing else changes: one interpreter, one extension set, one php.ini, and
every consumer (both FPM pools, the Nextcloud cron job, occ and wp-cli) still
resolves through config.sovran_systemsOS.phpPackage, so CLI and FPM stay in
lockstep at 8.5.12.

The source is php-8.5.12.tar.bz2 from php.net, hashed here.  Hydra cannot have
this exact derivation, so the first rebuild compiles PHP plus bz2, apcu, redis,
imagick and memcached locally — a one-off cost, after which it is a store path
like any other and the configuration stays reproducible.

`nixos-rebuild switch` is enough: the phpfpm units change closure and restart,
no reboot and no staged generation required.
2026-10-22 20:38:22 -05:00
naturallaw777 39ed0cc3ae docs: update changelog and versions with nixpkgs and matrix-synapse 1.163.0 2026-10-21 15:49:55 -05:00
naturallaw777 87aa9b741b chore(release): prepare v1.2.2 2026-10-21 14:23:35 -05:00
naturallaw777 ed9e7ce065 updated nixpkgs 2026-10-21 12:30:07 -05:00
Arena Agent e060ec96eb fix(hub): make Hub login verification loadable and diagnosable
Correction to the patch-0002 report: after a reboot the Hub icon asks for a password AND the correct free (desktop) password is rejected there, while desktop sign-in accepts it. Patch 0002/0003 explain the icon prompt (legacy hub_manual_logout cookie; fixed in 0003), but the rejection itself means verify_system_password() never gets a conclusive crypt answer in the Hub unit and its PAM fallback cannot load either: on NixOS a bare libcrypt.so.1/libpam.so.0 resolves through LD_LIBRARY_PATH or not at all (or to glibc's failure-token stub), and until now every stage failed silently into a generic 'Incorrect password'.

password-model.nix now puts pkgs.libxcrypt and pkgs.pam on sovran-hub-web's LD_LIBRARY_PATH so both the crypt fallbacks and PAM can actually load. verify_system_password() now logs, journal-visible and never containing a password or hash: the shadow hash scheme, every libcrypt source tried and whether it loaded, and PAM's service + return code when it has to decide.
2026-10-18 21:39:41 -05:00
Arena Agent 761a7d76e9 fix(hub): trusted Hub icon, truthful root hint, no Default-keyring prompt
1) Clicking the desktop Hub icon demanded the Hub password after any sign-out: api_logout set a hub_manual_logout cookie for 400 days and /auto-login honoured it. The icon is a trusted local entry point (its user is already signed in to the OS as free), so /auto-login now ignores and clears that legacy cookie and logout no longer sets it. Sign-out still destroys the session, and LAN clients -- who never get auto-login -- must still enter the password.

2) The Administrator (root) row in System Passwords showed the free/Hub hint verbatim. Root has no desktop or Hub sign-in and nothing refreshes its display copy, so it now gets a root-specific hint (sudo passwd root to set it; Hub Security Reset to make the tile show one again).

3) Brave prompted for a locked 'Default keyring' on Hub launch: the password cleanup removed configuration.nix's keyrings/default pointer and password-model.nix never re-seeded it, so gnome-keyring fell back to an encrypted keyring named Default. password-model.nix now seeds default -> login (the blank keyring) via tmpfiles, and its activation migration restores the pointer and moves any encrypted default.keyring aside (kept, never deleted).
2026-10-18 21:39:25 -05:00
Arena Agent d84e0c4d82 fix(hub): Hub login must accept the password the desktop accepts
verify_system_password() trusted the first loadable libcrypt. A library that cannot speak the shadow hash's algorithm (yescrypt, NixOS's default) answers with the '*0' sentinel, which was compared to the hash and reported as a wrong password -- even though the same password still signed in at the desktop. Following the System Passwords tile's 'sign out and back in' hint then locked the user out of the Hub, asking for a 'new' Hub password that does not exist: Hub login is the free account password, verified against /etc/shadow.

Try every loadable libcrypt, treat None/'*' replies as inconclusive rather than wrong, and when no library can decide, verify via PAM -- the same check GDM performs. Also replaces the sign-out hint (server + JS fallback) with wording that says the Hub has no separate password and not to sign out unless you know the current desktop password.

Recovery for an already locked-out user whose desktop login still works: on the machine itself, clear the localhost:8937 cookies and open http://localhost:8937/auto-login (localhost-only), or run 'sudo change-free-password' in a terminal to set a known free/Hub password.
2026-10-18 21:18:27 -05:00
Arena Agent e718404e84 fix(hub): don't let password verification crash the System Passwords tile
The passwords update made the System Passwords tile the only tile whose credential resolution calls system_password.verify_system_password(). That loaded libcrypt with no error handling: an unloadable LIBCRYPT (e.g. a stale Nix store path) raised OSError through _resolve_credential(), so GET /api/service-detail/root-password-setup.service returned 500 and the Hub showed only 'Could not load service details.'

Verification now fails closed, never fatal: _crypt() falls back through LIBCRYPT / find_library / libcrypt.so.1/.so.2, verify_system_password() returns False on any load or crypt failure, _resolve_credential() degrades that one credential to the 'Changed outside the Hub' hint instead of 500-ing the modal, and the login-time display-copy heal is best-effort.

Adds tests/test_system_password.py regression tests.
2026-10-18 21:04:35 -05:00
naturallaw777 07566c6a28 updated Sovran_Bitcoin flake for a how fix for NWC 2026-10-18 20:26:06 -05:00
naturallaw777 1d59a995e8 chore(flake): update sovran-bitcoin with btcpayserver RPC whitelist fix 2026-10-18 17:15:44 -05:00
Sovran Systems f1f0bf0d11 fix(bitcoin): expand btcpayserver RPC whitelist for NBXplorer
- Ensure services.bitcoind.rpc.users.btcpayserver.rpcwhitelist includes
  public.rpcwhitelist and NBXplorer capability-scan/wallet RPC methods
  (validateaddress, testmempoolaccept, signrawtransactionwithkey,
  signrawtransactionwithwallet, getblockfrompeer, generatetoaddress,
  getindexinfo, createwallet, loadwallet, importdescriptors, etc.).
2026-10-18 17:15:03 -05:00
naturallaw777 7b5f25d599 chore(flake): update sovran-bitcoin with electrs 0.12.0 fix 2026-10-18 16:47:38 -05:00
Sovran Systems 497ac49cfd fix(bitcoin): support electrs 0.12.0 (drop --daemon-p2p-addr, enable bitcoind rest=1)
- Ensure bitcoind enables rest=1 and electrs.service omits the removed
  --daemon-p2p-addr flag even before Sovran_Bitcoin flake input is re-locked.
- Clean up pre-0.12.0 (FixedPrefix.8) RocksDB directories in electrs
  preStart so bindex 0.12.0 reindexes cleanly.
2026-10-18 16:47:36 -05:00
naturallaw777 8fd0318083 chore(release): prepare v1.2.1 2026-10-18 16:09:46 -05:00
Sovran Systems aed1ed5bd6 chore: update flake.lock (nixpkgs, Sovran_Bitcoin 1.1.1, Bisq2 2.1.14) and integration
- Update flake.lock inputs:
  - sovran-bitcoin: b0da63b -> 50a8b76 (1.1.1 with RTL 0.15.13 and Nix-native sovran-nwc 2.0.0)
  - nixpkgs (nixos-unstable): c59305b -> fe13242 (Caddy 2.11.7, Tor 0.4.9.14, GNOME Remote Desktop 50.2, Bitcoin Core 31.1, Electrs 0.12.0, LND 0.21.4-beta, BTCPay Server 2.4.5, Matrix Synapse 1.162.0, LiveKit 1.13.8, Vaultwarden 1.37.4, Haven 1.2.2)
  - nixpkgs-stable (nixos-26.05): 78e9c78 -> 26f4107
  - btc-clients: 99b0442 -> 990d29e (Bisq 1.10.9, Bisq2 2.1.14, Sparrow 2.5.5)
  - nixvim: 5980a62 -> 453dc9c
- flake.nix: replace deprecated pkgs.system with pkgs.stdenv.hostPlatform.system for btc-clients packages
- modules/sovran-bitcoin-integration.nix: guard config.networking.domain against null/empty when both haven and nwc-wallets features are enabled
- app/sovran_systemsos_web/versions.json: sync static fallback service versions with updated flake inputs
- CHANGELOG.md: document updates under [Unreleased]
2026-10-18 15:36:45 -05:00
Sovran_SystemsOS contributor 8f5e5394e6 mullvad: install the client on the Desktop Role only
The Mullvad VPN app and its daemon now ship with Desktop Only, as the stock
NixOS service with nothing configured on top of it: no local-network boot
service, no DNS or firewall edits of our own, no Hub tile, no Feature Manager
card, no toggles. The account, the relay, the kill switch and the app's other
switches belong to the app.

modules/mullvad.nix is the whole change to the system: a role gate around

  services.mullvad-vpn = {
    enable = true;      # daemon, pkgs.mullvad
    gui.enable = true;  # app, pkgs.mullvad-vpn
  };

Server + Desktop and Bitcoin Node are machines that serve; a VPN client with its
own routing and DNS policy is not something their services asked for, so only
roles.desktop builds it. Every other option stays at the distribution's default
(early boot blocking off, the mullvad-exclude wrapper on), and the only
consequences are the ones the NixOS module already defines — the tun module, and
systemd-resolved with NetworkManager pointed at it while the client is enabled.

The Hub and the desktop shell are untouched: no tile, no feature card, no
service description, no icon, no GNOME folder edit. An app that is simply
installed has no decision to put in front of the user, and no state to report.

README, SECURITY.md (what the app's own kill switch and DNS behaviour mean, so
the change in network behaviour is not a surprise), CHANGELOG, the custom.nix
template (the mkForce line that removes it) and the installer's Desktop card
document it as an app rather than a service.

tests/test_mullvad_desktop_only.py guards the gate and the restraint: the client
builds on Desktop and nowhere else, the module adds no services, no DNS or
firewall edits and no packages, the Hub and desktop shell never mention it, and
it ships no tile icon.

Checked by evaluating the real module against the revision flake.lock pins
(3d23ea05a8a3) with eval-config.nix, four cases: Desktop -> daemon and GUI on,
one unit (mullvad-daemon), mullvad + mullvad-vpn in systemPackages, every other
option at the distribution default; Bitcoin Node and Server + Desktop -> nothing
at all, not even the tun module or resolved; Desktop with
`services.mullvad-vpn.enable = lib.mkForce false;` -> no unit, no package.
2026-10-16 13:07:29 -05:00
naturallaw777 36bd2b4e78 password-model.nix: fix Nix string escaping in the generate fallback
The word-picking one-liner used `eval echo \${...}`, but in a Nix
indented string a backslash does not escape `${` -- it starts
interpolation, so evaluation failed with "syntax error, unexpected
invalid token". Replace the eval/positional-parameter trick with a
plain `shuf` pipeline that contains no `${` at all.

Found by nixos-rebuild boot on staging-dev. The shell logic itself was
tested; the file had not been parse-checked by Nix. Re-scanned the
whole module: every remaining `${` is an intended interpolation and
every `''` is a string delimiter.
2026-10-16 12:39:01 -05:00
naturallaw777 4a25ef24d1 password & keyring cleanup: one password, one truth, no prompts
The Hub, GDM and sudo now share /etc/shadow as the single source of
truth. /var/lib/secrets/{free,root}-password become root-only display
copies, written only by system_password.py and verified against shadow
before being shown. The GNOME login keyring is decoupled from the login
password (blank master password, pam_gnome_keyring off), so nothing
needs re-keying or wiping when the password changes and the keyring
never pops an unlock prompt.

- add app/sovran_systemsos_web/system_password.py: the single module
  that verifies / sets / displays the free + root passwords and drives
  the legacy-migration onboarding step (same endpoints and UI)
- add modules/password-model.nix: PAM gnome-keyring off, blank
  login.keyring seed with a one-time migration of old encrypted
  keyrings, first-boot-only password application (no more boot-revert),
  LIBCRYPT for the Hub
- server.py: login verifies against shadow and heals stale display
  copies; change-password takes the current password; security reset
  sets both passwords through the same writer (root is shown as a
  password again, not a hash); all keyring wipes removed
- credentials.nix: drop root-password-setup, free-password-setup and
  free-password-migration (replaced by password-model.nix) and the
  keyring wipe in change-free-password
- configuration.nix: drop enableGnomeKeyring and the keyring tmpfiles
  hack (replaced by password-model.nix)
- service-detail.js: "Current password" field; credentials changed
  outside the Hub render with a hint instead of a stale value

Installed machines keep their exact passwords: the first-boot services
no-op once an account has a real hash, and the upgrade never writes
/etc/shadow. The legacy-migration onboarding step and "reset all
passwords" keep working.
2026-10-16 12:25:16 -05:00
23 changed files with 1293 additions and 536 deletions

No files matched your search

+49
View File
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.2.2] - 2026-10-21
### Changed
- **Nixpkgs Update (`nixpkgs-stable` / `nixos-26.05`):**
- Updated to commit [`cd4ea8d`](https://github.com/NixOS/nixpkgs/commit/cd4ea8d57de16f4810bc5f14207bac45fbf19f67)
- Upgraded **Matrix Synapse**: `1.162.0` → `1.163.0` (automatically picked up by The Hub)
- **Sovran_Bitcoin Update (`1.1.2`):**
- Updated to commit [`63e1049`](https://github.com/naturallaw777/Sovran_Bitcoin/commit/63e104940ef4ff21eb2b43977ca6e110e9c5d524)
- Hotfix for Nostr Wallet Connect (NWC): fixes spending on LND 0.21, real internal preimages, and `pay_keysend` support
### Fixed
- Make Hub login verification loadable and diagnosable
- Trusted Hub icon, truthful root hint, no Default-keyring prompt
- Hub login must accept the password the desktop accepts
- Don't let password verification crash the System Passwords tile
[1.2.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.2
## [1.2.1] - 2026-10-18
### Added
- Mullvad: install the client on the Desktop Role only
- Password-model.nix: fix Nix string escaping in the generate fallback
- Password & keyring cleanup: one password, one truth, no prompts
- Update security details in SECURITY.md
- Enhance README formatting and emphasis on features
- Revise SECURITY.md for clarity on Hub operations
### Changed
- Update flake.lock (nixpkgs, Sovran_Bitcoin 1.1.1, Bisq2 2.1.14) and integration
### Fixed
- Fix README formatting and clarify service exposure
- Fix formatting and clarify server access instructions
[1.2.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.1
## [Unreleased]
### Added
- Mullvad: install the VPN app and daemon, as NixOS ships them, on the Desktop Role only
### Changed
- Updated `flake.lock` (`nixpkgs`, `nixpkgs-stable`, `nixvim`, `btc-clients` with Bisq2 `2.1.14`, and `Sovran_Bitcoin` `1.1.1` with RTL `0.15.13` and Nix-native `sovran-nwc` `2.0.0`)
- Updated fallback service version map (`app/sovran_systemsos_web/versions.json`)
- Fixed deprecated `pkgs.system` reference in `flake.nix` and guarded `config.networking.domain` in `modules/sovran-bitcoin-integration.nix` when `haven` and `nwc-wallets` are both enabled
---
## [1.2.0] - 2026-10-02
### Added
+29 -15
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready.
[Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso.sha256) ·
[Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -172,6 +172,7 @@ hardware you control.
- [GNOME](https://www.gnome.org) desktop
- [Brave Origin](https://brave.com/origin/) and
[Firefox](https://www.mozilla.org/firefox) browsers
- [Mullvad VPN](https://mullvad.net) desktop app and daemon, on Desktop Only
- File management, email, calendar, and office applications
- System monitoring and administration tools
@@ -185,15 +186,25 @@ Bitcoin and self-hosting infrastructure runs on the machine.
| Mode | Best for | What you get |
|---|---|---|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use, plus the Mullvad VPN app |
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections. **Publishes nothing unless you enable *Put BTCPay Server Online* or *Lightning Wallet Connections*:** [read this first](#server--desktop-and-your-home-ip-address) |
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
edition: it is a complete, private everyday computer with a clean GNOME
desktop, Tor, and the Sovran Hub, and a lower-hardware path to self-custody and
non-KYC Bitcoin tools from the first boot. Move to Node mode when your
hardware, storage, and needs are ready.
desktop, Tor, the Sovran Hub, and the Mullvad VPN app and daemon — which no
other role installs — and a lower-hardware path to self-custody and non-KYC
Bitcoin tools from the first boot. Move to Node mode when your hardware,
storage, and needs are ready.
Mullvad is installed the way NixOS ships it and nothing in it is configured for
you: create an account at [mullvad.net](https://mullvad.net), sign in from the
app, and set the relay and the app's own switches to taste. It is the one thing
that reaches the network from this machine as an application rather than as a
service, so Server + Desktop and Bitcoin Node — built to serve — do not carry
it. On Desktop it is simply there; remove it with
`services.mullvad-vpn.enable = lib.mkForce false;` in `custom.nix` if you would
rather not have it.
**Node: verify your own money.** You operate the infrastructure that performs
the verification instead of asking someone else's server: your node verifies,
@@ -390,8 +401,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.2.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso)
- [Download Sovran_SystemsOS-1.2.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256)
- [Download Sovran_SystemsOS-1.2.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso)
- [Download Sovran_SystemsOS-1.2.2.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso.sha256)
The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder.
@@ -408,13 +419,13 @@ ISO must match the published one exactly.
Open a terminal in the download folder and run:
```bash
sha256sum --check Sovran_SystemsOS-1.2.0.iso.sha256
sha256sum --check Sovran_SystemsOS-1.2.2.iso.sha256
```
A successful comparison reports:
```text
Sovran_SystemsOS-1.2.0.iso: OK
Sovran_SystemsOS-1.2.2.iso: OK
```
</details>
@@ -425,11 +436,11 @@ Sovran_SystemsOS-1.2.0.iso: OK
Open Terminal in the download folder and run:
```bash
shasum -a 256 Sovran_SystemsOS-1.2.0.iso
shasum -a 256 Sovran_SystemsOS-1.2.2.iso
```
Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.2.0.iso.sha256`.
`Sovran_SystemsOS-1.2.2.iso.sha256`.
</details>
@@ -439,7 +450,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run:
```powershell
Get-FileHash .\Sovran_SystemsOS-1.2.0.iso -Algorithm SHA256
Get-FileHash .\Sovran_SystemsOS-1.2.2.iso -Algorithm SHA256
```
Compare the value under `Hash` with the published checksum.
@@ -454,7 +465,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.0.iso`.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.2.iso`.
3. Choose **Select target**, select the USB drive, and review your selection
carefully.
4. Choose **Flash** and wait for the writing and verification process to
@@ -588,6 +599,8 @@ and `/etc/nix-bitcoin-secrets` remain for compatibility.
- [GNOME](https://www.gnome.org) desktop environment
- [Caddy](https://caddyserver.com) for local and public service routing
- [Tor](https://www.torproject.org) for Bitcoin network privacy
- [Mullvad VPN](https://mullvad.net), installed as the stock NixOS service on
the Desktop Role only (`pkgs.mullvad` daemon and `pkgs.mullvad-vpn` app)
- [AGPL-3.0](LICENSE) licensing
### Build from source
@@ -737,6 +750,7 @@ rebuilds the machine into the selected declarative state.
| Optional Haven Nostr relay | `modules/haven.nix` |
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
| Mullvad VPN app (Desktop Role only) | `modules/mullvad.nix` |
Feature availability and defaults may change as Sovran_SystemsOS develops:
review the relevant Nix module before relying on a default in production.
@@ -869,7 +883,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso) ·
[View the License](LICENSE)
</div>
+19
View File
@@ -92,6 +92,25 @@ the public internet. Key authentication only. Turn it off when you are done.
Tor reduces network exposure for configured Bitcoin services. It is not a
guarantee against every IP leak, application bug, or traffic-analysis attack.
### Mullvad VPN (Desktop Only)
The Mullvad VPN client — daemon and app — is installed on the Desktop Role and
on no other role: Server + Desktop and Bitcoin Node keep the one network
configuration their services need. `modules/mullvad.nix` is a role gate around
the stock NixOS service, with nothing configured by this system on top of it.
Two behaviours are the app's own, and worth knowing before you connect:
- While the tunnel is up, Mullvad's kill switch blocks everything outside it,
the local network included, so Remote Desktop and other local devices stop
answering until you switch local network sharing on in the app.
- Name resolution follows the tunnel for as long as the client is enabled: the
NixOS service turns on systemd-resolved and points NetworkManager at it.
Neither is set or restricted here; both are visible and changeable in the app.
The firewall rules, the Hub's exposure checks, the Bitcoin stack and Tor are
untouched by this module.
### Restricted support access
Tech Support runs over SSH, and the Hub refuses to start a session until SSH
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.2.2
+81 -264
View File
@@ -5,9 +5,7 @@ from __future__ import annotations
import asyncio
import base64
import contextlib
import glob
import hashlib
import hmac
import ipaddress
import json
import logging
@@ -38,6 +36,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import load_config, load_versions
from . import systemctl as sysctl
from . import system_password as sp
from sovran_nwc import nwc_hub_manager as _nwc_mgr
from . import support_ops as _support_ops
from .ddns_update import normalise_url as _normalise_ddns_url
@@ -150,9 +149,7 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
# ── Hub web authentication ────────────────────────────────────────
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
# free/root password verify/set/display lives in system_password.py
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
SESSION_COOKIE_NAME = "hub_session"
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
@@ -724,58 +721,23 @@ def _is_authenticated(request: Request) -> bool:
return True
def _read_free_password() -> str | None:
"""Return the contents of the free-password secrets file, or None."""
try:
with open(FREE_PASSWORD_FILE, "r") as f:
return f.read().strip()
except Exception:
return None
def _hash_password(password: str) -> str:
"""Return a scrypt-hash with 16-byte salt, formatted salt_hex:hash_hex."""
salt = os.urandom(16)
hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
return salt.hex() + ":" + hashed.hex()
def _check_password(submitted: str) -> bool:
"""Constant-time comparison against stored scrypt hash or legacy plaintext."""
for fp in (FREE_PASSWORD_FILE_WEB, FREE_PASSWORD_FILE):
try:
with open(fp, "r") as f:
stored = f.read().strip()
except Exception:
continue
if not stored:
continue
if ":" in stored:
try:
salt_hex, hash_hex = stored.split(":", 1)
salt = bytes.fromhex(salt_hex)
submitted_hash = hashlib.scrypt(submitted.encode(), salt=salt, n=16384, r=8, p=1)
if hmac.compare_digest(submitted_hash.hex().encode(), hash_hex.encode()):
return True
except Exception:
pass
else:
if hmac.compare_digest(submitted.encode(), stored.encode()):
try:
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
f.write(_hash_password(submitted))
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
except Exception:
pass
return True
return False
"""Verify against /etc/shadow -- the same password GDM accepts.
On success, heal a stale display copy with the password the user just
proved (e.g. changed in GNOME Settings or the pre-upgrade Hub)."""
if not sp.verify_system_password(submitted):
return False
try:
sp.record_known_password(submitted, only_if_stale=True)
except Exception as exc: # healing the display copy is best-effort
logger.warning("Could not heal stale password display copy: %s", exc)
return True
def _ensure_onboarding_reopened_for_migration() -> None:
"""Re-open onboarding when a migration password disclosure is pending."""
if not os.path.isfile(MIGRATION_NEWPASS_FILE):
if not sp.ensure_migration_pending(_generate_diceware_password):
return
try:
os.remove(ONBOARDING_FLAG)
@@ -1846,6 +1808,31 @@ def _resolve_credential(cred: dict) -> dict | None:
if not filepath:
return None
# free/root login passwords: shown only if the display copy really is the
# current one (system_password verifies it against /etc/shadow).
if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):
try:
pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE
else sp.read_root_display_password())
migration = sp.migration_pending() if pw is None else None
except Exception as exc:
# Never let one credential 500 the whole service-detail modal:
# the System Passwords tile must still open and say the password
# is unavailable instead of "Could not load service details."
logger.warning("Could not resolve %s for display: %s", filepath, exc)
pw, migration = None, None
if pw is None:
if filepath == sp.ROOT_DISPLAY_FILE:
# Root is NOT the desktop/Hub sign-in: nothing a user signs
# in with refreshes this copy, so never show the free text.
hint = ("Changed outside the Hub — the Hub's saved copy no longer matches the current root password (set by the installer, 'passwd', or a terminal). Root has no desktop or Hub sign-in, so signing in will not refresh this copy. If you do not know the root password, set one in a terminal with 'sudo passwd root'; to make this tile show a root password again, run a Hub Security Reset, which sets new free + root passwords and saves copies of both.")
elif migration:
hint = "Changed outside the Hub — finish the password step in onboarding to see it here."
else:
hint = "Changed outside the Hub — the current password is your desktop (free) sign-in password; it will show here after you next sign in to the Hub with it. Do not sign out unless you know that password — the Hub has no separate password."
return {"label": label, "value": "", "unknown": True, "hint": hint}
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
try:
with open(filepath, "r") as f:
raw = f.read().strip()
@@ -2681,10 +2668,13 @@ async def auto_login_redirect(request: Request):
client_ip = request.client.host if request.client else "unknown"
if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=403, detail="Forbidden")
# An explicit logout must take precedence over the desktop launcher's
# localhost auto-login, including after the Hub window is closed/reopened.
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
return RedirectResponse(url="/login", status_code=303)
# The desktop Hub icon is a trusted local entry point: whoever can click
# it is already signed in to this machine as 'free', so it must never
# demand the Hub password again. A legacy hub_manual_logout cookie (set
# by older versions for 400 days after any Hub sign-out) used to send
# the icon to /login forever -- ignore it and clear it. Sign-out still
# destroys the session; clients on the LAN never get auto-login and
# must sign in with the password as before.
token = _create_session()
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
@@ -2695,6 +2685,7 @@ async def auto_login_redirect(request: Request):
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
return response
@@ -2742,20 +2733,20 @@ async def api_login(req: LoginRequest, request: Request):
@app.post("/api/logout")
async def api_logout(request: Request):
"""Destroy the session and prevent desktop auto-login until password login."""
"""Destroy the session.
This is a real sign-out for LAN clients (they must enter the password
again). On this machine the desktop Hub icon remains a trusted,
passwordless entry point -- its user is already signed in to the OS --
so no persistent "manual logout" cookie is set any more: it made the
Hub icon demand a password for 400 days after a single sign-out.
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
_destroy_session(token)
response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.set_cookie(
key=MANUAL_LOGOUT_COOKIE_NAME,
value="1",
max_age=MANUAL_LOGOUT_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
return response
@@ -2863,69 +2854,19 @@ async def api_onboarding_complete():
@app.get("/api/migration/password-status")
async def api_migration_password_status():
"""Return whether a migration-generated password is awaiting acknowledgement."""
try:
with open(MIGRATION_NEWPASS_FILE, "r") as f:
return {"pending": True, "password": f.read().strip()}
except FileNotFoundError:
return {"pending": False}
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}")
pw = sp.migration_pending()
return {"pending": True, "password": pw} if pw else {"pending": False}
@app.post("/api/migration/password-acknowledge")
async def api_migration_password_acknowledge():
"""Acknowledge the migration password and update /etc/shadow to match."""
# Read the new password before deleting the file
new_password = None
"""Acknowledge the migration password: apply it to /etc/shadow and the display copy."""
try:
with open(MIGRATION_NEWPASS_FILE, "r") as f:
new_password = f.read().strip()
except FileNotFoundError:
pass
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}")
# Update /etc/shadow so GDM accepts the new password going forward
if new_password:
chpasswd_bin = (
shutil.which("chpasswd")
or ("/run/current-system/sw/bin/chpasswd"
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
)
if chpasswd_bin:
try:
result = subprocess.run(
[chpasswd_bin],
input=f"free:{new_password}",
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning(
"chpasswd failed during migration acknowledge (rc=%d): %s",
result.returncode,
(result.stderr or result.stdout).strip(),
)
except Exception as exc:
logger.warning("chpasswd exception during migration acknowledge: %s", exc)
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
keyring_dir = "/home/free/.local/share/keyrings"
keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring"))
for kf in keyring_files:
try:
os.remove(kf)
except OSError as exc:
logger.warning("Could not remove old keyring file %s: %s", kf, exc)
# Clear the pending marker
try:
os.remove(MIGRATION_NEWPASS_FILE)
except FileNotFoundError:
pass
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not clear migration password: {exc}")
ok = await asyncio.get_event_loop().run_in_executor(None, sp.acknowledge_migration)
except subprocess.CalledProcessError as exc:
raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip())
if not ok:
raise HTTPException(status_code=404, detail="No migration password pending.")
return {"ok": True}
@@ -5558,86 +5499,16 @@ async def api_security_reset():
new_free_password = _generate_diceware_password()
new_root_password = _generate_diceware_password()
# Locate chpasswd
chpasswd_bin = (
shutil.which("chpasswd")
or ("/run/current-system/sw/bin/chpasswd"
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
)
if chpasswd_bin:
# Set free user password
# Set the new passwords (shadow first, then the plaintext display copies the
# credentials view shows). No keyring handling: the login keyring is
# password-independent and does not need re-keying or wiping.
for setter, pw, tag in ((sp.set_free_password, new_free_password, "free"),
(sp.set_root_password, new_root_password, "root")):
try:
result = subprocess.run(
[chpasswd_bin],
input=f"free:{new_free_password}",
capture_output=True, text=True,
)
if result.returncode != 0:
logger.warning("chpasswd free failed: %s", (result.stderr or result.stdout).strip())
errors.append("chpasswd_free_failed")
setter(pw)
except Exception as exc:
logger.warning("chpasswd free error: %s", exc)
errors.append("chpasswd_free_failed")
# Set root password
try:
result = subprocess.run(
[chpasswd_bin],
input=f"root:{new_root_password}",
capture_output=True, text=True,
)
if result.returncode != 0:
logger.warning("chpasswd root failed: %s", (result.stderr or result.stdout).strip())
errors.append("chpasswd_root_failed")
except Exception as exc:
logger.warning("chpasswd root error: %s", exc)
errors.append("chpasswd_root_failed")
else:
logger.warning("chpasswd not found; passwords not reset")
errors.append("chpasswd_not_found")
# Write new passwords to secrets files
try:
os.makedirs("/var/lib/secrets", exist_ok=True)
try:
import subprocess
subprocess.run(
["chpasswd"],
input=f"free:{new_free_password}\n".encode(),
check=True,
capture_output=True,
)
except Exception:
pass
try:
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
f.write(_hash_password(new_free_password))
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
except Exception:
pass
except Exception as exc:
logger.warning("write free-password error: %s", exc)
errors.append("write_free_password_failed")
try:
os.makedirs("/var/lib/secrets", exist_ok=True)
with open("/var/lib/secrets/root-password", "w") as f:
f.write(_hash_password(new_root_password))
os.chmod("/var/lib/secrets/root-password", 0o600)
except Exception as exc:
logger.warning("write root-password error: %s", exc)
errors.append("write_root_password_failed")
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
keyring_dir = "/home/free/.local/share/keyrings"
keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring"))
for kf in keyring_files:
try:
os.remove(kf)
except OSError as exc:
logger.warning("keyring wipe error for %s: %s", kf, exc)
errors.append("keyring_wipe_failed")
logger.warning("security reset: set %s password failed: %s", tag, exc)
errors.append(f"set_{tag}_password_failed")
# The user performed a full security reset — the banner's purpose is served.
try:
@@ -5753,83 +5624,29 @@ async def api_security_verify_integrity():
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
confirm_password: str
@app.post("/api/change-password")
async def api_change_password(req: ChangePasswordRequest):
"""Change the system 'free' user password.
"""Change the system 'free' user password (also the Hub login password).
Updates /etc/shadow via chpasswd and writes the new password to
/var/lib/secrets/free-password so the Hub credentials view stays in sync.
"""
Updates /etc/shadow via system_password.set_free_password, which also keeps
the /var/lib/secrets/free-password display copy in sync."""
if not sp.verify_system_password(req.current_password):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
if not req.new_password:
raise HTTPException(status_code=400, detail="New password must not be empty.")
if req.new_password != req.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match.")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.")
# Locate chpasswd binary (NixOS puts it in the Nix store, not /usr/bin)
chpasswd_bin = (
shutil.which("chpasswd")
or ("/run/current-system/sw/bin/chpasswd"
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
)
if chpasswd_bin is None:
raise HTTPException(
status_code=500,
detail="chpasswd binary not found. Cannot update system password.",
)
# Update /etc/shadow via chpasswd
try:
result = subprocess.run(
[chpasswd_bin],
input=f"free:{req.new_password}",
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip() or "chpasswd failed."
raise HTTPException(status_code=500, detail=detail)
except HTTPException:
raise
except Exception as exc:
logger.error("Failed to update system password: %s", exc)
raise HTTPException(status_code=500, detail="Failed to update system password.")
# Write new password to secrets file so Hub credentials stay in sync
try:
os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True)
# Write secure web-only hash; sync system shadow via chpasswd (memory)
try:
import subprocess
subprocess.run(
["chpasswd"],
input=f"free:{req.new_password}\n".encode(),
check=True,
capture_output=True,
)
except Exception as exc:
# Log but do not block; web hash is the critical fix
pass
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
f.write(_hash_password(req.new_password))
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
except Exception as exc:
logger.error("Failed to write secrets file: %s", exc)
raise HTTPException(status_code=500, detail="Failed to write secrets file.")
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
keyring_dir = "/home/free/.local/share/keyrings"
for kf in glob.glob(os.path.join(keyring_dir, "*.keyring")):
try:
os.remove(kf)
except OSError:
pass # Non-fatal: keyring will be re-created on next login regardless
await asyncio.get_event_loop().run_in_executor(None, sp.set_free_password, req.new_password)
except subprocess.CalledProcessError as exc:
raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip())
return {"ok": True}
@@ -1520,3 +1520,6 @@
margin-top: 10px;
line-height: 1.5;
}
/* password cleanup: display copy changed outside the Hub */
.creds-value.creds-unknown { font-style: italic; opacity: 0.75; }
@@ -34,6 +34,10 @@ function _renderCredsHtml(credentials, unit) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
continue;
}
if (cred.unknown) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div><div class="creds-value-wrap"><div class="creds-value creds-unknown">' + escHtml(cred.hint || "Changed outside the Hub — the current password is your desktop (free) sign-in password; it will show here after you next sign in to the Hub with it. Do not sign out unless you know that password — the Hub has no separate password.") + '</div></div></div>';
continue;
}
var displayValue = linkify(cred.value);
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
}
@@ -1293,6 +1297,11 @@ function openSystemChangePasswordModal(unit, name, icon) {
'<div class="sys-chpw-title">🔑 Change Free Account &amp; Hub Login Password</div>' +
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
'</div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-current">Current Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-current" placeholder="Current password" autocomplete="current-password">' +
'<button type="button" class="pw-toggle-btn" id="sys-chpw-current-toggle" aria-label="Toggle password visibility">👁</button>' +
'</div></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
@@ -1315,6 +1324,13 @@ function openSystemChangePasswordModal(unit, name, icon) {
openServiceDetailModal(unit, name, icon);
});
document.getElementById("sys-chpw-current-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-current");
var isHidden = inp.type === "password";
inp.type = isHidden ? "text" : "password";
this.textContent = isHidden ? "👁‍🗨" : "👁";
});
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-new");
var isHidden = inp.type === "password";
@@ -1332,12 +1348,13 @@ function openSystemChangePasswordModal(unit, name, icon) {
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("sys-chpw-submit-btn");
var resultEl = document.getElementById("sys-chpw-result");
var currentPassword = document.getElementById("sys-chpw-current").value || "";
var newPassword = document.getElementById("sys-chpw-new").value || "";
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
if (!newPassword || !confirmPassword) {
if (!currentPassword || !newPassword || !confirmPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Both password fields are required.";
resultEl.textContent = "All password fields are required.";
return;
}
@@ -1362,7 +1379,7 @@ function openSystemChangePasswordModal(unit, name, icon) {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword, confirm_password: confirmPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ Free account & Hub login password changed successfully.";
+401
View File
@@ -0,0 +1,401 @@
"""system_password.py -- the one place that knows about the 'free' (and root) password.
Rules
* /etc/shadow is the ONLY source of truth for authentication: Hub login == GDM login.
* /var/lib/secrets/free-password (0600 root) is a DISPLAY copy for the Hub's credentials
view. Only set_free_password() writes it, and it is verified against /etc/shadow before
it is shown, so the Hub can never show a stale password.
* Legacy / unknown-password machines go through the *migration* flow the onboarding already
has: generate a new diceware password, show it, and apply it on acknowledgement. Until
then the old password keeps working everywhere (shadow is untouched).
* No keyring code anywhere: the GNOME login keyring is decoupled from the login password
(blank-password keyring, pam_gnome_keyring off), so nothing has to be wiped or re-keyed.
Needs libxcrypt. On NixOS the Hub service gets: environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"
"""
from __future__ import annotations
import ctypes
import ctypes.util
import hmac
import logging
import os
import secrets
import shutil
import subprocess
import threading
from typing import Callable
logger = logging.getLogger(__name__)
USER = "free"
SECRETS_DIR = "/var/lib/secrets"
DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
ROOT_DISPLAY_FILE = f"{SECRETS_DIR}/root-password"
MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path the onboarding UI already uses
LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start a migration there
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls
_libcrypt_libs = None # list of every loadable libcrypt, in preference order
_libcrypt_sources: list[str] = [] # where those libraries were loaded from
# ── low level ──────────────────────────────────────────────────────────────
def _crypt_libs() -> list:
"""Every libcrypt we can load, best first ($LIBCRYPT, then system ones).
More than one library can be *loadable* while only one can actually
verify the system's hashes: e.g. a bare-soname libcrypt that loads fine
but returns the "*0" failure sentinel for yescrypt ($y$) hashes, which
NixOS uses by default. Verification therefore tries each library until
one gives a conclusive answer instead of trusting the first that loads.
A stale $LIBCRYPT (Nix store path from before an update / GC) is skipped,
never fatal.
"""
global _libcrypt_libs
if _libcrypt_libs is None:
candidates: list[str] = []
env_path = os.environ.get("LIBCRYPT")
if env_path:
candidates.append(env_path)
found = ctypes.util.find_library("crypt")
if found:
candidates.append(found)
candidates += ["libcrypt.so.1", "libcrypt.so.2", "libcrypt.so"]
libs = []
seen = set()
for path in candidates:
try:
lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
except (OSError, AttributeError) as exc:
_libcrypt_sources.append(f"{path} (unloadable: {exc})")
continue
if lib._handle in seen: # same library reached by two names
continue
seen.add(lib._handle)
libs.append(lib)
_libcrypt_sources.append(path)
_libcrypt_libs = libs
return _libcrypt_libs
def _crypt():
libs = _crypt_libs()
if not libs:
raise OSError("could not load any libcrypt")
return libs[0]
# ── PAM fallback ───────────────────────────────────────────────────────────
# crypt() only answers conclusively when a loaded library understands the
# shadow hash's algorithm. When none does (sentinel replies, no library),
# ask PAM instead -- it is literally the check GDM/sudo perform, so a
# password the desktop accepts can never be refused by the Hub just
# because libcrypt could not speak its hash format.
_PAM_SERVICE_CANDIDATES = ("login", "system-auth", "gdm-password", "sudo")
class _PamMessage(ctypes.Structure):
_fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)]
class _PamResponse(ctypes.Structure):
_fields_ = [("resp", ctypes.c_char_p), ("resp_retcode", ctypes.c_int)]
_PAM_CONV_FUNC = ctypes.CFUNCTYPE(
ctypes.c_int, ctypes.c_int,
ctypes.POINTER(ctypes.POINTER(_PamMessage)),
ctypes.POINTER(ctypes.POINTER(_PamResponse)), ctypes.c_void_p,
)
class _PamConv(ctypes.Structure):
_fields_ = [("conv", _PAM_CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)]
def _verify_via_pam(password: str, user: str) -> bool | None:
"""True/False if PAM gave a verdict, None if PAM is unavailable here."""
try:
libpam = None
for candidate in dict.fromkeys(
filter(None, [ctypes.util.find_library("pam"), "libpam.so.0"])):
try:
libpam = ctypes.CDLL(candidate)
break
except OSError:
continue
if libpam is None:
return None
libc = ctypes.CDLL(None)
libc.calloc.restype = ctypes.c_void_p
libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t]
libc.strdup.restype = ctypes.c_void_p
libc.strdup.argtypes = [ctypes.c_char_p]
libpam.pam_start.restype = ctypes.c_int
libpam.pam_start.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
ctypes.POINTER(_PamConv), ctypes.POINTER(ctypes.c_void_p)]
libpam.pam_authenticate.restype = ctypes.c_int
libpam.pam_authenticate.argtypes = [ctypes.c_void_p, ctypes.c_int]
libpam.pam_end.restype = ctypes.c_int
libpam.pam_end.argtypes = [ctypes.c_void_p, ctypes.c_int]
pw_bytes = password.encode()
def _conv(num_msg, msg, resp, _appdata):
if num_msg <= 0:
return 1 # PAM_CONV_ERR
responses = libc.calloc(num_msg, ctypes.sizeof(_PamResponse))
if not responses:
return 1
resp_array = ctypes.cast(responses, ctypes.POINTER(_PamResponse))
for i in range(num_msg):
style = msg[i].contents.msg_style
if style in (1, 2): # PROMPT_ECHO_OFF / PROMPT_ECHO_ON
resp_array[i].resp = ctypes.cast(libc.strdup(pw_bytes), ctypes.c_char_p)
resp[0] = ctypes.cast(responses, ctypes.POINTER(_PamResponse))
return 0 # PAM_SUCCESS
conv_fn = _PAM_CONV_FUNC(_conv) # keep alive for the pam_* calls
conv = _PamConv(conv=conv_fn, appdata_ptr=None)
for service in _PAM_SERVICE_CANDIDATES:
handle = ctypes.c_void_p()
if libpam.pam_start(service.encode(), user.encode(),
ctypes.byref(conv), ctypes.byref(handle)) != 0:
continue
try:
rc = libpam.pam_authenticate(handle, 0)
if rc != 0:
# e.g. 7 = auth failure, 9/10 = bad item / user unknown.
# Logged (code only, never the password) so a Hub-only
# rejection can be told apart from a wrong password.
logger.warning(
"verify via PAM: service=%s user=%s pam_authenticate rc=%d",
service, user, rc)
return rc == 0
finally:
libpam.pam_end(handle, 0)
logger.warning("verify via PAM: no PAM service could be started for user=%s", user)
return None
except Exception as exc:
logger.warning("verify via PAM unavailable for user=%s: %s", user, exc)
return None
def shadow_hash(user: str = USER) -> str | None:
"""Password hash from /etc/shadow, or None if the account has none / is locked."""
try:
with open("/etc/shadow", encoding="utf-8") as f:
for line in f:
fields = line.rstrip("\n").split(":")
if fields[0] == user:
h = fields[1]
return h if h and h[0] not in "!*" else None
except OSError:
return None
return None
def verify_system_password(password: str, user: str = USER) -> bool:
"""True iff `password` is the account's current login password
(yescrypt, sha512crypt, ... whatever chpasswd produced)."""
h = shadow_hash(user)
if not h or not password:
return False
# Try every loadable libcrypt. A reply of None or "*0"/"*1" is a library
# saying "I don't understand this hash" (e.g. no yescrypt support) --
# inconclusive, NOT a wrong password -- so keep trying, and finally ask
# PAM. A real hash that simply doesn't match is conclusive: wrong
# password. Nothing here may raise: raising crashed the System
# Passwords tile ("Could not load service details."), and returning a
# false "wrong password" at login would lock a user whose desktop
# login still works out of the Hub.
try:
with _lock:
for lib in _crypt_libs():
try:
out = lib.crypt(password.encode(), h.encode())
except Exception:
continue
if isinstance(out, str):
out = out.encode()
if out is None or out.startswith(b"*"):
continue
return hmac.compare_digest(out, h.encode())
except Exception:
pass
# No libcrypt could decide. Log *why* (hash scheme only -- never the
# password or hash) so a false "wrong password" at Hub login, while the
# same password signs in at the desktop, is diagnosable from
# `journalctl -u sovran-hub-web` instead of looking like user error.
scheme = h[1:].split("$", 1)[0] if h.startswith("$") else "legacy"
logger.warning(
"verify_system_password: libcrypt inconclusive for user=%s (scheme=%s, "
"sources=%r); falling back to PAM",
user, scheme, _libcrypt_sources,
)
pam_result = _verify_via_pam(password, user)
logger.warning("verify_system_password: PAM verdict for user=%s: %s", user, pam_result)
return bool(pam_result)
def _write_secret(path: str, value: str) -> None:
os.makedirs(SECRETS_DIR, mode=0o700, exist_ok=True)
tmp = path + ".tmp"
with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
f.write(value + "\n")
os.replace(tmp, path)
def _read_secret(path: str) -> str | None:
try:
with open(path, encoding="utf-8") as f:
v = f.read().rstrip("\n")
return v or None
except OSError:
return None
def _chpasswd(user: str, password: str) -> None:
import shutil
bin_ = shutil.which("chpasswd") or "/run/current-system/sw/bin/chpasswd"
subprocess.run([bin_], input=f"{user}:{password}\n", text=True, check=True, capture_output=True)
# ── the single writers ─────────────────────────────────────────────────────
def set_free_password(new_password: str) -> None:
"""System password first, then the display copy. Used by: change-password, reset,
migration acknowledgement, the CLI."""
if not new_password:
raise ValueError("empty password")
_chpasswd(USER, new_password)
_write_secret(DISPLAY_FILE, new_password)
def set_root_password(new_password: str) -> None:
"""Same contract for root (the credentials view lists it too)."""
if not new_password:
raise ValueError("empty password")
_chpasswd("root", new_password)
_write_secret(ROOT_DISPLAY_FILE, new_password)
# ── what the Hub shows ─────────────────────────────────────────────────────
def read_display_password() -> str | None:
"""The 'free' password to SHOW -- only if it really is the current one."""
pw = _read_secret(DISPLAY_FILE)
return pw if pw and verify_system_password(pw) else None
def read_root_display_password() -> str | None:
pw = _read_secret(ROOT_DISPLAY_FILE)
if not pw or ":" in pw: # ':' = scrypt hash written by older reset code -> not showable
return None
return pw if verify_system_password(pw, "root") else None
def _display_state(path: str) -> str:
""""missing" | "legacy" | "present" -- "present" only means a plaintext copy
exists; it can still be stale (use read_display_password() to check that)."""
pw = _read_secret(path)
if pw is None:
return "missing"
return "legacy" if ":" in pw else "present"
# ── migration flow (legacy machines) ────────────────────────────────────────
def migration_pending() -> str | None:
"""The generated password awaiting acknowledgement, if any."""
return _read_secret(MIGRATION_FILE)
def ensure_migration_pending(generate: Callable[[], str]) -> bool:
"""Legacy detection. Call it where onboarding status is computed.
Condition: the account HAS a password (so this is not the first boot) but the Hub
has NO display copy of it at all (file missing or still the old hash format).
Then generate a new password once and park it for the onboarding "migration
password" step. Nothing is applied until the user acknowledges, so the old
password keeps working at GDM and in the Hub. Returns True when a migration is
pending (caller re-opens onboarding).
A copy that exists but merely fails to verify (password changed in GNOME
Settings, `passwd`, or the pre-upgrade Hub) is DRIFT, not a legacy install:
it never triggers a migration and never changes any password -- it is healed
automatically at the next login via record_known_password(pw, only_if_stale=True).
Never fires on the live ISO (/iso) or while shadow is still locked."""
if os.path.exists(LIVE_ISO_MARKER):
return False
if migration_pending():
return True
if shadow_hash() is None:
return False
if _display_state(DISPLAY_FILE) == "present":
return False # copy exists: current, or just stale (drift)
_write_secret(MIGRATION_FILE, generate())
return True
def acknowledge_migration() -> bool:
"""User has written the new password down: make it real, then clear the pending file."""
pw = migration_pending()
if not pw:
return False
set_free_password(pw)
os.remove(MIGRATION_FILE)
return True
def record_known_password(password: str, only_if_stale: bool = False) -> bool:
""""Keep my current password" (migration step): if the user types the correct
current password, store it as the display copy and cancel the migration.
only_if_stale=True is the login-handler heal: write ONLY when an existing
plaintext copy is stale, never create a missing one (a missing copy is the
legacy case and belongs to the migration flow)."""
if not verify_system_password(password):
return False
if only_if_stale and _display_state(DISPLAY_FILE) != "present":
return False
if _read_secret(DISPLAY_FILE) != password:
_write_secret(DISPLAY_FILE, password)
try:
os.remove(MIGRATION_FILE)
except FileNotFoundError:
pass
return True
def generate_diceware_password(words: list[str] | None = None, n: int = 4) -> str:
"""word-word-word-word-NN, same shape the installer/reset produce (CSPRNG)."""
words = words or ("apple barn brook cabin cedar cloud coral crane delta eagle ember fern field "
"flame flora flint frost grove haven hedge holly heron jade juniper kelp larch "
"lemon lilac linden loch lotus maple marsh meadow mist mossy mount oak ocean "
"olive petal pine pixel plum pond prism quartz raven ridge river robin rocky "
"rose rowan sage sand sierra silver slate snow solar spark spruce stone storm "
"summit swift thorn tide timber torch trout vale vault vine walnut wave willow "
"wren amber aspen birch blaze bloom bluff coast copper crest dune elder fjord "
"forge glade glen glow gulf").split()
return "-".join(secrets.choice(words) for _ in range(n)) + f"-{secrets.randbelow(100):02d}"
if __name__ == "__main__": # tiny CLI for credentials.nix: echo NEW | system_password.py set-free
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd in ("set-free", "set-root"):
pw = sys.stdin.readline().rstrip("\n")
(set_free_password if cmd == "set-free" else set_root_password)(pw)
print("ok")
elif cmd == "verify":
sys.exit(0 if verify_system_password(sys.stdin.readline().rstrip("\n")) else 1)
else:
print("usage: system_password.py set-free|set-root|verify (password on stdin)", file=sys.stderr)
sys.exit(2)
+14 -14
View File
@@ -1,16 +1,16 @@
{
"caddy.service": "2.8.4",
"tor.service": "0.4.8.12",
"gnome-remote-desktop.service": "46.0",
"bitcoind.service": "27.1.0",
"electrs.service": "0.10.6",
"lnd.service": "0.18.0",
"rtl.service": "0.15.12",
"btcpayserver.service": "2.4.2",
"albyhub.service": "1.24.0",
"mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0",
"livekit.service": "1.13.6",
"vaultwarden.service": "1.32.0",
"haven-relay.service": "0.1.0"
"caddy.service": "2.11.7",
"tor.service": "0.4.9.14",
"gnome-remote-desktop.service": "50.2",
"bitcoind.service": "31.1",
"electrs.service": "0.12.0",
"lnd.service": "0.21.4-beta",
"rtl.service": "0.15.13",
"btcpayserver.service": "2.4.5",
"albyhub.service": "2.0.0",
"mempool.service": "3.3.1",
"matrix-synapse.service": "1.163.0",
"livekit.service": "1.13.8",
"vaultwarden.service": "1.37.4",
"haven-relay.service": "1.2.2"
}
-12
View File
@@ -74,18 +74,6 @@
services.printing.enable = true;
systemd.enableEmergencyMode = false;
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
security.pam.services.gdm-password.enableGnomeKeyring = true;
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
# Declaratively guarantee the GNOME Keyring default pointer exists.
# Defining the full path ensures root doesn't accidentally lock the user out of .local
systemd.tmpfiles.rules = [
"d /home/free/.local 0700 free users -"
"d /home/free/.local/share 0700 free users -"
"d /home/free/.local/share/keyrings 0700 free users -"
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
];
# ── Audio ──────────────────────────────────────────────────
services.pulseaudio.enable = false;
+5
View File
@@ -22,6 +22,11 @@
# ─── Add your custom NixOS configuration below ───────────
# ─── Mullvad VPN (Desktop Only) ───────────────────────────
# Installed on the Desktop Role as the stock NixOS service; account, relay
# and settings belong to the app. To take it off a Desktop machine:
# services.mullvad-vpn.enable = lib.mkForce false;
# ─── Custom Caddy virtual hosts ──────────────────────────
# Uncomment and edit below to add your own Caddy sites:
#
Generated
+30 -30
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1790862821,
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
"lastModified": 1792500275,
"narHash": "sha256-Y86rSoq9EWo6538kYoWZCnH9+bl0jNS1vQrnNvm8znE=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
"rev": "487c7e9ce3978601d37ac10b18610db672850f3b",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1788450739,
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
"lastModified": 1790898470,
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
"type": "github"
},
"original": {
@@ -41,11 +41,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1790861130,
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
"lastModified": 1792155732,
"narHash": "sha256-3Dk56anLQ1V1VklQFKFD6ul3cKQk40sThurHlPHIhOo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
"rev": "7694c4f6b8537d50062ff36c21ef36de3c9322d7",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1790750587,
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
"lastModified": 1792503283,
"narHash": "sha256-vhe2jyvRCgufWPqDuDq1S3t3ypE+ka46IFutV02xKso=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
"rev": "cd4ea8d57de16f4810bc5f14207bac45fbf19f67",
"type": "github"
},
"original": {
@@ -72,11 +72,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1790822859,
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"lastModified": 1792502884,
"narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
"type": "github"
},
"original": {
@@ -88,11 +88,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1789724158,
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
"lastModified": 1791487292,
"narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
"rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
"type": "github"
},
"original": {
@@ -104,11 +104,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1790822859,
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"lastModified": 1792502884,
"narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
"type": "github"
},
"original": {
@@ -125,11 +125,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1790541651,
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
"lastModified": 1792203519,
"narHash": "sha256-yJ072hxngo/srNdLTp4BlFeo0hedwnKicI3TGlSXLFE=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
"rev": "453dc9c5037e6d1bec81d2a51ddacd58a2f44e9c",
"type": "github"
},
"original": {
@@ -152,11 +152,11 @@
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1790877969,
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
"lastModified": 1792602201,
"narHash": "sha256-5LkFX16SxgXyaUDvFPtHdFGp4w4deqQy9btRmPQSlwU=",
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
"rev": "63e104940ef4ff21eb2b43977ca6e110e9c5d524",
"type": "github"
},
"original": {
@@ -168,11 +168,11 @@
"systems": {
"flake": false,
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"lastModified": 1791244623,
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
"type": "github"
},
"original": {
+3 -3
View File
@@ -51,9 +51,9 @@
];
config = {
environment.systemPackages = with pkgs; [
btc-clients.packages.${pkgs.system}.bisq
btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq2
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.sparrow
];
};
};
+1 -1
View File
@@ -479,7 +479,7 @@ class InstallerWindow(Adw.ApplicationWindow):
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
"Server+Desktop"),
("Desktop Only",
"A beautiful, easy-to-use desktop without the background server applications.",
"A beautiful, easy-to-use desktop without the background server applications. Includes the Mullvad VPN app.",
"Desktop Only"),
("Node Only",
"Full Bitcoin node with Lightning and non-KYC buying and selling. No desktop.",
-171
View File
@@ -33,9 +33,6 @@ let
echo "$NEW_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Password for 'free' updated and saved."
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
rm -f /home/free/.local/share/keyrings/*.keyring
echo "GNOME Keyring files cleared — a fresh keyring will be created on next login."
'';
in
{
@@ -76,172 +73,4 @@ in
command passwd $argv
end
'';
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
systemd.services.root-password-setup = {
description = "Generate and set a random root password";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/root-password"
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
# Generate a diceware-style passphrase: word-word-word-word-NN
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$(printf '%02d' $((RANDOM % 100)))
ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT"
echo "$ROOT_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
fi
# If the file contains a scrypt hash (salt:hash), skip chpasswd — the
# password was already set via the Hub security reset endpoint and this
# service is only re-running as a manual recovery step.
CONTENT="$(cat "$SECRET_FILE")"
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2
else
echo "root:$CONTENT" | chpasswd
fi
'';
};
# ── 1b. Generate random 'free' password on first boot ──────
systemd.services.free-password-setup = {
description = "Generate and set a random 'free' user password";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/free-password"
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
if [ -f "$SECRET_FILE" ]; then
echo "free:$(cat "$SECRET_FILE")" | chpasswd
exit 0
fi
SHADOW_HASH=""
while IFS=: read -r user hash _; do
if [ "$user" = "free" ]; then
SHADOW_HASH="$hash"
break
fi
done < /etc/shadow
HAS_REAL_HASH=0
case "$SHADOW_HASH" in
""|"!"|"*"|"!!"|"!"*|"*"*)
HAS_REAL_HASH=0
;;
*)
HAS_REAL_HASH=1
;;
esac
if [ "$HAS_REAL_HASH" -eq 1 ]; then
mkdir -p /var/lib/secrets
touch "$PENDING_FILE"
chmod 600 "$PENDING_FILE"
exit 0
fi
mkdir -p /var/lib/secrets
# Generate a diceware-style passphrase: word-word-word-word-NN
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$(printf '%02d' $((RANDOM % 100)))
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
echo "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "free:$FREE_PASS" | chpasswd
'';
};
systemd.services.free-password-migration = {
description = "Generate and set 'free' password for migrated machines";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
requires = [ "free-password-setup.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
SECRET_FILE="/var/lib/secrets/free-password"
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
[ -f "$PENDING_FILE" ] || exit 0
mkdir -p /var/lib/secrets
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$(printf '%02d' $((RANDOM % 100)))
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
chmod 600 "$NEWPASS_FILE"
rm -f "$PENDING_FILE"
'';
};
}
+4
View File
@@ -21,6 +21,7 @@
# ── Always on (no flag) ───────────────────────────────────
./php.nix
./credentials.nix
./password-model.nix
# ── Services (default ON — disable in custom.nix) ─────────
./synapse.nix
@@ -34,5 +35,8 @@
./element-calling.nix
./rdp.nix
./sshd.nix
# ── Desktop Role only (gated inside the module) ───────────
./mullvad.nix
];
}
+28
View File
@@ -0,0 +1,28 @@
# ── modules/mullvad.nix ───────────────────────────────────────────────────────
# Mullvad VPN client — Desktop Role only.
#
# A plain install: the daemon (pkgs.mullvad) and the graphical app
# (pkgs.mullvad-vpn) exactly as NixOS ships them, enabled and nothing more. The
# account, the relay, the kill switch and every other setting are the app's
# business; nothing here configures them, and nothing here is wired into the
# Hub or into any other part of the system.
#
# The role gate is the whole point: Server + Desktop and Bitcoin Node are
# machines that serve, and a VPN client with its own DNS and routing policy is
# not something their services asked for. Only roles.desktop builds it.
#
# Turning it off on a Desktop machine: uncomment
# services.mullvad-vpn.enable = lib.mkForce false;
# in custom.nix (the mkForce is needed because this module sets it too).
{ config, lib, ... }:
lib.mkIf config.sovran_systemsOS.roles.desktop {
services.mullvad-vpn = {
# The daemon and its systemd service.
enable = true;
# The desktop app.
gui.enable = true;
};
}
+160
View File
@@ -0,0 +1,160 @@
{ config, pkgs, lib, ... }:
# Sovran_SystemsOS -- password model cleanup (GNOME 50 / gnome-keyring 50)
#
# * /etc/shadow is the only source of truth for authentication (Hub login == GDM login).
# * /var/lib/secrets/{free,root}-password are root-only DISPLAY copies for the Hub, written
# by exactly one module (system_password.py) and verified against shadow before shown.
# * The GNOME login keyring is decoupled from the login password: it is created with a
# blank password and pam_gnome_keyring never hands the login password to the daemon.
# => no "unlock your login keyring" prompts, ever (also on the autologin live ISO);
# Brave's cookie/password key survives every password change; no *.keyring wiping.
# * Legacy machines (password unknown to the Hub) are handled by the Hub's existing
# onboarding "migration password" step -- not by a boot service any more.
#
# Import from configuration.nix and DELETE these lines there:
# security.pam.services.gdm-password.enableGnomeKeyring = true;
# security.pam.services.gdm-autologin.enableGnomeKeyring = true;
# the four systemd.tmpfiles.rules for /home/free/.local/share/keyrings/{,default}
# and in modules/credentials.nix delete root-password-setup, free-password-setup,
# free-password-migration and the `rm -f /home/free/.local/share/keyrings/*.keyring` lines.
let
user = "free";
home = "/home/${user}";
# A login keyring with a blank password. gnome-keyring stores those in this textual
# format and auto-unlocks them on demand without any prompt (verified on 48; the rule in
# gkm-secret-collection.c that picks the textual format for a blank master is unchanged
# in 50 and the 50.0 release notes contain no behavioural changes).
blankLoginKeyring = pkgs.writeText "login.keyring" ''
[keyring]
display-name=login
ctime=0
mtime=0
lock-on-idle=false
lock-after=false
'';
# Which keyring gnome-keyring treats as the default collection. Without
# this pointer the daemon falls back to a keyring literally named
# "Default" (default.keyring), which is created encrypted and prompts
# "An application wants access to the keyring \"Default keyring\"" --
# e.g. Brave on Hub launch -- even though the login keyring is blank.
# The pre-cleanup configuration.nix seeded this file; password-model.nix
# dropped it by mistake.
defaultKeyringPointer = pkgs.writeText "keyring-default" ''
login
'';
# Apply the installer's plaintext password ONCE: only while the account still has no
# usable hash (nixos-install --no-root-password leaves both accounts locked). After that
# the file is display-only and the Hub (system_password.py) is the only thing that
# writes it. This replaces the old services, which re-applied the file on EVERY boot
# and silently reverted any password changed elsewhere.
# On already-installed machines the case below matches `*)` (real hash) -> no-op:
# an upgrade can never change or generate a password.
applyOnce = { account, fallback ? false }: ''
set -euo pipefail
f=/var/lib/secrets/${account}-password
hash="$(getent shadow ${account} | cut -d: -f2 || true)"
case "$hash" in
""|"!"*|"*"*)
if [ -s "$f" ] && ! grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$' "$f"; then
echo "${account}:$(head -n1 "$f")" | chpasswd
${lib.optionalString fallback ''
else
# Installer left no usable file: generate one so GDM is always reachable
# (the user reads it in the Hub). Never runs once the account has a password.
W="apple barn brook cabin cedar cloud coral crane delta eagle ember fern field flame
flora flint frost grove haven hedge holly heron jade juniper kelp larch lemon lilac
linden loch lotus maple marsh meadow mist mossy mount oak ocean olive petal pine
pixel plum pond prism quartz raven ridge river robin rocky rose rowan sage sand
sierra silver slate snow solar spark spruce stone storm summit swift thorn tide
timber torch trout vale vault vine walnut wave willow wren"
pw=$(echo $W | tr ' ' '\n' | shuf -n4 | paste -sd- -)-$(printf '%02d' $((RANDOM % 100)))
umask 077; printf '%s\n' "$pw" > "$f"
echo "${account}:$pw" | chpasswd
''}
fi ;;
*) ;; # password already set: never touch it here again
esac
'';
in
{
# ── 1. Keyring: never learns the login password ───────────────────────────
# nixpkgs' gnome-keyring module turns this on for `login` (gdm-password includes it).
# If the daemon ever receives the login password it re-encrypts the blank keyring with
# it on the next save -- so it must be off everywhere.
security.pam.services.login.enableGnomeKeyring = lib.mkForce false;
security.pam.services.gdm-password.enableGnomeKeyring = lib.mkForce false;
security.pam.services.gdm-autologin.enableGnomeKeyring = lib.mkForce false;
security.pam.services.passwd.enableGnomeKeyring = lib.mkForce false;
# Seed the blank keyring. `C` copies only if the file does not exist, so an existing
# keyring is never overwritten -- which is also what makes a reset's `rm *.keyring`
# harmless: the next boot re-seeds a fresh blank keyring.
systemd.tmpfiles.rules = [
"d ${home}/.local 0700 ${user} users -"
"d ${home}/.local/share 0700 ${user} users -"
"d ${home}/.local/share/keyrings 0700 ${user} users -"
"C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}"
"C ${home}/.local/share/keyrings/default 0600 ${user} users - ${defaultKeyringPointer}"
];
# One-time migration for machines that already have a keyring encrypted with the
# (old) login password: move it aside and seed the blank one. Runs before GDM; the old
# file is kept. Same cost users pay today on every password change (Brave logs you out
# of sites once) -- but only once, ever.
system.activationScripts.sovranBlankKeyring = lib.stringAfter [ "users" ] ''
d=${home}/.local/share/keyrings
if [ -f "$d/login.keyring" ] && ! head -c 9 "$d/login.keyring" | grep -q '^\[keyring\]'; then
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
fi
# Machines that already lost the default pointer may also have picked up
# an encrypted "Default keyring" (default.keyring) that prompts on every
# application start (Brave/Hub). Point the default at the blank login
# keyring again, and move any encrypted Default keyring aside (kept,
# never deleted) so the prompt cannot come back.
if [ -d "$d" ]; then
if [ ! -f "$d/default" ] || ! grep -qx 'login' "$d/default"; then
install -o ${user} -g users -m 0600 ${defaultKeyringPointer} "$d/default"
fi
if [ -f "$d/default.keyring" ] && ! head -c 9 "$d/default.keyring" | grep -q '^\[keyring\]'; then
mv "$d/default.keyring" "$d/default.keyring.encrypted-$(date +%Y%m%d)"
fi
fi
'';
# ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
# service card is bound to root-password-setup.service) ───────────────────────
systemd.services.free-password-setup = {
description = "Apply the installer's 'free' password (first boot only)";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
script = applyOnce { account = user; fallback = true; };
};
systemd.services.root-password-setup = {
description = "Apply the installer's root password (first boot only)";
wantedBy = [ "multi-user.target" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
script = applyOnce { account = "root"; };
};
# Legacy detection now lives in the Hub (system_password.ensure_migration_pending).
systemd.services.free-password-migration.enable = false;
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
# ctypes fallback lookups (bare "libcrypt.so.1" / "libpam.so.0") resolve
# through LD_LIBRARY_PATH inside the unit. Without this they find nothing
# on NixOS (no ld.so.conf paths) or glibc's failure-token libcrypt stub,
# whose "*0"/NULL answers read as a wrong password at Hub login even
# though the same password signs in at the desktop. Put the real
# libxcrypt and PAM libraries on the Hub's library path.
systemd.services.sovran-hub-web.environment.LD_LIBRARY_PATH =
lib.makeLibraryPath [ pkgs.libxcrypt pkgs.pam ];
}
+34 -1
View File
@@ -18,9 +18,42 @@
#
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
# the `phpBase` line below.
#
# Security floor (October 2026):
# CVE-2026-109618 (RCE via OOM corruption) and CVE-2026-109619 (RCE via
# stale global state) are critical, unauthenticated flaws in PHP itself,
# CVSS v4.0 9.2 each, fixed in 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 on
# 2026-10-21. PHP-FPM is named as susceptible in both advisories, so the
# pools below are in scope; updating is the only complete mitigation.
# nixpkgs bumped php85 in 81513f364091c759812f716cef3c175109d07342 the same
# day, but the nixpkgs revision this flake pins predates it, so the floor is
# enforced here. Once flake.lock is at or past that commit the `else`
# branch below stops being taken and can be deleted.
let
phpBase = pkgs.php85;
# The first 8.5.x release that carries the fixes above. Bump only upward.
phpSecurityFloor = "8.5.12";
# php-8.5.12.tar.bz2, the same artifact and URL shape nixpkgs' own
# generic.nix fetches. Verified against php.net on 2026-10-23.
phpSourceHash = "sha256-/7YO3EN87WO1E35JhNs3moERxQBJxxFRDYuVgnfvwag=";
# `.override` (not `overrideAttrs`) on purpose: php85 is a `makeOverridable`
# buildEnv, so this threads `version`/`phpSrc` into generic.nix while
# keeping the default extension set. `overrideAttrs` would keep the new
# source but drop the extension set, which would silently strip gd, intl,
# pdo_pgsql and friends out of the pools below.
phpBase =
if lib.versionAtLeast pkgs.php85.version phpSecurityFloor then
pkgs.php85
else
pkgs.php85.override {
version = phpSecurityFloor;
phpSrc = pkgs.fetchurl {
url = "https://www.php.net/distributions/php-${phpSecurityFloor}.tar.bz2";
hash = phpSourceHash;
};
};
custom-php = phpBase.buildEnv {
# `enabled` is nixpkgs' default extension set. It already contains every
+59 -9
View File
@@ -12,7 +12,7 @@
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
# forced wallet enable, Hub firewall port, domain requirements)
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
{ config, pkgs, lib, ... }:
{ config, pkgs, lib, options, ... }:
let
cfg = config.sovran_systemsOS;
@@ -32,7 +32,7 @@ let
NWC_LND_CERT_FILE = lndCertPath;
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
NWC_RELAY =
if cfg.features.haven
if cfg.features.haven && config.networking.domain != null && config.networking.domain != ""
then "wss://haven.${config.networking.domain}/nostr"
else "wss://relay.getalby.com,wss://relay2.getalby.com";
};
@@ -55,11 +55,34 @@ in {
};
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
services.bitcoind = lib.mkIf cfg.services.bitcoin {
services.bitcoind = lib.mkIf cfg.services.bitcoin ({
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
disablewallet = lib.mkForce false;
};
# electrs 0.12.0 (bindex) requires bitcoind's REST interface (rest=1).
extraConfig = lib.optionalString (!(options.services.bitcoind ? rest)) "rest=1";
} // lib.optionalAttrs cfg.web.btcpayserver {
rpc.users.btcpayserver.rpcwhitelist = lib.mkForce (lib.unique (config.services.bitcoind.rpc.users.public.rpcwhitelist ++ [
"setban"
"generatetoaddress"
"getrpcinfo"
"gettransaction"
"getwalletinfo"
"getnewaddress"
"listunspent"
"listtransactions"
"listreceivedbyaddress"
"importmulti"
"importdescriptors"
"importaddress"
"importprivkey"
"importpubkey"
"rescanblockchain"
"createwallet"
"loadwallet"
"signrawtransactionwithwallet"
]));
});
services.electrs = lib.mkIf cfg.services.bitcoin {
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
@@ -72,11 +95,38 @@ in {
serviceConfig.PrivateUsers = lib.mkForce false;
};
systemd.services.electrs = lib.mkIf cfg.services.bitcoin {
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
wants = [ "bitcoind.service" ];
};
systemd.services.electrs = lib.mkIf cfg.services.bitcoin (
let
bitcoind = config.services.bitcoind;
electrsCfg = config.services.electrs;
nbLib = config.nix-bitcoin.lib;
in {
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
wants = [ "bitcoind.service" ];
} // lib.optionalAttrs (!(options.services.bitcoind ? rest)) {
# Fallback when locked against Sovran_Bitcoin <= 1.1.1:
# electrs 0.12.0 removed --daemon-p2p-addr and switched to bindex (format v1).
preStart = lib.mkAfter ''
dbNetDir='${electrsCfg.dataDir}/${bitcoind.makeNetworkName "bitcoin" "regtest"}'
if [[ -d "$dbNetDir" ]] && grep -q 'prefix_extractor=rocksdb\.FixedPrefix\.8' "$dbNetDir"/OPTIONS-* 2>/dev/null; then
echo "electrs: removing pre-0.12.0 database at $dbNetDir for bindex 0.12.0 reindex"
rm -rf "$dbNetDir"
fi
'';
serviceConfig.ExecStart = lib.mkForce ''
${pkgs.electrs}/bin/electrs \
--log-filters=INFO \
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
--db-dir='${electrsCfg.dataDir}' \
--daemon-dir='${bitcoind.dataDir}' \
--electrum-rpc-addr=${electrsCfg.address}:${toString electrsCfg.port} \
--monitoring-addr=${electrsCfg.address}:${toString electrsCfg.monitoringPort} \
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
${electrsCfg.extraArgs}
'';
}
);
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
wants = [ "bitcoind.service" ];
+112
View File
@@ -0,0 +1,112 @@
"""Guards for the Mullvad VPN client being a plain Desktop Role install.
The desktop app and its daemon ship with Desktop Only and with no other role:
Server + Desktop and Bitcoin Node are machines that serve, and a VPN client with
its own DNS and routing policy is not something their services asked for.
Beyond that the install is deliberately boring. It is the NixOS service exactly
as NixOS ships it — no local-network boot service, no DNS or firewall edits of
our own, no Hub tile, no Feature Manager card, no toggles. The account, the
relay and the kill switch belong to the app.
Like the other nix-file checks these read the shipped sources as text: nothing is
run and nothing touches the network.
"""
import os
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _without_comments(src):
"""The source with whole-line `#` comments removed."""
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
class TheClientIsBuiltOnDesktopAndNowhereElse(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = _read("modules", "mullvad.nix")
cls.code = _without_comments(cls.module)
def test_the_module_is_imported(self):
self.assertIn("./mullvad.nix", _read("modules", "modules.nix"))
def test_the_gate_is_the_desktop_role(self):
self.assertRegex(
self.code,
r"lib\.mkIf\s+config\.sovran_systemsOS\.roles\.desktop\s*\{",
)
def test_no_other_role_is_mentioned(self):
self.assertNotIn("server_plus_desktop", self.code)
self.assertNotIn("roles.node", self.code)
def test_the_install_is_the_daemon_and_the_app(self):
self.assertRegex(self.code, r"services\.mullvad-vpn\s*=\s*\{")
self.assertRegex(self.code, r"(?m)^ enable = true;$")
self.assertRegex(self.code, r"gui\.enable = true;")
class TheInstallStaysVanilla(unittest.TestCase):
"""Nothing is configured for the user, in the module or out of it."""
@classmethod
def setUpClass(cls):
cls.module = _without_comments(_read("modules", "mullvad.nix"))
def test_no_extra_services_are_added(self):
self.assertNotIn("systemd.services", self.module)
def test_no_dns_or_network_edits_of_our_own(self):
# The NixOS module already turns on systemd-resolved and points
# NetworkManager at it; re-declaring either here would be our policy,
# not the distribution's.
for key in ("services.resolved", "networkmanager", "networking."):
self.assertNotIn(key, self.module)
def test_no_firewall_or_kernel_edits(self):
self.assertNotIn("firewall", self.module)
self.assertNotIn("boot.", self.module)
def test_no_packages_are_forced_in(self):
self.assertNotIn("environment.systemPackages", self.module)
def test_the_feature_flag_does_not_come_back(self):
# A Hub-managed feature flag on an app that is simply installed would
# put a toggle in front of something that has no decision to make.
for path in (("modules", "core", "roles.nix"),
("modules", "core", "role-logic.nix")):
self.assertNotIn("mullvad", _read(*path).lower(), f"{path[-1]} mentions mullvad")
class TheHubDoesNotMentionIt(unittest.TestCase):
"""The app is not a service the Hub manages or announces."""
def test_the_hub_tiles_and_roles_are_untouched(self):
self.assertNotIn("mullvad", _read("modules", "core", "sovran-hub.nix").lower())
def test_the_hub_backend_is_untouched(self):
self.assertNotIn("mullvad", _read("app", "sovran_systemsos_web", "server.py").lower())
def test_the_desktop_shell_is_untouched(self):
# The app appears in the GNOME app grid by itself; no folder edit, no
# dock entry.
self.assertNotIn(
"mullvad", _read("modules", "core", "sovran_systemsos-desktop.nix").lower())
def test_there_is_no_icon_for_it(self):
# app/icons holds Hub tile icons only.
self.assertFalse(
os.path.exists(os.path.join(_ROOT, "app", "icons", "mullvad.svg")))
if __name__ == "__main__":
unittest.main()
+19 -12
View File
@@ -386,7 +386,13 @@ class TestFrontendAuthRecovery(unittest.TestCase):
class TestManualLogoutPersistence(unittest.TestCase):
"""Explicit logout must take precedence over desktop auto-login."""
"""Hub sign-out must NOT make the desktop Hub icon demand a password.
The icon is a trusted local entry point (its user is already signed in
to the OS), and the old hub_manual_logout cookie blocked it for 400
days after a single sign-out -- users read that as "the Hub is locked".
Sign-out still destroys the session; LAN clients never get auto-login.
"""
@classmethod
def setUpClass(cls):
@@ -397,22 +403,24 @@ class TestManualLogoutPersistence(unittest.TestCase):
def _between(self, start, end):
return self.server.split(start, 1)[1].split(end, 1)[0]
def test_auto_login_honors_manual_logout_cookie(self):
def test_auto_login_ignores_manual_logout_cookie(self):
route = self._between(
'@app.get("/auto-login")',
"class LoginRequest",
)
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn('RedirectResponse(url="/login"', route)
self.assertNotIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertNotIn('RedirectResponse(url="/login"', route)
# Legacy cookies are cleared so the icon keeps working.
self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
def test_logout_sets_persistent_manual_logout_cookie(self):
def test_logout_sets_no_persistent_manual_logout_cookie(self):
route = self._between(
'@app.post("/api/logout")',
"def _get_sovran_version",
)
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
self.assertIn("httponly=True", route)
self.assertNotIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn("_destroy_session(token)", route)
def test_password_login_clears_manual_logout_cookie(self):
route = self._between(
@@ -427,10 +435,9 @@ class TestManualLogoutPersistence(unittest.TestCase):
class TestHubBrowserProfilePersistence(unittest.TestCase):
"""The desktop launcher must keep a persistent browser profile.
The hub_manual_logout marker (and the session cookie) are stored in this
profile. If the launcher used an ephemeral /tmp profile that it deleted on
exit, closing and reopening the Hub window would wipe the marker and
/auto-login would silently log the user back in without a password.
The session cookie is stored in this profile. If the launcher used an
ephemeral /tmp profile that it deleted on exit, the browser would lose
its session (and all site state) every time the Hub window closed.
"""
@classmethod
+221
View File
@@ -0,0 +1,221 @@
"""Regression tests for the System Passwords Hub tile.
Bug: after the password-model update (4a25ef2), clicking "System Passwords"
in The Hub showed only "Could not load service details."
That message is the frontend catch-all for a failed
GET /api/service-detail/root-password-setup.service. The new credential path
was the only one that called system_password.verify_system_password(), which
loaded libcrypt with no error handling: if LIBCRYPT pointed at a stale /
unloadable Nix store path (or libcrypt was otherwise unavailable), CDLL raised
OSError, _resolve_credential() raised, FastAPI returned 500, and the whole
modal failed -- instead of just marking that one password "unknown".
Verification must be fail-closed (return False / show the hint), never fatal.
"""
import importlib.util
import os
import sys
import unittest
import unittest.mock
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _load_system_password():
path = os.path.join(_ROOT, "app", "sovran_systemsos_web", "system_password.py")
spec = importlib.util.spec_from_file_location("system_password_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
# A real sha512crypt hash (of "hello", salt "saltsalt") so crypt() can succeed
# against the system libcrypt when the preferred path is stale.
_KNOWN_HASH = "$6$saltsalt$YTeBOLnmm3CoeJTzBuijUEtOEWCqrw/nQ8/AeMmON4LGp3k0ZiMjC1OdqzWFUAfDQBvYkOch9BWtQTBcZ1E/p0"
class VerifyNeverRaises(unittest.TestCase):
def setUp(self):
self.sp = _load_system_password()
self._old_env = os.environ.get("LIBCRYPT")
def tearDown(self):
if self._old_env is None:
os.environ.pop("LIBCRYPT", None)
else:
os.environ["LIBCRYPT"] = self._old_env
def test_unloadable_libcrypt_is_false_not_an_exception(self):
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
# No working libcrypt anywhere: every load attempt fails.
with unittest.mock.patch.object(
self.sp.ctypes, "CDLL", side_effect=OSError("cannot open shared object")
):
self.assertFalse(self.sp.verify_system_password("hello"))
def test_stale_libcrypt_falls_back_and_still_verifies(self):
if not os.path.exists("/usr/lib/x86_64-linux-gnu/libcrypt.so.1") and \
not os.path.exists("/lib/x86_64-linux-gnu/libcrypt.so.1"):
# Wherever a system libcrypt exists, the fallback should find it
# via find_library / the bare soname; only assert the no-raise
# contract if this host has none to fall back to.
pass
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
try:
result = self.sp.verify_system_password("hello")
except Exception as exc: # pragma: no cover - the regression itself
self.fail(f"verify_system_password raised {exc!r} instead of failing closed")
self.assertIn(result, (True, False))
def test_display_password_is_none_not_an_exception_when_unverifiable(self):
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
self.sp._read_secret = lambda path: "hello"
with unittest.mock.patch.object(
self.sp.ctypes, "CDLL", side_effect=OSError("cannot open shared object")
):
self.assertIsNone(self.sp.read_display_password())
self.assertIsNone(self.sp.read_root_display_password())
class WrongLibraryMustNotLockTheUserOut(unittest.TestCase):
"""A loadable libcrypt that cannot speak the shadow hash's algorithm
(yescrypt $y$ on NixOS) answers with the "*0" sentinel. That used to be
compared against the hash, found unequal, and reported as a *wrong
password* at Hub login -- locking out a user whose desktop login with
the very same password still worked, after the tile's hint told them
to sign out. Inconclusive answers must fall through to the next
library, and finally to PAM (the check GDM itself performs)."""
def setUp(self):
self.sp = _load_system_password()
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
self._old_libs = self.sp._libcrypt_libs
def tearDown(self):
self.sp._libcrypt_libs = self._old_libs
def test_sentinel_from_first_library_falls_through_to_the_next(self):
class _SentinelLib:
@staticmethod
def crypt(_pw, _salt):
return b"*0"
class _RealLib:
@staticmethod
def crypt(pw, salt):
return _KNOWN_HASH.encode() if pw == b"hello" else b"$6$saltsalt$nomatch"
self.sp._libcrypt_libs = [_SentinelLib, _RealLib]
self.assertTrue(self.sp.verify_system_password("hello"))
self.assertFalse(self.sp.verify_system_password("not-the-password"))
def test_when_no_library_understands_the_hash_pam_decides(self):
class _SentinelLib:
@staticmethod
def crypt(_pw, _salt):
return b"*0"
self.sp._libcrypt_libs = [_SentinelLib]
with unittest.mock.patch.object(
self.sp, "_verify_via_pam", return_value=True
) as pam:
self.assertTrue(self.sp.verify_system_password("hello"))
pam.assert_called_once()
def test_hint_no_longer_tells_the_user_to_sign_out_blind(self):
for text in (
_read("app", "sovran_systemsos_web", "server.py"),
_read("app", "sovran_systemsos_web", "static", "js", "service-detail.js"),
):
self.assertNotIn("sign out and back in to show it here", text)
class HubUnitMustBeAbleToLoadItsVerifiers(unittest.TestCase):
"""Patch 0002's PAM fallback could only work if libpam.so.0 is findable
inside the Hub unit. On NixOS a bare soname resolves via LD_LIBRARY_PATH
or not at all, so the unit must put the real libxcrypt + PAM libraries
on it -- otherwise a correct desktop password reads as 'wrong password'
at Hub login while the desktop accepts it."""
def test_hub_unit_gets_real_libcrypt_and_pam_on_library_path(self):
nix = _read("modules", "password-model.nix")
self.assertIn("LD_LIBRARY_PATH", nix)
self.assertIn("pkgs.libxcrypt pkgs.pam", nix)
def test_no_unix_chkpwd_shortcut(self):
# unix_chkpwd refuses (and stalls) when invoked outside pam_unix;
# PAM is the only supported route to it.
src = _read("app", "sovran_systemsos_web", "system_password.py")
self.assertNotIn("_verify_via_unix_chkpwd", src)
class RootHintMustNotClaimItIsTheFreePassword(unittest.TestCase):
"""The root row in the System Passwords tile showed the free/hub hint
verbatim. Root has no desktop or Hub sign-in, so that text was false
and sent users looking for a sign-in that could refresh it."""
@classmethod
def setUpClass(cls):
cls.server = _read("app", "sovran_systemsos_web", "server.py")
def test_root_gets_its_own_hint(self):
self.assertIn("ROOT_DISPLAY_FILE", self.server)
start = self.server.index('if filepath == sp.ROOT_DISPLAY_FILE:')
block = self.server[start:start + 1400]
self.assertIn("Root has no desktop or Hub sign-in", block)
self.assertIn("sudo passwd root", block)
self.assertIn("Security Reset", block)
class DefaultKeyringMustStayTheBlankLoginKeyring(unittest.TestCase):
"""The password cleanup dropped the keyrings/default pointer, so
gnome-keyring fell back to an encrypted keyring literally named
"Default" and Brave prompted for it on every Hub launch."""
@classmethod
def setUpClass(cls):
cls.nix = _read("modules", "password-model.nix")
def test_default_pointer_is_seeded(self):
self.assertIn("keyring-default", self.nix)
self.assertIn("/.local/share/keyrings/default ", self.nix)
def test_encrypted_default_keyring_is_moved_aside_never_deleted(self):
self.assertIn("default.keyring.encrypted-", self.nix)
self.assertNotIn('rm "$d/default.keyring"', self.nix)
self.assertNotIn("rm -f \"$d/default.keyring\"", self.nix)
class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
"""Source guards (server.py is not importable without the full Hub deps,
same reason the other suites read it as text)."""
@classmethod
def setUpClass(cls):
cls.server = _read("app", "sovran_systemsos_web", "server.py")
def test_resolve_credential_guards_system_password_calls(self):
start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):")
block = self.server[start:start + 3200]
self.assertIn("try:", block)
self.assertIn("except Exception", block)
self.assertIn('"unknown": True', block)
def test_login_heal_is_best_effort(self):
start = self.server.index("sp.record_known_password(submitted, only_if_stale=True)")
block = self.server[max(0, start - 200):start + 200]
self.assertIn("except Exception", block)
if __name__ == "__main__":
unittest.main()