mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-30 00:19:30 +00:00
Merge pull request #906 from CyberMind-FR/feat/assist-request
Some checks are pending
License Headers / check (push) Waiting to run
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:
commit
6b8d652a09
2490
docs/superpowers/plans/2026-07-25-assist-request.md
Normal file
2490
docs/superpowers/plans/2026-07-25-assist-request.md
Normal file
File diff suppressed because it is too large
Load Diff
160
docs/superpowers/specs/2026-07-25-assist-request-design.md
Normal file
160
docs/superpowers/specs/2026-07-25-assist-request-design.md
Normal 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.
|
||||
122
packages/secubox-annuaire/annuaire/assist.py
Normal file
122
packages/secubox-annuaire/annuaire/assist.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
packages/secubox-annuaire/tests/test_assist_model.py
Normal file
51
packages/secubox-annuaire/tests/test_assist_model.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
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)
|
||||
166
packages/secubox-annuaire/tests/test_assist_resolve.py
Normal file
166
packages/secubox-annuaire/tests/test_assist_resolve.py
Normal 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")
|
||||
59
packages/secubox-annuaire/tests/test_assist_verbs.py
Normal file
59
packages/secubox-annuaire/tests/test_assist_verbs.py
Normal 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"
|
||||
0
packages/secubox-assist/api/__init__.py
Normal file
0
packages/secubox-assist/api/__init__.py
Normal file
133
packages/secubox-assist/api/main.py
Normal file
133
packages/secubox-assist/api/main.py
Normal 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)
|
||||
0
packages/secubox-assist/assist/__init__.py
Normal file
0
packages/secubox-assist/assist/__init__.py
Normal file
33
packages/secubox-assist/assist/audit.py
Normal file
33
packages/secubox-assist/assist/audit.py
Normal 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 (request→accept→open→each action→console→keystrokes→close).
|
||||
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())
|
||||
87
packages/secubox-assist/assist/catalog.py
Normal file
87
packages/secubox-assist/assist/catalog.py
Normal 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"},
|
||||
}
|
||||
70
packages/secubox-assist/assist/console.py
Normal file
70
packages/secubox-assist/assist/console.py
Normal 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
|
||||
108
packages/secubox-assist/assist/daemon.py
Normal file
108
packages/secubox-assist/assist/daemon.py
Normal 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()
|
||||
61
packages/secubox-assist/assist/diag.py
Normal file
61
packages/secubox-assist/assist/diag.py
Normal 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"},
|
||||
}
|
||||
32
packages/secubox-assist/assist/token.py
Normal file
32
packages/secubox-assist/assist/token.py
Normal 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)
|
||||
85
packages/secubox-assist/assist/wsserver.py
Normal file
85
packages/secubox-assist/assist/wsserver.py
Normal 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)}
|
||||
4
packages/secubox-assist/conftest.py
Normal file
4
packages/secubox-assist/conftest.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
31
packages/secubox-assist/debian/changelog
Normal file
31
packages/secubox-assist/debian/changelog
Normal 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
|
||||
15
packages/secubox-assist/debian/control
Normal file
15
packages/secubox-assist/debian/control
Normal 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.
|
||||
43
packages/secubox-assist/debian/postinst
Executable file
43
packages/secubox-assist/debian/postinst
Executable 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
|
||||
9
packages/secubox-assist/debian/prerm
Executable file
9
packages/secubox-assist/debian/prerm
Executable 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
|
||||
3
packages/secubox-assist/debian/rules
Executable file
3
packages/secubox-assist/debian/rules
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/make -f
|
||||
%:
|
||||
dh $@ --with python3
|
||||
9
packages/secubox-assist/debian/secubox-assist.install
Normal file
9
packages/secubox-assist/debian/secubox-assist.install
Normal 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/
|
||||
8
packages/secubox-assist/menu.d/580-assist.json
Normal file
8
packages/secubox-assist/menu.d/580-assist.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "assist",
|
||||
"name": "Assistance",
|
||||
"icon": "🆘",
|
||||
"path": "/assist/",
|
||||
"category": "mesh",
|
||||
"order": 580
|
||||
}
|
||||
20
packages/secubox-assist/nft/secubox-assist.nft
Normal file
20
packages/secubox-assist/nft/secubox-assist.nft
Normal 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
|
||||
}
|
||||
}
|
||||
15
packages/secubox-assist/nginx/assist.conf
Normal file
15
packages/secubox-assist/nginx/assist.conf
Normal 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;
|
||||
}
|
||||
3
packages/secubox-assist/pytest.ini
Normal file
3
packages/secubox-assist/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = ../secubox-annuaire
|
||||
366
packages/secubox-assist/sbin/secubox-assistctl
Executable file
366
packages/secubox-assist/sbin/secubox-assistctl
Executable 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())
|
||||
7
packages/secubox-assist/sudoers/secubox-assist
Normal file
7
packages/secubox-assist/sudoers/secubox-assist
Normal 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
|
||||
18
packages/secubox-assist/systemd/secubox-assist-api.service
Normal file
18
packages/secubox-assist/systemd/secubox-assist-api.service
Normal 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
|
||||
27
packages/secubox-assist/systemd/secubox-assist.service
Normal file
27
packages/secubox-assist/systemd/secubox-assist.service
Normal 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
|
||||
29
packages/secubox-assist/tests/test_api.py
Normal file
29
packages/secubox-assist/tests/test_api.py
Normal 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)
|
||||
66
packages/secubox-assist/tests/test_assistctl.py
Normal file
66
packages/secubox-assist/tests/test_assistctl.py
Normal 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
|
||||
19
packages/secubox-assist/tests/test_audit.py
Normal file
19
packages/secubox-assist/tests/test_audit.py
Normal 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"
|
||||
39
packages/secubox-assist/tests/test_catalog.py
Normal file
39
packages/secubox-assist/tests/test_catalog.py
Normal 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 /")
|
||||
28
packages/secubox-assist/tests/test_console.py
Normal file
28
packages/secubox-assist/tests/test_console.py
Normal 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")
|
||||
159
packages/secubox-assist/tests/test_daemon.py
Normal file
159
packages/secubox-assist/tests/test_daemon.py
Normal 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]
|
||||
131
packages/secubox-assist/tests/test_diag.py
Normal file
131
packages/secubox-assist/tests/test_diag.py
Normal 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
|
||||
26
packages/secubox-assist/tests/test_menu.py
Normal file
26
packages/secubox-assist/tests/test_menu.py
Normal 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
|
||||
126
packages/secubox-assist/tests/test_packaging.py
Normal file
126
packages/secubox-assist/tests/test_packaging.py
Normal 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
|
||||
22
packages/secubox-assist/tests/test_token.py
Normal file
22
packages/secubox-assist/tests/test_token.py
Normal 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]
|
||||
55
packages/secubox-assist/tests/test_wsserver_bind.py
Normal file
55
packages/secubox-assist/tests/test_wsserver_bind.py
Normal 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")
|
||||
68
packages/secubox-assist/www/assist/index.html
Normal file
68
packages/secubox-assist/www/assist/index.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user