feat: Bildschirmsperre mit Face ID und PIN

Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach
sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer
Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN
als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen
PINs meldet die App ab.

Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden
die Kontostaende im App-Umschalter.

Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die
Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die
Oberflaeche, sie verschluesselt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-24 18:09:05 +02:00
co-authored by Claude Opus 5
parent 4e7f572d93
commit 09ec2dc5fd
10 changed files with 402 additions and 5 deletions
+64
View File
@@ -43,6 +43,22 @@ final class AppZustand {
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
private(set) var beschaeftigt = false
// MARK: - Bildschirmsperre
/// Liegt der Sperrbildschirm über der App?
private(set) var gesperrt = false
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
private(set) var hatPIN = Ablage.hatPIN
private var seitWannWeg: Date?
private var fehlversuche = 0
/// So lange darf man weg sein, ohne erneut zu entsperren.
private static let gnadenfrist: TimeInterval = 60
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
private static let maxFehlversuche = 5
var serverAdresse: URL? { Ablage.serverAdresse }
// MARK: - Start
@@ -54,6 +70,9 @@ final class AppZustand {
}
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
client = neuer
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
hatPIN = Ablage.hatPIN
gesperrt = hatPIN
guard Ablage.marke != nil else {
lage = .brauchtAnmeldung
return
@@ -237,6 +256,9 @@ final class AppZustand {
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
Ablage.raeumeAuf()
Ablage.serverAdresse = adresse
hatPIN = false
gesperrt = false
fehlversuche = 0
if let adresse {
client = CertainClient(basis: adresse)
lage = .brauchtAnmeldung
@@ -246,6 +268,48 @@ final class AppZustand {
}
}
// MARK: - Sperren und entsperren
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
/// stünden die Kontostände im Umschalter.
func gehtWeg() {
guard hatPIN, !gesperrt else { return }
seitWannWeg = Date()
gesperrt = true
}
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
func kommtZurueck() {
guard let weg = seitWannWeg else { return }
seitWannWeg = nil
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
}
func entsperrt() {
gesperrt = false
fehlversuche = 0
}
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
/// beim letzten meldet die App ab.
func falschePIN() async -> Bool {
fehlversuche += 1
guard fehlversuche >= Self.maxFehlversuche else { return true }
await meldeAb()
return false
}
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
func setzePIN(_ pin: String) {
Ablage.setzePIN(pin)
hatPIN = true
gesperrt = false
fehlversuche = 0
}
func aktualisiereEinstellungen() async {
guard let client else { return }
if let frisch = try? await client.einstellungen() { einstellungen = frisch }