Compare commits

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

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

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

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

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

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

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

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

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

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

3) Brave prompted for a locked 'Default keyring' on Hub launch: the password cleanup removed configuration.nix's keyrings/default pointer and password-model.nix never re-seeded it, so gnome-keyring fell back to an encrypted keyring named Default. password-model.nix now seeds default -> login (the blank keyring) via tmpfiles, and its activation migration restores the pointer and moves any encrypted default.keyring aside (kept, never deleted).
2026-10-18 21:39:25 -05:00
11 changed files with 245 additions and 61 deletions

No files matched your search

+18
View File
@@ -7,6 +7,24 @@ 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 ## [1.2.1] - 2026-10-18
### Added ### Added
+11 -11
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready. are ready.
[Visit the Website](https://sovransystems.com) · [Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.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) · [Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256) · [Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso.sha256) ·
[Build from Source](#build-from-source) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -401,8 +401,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum ### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.2.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) - [Download Sovran_SystemsOS-1.2.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso)
- [Download Sovran_SystemsOS-1.2.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256) - [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 The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. verifying it, and keep both files in the same folder.
@@ -419,13 +419,13 @@ ISO must match the published one exactly.
Open a terminal in the download folder and run: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS-1.2.1.iso.sha256 sha256sum --check Sovran_SystemsOS-1.2.2.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```text
Sovran_SystemsOS-1.2.1.iso: OK Sovran_SystemsOS-1.2.2.iso: OK
``` ```
</details> </details>
@@ -436,11 +436,11 @@ Sovran_SystemsOS-1.2.1.iso: OK
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```bash
shasum -a 256 Sovran_SystemsOS-1.2.1.iso shasum -a 256 Sovran_SystemsOS-1.2.2.iso
``` ```
Compare the value shown in Terminal with the value inside Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.2.1.iso.sha256`. `Sovran_SystemsOS-1.2.2.iso.sha256`.
</details> </details>
@@ -450,7 +450,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```powershell
Get-FileHash .\Sovran_SystemsOS-1.2.1.iso -Algorithm SHA256 Get-FileHash .\Sovran_SystemsOS-1.2.2.iso -Algorithm SHA256
``` ```
Compare the value under `Hash` with the published checksum. Compare the value under `Hash` with the published checksum.
@@ -465,7 +465,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.1.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 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 4. Choose **Flash** and wait for the writing and verification process to
@@ -883,7 +883,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) · [Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.2.iso) ·
[View the License](LICENSE) [View the License](LICENSE)
</div> </div>
+1 -1
View File
@@ -1 +1 @@
1.2.1 1.2.2
+25 -16
View File
@@ -1822,9 +1822,14 @@ def _resolve_credential(cred: dict) -> dict | None:
logger.warning("Could not resolve %s for display: %s", filepath, exc) logger.warning("Could not resolve %s for display: %s", filepath, exc)
pw, migration = None, None pw, migration = None, None
if pw is None: if pw is None:
hint = ("Changed outside the Hub — finish the password step in onboarding to see it here." if filepath == sp.ROOT_DISPLAY_FILE:
if migration else # Root is NOT the desktop/Hub sign-in: nothing a user signs
"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.") # 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": "", "unknown": True, "hint": hint}
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline} return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
@@ -2663,10 +2668,13 @@ async def auto_login_redirect(request: Request):
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
if client_ip not in ("127.0.0.1", "::1"): if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
# An explicit logout must take precedence over the desktop launcher's # The desktop Hub icon is a trusted local entry point: whoever can click
# localhost auto-login, including after the Hub window is closed/reopened. # it is already signed in to this machine as 'free', so it must never
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1": # demand the Hub password again. A legacy hub_manual_logout cookie (set
return RedirectResponse(url="/login", status_code=303) # 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() token = _create_session()
response = RedirectResponse(url="/", status_code=303) response = RedirectResponse(url="/", status_code=303)
response.set_cookie( response.set_cookie(
@@ -2677,6 +2685,7 @@ async def auto_login_redirect(request: Request):
samesite="lax", samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port secure=False, # LAN-only appliance; no TLS on the Hub port
) )
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
return response return response
@@ -2724,20 +2733,20 @@ async def api_login(req: LoginRequest, request: Request):
@app.post("/api/logout") @app.post("/api/logout")
async def api_logout(request: Request): 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) token = request.cookies.get(SESSION_COOKIE_NAME)
if token: if token:
_destroy_session(token) _destroy_session(token)
response = JSONResponse({"ok": True}) response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME) response.delete_cookie(key=SESSION_COOKIE_NAME)
response.set_cookie( response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
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
)
return response return response
+31 -3
View File
@@ -18,12 +18,16 @@ from __future__ import annotations
import ctypes import ctypes
import ctypes.util import ctypes.util
import hmac import hmac
import logging
import os import os
import secrets import secrets
import shutil
import subprocess import subprocess
import threading import threading
from typing import Callable from typing import Callable
logger = logging.getLogger(__name__)
USER = "free" USER = "free"
SECRETS_DIR = "/var/lib/secrets" SECRETS_DIR = "/var/lib/secrets"
DISPLAY_FILE = f"{SECRETS_DIR}/free-password" DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
@@ -33,6 +37,7 @@ LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls _lock = threading.Lock() # crypt() returns a static buffer; serialise calls
_libcrypt_libs = None # list of every loadable libcrypt, in preference order _libcrypt_libs = None # list of every loadable libcrypt, in preference order
_libcrypt_sources: list[str] = [] # where those libraries were loaded from
# ── low level ────────────────────────────────────────────────────────────── # ── low level ──────────────────────────────────────────────────────────────
@@ -65,12 +70,14 @@ def _crypt_libs() -> list:
lib = ctypes.CDLL(path) lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
except (OSError, AttributeError): except (OSError, AttributeError) as exc:
_libcrypt_sources.append(f"{path} (unloadable: {exc})")
continue continue
if lib._handle in seen: # same library reached by two names if lib._handle in seen: # same library reached by two names
continue continue
seen.add(lib._handle) seen.add(lib._handle)
libs.append(lib) libs.append(lib)
_libcrypt_sources.append(path)
_libcrypt_libs = libs _libcrypt_libs = libs
return _libcrypt_libs return _libcrypt_libs
@@ -161,11 +168,21 @@ def _verify_via_pam(password: str, user: str) -> bool | None:
ctypes.byref(conv), ctypes.byref(handle)) != 0: ctypes.byref(conv), ctypes.byref(handle)) != 0:
continue continue
try: try:
return libpam.pam_authenticate(handle, 0) == 0 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: finally:
libpam.pam_end(handle, 0) libpam.pam_end(handle, 0)
logger.warning("verify via PAM: no PAM service could be started for user=%s", user)
return None return None
except Exception: except Exception as exc:
logger.warning("verify via PAM unavailable for user=%s: %s", user, exc)
return None return None
@@ -211,7 +228,18 @@ def verify_system_password(password: str, user: str = USER) -> bool:
return hmac.compare_digest(out, h.encode()) return hmac.compare_digest(out, h.encode())
except Exception: except Exception:
pass 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) 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) return bool(pam_result)
+1 -1
View File
@@ -9,7 +9,7 @@
"btcpayserver.service": "2.4.5", "btcpayserver.service": "2.4.5",
"albyhub.service": "2.0.0", "albyhub.service": "2.0.0",
"mempool.service": "3.3.1", "mempool.service": "3.3.1",
"matrix-synapse.service": "1.162.0", "matrix-synapse.service": "1.163.0",
"livekit.service": "1.13.8", "livekit.service": "1.13.8",
"vaultwarden.service": "1.37.4", "vaultwarden.service": "1.37.4",
"haven-relay.service": "1.2.2" "haven-relay.service": "1.2.2"
Generated
+15 -15
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1792157233, "lastModified": 1792500275,
"narHash": "sha256-XQnyEc+Pj0Tg21ckbPMri0fiK/exCzhBw6wNgd1INqw=", "narHash": "sha256-Y86rSoq9EWo6538kYoWZCnH9+bl0jNS1vQrnNvm8znE=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "990d29e23437f30b8573f640d3e323167de1dc7b", "rev": "487c7e9ce3978601d37ac10b18610db672850f3b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -56,11 +56,11 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1792030654, "lastModified": 1792503283,
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=", "narHash": "sha256-vhe2jyvRCgufWPqDuDq1S3t3ypE+ka46IFutV02xKso=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8", "rev": "cd4ea8d57de16f4810bc5f14207bac45fbf19f67",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -72,11 +72,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1792094642, "lastModified": 1792502884,
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=", "narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df", "rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -104,11 +104,11 @@
}, },
"nixpkgs_4": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1791956154, "lastModified": 1792502884,
"narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=", "narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044", "rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -152,11 +152,11 @@
"nixpkgs": "nixpkgs_4" "nixpkgs": "nixpkgs_4"
}, },
"locked": { "locked": {
"lastModified": 1792372617, "lastModified": 1792602201,
"narHash": "sha256-MLFWEcj6ZyFgFWubNPYK7IEI8NUtjbrhxhXQiI6JbPM=", "narHash": "sha256-5LkFX16SxgXyaUDvFPtHdFGp4w4deqQy9btRmPQSlwU=",
"owner": "naturallaw777", "owner": "naturallaw777",
"repo": "Sovran_Bitcoin", "repo": "Sovran_Bitcoin",
"rev": "78e4430525069172a8d1a9c08a951f8b87a03bf3", "rev": "63e104940ef4ff21eb2b43977ca6e110e9c5d524",
"type": "github" "type": "github"
}, },
"original": { "original": {
+33
View File
@@ -36,6 +36,17 @@ let
lock-after=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 # 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 # 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 # the file is display-only and the Hub (system_password.py) is the only thing that
@@ -88,6 +99,7 @@ in
"d ${home}/.local/share 0700 ${user} users -" "d ${home}/.local/share 0700 ${user} users -"
"d ${home}/.local/share/keyrings 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/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 # One-time migration for machines that already have a keyring encrypted with the
@@ -100,6 +112,19 @@ in
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)" mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring" install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
fi 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" # ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
@@ -124,4 +149,12 @@ in
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ─────── # ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"; systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
# ctypes fallback lookups (bare "libcrypt.so.1" / "libpam.so.0") resolve
# through LD_LIBRARY_PATH inside the unit. Without this they find nothing
# on NixOS (no ld.so.conf paths) or glibc's failure-token libcrypt stub,
# whose "*0"/NULL answers read as a wrong password at Hub login even
# though the same password signs in at the desktop. Put the real
# libxcrypt and PAM libraries on the Hub's library path.
systemd.services.sovran-hub-web.environment.LD_LIBRARY_PATH =
lib.makeLibraryPath [ pkgs.libxcrypt pkgs.pam ];
} }
+34 -1
View File
@@ -18,9 +18,42 @@
# #
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only # To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
# the `phpBase` line below. # 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 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 { custom-php = phpBase.buildEnv {
# `enabled` is nixpkgs' default extension set. It already contains every # `enabled` is nixpkgs' default extension set. It already contains every
+19 -12
View File
@@ -386,7 +386,13 @@ class TestFrontendAuthRecovery(unittest.TestCase):
class TestManualLogoutPersistence(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 @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -397,22 +403,24 @@ class TestManualLogoutPersistence(unittest.TestCase):
def _between(self, start, end): def _between(self, start, end):
return self.server.split(start, 1)[1].split(end, 1)[0] 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( route = self._between(
'@app.get("/auto-login")', '@app.get("/auto-login")',
"class LoginRequest", "class LoginRequest",
) )
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route) self.assertNotIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn('RedirectResponse(url="/login"', 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( route = self._between(
'@app.post("/api/logout")', '@app.post("/api/logout")',
"def _get_sovran_version", "def _get_sovran_version",
) )
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route) self.assertNotIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route) self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn("httponly=True", route) self.assertIn("_destroy_session(token)", route)
def test_password_login_clears_manual_logout_cookie(self): def test_password_login_clears_manual_logout_cookie(self):
route = self._between( route = self._between(
@@ -427,10 +435,9 @@ class TestManualLogoutPersistence(unittest.TestCase):
class TestHubBrowserProfilePersistence(unittest.TestCase): class TestHubBrowserProfilePersistence(unittest.TestCase):
"""The desktop launcher must keep a persistent browser profile. """The desktop launcher must keep a persistent browser profile.
The hub_manual_logout marker (and the session cookie) are stored in this The session cookie is stored in this profile. If the launcher used an
profile. If the launcher used an ephemeral /tmp profile that it deleted on ephemeral /tmp profile that it deleted on exit, the browser would lose
exit, closing and reopening the Hub window would wipe the marker and its session (and all site state) every time the Hub window closed.
/auto-login would silently log the user back in without a password.
""" """
@classmethod @classmethod
+57 -1
View File
@@ -140,6 +140,62 @@ class WrongLibraryMustNotLockTheUserOut(unittest.TestCase):
self.assertNotIn("sign out and back in to show it here", text) 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): class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
"""Source guards (server.py is not importable without the full Hub deps, """Source guards (server.py is not importable without the full Hub deps,
same reason the other suites read it as text).""" same reason the other suites read it as text)."""
@@ -150,7 +206,7 @@ class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
def test_resolve_credential_guards_system_password_calls(self): def test_resolve_credential_guards_system_password_calls(self):
start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):") start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):")
block = self.server[start:start + 1600] block = self.server[start:start + 3200]
self.assertIn("try:", block) self.assertIn("try:", block)
self.assertIn("except Exception", block) self.assertIn("except Exception", block)
self.assertIn('"unknown": True', block) self.assertIn('"unknown": True', block)