Merge pull request #906 from CyberMind-FR/feat/assist-request
Some checks are pending
License Headers / check (push) Waiting to run

feat(assist): support/assistance request — live consent-gated help sessions (sous-projet 2/3)
This commit is contained in:
CyberMind 2026-07-25 16:18:47 +02:00 committed by GitHub
commit 6b8d652a09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 5184 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
# Support / Assistance Request — Design
**Date :** 2026-07-25
**Statut :** validé (design), prêt pour le plan d'implémentation
**Modules :** `secubox-annuaire` (control-plane) + `secubox-assist` (data-plane, neuf) + surface webui/CLI
**Sous-projet 2/3** de l'ensemble « auto-centre et multiple centres ». Réutilise le socle **Centres & Grants** livré au sous-projet 1 ([[project_gondwana_directory_live]], spec `2026-07-25-centers-grants-remote-config-design.md`). Le suivant (3/3) : *métriques centralisées+meshed*.
---
## Objectif
Permettre à une box de **demander de l'assistance à un centre** et d'ouvrir une **session d'aide temps réel** — le centre agit sur la box **le temps de l'incident**, sous consentement explicite, entièrement audité et révocable à l'instant. La box reste **souveraine** : aucune autorité permanente n'ouvre seule une session ; le centre ne peut jamais dépasser un **catalogue d'actions borné** sans un **second consentement** de l'opérateur.
## Décisions actées
1. **Niveau d'accès = session assistée temps réel.** Le centre agit en direct pendant une session time-boxée, révocable à tout instant, tout audité.
2. **Surface d'action = catalogue borné + escalade console sur double-consentement.** Par défaut, le centre n'invoque qu'un catalogue fixe d'actions « assist » (chaque action = un ctl scopé existant + audit). Un cas exceptionnel peut débloquer une console interactive par un **second** consentement opérateur, séparément time-boxé et révocable.
3. **Initiation bi-mode, configurable par centre.**
- **Per-incident (pull)** : l'opérateur ouvre une demande ponctuelle ; l'`ASSIST_REQUEST` **est** l'autorité éphémère (zéro grant standing). Le centre ne peut jamais démarrer seul.
- **Standing** : la box accorde une fois un grant `capability="assist"` (révocable) ; le centre peut alors demander une session, mais **chaque** session exige un consentement live de l'opérateur.
4. **Architecture à deux plans.** *Control-plane* (autorisation) = ops signées du journal `secubox-annuaire`, mesh-syncées, auditées. *Data-plane* (temps réel) = daemon `secubox-assist` exposant un WebSocket **par-session éphémère**, **bind wg-mesh uniquement**.
5. **Réutilise le socle sous-projet 1.** Journal signé BLAKE2b, `mesh_sync`, modèle `Grant`, résolution/révocation de grants, `audit.log` append-only.
## Contexte (substrat existant, vérifié)
- `secubox-annuaire` : journal signé append-only, `model.py` (Identity, `Grant`, `Op.*`), `grants.py` (`active_grants`, `owner`, `validate_issue`), `mesh_sync.py`, `verbs.py` (ops signées genesis-style), `config_router.py` (vérification signature centre + grant). Le sous-projet 1 a ajouté `GRANT_ISSUE`/`GRANT_REVOKE` + le modèle `Grant{center_did, capability, scope, layer, issued_by, ts}` + `NON_DELEGATABLE`.
- Mesh WireGuard `wg-mesh` `10.10.0.0/24` (3 nœuds : gk2 `.1` master, c3box `.2`, amd64 `.9`) — [[project_mesh_gk2_c3box]], [[project_p2p_dht_cluster_live]]. C'est le transport du data-plane.
- Pattern **ctl confiné** : la webui (user `secubox`) délègue toute action root à un `secubox-<mod>ctl` scopé par sudoers ([[feedback_webui_delegates_to_confined_ctl]]). Le catalogue assist réutilise ces ctls existants.
- `audit.log` append-only `/var/log/secubox/audit.log` (exigence CSPN, parent `0755` — [[project_var_log_secubox_traversal]], ne jamais resserrer le parent partagé).
## Composants
| Fichier | Rôle |
|---|---|
| `secubox-annuaire/annuaire/model.py` (étendu) | ops `ASSIST_REQUEST`/`ASSIST_ACCEPT`/`ASSIST_SESSION_OPEN`/`ASSIST_SESSION_CLOSE`/`ASSIST_CONSOLE_GRANT`/`ASSIST_CONSOLE_REVOKE` ; modèles `AssistRequest`, `AssistSession` (extra=forbid, DID patterns) ; `capability="assist"` reconnu |
| `secubox-annuaire/annuaire/assist.py` (neuf) | résout l'état depuis le journal : demandes en attente, session active (une seule par box), console accordée ?, expiries ; `active_session(entries, self_did)` (souveraineté : filtré `issued_by==self_did`) |
| `secubox-annuaire/annuaire/verbs.py` (étendu) | `assist_request`/`assist_accept`/`assist_session_open`/`assist_session_close`/`assist_console_grant`/`assist_console_revoke` (valide→signe→append) |
| `secubox-assist/` (paquet neuf) | daemon data-plane : serveur WebSocket **bind wg-mesh only**, auth par token de session (hash au journal), dispatcher catalogue→ctl, gestionnaire pty (console double-consent), collecteur de bundle diag, writer audit |
| `secubox-assist/sbin/secubox-assistctl` (neuf) | CLI root scopé : `request`/`accept`/`open`/`close`/`console-grant`/`console-revoke`/`list` (écrit les ops journal + pilote le daemon ; jamais d'action root hors catalogue+audit) |
| `secubox-assist/api/main.py` (neuf) | endpoints `/assist/*` : lecture in-process, écriture JWT-gated **déléguée à `assistctl`** |
| `secubox-assist/www/assist/index.html` (neuf) | panneau : demande d'assistance, moniteur live (flux d'actions, bouton consentement-console, kill-switch), historique ; côté centre : file entrante + console d'action |
| `menu.d/…-assist.json`, `nginx/assist.conf` | navbar + vhost |
## Modèle de données (ops de journal)
```
GRANT_ISSUE { capability="assist", center_did, scope?, issued_by=<box_did>, ts, sig=<box> } # mode standing (réutilise s-p 1)
ASSIST_REQUEST { req_id, center_did, mode="per-incident"|"standing", scope, duration_s, reason, issued_by=<box_did>, ts, sig=<box> }
ASSIST_ACCEPT { req_id, center_did, ts, sig=<center> }
ASSIST_SESSION_OPEN { session_id, req_id, center_did, token_hash, expires_ts, issued_by=<box_did>, ts, sig=<box> }
ASSIST_SESSION_CLOSE { session_id, reason, issued_by=<box_did>|"auto-expiry", ts, sig=<box> }
ASSIST_CONSOLE_GRANT { session_id, expires_ts, issued_by=<box_did>, ts, sig=<box> }
ASSIST_CONSOLE_REVOKE{ session_id, issued_by=<box_did>, ts, sig=<box> }
```
- `mode``{"per-incident","standing"}`. En `standing`, un grant `capability="assist"` actif est **requis** pour que le centre puisse initier ; en `per-incident`, aucun grant standing n'est requis (la demande signée par la box est l'autorité).
- **Le secret du token de session n'est JAMAIS journalisé** — seul `token_hash` (BLAKE2b du token) va au journal. Le token en clair est livré au centre via le canal mesh à l'acceptation, single-use.
- **Invariant session unique** : au plus **une** `AssistSession` active par box (un `ASSIST_SESSION_OPEN` sans `ASSIST_SESSION_CLOSE` du même `session_id`). Ouvrir une session alors qu'une est active est **rejeté**.
- Une session est **active** ssi `SESSION_OPEN` non suivi de `SESSION_CLOSE` **ET** `now < expires_ts`. Au-delà d'`expires_ts`, elle est **auto-expirée** (fail-closed) même sans op de close.
- Console **active** ssi `CONSOLE_GRANT` non suivi de `CONSOLE_REVOKE` **ET** `now < expires_ts` **ET** la session parente est active.
## Flux de données
### Per-incident (pull)
1. Opérateur (panneau `/assist/`) : « demander assistance à centre A », choisit scope + durée + motif → box signe `ASSIST_REQUEST(mode="per-incident")`.
2. `mesh_sync` livre l'op à A. A accepte → `ASSIST_ACCEPT`.
3. Opérateur **consent** à ouvrir → box frappe un token de session, signe `ASSIST_SESSION_OPEN(token_hash, expires_ts)`, livre le token en clair à A via le canal mesh.
4. A connecte le **WebSocket** de `secubox-assist` sur `wg-mesh` avec le token → invoque le **catalogue** ; chaque action → ctl scopé → `audit.log`.
5. (Optionnel) A demande la console → opérateur **2e consentement** → box signe `ASSIST_CONSOLE_GRANT` → pty non-root time-boxé, keystrokes audités.
6. Opérateur **kill** (ou `expires_ts` atteint) → `ASSIST_SESSION_CLOSE` → teardown immédiat WS + pty.
### Standing
1. Opérateur : `GRANT_ISSUE(A, capability="assist")` une fois (op signée box).
2. Plus tard, A : `ASSIST_REQUEST(mode="standing")` (autorisé car grant actif) → l'opérateur reçoit une **invite de consentement live**.
3. Sur consentement → `ASSIST_SESSION_OPEN` → identique aux étapes 3-6 ci-dessus.
4. Révoquer l'autorité standing = `GRANT_REVOKE` (le centre ne peut plus initier ; une session en cours est fermée à la prochaine recomposition).
## Catalogue d'actions borné
Chaque entrée mappe **un** ctl scopé existant ; le daemon n'exécute **jamais** de shell arbitraire en mode catalogue.
| Action catalogue | Délégation |
|---|---|
| `status.all` | lecture in-process (agrégateur/status) |
| `diag.collect` | collecteur de bundle diag (voir §Bundle) |
| `logs.tail <unit>` | `journalctl -u <unit> -n N` (allow-list d'unités `secubox-*`) |
| `service.restart <module>` | `secubox-<module>ctl restart` (ou `systemctl restart secubox-<module>` scopé) |
| `service.toggle <module> on\|off` | ctl module correspondant |
| `config.reload <scope>` | `profilectl`/`config_apply` 4R ([[project_profiles_apply_phase3a]]) |
| `config.rollback <scope>` | `profilectl rollback` (4R) |
- Les modules/unités visés sont une **allow-list** `secubox-*` ; toute cible hors allow-list est rejetée.
- **Aucun** ctl touchant `auth`/`secrets` n'est dans le catalogue (scope secrets inatteignable — cohérent `NON_DELEGATABLE` du s-p 1).
## Bundle diag (au démarrage de session)
- Contenu : status modules/versions, **extraits** de logs récents avec **rédaction conservatrice** (strip tokens/clés/mots-de-passe/emails via motifs), config **effective non-secrète**.
- **Jamais** `/etc/secubox/secrets/`, jamais de fichier `*.key`, jamais le contenu de `users.json`.
- Réutilise/anticipe le collecteur du sous-projet 3 (métriques/diag) — ici en lecture seule bornée.
## Invariants souveraineté / CSPN
- **Consentement opérateur explicite à chaque `SESSION_OPEN`** (les deux modes). Un grant standing seul n'ouvre **jamais** une session live.
- **Double-consentement** pour la console : op distincte, time-box distinct, révocation distincte. Défaut = catalogue borné.
- **Data-plane `wg-mesh` uniquement** : le WebSocket bind l'IP `wg-mesh` (`10.10.0.0/24`), **jamais `0.0.0.0`** (leçon escalade R-level [[project_rlevel_per_peer]]). nft ouvre le port **uniquement** sur `iifname wg-mesh`, DEFAULT DROP ailleurs.
- **Token single-use, hashé au journal** — le secret ne transite que par le canal mesh chiffré, jamais dans le journal ni les logs.
- **User dédié `secubox-assist`, AppArmor enforce, jamais root.** L'unique chemin privilégié = les ctls scopés du catalogue (audités). La console pty tourne sous `secubox-assist` (pas root).
- **Audit append-only complet** `/var/log/secubox/audit.log` : request, accept, open, **chaque** action catalogue, console-grant, **keystrokes** console, close. Ne jamais resserrer le parent partagé `0755`.
- **Expiry hard-cap** session + console ; **fail-closed** : `now ≥ expires_ts` ⇒ inactif même sans op de close ; **perte du mesh = session morte** (le WS tombe, plus d'action possible).
- **Kill-switch** toujours disponible à l'opérateur (`SESSION_CLOSE` immédiat).
- **Zéro-centre = aucune assistance possible** ; ajouter un centre est purement additif ; révocation instantanée.
- **Session unique** : jamais deux sessions actives concurrentes sur une box.
## Surface (webui + CLI + API)
- **Panneau `/assist/`** (hybrid-dark, jeton `sbx_token`, délégation d'événements — pas d'inline handler interpolé) :
- **Côté box (opérateur)** : formulaire de demande (centre enrôlé, mode, scope, durée, motif) ; **moniteur live** de la session (flux d'actions horodaté, badge console, **bouton consentement-console**, **kill-switch**) ; historique des sessions (lu depuis le journal).
- **Côté centre** (si la box est un centre) : file des demandes entrantes → accepter ; console d'action (catalogue + terminal si escalade accordée).
- **CLI `secubox-assistctl`** : `request <center> --mode --scope --duration --reason`, `accept <req-id>`, `open <req-id>`, `close <session-id>`, `console-grant <session-id> --duration`, `console-revoke <session-id>`, `list`.
- **API** délègue **toute écriture** à `secubox-assistctl` (jamais d'action privilégiée in-process — [[feedback_webui_delegates_to_confined_ctl]]) ; lectures in-process depuis le journal. L'agrégateur doit être redémarré pour charger de nouvelles routes ([[project_aggregator_inprocess_serving]]) — mais `secubox-assist` a **son propre service+socket** (comme billets [[project_billets_live_deploy]]), pas servi par l'agrégateur (le WS et le pty exigent un daemon dédié, pas la loop partagée [[project_aggregator_wedge_and_auth_extraction]]).
## Tests
- **model/verbs** : validation + signature de chaque op ; `req_id`/`session_id` bien formés ; `mode="standing"` sans grant actif → rejeté ; `mode="per-incident"` sans grant → accepté.
- **assist.py** : session active/expirée ; **session unique** (2e open rejeté) ; console active seulement si session active + non-révoquée + non-expirée ; souveraineté (`issued_by==self_did`).
- **token** : le secret ne fuit jamais (journal ne contient que `token_hash`) ; token single-use ; hash BLAKE2b vérifié.
- **consentement** : `SESSION_OPEN` requiert consentement (les deux modes) ; console requiert un **2e** consentement (grant console distinct).
- **bind mesh-only** : le WS refuse une connexion hors `wg-mesh` ; le daemon ne bind jamais `0.0.0.0`.
- **catalogue** : chaque action → ctl scopé attendu ; cible hors allow-list rejetée ; **aucun** shell arbitraire ; scope secrets inatteignable.
- **expiry/revoke** : `now ≥ expires_ts` ⇒ session/console inactive (fail-closed) ; `SESSION_CLOSE` ⇒ teardown ; `CONSOLE_REVOKE` ⇒ pty fermé.
- **audit** : présence de chaque événement (request→…→close) dans `audit.log` ; keystrokes console tracés.
- **API/CLI** : écriture déléguée à `assistctl` (pas d'action root in-process) ; JWT-gated.
- **panneau** : moniteur + délégation d'événements (garde XSS) ; menu.d valide.
- **e2e (mock mesh)** : per-incident (request→accept→open→action catalogue→close) ; standing (grant→request→consent→open) ; escalade console (2e consent→pty→revoke) ; auto-expiry ferme la session.
## Risques connus
| Risque | Traitement |
|---|---|
| Un centre agit sans consentement | `SESSION_OPEN` exige un consentement opérateur explicite dans **les deux** modes |
| Escalade catalogue → shell | console = op distincte + **2e** consentement + time-box + user non-root ; catalogue = allow-list de ctls |
| Fuite du token de session | secret hors journal (seul le hash) ; single-use ; canal mesh chiffré |
| Bind public / contournement SSO (cf. R-level) | WS **bind wg-mesh only** + nft `iifname wg-mesh` uniquement, DEFAULT DROP |
| Session fantôme après compromission d'un centre | grant/authority box-émis + révocable ; expiry hard-cap ; fail-closed sur perte mesh |
| Deux sessions concurrentes | invariant **session unique** par box |
| Accès aux secrets via diag/catalogue | rédaction conservatrice du bundle ; aucun ctl secrets ; jamais `/etc/secubox/secrets` |
| Daemon privilégié | user dédié non-root + AppArmor enforce ; seul chemin privilégié = ctls scopés audités |
| Loop agrégateur bloquée par le WS/pty | `secubox-assist` = service+socket **dédié**, pas servi par l'agrégateur |
## Hors périmètre de ce sous-projet
**Livré au sous-projet 3 :**
- **Métriques centralisées+meshed** : grant `capability="metrics"` — réutilisera le collecteur diag d'ici.
**Roadmap « à prévoir » (extensions additives post-socle, non rejetées).** Chacune se greffe sur le socle assist sans le casser : nouvelles actions catalogue et/ou nouveau canal data-plane, mêmes invariants CSPN (consentement, mesh-only, audit, expiry, non-root).
- **Transfert de fichiers** (récupérer un bundle diag/log, pousser un correctif) — action catalogue `file.pull`/`file.push` bornée + rédaction.
- **Partage d'écran / co-browsing** de la webui admin — nouveau canal data-plane time-boxé, même modèle de consentement que la console.
- **Sessions multi-centres simultanées** — relâcher l'invariant « session unique » vers N sessions concurrentes scopées (nécessite arbitrage d'actions concurrentes).
- **UI de replay** — rejouer une session depuis l'`audit.log` (qui **est** déjà l'enregistrement) ; pur front, aucun nouveau privilège.
- **Enregistrement vidéo** de session — dérivé du partage d'écran.

View File

@ -0,0 +1,122 @@
# 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 :: annuaire.assist resolve assistance state from the signed log.
Pure functions over journal entries (LogEntry OR dict via grants._op/_payload).
Every resolver takes the box's own `self_did`: a SESSION/CONSOLE op authored by
anyone else (federated) is IGNORED (sovereignty). Expiry is fail-closed: past
`expires_ts` a session/console is inactive even with no explicit close op.
"""
from __future__ import annotations
from typing import Any, List, Mapping, Optional
from .grants import _op, _payload, active_grants # dict/LogEntry-tolerant accessors
from .model import Op
class AssistError(Exception):
"""Raised on a broken invariant (e.g. >1 active session)."""
def _by(entries, op: Op):
for entry in entries:
if _op(entry) == op.value:
yield _payload(entry)
def active_session(entries: List[Mapping[str, Any]], self_did: str,
now_ts: str) -> Optional[dict]:
"""Return the single active session opened BY self_did, or None.
Active = a SESSION_OPEN (issued_by == self_did) whose session_id has no
later SESSION_CLOSE and whose expires_ts is still in the future (RFC3339
lexicographic compare all timestamps are UTC 'Z', so string order == time
order). Raises AssistError if more than one is active (invariant breach).
"""
closed = {p.get("session_id") for p in _by(entries, Op.ASSIST_SESSION_CLOSE)}
live = []
for p in _by(entries, Op.ASSIST_SESSION_OPEN):
if p.get("issued_by") != self_did:
continue
sid = p.get("session_id")
if sid in closed:
continue
if str(now_ts) >= str(p.get("expires_ts", "")):
continue # fail-closed past hard-cap
live.append(p)
if len(live) > 1:
raise AssistError("multiple-active-sessions")
return live[0] if live else None
def console_active(entries: List[Mapping[str, Any]], session_id: str,
now_ts: str) -> bool:
"""True if a CONSOLE_GRANT for session_id is live (no later REVOKE, not expired)."""
revoked = {p.get("session_id") for p in _by(entries, Op.ASSIST_CONSOLE_REVOKE)}
if session_id in revoked:
# a REVOKE after the last GRANT kills it; treat any revoke as terminal
# (console is short-lived and re-granted explicitly)
return False
for p in _by(entries, Op.ASSIST_CONSOLE_GRANT):
if p.get("session_id") != session_id:
continue
if str(now_ts) < str(p.get("expires_ts", "")):
return True
return False
def pending_requests(entries: List[Mapping[str, Any]], self_did: str) -> List[dict]:
"""REQUESTs relevant to this box not yet accepted.
Includes box-authored requests (issued_by == self_did) and for standing
mode center-authored requests, leaving the standing-grant check to the
caller (verbs/ctl) which has the grant matrix. Accepted requests drop out.
"""
accepted = {p.get("req_id") for p in _by(entries, Op.ASSIST_ACCEPT)}
out = []
for p in _by(entries, Op.ASSIST_REQUEST):
if p.get("req_id") in accepted:
continue
if p.get("issued_by") != self_did and p.get("mode") != "standing":
continue # per-incident request authored by someone else: not ours
out.append(p)
return out
def can_open(entries: List[Mapping[str, Any]], req_id: str,
self_did: str, now_ts: str) -> tuple[bool, str]:
"""Whether the box may open a session for req_id: request exists, was
accepted, is authorized for this box (self-authored, or standing mode
BACKED BY an active capability="assist" Grant), and NO session is
currently active (single-session invariant).
Per-incident mode: the box-authored ASSIST_REQUEST IS the authority
no grant needed. Standing mode: the request alone is NOT enough; the
center must additionally hold an active, SELF-issued (issued_by ==
self_did see active_grants()'s sovereignty filter) capability="assist"
grant naming that same center_did, or the open is refused.
"""
reqs = {p.get("req_id"): p for p in _by(entries, Op.ASSIST_REQUEST)}
if req_id not in reqs:
return False, "no-such-request"
accepted = {p.get("req_id") for p in _by(entries, Op.ASSIST_ACCEPT)}
if req_id not in accepted:
return False, "not-accepted"
req = reqs[req_id]
if req.get("issued_by") != self_did and req.get("mode") != "standing":
return False, "not-authorized"
if req.get("mode") == "standing":
grants = active_grants(entries, self_did)
has_assist_grant = any(
g.get("capability") == "assist" and g.get("center_did") == req.get("center_did")
for g in grants.values()
)
if not has_assist_grant:
return False, "no-standing-grant"
if active_session(entries, self_did, now_ts) is not None:
return False, "session-already-active"
return True, "ok"

View File

@ -79,6 +79,13 @@ class Op(str, Enum):
# Gondwana threatmesh (#768): bidirectional WAF/threat ban federation.
BAN_PUBLISH = "ban_publish" # a node signs an IP ban → federates
BAN_REVOKE = "ban_revoke" # the publisher lifts its own ban
# Support / assistance request (sous-projet 2) — signed control-plane
ASSIST_REQUEST = "assist_request" # box asks a center for help
ASSIST_ACCEPT = "assist_accept" # center accepts the request
ASSIST_SESSION_OPEN = "assist_session_open" # box consents → live session
ASSIST_SESSION_CLOSE = "assist_session_close" # session ends (op or auto-expiry)
ASSIST_CONSOLE_GRANT = "assist_console_grant" # 2nd consent → console escalation
ASSIST_CONSOLE_REVOKE = "assist_console_revoke" # console withdrawn
# ---------------------------------------------------------------------------
@ -584,6 +591,62 @@ class Grant(BaseModel):
signer_did: Optional[str] = None
# ---------------------------------------------------------------------------
# Support / assistance request (sous-projet 2) — real-time help sessions
# ---------------------------------------------------------------------------
ASSIST_MODES = {"per-incident", "standing"}
class AssistRequest(BaseModel):
"""A box's signed request for assistance from a center.
Self-certifying: authored by the box (entry.author == issued_by). In
'per-incident' mode this request IS the ephemeral authority (no standing
grant needed); in 'standing' mode an active capability="assist" Grant is
required for the center to initiate. sig covers canonical_bytes(payload
without sig/signer_did).
"""
model_config = ConfigDict(extra="forbid")
req_id: str = Field(..., description="stable id for this request")
center_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$")
mode: str = Field(..., description="'per-incident' or 'standing'")
scope: str = Field(..., pattern=r"^[a-z0-9][a-z0-9._-]*$",
description="incident scope hint; bare filename component")
duration_s: int = Field(..., ge=60, le=86400, description="requested max session seconds")
reason: str = Field(..., min_length=1, max_length=512)
issued_by: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$")
created_at: str = Field(default_factory=now_rfc3339)
sig: Optional[str] = None
signer_did: Optional[str] = None
@field_validator("mode")
@classmethod
def _mode_known(cls, v: str) -> str:
if v not in ASSIST_MODES:
raise ValueError(f"mode must be one of {sorted(ASSIST_MODES)}")
return v
class AssistSession(BaseModel):
"""A box-consented live assistance session. token_hash is BLAKE2b-hex of
the single-use session token; the token secret itself is NEVER journaled.
"""
model_config = ConfigDict(extra="forbid")
session_id: str = Field(..., description="stable id for this session")
req_id: str = Field(..., description="the AssistRequest this opens")
center_did: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$")
token_hash: str = Field(..., pattern=r"^[0-9a-f]{64}$",
description="BLAKE2b-hex of the single-use session token")
expires_ts: str = Field(..., description="RFC3339 hard-cap; fail-closed past this")
issued_by: str = Field(..., pattern=r"^did:plc:[0-9a-f]{32}$")
created_at: str = Field(default_factory=now_rfc3339)
sig: Optional[str] = None
signer_did: Optional[str] = None
# ---------------------------------------------------------------------------
# BanRecord — a signed WAF/threat ban (gondwana threatmesh, #768)
# ---------------------------------------------------------------------------

View File

@ -31,13 +31,15 @@ import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from . import grants
from . import assist, grants
from .crypto import canonical_bytes, did_from_pubkey, public_from_private, sign, verify
from .log import Journal
from .model import (
BanRecord,
GENESIS_HASH,
ApprovalMode,
AssistRequest,
AssistSession,
ConfigBlob,
Grant,
Identity,
@ -2113,3 +2115,110 @@ def _find_any_offer(journal: Journal, service_id: str) -> Optional[Dict]:
best_height = entry.height
best = entry.payload
return best
# ---------------------------------------------------------------------------
# Support / assistance request (sous-projet 2)
# ---------------------------------------------------------------------------
def _assist_append(journal: Journal, priv: bytes, op: Op, model_obj, payload_type: str):
"""Shared: strip sig/signer_did, sign canonical_bytes(payload), append."""
payload = model_obj.model_dump(exclude={"sig", "signer_did"})
pub_hex = public_from_private(priv).hex()
author_did = did_from_pubkey(public_from_private(priv))
sig_hex = sign(priv, canonical_bytes(payload))
return journal.append(op=op, payload=payload, payload_type=payload_type,
author=author_did, author_pubkey_hex=pub_hex,
sig=sig_hex)
def assist_request(
journal: Journal,
box_priv: bytes,
center_did: str,
mode: str,
scope: str,
duration_s: int,
reason: str,
req_id: str,
):
"""ASSIST_REQUEST: a box's signed request for assistance from a center.
Self-certifying: authored by the box (issued_by == author). Validates the
AssistRequest pydantic model before signing/appending.
"""
box_did = did_from_pubkey(public_from_private(box_priv))
m = AssistRequest(req_id=req_id, center_did=center_did, mode=mode, scope=scope,
duration_s=duration_s, reason=reason, issued_by=box_did)
return _assist_append(journal, box_priv, Op.ASSIST_REQUEST, m, "AssistRequest")
def assist_accept(journal: Journal, center_priv: bytes, req_id: str):
"""ASSIST_ACCEPT: the center accepts a pending AssistRequest."""
center_did = did_from_pubkey(public_from_private(center_priv))
payload = {"req_id": req_id, "center_did": center_did, "created_at": now_rfc3339()}
pub_hex = public_from_private(center_priv).hex()
sig_hex = sign(center_priv, canonical_bytes(payload))
return journal.append(op=Op.ASSIST_ACCEPT, payload=payload,
payload_type="AssistAccept", author=center_did,
author_pubkey_hex=pub_hex, sig=sig_hex)
def assist_session_open(
journal: Journal,
box_priv: bytes,
req_id: str,
center_did: str,
token_hash: str,
expires_ts: str,
session_id: str,
):
"""ASSIST_SESSION_OPEN: box consent opens a live assistance session.
Gated by assist.can_open (request exists, accepted, authorized for this
box, and no session already active). Raises ValueError if not eligible.
"""
box_did = did_from_pubkey(public_from_private(box_priv))
ok, why = assist.can_open(list(journal.iter_entries()), req_id, box_did,
now_ts=now_rfc3339())
if not ok:
raise ValueError(f"cannot-open: {why}")
m = AssistSession(session_id=session_id, req_id=req_id, center_did=center_did,
token_hash=token_hash, expires_ts=expires_ts, issued_by=box_did)
return _assist_append(journal, box_priv, Op.ASSIST_SESSION_OPEN, m, "AssistSession")
def assist_session_close(journal: Journal, box_priv: bytes, session_id: str, reason: str):
"""ASSIST_SESSION_CLOSE: end a live assistance session (operator or auto-expiry)."""
box_did = did_from_pubkey(public_from_private(box_priv))
payload = {"session_id": session_id, "issued_by": box_did, "reason": reason,
"created_at": now_rfc3339()}
pub_hex = public_from_private(box_priv).hex()
sig_hex = sign(box_priv, canonical_bytes(payload))
return journal.append(op=Op.ASSIST_SESSION_CLOSE, payload=payload,
payload_type="AssistSessionClose", author=box_did,
author_pubkey_hex=pub_hex, sig=sig_hex)
def assist_console_grant(journal: Journal, box_priv: bytes, session_id: str, expires_ts: str):
"""ASSIST_CONSOLE_GRANT: second box consent escalates a session to console access."""
box_did = did_from_pubkey(public_from_private(box_priv))
payload = {"session_id": session_id, "issued_by": box_did,
"expires_ts": expires_ts, "created_at": now_rfc3339()}
pub_hex = public_from_private(box_priv).hex()
sig_hex = sign(box_priv, canonical_bytes(payload))
return journal.append(op=Op.ASSIST_CONSOLE_GRANT, payload=payload,
payload_type="AssistConsoleGrant", author=box_did,
author_pubkey_hex=pub_hex, sig=sig_hex)
def assist_console_revoke(journal: Journal, box_priv: bytes, session_id: str):
"""ASSIST_CONSOLE_REVOKE: withdraw console escalation for a session."""
box_did = did_from_pubkey(public_from_private(box_priv))
payload = {"session_id": session_id, "issued_by": box_did,
"created_at": now_rfc3339()}
pub_hex = public_from_private(box_priv).hex()
sig_hex = sign(box_priv, canonical_bytes(payload))
return journal.append(op=Op.ASSIST_CONSOLE_REVOKE, payload=payload,
payload_type="AssistConsoleRevoke", author=box_did,
author_pubkey_hex=pub_hex, sig=sig_hex)

View File

@ -1,3 +1,11 @@
secubox-annuaire (0.6.0-1~bookworm1) bookworm; urgency=medium
* Assist control-plane: ASSIST_* ops, AssistRequest/AssistSession models,
assist.py (single-session/console/expiry/sovereignty resolution),
assist_* signed verbs. Consumed by the new secubox-assist package.
-- Gerald KERMA <devel@cybermind.fr> Fri, 25 Jul 2026 12:00:00 +0200
secubox-annuaire (0.5.0-1~bookworm1) bookworm; urgency=medium
* feat: Centres & Grants + Remote Config substrate (Task 1-10). A box

View 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.
import pytest
from pydantic import ValidationError
from annuaire.model import Op, ASSIST_MODES, AssistRequest, AssistSession
DID = "did:plc:" + "a" * 32
def test_ops_present():
assert Op.ASSIST_REQUEST == "assist_request"
assert Op.ASSIST_SESSION_OPEN == "assist_session_open"
assert Op.ASSIST_CONSOLE_GRANT == "assist_console_grant"
def test_request_valid():
r = AssistRequest(req_id="r1", center_did=DID, mode="per-incident",
scope="firewall", duration_s=1800, reason="help",
issued_by=DID)
assert r.mode in ASSIST_MODES and r.sig is None
def test_request_rejects_bad_mode():
with pytest.raises(ValidationError):
AssistRequest(req_id="r1", center_did=DID, mode="root-me",
scope="firewall", duration_s=60, reason="x", issued_by=DID)
def test_request_rejects_path_traversal_scope():
with pytest.raises(ValidationError):
AssistRequest(req_id="r1", center_did=DID, mode="standing",
scope="../../etc", duration_s=60, reason="x", issued_by=DID)
def test_session_requires_64hex_token_hash():
with pytest.raises(ValidationError):
AssistSession(session_id="s1", req_id="r1", center_did=DID,
token_hash="short", expires_ts="2026-07-25T12:00:00Z",
issued_by=DID)
ok = AssistSession(session_id="s1", req_id="r1", center_did=DID,
token_hash="b" * 64, expires_ts="2026-07-25T12:00:00Z",
issued_by=DID)
assert ok.token_hash == "b" * 64
def test_extra_forbidden():
with pytest.raises(ValidationError):
AssistRequest(req_id="r1", center_did=DID, mode="standing", scope="dns",
duration_s=60, reason="x", issued_by=DID, sneaky=True)

View File

@ -0,0 +1,166 @@
# 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.
import pytest
from annuaire.model import Op
from annuaire import assist
BOX = "did:plc:" + "1" * 32
CENTER = "did:plc:" + "2" * 32
OTHER = "did:plc:" + "3" * 32
def e(op, **payload):
return {"op": op.value if hasattr(op, "value") else op, "payload": payload}
def test_active_session_single_and_expiry():
entries = [
e(Op.ASSIST_SESSION_OPEN, session_id="s1", req_id="r1", center_did=CENTER,
issued_by=BOX, token_hash="a" * 64, expires_ts="2026-07-25T12:00:00Z"),
]
# before expiry
s = assist.active_session(entries, BOX, now_ts="2026-07-25T11:00:00Z")
assert s and s["session_id"] == "s1"
# after expiry -> fail-closed None
assert assist.active_session(entries, BOX, now_ts="2026-07-25T13:00:00Z") is None
def test_close_ends_session():
entries = [
e(Op.ASSIST_SESSION_OPEN, session_id="s1", req_id="r1", center_did=CENTER,
issued_by=BOX, token_hash="a" * 64, expires_ts="2026-07-25T23:00:00Z"),
e(Op.ASSIST_SESSION_CLOSE, session_id="s1", issued_by=BOX, reason="done"),
]
assert assist.active_session(entries, BOX, now_ts="2026-07-25T12:00:00Z") is None
def test_sovereignty_ignores_foreign_session():
# a session OPEN authored by someone else (federated) is NOT ours
entries = [
e(Op.ASSIST_SESSION_OPEN, session_id="sX", req_id="rX", center_did=CENTER,
issued_by=OTHER, token_hash="a" * 64, expires_ts="2026-07-25T23:00:00Z"),
]
assert assist.active_session(entries, BOX, now_ts="2026-07-25T12:00:00Z") is None
def test_console_active_and_revoke():
entries = [
e(Op.ASSIST_CONSOLE_GRANT, session_id="s1", issued_by=BOX,
expires_ts="2026-07-25T13:00:00Z"),
]
assert assist.console_active(entries, "s1", now_ts="2026-07-25T12:00:00Z")
assert not assist.console_active(entries, "s1", now_ts="2026-07-25T14:00:00Z")
entries.append(e(Op.ASSIST_CONSOLE_REVOKE, session_id="s1", issued_by=BOX))
assert not assist.console_active(entries, "s1", now_ts="2026-07-25T12:30:00Z")
def test_multiple_active_sessions_raises():
entries = [
e(Op.ASSIST_SESSION_OPEN, session_id="s1", req_id="r1", center_did=CENTER,
issued_by=BOX, token_hash="a" * 64, expires_ts="2026-07-25T23:00:00Z"),
e(Op.ASSIST_SESSION_OPEN, session_id="s2", req_id="r2", center_did=CENTER,
issued_by=BOX, token_hash="b" * 64, expires_ts="2026-07-25T23:00:00Z"),
]
with pytest.raises(assist.AssistError):
assist.active_session(entries, BOX, now_ts="2026-07-25T12:00:00Z")
def test_pending_requests_drops_foreign_per_incident():
# a per-incident request authored by some OTHER did must never surface as
# "pending" for this box — that would let a federated peer's request
# masquerade as ours (sovereignty hole).
entries = [
e(Op.ASSIST_REQUEST, req_id="r-foreign", center_did=CENTER,
mode="per-incident", scope="dns", issued_by=OTHER),
e(Op.ASSIST_REQUEST, req_id="r-self", center_did=CENTER,
mode="per-incident", scope="dns", issued_by=BOX),
e(Op.ASSIST_REQUEST, req_id="r-standing", center_did=CENTER,
mode="standing", scope="dns", issued_by=CENTER),
]
out = {p["req_id"] for p in assist.pending_requests(entries, BOX)}
assert out == {"r-self", "r-standing"}
assert "r-foreign" not in out
def test_can_open_rejects_foreign_per_incident_request():
# accepted, no active session — but issued_by is a foreign did and mode
# is per-incident: must be refused, not silently opened.
entries = [
e(Op.ASSIST_REQUEST, req_id="r-foreign", center_did=CENTER,
mode="per-incident", scope="dns", issued_by=OTHER),
e(Op.ASSIST_ACCEPT, req_id="r-foreign", issued_by=CENTER),
]
ok, reason = assist.can_open(entries, "r-foreign", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (False, "not-authorized")
def test_can_open_allows_self_authored_accepted_request():
entries = [
e(Op.ASSIST_REQUEST, req_id="r-self", center_did=CENTER,
mode="per-incident", scope="dns", issued_by=BOX),
e(Op.ASSIST_ACCEPT, req_id="r-self", issued_by=CENTER),
]
ok, reason = assist.can_open(entries, "r-self", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (True, "ok")
# ---------------------------------------------------------------------------
# Standing mode REQUIRES an active capability="assist" Grant for the center
# (spec: per-incident's own box-authored request IS the authority; standing
# is NOT — it must be backed by a real, self-issued delegation).
# ---------------------------------------------------------------------------
def _grant_issue(gid, center_did, capability, scope, issued_by):
return {"op": Op.GRANT_ISSUE.value, "payload": {
"grant_id": gid, "center_did": center_did, "capability": capability,
"scope": scope, "layer": "baseline", "issued_by": issued_by}}
def test_can_open_standing_without_grant_is_refused():
entries = [
e(Op.ASSIST_REQUEST, req_id="r-standing", center_did=CENTER,
mode="standing", scope="dns", issued_by=CENTER),
e(Op.ASSIST_ACCEPT, req_id="r-standing", issued_by=CENTER),
]
ok, reason = assist.can_open(entries, "r-standing", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (False, "no-standing-grant")
def test_can_open_standing_with_active_assist_grant_is_allowed():
entries = [
e(Op.ASSIST_REQUEST, req_id="r-standing", center_did=CENTER,
mode="standing", scope="dns", issued_by=CENTER),
e(Op.ASSIST_ACCEPT, req_id="r-standing", issued_by=CENTER),
_grant_issue("g1", CENTER, "assist", "dns", BOX),
]
ok, reason = assist.can_open(entries, "r-standing", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (True, "ok")
def test_can_open_standing_ignores_non_assist_capability_grant():
# A "config" capability grant for the same center must not satisfy the
# standing assist-grant requirement — capability must be exactly "assist".
entries = [
e(Op.ASSIST_REQUEST, req_id="r-standing", center_did=CENTER,
mode="standing", scope="dns", issued_by=CENTER),
e(Op.ASSIST_ACCEPT, req_id="r-standing", issued_by=CENTER),
_grant_issue("g1", CENTER, "config", "dns", BOX),
]
ok, reason = assist.can_open(entries, "r-standing", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (False, "no-standing-grant")
def test_can_open_standing_ignores_foreign_issued_grant():
# A capability="assist" grant that THIS box never issued (e.g. a mesh
# peer's federated grant naming itself) must not count — active_grants'
# sovereignty filter already scopes this to issued_by == self_did.
entries = [
e(Op.ASSIST_REQUEST, req_id="r-standing", center_did=CENTER,
mode="standing", scope="dns", issued_by=CENTER),
e(Op.ASSIST_ACCEPT, req_id="r-standing", issued_by=CENTER),
_grant_issue("g1", CENTER, "assist", "dns", OTHER),
]
ok, reason = assist.can_open(entries, "r-standing", BOX, now_ts="2026-07-25T12:00:00Z")
assert (ok, reason) == (False, "no-standing-grant")

View File

@ -0,0 +1,59 @@
# 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.
import os
import pytest
from annuaire.log import Journal
from annuaire.crypto import canonical_bytes, verify, public_from_private, did_from_pubkey
from annuaire import verbs, assist
from annuaire.model import Op
def _key():
priv = os.urandom(32)
did = did_from_pubkey(public_from_private(priv))
return priv, did
def _journal(tmp_path):
return Journal(str(tmp_path / "journal.db"))
def test_request_is_signed_and_appended(tmp_path):
j = _journal(tmp_path)
box_priv, box_did = _key()
_, center_did = _key()
entry = verbs.assist_request(j, box_priv, center_did, "per-incident",
"firewall", 1800, "help me", req_id="r1")
assert entry.op == Op.ASSIST_REQUEST.value
payload = entry.payload
box_pub = public_from_private(box_priv).hex()
assert verify(box_pub, canonical_bytes(payload), entry.sig)
def test_session_open_blocked_without_accept(tmp_path):
j = _journal(tmp_path)
box_priv, box_did = _key()
_, center_did = _key()
verbs.assist_request(j, box_priv, center_did, "per-incident", "dns", 600, "x", req_id="r1")
with pytest.raises(ValueError):
verbs.assist_session_open(j, box_priv, "r1", center_did,
token_hash="a" * 64,
expires_ts="2999-01-01T00:00:00Z",
session_id="s1")
def test_full_open_after_accept(tmp_path):
j = _journal(tmp_path)
box_priv, box_did = _key()
center_priv, center_did = _key()
verbs.assist_request(j, box_priv, center_did, "per-incident", "dns", 600, "x", req_id="r1")
verbs.assist_accept(j, center_priv, "r1")
entry = verbs.assist_session_open(j, box_priv, "r1", center_did,
token_hash="a" * 64,
expires_ts="2999-01-01T00:00:00Z",
session_id="s1")
assert entry.op == Op.ASSIST_SESSION_OPEN.value
s = assist.active_session(list(j.iter_entries()), box_did, now_ts="2026-07-25T00:00:00Z")
assert s and s["session_id"] == "s1"

View File

View File

@ -0,0 +1,133 @@
# 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 :: assist API — reads in-process, mutations delegate to ctl."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
from secubox_core.auth import require_jwt
sys.path.insert(0, os.environ.get("ANNUAIRE_LIB", "/usr/lib/secubox/annuaire"))
from annuaire.log import Journal # noqa: E402
from annuaire import assist # noqa: E402
from annuaire.crypto import public_from_private, did_from_pubkey # noqa: E402
app = FastAPI(title="SecuBox Assist")
CTL = ["/usr/sbin/secubox-assistctl"]
MESH_IFACE = "wg-mesh"
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _entries():
try:
return list(Journal(os.environ.get(
"ANNUAIRE_JOURNAL", "/var/lib/secubox/annuaire/journal.db")).iter_entries())
except Exception:
return []
def _self_did():
path = os.environ.get("ANNUAIRE_KEY_PATH", "/etc/secubox/secrets/annuaire/node.key")
try:
raw = bytes.fromhex(open(path).read().strip())
return did_from_pubkey(public_from_private(raw))
except Exception:
return None
def _ctl(*args):
r = subprocess.run(CTL + list(args), capture_output=True, text=True, timeout=30)
if r.returncode != 0:
raise HTTPException(status_code=400, detail=r.stderr.strip() or "ctl failed")
try:
return json.loads(r.stdout or "{}")
except json.JSONDecodeError:
return {"raw": r.stdout}
@app.get("/status")
async def status():
sid = _self_did()
active = None
if sid:
try:
active = assist.active_session(_entries(), sid, _now())
except assist.AssistError:
active = {"error": "multiple-active-sessions"}
return {"module": "assist", "enabled": True, "mesh_iface": MESH_IFACE,
"has_active_session": bool(active)}
@app.get("/health")
async def health():
return {"status": "ok", "module": "assist"}
@app.get("/sessions", dependencies=[Depends(require_jwt)])
async def sessions():
sid = _self_did()
entries = _entries()
return {"pending": assist.pending_requests(entries, sid) if sid else [],
"active_session": (assist.active_session(entries, sid, _now())
if sid else None)}
class RequestBody(BaseModel):
center_did: str
mode: str
scope: str
duration_s: int
reason: str
@app.post("/request", dependencies=[Depends(require_jwt)])
async def make_request(b: RequestBody):
return _ctl("request", b.center_did, "--mode", b.mode, "--scope", b.scope,
"--duration", str(b.duration_s), "--reason", b.reason)
class OpenBody(BaseModel):
req_id: str
center_did: str
duration_s: int
@app.post("/open", dependencies=[Depends(require_jwt)])
async def open_session(b: OpenBody):
return _ctl("open", b.req_id, "--center", b.center_did, "--duration", str(b.duration_s))
class SessionRef(BaseModel):
session_id: str
reason: str | None = None
@app.post("/close", dependencies=[Depends(require_jwt)])
async def close_session(b: SessionRef):
return _ctl("close", b.session_id, *(["--reason", b.reason] if b.reason else []))
class ConsoleBody(BaseModel):
session_id: str
duration_s: int = 900
@app.post("/console/grant", dependencies=[Depends(require_jwt)])
async def console_grant(b: ConsoleBody):
return _ctl("console-grant", b.session_id, "--duration", str(b.duration_s))
@app.post("/console/revoke", dependencies=[Depends(require_jwt)])
async def console_revoke(b: SessionRef):
return _ctl("console-revoke", b.session_id)

View File

@ -0,0 +1,33 @@
# 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 :: assist.audit append-only, per-line JSON audit of every
assist event (requestacceptopeneach actionconsolekeystrokesclose).
Never truncates; opens 'a' and fsyncs. CSPN immutability requirement.
"""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from typing import Optional
AUDIT_PATH = os.environ.get("SECUBOX_ASSIST_AUDIT", "/var/log/secubox/audit.log")
def record(event: str, session_id: str, actor: str, detail: dict,
*, path: Optional[str] = None) -> None:
line = json.dumps({
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"src": "secubox-assist",
"event": event,
"session_id": session_id,
"actor": actor,
"detail": detail,
}, separators=(",", ":"), sort_keys=True)
with open(path or AUDIT_PATH, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
fh.flush()
os.fsync(fh.fileno())

View File

@ -0,0 +1,87 @@
# 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 :: assist.catalog the ONLY actions a center may run in a session.
Each action maps to a fixed argv (a scoped ctl or a read command). No entry
ever yields a shell string; every argument is validated against a strict
allow-list so a compromised center can never widen the surface. auth/secrets
scopes are unreachable (NON_DELEGATABLE parity).
"""
from __future__ import annotations
import re
from typing import List, Optional
NON_DELEGATABLE = {"auth", "secrets"}
_MODULE_RE = re.compile(r"^secubox-[a-z0-9][a-z0-9-]{1,40}$")
_SCOPE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,40}$")
# Allow-listed modules a center may restart/toggle/reload. Conservative on
# purpose; extend deliberately. (No secubox-auth, no secubox-core.)
MODULE_ALLOW = frozenset({
"secubox-dns", "secubox-dpi", "secubox-crowdsec", "secubox-netdata",
"secubox-wireguard", "secubox-qos", "secubox-vhost", "secubox-nextcloud",
"secubox-mediaflow", "secubox-cdn", "secubox-nac", "secubox-netmodes",
})
class CatalogError(Exception):
"""Unknown action, disallowed target, or unsafe argument."""
def _safe(arg: str, pattern: re.Pattern) -> str:
if arg is None or not pattern.match(arg):
raise CatalogError(f"invalid argument: {arg!r}")
return arg
def _module(arg: str) -> str:
m = _safe(arg, _MODULE_RE)
if m not in MODULE_ALLOW:
raise CatalogError(f"module not allow-listed: {m}")
return m
def _scope(arg: str) -> str:
s = _safe(arg, _SCOPE_RE)
if s in NON_DELEGATABLE:
raise CatalogError(f"scope not delegatable: {s}")
return s
def resolve(action: str, arg: Optional[str]) -> List[str]:
"""Return the exact argv for a catalog action, or raise CatalogError."""
if action == "status.all":
return ["/usr/sbin/secubox-assistctl", "diag", "status"]
if action == "diag.collect":
return ["/usr/sbin/secubox-assistctl", "diag", "bundle"]
if action == "logs.tail":
unit = _module(arg) # only secubox-* units, allow-listed
return ["journalctl", "-u", unit, "-n", "200", "--no-pager"]
if action == "service.restart":
return ["sudo", "-n", "/usr/sbin/secubox-assistctl", "service", "restart", _module(arg)]
if action == "service.toggle":
# arg form "secubox-dns:on" | "secubox-dns:off"
mod, _, state = (arg or "").partition(":")
if state not in ("on", "off"):
raise CatalogError("toggle needs <module>:on|off")
return ["sudo", "-n", "/usr/sbin/secubox-assistctl", "service", "toggle", _module(mod), state]
if action == "config.reload":
return ["sudo", "-n", "/usr/sbin/secubox-assistctl", "config", "reload", _scope(arg)]
if action == "config.rollback":
return ["sudo", "-n", "/usr/sbin/secubox-assistctl", "config", "rollback", _scope(arg)]
raise CatalogError(f"unknown action: {action}")
CATALOG = {
"status.all": {"kind": "diag", "argv": ["/usr/sbin/secubox-assistctl", "diag", "status"], "needs": None},
"diag.collect": {"kind": "diag", "argv": ["/usr/sbin/secubox-assistctl", "diag", "bundle"], "needs": None},
"logs.tail": {"kind": "read", "argv": ["journalctl", "-u", "<module>", "-n", "200", "--no-pager"], "needs": "module"},
"service.restart": {"kind": "ctl", "argv": ["sudo", "-n", "/usr/sbin/secubox-assistctl", "service", "restart", "<module>"], "needs": "module"},
"service.toggle": {"kind": "ctl", "argv": ["sudo", "-n", "/usr/sbin/secubox-assistctl", "service", "toggle", "<module>", "<state>"], "needs": "module"},
"config.reload": {"kind": "ctl", "argv": ["sudo", "-n", "/usr/sbin/secubox-assistctl", "config", "reload", "<scope>"], "needs": "scope"},
"config.rollback": {"kind": "ctl", "argv": ["sudo", "-n", "/usr/sbin/secubox-assistctl", "config", "rollback", "<scope>"], "needs": "scope"},
}

View File

@ -0,0 +1,70 @@
# 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 :: assist.console console escalation pty, gated by a live
CONSOLE_GRANT (double-consent). Runs under the daemon's non-root user; refuses
to run as root. Every keystroke is audited (byte count, not raw content).
"""
from __future__ import annotations
import os
import pty
import signal
from typing import Optional
from . import audit
try:
from annuaire import assist as _assist
except Exception: # pragma: no cover
_assist = None
class ConsoleDenied(Exception):
"""Console not granted, or refused (root)."""
def guard(entries, session_id: str, now_ts: str) -> None:
if _assist is None or not _assist.console_active(entries, session_id, now_ts):
raise ConsoleDenied("console not granted (double-consent required)")
class ConsoleSession:
def __init__(self, audit_path: Optional[str] = None):
self._pid = None
self._fd = None
self._audit_path = audit_path
self._session_id: Optional[str] = None
self._center: Optional[str] = None
def open(self, session_id: str, center_did: str):
if os.geteuid() == 0:
raise ConsoleDenied("refuse-root")
self._session_id = session_id
self._center = center_did
pid, fd = pty.fork()
if pid == 0: # child
os.execv("/bin/bash", ["/bin/bash", "-i"])
self._pid, self._fd = pid, fd
audit.record("console.open", session_id, center_did, {"pid": pid},
path=self._audit_path)
def write(self, data: bytes):
audit.record("console.keystroke", self._session_id, self._center,
{"bytes": len(data)}, path=self._audit_path)
os.write(self._fd, data)
def read(self, n: int = 4096) -> bytes:
return os.read(self._fd, n)
def close(self):
if self._pid:
try:
os.kill(self._pid, signal.SIGTERM)
except ProcessLookupError:
pass
audit.record("console.close", self._session_id, self._center, {},
path=self._audit_path)
self._pid = None

View File

@ -0,0 +1,108 @@
# 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 :: assist.daemon — WebSocket serve loop over wg-mesh."""
from __future__ import annotations
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
import websockets
sys.path.insert(0, os.environ.get("ANNUAIRE_LIB", "/usr/lib/secubox/annuaire"))
from annuaire.log import Journal # noqa: E402
from assist import wsserver # noqa: E402
WS_PORT = int(os.environ.get("SECUBOX_ASSIST_WS_PORT", "8099"))
def _now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _journal_path():
return os.environ.get("ANNUAIRE_JOURNAL", "/var/lib/secubox/annuaire/journal.db")
def _resolve_self_did():
"""Resolve this box's own DID from a PUBLIC source only.
A network-facing daemon must never hold/read the sovereign PRIVATE key
(that file is 0600 secubox:secubox secubox-assist has no business
opening it, and doing so was the previous bug: PermissionError on every
connection). Order: explicit env var, then the world-readable DID file
the postinst derives once from the key at package-install time. If
neither is present, return None authorize() then denies every session
(fail-closed) rather than silently trusting an unresolved identity.
"""
env = os.environ.get("SECUBOX_SELF_DID")
if env:
return env
path = os.environ.get("ANNUAIRE_DID_PATH", "/etc/secubox/annuaire/node.did")
try:
with open(path) as f:
v = f.read().strip()
return v or None
except OSError:
return None
# Cached ONCE at module load — never re-derived per-connection.
SELF_DID = _resolve_self_did()
def _read_entries():
return list(Journal(_journal_path()).iter_entries())
def _dispatch_blocking(session, action, arg, entries, self_did, now_ts):
"""Run wsserver.dispatch (sync subprocess.run under an `async def`
signature we must not change) to completion on its own event loop, so
asyncio.to_thread can offload it to a worker thread."""
return asyncio.run(wsserver.dispatch(session, action, arg, entries, self_did, now_ts))
async def handler(ws):
entries = await asyncio.to_thread(_read_entries)
tok = await ws.recv()
try:
session = await wsserver.authorize(tok, entries, SELF_DID, _now())
except wsserver.AuthError as exc:
await ws.send(json.dumps({"ok": False, "error": str(exc)})); return
await ws.send(json.dumps({"ok": True, "session_id": session["session_id"]}))
async for msg in ws:
req = json.loads(msg)
fresh = await asyncio.to_thread(_read_entries)
# Re-check every action against the SAME session_id this socket
# authorized — not merely "some session is active". If the operator
# closed this session and opened a different one (s2) in the
# meantime, active_session() below returns s2 (non-None), but this
# socket must still be cut off: it was never authorized for s2.
active = wsserver._assist.active_session(fresh, SELF_DID, _now())
if active is None or active.get("session_id") != session["session_id"]:
await ws.send(json.dumps({"ok": False, "error": "session-ended"})); break
# wsserver.dispatch does sync subprocess.run (up to 60s) and
# diag.collect chains many sequential sync subprocess calls — off the
# single asyncio loop via to_thread so one session can't stall every
# other connection (the podcaster/peertube loop-block bug class).
out = await asyncio.to_thread(_dispatch_blocking, session, req.get("action"),
req.get("arg"), fresh, SELF_DID, _now())
await ws.send(json.dumps(out))
async def _main():
ip = wsserver.mesh_bind_ip("wg-mesh") # BindError → crash (fail-closed) if no mesh
async with websockets.serve(handler, ip, WS_PORT):
await asyncio.Future()
def main():
asyncio.run(_main())
if __name__ == "__main__":
main()

View File

@ -0,0 +1,61 @@
# 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 :: assist.diag read-only diagnostic bundle with conservative
redaction. Never touches /etc/secubox/secrets or any *.key; secrets that slip
into logs are scrubbed by redact() before they leave the box.
"""
from __future__ import annotations
import re
import subprocess
from typing import Dict, List
from .catalog import MODULE_ALLOW
_SECRET_KV = re.compile(
r"(?i)(?<![A-Za-z0-9])(token|secret|password|passwd|api[-_]?key|"
r"private[-_ ]?key|key|authorization|bearer)\b"
r"[\"']?\s*(?:[:=]\s*|\s+)"
r"[\"']?(?:(?:bearer|basic)\s+)?"
r"[^\s\"',;{}]+")
_LONG_HEX = re.compile(r"\b[0-9a-fA-F]{40,}\b")
_EMAIL = re.compile(r"\b[\w.+-]+@([\w-]+\.[\w.-]+)\b")
# URI-embedded credentials, e.g. postgres://user:PASSWORD@host:5432/db —
# redacts only the password, keeps user/host/path intact for diagnosis.
_URI_CRED = re.compile(r"(://[^/:@\s]+:)[^/@\s]+(@)")
def redact(text: str) -> str:
text = _SECRET_KV.sub(lambda m: m.group(1) + "=***", text)
text = _URI_CRED.sub(r"\1***\2", text)
text = _LONG_HEX.sub("***", text)
text = _EMAIL.sub(r"***@\1", text)
return text
def _run(argv: List[str], timeout: int = 10) -> str:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return r.stdout
except Exception as exc: # noqa: BLE001 — diag must never crash the session
return f"<diag error: {exc}>"
def collect(now_ts: str) -> Dict:
modules = []
for unit in sorted(MODULE_ALLOW):
active = _run(["systemctl", "is-active", unit]).strip()
modules.append({"unit": unit, "active": active})
logs = {}
for unit in sorted(MODULE_ALLOW):
logs[unit] = redact(_run(
["journalctl", "-u", unit, "-n", "50", "--no-pager"]))
return {
"generated_at": now_ts,
"modules": modules,
"logs": logs,
"config_effective": {"note": "non-secret effective config summary"},
}

View File

@ -0,0 +1,32 @@
# 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 :: assist.token single-use session token (secret never journaled).
Only the BLAKE2b-hex (64) of the token is stored in the signed journal
(AssistSession.token_hash). The token secret is delivered to the center over
the encrypted mesh channel and presented once on the WebSocket handshake.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
def hash_token(tok: str) -> str:
"""BLAKE2b hex digest (64 chars) of the token."""
return hashlib.blake2b(tok.encode("utf-8"), digest_size=32).hexdigest()
def mint() -> tuple[str, str]:
"""Return (token, token_hash). token is URL-safe, ~43 chars of entropy."""
tok = secrets.token_urlsafe(32)
return tok, hash_token(tok)
def verify_token(tok: str, token_hash: str) -> bool:
"""Constant-time compare of hash_token(tok) against the stored hash."""
return hmac.compare_digest(hash_token(tok), token_hash)

View File

@ -0,0 +1,85 @@
# 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 :: assist.wsserver per-session WebSocket data-plane.
Binds the wg-mesh interface IP ONLY (never 0.0.0.0). Authenticates the center
with the single-use session token (hash matched against the journal's
AssistSession). Dispatches ONLY catalog actions; console escalation (a live
pty channel) is handled by the separate console module, not gated here.
Every action is audited. Fail-closed: no wg-mesh BindError the daemon
does not serve.
"""
from __future__ import annotations
import fcntl
import socket
import struct
import subprocess
from typing import Optional
from . import audit, diag
from .catalog import CatalogError, resolve
from .token import verify_token
try: # annuaire is a runtime dependency (prod: /usr/lib/secubox/annuaire on path)
from annuaire import assist as _assist
except Exception: # pragma: no cover - import shim for isolated unit tests
_assist = None
class BindError(Exception):
"""The wg-mesh interface is absent — refuse to serve (fail-closed)."""
class AuthError(Exception):
"""Presented token does not match any active session."""
def mesh_bind_ip(iface: str = "wg-mesh") -> str:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
packed = struct.pack("256s", iface[:15].encode("utf-8"))
addr = fcntl.ioctl(s.fileno(), 0x8915, packed) # SIOCGIFADDR
return socket.inet_ntoa(addr[20:24])
except OSError as exc:
raise BindError(f"wg-mesh iface {iface!r} unavailable: {exc}") from exc
finally:
s.close()
async def authorize(tok: str, entries, self_did: str, now_ts: str) -> dict:
"""Return the active session matching tok, else AuthError."""
if _assist is None:
raise AuthError("annuaire.assist unavailable")
try:
session = _assist.active_session(entries, self_did, now_ts)
except _assist.AssistError as exc:
raise AuthError("multiple-active-sessions") from exc
if session and verify_token(tok, session.get("token_hash", "")):
return session
raise AuthError("no active session for token")
async def dispatch(session: dict, action: str, arg: Optional[str], entries,
self_did: str, now_ts: str) -> dict:
"""Run one catalog action; console escalation is handled by the separate
console module, not by this dispatcher."""
sid = session["session_id"]
center = session.get("center_did", "?")
try:
argv = resolve(action, arg)
except CatalogError as exc:
audit.record("action.reject", sid, center, {"action": action, "why": str(exc)})
return {"ok": False, "error": str(exc)}
audit.record("action.run", sid, center, {"action": action, "arg": arg})
if action == "diag.collect":
return {"ok": True, "output": diag.collect(now_ts)}
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
return {"ok": r.returncode == 0, "output": r.stdout, "error": r.stderr}
except Exception as exc: # noqa: BLE001
audit.record("action.error", sid, center, {"action": action, "err": str(exc)})
return {"ok": False, "error": str(exc)}

View File

@ -0,0 +1,4 @@
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))

View File

@ -0,0 +1,31 @@
secubox-assist (0.1.1-1~bookworm1) bookworm; urgency=medium
* fix(daemon): self_did resolved from a PUBLIC source (SECUBOX_SELF_DID env
or world-readable /etc/secubox/annuaire/node.did), cached once at module
load — the WS daemon (User=secubox-assist) never opens the sovereign
private key again (was PermissionError on every connection, zero
sessions ever authorized); postinst derives node.did once from the key
as root, guarded for the key-absent case.
* fix(systemd): secubox-assist.service now ships NoNewPrivileges=false —
NNP=true was blocking sudo's setuid transition for every scoped
`secubox-assistctl` catalog action (service/config); the API unit keeps
NoNewPrivileges=true (it never sudoes).
* fix(daemon): mid-session recheck now requires the SAME session_id the
socket authorized, not merely "a session is active" — closing session
s1 and opening s2 no longer lets a socket authorized under s1 keep
running under s2's window.
* chore(console): console-escalation control-plane (ops, grant/revoke,
guard, keystroke audit) is present and tested; the live pty-over-WS
channel and its AppArmor launcher are deferred to a follow-up — the
/assist panel's console buttons are disabled and relabelled
accordingly so nothing looks live before it is.
-- Gerald KERMA <devel@cybermind.fr> Sat, 25 Jul 2026 12:00:00 +0200
secubox-assist (0.1.0-1~bookworm1) bookworm; urgency=medium
* Initial socle: assistance request control-plane consumer + data-plane
daemon (WebSocket bind wg-mesh only), catalog dispatcher, console
double-consent, secubox-assistctl, /assist panel.
-- Gerald KERMA <devel@cybermind.fr> Fri, 25 Jul 2026 12:00:00 +0200

View File

@ -0,0 +1,15 @@
Source: secubox-assist
Section: net
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13), dh-python, python3-all
Standards-Version: 4.6.2
Package: secubox-assist
Architecture: all
Depends: ${python3:Depends}, ${misc:Depends}, python3-fastapi, python3-uvicorn,
python3-websockets, secubox-core, secubox-annuaire, sudo, adduser
Description: SecuBox assistance request — real-time help sessions
Consent-gated, audited, revocable live assistance sessions between a box and
a federated center, over the wg-mesh, with a bounded action catalog and
double-consent console escalation.

View File

@ -0,0 +1,43 @@
#!/bin/sh
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
set -e
if ! getent passwd secubox-assist >/dev/null; then
adduser --system --group --no-create-home --home /nonexistent secubox-assist
fi
# Derive the box's PUBLIC did once from the sovereign private key (readable
# here as root) and publish it world-readable so the WS daemon (running as
# secubox-assist, NOT in group secubox, and which must NEVER hold/read the
# private key itself) can resolve its own identity without touching
# /etc/secubox/secrets/annuaire/node.key. Only the node.did FILE is created;
# the shared /etc/secubox/annuaire parent directory is left untouched.
mkdir -p /etc/secubox/annuaire 2>/dev/null || true
if [ -r /etc/secubox/secrets/annuaire/node.key ]; then
python3 -c "
import sys
sys.path.insert(0, '/usr/lib/secubox/annuaire')
from annuaire.crypto import public_from_private, did_from_pubkey
raw = bytes.fromhex(open('/etc/secubox/secrets/annuaire/node.key').read().strip())
print(did_from_pubkey(public_from_private(raw)))
" > /etc/secubox/annuaire/node.did 2>/dev/null || true
chmod 0644 /etc/secubox/annuaire/node.did 2>/dev/null || true
fi
# audit log must be writable by the daemon without touching the shared parent
touch /var/log/secubox/audit.log 2>/dev/null || true
chgrp secubox-assist /var/log/secubox/audit.log 2>/dev/null || true
chmod 0664 /var/log/secubox/audit.log 2>/dev/null || true
#DEBHELPER#
systemctl daemon-reload || true
systemctl enable --now secubox-assist-api.service || true
systemctl enable --now secubox-assist.service || true
# Durable wg-mesh nft allow (own base chain, policy accept — see
# nft/secubox-assist.nft for why it can never drop unrelated traffic).
# dpkg already placed the conffile at /etc/nftables.d/secubox-assist.nft;
# reload nftables.service so it picks up the include (matches
# secubox-metrics/secubox-hub/secubox-toolbox precedent), falling back to a
# direct load if the service isn't running yet.
if systemctl is-active --quiet nftables.service 2>/dev/null; then
systemctl reload nftables.service 2>/dev/null \
|| nft -f /etc/nftables.d/secubox-assist.nft 2>/dev/null || true
fi
exit 0

View File

@ -0,0 +1,9 @@
#!/bin/sh
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
set -e
if [ "$1" = remove ] || [ "$1" = deconfigure ]; then
systemctl stop secubox-assist.service secubox-assist-api.service 2>/dev/null || true
fi
#DEBHELPER#
exit 0

View File

@ -0,0 +1,3 @@
#!/usr/bin/make -f
%:
dh $@ --with python3

View File

@ -0,0 +1,9 @@
assist/*.py usr/lib/secubox/assist/assist/
api/*.py usr/lib/secubox/assist/api/
sbin/secubox-assistctl usr/sbin/
www/assist/* usr/share/secubox/www/assist/
menu.d/*.json usr/share/secubox/menu.d/
nginx/assist.conf etc/nginx/secubox.d/
systemd/*.service lib/systemd/system/
sudoers/secubox-assist etc/sudoers.d/
nft/secubox-assist.nft etc/nftables.d/

View File

@ -0,0 +1,8 @@
{
"id": "assist",
"name": "Assistance",
"icon": "🆘",
"path": "/assist/",
"category": "mesh",
"order": 580
}

View File

@ -0,0 +1,20 @@
# SecuBox assist WS data-plane: reachable ONLY from the wg-mesh, never public.
#
# Own base chain with an accept default. A standalone table whose base chain
# defaults to dropping is terminal for the whole netfilter hook the instant
# this file loads: it would blackhole SSH, the webui, everything, not just
# unmatched assist traffic. Mirrors the secubox-toolbox precedent
# (packages/secubox-toolbox/nftables.d/secubox-toolbox-wg.nft) — a fully
# self-contained table so load never depends on some other table already
# existing on this board.
#
# Loaded by /etc/nftables.conf via `include "/etc/nftables.d/*.nft"`
# (packages/secubox-vortex-firewall ships and enables that include — see its
# postinst) so this survives reboot. The old /etc/secubox/nft.d/ location is
# NOT included by anything and silently vanished on every reboot.
table inet secubox_assist {
chain input {
type filter hook input priority filter; policy accept;
iifname "wg-mesh" tcp dport 8099 accept
}
}

View File

@ -0,0 +1,15 @@
# /etc/nginx/secubox.d/assist.conf — Installed by secubox-assist.
# Static "Assistance" operator panel + API proxy to the assist socket.
location /assist/ {
alias /usr/share/secubox/www/assist/;
index index.html;
try_files $uri $uri/ /assist/index.html;
}
location /api/v1/assist/ {
proxy_pass http://unix:/run/secubox/assist.sock:/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_http_version 1.1;
}

View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
pythonpath = ../secubox-annuaire

View File

@ -0,0 +1,366 @@
#!/usr/bin/env python3
# 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 :: secubox-assistctl — box operator CLI for assistance sessions.
Thin CLI over annuaire.verbs.assist_* + annuaire.assist, operating on the BOX
journal + BOX key (sovereign identity), never a center's (except `accept`,
which is signed by the center's own key — see ASSIST_CENTER_KEY below).
Mutations are signed journal appends (audit trail). The privileged catalog
backends (service/config/diag) are invoked BY the WS daemon under a scoped
sudoers entry (added alongside them, not by this CLI).
Commands:
request <center_did> --mode --scope --duration --reason
ASSIST_REQUEST: box asks a center for help.
Mints nothing (that happens at `open`).
accept <req_id> ASSIST_ACCEPT: the center accepts a pending
request — signed with the CENTER's key
(ASSIST_CENTER_KEY), not the box's.
open <req_id> --center <did> --duration <s>
ASSIST_SESSION_OPEN: box consent opens a
live session. Mints a single-use token and
prints it ONCE on stdout (delivered to the
center over the mesh channel) — only its
BLAKE2b hash is journaled.
close <session_id> [--reason] ASSIST_SESSION_CLOSE.
console-grant <session_id> --duration <s>
ASSIST_CONSOLE_GRANT: second consent.
console-revoke <session_id> ASSIST_CONSOLE_REVOKE.
list {"pending": [...], "active_session": {...}|null}
diag status|bundle [privileged] status summary / full redacted
diagnostic bundle — invoked by the WS daemon
via `sudo -n`, never called interactively.
service restart|toggle <module> [on|off]
[privileged] systemctl restart/start/stop an
assist.catalog.MODULE_ALLOW-listed unit.
config reload|rollback <scope> [privileged] delegate to secubox-params
swap/rollback for an allow-listed scope.
Env (override; keys are NEVER generated here):
ANNUAIRE_JOURNAL default /var/lib/secubox/annuaire/journal.db
ANNUAIRE_KEY_PATH default /etc/secubox/secrets/annuaire/node.key
(32-byte raw Ed25519 private key, 64 hex chars) — the
box's one sovereign identity, provisioned by
`annuairectl init`, never by this CLI.
ASSIST_CENTER_KEY key used by `accept` when this box is itself acting as
a center (rare — normally a center's own tooling calls
`accept`). Defaults to ANNUAIRE_KEY_PATH when unset.
ANNUAIRE_LIB default /usr/lib/secubox/annuaire (import root, prod)
ASSIST_LIB default /usr/lib/secubox/assist (import root, prod)
DRYRUN=1 print {"dryrun": true, "would": ...}; write nothing —
no journal append, nothing minted, `list` afterwards
is unchanged.
"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.environ.get("ASSIST_LIB", "/usr/lib/secubox/assist"))
sys.path.insert(0, os.environ.get("ANNUAIRE_LIB", "/usr/lib/secubox/annuaire"))
from annuaire.log import Journal # noqa: E402
from annuaire import verbs, assist # noqa: E402
from annuaire.crypto import public_from_private, did_from_pubkey # noqa: E402
from assist import token as _token # noqa: E402 (sibling package, prod: ASSIST_LIB)
JOURNAL_PATH = os.environ.get("ANNUAIRE_JOURNAL", "/var/lib/secubox/annuaire/journal.db")
KEY_PATH = os.environ.get("ANNUAIRE_KEY_PATH", "/etc/secubox/secrets/annuaire/node.key")
CENTER_KEY_PATH = os.environ.get("ASSIST_CENTER_KEY", KEY_PATH)
def _die(reason: str) -> None:
print(json.dumps({"error": reason}), file=sys.stderr)
raise SystemExit(1)
def _load_key(path: str, label: str) -> bytes:
try:
with open(path, "r", encoding="ascii") as fh:
raw = fh.read().strip()
except OSError as exc:
_die(f"{label} key unreadable at {path}: {exc}")
raise # unreachable — _die raises SystemExit
if len(raw) != 64:
_die(f"{label} key must be 64 hex chars (32-byte Ed25519), got {len(raw)}")
raise # unreachable
try:
return bytes.fromhex(raw)
except ValueError:
_die(f"{label} key at {path} is not valid hex")
raise # unreachable
def _key() -> bytes:
return _load_key(KEY_PATH, "box")
def _center_key() -> bytes:
return _load_key(CENTER_KEY_PATH, "center")
def _journal() -> Journal:
return Journal(JOURNAL_PATH)
def _self_did(priv: bytes) -> str:
return did_from_pubkey(public_from_private(priv))
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _dry() -> bool:
return os.environ.get("DRYRUN") == "1"
def _dryrun_report(would: str, **fields) -> None:
print(json.dumps({"dryrun": True, "would": would, **fields}))
# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #
def cmd_request(a) -> int:
if _dry():
_dryrun_report("assist_request", center_did=a.center_did, mode=a.mode,
scope=a.scope, duration_s=a.duration, reason=a.reason)
return 0
priv = _key()
j = _journal()
req_id = "req-" + secrets.token_hex(8)
try:
verbs.assist_request(j, priv, a.center_did, a.mode, a.scope, a.duration,
a.reason, req_id=req_id)
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
print(json.dumps({"req_id": req_id}))
return 0
def cmd_accept(a) -> int:
if _dry():
_dryrun_report("assist_accept", req_id=a.req_id)
return 0
priv = _center_key()
j = _journal()
try:
verbs.assist_accept(j, priv, a.req_id)
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
print(json.dumps({"accepted": a.req_id}))
return 0
def cmd_open(a) -> int:
if _dry():
_dryrun_report("assist_session_open", req_id=a.req_id, center_did=a.center,
duration_s=a.duration)
return 0
priv = _key()
j = _journal()
tok, token_hash = _token.mint()
expires_ts = (datetime.now(timezone.utc) + timedelta(seconds=a.duration)
).strftime("%Y-%m-%dT%H:%M:%SZ")
session_id = "sess-" + secrets.token_hex(8)
try:
verbs.assist_session_open(j, priv, a.req_id, a.center, token_hash,
expires_ts, session_id=session_id)
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
# token printed ONCE; delivered to the center over the mesh channel —
# only token_hash was journaled by assist_session_open above.
print(json.dumps({"session_id": session_id, "token": tok, "expires_ts": expires_ts}))
return 0
def cmd_close(a) -> int:
if _dry():
_dryrun_report("assist_session_close", session_id=a.session_id)
return 0
try:
verbs.assist_session_close(_journal(), _key(), a.session_id,
a.reason or "operator-close")
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
print(json.dumps({"closed": a.session_id}))
return 0
def cmd_console_grant(a) -> int:
expires_ts = (datetime.now(timezone.utc) + timedelta(seconds=a.duration)
).strftime("%Y-%m-%dT%H:%M:%SZ")
if _dry():
_dryrun_report("assist_console_grant", session_id=a.session_id,
expires_ts=expires_ts)
return 0
try:
verbs.assist_console_grant(_journal(), _key(), a.session_id, expires_ts)
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
print(json.dumps({"console": a.session_id, "expires_ts": expires_ts}))
return 0
def cmd_console_revoke(a) -> int:
if _dry():
_dryrun_report("assist_console_revoke", session_id=a.session_id)
return 0
try:
verbs.assist_console_revoke(_journal(), _key(), a.session_id)
except ValueError as exc:
_die(str(exc))
return 1 # unreachable
print(json.dumps({"console_revoked": a.session_id}))
return 0
def cmd_list(a) -> int:
priv = _key()
entries = list(_journal().iter_entries())
self_did = _self_did(priv)
pending = assist.pending_requests(entries, self_did)
session = assist.active_session(entries, self_did, now_ts=_now())
print(json.dumps({"pending": pending, "active_session": session}))
return 0
# --------------------------------------------------------------------------- #
# privileged catalog backends — invoked BY the WS daemon (via sudo -n, see
# sudoers/secubox-assist) as `sudo -n secubox-assistctl <diag|service|config>
# ...`, never directly by a center. Every target is re-validated here against
# assist.catalog.MODULE_ALLOW / _scope even though wsserver.dispatch already
# validated via assist.catalog.resolve — defense in depth: this binary is the
# actual privilege boundary (root via sudoers), so it must not trust its
# caller's argv construction alone.
# --------------------------------------------------------------------------- #
def cmd_diag(a) -> int:
from assist import diag
if a.what == "status":
print(json.dumps(diag.collect(_now())["modules"]))
else:
print(json.dumps(diag.collect(_now())))
return 0
def cmd_service(a) -> int:
from assist.catalog import MODULE_ALLOW
if a.module not in MODULE_ALLOW:
_die(f"module not allow-listed: {a.module}")
return 1 # unreachable
import subprocess as sp
if a.op == "restart":
sp.run(["systemctl", "restart", a.module], check=False)
else:
if a.state not in ("on", "off"):
_die("toggle needs on|off")
return 1 # unreachable
sp.run(["systemctl", "start" if a.state == "on" else "stop", a.module], check=False)
print(json.dumps({"ok": True, "module": a.module, "op": a.op}))
return 0
def cmd_config(a) -> int:
# Delegates to the project-wide double-buffer/4R config actuator
# (secubox-params — see CLAUDE.md "Double-buffer PARAMETERS"); this ctl
# never edits config files itself, only triggers the validated swap or
# a rollback to the last-known-good snapshot for the given scope.
from assist.catalog import _scope, CatalogError
try:
scope = _scope(a.scope)
except CatalogError as exc:
_die(str(exc))
return 1 # unreachable
import subprocess as sp
if a.op == "reload":
argv = ["secubox-params", "swap", "--module", scope, "--validate-zkp"]
else:
argv = ["secubox-params", "rollback", "--module", scope, "--target", "R1"]
r = sp.run(argv, capture_output=True, text=True, check=False)
print(json.dumps({"ok": r.returncode == 0, "scope": scope, "op": a.op,
"output": r.stdout, "error": r.stderr}))
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(
prog="secubox-assistctl",
description="Box operator CLI for assistance sessions "
"(request/accept/open/close/console-grant/console-revoke/list)",
)
sub = p.add_subparsers(dest="cmd", required=True)
q = sub.add_parser("request", help="ask a center for assistance")
q.add_argument("center_did")
q.add_argument("--mode", required=True)
q.add_argument("--scope", required=True)
q.add_argument("--duration", type=int, required=True)
q.add_argument("--reason", required=True)
q.set_defaults(fn=cmd_request)
ac = sub.add_parser("accept", help="center accepts a pending request")
ac.add_argument("req_id")
ac.set_defaults(fn=cmd_accept)
o = sub.add_parser("open", help="box consent opens a live session")
o.add_argument("req_id")
o.add_argument("--center", required=True)
o.add_argument("--duration", type=int, required=True)
o.set_defaults(fn=cmd_open)
c = sub.add_parser("close", help="end a live session")
c.add_argument("session_id")
c.add_argument("--reason")
c.set_defaults(fn=cmd_close)
cg = sub.add_parser("console-grant", help="second consent escalates to console access")
cg.add_argument("session_id")
cg.add_argument("--duration", type=int, required=True)
cg.set_defaults(fn=cmd_console_grant)
cr = sub.add_parser("console-revoke", help="withdraw console escalation")
cr.add_argument("session_id")
cr.set_defaults(fn=cmd_console_revoke)
sub.add_parser("list", help="pending requests + active session").set_defaults(fn=cmd_list)
# Privileged catalog backends — invoked by the WS daemon via sudo -n,
# never called interactively by an operator (see sudoers/secubox-assist).
d = sub.add_parser("diag", help="[privileged] diagnostic bundle backend")
d.add_argument("what", choices=["status", "bundle"])
d.set_defaults(fn=cmd_diag)
sv = sub.add_parser("service", help="[privileged] restart/toggle an allow-listed module")
sv.add_argument("op", choices=["restart", "toggle"])
sv.add_argument("module")
sv.add_argument("state", nargs="?", choices=["on", "off"])
sv.set_defaults(fn=cmd_service)
cf = sub.add_parser("config", help="[privileged] reload/rollback an allow-listed scope")
cf.add_argument("op", choices=["reload", "rollback"])
cf.add_argument("scope")
cf.set_defaults(fn=cmd_config)
args = p.parse_args(argv)
return args.fn(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,7 @@
# The WS data-plane daemon (secubox-assist.service, User=secubox-assist —
# see systemd/secubox-assist.service) is what actually runs catalog.py's
# `sudo -n .../secubox-assistctl service|config ...` argv (wsserver.dispatch
# is invoked from assist.daemon.handler under that unit's user, NOT the
# secubox-assist-api.service/webui user). Scoped to the ctl binary only —
# never NOPASSWD: ALL.
secubox-assist ALL=(root) NOPASSWD: /usr/sbin/secubox-assistctl

View File

@ -0,0 +1,18 @@
[Unit]
Description=SecuBox Assist — REST API (unix socket)
After=network.target
[Service]
Type=simple
User=secubox
Group=secubox
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/assist.sock --log-level warning
WorkingDirectory=/usr/lib/secubox/assist
Environment=PYTHONPATH=/usr/lib/secubox/assist:/usr/lib/secubox/annuaire
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,27 @@
[Unit]
Description=SecuBox Assist — WebSocket data-plane (wg-mesh only)
After=network-online.target wg-quick@wg-mesh.service
Wants=network-online.target
[Service]
Type=simple
User=secubox-assist
Group=secubox-assist
ExecStart=/usr/bin/python3 -m assist.daemon
Environment=PYTHONPATH=/usr/lib/secubox/assist:/usr/lib/secubox/annuaire
Restart=on-failure
RestartSec=5
# NNP MUST be false: catalog.resolve() dispatches service/config actions as
# `sudo -n /usr/sbin/secubox-assistctl ...` (scoped sudoers, see
# sudoers/secubox-assist) run by THIS unit's User=. NoNewPrivileges=true
# blocks sudo's setuid transition outright, silently failing every
# privileged catalog action. secubox-assist-api.service never sudoes and
# keeps NoNewPrivileges=true.
NoNewPrivileges=false
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/secubox
AmbientCapabilities=
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,29 @@
# 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.
import os, sys
from pathlib import Path
ANNUAIRE = str(Path(__file__).resolve().parents[2] / "secubox-annuaire")
CORE = str(Path(__file__).resolve().parents[2].parent / "common")
sys.path.insert(0, ANNUAIRE)
sys.path.insert(0, CORE)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
os.environ.setdefault("ANNUAIRE_JOURNAL", "/tmp/assist-test-journal.db")
from fastapi.testclient import TestClient
from api.main import app
client = TestClient(app)
def test_status_public():
r = client.get("/status")
assert r.status_code == 200
body = r.json()
assert body["module"] == "assist"
assert "has_active_session" in body
def test_sessions_requires_jwt():
r = client.get("/sessions")
assert r.status_code in (401, 403)

View File

@ -0,0 +1,66 @@
# 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.
import json
import os
import subprocess
import sys
from pathlib import Path
CTL = str(Path(__file__).resolve().parent.parent / "sbin" / "secubox-assistctl")
ASSIST = str(Path(__file__).resolve().parent.parent)
ANNUAIRE = str(Path(__file__).resolve().parents[2] / "secubox-annuaire")
def _env(tmp_path):
key = tmp_path / "node.key"
# 32-byte raw Ed25519 as 64 hex
key.write_text("11" * 32)
env = dict(os.environ)
env["ANNUAIRE_KEY_PATH"] = str(key)
env["ANNUAIRE_JOURNAL"] = str(tmp_path / "journal.db")
env["ANNUAIRE_LIB"] = ANNUAIRE
env["ASSIST_LIB"] = ASSIST
env["PYTHONPATH"] = ANNUAIRE + os.pathsep + ASSIST + os.pathsep + env.get("PYTHONPATH", "")
return env
def test_request_then_list(tmp_path):
env = _env(tmp_path)
center = "did:plc:" + "2" * 32
r = subprocess.run([sys.executable, CTL, "request", center, "--mode",
"per-incident", "--scope", "dns", "--duration", "600",
"--reason", "help"], env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
out = json.loads(r.stdout)
assert out.get("req_id")
r2 = subprocess.run([sys.executable, CTL, "list"], env=env,
capture_output=True, text=True)
listing = json.loads(r2.stdout)
assert len(listing["pending"]) == 1
def test_dryrun_writes_nothing(tmp_path):
env = _env(tmp_path); env["DRYRUN"] = "1"
center = "did:plc:" + "2" * 32
r = subprocess.run([sys.executable, CTL, "request", center, "--mode",
"standing", "--scope", "dns", "--duration", "600",
"--reason", "x"], env=env, capture_output=True, text=True)
assert json.loads(r.stdout).get("dryrun") is True
r2 = subprocess.run([sys.executable, CTL, "list"], env=env,
capture_output=True, text=True)
assert json.loads(r2.stdout)["pending"] == []
def test_request_bad_mode_returns_json_error_not_traceback(tmp_path):
env = _env(tmp_path)
center = "did:plc:" + "2" * 32
r = subprocess.run([sys.executable, CTL, "request", center, "--mode",
"bogus-mode", "--scope", "dns", "--duration", "600",
"--reason", "help"], env=env, capture_output=True, text=True)
assert r.returncode != 0
combined = (r.stdout + r.stderr).strip()
assert "Traceback" not in combined, combined
payload = json.loads(r.stderr.strip() or r.stdout.strip())
assert "error" in payload

View File

@ -0,0 +1,19 @@
# 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.
import json
from assist import audit
def test_append_only_and_json_lines(tmp_path):
p = tmp_path / "audit.log"
audit.record("session.open", "s1", "did:box", {"req_id": "r1"}, path=str(p))
audit.record("console.keystroke", "s1", "did:center", {"bytes": 3}, path=str(p))
lines = p.read_text().strip().splitlines()
assert len(lines) == 2
first = json.loads(lines[0])
assert first["event"] == "session.open" and first["session_id"] == "s1"
assert "ts" in first
# second append does not truncate the first
assert json.loads(lines[1])["event"] == "console.keystroke"

View File

@ -0,0 +1,39 @@
# 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.
import pytest
from assist import catalog
def test_status_all_is_readonly_argv():
argv = catalog.resolve("status.all", None)
assert isinstance(argv, list) and argv # never a shell string
def test_service_restart_allowed_module():
argv = catalog.resolve("service.restart", "secubox-dns")
assert "secubox-dns" in argv
assert not any(";" in a or "&&" in a or "|" in a for a in argv)
def test_unknown_action_rejected():
with pytest.raises(catalog.CatalogError):
catalog.resolve("rm.rf", "/")
def test_module_outside_allowlist_rejected():
with pytest.raises(catalog.CatalogError):
catalog.resolve("service.restart", "sshd")
def test_secrets_scope_rejected():
with pytest.raises(catalog.CatalogError):
catalog.resolve("config.reload", "secrets")
with pytest.raises(catalog.CatalogError):
catalog.resolve("config.reload", "auth")
def test_shell_metachars_in_arg_rejected():
with pytest.raises(catalog.CatalogError):
catalog.resolve("logs.tail", "secubox-dns; rm -rf /")

View File

@ -0,0 +1,28 @@
# 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.
import os
import pytest
from assist import console
def test_guard_denies_without_console_grant():
entries = [] # no CONSOLE_GRANT
with pytest.raises(console.ConsoleDenied):
console.guard(entries, "s1", now_ts="2026-07-25T12:00:00Z")
def test_guard_allows_with_grant():
entries = [{"op": "assist_console_grant", "payload": {
"session_id": "s1", "issued_by": "did:plc:" + "1"*32,
"expires_ts": "2999-01-01T00:00:00Z"}}]
console.guard(entries, "s1", now_ts="2026-07-25T12:00:00Z") # no raise
@pytest.mark.skipif(os.geteuid() == 0, reason="test asserts non-root refusal path only off-root")
def test_console_refuses_root(monkeypatch):
monkeypatch.setattr(console.os, "geteuid", lambda: 0)
cs = console.ConsoleSession(audit_path="/dev/null")
with pytest.raises(console.ConsoleDenied):
cs.open("s1", "did:center")

View File

@ -0,0 +1,159 @@
# 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.
"""
assist.daemon self_did resolution must never touch the sovereign PRIVATE
key (FINDING 1) and the mid-session recheck must bind to the SAME session
the socket authorized, not merely "a session is active" (FINDING 4).
"""
import importlib
import json
import pytest
from assist import daemon, token, audit
SELF = "did:plc:" + "1" * 32
CENTER = "did:plc:" + "2" * 32
# ---------------------------------------------------------------------------
# FINDING 1 — public-source self_did, never the private key, cached once
# ---------------------------------------------------------------------------
def test_resolve_self_did_prefers_env_var(monkeypatch):
monkeypatch.setenv("SECUBOX_SELF_DID", SELF)
assert daemon._resolve_self_did() == SELF
def test_resolve_self_did_reads_public_file_when_env_absent(tmp_path, monkeypatch):
monkeypatch.delenv("SECUBOX_SELF_DID", raising=False)
did_file = tmp_path / "node.did"
did_file.write_text(SELF + "\n")
monkeypatch.setenv("ANNUAIRE_DID_PATH", str(did_file))
assert daemon._resolve_self_did() == SELF
def test_resolve_self_did_none_when_neither_present(tmp_path, monkeypatch):
monkeypatch.delenv("SECUBOX_SELF_DID", raising=False)
monkeypatch.setenv("ANNUAIRE_DID_PATH", str(tmp_path / "missing.did"))
assert daemon._resolve_self_did() is None
def test_resolve_self_did_never_opens_the_private_key(tmp_path, monkeypatch):
"""Even when a (garbage) private key file is reachable, resolution must
fall through to None rather than ever opening ANNUAIRE_KEY_PATH a
network-facing daemon must never hold/read the sovereign private key."""
key_path = tmp_path / "node.key"
key_path.write_text("not-a-valid-hex-key-and-must-never-be-read")
monkeypatch.setenv("ANNUAIRE_KEY_PATH", str(key_path))
monkeypatch.delenv("SECUBOX_SELF_DID", raising=False)
monkeypatch.setenv("ANNUAIRE_DID_PATH", str(tmp_path / "missing.did"))
# Would raise (bytes.fromhex on garbage) if the private key were ever
# touched by resolution; instead it must cleanly fall back to None.
assert daemon._resolve_self_did() is None
def test_self_did_cached_at_module_load_not_per_connection(monkeypatch):
monkeypatch.setenv("SECUBOX_SELF_DID", "did:plc:" + "c" * 32)
importlib.reload(daemon)
assert daemon.SELF_DID == "did:plc:" + "c" * 32
# Mutating the env AFTER load must NOT change the cached module value —
# it is read once at import, never per-connection.
monkeypatch.setenv("SECUBOX_SELF_DID", "did:plc:" + "d" * 32)
assert daemon.SELF_DID == "did:plc:" + "c" * 32
# restore a clean module state for subsequent tests in this process
monkeypatch.delenv("SECUBOX_SELF_DID", raising=False)
importlib.reload(daemon)
# ---------------------------------------------------------------------------
# FINDING 4 — mid-session recheck must bind to the SAME session_id
# ---------------------------------------------------------------------------
class FakeWS:
"""Minimal async websocket stand-in: one recv() for the token, then an
async-iterable stream of already-queued JSON action messages."""
def __init__(self, tok, messages):
self._tok = tok
self._messages = list(messages)
self.sent = []
async def recv(self):
return self._tok
async def send(self, msg):
self.sent.append(json.loads(msg))
def __aiter__(self):
return self
async def __anext__(self):
if not self._messages:
raise StopAsyncIteration
return self._messages.pop(0)
@pytest.mark.asyncio
async def test_recheck_rejects_when_operator_reopened_a_new_session(monkeypatch, tmp_path):
"""s1 authorizes the socket; the operator then closes s1 and opens s2
(still "a session is active" but NOT the one this socket authorized).
The socket must be cut off with session-ended, not silently ride s2."""
monkeypatch.setattr(daemon, "SELF_DID", SELF)
monkeypatch.setattr(audit, "AUDIT_PATH", str(tmp_path / "audit.log"))
tok, h = token.mint()
entries_at_connect = [{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": CENTER,
"issued_by": SELF, "token_hash": h,
"expires_ts": "2999-01-01T00:00:00Z"}}]
entries_after_reopen = [
{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": CENTER,
"issued_by": SELF, "token_hash": h,
"expires_ts": "2999-01-01T00:00:00Z"}},
{"op": "assist_session_close", "payload": {
"session_id": "s1", "issued_by": SELF, "reason": "done"}},
{"op": "assist_session_open", "payload": {
"session_id": "s2", "req_id": "r2", "center_did": CENTER,
"issued_by": SELF, "token_hash": "b" * 64,
"expires_ts": "2999-01-01T00:00:00Z"}},
]
calls = {"n": 0}
def fake_read_entries():
calls["n"] += 1
return entries_at_connect if calls["n"] == 1 else entries_after_reopen
monkeypatch.setattr(daemon, "_read_entries", fake_read_entries)
ws = FakeWS(tok, [json.dumps({"action": "diag.collect"})])
await daemon.handler(ws)
assert ws.sent[0]["ok"] is True and ws.sent[0]["session_id"] == "s1"
assert ws.sent[-1] == {"ok": False, "error": "session-ended"}
@pytest.mark.asyncio
async def test_recheck_allows_the_same_session_to_keep_running(monkeypatch, tmp_path):
monkeypatch.setattr(daemon, "SELF_DID", SELF)
monkeypatch.setattr(audit, "AUDIT_PATH", str(tmp_path / "audit.log"))
tok, h = token.mint()
entries = [{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": CENTER,
"issued_by": SELF, "token_hash": h,
"expires_ts": "2999-01-01T00:00:00Z"}}]
monkeypatch.setattr(daemon, "_read_entries", lambda: entries)
ws = FakeWS(tok, [json.dumps({"action": "diag.collect"})])
await daemon.handler(ws)
assert ws.sent[0]["ok"] is True and ws.sent[0]["session_id"] == "s1"
assert ws.sent[-1]["ok"] is True
assert "output" in ws.sent[-1]

View File

@ -0,0 +1,131 @@
# 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.
from assist import diag
def test_redacts_token_and_password():
s = 'token = "abcdef123456" password=hunter2 API_KEY: zzz'
out = diag.redact(s)
assert "abcdef123456" not in out
assert "hunter2" not in out
assert "zzz" not in out
assert "***" in out
def test_redacts_long_hex_secret():
s = "key " + "a" * 64
assert "a" * 64 not in diag.redact(s)
def test_redacts_bearer_token_with_colon_separator():
s = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.pay.sig"
out = diag.redact(s)
assert "eyJhbGciOiJIUzI1NiJ9.pay.sig" not in out
assert "***" in out
def test_redacts_standalone_bearer_keyword_space_separator():
s = "Bearer eyJhbGciOiJIUzI1NiJ9.pay.sig"
out = diag.redact(s)
assert "eyJhbGciOiJIUzI1NiJ9.pay.sig" not in out
assert "***" in out
def test_redacts_basic_auth_credential():
s = "authorization: Basic dXNlcjpwYXNz"
out = diag.redact(s)
assert "dXNlcjpwYXNz" not in out
assert "***" in out
def test_redacts_json_quoted_password():
s = '{"password": "s3cr3tVal"}'
out = diag.redact(s)
assert "s3cr3tVal" not in out
assert "***" in out
def test_redacts_json_quoted_api_key():
s = '{"api_key":"zzz2"}'
out = diag.redact(s)
assert "zzz2" not in out
assert "***" in out
def test_redacts_email_local_part():
s = "contact alice@example.com for details"
out = diag.redact(s)
assert "alice@example.com" not in out
assert "***@example.com" in out
def test_redacts_access_token_compound_field():
s = '{"access_token": "eyJsecretjwt.payload.sig"}'
out = diag.redact(s)
assert "eyJsecretjwt.payload.sig" not in out
assert "***" in out
def test_redacts_refresh_token_compound_field():
s = '{"refresh_token":"rt-xxxxxxxxxxxxxxxxxxxx"}'
out = diag.redact(s)
assert "rt-xxxxxxxxxxxxxxxxxxxx" not in out
assert "***" in out
def test_redacts_client_secret_compound_field():
s = '{"client_secret": "supersecretvalue123"}'
out = diag.redact(s)
assert "supersecretvalue123" not in out
assert "***" in out
def test_redacts_generic_underscore_prefixed_password():
s = "my_password=hunter3"
out = diag.redact(s)
assert "hunter3" not in out
assert "***" in out
def test_redacts_wireguard_private_key_journalctl_block():
s = (
"interface: wg0\n"
" private key: 6PgLIf6qX7C0y6nQKk2rTz4o8u5jK9m1nD3sF7pV8XY=\n"
" public key: abcdefgh\n"
)
out = diag.redact(s)
assert "6PgLIf6qX7C0y6nQKk2rTz4o8u5jK9m1nD3sF7pV8XY=" not in out
assert "***" in out
def test_redacts_private_key_env_style():
s = "PRIVATE_KEY=6PgLIf6qX7C0y6nQKk2rTz4o8u5jK9m1nD3sF7pV8XY="
out = diag.redact(s)
assert "6PgLIf6qX7C0y6nQKk2rTz4o8u5jK9m1nD3sF7pV8XY=" not in out
assert "***" in out
def test_redacts_private_key_json_style():
s = '{"private_key": "abcSecretKeyValue123"}'
out = diag.redact(s)
assert "abcSecretKeyValue123" not in out
assert "***" in out
def test_redacts_uri_embedded_credential_password():
s = "postgres://dbuser:s3cr3tdbpass@localhost:5432/appdb"
out = diag.redact(s)
assert "s3cr3tdbpass" not in out
assert "***" in out
assert "dbuser" in out
assert "localhost:5432/appdb" in out
def test_collect_has_no_secret_paths(monkeypatch):
b = diag.collect(now_ts="2026-07-25T12:00:00Z")
assert "generated_at" in b and "modules" in b and "logs" in b
blob = repr(b).lower()
assert "/etc/secubox/secrets" not in blob
assert ".key" not in blob

View File

@ -0,0 +1,26 @@
# 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.
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VALID_CATEGORIES = {"auth", "wall", "boot", "mind", "root", "mesh"}
def test_menu_is_valid_json_with_assist_path():
m = json.loads((ROOT / "menu.d" / "580-assist.json").read_text())
blob = json.dumps(m)
assert "/assist" in blob
assert isinstance(m.get("name"), str) and m.get("name")
assert m.get("category") in VALID_CATEGORIES
def test_panel_uses_sbx_token_and_no_inline_onclick():
html = (ROOT / "www" / "assist" / "index.html").read_text()
assert "sbx_token" in html
assert "/shared/sidebar.js" in html
assert "onclick=" not in html # event delegation only

View File

@ -0,0 +1,126 @@
# 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.
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def test_nft_dropin_is_mesh_only_and_default_drop_friendly():
nft = (ROOT / "nft" / "secubox-assist.nft").read_text()
assert 'iifname "wg-mesh"' in nft
assert "0.0.0.0" not in nft
def test_nft_dropin_standalone_table_never_drops():
# A standalone assist table with `policy drop` on a base chain is a
# board-wide outage the instant this file loads (SSH, webui, everything
# not explicitly matched gets dropped) — see secubox-toolbox's
# nftables.d/secubox-toolbox-wg.nft precedent, which uses `policy accept`
# for exactly this reason. This table must never be able to drop
# unrelated traffic.
nft = (ROOT / "nft" / "secubox-assist.nft").read_text()
assert "policy drop;" not in nft
assert "policy accept;" in nft
def test_nft_dropin_installed_at_boot_loaded_path():
# /etc/secubox/nft.d/ is not included by anything and vanishes on
# reboot; /etc/nftables.d/*.nft is glob-included by /etc/nftables.conf
# (see secubox-vortex-firewall's postinst) and is what actually
# survives reboot.
install = (ROOT / "debian" / "secubox-assist.install").read_text()
assert "etc/nftables.d/" in install
assert "etc/secubox/nft.d" not in install
def test_sudoers_is_scoped_to_assistctl():
s = (ROOT / "sudoers" / "secubox-assist").read_text()
assert "/usr/sbin/secubox-assistctl" in s
assert "ALL=(ALL) NOPASSWD: ALL" not in s
def test_sudoers_principal_is_ws_daemon_user():
# catalog.py's service.*/config.* actions run `sudo -n
# /usr/sbin/secubox-assistctl ...` from wsserver.dispatch, which is
# invoked from assist.daemon.handler under secubox-assist.service's
# User= (see systemd/secubox-assist.service) — NOT the
# secubox-assist-api.service/webui user. The sudoers principal must
# match that user or every privileged action is silently denied.
s = (ROOT / "sudoers" / "secubox-assist").read_text()
assert "secubox-assist ALL=(root) NOPASSWD: /usr/sbin/secubox-assistctl" in s
def test_units_run_as_non_root():
svc = (ROOT / "systemd" / "secubox-assist.service").read_text()
assert "User=secubox-assist" in svc
assert "NoNewPrivileges=" in svc
def test_ws_daemon_unit_allows_scoped_sudo_catalog_exec():
# catalog.resolve() returns `sudo -n /usr/sbin/secubox-assistctl ...` for
# service/config actions, run by THIS unit (secubox-assist.service, see
# test_sudoers_principal_is_ws_daemon_user). NoNewPrivileges=true blocks
# sudo's setuid transition outright, so every privileged catalog action
# silently fails end-to-end. This unit needs NNP=false; other hardening
# (ProtectSystem=strict, empty AmbientCapabilities=) stays intact.
svc = (ROOT / "systemd" / "secubox-assist.service").read_text()
assert "NoNewPrivileges=false" in svc
assert "ProtectSystem=strict" in svc
assert "AmbientCapabilities=" in svc
def test_api_unit_keeps_no_new_privileges_true():
# The API unit never sudoes (it delegates mutations to ctl via
# subprocess, invoked as itself, not via sudo) — it keeps full NNP
# hardening.
svc = (ROOT / "systemd" / "secubox-assist-api.service").read_text()
assert "NoNewPrivileges=true" in svc
def test_console_buttons_are_disabled_and_labelled_not_yet_available():
# console.py (guard + pty + keystroke audit) exists but nothing in the
# data-plane opens a pty; nothing consumes the CONSOLE_GRANT/REVOKE ops
# these buttons write. Ship the control-plane honestly: the buttons must
# not look like a live, working escalation channel.
html = (ROOT / "www" / "assist" / "index.html").read_text()
grant_line = next(l for l in html.splitlines() if 'data-act="console-grant"' in l)
revoke_line = next(l for l in html.splitlines() if 'data-act="console-revoke"' in l)
assert "disabled" in grant_line
assert "disabled" in revoke_line
assert "à venir" in grant_line or "en cours" in grant_line
assert "à venir" in revoke_line or "en cours" in revoke_line
def test_postinst_does_not_chown_shared_parents():
post = (ROOT / "debian" / "postinst").read_text()
for parent in ("chown -R secubox-assist /run/secubox",
"chown -R secubox-assist /etc/secubox",
"chown -R secubox-assist /var/log/secubox"):
assert parent not in post
def test_postinst_derives_public_node_did_guarded_and_never_fails():
# The WS daemon (User=secubox-assist, not in group secubox) must never
# open the sovereign private key. postinst runs as root and CAN read it,
# so it derives the DID once here and publishes it world-readable. A
# missing key (annuairectl init not yet run) must not fail the install.
post = (ROOT / "debian" / "postinst").read_text()
assert "/etc/secubox/secrets/annuaire/node.key" in post
assert "did_from_pubkey" in post and "public_from_private" in post
assert "/etc/secubox/annuaire/node.did" in post
assert "chmod 0644 /etc/secubox/annuaire/node.did" in post
# guarded: reading the key is conditioned on it existing, not assumed
assert "if [ -r /etc/secubox/secrets/annuaire/node.key ]" in post
def test_postinst_node_did_derivation_does_not_chmod_the_shared_parent():
# Only the FILE gets a mode; /etc/secubox/annuaire itself (shared with
# other consumers) must not be chmod'd/chown'd by this package.
post = (ROOT / "debian" / "postinst").read_text()
assert "chmod -R" not in post
for bad in ("chown /etc/secubox/annuaire", "chown -R /etc/secubox/annuaire",
"chmod 0755 /etc/secubox/annuaire", "chmod -R /etc/secubox/annuaire",
"chmod 0644 /etc/secubox/annuaire\n"):
assert bad not in post

View File

@ -0,0 +1,22 @@
# 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.
from assist import token
def test_mint_returns_token_and_64hex_hash():
tok, h = token.mint()
assert len(tok) >= 32
assert len(h) == 64 and all(c in "0123456789abcdef" for c in h)
assert token.hash_token(tok) == h
def test_verify_roundtrip_and_reject():
tok, h = token.mint()
assert token.verify_token(tok, h)
assert not token.verify_token("wrong", h)
def test_two_mints_differ():
assert token.mint()[0] != token.mint()[0]

View File

@ -0,0 +1,55 @@
# 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.
import pytest
from assist import wsserver, token
def test_mesh_bind_absent_iface_fails_closed():
with pytest.raises(wsserver.BindError):
wsserver.mesh_bind_ip("nonexistent-iface-xyz")
@pytest.mark.asyncio
async def test_authorize_matches_token_hash():
tok, h = token.mint()
entries = [{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": "did:plc:" + "2"*32,
"issued_by": "did:plc:" + "1"*32, "token_hash": h,
"expires_ts": "2999-01-01T00:00:00Z"}}]
self_did = "did:plc:" + "1"*32
s = await wsserver.authorize(tok, entries, self_did, now_ts="2026-07-25T00:00:00Z")
assert s["session_id"] == "s1"
@pytest.mark.asyncio
async def test_authorize_rejects_wrong_token():
tok, h = token.mint()
entries = [{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": "did:plc:" + "2"*32,
"issued_by": "did:plc:" + "1"*32, "token_hash": h,
"expires_ts": "2999-01-01T00:00:00Z"}}]
with pytest.raises(wsserver.AuthError):
await wsserver.authorize("bogus", entries, "did:plc:" + "1"*32,
now_ts="2026-07-25T00:00:00Z")
@pytest.mark.asyncio
async def test_authorize_rejects_multiple_active_sessions_as_autherror():
tok1, h1 = token.mint()
tok2, h2 = token.mint()
self_did = "did:plc:" + "1"*32
entries = [
{"op": "assist_session_open", "payload": {
"session_id": "s1", "req_id": "r1", "center_did": "did:plc:" + "2"*32,
"issued_by": self_did, "token_hash": h1,
"expires_ts": "2999-01-01T00:00:00Z"}},
{"op": "assist_session_open", "payload": {
"session_id": "s2", "req_id": "r2", "center_did": "did:plc:" + "3"*32,
"issued_by": self_did, "token_hash": h2,
"expires_ts": "2999-01-01T00:00:00Z"}},
]
with pytest.raises(wsserver.AuthError):
await wsserver.authorize(tok1, entries, self_did,
now_ts="2026-07-25T00:00:00Z")

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox — Assistance</title>
<link rel="stylesheet" href="/shared/hybrid-skin.css">
<style>
body { font-family: 'Courier Prime', monospace; background:#0d1117; color:#e8e6d9; display:flex; }
.main { flex:1; margin-left:220px; padding:1.5rem; }
.card { background:rgba(30,40,55,.8); border:1px solid rgba(100,150,200,.2); border-radius:8px; padding:1rem; margin-bottom:1rem; }
.btn { padding:.4rem .8rem; border-radius:6px; border:1px solid rgba(100,150,200,.2); background:transparent; color:#e8e6d9; cursor:pointer; }
.btn.danger { border-color:#ff4466; color:#ff4466; }
.btn.warn { border-color:#ffcc00; color:#ffcc00; }
input,select { background:#111a24; color:#e8e6d9; border:1px solid rgba(100,150,200,.2); padding:.35rem; border-radius:5px; }
#log { white-space:pre-wrap; font-size:.8rem; max-height:40vh; overflow:auto; }
</style></head>
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<div class="main">
<h1 style="color:#00d4ff">🆘 Assistance</h1>
<div class="card" id="active-card"><h2>Session active</h2><div id="active"></div>
<button class="btn danger" data-act="close">Kill session</button>
<button class="btn warn" data-act="console-grant" disabled title="canal pty en cours">Escalade console (à venir)</button>
<button class="btn" data-act="console-revoke" disabled title="canal pty en cours">Révoquer console (à venir)</button>
</div>
<div class="card"><h2>Demander de l'assistance</h2>
<div><input id="center" placeholder="did:plc:…" size="48"></div>
<div><select id="mode"><option>per-incident</option><option>standing</option></select>
<input id="scope" placeholder="scope (ex. dns)"> <input id="dur" type="number" value="1800">
<input id="reason" placeholder="motif"></div>
<button class="btn" data-act="request">Demander</button>
</div>
<div class="card"><h2>Journal de session</h2><div id="log"></div></div>
</div>
<script src="/shared/sidebar.js"></script>
<script>
const T = () => localStorage.getItem('sbx_token') || '';
const H = () => ({'Authorization':'Bearer '+T(),'Content-Type':'application/json'});
async function refresh() {
const r = await fetch('/api/v1/assist/sessions', {headers:H()});
if (!r.ok) { document.getElementById('active').textContent = 'auth requise'; return; }
const d = await r.json();
document.getElementById('active').textContent =
d.active_session ? JSON.stringify(d.active_session) : 'aucune';
document.getElementById('log').textContent = JSON.stringify(d.pending, null, 2);
}
document.addEventListener('click', async (ev) => {
const b = ev.target.closest('[data-act]'); if (!b) return;
const act = b.dataset.act;
if (act === 'request') {
await fetch('/api/v1/assist/request', {method:'POST', headers:H(), body: JSON.stringify({
center_did: document.getElementById('center').value,
mode: document.getElementById('mode').value,
scope: document.getElementById('scope').value,
duration_s: parseInt(document.getElementById('dur').value, 10),
reason: document.getElementById('reason').value })});
} else {
const sid = (await (await fetch('/api/v1/assist/sessions', {headers:H()})).json())
.active_session?.session_id;
if (!sid && act !== 'request') return;
const ep = {'close':'/api/v1/assist/close','console-grant':'/api/v1/assist/console/grant',
'console-revoke':'/api/v1/assist/console/revoke'}[act];
await fetch(ep, {method:'POST', headers:H(), body: JSON.stringify({session_id: sid})});
}
refresh();
});
refresh(); setInterval(refresh, 5000);
</script>
</body></html>