mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Merge fix/users-secret-leak: stop leaking password hashes + TOTP secrets (#870)
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
commit
310f4713f5
|
|
@ -39,6 +39,8 @@ config = get_config("users") if callable(get_config) else {}
|
|||
USERSCTL = "/usr/sbin/usersctl"
|
||||
USERS_FILE = os.environ.get("USERS_FILE", "/etc/secubox/users.json")
|
||||
ROLES_FILE = os.environ.get("ROLES_FILE", "/etc/secubox/roles.json")
|
||||
from .redact import redact_user # expurgation des secrets (module dédié, testable seul)
|
||||
|
||||
SERVICES = ["nextcloud", "gitea", "email", "matrix", "jellyfin", "peertube", "jabber"]
|
||||
# YaCy has a single admin account (no per-user accounts), so its password is
|
||||
# synced from exactly one SecuBox user: the master "admin". Changing that user's
|
||||
|
|
@ -502,12 +504,10 @@ async def get_access():
|
|||
|
||||
@app.get("/users", dependencies=[Depends(require_jwt)])
|
||||
async def list_users():
|
||||
"""List all users."""
|
||||
"""List all users (redacted — never ships hashes or TOTP secrets)."""
|
||||
data = load_users()
|
||||
return {
|
||||
"users": data.get("users", []),
|
||||
"total": len(data.get("users", []))
|
||||
}
|
||||
users = [redact_user(u) for u in data.get("users", [])]
|
||||
return {"users": users, "total": len(users)}
|
||||
|
||||
@app.get("/user/{username}", dependencies=[Depends(require_jwt)])
|
||||
async def get_user(username: str):
|
||||
|
|
@ -515,11 +515,12 @@ async def get_user(username: str):
|
|||
data = load_users()
|
||||
for user in data.get("users", []):
|
||||
if user.get("username") == username:
|
||||
out = redact_user(user)
|
||||
# Add service status
|
||||
user["service_status"] = {}
|
||||
out["service_status"] = {}
|
||||
for svc in user.get("services", []):
|
||||
user["service_status"][svc] = check_service(svc)
|
||||
return user
|
||||
out["service_status"][svc] = check_service(svc)
|
||||
return out
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
@app.post("/user", dependencies=[Depends(require_jwt)])
|
||||
|
|
@ -1064,14 +1065,20 @@ async def validate_acl(entries: List[ACLEntry]):
|
|||
|
||||
@app.get("/export", dependencies=[Depends(require_jwt)])
|
||||
async def export_users():
|
||||
"""Export all users."""
|
||||
"""Export all users (redacted).
|
||||
|
||||
This route previously claimed to "remove sensitive data" while dropping only
|
||||
`provision_results` — it shipped every password hash and TOTP secret into a
|
||||
downloadable file. A comment asserting a guarantee the code does not provide
|
||||
is worse than no comment: it stops reviewers from looking. The guarantee now
|
||||
lives in redact_user(), which is the single place that decides what a client
|
||||
may see.
|
||||
"""
|
||||
data = load_users()
|
||||
# Remove sensitive data
|
||||
export_data = {"users": [], "groups": data.get("groups", [])}
|
||||
for user in data.get("users", []):
|
||||
export_user = {k: v for k, v in user.items() if k != "provision_results"}
|
||||
export_data["users"].append(export_user)
|
||||
return export_data
|
||||
return {
|
||||
"users": [redact_user(u) for u in data.get("users", [])],
|
||||
"groups": data.get("groups", []),
|
||||
}
|
||||
|
||||
@app.post("/import", dependencies=[Depends(require_permission("system.import"))])
|
||||
async def import_users(file: UploadFile = File(...)):
|
||||
|
|
|
|||
51
packages/secubox-users/api/redact.py
Normal file
51
packages/secubox-users/api/redact.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: users — expurgation des secrets avant sortie du processus
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
Module séparé, sans dépendance : `api.main` lit la config au niveau module, donc
|
||||
l'importer exige de pouvoir lire /etc/secubox/secubox.conf. Une règle de sécurité
|
||||
doit rester testable sans privilèges — d'où ce fichier, importable seul.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Jamais renvoyés à un client. `password_hash` est une primitive d'attaque
|
||||
# hors-ligne ; `provision_results` peut contenir des retours de service bruts.
|
||||
SECRET_KEYS = ("password_hash", "provision_results")
|
||||
|
||||
# Dans le sous-dict `totp` : `secret` permet à lui seul de forger des codes à
|
||||
# 6 chiffres indéfiniment (contournement complet du MFA) ; `backup_codes` porte
|
||||
# les hashes argon2id des codes de secours ; `last_step` est un détail interne.
|
||||
TOTP_SECRET_KEYS = ("secret", "backup_codes", "last_step")
|
||||
|
||||
|
||||
def redact_user(user: dict) -> dict:
|
||||
"""Copie d'un enregistrement utilisateur sans aucun secret.
|
||||
|
||||
users.json contient le hash argon2id du mot de passe, le SECRET TOTP et les
|
||||
hashes des codes de secours. Aucun n'a d'usage côté client.
|
||||
|
||||
Toute route renvoyant un utilisateur DOIT passer par ici. Ces routes sont
|
||||
gardées par `require_jwt` — n'importe quel compte authentifié, pas seulement
|
||||
un admin — donc un enregistrement non expurgé transforme le compte le moins
|
||||
privilégié en moissonneuse d'identifiants de tous les autres.
|
||||
|
||||
`totp` est conservé mais réduit à ses métadonnées non secrètes, et sa
|
||||
PRÉSENCE est préservée : la webui décide d'afficher le badge MFA par simple
|
||||
test de véracité (`u.totp_enabled || u.totp || u.mfa_enabled`), donc
|
||||
supprimer la clé éteindrait le badge en silence, et en émettre une toujours
|
||||
l'allumerait pour des utilisateurs jamais enrôlés.
|
||||
|
||||
Ne mute jamais l'entrée : elle vient de users.json et serait réécrite telle
|
||||
quelle au prochain enregistrement.
|
||||
"""
|
||||
out = {k: v for k, v in user.items() if k not in SECRET_KEYS}
|
||||
totp = user.get("totp")
|
||||
if isinstance(totp, dict):
|
||||
out["totp"] = {k: v for k, v in totp.items() if k not in TOTP_SECRET_KEYS}
|
||||
out["totp_enabled"] = bool(isinstance(totp, dict) and totp.get("enabled"))
|
||||
return out
|
||||
104
packages/secubox-users/tests/test_redaction.py
Normal file
104
packages/secubox-users/tests/test_redaction.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: users — les secrets ne doivent jamais quitter le processus.
|
||||
|
||||
users.json contient le hash argon2id du mot de passe, le SECRET TOTP et les
|
||||
hashes des codes de secours. Les routes qui renvoient un utilisateur sont
|
||||
gardées par `require_jwt` — donc n'importe quel compte authentifié, pas
|
||||
seulement un admin. Un enregistrement non expurgé transforme le compte le moins
|
||||
privilégié en moissonneuse d'identifiants.
|
||||
"""
|
||||
from api.redact import redact_user
|
||||
|
||||
# La forme réelle observée dans /etc/secubox/users.json sur la board.
|
||||
REAL_SHAPE = {
|
||||
"username": "admin",
|
||||
"email": "gandalf@gk2.net",
|
||||
"role": "admin",
|
||||
"enabled": True,
|
||||
"created": "2026-05-09T11:14:11.719355",
|
||||
"last_login": "2026-07-17T09:01:36.952678+00:00",
|
||||
"must_change_password": False,
|
||||
"google": None,
|
||||
"services": [],
|
||||
"password_hash": "$argon2id$v=19$m=1024,t=1,p=1$c2FsdA$hash",
|
||||
"totp": {
|
||||
"enabled": True,
|
||||
"enrolled_at": "2026-05-09T11:20:00",
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
"last_step": 59475852,
|
||||
"backup_codes": [{"hash": "$argon2id$v=19$m=65536,t=3,p=4$abc$def"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _flat(obj) -> str:
|
||||
"""Sérialisation grossière : un secret imbriqué reste détectable."""
|
||||
import json
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
def test_password_hash_never_leaves():
|
||||
assert "password_hash" not in redact_user(REAL_SHAPE)
|
||||
|
||||
|
||||
def test_totp_secret_never_leaves():
|
||||
# Le pire des trois : le secret TOTP seul permet de forger des codes à 6
|
||||
# chiffres indéfiniment — un contournement complet du MFA.
|
||||
out = redact_user(REAL_SHAPE)
|
||||
assert "secret" not in out["totp"]
|
||||
assert "JBSWY3DPEHPK3PXP" not in _flat(out)
|
||||
|
||||
|
||||
def test_backup_code_hashes_never_leave():
|
||||
out = redact_user(REAL_SHAPE)
|
||||
assert "backup_codes" not in out["totp"]
|
||||
assert "argon2id" not in _flat(out)
|
||||
|
||||
|
||||
def test_no_argon2_hash_survives_anywhere():
|
||||
# Filet global : attrape tout futur champ portant un hash.
|
||||
assert "$argon2" not in _flat(redact_user(REAL_SHAPE))
|
||||
|
||||
|
||||
def test_fields_the_panel_needs_are_preserved():
|
||||
out = redact_user(REAL_SHAPE)
|
||||
for k in ("username", "email", "role", "enabled", "created",
|
||||
"last_login", "must_change_password", "services"):
|
||||
assert k in out, f"le panel a besoin de {k}"
|
||||
|
||||
|
||||
def test_totp_presence_is_preserved_for_the_mfa_badge():
|
||||
# Le panel décide du badge MFA par simple véracité
|
||||
# (`u.totp_enabled || u.totp || u.mfa_enabled`) : supprimer la clé
|
||||
# éteindrait le badge en silence.
|
||||
out = redact_user(REAL_SHAPE)
|
||||
assert out["totp"] # truthy
|
||||
assert out["totp"]["enabled"] is True
|
||||
assert out["totp_enabled"] is True
|
||||
|
||||
|
||||
def test_user_without_totp_does_not_gain_a_badge():
|
||||
# Symétrique : ne jamais allumer le badge d'un utilisateur non enrôlé.
|
||||
out = redact_user({"username": "gk2", "password_hash": "$argon2id$x"})
|
||||
assert out.get("totp") is None
|
||||
assert out["totp_enabled"] is False
|
||||
|
||||
|
||||
def test_totp_present_but_disabled_reports_not_enabled():
|
||||
out = redact_user({"username": "u", "totp": {"enabled": False, "secret": "S"}})
|
||||
assert out["totp_enabled"] is False
|
||||
assert "secret" not in out["totp"]
|
||||
|
||||
|
||||
def test_redaction_does_not_mutate_the_stored_record():
|
||||
# redact_user reçoit l'objet chargé depuis users.json ; le muter
|
||||
# corromprait la base au prochain save.
|
||||
import copy
|
||||
original = copy.deepcopy(REAL_SHAPE)
|
||||
redact_user(REAL_SHAPE)
|
||||
assert REAL_SHAPE == original
|
||||
Loading…
Reference in New Issue
Block a user