diff --git a/android/App.tsx b/android/App.tsx index 2baf3f8..956f2d4 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -6,15 +6,16 @@ */ import React, { useEffect } from 'react'; -import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native'; +import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet, View } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import WorkspaceScreen from './src/workspace/WorkspaceScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import ViewModeToggle from './src/components/ViewModeToggle'; +import SecurityLock from './src/components/SecurityLock'; import rvs from './src/services/rvs'; import { initLogger, installGlobalCrashReporter } from './src/services/logger'; import { acquireBackgroundAudio } from './src/services/backgroundAudio'; @@ -138,6 +139,15 @@ const App: React.FC = () => { ( + // Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad + + + + + props.navigation.navigate('Einstellungen')} /> + + )} screenOptions={({ route }) => ({ headerStyle: styles.header, headerTitleStyle: styles.headerTitle, @@ -205,10 +215,18 @@ const styles = StyleSheet.create({ fontSize: 18, fontWeight: '700', }, - tabBar: { + tabBarRow: { + flexDirection: 'row', backgroundColor: '#12122A', borderTopColor: '#1E1E2E', borderTopWidth: 1, + }, + tabBarTabs: { + flex: 1, + }, + tabBar: { + backgroundColor: '#12122A', + borderTopWidth: 0, height: 60, paddingBottom: 6, paddingTop: 4, diff --git a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt index 28478ab..30943a1 100644 --- a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt +++ b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt @@ -26,6 +26,7 @@ class MainApplication : Application(), ReactApplication { add(PhoneCallPackage()) add(BackgroundAudioPackage()) add(ShareIntentPackage()) + add(TlsTrustPackage()) } override fun getJSMainModuleName(): String = "index" @@ -42,6 +43,8 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() SoLoader.init(this, false) + // Vor der ersten Verbindung: Zertifikatspruefung (abgelaufen erlaubt?) + TlsTrust.install(this) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. load() diff --git a/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt b/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt new file mode 100644 index 0000000..57994f7 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt @@ -0,0 +1,185 @@ +package com.ariacockpit + +import android.content.Context +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.network.NetworkingModule +import com.facebook.react.modules.websocket.WebSocketModule +import java.net.Socket +import java.security.KeyStore +import java.security.cert.CertPathValidator +import java.security.cert.CertificateException +import java.security.cert.CertificateExpiredException +import java.security.cert.CertificateFactory +import java.security.cert.PKIXParameters +import java.security.cert.X509Certificate +import java.util.Date +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedTrustManager +import javax.net.ssl.X509TrustManager + +/** + * Zertifikatspruefung fuer RVS-Verbindung (WebSocket) und fetch. + * + * Normal pruefen wir wie Android selbst. Mit dem Schalter "Abgelaufenes + * Zertifikat akzeptieren" wird GENAU EIN Fehler toleriert: das Ablaufdatum. + * Kette und Aussteller werden dann zum Zeitpunkt kurz vor dem Ablauf geprueft + * (war das Zertifikat damals gueltig, ist es echt), der Hostname prueft OkHttp + * ohnehin separat. Ein fremdes oder selbstgebautes Zertifikat kommt also auch + * mit Schalter nicht durch — sonst koennte sich jeder dazwischenhaengen. + * + * Nebenbei merkt sich das Modul, wie die letzte Pruefung ausging, damit die + * App das Schloss-Symbol richtig zeigen kann. + */ +object TlsTrust { + private const val PREFS = "aria_tls" + private const val KEY_ALLOW_EXPIRED = "allow_expired" + + // ok | expired_accepted | expired | invalid | unknown + @Volatile var lastStatus: String = "unknown" + @Volatile var lastDetail: String = "" + @Volatile var lastNotAfter: Long = 0 + @Volatile var lastSubject: String = "" + @Volatile var allowExpired: Boolean = false + private set + + private var appContext: Context? = null + + fun install(context: Context) { + appContext = context.applicationContext + allowExpired = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_ALLOW_EXPIRED, false) + try { + val tm = ExpiryTolerantTrustManager(defaultTrustManager()) + val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) } + val factory = ssl.socketFactory + WebSocketModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) } + NetworkingModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) } + } catch (e: Exception) { + // Lieber normale Pruefung als gar keine Verbindung + android.util.Log.w("TlsTrust", "Eigene Zertifikatspruefung nicht aktiv: ${e.message}") + } + } + + fun saveAllowExpired(value: Boolean) { + allowExpired = value + appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + ?.edit()?.putBoolean(KEY_ALLOW_EXPIRED, value)?.apply() + } + + private fun defaultTrustManager(): X509TrustManager { + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as KeyStore?) + return tmf.trustManagers.filterIsInstance().first() + } + + private fun remember(status: String, chain: Array?, detail: String = "") { + lastStatus = status + lastDetail = detail + val leaf = chain?.firstOrNull() + lastNotAfter = leaf?.notAfter?.time ?: 0 + lastSubject = leaf?.subjectX500Principal?.name ?: "" + } + + private fun isExpired(chain: Array): Boolean = + chain.any { c -> + try { c.checkValidity(); false } catch (_: CertificateExpiredException) { true } catch (_: Exception) { false } + } + + /** Kette gegen die System-Zertifizierungsstellen pruefen — zum Zeitpunkt + * kurz vor dem fruehesten Ablauf in der Kette. */ + private fun chainValidBeforeExpiry(chain: Array) { + val ks = KeyStore.getInstance("AndroidCAStore").apply { load(null) } + val params = PKIXParameters(ks).apply { + isRevocationEnabled = false + date = Date(chain.minOf { it.notAfter.time } - 60_000) + } + // Mitgeschickte Wurzel (selbstsigniert) gehoert nicht in den Pfad — aber NIE + // das Server-Zertifikat selbst: ein leerer Pfad gilt als gueltig, ein + // selbstgebautes Zertifikat kaeme sonst durch. + if (chain.isEmpty()) throw CertificateException("Leere Zertifikatskette") + val path = listOf(chain[0]) + chain.drop(1).filter { it.subjectX500Principal != it.issuerX500Principal } + val certPath = CertificateFactory.getInstance("X.509").generateCertPath(path) + CertPathValidator.getInstance("PKIX").validate(certPath, params) + } + + private class ExpiryTolerantTrustManager(private val inner: X509TrustManager) : X509ExtendedTrustManager() { + private val ext = inner as? X509ExtendedTrustManager + + private fun check(chain: Array, run: () -> Unit) { + try { + run() + remember("ok", chain) + } catch (e: CertificateException) { + if (!isExpired(chain)) { + remember("invalid", chain, e.message ?: "") + throw e + } + if (!allowExpired) { + remember("expired", chain, e.message ?: "") + throw e + } + try { + chainValidBeforeExpiry(chain) + } catch (inner: Exception) { + // Abgelaufen UND sonst nicht in Ordnung → nicht akzeptieren + remember("invalid", chain, inner.message ?: "") + throw e + } + remember("expired_accepted", chain) + } + } + + override fun checkServerTrusted(chain: Array, authType: String) = + check(chain) { inner.checkServerTrusted(chain, authType) } + + override fun checkServerTrusted(chain: Array, authType: String, socket: Socket?) = + check(chain) { + if (ext != null) ext.checkServerTrusted(chain, authType, socket) + else inner.checkServerTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array, authType: String, engine: SSLEngine?) = + check(chain) { + if (ext != null) ext.checkServerTrusted(chain, authType, engine) + else inner.checkServerTrusted(chain, authType) + } + + override fun checkClientTrusted(chain: Array, authType: String) = + inner.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String, socket: Socket?) = + if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array, authType: String, engine: SSLEngine?) = + if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType) + + override fun getAcceptedIssuers(): Array = inner.acceptedIssuers + } +} + +class TlsTrustModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "TlsTrust" + + @ReactMethod + fun setAllowExpired(value: Boolean, promise: Promise) { + TlsTrust.saveAllowExpired(value) + promise.resolve(value) + } + + @ReactMethod + fun getStatus(promise: Promise) { + val m = Arguments.createMap() + m.putString("status", TlsTrust.lastStatus) + m.putString("detail", TlsTrust.lastDetail) + m.putDouble("notAfter", TlsTrust.lastNotAfter.toDouble()) + m.putString("subject", TlsTrust.lastSubject) + m.putBoolean("allowExpired", TlsTrust.allowExpired) + promise.resolve(m) + } +} diff --git a/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt b/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt new file mode 100644 index 0000000..5a38a0f --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt @@ -0,0 +1,16 @@ +package com.ariacockpit + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class TlsTrustPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(TlsTrustModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/src/components/SecurityLock.tsx b/android/src/components/SecurityLock.tsx new file mode 100644 index 0000000..93855eb --- /dev/null +++ b/android/src/components/SecurityLock.tsx @@ -0,0 +1,150 @@ +/** + * Schloss-Symbol in der Tab-Leiste (neben dem Zahnrad): zeigt, ob die + * Verbindung zu ARIA verschluesselt ist. + * + * 🔒 verschluesselt, alles in Ordnung + * 🔒 + ! Problem mit dem Zertifikat (abgelaufen / abgelehnt) + * 🔒 durchgestrichen keine Verschluesselung (Fallback oder so eingestellt) + * + * Antippen erklaert in einfachen Worten, was das Symbol bedeutet. + * Einmal pro App-Start gibt's ausserdem einen Hinweis, wenn das Zertifikat + * abgelaufen ist — damit es nicht unbemerkt bleibt. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import rvs, { SecurityState } from '../services/rvs'; + +interface Props { + onOpenSettings: () => void; +} + +const fmtDate = (ms: number) => + ms ? new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' }) : 'unbekannt'; + +const SecurityLock: React.FC = ({ onOpenSettings }) => { + const [sec, setSec] = useState(rvs.getSecurity()); + const [open, setOpen] = useState(false); + const alerted = useRef>(new Set()); + + useEffect(() => rvs.onSecurityChange(setSec), []); + + // Einmaliger Hinweis pro App-Start + useEffect(() => { + if (sec.kind === 'expired' && !alerted.current.has('expired')) { + alerted.current.add('expired'); + Alert.alert('Zertifikat abgelaufen', + `Die Verbindung zu ARIA ist weiterhin verschlüsselt, aber das Sicherheits-Zertifikat ` + + `des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Bitte dem Admin Bescheid geben.`); + } else if (sec.kind === 'cert_error' && sec.certStatus === 'expired' && !alerted.current.has('cert_error')) { + alerted.current.add('cert_error'); + Alert.alert('Keine Verbindung — Zertifikat abgelaufen', + `Das Sicherheits-Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen, ` + + `deshalb verbindet sich die App nicht.\n\nIn den Einstellungen unter „Verbindung“ kann man ` + + `abgelaufene Zertifikate vorübergehend erlauben.`, + [{ text: 'OK' }, { text: 'Einstellungen', onPress: onOpenSettings }]); + } + }, [sec, onOpenSettings]); + + const warn = sec.kind === 'expired' || sec.kind === 'cert_error'; + const crossed = sec.kind === 'fallback' || sec.kind === 'plain'; + if (sec.kind === 'unknown') return ; + + let title = ''; + let text = ''; + if (sec.kind === 'secure') { + title = 'Verbindung verschlüsselt'; + text = 'Alles in Ordnung. Die Verbindung zu ARIA ist verschlüsselt und das Zertifikat des Servers ist gültig.'; + } else if (sec.kind === 'expired') { + title = 'Zertifikat abgelaufen'; + text = `Die Verbindung ist verschlüsselt, aber das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} ` + + 'abgelaufen. Die App akzeptiert es, weil das in den Einstellungen so erlaubt ist. ' + + 'Geprüft wird trotzdem, dass es wirklich das Zertifikat dieses Servers ist.\n\n' + + 'Das Zertifikat sollte erneuert werden.'; + } else if (sec.kind === 'cert_error') { + title = 'Keine Verbindung — Zertifikat-Problem'; + text = sec.certStatus === 'expired' + ? `Das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Deshalb verbindet sich die App nicht.\n\n` + + 'In den Einstellungen unter „Verbindung“ kann man abgelaufene Zertifikate vorübergehend erlauben.' + : 'Das Zertifikat des Servers ist ungültig oder gehört nicht zu diesem Server. Zur Sicherheit verbindet sich ' + + 'die App nicht.' + (sec.reason ? `\n\nGrund: ${sec.reason}` : ''); + } else if (sec.kind === 'fallback') { + title = 'Nicht verschlüsselt (Notlösung)'; + text = 'Die verschlüsselte Verbindung hat nicht geklappt. Die App ist deshalb ersatzweise UNVERSCHLÜSSELT ' + + 'verbunden (TLS-Fallback).' + + (sec.certStatus === 'expired' ? `\n\nGrund: Das Zertifikat ist seit ${fmtDate(sec.notAfter)} abgelaufen.` + : sec.certStatus === 'invalid' ? '\n\nGrund: Das Zertifikat des Servers ist ungültig.' + : '\n\nGrund: Die verschlüsselte Verbindung kam nicht zustande.'); + } else if (sec.kind === 'plain') { + title = 'Nicht verschlüsselt'; + text = 'Für diese Verbindung ist keine Verschlüsselung eingestellt. Das ist nur im eigenen Heimnetz in Ordnung.'; + } + + return ( + <> + setOpen(true)} accessibilityLabel={title}> + + {'🔒'} + {crossed && } + {warn && !} + + + + setOpen(false)}> + setOpen(false)}> + + {title} + {text} + + + {'🔒'} verschlüsselt, alles in Ordnung + {'🔒'}! Problem mit dem Zertifikat + {'🔒'}/ nicht verschlüsselt + + + + {(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && ( + { setOpen(false); onOpenSettings(); }}> + Einstellungen + + )} + setOpen(false)}> + OK + + + + + + + ); +}; + +const styles = StyleSheet.create({ + slot: { width: 52, alignItems: 'center', justifyContent: 'center' }, + lock: { fontSize: 20 }, + lockDim: { opacity: 0.7 }, + strike: { + position: 'absolute', left: -3, right: -3, top: '50%', height: 2.5, + backgroundColor: '#FF3B30', transform: [{ rotate: '-45deg' }], + }, + bang: { + position: 'absolute', right: -8, top: -6, minWidth: 15, height: 15, borderRadius: 8, + backgroundColor: '#FF9F0A', color: '#000', fontSize: 11, fontWeight: '900', + textAlign: 'center', lineHeight: 15, overflow: 'hidden', + }, + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 }, + box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 }, + title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginBottom: 10 }, + text: { color: '#C8C8D8', fontSize: 14, lineHeight: 20 }, + legend: { marginTop: 16, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#2A2A3E' }, + legendRow: { color: '#9090A8', fontSize: 13, marginVertical: 2 }, + legendBang: { color: '#FF9F0A', fontWeight: '900' }, + legendStrike: { color: '#FF3B30', fontWeight: '900' }, + buttons: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16, gap: 10 }, + btn: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#2A2A3E' }, + btnPrimary: { backgroundColor: '#0096FF' }, + btnText: { color: '#FFFFFF', fontWeight: '600' }, +}); + +export default SecurityLock; diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index dc1a01c..5eee6b5 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -178,6 +178,14 @@ const SettingsScreen: React.FC = () => { const winDims = useWindowDimensions(); const [connectionState, setConnectionState] = useState('disconnected'); const [manualToken, setManualToken] = useState(''); + // Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft) + const [allowExpiredCert, setAllowExpiredCert] = useState(false); + useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []); + const handleAllowExpiredCert = useCallback(async (value: boolean) => { + setAllowExpiredCert(value); + await rvs.setAllowExpiredCert(value); + rvs.connect(true); // gleich mit der neuen Regel neu verbinden + }, []); const [manualHost, setManualHost] = useState(''); const [manualPort, setManualPort] = useState('8765'); const [currentMode, setCurrentMode] = useState('normal'); @@ -1450,6 +1458,24 @@ const SettingsScreen: React.FC = () => { Verbinden + + + + Abgelaufenes Zertifikat akzeptieren + + Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt + verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses + Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das + Zertifikat erneuert ist. + + + + {/* === Verbindungslog === */} diff --git a/android/src/services/rvs.ts b/android/src/services/rvs.ts index 04f2728..9b188c6 100644 --- a/android/src/services/rvs.ts +++ b/android/src/services/rvs.ts @@ -7,6 +7,9 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { NativeModules } from 'react-native'; + +const TlsTrust: any = NativeModules.TlsTrust; // --- Typen --- @@ -27,6 +30,29 @@ export interface ConnectionConfig { useTLS: boolean; } +/** + * Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste. + * secure — verschluesselt, Zertifikat in Ordnung + * expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt) + * cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde + * fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug + * plain — verbunden ohne Verschluesselung, so konfiguriert + * unknown — (noch) nichts Genaues + */ +export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown'; + +export interface SecurityState { + kind: SecurityKind; + /** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */ + certStatus: string; + /** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */ + notAfter: number; + /** Bei fallback/cert_error: warum TLS nicht ging */ + reason: string; +} + +type SecurityCallback = (state: SecurityState) => void; + type MessageCallback = (message: RVSMessage) => void; type StateCallback = (state: ConnectionState) => void; @@ -64,6 +90,10 @@ class RVSConnection { private logListeners: LogCallback[] = []; private connectionLog: ConnectionLogEntry[] = []; private usingTLSFallback: boolean = false; + private securityListeners: SecurityCallback[] = []; + private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' }; + /** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */ + private certFailure: { status: string; notAfter: number; detail: string } | null = null; // --- Konfiguration --- @@ -216,6 +246,7 @@ class RVSConnection { const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : ''; this.log('info', `Verbunden${tlsInfo}`); this.setState('connected'); + this.updateSecurityOnOpen(useTLS); this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.startHeartbeat(); }; @@ -239,6 +270,10 @@ class RVSConnection { this.clearTimers(); this.ws = null; this.setState('disconnected'); + this.setSecurity(this.certFailure + ? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter, + reason: this.certFailure.detail } + : { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' }); // Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary // (wss://) versuchen statt fuer immer auf ws:// zu kleben. War @@ -258,6 +293,7 @@ class RVSConnection { this.ws.onerror = (error) => { const errorMsg = (error as any)?.message || 'Unbekannter Fehler'; this.log('error', `WebSocket-Fehler: ${errorMsg}`); + if (useTLS) this.checkCertFailure(errorMsg); // TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln if (this.config?.useTLS && !this.usingTLSFallback) { @@ -287,6 +323,83 @@ class RVSConnection { } } + // --- Verbindungs-Sicherheit (Schloss-Symbol) --- + + getSecurity(): SecurityState { + return this.security; + } + + onSecurityChange(callback: SecurityCallback): () => void { + this.securityListeners.push(callback); + return () => { + this.securityListeners = this.securityListeners.filter(cb => cb !== callback); + }; + } + + private setSecurity(next: SecurityState): void { + const cur = this.security; + if (cur.kind === next.kind && cur.certStatus === next.certStatus + && cur.notAfter === next.notAfter && cur.reason === next.reason) return; + this.security = next; + this.securityListeners.forEach(cb => cb(next)); + } + + private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> { + try { + const r = await TlsTrust?.getStatus(); + return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' }; + } catch (_) { + return { status: 'unknown', notAfter: 0, detail: '' }; + } + } + + private async updateSecurityOnOpen(viaTLS: boolean): Promise { + if (viaTLS) { + this.certFailure = null; + const c = await this.nativeCertStatus(); + if (c.status === 'expired_accepted') { + this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`); + } + this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure', + certStatus: c.status, notAfter: c.notAfter, reason: '' }); + } else if (this.config?.useTLS) { + // wss:// ging nicht, ws:// schon → unverschluesselt per Fallback + this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown', + notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' }); + } else { + this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' }); + } + } + + /** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */ + private async checkCertFailure(errorMsg: string): Promise { + if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return; + const c = await this.nativeCertStatus(); + if (c.status !== 'expired' && c.status !== 'invalid') return; + this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg }; + this.log('error', c.status === 'expired' + ? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}` + : 'Server-Zertifikat ungueltig'); + if (this.state !== 'connected') { + this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter, + reason: this.certFailure.detail }); + } else if (this.security.kind === 'fallback') { + // ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen + this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter, + reason: this.certFailure.detail }); + } + } + + /** Schalter "Abgelaufenes Zertifikat akzeptieren" */ + async getAllowExpiredCert(): Promise { + try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; } + } + + async setAllowExpiredCert(value: boolean): Promise { + try { await TlsTrust?.setAllowExpired(value); } catch (_) {} + this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`); + } + /** Reconnect mit exponentiellem Backoff planen */ private scheduleReconnect(): void { this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`);