diff --git a/ansible/roles/guacd/defaults/main.yml b/ansible/roles/guacd/defaults/main.yml index b5b2a48..b69abfa 100644 --- a/ansible/roles/guacd/defaults/main.yml +++ b/ansible/roles/guacd/defaults/main.yml @@ -66,3 +66,29 @@ guacd_tls_min_protocol: "TLSv1" guacd_tls_max_protocol: "" guacd_tls_security_level: 0 guacd_tls_unsafe_legacy_renegotiation: true +# Leer = OpenSSL-Vorgabe. Nur setzen, wenn das Ziel ein bestimmtes +# Signaturverfahren braucht, z.B.: +# "RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA256:RSA+SHA1:ECDSA+SHA1" +# Wirkt nur zusammen mit guacd_tls_security_level: 0 -- die +# Sicherheitsstufe filtert die Liste anschliessend nochmals. +guacd_tls_signature_algorithms: "" + +# OpenSSL-Sicherheitsstufe, die FreeRDP fuer RDP-Verbindungen verwenden soll. +# Leer = nicht eingreifen (FreeRDP-Vorgabe 1). +# +# Gemessen am 3.9. gegen 10.0.0.12: Stufe 2 UND Stufe 1 werden vom Ziel mit +# einem TCP-RST beantwortet, erst Stufe 0 kommt durch (TLS 1.2, +# ECDHE-RSA-AES256-SHA384). Die Cipher-Listen sind auf allen Stufen identisch; +# der Unterschied ist die signature_algorithms-Erweiterung, in der OpenSSL 3.0 +# ab Stufe 1 kein SHA-1 mehr anbietet. +# +# ACHTUNG -- das laesst sich NICHT ueber OPENSSL_CONF loesen: FreeRDP ruft +# SSL_CTX_set_security_level() selbst auf und ueberschreibt die Datei-Vorgabe. +# Deshalb die Zwischenschicht in files/guacd_tls_seclevel_shim.c, die nur im +# guacd-Prozess geladen wird. Sie gilt fuer ALLE RDP-Ziele dieses Jumphosts, +# nicht je Host. +# +# Zusammen mit guacd_tls_legacy_compat verwenden (MinProtocol/MaxProtocol); +# beide zusammen ergeben genau die Kombination, die das Diagnoseskript als +# funktionierend gemessen hat. +guacd_tls_seclevel: "" diff --git a/ansible/roles/guacd/files/guacd_tls_seclevel_shim.c b/ansible/roles/guacd/files/guacd_tls_seclevel_shim.c new file mode 100644 index 0000000..cc75e0c --- /dev/null +++ b/ansible/roles/guacd/files/guacd_tls_seclevel_shim.c @@ -0,0 +1,126 @@ +/* + * guacd-tls-seclevel-shim -- setzt die OpenSSL-Sicherheitsstufe, die FreeRDP + * fuer seine RDP-Verbindungen verwendet. + * + * WOZU (Live-Test 3.9.2026, jumphost-97 -> 10.0.0.12) + * --------------------------------------------------- + * RDP-Verbindungen scheiterten mit + * ERRCONNECT_TLS_CONNECT_FAILED [0x00020008] + * und guacds daraus abgeleiteter Meldung + * "SSL/TLS connection failed (untrusted/self-signed certificate?)". + * Der Klammerzusatz ist irrefuehrend -- guacd setzt diesen festen Text fuer + * genau diesen FreeRDP-Fehlercode. Am Zertifikat lag es nicht: im Journal + * fehlte jede "Certificate validation ..."-Zeile, der Zertifikats-Rueckruf + * wurde also nie erreicht. + * + * Gemessen mit scripts/diagnose_rdp_tls.py: + * OpenSSL-Sicherheitsstufe 2 -> Verbindung vom Ziel zurueckgesetzt (TCP-RST) + * OpenSSL-Sicherheitsstufe 1 -> IDENTISCH zurueckgesetzt + * OpenSSL-Sicherheitsstufe 0 -> erfolgreich, TLS 1.2, ECDHE-RSA-AES256-SHA384 + * + * Die Cipher-Listen sind auf allen drei Stufen identisch (gegengeprueft mit + * `openssl ciphers -s 'DEFAULT@SECLEVEL=0|1|2'` -- jeweils dieselben 30). Der + * Unterschied liegt also in der signature_algorithms-Erweiterung: OpenSSL 3.0 + * bietet SHA-1 ab Stufe 1 nicht mehr als Signaturverfahren an. Das Windows-Ziel + * kann den ServerKeyExchange offenbar nicht anders signieren, findet nichts + * Verwendbares und legt auf -- SCHANNEL schickt dabei ein TCP-RST statt einer + * sauberen handshake_failure-Warnung. + * + * WARUM NICHT EINFACH OPENSSL_CONF + * -------------------------------- + * Wurde versucht und reicht nicht. FreeRDP 2 setzt die Sicherheitsstufe SELBST + * (Kommandozeilenoption /tls-seclevel, Vorgabe 1) und ruft dafuer nach dem + * Anlegen des SSL_CTX SSL_CTX_set_security_level() auf. Das ueberschreibt jede + * Vorgabe aus /etc/ssl/openssl.cnf bzw. aus einer eigenen OPENSSL_CONF. + * guacamole-server 1.5.5 reicht diese FreeRDP-Option nicht als RDP-Parameter + * durch -- es gibt also keinen Konfigurationsweg dorthin. + * + * WARUM DIESER WEG UND NICHT EIN QUELLCODE-PATCH + * ---------------------------------------------- + * Ein Patch an guacamole-server muesste das Argument-Array GUAC_RDP_CLIENT_ARGS + * und das dazu positionsgleiche IDX-Enum in src/protocols/rdp/settings.c + * gemeinsam erweitern. Das ist bei jedem Versionswechsel neu anzupassen und + * bricht den Build, sobald sich dort etwas verschiebt -- auf einem Host, der + * guacd bei jedem Ansible-Lauf aus dem Quellcode baut, ein schlechtes Geschaeft. + * Diese Zwischenschicht ist dagegen versionsunabhaengig, kann den Build nicht + * beschaedigen und laesst sich durch Entfernen einer Environment-Zeile in der + * guacd-Unit vollstaendig zuruecknehmen. + * + * VERHALTEN + * --------- + * Ohne die Umgebungsvariable JUMPHOST_GUACD_TLS_SECLEVEL tut diese Bibliothek + * NICHTS: sie reicht jeden Aufruf unveraendert weiter. Erst ein gesetzter, + * nicht-negativer Wert erzwingt diese Stufe. Die erste tatsaechliche Aenderung + * wird nach syslog gemeldet, damit im Journal nachvollziehbar steht, dass hier + * etwas eingreift. + * + * REICHWEITE + * ---------- + * Wird ausschliesslich ueber Environment=LD_PRELOAD= in der guacd-Unit geladen + * und wirkt damit nur im guacd-Prozess. Da guacd auf diesem Jumphost nur RDP + * spricht und selbst kein TLS anbietet (bind_host=127.0.0.1, kein [ssl]- + * Abschnitt in guacd.conf), betrifft die Absenkung genau die Strecke + * guacd -> Windows-Ziel. Browser -> Jumphost, nginx, die Anwendung, SSH und + * apt bleiben unberuehrt. + * + * EINSCHRAENKUNG + * -------------- + * Die Stufe gilt fuer ALLE RDP-Ziele dieses Jumphosts, nicht je Host. Eine + * Einstellung je Host waere nur ueber den oben verworfenen Quellcode-Patch zu + * haben. + * + * Bewusst ohne : der SSL_CTX-Zeiger wird nur durchgereicht, nie + * dereferenziert. Damit braucht das Uebersetzen nur gcc, kein libssl-dev. + */ +#define _GNU_SOURCE +#include +#include +#include + +typedef struct ssl_ctx_st SSL_CTX; + +static int gewuenschte_stufe = -1; /* -1 = nicht eingreifen */ +static int stufe_gelesen = 0; +static int bereits_gemeldet = 0; + +static void stufe_lesen(void) { + const char* wert = getenv("JUMPHOST_GUACD_TLS_SECLEVEL"); + if (wert != NULL && *wert != '\0') { + char* ende = NULL; + long gelesen = strtol(wert, &ende, 10); + /* Nur vollstaendig geparste Werte im gueltigen OpenSSL-Bereich 0..5 + * uebernehmen. Bei Unsinn in der Variablen lieber nicht eingreifen, + * als eine unbeabsichtigte Stufe zu erzwingen. */ + if (ende != NULL && *ende == '\0' && gelesen >= 0 && gelesen <= 5) + gewuenschte_stufe = (int) gelesen; + else + syslog(LOG_WARNING, "guacd-tls-seclevel-shim: unbrauchbarer Wert " + "JUMPHOST_GUACD_TLS_SECLEVEL=\"%s\", bleibe untaetig", wert); + } + stufe_gelesen = 1; +} + +void SSL_CTX_set_security_level(SSL_CTX* ctx, int stufe) { + static void (*original)(SSL_CTX*, int) = NULL; + + if (original == NULL) + original = (void (*)(SSL_CTX*, int)) dlsym(RTLD_NEXT, "SSL_CTX_set_security_level"); + + if (!stufe_gelesen) + stufe_lesen(); + + if (gewuenschte_stufe >= 0 && stufe != gewuenschte_stufe) { + if (!bereits_gemeldet) { + syslog(LOG_INFO, "guacd-tls-seclevel-shim: OpenSSL-Sicherheitsstufe %d -> %d " + "(siehe ansible/roles/guacd, guacd_tls_seclevel)", + stufe, gewuenschte_stufe); + bereits_gemeldet = 1; + } + stufe = gewuenschte_stufe; + } + + /* Fehlt das Original wider Erwarten, ist Nichtstun richtig: OpenSSL bleibt + * dann bei seiner Vorgabe, statt dass der Aufruf ins Leere laeuft. */ + if (original != NULL) + original(ctx, stufe); +} diff --git a/ansible/roles/guacd/tasks/main.yml b/ansible/roles/guacd/tasks/main.yml index 4e252dd..cf48bc8 100644 --- a/ansible/roles/guacd/tasks/main.yml +++ b/ansible/roles/guacd/tasks/main.yml @@ -255,59 +255,24 @@ group: root mode: "0755" -- name: guacd nur an localhost binden - ansible.builtin.lineinfile: - path: /etc/guacamole/guacd.conf - regexp: '^bind_host' - line: "bind_host = 127.0.0.1" - create: true +- name: guacd.conf vollstaendig ausrollen + # Ersetzt vier fruehere lineinfile-Tasks (bind_host, bind_port und zwei + # Fehlversuche fuer die Protokollierstufe). lineinfile haengt neue Zeilen + # ans Dateiende und damit in den zuletzt geoeffneten INI-Abschnitt -- bei + # guacd.conf, wo jeder Parameter in genau einen Abschnitt gehoert, fuehrt + # das verlaesslich zu einem Startabbruch von guacd. Siehe Kopfkommentar + # der Vorlage. + ansible.builtin.template: + src: guacd.conf.j2 + dest: /etc/guacamole/guacd.conf + owner: root + group: root + mode: "0644" + # Vor dem Ueberschreiben eine Sicherung anlegen: die Datei koennte auf + # einem bestehenden Host von Hand angepasst worden sein. + backup: true notify: restart guacd -- name: guacd-Port setzen - ansible.builtin.lineinfile: - path: /etc/guacamole/guacd.conf - regexp: '^bind_port' - line: "bind_port = {{ guacd_port }}" - create: true - notify: restart guacd - -- name: Falsch benannte Protokollierstufe aus guacd.conf entfernen - # BUGFIX (3.9.): hier stand zuerst "log_level = ...". Diesen Parameter - # kennt guacd.conf NICHT -- er heisst "max_log_level" (guacd.conf(5): - # pid_file, max_log_level, bind_host, bind_port, server_certificate, - # server_key). guacd bricht bei einem unbekannten Schluessel den Start - # komplett ab ("Parse error at line N: Invalid parameter or section name"), - # es gibt also keine stille Fehlbedienung -- der Dienst startete gar nicht - # mehr. Diese Aufraeumzeile entfernt die falsche Zeile auf Hosts, auf denen - # die fehlerhafte Fassung bereits ausgerollt wurde. - ansible.builtin.lineinfile: - path: /etc/guacamole/guacd.conf - regexp: '^\s*log_level\s*=' - state: absent - notify: restart guacd - -- name: guacd-Protokollierstufe setzen - # Auf "info" (Standard) protokolliert guacd bereits, ob die - # Zertifikatspruefung wegen ignore-cert uebersprungen wurde - # ("Certificate validation bypassed") oder fehlgeschlagen ist -- die - # entscheidende Unterscheidung bei "Ziel nicht erreichbar (Code 519): - # SSL/TLS connection failed". Fuer den konkreten FreeRDP-/OpenSSL-Fehler - # guacd_log_level voruebergehend auf "debug" setzen (siehe defaults). - ansible.builtin.lineinfile: - path: /etc/guacamole/guacd.conf - regexp: '^\s*max_log_level\s*=' - line: "max_log_level = {{ guacd_log_level }}" - create: true - notify: restart guacd - -- name: RDP-Laufwerksumleitungs-Verzeichnisse anlegen (Filetransfer, Konzept 4.3) - ansible.builtin.file: - path: "/var/lib/jumphost/rdp-drives" - state: directory - owner: guacd - group: guacd - mode: "0750" - - name: TLS-Kompatibilitaetskonfiguration fuer guacd ausrollen (nur wenn eingeschaltet) # Greift ausschliesslich ueber Environment=OPENSSL_CONF in der guacd-Unit # und damit nur fuer diesen einen Prozess -- siehe Kopfkommentar der @@ -331,6 +296,91 @@ when: not (guacd_tls_legacy_compat | bool) notify: restart guacd +# --- OpenSSL-Sicherheitsstufe fuer FreeRDP (Befund 3.9., siehe files/*.c) --- +# +# Nur aktiv, wenn guacd_tls_seclevel gesetzt ist. Standardmaessig leer. + +- name: Uebersetzer fuer die Sicherheitsstufen-Zwischenschicht sicherstellen + # Auf dem Quellcode-Pfad ist gcc ueber build-essential ohnehin da; auf dem + # apt-Pfad (Ubuntu) nicht. Die Zwischenschicht bindet bewusst KEINE + # OpenSSL-Header ein, deshalb reicht gcc -- kein libssl-dev noetig. + ansible.builtin.apt: + name: gcc + state: present + when: guacd_tls_seclevel | string | length > 0 + +- name: Quelle der Sicherheitsstufen-Zwischenschicht ablegen + ansible.builtin.copy: + src: guacd_tls_seclevel_shim.c + dest: /usr/local/src/guacd_tls_seclevel_shim.c + owner: root + group: root + mode: "0644" + when: guacd_tls_seclevel | string | length > 0 + register: _shim_quelle + +- name: Verzeichnis fuer die uebersetzte Zwischenschicht anlegen + ansible.builtin.file: + path: /usr/local/lib/jumphost + state: directory + owner: root + group: root + mode: "0755" + when: guacd_tls_seclevel | string | length > 0 + +- name: Sicherheitsstufen-Zwischenschicht uebersetzen + ansible.builtin.command: + cmd: >- + gcc -shared -fPIC -O2 -Wall -Wextra + -o /usr/local/lib/jumphost/guacd-tls-seclevel-shim.so + /usr/local/src/guacd_tls_seclevel_shim.c -ldl + when: guacd_tls_seclevel | string | length > 0 + register: _shim_build + changed_when: true + notify: restart guacd + +- name: Pruefen, ob FreeRDP die OpenSSL-Sicherheitsstufe tatsaechlich selbst setzt + # Die Zwischenschicht kann nur wirken, wenn FreeRDP + # SSL_CTX_set_security_level dynamisch aufruft. Taete es das nicht, waere + # sie wirkungslos -- und das soll auffallen, statt dass jemand spaeter + # raetselt, warum die Einstellung nichts bewirkt. + ansible.builtin.shell: | + set -o pipefail + lib=$(ldconfig -p | awk '/libfreerdp[0-9]*\.so/ {print $NF; exit}') + if [ -z "$lib" ]; then echo "KEINE_LIB"; exit 0; fi + if grep -qa 'SSL_CTX_set_security_level' "$lib"; then echo "JA $lib"; else echo "NEIN $lib"; fi + args: + executable: /bin/bash + register: _freerdp_seclevel + changed_when: false + check_mode: false + when: guacd_tls_seclevel | string | length > 0 + +- name: Abbrechen, wenn die Zwischenschicht wirkungslos waere + ansible.builtin.fail: + msg: >- + guacd_tls_seclevel ist gesetzt, aber die gefundene FreeRDP-Bibliothek + ruft SSL_CTX_set_security_level nicht auf ({{ _freerdp_seclevel.stdout }}). + Die Zwischenschicht bliebe wirkungslos. Entweder guacd_tls_seclevel + wieder leeren, oder die Ursache erneut mit + scripts/diagnose_rdp_tls.py eingrenzen. + when: + - guacd_tls_seclevel | string | length > 0 + - _freerdp_seclevel.stdout is defined + - _freerdp_seclevel.stdout is not match('^JA ') + +- name: Sicherheitsstufen-Zwischenschicht wieder entfernen, wenn abgeschaltet + # Damit ein Leeren von guacd_tls_seclevel die Absenkung auch tatsaechlich + # zurueckdreht und keine verwaiste Bibliothek liegen bleibt. + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /usr/local/lib/jumphost/guacd-tls-seclevel-shim.so + - /usr/local/src/guacd_tls_seclevel_shim.c + when: guacd_tls_seclevel | string | length == 0 + notify: restart guacd + - name: Override-Verzeichnis fuer guacd-Unit anlegen ansible.builtin.file: path: /etc/systemd/system/guacd.service.d diff --git a/ansible/roles/guacd/templates/guacd.conf.j2 b/ansible/roles/guacd/templates/guacd.conf.j2 new file mode 100644 index 0000000..676ecf3 --- /dev/null +++ b/ansible/roles/guacd/templates/guacd.conf.j2 @@ -0,0 +1,30 @@ +# /etc/guacamole/guacd.conf -- von Ansible verwaltet (Rolle "guacd"). +# +# WICHTIG, zweimal falsch gemacht am 3.9.: guacd.conf ist eine INI-Datei mit +# FESTEN Abschnitten, und jeder Parameter gehoert in genau einen davon. +# guacd bricht bei einem Parameter im falschen Abschnitt den START ab +# ("Parse error at line N, column 1: Invalid parameter or section name") -- +# der Dienst laeuft dann gar nicht mehr. +# +# [daemon] pid_file, log_level +# [server] bind_host, bind_port +# [ssl] server_certificate, server_key +# +# "log_level" gehoert also unter [daemon], NICHT unter [server]. Und es heisst +# "log_level" -- "max_log_level" gibt es nicht (das war der zweite Fehlversuch). +# +# Deshalb jetzt eine vollstaendige Vorlage statt einzelner lineinfile-Eintraege: +# lineinfile haengt eine Zeile ans DATEIENDE, also in den zuletzt geoeffneten +# Abschnitt, ohne den Zusammenhang zu kennen. Fuer eine abschnittsbasierte +# Konfigurationsdatei ist das die falsche Werkzeugwahl. + +[daemon] +# trace | debug | info | warning | error +# "info" protokolliert bereits "Certificate validation bypassed" bzw. +# "... failed" -- die entscheidende Unterscheidung bei RDP-Fehler 519. +# Fuer den konkreten FreeRDP-/OpenSSL-Fehler voruebergehend "debug". +log_level = {{ guacd_log_level }} + +[server] +bind_host = 127.0.0.1 +bind_port = {{ guacd_port }} diff --git a/ansible/roles/guacd/templates/guacd.service.j2 b/ansible/roles/guacd/templates/guacd.service.j2 index d88977d..bc29545 100644 --- a/ansible/roles/guacd/templates/guacd.service.j2 +++ b/ansible/roles/guacd/templates/guacd.service.j2 @@ -7,6 +7,16 @@ [Service] User=guacd Group=guacd +{% if guacd_tls_seclevel | string | length > 0 %} +# OpenSSL-Sicherheitsstufe fuer FreeRDP erzwingen (Befund 3.9.). FreeRDP setzt +# sie selbst (Vorgabe 1) und ueberschreibt damit jede OPENSSL_CONF-Vorgabe; +# guacamole-server reicht die zustaendige FreeRDP-Option nicht durch. Die +# Zwischenschicht greift NUR in diesem Prozess und nur, solange +# JUMPHOST_GUACD_TLS_SECLEVEL gesetzt ist -- ausfuehrliche Begruendung im +# Kopfkommentar von roles/guacd/files/guacd_tls_seclevel_shim.c. +Environment=LD_PRELOAD=/usr/local/lib/jumphost/guacd-tls-seclevel-shim.so +Environment=JUMPHOST_GUACD_TLS_SECLEVEL={{ guacd_tls_seclevel }} +{% endif %} {% if guacd_tls_legacy_compat %} # TLS-Kompatibilitaet fuer die Strecke guacd -> Windows-Ziel (Live-Test 3.9.). # Setzt die OpenSSL-Vorgaben NUR fuer diesen Prozess; systemweit bleibt diff --git a/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 index dd68dd0..78947ee 100644 --- a/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 +++ b/ansible/roles/guacd/templates/openssl-guacd.cnf.j2 @@ -15,6 +15,14 @@ # diesem Fall irrefuehrend; die Hosteinstellung "Zertifikat ignorieren" # kann daran folglich nichts aendern. # +# Gemessener Fall (jumphost-97 -> 10.0.0.12, 3.9.): der Handshake gelingt erst +# ab Sicherheitsstufe 0; Stufe 1 UND 2 scheitern beide gleich mit einem TCP-RST +# der Gegenstelle. Das deutet auf die signature_algorithms-Erweiterung: OpenSSL +# 3.0 bietet ab Stufe 1 kein SHA-1 mehr als Signaturverfahren an, und das Ziel +# kann den ServerKeyExchange offenbar nicht anders signieren. Nicht das +# ZERTIFIKAT ist das Problem -- dessen Signatur prueft bei ignore-cert ohnehin +# niemand (eigens gegengeprueft). +# # ABWAEGUNG (bewusst zu treffen, deshalb standardmaessig AUS): # Diese Datei senkt die TLS-Anforderungen fuer die Strecke # Jumphost -> Windows-Ziel. Die Strecke Browser -> Jumphost ist davon nicht @@ -22,6 +30,9 @@ # vertretbar; die saubere Loesung bleibt, TLS 1.2 auf dem Windows-Ziel zu # aktivieren. Nur so weit lockern wie noetig -- guacd_tls_min_protocol und # guacd_tls_security_level einzeln nachziehen, statt pauschal auf TLSv1/0. +# Auch bei SECLEVEL=0 schliesst 'DEFAULT' weiterhin NULL- und anonyme Cipher +# aus -- eine ungeschuetzte Verbindung wird dadurch also nicht moeglich. +# Zusammen mit MinProtocol=MaxProtocol=TLSv1.2 bleibt die Lockerung eng. openssl_conf = default_conf @@ -37,6 +48,16 @@ MinProtocol = {{ guacd_tls_min_protocol }} MaxProtocol = {{ guacd_tls_max_protocol }} {% endif %} CipherString = DEFAULT@SECLEVEL={{ guacd_tls_security_level }} +{% if guacd_tls_signature_algorithms | default('', true) | length > 0 %} +# Explizite Liste der angebotenen Signaturverfahren. Gemessen am 3.9.: +# die Cipher-Liste ist auf allen Sicherheitsstufen IDENTISCH +# (openssl ciphers -s 'DEFAULT@SECLEVEL=0|1|2' -> jeweils dieselben 30), +# der Unterschied liegt also in der signature_algorithms-Erweiterung: +# OpenSSL 3.0 bietet SHA-1 ab Stufe 1 nicht mehr an. Achtung -- die +# Sicherheitsstufe filtert diese Liste NACHTRAEGLICH nochmals, ohne +# guacd_tls_security_level: 0 bleibt SHA-1 also trotzdem draussen. +SignatureAlgorithms = {{ guacd_tls_signature_algorithms }} +{% endif %} {% if guacd_tls_unsafe_legacy_renegotiation %} # Manche aeltere Windows-/SCHANNEL-Staende koennen die in OpenSSL 3 # standardmaessig abgeschaltete unsichere Neuverhandlung verlangen. diff --git a/scripts/diagnose_rdp_tls.py b/scripts/diagnose_rdp_tls.py index f15aea0..916bcfb 100644 --- a/scripts/diagnose_rdp_tls.py +++ b/scripts/diagnose_rdp_tls.py @@ -47,7 +47,9 @@ import argparse import socket import ssl import struct +import subprocess import sys +import tempfile import warnings # ssl.TLSVersion.TLSv1/TLSv1_1 sind in neueren Python-Fassungen als veraltet @@ -207,6 +209,64 @@ def negotiate(sock: socket.socket, requested: int) -> int: return selected +# Letztes erfolgreich abgeholtes Serverzertifikat (DER) -- fuer die Auswertung +# am Ende. Nicht schoen als Modulvariable, aber dieses Skript ist ein +# Diagnosewerkzeug mit genau einem Durchlauf, kein Baustein zum Wiederverwenden. +_ZERTIFIKAT: dict[str, bytes] = {} + + +def beschreibe_zertifikat(der: bytes) -> list[str]: + """Eckdaten des Serverzertifikats -- zur Information, NICHT als Ursache. + + Wichtige Einordnung (in einem Gegentest am 3.9. nachgeprueft): das + Signaturverfahren des Zertifikats ist hier NICHT der Ausloeser. Die + Zertifikatspruefung ist ausgeschaltet (wie bei ignore-cert), und ein + selbstsigniertes Zertifikat, dessen Signatur niemand prueft, kommt auch mit + SHA-1 und Sicherheitsstufe 2 anstandslos durch -- gegengeprueft gegen einen + eigens dafuer aufgesetzten Server mit SHA-1-Zertifikat. + + Was bei einem Abbruch, der erst unterhalb von Sicherheitsstufe 1 + verschwindet, tatsaechlich passiert, ist eine Stufe subtiler: OpenSSL 3.0 + hat SHA-1 als SIGNATURVERFAHREN ab Stufe 1 verboten und nimmt es deshalb + nicht mehr in die signature_algorithms-Erweiterung des ClientHello auf. + Bei TLS 1.2 mit ECDHE muss der Server aber den ServerKeyExchange mit einem + Verfahren signieren, das der Client angeboten hat. Kann das Windows-Ziel + nur mit SHA-1 signieren -- etwa weil in der SCHANNEL-Registry unter + "Hashes\\SHA256" Enabled=0 steht oder eine Haertungsvorlage die neueren + Hashverfahren abgeschaltet hat --, findet es nichts Verwendbares und legt + auf. SCHANNEL schickt dabei ein TCP-RST statt einer sauberen + handshake_failure-Warnung: genau das "Connection reset by peer" oben. + + Diese Angaben helfen trotzdem beim Einordnen des Ziels (Alter, Herkunft, + Schluessellaenge des Zertifikats). + """ + with tempfile.NamedTemporaryFile(suffix=".der") as fh: + fh.write(der) + fh.flush() + try: + ausgabe = subprocess.run( + ["openssl", "x509", "-inform", "DER", "-in", fh.name, "-noout", "-text"], + capture_output=True, text=True, timeout=15, + ).stdout + except (OSError, subprocess.SubprocessError) as exc: + return [f" (Zertifikat nicht auswertbar: {exc})"] + sig = kex = subject = None + for zeile in ausgabe.splitlines(): + z = zeile.strip() + if z.startswith("Signature Algorithm:") and sig is None: + sig = z.split(":", 1)[1].strip() + elif z.startswith("Public-Key:") and kex is None: + kex = z.split(":", 1)[1].strip() + elif z.startswith("Subject:") and subject is None: + subject = z.split(":", 1)[1].strip() + return [ + f" Inhaber: {subject or '(unbekannt)'}", + f" Schluessel: {kex or '(unbekannt)'}", + f" Signaturverfahren: {sig or '(unbekannt)'}", + " (nur zur Einordnung -- bei abgeschalteter Pruefung nicht die Fehlerursache)", + ] + + def try_handshake(host, port, requested, variante: Variante, timeout): try: with socket.create_connection((host, port), timeout=timeout) as sock: @@ -215,6 +275,7 @@ def try_handshake(host, port, requested, variante: Variante, timeout): return (False, "Server waehlt 'Native RDP' -- kein TLS auf dieser Stufe") with variante.context().wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert(binary_form=True) + _ZERTIFIKAT["der"] = cert return (True, f"{tls.version()}, {tls.cipher()[0]}, Zertifikat {len(cert)} Byte") except ssl.SSLError as exc: grund = getattr(exc, "reason", None) or exc @@ -281,6 +342,11 @@ def main() -> int: gesprochen.append(v.label.replace("nur ", "")) print(f" [{'OK ' if ok else 'FEHL'}] {v.label}: {text}") + if _ZERTIFIKAT.get("der"): + print("\n== Schritt 4: das vom Ziel vorgelegte Zertifikat ==") + for zeile in beschreibe_zertifikat(_ZERTIFIKAT["der"]): + print(zeile) + print("\n== Befund ==") if ergebnisse["system"]: print(" Der TLS-Handshake gelingt bereits mit den System-Vorgaben.") @@ -309,8 +375,18 @@ def main() -> int: gezeigt = '""' if value == "" else (f'"{value}"' if isinstance(value, str) else value) print(f" {key}: {gezeigt}") print("\n Das wirkt NUR auf den guacd-Prozess (Environment=OPENSSL_CONF in dessen") - print(" Unit), nicht systemweit. Die saubere Loesung bleibt, das Windows-Ziel") - print(" passend zu konfigurieren -- dies ist die Ueberbrueckung bis dahin.") + print(" Unit), nicht systemweit. 'DEFAULT' schliesst auch bei Stufe 0 weiterhin") + print(" NULL- und anonyme Cipher aus -- es wird also keine ungeschuetzte") + print(" Verbindung moeglich, nur eine aeltere.") + if not ergebnisse.get("sec1") and not ergebnisse.get("sec1_max12"): + print("\n Dass erst Stufe 0 hilft (Stufe 1 UND 2 scheitern gleich), deutet auf die") + print(" signature_algorithms-Erweiterung hin: OpenSSL 3.0 bietet ab Stufe 1 kein") + print(" SHA-1 mehr an, und das Ziel kann den ServerKeyExchange offenbar nicht") + print(" anders signieren. Am Windows-Ziel gegenpruefen (PowerShell, Administrator):") + print(" Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\SecurityProviders" + "\\SCHANNEL\\Hashes\\SHA256' -ErrorAction SilentlyContinue") + print(" Steht dort Enabled = 0, ist das die Wurzel. SHA256/SHA384 wieder") + print(" aktivieren, neu starten -- danach guacd_tls_legacy_compat auf false.") return 1