Compare commits
19
Commits
5e56b07465
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccef1f145f | ||
|
|
be9afb2f53 | ||
|
|
39ed0cc3ae | ||
|
|
87aa9b741b | ||
|
|
ed9e7ce065 | ||
|
|
e060ec96eb | ||
|
|
761a7d76e9 | ||
|
|
d84e0c4d82 | ||
|
|
e718404e84 | ||
|
|
07566c6a28 | ||
|
|
1d59a995e8 | ||
|
|
f1f0bf0d11 | ||
|
|
7b5f25d599 | ||
|
|
497ac49cfd | ||
|
|
8fd0318083 | ||
|
|
aed1ed5bd6 | ||
|
|
8f5e5394e6 | ||
|
|
36bd2b4e78 | ||
|
|
4a25ef24d1 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""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 & 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.";
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 ];
|
||||
}
|
||||
+69
-2
@@ -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
|
||||
@@ -29,7 +62,41 @@ let
|
||||
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
|
||||
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
|
||||
# and no longer appears as a separate extension.
|
||||
extensions = { enabled, all }: enabled ++ (with all; [
|
||||
extensions = { enabled, all }:
|
||||
let
|
||||
# nixpkgs builds PHP with `--disable-all` and every extension as a
|
||||
# shared module. Three of the OOM regression tests shipped with
|
||||
# 8.5.12 spawn `php-cgi -n` on purpose — no php.ini, so no shared
|
||||
# module, so filter_input() is never defined and the request never
|
||||
# reaches the code under test. php-filter's `make test` then fails
|
||||
# with "doesn't reach hard limit" for oom_filter_input,
|
||||
# oom_filter_input_array and oom_filter_default_encoded, and the
|
||||
# whole system build stops there.
|
||||
#
|
||||
# Upstream nixpkgs fixes this in a5fc1be5b6 ("php: fix `filter`
|
||||
# tests"), shipped alongside the 8.5.12 bump. This lock predates
|
||||
# it, so apply the same substitution here; drop this once flake.lock
|
||||
# is past a5fc1be5b6, since the two would otherwise both patch.
|
||||
#
|
||||
# Done by rewriting the filter derivation in the extension list
|
||||
# rather than through buildEnv's `packageOverrides`: that argument
|
||||
# only reaches the unwrapped PHP, while the extension set is folded
|
||||
# from mkPhp's own packageOverrides, so it is silently ignored here.
|
||||
patchFilterTests = ext:
|
||||
if (ext.extensionName or "") != "filter" then
|
||||
ext
|
||||
else
|
||||
ext.overrideAttrs (prevAttrs: {
|
||||
postConfigure = (prevAttrs.postConfigure or "") + ''
|
||||
substituteInPlace \
|
||||
tests/oom_filter_input.phpt \
|
||||
tests/oom_filter_input_array.phpt \
|
||||
tests/oom_filter_default_encoded.phpt \
|
||||
--replace-fail "'-n'," "'-n', '-d', 'extension=$PWD/modules/filter.so',"
|
||||
'';
|
||||
});
|
||||
in
|
||||
map patchFilterTests enabled ++ (with all; [
|
||||
bz2 # Nextcloud: bz2 archive support
|
||||
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
|
||||
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
|
||||
|
||||
@@ -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 {
|
||||
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" ];
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user