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
+47
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import Security
@@ -26,6 +27,7 @@ enum Ablage {
static let serverAdresse = "certain.serverAdresse"
static let marke = "certain.marke"
static let benutzername = "certain.benutzername"
static let pin = "certain.pin"
}
// MARK: - Server-Adresse
@@ -71,9 +73,54 @@ enum Ablage {
}
}
// MARK: - Bildschirmsperre
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
///
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
static func setzePIN(_ pin: String) {
var salz = Data(count: 16)
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
}
static func stimmtPIN(_ pin: String) -> Bool {
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
let teile = abgelegt.split(separator: ":", maxSplits: 1)
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
return hex(verhashe(pin, salz: salz)) == String(teile[1])
}
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
private static func verhashe(_ pin: String, salz: Data) -> Data {
Data(SHA256.hash(data: salz + Data(pin.utf8)))
}
private static func hex(_ daten: Data) -> String {
daten.map { String(format: "%02x", $0) }.joined()
}
private static func daten(hex text: String) -> Data? {
guard text.count % 2 == 0 else { return nil }
var roh = Data(); var rest = Substring(text)
while !rest.isEmpty {
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
roh.append(wert); rest = rest.dropFirst(2)
}
return roh
}
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
static func raeumeAuf() {
marke = nil
loeschePIN()
benutzername = nil
serverAdresse = nil
Zwischenspeicher.leere()