feat(app): Schloss-Symbol fuer die Verbindung + abgelaufenes Zertifikat erlaubbar
Neben dem Zahnrad zeigt ein Schloss, wie sicher die Verbindung zu ARIA ist: Schloss = verschluesselt und in Ordnung, Schloss mit ! = Zertifikat-Problem (abgelaufen oder abgelehnt), durchgestrichen = unverschluesselt (TLS-Fallback oder so eingestellt). Antippen erklaert es in einfachen Worten; einmal pro App-Start kommt ein Hinweis, wenn das Zertifikat abgelaufen ist. Einstellungen → Verbindung: "Abgelaufenes Zertifikat akzeptieren". Toleriert GENAU das Ablaufdatum: die Kette wird zum Zeitpunkt kurz vor dem Ablauf gegen die System-CAs geprueft, der Hostname weiter von OkHttp. Selbstsignierte oder fremde Zertifikate kommen auch mit Schalter nicht durch (gegen das echte, abgelaufene RVS-Zertifikat und badssl.com getestet). Gilt fuer WebSocket und fetch (z.B. App-Update vom RVS). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Props> = ({ onOpenSettings }) => {
|
||||
const [sec, setSec] = useState<SecurityState>(rvs.getSecurity());
|
||||
const [open, setOpen] = useState(false);
|
||||
const alerted = useRef<Set<string>>(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 <View style={styles.slot} />;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TouchableOpacity style={styles.slot} onPress={() => setOpen(true)} accessibilityLabel={title}>
|
||||
<View>
|
||||
<Text style={[styles.lock, crossed && styles.lockDim]}>{'🔒'}</Text>
|
||||
{crossed && <View style={styles.strike} />}
|
||||
{warn && <Text style={styles.bang}>!</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
||||
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setOpen(false)}>
|
||||
<View style={styles.box}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.text}>{text}</Text>
|
||||
|
||||
<View style={styles.legend}>
|
||||
<Text style={styles.legendRow}>{'🔒'} verschlüsselt, alles in Ordnung</Text>
|
||||
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendBang}>!</Text> Problem mit dem Zertifikat</Text>
|
||||
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendStrike}>/</Text> nicht verschlüsselt</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
|
||||
<TouchableOpacity style={styles.btn} onPress={() => { setOpen(false); onOpenSettings(); }}>
|
||||
<Text style={styles.btnText}>Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={[styles.btn, styles.btnPrimary]} onPress={() => setOpen(false)}>
|
||||
<Text style={styles.btnText}>OK</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user