From 7b86ea9ce55152715e5880dcbf8736b9dcaf7b33 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 21 Aug 2026 05:04:37 +0200 Subject: [PATCH] fix: harden DWH auth recovery guidance --- docs/install/dwh-auth-server.md | 158 ++++++++++++++++----- docs/operations/psd-dwh-auth-rollout.md | 10 +- docs/testing/dwh-auth-manual-acceptance.md | 10 +- scripts/test-verify-dwh-auth-docs.sh | 77 ++++++++++ scripts/verify-dwh-auth-docs.sh | 36 ++++- 5 files changed, 245 insertions(+), 46 deletions(-) diff --git a/docs/install/dwh-auth-server.md b/docs/install/dwh-auth-server.md index cded2651..9f5f40cb 100644 --- a/docs/install/dwh-auth-server.md +++ b/docs/install/dwh-auth-server.md @@ -84,7 +84,7 @@ pubblico, installazione e percorso. Il file di output non deve esistere. ```bash installation_id=psd-mac-primary description=operatore-mac-primario -key_output="/root/dwh-auth-provision/$installation_id-primary.key" +key_output=/root/dwh-auth-provision/psd-mac-primary.key sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key create \ --installation-id "$installation_id" \ --description "$description" \ @@ -120,11 +120,12 @@ La revoca non è annullabile e un ID revocato non si ricrea. ## Backup, rollback e disinstallazione -Prima di mutare, creare un archivio cifrato e protetto del registro e copie protette delle sole -configurazioni coinvolte. L'archivio contiene digest, quindi è riservato: evidenza ammessa solo -percorso, owner, mode, timestamp e checksum dell'archivio. Il rollback dual-key ripristina la route -e il servizio revisionati, esegue `nginx -t` e fa reload solo autorizzato; non ripristina chiavi -revocate, PostgreSQL, sessioni legacy, indici Qdrant o cache Ollama. +Prima di mutare, creare un archivio root-only `0600` del registro e copie protette delle sole +configurazioni coinvolte. L'archivio contiene digest, quindi è materiale riservato: custodirlo su +storage cifrato approvato; l'evidenza ammessa riporta solo percorso, owner, mode, timestamp e +checksum dell'archivio. Il rollback dual-key ripristina la route e il servizio revisionati, esegue +`nginx -t` e fa reload solo autorizzato; non ripristina chiavi revocate, PostgreSQL, sessioni +legacy, indici Qdrant o cache Ollama. La disinstallazione richiede autorizzazione esplicita, client REST migrati/revocati e rollback non più necessario. Solo allora disabilitare l'unità; conservare registro e backup fino alla retention @@ -132,11 +133,14 @@ approvata. Non inserire `dwh-auth` in Compose o in `tht start`/`tht stop`. ## Procedure riproducibili e secret-safe -Eseguire soltanto nel gate autorizzato. Le variabili seguenti contengono percorsi, timestamp e codici, mai una chiave. Il manifest e l'archivio del registro sono `0600`; l'archivio resta materiale riservato. +Eseguire soltanto nel gate autorizzato. Le variabili seguenti contengono percorsi, timestamp e +codici, mai una chiave. Il manifest e l'archivio del registro sono `0600`; l'archivio resta +materiale riservato su storage cifrato approvato. ```bash run_id=$(date -u +%Y%m%dT%H%M%SZ) backup_root=/root/dwh-auth-provision +registry_root=/var/lib/dwh-auth registry_backup="$backup_root/registry-$run_id.tar" manifest="$backup_root/registry-$run_id.manifest" sudo install -o root -g root -m 0600 /dev/null "$registry_backup" @@ -146,52 +150,111 @@ sudo sh -c 'sha256sum "$1" > "$2"' sh "$registry_backup" "$manifest" if sudo sha256sum -c "$manifest" >/dev/null; then printf 'registry_manifest=PASS\n'; else printf 'registry_manifest=FAIL\n' >&2; exit 1; fi ``` -Per ripristinare, fermare prima il servizio, verificare il manifest senza stamparne il contenuto, estrarre l'archivio solo nel root autorizzato, rieseguire `dwh-auth check` e avviare l'unità. Conservare archivio e manifest per la retention approvata; non sovrascrivere né cancellare record per correggere un errore. +Il ripristino non sovrappone mai un tar al registro attivo. Estrarre prima in staging nello stesso +filesystem di `/var/lib`, verificare il candidato, rinominare il registro attuale in una copia +recuperabile e sostituirlo. Non cancellare il pre-ripristino: serve al rollback se `check` o +l'avvio falliscono. ```bash +registry_staging="/var/lib/.dwh-auth-restore-$run_id" +registry_candidate="$registry_staging/dwh-auth" +registry_previous="/var/lib/dwh-auth.pre-restore-$run_id" +if [ -e "$registry_staging" ] || [ -e "$registry_previous" ]; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi if ! sudo sha256sum -c "$manifest" >/dev/null; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi -sudo systemctl stop dwh-auth -sudo tar --acls --xattrs -C /var/lib -xf "$registry_backup" -sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth check -sudo systemctl start dwh-auth -printf 'registry_restore=PASS\n' +if ! sudo install -d -o root -g root -m 0700 "$registry_staging"; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi +if ! sudo tar --acls --xattrs -C "$registry_staging" -xf "$registry_backup"; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi +if ! sudo /usr/local/sbin/dwh-auth --registry-root "$registry_candidate" check; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi +if ! sudo systemctl stop dwh-auth; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi +if ! sudo mv -T -- "$registry_root" "$registry_previous"; then + if sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi + exit 1 +fi +if ! sudo mv -T -- "$registry_candidate" "$registry_root"; then + if ! sudo mv -T -- "$registry_previous" "$registry_root"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi + if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi + exit 1 +fi +if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then + printf 'registry_restore=PASS\n' +else + sudo systemctl stop dwh-auth || true + if ! sudo mv -T -- "$registry_root" "$registry_staging/failed-dwh-auth"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi + if ! sudo mv -T -- "$registry_previous" "$registry_root"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi + if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi + exit 1 +fi ``` -Per i test socket, creare la configurazione curl `0600` leggendo il file chiave direttamente nel file di configurazione: il valore non passa in argv, ambiente o stdout. +Per le prove, creare file header `0600` che contengono esattamente `X-API-Key: valore`. Il valore +passa dal file chiave al file header senza argv, ambiente o stdout. I file header sono materiale +segreto con la stessa custodia e retention delle chiavi. ```bash -key_file=/root/dwh-auth-provision/psd-mac-primary.key -curl_cfg=/root/dwh-auth-provision/dwh-auth-new.curl -random_cfg=/root/dwh-auth-provision/dwh-auth-random.curl -sudo install -o root -g root -m 0600 /dev/null "$curl_cfg" -sudo install -o root -g root -m 0600 /dev/null "$random_cfg" -sudo sh -c '{ printf "%s" "header = X-API-Key: "; tr -d "\r\n" < "$1"; printf "\n"; } > "$2"' sh "$key_file" "$curl_cfg" -sudo sh -c 'printf "%s\n" "header = X-API-Key: invalid-test" > "$1"' sh "$random_cfg" +v1_key_file="$key_output" +legacy_key_file=/root/dwh-auth-provision/legacy-shared.key +v1_header_file=/root/dwh-auth-provision/dwh-auth-v1.header +legacy_header_file=/root/dwh-auth-provision/dwh-auth-legacy.header +random_header_file=/root/dwh-auth-provision/dwh-auth-random.header +if ! sudo python3 -c ' +import pathlib, sys +if any(b"\n" in pathlib.Path(path).read_bytes() for path in sys.argv[1:]): + raise SystemExit(1) +' "$v1_key_file" "$legacy_key_file"; then + printf 'key_file_bytes=FAIL\n' >&2 + exit 1 +fi +printf 'key_file_bytes=PASS\n' +for header_file in "$v1_header_file" "$legacy_header_file" "$random_header_file"; do + sudo install -o root -g root -m 0600 /dev/null "$header_file" +done +sudo sh -c '{ printf "%s" "X-API-Key: "; dd if="$1" bs=65536 status=none; printf "\n"; } > "$2"' sh "$v1_key_file" "$v1_header_file" +sudo sh -c '{ printf "%s" "X-API-Key: "; dd if="$1" bs=65536 status=none; printf "\n"; } > "$2"' sh "$legacy_key_file" "$legacy_header_file" +sudo sh -c 'printf "%s\n" "X-API-Key: invalid-test" > "$1"' sh "$random_header_file" ``` -Il socket `/verify` deve restituire 204 per il file nuovo e 401 per configurazione casuale e richiesta senza header; stampare soltanto il codice. +Il socket `/verify` deve restituire 204 per v1 e legacy durante il dual-key, 401 per file casuale +e richiesta senza header. Stampare solo PASS/FAIL. ```bash -status=$(sudo curl --config "$curl_cfg" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) -[ "$status" = 204 ] && printf 'socket_new=PASS\n' || { printf 'socket_new=FAIL\n' >&2; exit 1; } -status=$(sudo curl --config "$random_cfg" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) +status=$(sudo curl --header "@$v1_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) +[ "$status" = 204 ] && printf 'socket_v1=PASS\n' || { printf 'socket_v1=FAIL\n' >&2; exit 1; } +status=$(sudo curl --header "@$legacy_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) +[ "$status" = 204 ] && printf 'socket_legacy=PASS\n' || { printf 'socket_legacy=FAIL\n' >&2; exit 1; } +status=$(sudo curl --header "@$random_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) [ "$status" = 401 ] && printf 'socket_random=PASS\n' || { printf 'socket_random=FAIL\n' >&2; exit 1; } status=$(sudo curl --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify) [ "$status" = 401 ] && printf 'socket_missing=PASS\n' || { printf 'socket_missing=FAIL\n' >&2; exit 1; } ``` -Per HTTPS reale usare la configurazione protetta e la CA approvata contro `/dwh/rpc/ping`: PostgREST può restituire qualsiasi 2xx, non si pretende 204. La prova 401 usa solo configurazione casuale protetta. +Per HTTPS reale usare i file header protetti e la CA approvata contro `/dwh/rpc/ping`: PostgREST +può restituire qualsiasi 2xx, non si pretende 204. Prima della revoca, v1 e legacy devono dare +2xx; il file casuale deve dare 401. ```bash ping_url=https://supabase-aritmolab.policlinicosandonato.it/dwh/rpc/ping ca_file=/root/dwh-auth-provision/psd-dwh-ca.pem -status=$(sudo curl --config "$curl_cfg" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") -case "$status" in 2??) printf 'https_new=PASS\n' ;; *) printf 'https_new=FAIL\n' >&2; exit 1 ;; esac -status=$(sudo curl --config "$random_cfg" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") +status=$(sudo curl --header "@$v1_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") +case "$status" in 2??) printf 'https_v1_pre_revoke=PASS\n' ;; *) printf 'https_v1_pre_revoke=FAIL\n' >&2; exit 1 ;; esac +status=$(sudo curl --header "@$legacy_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") +case "$status" in 2??) printf 'https_legacy_pre_revoke=PASS\n' ;; *) printf 'https_legacy_pre_revoke=FAIL\n' >&2; exit 1 ;; esac +status=$(sudo curl --header "@$random_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") [ "$status" = 401 ] && printf 'https_random=PASS\n' || { printf 'https_random=FAIL\n' >&2; exit 1; } ``` -Per provare 503 in una finestra approvata, registrare l'orario, fermare temporaneamente l'unità, eseguire il ping con timeout e trap di ripristino; il comando deve stampare solo PASS/FAIL. +Dopo l'osservazione, revocare solo la legacy usando il suo ID pubblico già registrato. Dopo la +revoca v1 resta 2xx e legacy diventa 401 anche via HTTPS. + +```bash +legacy_key_id=legacy-shared +sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" key revoke --key-id "$legacy_key_id" --reason shared-credential-rotation +status=$(sudo curl --header "@$v1_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") +case "$status" in 2??) printf 'https_v1_post_revoke=PASS\n' ;; *) printf 'https_v1_post_revoke=FAIL\n' >&2; exit 1 ;; esac +status=$(sudo curl --header "@$legacy_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url") +[ "$status" = 401 ] && printf 'https_legacy_post_revoke=PASS\n' || { printf 'https_legacy_post_revoke=FAIL\n' >&2; exit 1; } +``` + +Per provare 503 in una finestra approvata, registrare l'orario, fermare temporaneamente l'unità, +eseguire il ping con timeout e trap di ripristino; il comando deve stampare solo PASS/FAIL. ```bash was_active=$(sudo systemctl is-active dwh-auth || true) @@ -199,20 +262,47 @@ was_active=$(sudo systemctl is-active dwh-auth || true) restore_auth() { sudo systemctl start dwh-auth; } trap restore_auth EXIT INT TERM sudo systemctl stop dwh-auth -status=$(sudo curl --config "$random_cfg" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url" || true) +status=$(sudo curl --header "@$random_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url" || true) [ "$status" = 503 ] && printf 'https_auth_down=PASS\n' || { printf 'https_auth_down=FAIL\n' >&2; exit 1; } sudo systemctl start dwh-auth trap - EXIT INT TERM ``` -Lo scan journal non salva righe grezze: emette solo PASS/FAIL. +Lo scan journal non salva righe grezze: controlla davvero le chiavi v1 e legacy leggendo solo i +percorsi dei file da argv, e conserva anche la difesa generica per prefisso e digest. Il filtro +emette solo PASS/FAIL. ```bash since=$(date -u -d '15 minutes ago' +%Y-%m-%dT%H:%M:%SZ) -if sudo journalctl -u dwh-auth --since "$since" --no-pager --output=cat | grep -E 'thtdwh_v1|secret_sha256' >/dev/null; then printf 'journal_secret_scan=FAIL\n' >&2; exit 1; else printf 'journal_secret_scan=PASS\n'; fi +if sudo python3 -c ' +import pathlib, subprocess, sys +try: + actual_keys = {pathlib.Path(path).read_bytes() for path in sys.argv[2:]} + result = subprocess.run( + ["journalctl", "-u", "dwh-auth", "--since", sys.argv[1], "--no-pager", "--output=cat"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) +except OSError: + raise SystemExit(2) +if result.returncode != 0: + raise SystemExit(2) +for line in result.stdout.splitlines(keepends=True): + if b"thtdwh_v1" in line or b"secret_sha256" in line or any(key in line for key in actual_keys): + raise SystemExit(1) +' "$since" "$v1_key_file" "$legacy_key_file"; then + printf 'journal_actual_key_scan=PASS\n' +else + printf 'journal_actual_key_scan=FAIL\n' >&2 + exit 1 +fi ``` -Dopo rollback verificato e migrazione/revoca di ogni client REST, la disinstallazione resta condizionata all'approvazione: eseguire `sudo systemctl disable --now dwh-auth`, ma mantenere registro, backup e manifest protetti per la retention; non cancellarli durante il rollback. +Dopo rollback verificato e migrazione/revoca di ogni client REST, la disinstallazione resta +condizionata all'approvazione: eseguire `sudo systemctl disable --now dwh-auth`, ma mantenere +registro, backup, manifest e file header protetti per la retention; non cancellarli durante il +rollback. ## Troubleshooting diff --git a/docs/operations/psd-dwh-auth-rollout.md b/docs/operations/psd-dwh-auth-rollout.md index 22e1c2fd..480ae90e 100644 --- a/docs/operations/psd-dwh-auth-rollout.md +++ b/docs/operations/psd-dwh-auth-rollout.md @@ -18,22 +18,22 @@ Richiedere prima autorizzazione per SHA congelato, target, rollback e impatto le 2. Costruire con `bash scripts/build-dwh-auth.sh --output /tmp/dwh-auth-release`; registrare solo SHA sorgente e checksum binario. 3. Installare binario/unit/tmpfiles come nella [guida server](../install/dwh-auth-server.md): registry `root:dwh-auth` `2750`, lock/record `0640`, socket `dwh-auth:www-data` `0660`. 4. Importare una sola legacy `legacy-shared` dal file protetto e creare `psd-mac-primary` in nuovo file `0600` sotto `/root/dwh-auth-provision/`; mai segreti in argv, ambiente, log o evidenze. -5. Eseguire `dwh-auth check`, `systemd-analyze verify`, avviare l'unità e testare sul socket Unix con config curl protette `0600`: nuova=204, legacy=204, casuale=401, assente=401. +5. Eseguire `dwh-auth check`, `systemd-analyze verify`, avviare l'unità e testare sul socket Unix con file header curl protetti `0600`: v1=204, legacy=204, casuale=401, assente=401. 6. Salvare solo ID pubblici, owner/mode, stato unit/socket, timestamp, checksum binario/config e rollback. Non modificare Nginx in questo gate. ## Gate B — Task 10, Nginx e client -Serve un secondo consenso: presentare file, backup, canale consegna Mac, osservazione ed esiti 204/401/503. +Serve un secondo consenso: presentare file, backup, canale consegna Mac, osservazione ed esiti 2xx/401/503. 1. Creare copie timestampate `root:root` `0600` di `/etc/nginx/sites-available/policlinicosandonato` e file coinvolti; non allegare configurazioni Nginx grezze alle evidenze. 2. Aggiungere solo `/etc/nginx/conf.d/dwh-auth-rate-limit.conf` e route DWH; preservare upstream `http://127.0.0.1:3001/`, mantenere byte-identiche le location vector e rimuovere la chiave prima di PostgREST. 3. Eseguire checker strutturale, scansione segreti con solo `PASS/FAIL` e metadati, installare candidati e `sudo nginx -t`. No raw diff: non eseguire o conservare raw diff, `nginx -T` o dump: il file legacy può contenere la chiave. Se uno fallisce, ripristinare backup prima di reload e registrare FAIL sanitizzato. -4. Dopo consenso fare reload, poi HTTPS `.it` con CA e config curl protette: legacy=2xx, nuova=2xx, casuale=401, assente=401 su `/dwh/rpc/ping`; guasto autenticatore=503, mai accesso permissivo. +4. Dopo consenso fare reload, poi HTTPS `.it` con CA e file header curl protetti 0600: v1=2xx, legacy=2xx, casuale=401, assente=401 su `/dwh/rpc/ping`; guasto autenticatore=503, mai accesso permissivo. 5. Consegnare al Mac chiave e CA separatamente, verificare fingerprint fuori banda, configurare vault GUI o `API_KEY_FILE`, poi **Validate workspace source** e **Test workspace connections**. -6. Dopo osservazione revocare `legacy-shared` con ragione `shared-credential-rotation`; nuova=204, legacy=401 e journal limitato senza chiavi/digest. +6. Dopo osservazione revocare `legacy-shared` con ragione `shared-credential-rotation`; v1=2xx post-revoca, legacy=401 post-revoca e journal limitato senza chiavi/digest. ## Rollback e chiusura Durante dual-key il rollback ripristina solo route/servizio revisionati, verifica `nginx -t` e fa reload autorizzato. Non ripristina chiavi revocate, PostgreSQL, dati legacy o stack. Scatta per TLS, risposte inattese, salute degradata o assenza di consenso. -Activity 1 diventa `PASS` solo con nuova positiva, legacy 401, servizio/Nginx validi, log sanitizzati, rollback leggibile e accettazione owner; poi avanza a Activity 2, con programma ancora `SURVEY_NO_GO`. Compilare [evidenza](../testing/evidence/psd-dwh-auth-rollout-report-template.md) e [collaudo](../testing/dwh-auth-manual-acceptance.md). +Activity 1 diventa `PASS` solo con v1=2xx post-revoca, legacy=401 post-revoca, servizio/Nginx validi, log sanitizzati, rollback leggibile e accettazione owner; poi avanza a Activity 2, con programma ancora `SURVEY_NO_GO`. Compilare [evidenza](../testing/evidence/psd-dwh-auth-rollout-report-template.md) e [collaudo](../testing/dwh-auth-manual-acceptance.md). diff --git a/docs/testing/dwh-auth-manual-acceptance.md b/docs/testing/dwh-auth-manual-acceptance.md index ccb70a68..2a26e8ec 100644 --- a/docs/testing/dwh-auth-manual-acceptance.md +++ b/docs/testing/dwh-auth-manual-acceptance.md @@ -16,12 +16,12 @@ clinici. | Caso | Azione autorizzata | Atteso | Evidenza ammessa | | --- | --- | --- | --- | | Registro | `check`, `key list`, `key status` | Stato e soli ID pubblici | ID, status, owner/mode, timestamp | -| Socket locale | Config curl protetta, nuova/legacy | `204` durante dual-key | codice, unit/socket status | -| Negativo locale | Config casuale e richiesta senza header | `401` | codice, nessun valore header | +| Socket locale | File header protetti, v1/legacy | `204` v1 e legacy durante dual-key | codice, unit/socket status | +| Negativo locale | File header casuale e richiesta senza header | `401` | codice, nessun valore header | | Guasto controllato | Autenticatore/registro non disponibili nel test approvato | `503`, mai accesso | codice e rollback | -| HTTPS reale | `/dwh/rpc/ping` con CA approvata | qualsiasi `2xx`, TLS valido | ID, esito e approvazione fingerprint | +| HTTPS dual-key | `/dwh/rpc/ping` con CA approvata, file header v1/legacy | v1 e legacy qualsiasi `2xx`, TLS valido | ID, esito e approvazione fingerprint | | Mac | **Validate workspace source**, **Test workspace connections** | Ping positivo | timestamp e stato GUI | -| Revoca | Chiave precedente dopo osservazione | `401`; nuova ancora positiva | ID pubblico e codici | +| Revoca | Chiave legacy dopo osservazione | v1 qualsiasi `2xx`; legacy `401` post-revoca | ID pubblico e codici | | Trasporti | Server PSD diretto e SSH diagnostico | nessuna chiave `dwh-auth` | trasporto selezionato | ## Sequenza @@ -30,7 +30,7 @@ clinici. stack. Nessun reload Nginx. 2. Al Gate B, fare backup protetti, `nginx -t`, reload autorizzato e ping `.it` con CA verificata. 3. Configurare il Mac nel vault GUI o con `API_KEY_FILE`; verificare ping e ID pubblico. -4. Dopo la finestra approvata, revocare legacy, ripetere nuova positiva/legacy 401 e controllare +4. Dopo la finestra approvata, revocare legacy, ripetere v1 2xx/legacy 401 post-revoca e controllare solo un journal bounded sanitizzato. 5. Verificare rollback: backup leggibili, scope limitato a route/unit; nessuna migrazione di sessioni legacy, indici Qdrant o cache Ollama. diff --git a/scripts/test-verify-dwh-auth-docs.sh b/scripts/test-verify-dwh-auth-docs.sh index 589d69dc..d18eb60c 100755 --- a/scripts/test-verify-dwh-auth-docs.sh +++ b/scripts/test-verify-dwh-auth-docs.sh @@ -61,6 +61,57 @@ expect_rejected() { report_pass "$name" } +exercise_header_file_curl() { + local v1_header="$temp_root/synthetic-v1.header" + local legacy_header="$temp_root/synthetic-legacy.header" + command -v curl >/dev/null 2>&1 || report_fail header_file_curl_command + printf '%s\n' 'X-API-Key: synthetic-v1' >"$v1_header" + printf '%s\n' 'X-API-Key: legacy opaque value /:[]' >"$legacy_header" + chmod 0600 "$v1_header" "$legacy_header" + if ! python3 - "$v1_header" "$legacy_header" <<'PY' +import http.server +import pathlib +import stat +import subprocess +import sys +import threading + +accepted = {"synthetic-v1", "legacy opaque value /:[]"} + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + def do_GET(self): + self.send_response(204 if self.headers.get("X-API-Key") in accepted else 401) + self.end_headers() + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + for header_file in sys.argv[1:]: + if stat.S_IMODE(pathlib.Path(header_file).stat().st_mode) != 0o600: + raise SystemExit(1) + result = subprocess.run( + ["curl", "--silent", "--show-error", "--output", "/dev/null", "--write-out", "%{http_code}", "--header", f"@{header_file}", f"http://127.0.0.1:{server.server_port}/rpc/ping"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0 or result.stdout != "204": + raise SystemExit(1) +finally: + server.shutdown() + thread.join() +PY + then + report_fail header_file_curl_synthetic + fi + report_pass header_file_curl_synthetic +} + # Build synthetic only-in-fixture text at runtime: it is never a provisioned credential. fake_key="thtdwh_v1.$(printf 'A%.0s' {1..16}).$(printf 'A%.0s' {1..43})" fake_digest="$(printf 'A%.0s' {1..43})" @@ -72,6 +123,7 @@ expect_rejected curl_insecure docs/install/dwh-auth-tls.md 'curl -k https://exam expect_rejected tls_disabled docs/install/dwh-auth-tls.md 'verify_tls=false' expect_rejected secret_in_environment docs/install/dwh-auth-client-enrollment.md "export THT_WS_PSD_CLINICAL_DWH_API_KEY=$fake_key" expect_rejected secret_in_argv docs/install/dwh-auth-client-enrollment.md "curl -H 'X-API-Key: $fake_key' https://example.invalid/dwh/rpc/ping" +expect_rejected curl_config_artifact docs/install/dwh-auth-server.md 'curl --config /root/dwh-auth-provision/request.conf https://example.invalid/dwh/rpc/ping' expect_rejected world_readable_secret docs/install/dwh-auth-server.md 'chmod 0644 /root/dwh-auth-provision/client.key' expect_rejected sudo_raw_nginx_capture docs/operations/psd-dwh-auth-rollout.md 'sudo nginx -T > /tmp/nginx-full.conf' expect_rejected raw_diff_capture docs/operations/psd-dwh-auth-rollout.md 'sudo diff -u /etc/nginx/sites-available/policlinicosandonato /root/backup.conf' @@ -100,6 +152,8 @@ expect_global_replacement_rejected() { report_pass "$name" } +exercise_header_file_curl + expect_replacement_rejected missing_exact_gui_label docs/install/dwh-auth-client-enrollment.md "Validate workspace source" "Validate workspace" expect_replacement_rejected server_transport_contradiction docs/operations/psd-dwh-auth-rollout.md 'server PSD: `postgres_direct` read-only' 'server PSD: `rest_api` read-only' expect_replacement_rejected missing_mac_local_marker deploy/psd/workspace-bindings.env.example "Mac/local/remota" "server PSD" @@ -107,4 +161,27 @@ expect_replacement_rejected missing_private_ca docs/install/psd-workspace-setup. expect_global_replacement_rejected missing_socket_path docs/install/dwh-auth-server.md "/run/dwh-auth/verify.sock" "/run/dwh-auth/other.sock" expect_replacement_rejected rest_transport_flag deploy/psd/workspace-bindings.env.example "THT_WS_PSD_CLINICAL_DWH_TRANSPORT=rest_api" "THT_WS_PSD_CLINICAL_DWH_TRANSPORT=postgres_direct" +expect_replacement_rejected wrong_key_output_path docs/install/dwh-auth-server.md "key_output=/root/dwh-auth-provision/psd-mac-primary.key" "key_output=/root/dwh-auth-provision/psd-mac-primary-primary.key" +expect_replacement_rejected wrong_legacy_key_id docs/install/dwh-auth-server.md "legacy_key_id=legacy-shared" "legacy_key_id=legacy-public-key-id" +expect_global_replacement_rejected missing_header_file_transport docs/install/dwh-auth-server.md '--header "@' '--header "X-API-Key: ' +expect_global_replacement_rejected missing_legacy_header_file docs/install/dwh-auth-server.md "legacy_header_file" "retired_auth_header" +expect_global_replacement_rejected missing_safe_restore docs/install/dwh-auth-server.md "registry_staging" "registry_overlay" +expect_replacement_rejected unsafe_restore_overlay docs/install/dwh-auth-server.md "sudo tar --acls --xattrs -C \"\$registry_staging\" -xf \"\$registry_backup\"" "sudo tar --acls --xattrs -C /var/lib -xf \"\$registry_backup\"" +expect_replacement_rejected missing_guarded_registry_publish docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_previous\"; then" "sudo mv -T -- \"\$registry_root\" \"\$registry_previous\"" +expect_replacement_rejected missing_candidate_check_guard docs/install/dwh-auth-server.md "if ! sudo /usr/local/sbin/dwh-auth --registry-root \"\$registry_candidate\" check; then" "sudo /usr/local/sbin/dwh-auth --registry-root \"\$registry_candidate\" check" +expect_replacement_rejected unsafe_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_previous\"; then" "if ! sudo mv \"\$registry_root\" \"\$registry_previous\"; then" +expect_replacement_rejected missing_key_file_byte_guard docs/install/dwh-auth-server.md "key_file_bytes=PASS" "key_file_bytes=SKIPPED" +expect_replacement_rejected missing_failed_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_staging/failed-dwh-auth\"; then" "if ! sudo mv \"\$registry_root\" \"\$registry_staging/failed-dwh-auth\"; then" +expect_global_replacement_rejected missing_rollback_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_previous\" \"\$registry_root\"; then" "if ! sudo mv \"\$registry_previous\" \"\$registry_root\"; then" +expect_global_replacement_rejected missing_exact_header_bytes docs/install/dwh-auth-server.md 'dd if="$1" bs=65536 status=none' 'dd if="$1" bs=1' + + +expect_replacement_rejected missing_actual_key_journal_scan docs/install/dwh-auth-server.md "journal_actual_key_scan=PASS" "journal_generic_scan=PASS" +expect_replacement_rejected missing_journal_actual_key_match docs/install/dwh-auth-server.md "any(key in line for key in actual_keys)" "False" +expect_replacement_rejected missing_journal_returncode_guard docs/install/dwh-auth-server.md "if result.returncode != 0:" "if result.returncode == 0:" +expect_replacement_rejected missing_socket_legacy_probe docs/install/dwh-auth-server.md "socket_legacy=PASS" "socket_legacy=SKIPPED" +expect_replacement_rejected missing_https_post_revoke_v1 docs/install/dwh-auth-server.md "https_v1_post_revoke=PASS" "https_v1_post_revoke=SKIPPED" +expect_replacement_rejected missing_https_post_revoke_legacy docs/install/dwh-auth-server.md "https_legacy_post_revoke=PASS" "https_legacy_post_revoke=SKIPPED" +expect_global_replacement_rejected rollout_post_revoke_status docs/operations/psd-dwh-auth-rollout.md "v1=2xx post-revoca" "v1=204 post-revoca" + report_pass summary diff --git a/scripts/verify-dwh-auth-docs.sh b/scripts/verify-dwh-auth-docs.sh index f5cd2296..48a9ddf2 100755 --- a/scripts/verify-dwh-auth-docs.sh +++ b/scripts/verify-dwh-auth-docs.sh @@ -34,10 +34,10 @@ for label, relative in docs.items(): text[label] = path.read_text(encoding="utf-8") requirements = { - "server": ["manifest", "curl_cfg", "journalctl", "systemctl disable --now", "trap", "/var/lib/dwh-auth", "root:dwh-auth", "2750", ".writer.lock", "0640", "/run/dwh-auth/verify.sock", "0660", "systemd", "key create", "key list", "key status", "key revoke", "check", "backup", "rollback", "disinstallazione", "rest_api", "postgres_direct", "ssh_tunnel"], + "server": ["key_output=/root/dwh-auth-provision/psd-mac-primary.key", "legacy_key_id=legacy-shared", "v1_header_file", "legacy_header_file", "random_header_file", "--header \"@", "registry_staging", "registry_previous", "registry_restore_rollback", "journal_actual_key_scan=PASS", "sys.argv[2:]", "read_bytes()", "subprocess.run", "stderr=subprocess.DEVNULL", "returncode != 0", "socket_v1=PASS", "socket_legacy=PASS", "https_v1_post_revoke=PASS", "https_legacy_post_revoke=PASS", "key_file_bytes=PASS", "dd if=\"$1\" bs=65536 status=none", "manifest", "journalctl", "systemctl disable --now", "trap", "/var/lib/dwh-auth", "root:dwh-auth", "2750", ".writer.lock", "0640", "/run/dwh-auth/verify.sock", "0660", "systemd", "key create", "key list", "key status", "key revoke", "check", "backup", "rollback", "disinstallazione", "rest_api", "postgres_direct", "ssh_tunnel"], "client": ["Workspace management", "Validate workspace source", "Test workspace connections", "Save entered secrets", "Forget stored value", "API_KEY_FILE", "THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE", "TLS_CA_FILE", "/rpc/ping", "rest_api", "postgres_direct", "ssh_tunnel", "401", "503", "rotazione", "revoca"], "tls": ["self-issued", ".it", ".com", "SAN", "TLS_CA_FILE", "openssl x509 -noout -fingerprint -sha256", "fuori banda", "rinnovo", "curl -k"], - "rollout": ["PASS/FAIL", "no raw diff", "Task 9", "Task 10", "IN_DISCUSSION", "postgres_direct", "rest_api", "legacy-shared", "nginx -t", "204", "401", "503", "Qdrant", "Ollama", "rollback"], + "rollout": ["v1=2xx", "legacy=2xx", "v1=2xx post-revoca", "legacy=401 post-revoca", "file header curl protetti 0600", "PASS/FAIL", "no raw diff", "Task 9", "Task 10", "IN_DISCUSSION", "postgres_direct", "rest_api", "legacy-shared", "nginx -t", "204", "401", "503", "Qdrant", "Ollama", "rollback"], "manual": ["credenziali reali", "sintetici", "/rpc/ping", "204", "401", "503", "TLS", "registry", "postgres_direct", "ssh_tunnel"], "evidence": ["ID pubblici", "owner", "mode", "timestamp", "checksum", "approvazione"], } @@ -72,6 +72,7 @@ for pattern, label in [ (r"(?i)chmod\s+0?[0-7][0-7][4-7]\s+[^\n]*(?:\.key|secret|provision)", "world-readable secret"), (r"(?m)^\s*(?:sudo\s+)?nginx\s+-T\b", "raw Nginx capture"), (r"(?m)^\s*(?:sudo\s+)?(?:diff\b|git\s+diff\b)", "raw diff capture"), + (r"(?m)^\s*(?:sudo\s+)?curl[^\n]*--config\b", "curl config artifact"), (r"(?i)docker\s+compose[^\n]*\bdwh-auth\b", "Compose coupling"), ]: if re.search(pattern, corpus): @@ -87,5 +88,36 @@ if "tht_ws_psd_clinical_dwh_transport=rest_api" not in template.lower() or "mac/ if "TLS_CA_FILE" not in text["psd"] or re.search(r"(?i)nessuna CA|HTTPS pubblico", text["psd"]): raise SystemExit("dwh-auth docs: PSD setup contradicts private CA TLS requirement") +restore_steps = ( + 'registry_staging="/var/lib/.dwh-auth-restore-$run_id"', + 'registry_candidate="$registry_staging/dwh-auth"', + 'sudo tar --acls --xattrs -C "$registry_staging" -xf "$registry_backup"', + 'if ! sudo /usr/local/sbin/dwh-auth --registry-root "$registry_candidate" check; then', + 'if ! sudo mv -T -- "$registry_root" "$registry_previous"; then', + 'if ! sudo mv -T -- "$registry_candidate" "$registry_root"; then', + 'if ! sudo mv -T -- "$registry_previous" "$registry_root"; then', + 'if ! sudo mv -T -- "$registry_root" "$registry_staging/failed-dwh-auth"; then', +) +restore_positions = [text["server"].find(step) for step in restore_steps] +if any(position < 0 for position in restore_positions) or restore_positions != sorted(restore_positions): + raise SystemExit("dwh-auth docs: restore must stage/check then use guarded same-filesystem renames") +if text["server"].count('if ! sudo mv -T -- "$registry_previous" "$registry_root"; then') != 2: + raise SystemExit("dwh-auth docs: every restore rollback rename must use guarded mv -T --") +if re.search(r"sudo tar[^\n]*-C /var/lib[^\n]*(?:-x|--extract)[^\n]*registry_backup", text["server"]): + raise SystemExit("dwh-auth docs: restore must not overlay the live registry") + + +journal_steps = ( + "subprocess.run(", + "[\"journalctl\", \"-u\", \"dwh-auth\", \"--since\", sys.argv[1]", + "sys.argv[2:]", + "stderr=subprocess.DEVNULL", + "if result.returncode != 0:", + "result.stdout.splitlines(keepends=True)", + "any(key in line for key in actual_keys)", +) +if any(step not in text["server"] for step in journal_steps): + raise SystemExit("dwh-auth docs: journal scan must fail closed and check the actual key bytes") + print("dwh-auth documentation contract passed") PY